From fa64b694e1d76014d46364d6f6f9f629557a2b67 Mon Sep 17 00:00:00 2001 From: Aras Abbasi Date: Sat, 8 Nov 2025 03:55:54 +0100 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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' From af8d34ed0db9a11b61b220c0a71e22dc2bf37f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 22 Jul 2026 21:47:47 +0200 Subject: [PATCH 6/9] fix(platform): sound types, correct browser detection, safe cache - types: collapse PlatformMetrics to a single flat interface (drop the non-discriminable Omit-based union; runtime: JSRuntime) - machine: normalize to the process.arch vocabulary (drop phantom x32); align the Machine union to the closed process.arch set - browser: derive OS from a navigator.platform prefix map instead of routing it through the arch normalizer (MacIntel -> darwin, etc.) - cache: only memoize the default environment; custom g/runtime bypass it entirely (no stale reads, no poisoning on useCache:false) - priority defaults to null (unknown), not -1 (a valid nice value) - export getPlatformMetrics/normalizeMachine/normalizeOSType + types - misc: hoist os.cpus(), consistent @ts-expect-error, type-only import, statement-form normalizers, richer JSDoc, drop interface semicolons - tests: update arch expectations to process.arch; add browser + cache regression tests --- src/index.ts | 5 + src/platform.ts | 134 ++++++++++++++++++++------- src/types.ts | 56 ++++------- test/platform-metrics.test.ts | 35 +++++++ test/platform-normalize-arch.test.ts | 12 ++- 5 files changed, 164 insertions(+), 78 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6b1c135d..73188d23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { Bench } from './bench' +export { getPlatformMetrics, normalizeMachine, normalizeOSType } from './platform' export { Task } from './task' export type { BenchEvent, @@ -14,9 +15,13 @@ export type { FnHook, FnOptions, FnReturnedObject, + GetPlatformMetricsOptions, Hook, JSRuntime, + Machine, NowFn, + OS, + PlatformMetrics, ResolvedBenchOptions, Samples, SortedSamples, diff --git a/src/platform.ts b/src/platform.ts index 930c4733..d9ae39ef 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,4 +1,5 @@ -import { GetPlatformMetricsOptions, type JSRuntime, Machine, OS, PlatformMetrics } from './types.js' +import type { GetPlatformMetricsOptions, JSRuntime, Machine, OS, PlatformMetrics } from './types.js' + import { runtime as jsRuntime } from './utils.js' const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThis) => { @@ -16,14 +17,14 @@ const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThi } : () => ([]), freemem: () => -1, - getPriority: () => -1, + getPriority: (): null | number => null, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition machine: typeof g.navigator?.platform === 'string' - ? () => normalizeMachine(g.navigator.platform.split(' ')[1]) + ? () => g.navigator.platform.split(' ')[1] ?? 'unknown' : () => 'unknown', // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition platform: typeof g.navigator?.platform === 'string' - ? () => normalizeMachine(g.navigator.platform.split(' ')[0]) + ? () => browserOSType(g.navigator.platform) : () => 'unknown', release: () => 'unknown', totalmem: typeof (g as unknown as { navigator?: { deviceMemory: number } }).navigator?.deviceMemory === 'number' @@ -32,24 +33,32 @@ const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThi } } -/* eslint-disable */ -const machineLookup: { [key: string]: Machine } = { - // @ts-ignore __proto__ makes the object null-prototyped and sets it in dictionary mode +const machineLookup: Record, Machine> = { + // @ts-expect-error __proto__ makes the object null-prototyped and sets it in dictionary mode __proto__: null, - ia32: "x32", - amd64: "x64", - x86_64: "x64", + aarch64: 'arm64', + amd64: 'x64', + i386: 'ia32', + i686: 'ia32', + x86: 'ia32', + x86_64: 'x64', } -/* eslint-enable */ /** + * Normalizes a raw CPU architecture token (e.g. from `os.machine()` or a + * browser `navigator.platform`) to a canonical `process.arch`-style value. + * Known uname aliases are mapped (`x86_64`/`amd64` → `x64`, `aarch64` → `arm64`, + * `i686`/`i386`/`x86` → `ia32`); unknown tokens are lowercased and returned + * as-is, and non-string/empty input yields `'unknown'`. * @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 + if (typeof machine !== 'string' || machine.length === 0) { + return 'unknown' + } + const key = machine.toLowerCase() + return machineLookup[key as Lowercase] ?? (key as Machine) } const osLookup: Record, OS> = { @@ -58,11 +67,66 @@ const osLookup: Record, OS> = { windows: 'win32', } +/** + * Normalizes a raw OS token (e.g. from `os.platform()` or {@link browserOSType}) + * to a canonical value; `windows` is mapped to `win32`, unknown tokens are + * lowercased and returned as-is, and non-string/empty input yields `'unknown'`. + * @param os - a value to normalize + * @returns normalized OS + */ +export function normalizeOSType (os?: unknown): OS { + if (typeof os !== 'string' || os.length === 0) { + return 'unknown' + } + const key = os.toLowerCase() + return osLookup[key as Lowercase] ?? (key as OS) +} + +/** + * Maps a (deprecated) `navigator.platform` value to a canonical OS token. + * `navigator.platform` is the only synchronous, broadly-available signal; + * space-less values (e.g. `'MacIntel'`, `'Win32'`) encode the OS in a prefix, + * which this resolves. Apple platforms (Mac/iPhone/iPad/iPod) are Darwin-based. + * @param platform - the `navigator.platform` string + * @returns normalized OS + */ +function browserOSType (platform: string): OS { + const value = platform.toLowerCase() + if ( + value.startsWith('mac') || + value.startsWith('iphone') || + value.startsWith('ipad') || + value.startsWith('ipod') + ) { + return 'darwin' + } + if (value.startsWith('win')) { + return 'win32' + } + if (value.startsWith('android')) { + return 'android' + } + if (value.startsWith('linux')) { + return 'linux' + } + return normalizeOSType(platform) +} + let cachedPlatformMetrics: null | PlatformMetrics = null /** - * @param opts - Options object - * @returns platform metrics + * Collects a best-effort snapshot of the host platform (CPU, memory, OS, + * runtime). Node-like runtimes (`bun`/`deno`/`node`) use `node:os`; other + * runtimes fall back to `navigator` (`hardwareConcurrency`/`deviceMemory`/ + * `platform`). Unavailable fields use sentinels: `-1` for numbers, + * `'unknown'` for strings, and `null` for `priority`. + * + * The result for the default environment (`g === globalThis` and the + * auto-detected `runtime`) is memoized when `useCache` is `true`; pass + * `useCache: false` to force recomputation. Calls with a custom `g` or + * `runtime` bypass the cache entirely — they never read nor write it. + * @param opts - options; see {@link GetPlatformMetricsOptions} + * @returns the platform metrics */ export async function getPlatformMetrics (opts: GetPlatformMetricsOptions = {}): Promise { const { @@ -70,7 +134,8 @@ export async function getPlatformMetrics (opts: GetPlatformMetricsOptions = {}): runtime = jsRuntime, useCache = true } = opts - if (useCache && cachedPlatformMetrics !== null) { + const cacheable = useCache && g === globalThis && runtime === jsRuntime + if (cacheable && cachedPlatformMetrics !== null) { return cachedPlatformMetrics } const userAgent = (g as unknown as { navigator?: { userAgent: string } }).navigator?.userAgent ?? '' @@ -81,7 +146,7 @@ export async function getPlatformMetrics (opts: GetPlatformMetricsOptions = {}): let osKernel = 'unknown' let osType: OS = 'unknown' let cpuMachine: Machine = 'unknown' - let priority: null | number = -1 + let priority: null | number = null let memoryTotal = -1 let memoryFree = -1 @@ -95,14 +160,19 @@ export async function getPlatformMetrics (opts: GetPlatformMetricsOptions = {}): 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 + const cpus = nodeOs.cpus() + cpuCores = cpus.length + const firstCpu = cpus[0] + if (firstCpu) { + cpuModel = firstCpu.model + cpuSpeed = firstCpu.speed } - } catch { /* ignore */ } + } catch { + // Best-effort: node:os can throw in restricted sandboxes; unresolved + // fields keep their sentinel defaults (-1 / 'unknown' / null). + } - return (cachedPlatformMetrics = { + const metrics: PlatformMetrics = { cpuCores, cpuMachine, cpuModel, @@ -114,15 +184,9 @@ export async function getPlatformMetrics (opts: GetPlatformMetricsOptions = {}): 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 + } + if (cacheable) { + cachedPlatformMetrics = metrics + } + return metrics } diff --git a/src/types.ts b/src/types.ts index 21903c94..660e0b6e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -420,9 +420,9 @@ export interface FnReturnedObject { } export interface GetPlatformMetricsOptions { - g?: typeof globalThis; - runtime?: JSRuntime; - useCache?: boolean; + g?: typeof globalThis + runtime?: JSRuntime + useCache?: boolean } /** @@ -461,15 +461,14 @@ export type JSRuntime = export type Machine = ( | 'arm64' | 'arm' - | 'i686' | 'ia32' | 'loong64' - | 'mips64' | 'mips' + | 'mipsel' | 'ppc64' | 'riscv64' | 's390x' - | 'x86_64') | (Lowercase & Record) + | 'x64') | (Lowercase & Record) /** * A function that returns the current timestamp. @@ -489,7 +488,19 @@ export type OS = ( | 'sunos' | 'win32') | (Lowercase & Record) -export type PlatformMetrics = PlatformMetricsBase | PlatformMetricsBrowser | PlatformMetricsNodeLike +export interface PlatformMetrics { + cpuCores: number + cpuMachine: Machine + cpuModel: string + cpuSpeed: number + memoryFree: number + memoryTotal: number + osKernel: string + osType: OS + priority: null | number + runtime: JSRuntime + userAgent: string +} // @types/node doesn't have these types globally, and we don't want to bring "dom" lib for everyone export type RemoveEventListenerOptionsArgument = Parameters< @@ -837,34 +848,3 @@ export interface TimestampProvider { * function. */ export type TimestampValue = bigint | 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 index 8e2cea71..acd3a8d5 100644 --- a/test/platform-metrics.test.ts +++ b/test/platform-metrics.test.ts @@ -7,3 +7,38 @@ test('platform metrics', async () => { expect(metrics).toHaveProperty('osType') expect(metrics).toHaveProperty('cpuMachine') }) + +test('browser OS/arch detection from navigator.platform', async () => { + const g = { + navigator: { + hardwareConcurrency: 4, + platform: 'Linux x86_64', + userAgent: 'Mozilla/5.0 (X11; Linux x86_64)' + } + } as unknown as typeof globalThis + const metrics = await getPlatformMetrics({ g, runtime: 'browser', useCache: false }) + expect(metrics.runtime).toBe('browser') + expect(metrics.osType).toBe('linux') + expect(metrics.cpuMachine).toBe('x64') + expect(metrics.cpuCores).toBe(4) + expect(metrics.userAgent).toBe('Mozilla/5.0 (X11; Linux x86_64)') +}) + +test('browser macOS platform maps to darwin', async () => { + const g = { + navigator: { hardwareConcurrency: 8, platform: 'MacIntel', userAgent: 'mac-ua' } + } as unknown as typeof globalThis + const metrics = await getPlatformMetrics({ g, runtime: 'browser', useCache: false }) + expect(metrics.osType).toBe('darwin') + expect(metrics.cpuMachine).toBe('unknown') +}) + +test('custom g/runtime never poison the default cache', async () => { + const g = { + navigator: { hardwareConcurrency: 2, platform: 'Win32', userAgent: 'fake' } + } as unknown as typeof globalThis + const fake = await getPlatformMetrics({ g, runtime: 'browser', useCache: false }) + expect(fake.runtime).toBe('browser') + const real = await getPlatformMetrics() + expect(real.runtime).not.toBe('browser') +}) diff --git a/test/platform-normalize-arch.test.ts b/test/platform-normalize-arch.test.ts index 21fb0c71..8317d10a 100644 --- a/test/platform-normalize-arch.test.ts +++ b/test/platform-normalize-arch.test.ts @@ -13,7 +13,7 @@ test('normalizeArch with non string value returns unknown', () => { test('normalizeArch', () => { expect(normalizeMachine('arm')).toBe('arm') expect(normalizeMachine('arm64')).toBe('arm64') - expect(normalizeMachine('ia32')).toBe('x32') + expect(normalizeMachine('ia32')).toBe('ia32') expect(normalizeMachine('loong64')).toBe('loong64') expect(normalizeMachine('mips')).toBe('mips') expect(normalizeMachine('mipsel')).toBe('mipsel') @@ -23,14 +23,16 @@ test('normalizeArch', () => { expect(normalizeMachine('x64')).toBe('x64') }) -test('normalizeArch with alternative values', () => { - expect(normalizeMachine('ia32')).toBe('x32') +test('normalizeArch canonicalizes uname aliases to process.arch', () => { + expect(normalizeMachine('aarch64')).toBe('arm64') expect(normalizeMachine('amd64')).toBe('x64') - expect(normalizeMachine('x86')).toBe('x86') + expect(normalizeMachine('i386')).toBe('ia32') + expect(normalizeMachine('i686')).toBe('ia32') + expect(normalizeMachine('x86')).toBe('ia32') expect(normalizeMachine('x86_64')).toBe('x64') }) test('normalizeArch returns lowercase', () => { expect(normalizeMachine('ARM')).toBe('arm') - expect(normalizeMachine('AARCH64')).toBe('aarch64') + expect(normalizeMachine('AARCH64')).toBe('arm64') }) From 51d9396fdf5688eaa7ef5937fe9c74d11e0d83f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 22 Jul 2026 22:17:16 +0200 Subject: [PATCH 7/9] fix(platform): detect Android via UA, normalize armv* to arm - browserOSType now takes userAgent: real Android reports navigator.platform 'Linux armv8l', not 'Android', so detect it from the user agent before the linux prefix (fixes the dead android branch) - machineLookup: map armv6l/armv7l/armv8l -> 'arm' so 32-bit ARM tokens stay in the process.arch vocabulary instead of leaking via the escape hatch - add 'armv' to the cspell word list - tests: Android-via-UA detection, desktop-linux stays linux, armv* arch --- eslint.config.js | 1 + src/platform.ts | 12 +++++++++--- test/platform-metrics.test.ts | 26 ++++++++++++++++++++++++++ test/platform-normalize-arch.test.ts | 3 +++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 3fdcb21d..a4cfcf36 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,7 @@ export default defineConfig([ autoFix: true, cspell: { words: [ + 'armv', 'codegen', 'evanwashere', 'fastly', diff --git a/src/platform.ts b/src/platform.ts index d9ae39ef..dc154c4c 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -24,7 +24,7 @@ const loadNodeOS = async (jsRuntime: JSRuntime, g: typeof globalThis = globalThi : () => 'unknown', // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition platform: typeof g.navigator?.platform === 'string' - ? () => browserOSType(g.navigator.platform) + ? () => browserOSType(g.navigator.platform, g.navigator.userAgent) : () => 'unknown', release: () => 'unknown', totalmem: typeof (g as unknown as { navigator?: { deviceMemory: number } }).navigator?.deviceMemory === 'number' @@ -38,6 +38,9 @@ const machineLookup: Record, Machine> = { __proto__: null, aarch64: 'arm64', amd64: 'x64', + armv6l: 'arm', + armv7l: 'arm', + armv8l: 'arm', i386: 'ia32', i686: 'ia32', x86: 'ia32', @@ -87,10 +90,13 @@ export function normalizeOSType (os?: unknown): OS { * `navigator.platform` is the only synchronous, broadly-available signal; * space-less values (e.g. `'MacIntel'`, `'Win32'`) encode the OS in a prefix, * which this resolves. Apple platforms (Mac/iPhone/iPad/iPod) are Darwin-based. + * Android is detected from the user agent, since its `navigator.platform` + * reports the Linux kernel (e.g. `'Linux armv8l'`) rather than `'Android'`. * @param platform - the `navigator.platform` string + * @param userAgent - the `navigator.userAgent` string * @returns normalized OS */ -function browserOSType (platform: string): OS { +function browserOSType (platform: string, userAgent = ''): OS { const value = platform.toLowerCase() if ( value.startsWith('mac') || @@ -103,7 +109,7 @@ function browserOSType (platform: string): OS { if (value.startsWith('win')) { return 'win32' } - if (value.startsWith('android')) { + if (/android/i.test(userAgent) || value.startsWith('android')) { return 'android' } if (value.startsWith('linux')) { diff --git a/test/platform-metrics.test.ts b/test/platform-metrics.test.ts index acd3a8d5..31c29d8c 100644 --- a/test/platform-metrics.test.ts +++ b/test/platform-metrics.test.ts @@ -33,6 +33,32 @@ test('browser macOS platform maps to darwin', async () => { expect(metrics.cpuMachine).toBe('unknown') }) +test('browser Android is detected from the user agent, not navigator.platform', async () => { + const g = { + navigator: { + hardwareConcurrency: 8, + platform: 'Linux armv8l', + userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8)' + } + } as unknown as typeof globalThis + const metrics = await getPlatformMetrics({ g, runtime: 'browser', useCache: false }) + expect(metrics.osType).toBe('android') + expect(metrics.cpuMachine).toBe('arm') +}) + +test('browser desktop Linux stays linux', async () => { + const g = { + navigator: { + hardwareConcurrency: 8, + platform: 'Linux armv8l', + userAgent: 'Mozilla/5.0 (X11; Linux)' + } + } as unknown as typeof globalThis + const metrics = await getPlatformMetrics({ g, runtime: 'browser', useCache: false }) + expect(metrics.osType).toBe('linux') + expect(metrics.cpuMachine).toBe('arm') +}) + test('custom g/runtime never poison the default cache', async () => { const g = { navigator: { hardwareConcurrency: 2, platform: 'Win32', userAgent: 'fake' } diff --git a/test/platform-normalize-arch.test.ts b/test/platform-normalize-arch.test.ts index 8317d10a..d1dc7013 100644 --- a/test/platform-normalize-arch.test.ts +++ b/test/platform-normalize-arch.test.ts @@ -26,6 +26,9 @@ test('normalizeArch', () => { test('normalizeArch canonicalizes uname aliases to process.arch', () => { expect(normalizeMachine('aarch64')).toBe('arm64') expect(normalizeMachine('amd64')).toBe('x64') + expect(normalizeMachine('armv6l')).toBe('arm') + expect(normalizeMachine('armv7l')).toBe('arm') + expect(normalizeMachine('armv8l')).toBe('arm') expect(normalizeMachine('i386')).toBe('ia32') expect(normalizeMachine('i686')).toBe('ia32') expect(normalizeMachine('x86')).toBe('ia32') From c3e146298d20d2068c6f1de45a1516e12a0bdc87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 24 Jul 2026 14:19:29 +0200 Subject: [PATCH 8/9] chore(size-limit): mark node:os as external size-limit bundles dist/index.js with esbuild (browser target), which cannot resolve the dynamic `import('node:os')` used by the platform feature. Declaring it as ignored lets size-limit resolve and measure the bundle instead of failing to build. --- .size-limit.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.size-limit.json b/.size-limit.json index c689200b..e2fb0802 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1,6 +1,7 @@ [ { "path": "dist/index.js", - "limit": "12 kB" + "limit": "12 kB", + "ignore": ["node:os"] } ] From 9df73b88c72efaabcd0a269d1e1b9ef392f6e33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 24 Jul 2026 14:47:20 +0200 Subject: [PATCH 9/9] docs(platform): drop @link to internal browserOSType typedoc --treatWarningsAsErrors fails because normalizeOSType's public doc comment linked to browserOSType, an internal (non-exported) helper. Replace the {@link} with plain text so a public symbol no longer links to a private one. --- src/platform.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform.ts b/src/platform.ts index dc154c4c..b6393f20 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -71,8 +71,8 @@ const osLookup: Record, OS> = { } /** - * Normalizes a raw OS token (e.g. from `os.platform()` or {@link browserOSType}) - * to a canonical value; `windows` is mapped to `win32`, unknown tokens are + * Normalizes a raw OS token (e.g. from `os.platform()` or the browser + * platform fallback) to a canonical value; `windows` is mapped to `win32`, unknown tokens are * lowercased and returned as-is, and non-string/empty input yields `'unknown'`. * @param os - a value to normalize * @returns normalized OS