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..d9d2bdb1 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,128 @@ +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 }, + () => ({ model: 'unknown', speed: -1 }) + ) + } + : () => ([]), + freemem: () => -1, + getPriority: () => -1, + // 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]) // eslint-disable-line @typescript-eslint/no-deprecated + : () => 'unknown', + // 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]) // eslint-disable-line @typescript-eslint/no-deprecated + : () => '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, + } +} + +/* 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 c8be4679..0c588aae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -232,6 +232,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. @@ -243,6 +249,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 @@ -451,3 +485,34 @@ export interface TaskResultWithStatistics { type Concurrency = 'bench' | 'task' | null type NowFn = () => 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') +})