From fa64b694e1d76014d46364d6f6f9f629557a2b67 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sat, 8 Nov 2025 03:55:54 +0100 Subject: [PATCH 1/5] implement platform info --- eslint.config.js | 2 + src/platform.ts | 126 +++++++++++++++++++++++++++ src/types.ts | 65 ++++++++++++++ test/platform-metrics.test.ts | 9 ++ test/platform-normalize-arch.test.ts | 36 ++++++++ test/platform-normalize-os.test.ts | 37 ++++++++ 6 files changed, 275 insertions(+) create mode 100644 src/platform.ts create mode 100644 test/platform-metrics.test.ts create mode 100644 test/platform-normalize-arch.test.ts create mode 100644 test/platform-normalize-os.test.ts diff --git a/eslint.config.js b/eslint.config.js index 74be4618..365a86c6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,8 @@ export default defineConfig([ autoFix: true, cspell: { words: [ + 'loong', + 'riscv', 'evanwashere', 'fastly', 'IsHTMLDDA', diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 00000000..1b65ef98 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,126 @@ +import { GetPlatformMetricsOptions, Machine, OS, PlatformMetrics } from './types.js' +import { runtime as jsRuntime, type JSRuntime } from './utils.js' + +const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThis) => { + return ['bun', 'deno', 'node'].includes(jsRuntime) + ? await import('node:os') + : { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + cpus: typeof g.navigator?.hardwareConcurrency === 'number' + ? () => { + return Array + .from({ length: (g.navigator as unknown as { hardwareConcurrency: number }).hardwareConcurrency }) + .fill({ + model: 'unknown', + speed: -1, + }) + } + : () => ([]), + freemem: () => -1, + getPriority: () => -1, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + machine: typeof g.navigator?.platform === 'string' + ? () => normalizeMachine(g.navigator.platform.split(' ')[1]) + : () => 'unknown', + platform: () => normalizeOSType(g.navigator.platform.split(' ')[0]), + release: () => 'unknown', + totalmem: typeof g.navigator.hardwareConcurrency === 'number' + ? () => g.navigator.hardwareConcurrency + : () => -1, + } +} + +/* eslint-disable */ +const machineLookup: { [key: string]: Machine } = { + // @ts-ignore __proto__ makes the object null-prototyped and sets it in dictionary mode + __proto__: null, + ia32: "x32", + amd64: "x64", + x86_64: "x64", +} +/* eslint-enable */ + +/** + * @param machine - a value to normalize + * @returns normalized architecture + */ +export function normalizeMachine (machine?: unknown): Machine { + return typeof machine !== 'string' || machine.length === 0 + ? 'unknown' + : ((machine = machine.toLowerCase()) && (machineLookup[machine as Machine] ?? machine)) as Machine +} + +const osLookup: Record, OS> = { + // @ts-expect-error __proto__ makes the object null-prototyped and sets it in dictionary mode + __proto__: null, + windows: 'win32', +} + +let cachedPlatformMetrics: null | PlatformMetrics = null + +/** + * @param opts - Options object + * @returns platform metrics + */ +export async function getPlatformMetrics (opts: GetPlatformMetricsOptions = {}): Promise { + const { + g = globalThis, + runtime = jsRuntime, + useCache = true + } = opts + if (useCache && cachedPlatformMetrics !== null) { + return cachedPlatformMetrics + } + const userAgent = (g as unknown as { navigator?: { userAgent: string } }).navigator?.userAgent ?? '' + + let cpuCores = -1 + let cpuModel = 'unknown' + let cpuSpeed = -1 + let osKernel = 'unknown' + let osType: OS = 'unknown' + let cpuMachine: Machine = 'unknown' + let priority: null | number = -1 + let memoryTotal = -1 + let memoryFree = -1 + + const nodeOs = await loadNodeOS(runtime, g) + + try { + osType = normalizeOSType(nodeOs.platform()) + cpuMachine = normalizeMachine(nodeOs.machine()) + osKernel = nodeOs.release() + memoryTotal = nodeOs.totalmem() + memoryFree = nodeOs.freemem() + priority = nodeOs.getPriority() + + cpuCores = nodeOs.cpus().length + if (cpuCores > 0) { + cpuModel = (nodeOs as unknown as { cpus: () => [{ model: string }, ...{ model: string }[]] }).cpus()[0].model + cpuSpeed = (nodeOs as unknown as { cpus: () => [{ speed: number }, ...{ speed: number }[]] }).cpus()[0].speed + } + } catch { /* ignore */ } + + return (cachedPlatformMetrics = { + cpuCores, + cpuMachine, + cpuModel, + cpuSpeed, + memoryFree, + memoryTotal, + osKernel, + osType, + priority, + runtime, + userAgent + }) +} + +/** + * @param os - a value to normalize + * @returns normalized OS + */ +export function normalizeOSType (os?: unknown): OS { + return typeof os !== 'string' || os.length === 0 + ? 'unknown' + : ((os = os.toLowerCase()) && (osLookup[os as OS] ?? os)) as OS +} diff --git a/src/types.ts b/src/types.ts index 74151303..64313798 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,6 +188,12 @@ export interface FnReturnedObject { overriddenDuration?: number } +export interface GetPlatformMetricsOptions { + g?: typeof globalThis; + runtime?: JSRuntime; + useCache?: boolean; +} + /** * The hook function signature. * If warmup is enabled, the hook will be called twice, once for the warmup and once for the run. @@ -199,6 +205,34 @@ export type Hook = ( mode?: 'run' | 'warmup' ) => Promise | void +export type Machine = (Lowercase & Record) | ( + | 'arm64' + | 'arm' + | 'i686' + | 'ia32' + | 'loong64' + | 'mips64' + | 'mips' + | 'ppc64' + | 'riscv64' + | 's390x' + | 'x86_64') + +export type OS = (Lowercase & Record) | ( + | 'aix' + | 'android' + | 'cygwin' + | 'darwin' + | 'freebsd' + | 'haiku' + | 'linux' + | 'netbsd' + | 'openbsd' + | 'sunos' + | 'win32') + +export type PlatformMetrics = PlatformMetricsBase | PlatformMetricsBrowser | PlatformMetricsNodeLike + // @types/node doesn't have these types globally, and we don't want to bring "dom" lib for everyone export type RemoveEventListenerOptionsArgument = Parameters< typeof EventTarget.prototype.removeEventListener @@ -501,3 +535,34 @@ interface DeprecatedStatistics { */ variance: number } + +interface PlatformMetricsBase { + cpuMachine: Machine; + memoryFree: number; + memoryTotal: number; + osType: OS; + runtime: Omit; + userAgent: string; +} + +interface PlatformMetricsBrowser { + cpuMachine: Machine; + memoryFree: number; + memoryTotal: number; + osType: OS; + runtime: Extract; + userAgent: string; +} + +interface PlatformMetricsNodeLike { + cpuCores: number; + cpuMachine: Machine; + cpuModel: string; + cpuSpeed: number; + memoryFree: number; + memoryTotal: number; + osKernel: string; + osType: OS; + priority: null | number; + runtime: Extract +} diff --git a/test/platform-metrics.test.ts b/test/platform-metrics.test.ts new file mode 100644 index 00000000..8e2cea71 --- /dev/null +++ b/test/platform-metrics.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from 'vitest' + +import { getPlatformMetrics } from '../src/platform' + +test('platform metrics', async () => { + const metrics = await getPlatformMetrics({ useCache: false }) + expect(metrics).toHaveProperty('osType') + expect(metrics).toHaveProperty('cpuMachine') +}) diff --git a/test/platform-normalize-arch.test.ts b/test/platform-normalize-arch.test.ts new file mode 100644 index 00000000..21fb0c71 --- /dev/null +++ b/test/platform-normalize-arch.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest' + +import { normalizeMachine } from '../src/platform' + +test('normalizeArch with non string value returns unknown', () => { + expect(normalizeMachine(undefined)).toBe('unknown') + expect(normalizeMachine(123)).toBe('unknown') + expect(normalizeMachine(null)).toBe('unknown') + expect(normalizeMachine({})).toBe('unknown') + expect(normalizeMachine([])).toBe('unknown') +}) + +test('normalizeArch', () => { + expect(normalizeMachine('arm')).toBe('arm') + expect(normalizeMachine('arm64')).toBe('arm64') + expect(normalizeMachine('ia32')).toBe('x32') + expect(normalizeMachine('loong64')).toBe('loong64') + expect(normalizeMachine('mips')).toBe('mips') + expect(normalizeMachine('mipsel')).toBe('mipsel') + expect(normalizeMachine('ppc64')).toBe('ppc64') + expect(normalizeMachine('riscv64')).toBe('riscv64') + expect(normalizeMachine('s390x')).toBe('s390x') + expect(normalizeMachine('x64')).toBe('x64') +}) + +test('normalizeArch with alternative values', () => { + expect(normalizeMachine('ia32')).toBe('x32') + expect(normalizeMachine('amd64')).toBe('x64') + expect(normalizeMachine('x86')).toBe('x86') + expect(normalizeMachine('x86_64')).toBe('x64') +}) + +test('normalizeArch returns lowercase', () => { + expect(normalizeMachine('ARM')).toBe('arm') + expect(normalizeMachine('AARCH64')).toBe('aarch64') +}) diff --git a/test/platform-normalize-os.test.ts b/test/platform-normalize-os.test.ts new file mode 100644 index 00000000..588cd1fa --- /dev/null +++ b/test/platform-normalize-os.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from 'vitest' + +import { normalizeOSType } from '../src/platform' + +test('normalizeOS with non string value returns unknown', () => { + expect(normalizeOSType(undefined)).toBe('unknown') + expect(normalizeOSType(123)).toBe('unknown') + expect(normalizeOSType(null)).toBe('unknown') + expect(normalizeOSType({})).toBe('unknown') + expect(normalizeOSType([])).toBe('unknown') +}) + +test('normalizeOS defaults provided by node', () => { + expect(normalizeOSType('aix')).toBe('aix') + expect(normalizeOSType('android')).toBe('android') + expect(normalizeOSType('darwin')).toBe('darwin') + expect(normalizeOSType('freebsd')).toBe('freebsd') + expect(normalizeOSType('haiku')).toBe('haiku') + expect(normalizeOSType('linux')).toBe('linux') + expect(normalizeOSType('openbsd')).toBe('openbsd') + expect(normalizeOSType('sunos')).toBe('sunos') + expect(normalizeOSType('win32')).toBe('win32') + expect(normalizeOSType('cygwin')).toBe('cygwin') + expect(normalizeOSType('netbsd')).toBe('netbsd') +}) + +test('normalizeOS returns lowercase', () => { + expect(normalizeOSType('Linux')).toBe('linux') + expect(normalizeOSType('SunOS')).toBe('sunos') +}) + +test('normalizeOS with alternative Windows values', () => { + expect(normalizeOSType('Windows')).toBe('win32') + expect(normalizeOSType('Win16')).toBe('win16') + expect(normalizeOSType('Win32')).toBe('win32') + expect(normalizeOSType('WinCE')).toBe('wince') +}) From 4373c47f5bb5f82c00d94bf6e87ffb775f3db186 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sat, 8 Nov 2025 04:08:43 +0100 Subject: [PATCH 2/5] fix --- src/platform.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform.ts b/src/platform.ts index 1b65ef98..ac4295ee 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -24,8 +24,8 @@ const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThi : () => 'unknown', platform: () => normalizeOSType(g.navigator.platform.split(' ')[0]), release: () => 'unknown', - totalmem: typeof g.navigator.hardwareConcurrency === 'number' - ? () => g.navigator.hardwareConcurrency + totalmem: typeof (g as unknown as { navigator?: { deviceMemory: number } }).navigator?.deviceMemory === 'number' + ? () => (g as unknown as { navigator: { deviceMemory: number } }).navigator.deviceMemory * 2 ** 30 : () => -1, } } From d9403c178d4751a81dbec95a2206d4c3912c99ab Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sat, 8 Nov 2025 04:11:58 +0100 Subject: [PATCH 3/5] fix --- src/platform.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/platform.ts b/src/platform.ts index ac4295ee..e1a7a33b 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -9,11 +9,10 @@ const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThi cpus: typeof g.navigator?.hardwareConcurrency === 'number' ? () => { return Array - .from({ length: (g.navigator as unknown as { hardwareConcurrency: number }).hardwareConcurrency }) - .fill({ - model: 'unknown', - speed: -1, - }) + .from( + { length: (g.navigator as unknown as { hardwareConcurrency: number }).hardwareConcurrency }, + () => ({ model: 'unknown', speed: -1 }) + ) } : () => ([]), freemem: () => -1, @@ -22,7 +21,10 @@ const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThi machine: typeof g.navigator?.platform === 'string' ? () => normalizeMachine(g.navigator.platform.split(' ')[1]) : () => 'unknown', - platform: () => normalizeOSType(g.navigator.platform.split(' ')[0]), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + platform: () => typeof g.navigator?.platform === 'string' + ? () => normalizeMachine(g.navigator.platform.split(' ')[0]) + : () => 'unknown', release: () => 'unknown', totalmem: typeof (g as unknown as { navigator?: { deviceMemory: number } }).navigator?.deviceMemory === 'number' ? () => (g as unknown as { navigator: { deviceMemory: number } }).navigator.deviceMemory * 2 ** 30 From 9ace79d160f0ec1cdf025eac74e0957fdcba65e4 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sat, 8 Nov 2025 04:34:00 +0100 Subject: [PATCH 4/5] fix --- src/platform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform.ts b/src/platform.ts index e1a7a33b..8b1cbc44 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -22,7 +22,7 @@ const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThi ? () => normalizeMachine(g.navigator.platform.split(' ')[1]) : () => 'unknown', // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - platform: () => typeof g.navigator?.platform === 'string' + platform: typeof g.navigator?.platform === 'string' ? () => normalizeMachine(g.navigator.platform.split(' ')[0]) : () => 'unknown', release: () => 'unknown', From 58d9927486457c88335c109d0fef40dc1aacb5bb Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Tue, 11 Nov 2025 10:42:27 +0100 Subject: [PATCH 5/5] fix lint --- src/platform.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/platform.ts b/src/platform.ts index 8b1cbc44..d9d2bdb1 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -17,13 +17,13 @@ const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThi : () => ([]), freemem: () => -1, getPriority: () => -1, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-deprecated machine: typeof g.navigator?.platform === 'string' - ? () => normalizeMachine(g.navigator.platform.split(' ')[1]) + ? () => normalizeMachine(g.navigator.platform.split(' ')[1]) // eslint-disable-line @typescript-eslint/no-deprecated : () => 'unknown', - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-deprecated platform: typeof g.navigator?.platform === 'string' - ? () => normalizeMachine(g.navigator.platform.split(' ')[0]) + ? () => normalizeMachine(g.navigator.platform.split(' ')[0]) // eslint-disable-line @typescript-eslint/no-deprecated : () => 'unknown', release: () => 'unknown', totalmem: typeof (g as unknown as { navigator?: { deviceMemory: number } }).navigator?.deviceMemory === 'number'