Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .size-limit.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[
{
"path": "dist/index.js",
"limit": "12 kB"
"limit": "12 kB",
"ignore": ["node:os"]
}
]
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { Bench } from './bench'
export { getPlatformMetrics, normalizeMachine, normalizeOSType } from './platform'
export { Task } from './task'
export type {
BenchEvent,
Expand All @@ -14,10 +15,14 @@ export type {
FnHook,
FnOptions,
FnReturnedObject,
GetPlatformMetricsOptions,
Hook,
HookMode,
JSRuntime,
Machine,
NowFn,
OS,
PlatformMetrics,
ResolvedBenchOptions,
Samples,
SortedSamples,
Expand Down
198 changes: 198 additions & 0 deletions src/platform.ts
Original file line number Diff line number Diff line change
@@ -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<Lowercase<string>, 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<string>] ?? (key as Machine)
}

const osLookup: Record<Lowercase<string>, 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<string>] ?? (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<PlatformMetrics> {
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
}
45 changes: 45 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -483,11 +489,50 @@ export type JSRuntime =
| 'v8'
| 'workerd'

export type Machine = (
| 'arm64'
| 'arm'
| 'ia32'
Comment thread
jerome-benoit marked this conversation as resolved.
Comment thread
jerome-benoit marked this conversation as resolved.
| 'loong64'
| 'mips'
| 'mipsel'
| 'ppc64'
| 'riscv64'
| 's390x'
| 'x64') | (Lowercase<string> & Record<never, never>)

/**
* A function that returns the current timestamp.
*/
export type NowFn = () => number

Comment thread
jerome-benoit marked this conversation as resolved.
export type OS = (
| 'aix'
| 'android'
| 'cygwin'
| 'darwin'
| 'freebsd'
| 'haiku'
| 'linux'
| 'netbsd'
| 'openbsd'
| 'sunos'
| 'win32') | (Lowercase<string> & Record<never, never>)

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']
Expand Down
70 changes: 70 additions & 0 deletions test/platform-metrics.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
Loading
Loading