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"] } ] diff --git a/eslint.config.js b/eslint.config.js index 289f5d4e..65b3eb02 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,16 +18,19 @@ export default defineConfig([ autoFix: true, cspell: { words: [ + 'armv', 'codegen', 'evanwashere', 'fastly', 'IsHTMLDDA', 'lagon', 'lockdown', + 'loong', 'moddable', 'neostandard', 'quickjs', 'Quii', + 'riscv', 'spidermonkey', 'workerd', ], diff --git a/src/index.ts b/src/index.ts index 36a04cd6..ba65ea16 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,10 +15,14 @@ export type { FnHook, FnOptions, FnReturnedObject, + GetPlatformMetricsOptions, Hook, HookMode, JSRuntime, + Machine, NowFn, + OS, + PlatformMetrics, ResolvedBenchOptions, Samples, SortedSamples, diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 00000000..b6393f20 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,198 @@ +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) => { + 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 }, + () => ({ model: 'unknown', speed: -1 }) + ) + } + : () => ([]), + freemem: () => -1, + getPriority: (): null | number => null, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + machine: typeof g.navigator?.platform === 'string' + ? () => g.navigator.platform.split(' ')[1] ?? 'unknown' + : () => 'unknown', + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + platform: typeof g.navigator?.platform === 'string' + ? () => browserOSType(g.navigator.platform, g.navigator.userAgent) + : () => '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 + : () => -1, + } +} + +const machineLookup: Record, Machine> = { + // @ts-expect-error __proto__ makes the object null-prototyped and sets it in dictionary mode + __proto__: null, + aarch64: 'arm64', + amd64: 'x64', + armv6l: 'arm', + armv7l: 'arm', + armv8l: 'arm', + i386: 'ia32', + i686: 'ia32', + x86: 'ia32', + x86_64: 'x64', +} + +/** + * 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 { + 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> = { + // @ts-expect-error __proto__ makes the object null-prototyped and sets it in dictionary mode + __proto__: null, + windows: 'win32', +} + +/** + * 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 + */ +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. + * 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, userAgent = ''): 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 (/android/i.test(userAgent) || value.startsWith('android')) { + return 'android' + } + if (value.startsWith('linux')) { + return 'linux' + } + return normalizeOSType(platform) +} + +let cachedPlatformMetrics: null | PlatformMetrics = null + +/** + * 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 { + g = globalThis, + runtime = jsRuntime, + useCache = true + } = opts + const cacheable = useCache && g === globalThis && runtime === jsRuntime + if (cacheable && 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 = null + 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() + + const cpus = nodeOs.cpus() + cpuCores = cpus.length + const firstCpu = cpus[0] + if (firstCpu) { + cpuModel = firstCpu.model + cpuSpeed = firstCpu.speed + } + } catch { + // Best-effort: node:os can throw in restricted sandboxes; unresolved + // fields keep their sentinel defaults (-1 / 'unknown' / null). + } + + const metrics: PlatformMetrics = { + cpuCores, + cpuMachine, + cpuModel, + cpuSpeed, + memoryFree, + memoryTotal, + osKernel, + osType, + priority, + runtime, + userAgent + } + if (cacheable) { + cachedPlatformMetrics = metrics + } + return metrics +} diff --git a/src/types.ts b/src/types.ts index 3b5a8354..e5596939 100644 --- a/src/types.ts +++ b/src/types.ts @@ -448,6 +448,12 @@ export interface FnReturnedObject { overriddenIterationCost?: number } +export interface GetPlatformMetricsOptions { + g?: typeof globalThis + runtime?: JSRuntime + useCache?: boolean +} + /** * The hook function signature. * Called once per task and phase: warmup (if enabled), then run. @@ -483,11 +489,50 @@ export type JSRuntime = | 'v8' | 'workerd' +export type Machine = ( + | 'arm64' + | 'arm' + | 'ia32' + | 'loong64' + | 'mips' + | 'mipsel' + | 'ppc64' + | 'riscv64' + | 's390x' + | 'x64') | (Lowercase & Record) + /** * A function that returns the current timestamp. */ export type NowFn = () => number +export type OS = ( + | 'aix' + | 'android' + | 'cygwin' + | 'darwin' + | 'freebsd' + | 'haiku' + | 'linux' + | 'netbsd' + | 'openbsd' + | 'sunos' + | 'win32') | (Lowercase & Record) + +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< EventTarget['removeEventListener'] diff --git a/test/platform-metrics.test.ts b/test/platform-metrics.test.ts new file mode 100644 index 00000000..31c29d8c --- /dev/null +++ b/test/platform-metrics.test.ts @@ -0,0 +1,70 @@ +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') +}) + +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('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' } + } 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 new file mode 100644 index 00000000..d1dc7013 --- /dev/null +++ b/test/platform-normalize-arch.test.ts @@ -0,0 +1,41 @@ +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('ia32') + 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 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') + expect(normalizeMachine('x86_64')).toBe('x64') +}) + +test('normalizeArch returns lowercase', () => { + expect(normalizeMachine('ARM')).toBe('arm') + expect(normalizeMachine('AARCH64')).toBe('arm64') +}) 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') +})