-
-
Notifications
You must be signed in to change notification settings - Fork 60
implement platform info #412
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Uzlopak
wants to merge
19
commits into
main
Choose a base branch
from
platform-metrics
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
fa64b69
implement platform info
Uzlopak 4373c47
fix
Uzlopak d9403c1
fix
Uzlopak 9ace79d
fix
Uzlopak 5423611
Merge branch 'main' into platform-metrics
Uzlopak 931a9d9
Merge branch 'main' into platform-metrics
Uzlopak 58d9927
fix lint
Uzlopak d8ba4c3
Merge branch 'main' into platform-metrics
Uzlopak 6d8af2b
Merge branch 'main' into platform-metrics
jerome-benoit af8d34e
fix(platform): sound types, correct browser detection, safe cache
jerome-benoit 2be7e81
Merge branch 'main' into platform-metrics
jerome-benoit 51d9396
fix(platform): detect Android via UA, normalize armv* to arm
jerome-benoit 9512772
Merge branch 'main' into platform-metrics
jerome-benoit c89c46a
Merge branch 'main' into platform-metrics
jerome-benoit c3e1462
chore(size-limit): mark node:os as external
jerome-benoit 9df73b8
docs(platform): drop @link to internal browserOSType
jerome-benoit 3ce139c
Merge branch 'main' into platform-metrics
jerome-benoit 5d7af46
Merge branch 'main' into platform-metrics
jerome-benoit ccb2982
Merge branch 'main' into platform-metrics
jerome-benoit File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] | ||
| } | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.