# os-detect — AI Reference OS, form-factor, and runtime detection from the user agent — synchronous, zero-dependency, SSR-safe. Vanilla JS / Vue 3 / React. Detects OS (`getOS`), device form factor (`getFormFactor`), JS runtime context (`getRuntime`: node/browser/webworker), input type, pixel ratio, and a grab-bag of environment booleans (Electron, PWA, TV, touch). Version 2.2.0. Zero runtime dependencies — `react`/`vue` are both optional peers, and each has its own dedicated entry point (no accidental hard-import of either framework from the core, unlike a real historical bug already fixed elsewhere in this package family — see section 8). This document is hand-written for AI agents and other tools that generate code against this package: every signature, default, and behavior note below is verified directly against the TypeScript source (not summarized from prose docs). For human-readable narrative docs, see the interactive site instead: - Full docs (EN): https://npm.vuecraft.ru/en/packages/os-detect/guide/overview - Full docs (RU): https://npm.vuecraft.ru/packages/os-detect/guide/overview - GitHub: https://github.com/macrulezru/os-detect - npm: https://www.npmjs.com/package/os-detect Links below starting with "/" are relative to https://npm.vuecraft.ru. --- ## 1. Package map | Import path | Contains | |---|---| | `os-detect` | Every detector function, `getOS`/`getFormFactor`/`getRuntime`/`getPrimaryInput`/`getPixelRatio`, `resetDetectionCache`, all types, and a default-exported object bundling all of the above (for UMD/plain-`require` convenience). | | `os-detect/vue` | Vue 3 composables (`useOS`, `useIsWindows11`, `useFormFactor`, `useRuntime`, `usePrimaryInput`) — imports the core internally, never re-exported from it. | | `os-detect/react` | The same five hooks for React `>=17`. | `unpkg`/`jsdelivr` point at `dist/index.umd.js` — a global `OsDetect` object, **core only** (no Vue/React build exists in UMD form — building `react.ts`/`vue.ts` as an IIFE would force-inline the entire peer dependency, since esbuild's `iife` format has no way to resolve an external import to a runtime global; this is a real, already-fixed bug — `vue.umd.js` used to ship at ~2.2 MB for exactly this reason before it was dropped from the UMD build entirely). --- ## 2. Core types ```ts type OS = 'ios' | 'macos' | 'android' | 'windows' | 'linux' | 'chromeos' | 'unknown' type FormFactor = 'phone' | 'tablet' | 'desktop' | 'tv' | 'unknown' type Runtime = 'node' | 'browser' | 'webworker' | 'unknown' type PrimaryInput = 'mouse' | 'touch' | 'unknown' ``` --- ## 3. Detection sources and priority (the core mechanism) For every OS boolean detector (`detectIsMacOS`, `detectIsWindows`, `detectIsAndroid`, `detectIsLinux`), the check order is: 1. **`process.platform`** (Node.js), via `getNodePlatform()` — checked first. 2. **`navigator.userAgentData.platform`** (Client Hints, Chromium 90+), lowercased. 3. **`navigator.userAgent`** regex fallback. `detectIsIOS`, `detectIsChromeOS`, and `detectIsTV` skip step 1 entirely — there's no `process.platform` value that means iOS/ChromeOS/TV, so those three only ever resolve `true` in a real (or jsdom) browser context, never in plain Node. ### 3.1 `getNodePlatform()` — the environment-disambiguation core ```ts function getUADataPlatform(): string | null // navigator.userAgentData.platform?.toLowerCase() ?? null function isNodeNavigator(nav: unknown): boolean // true iff nav.userAgent matches /^Node\.js\// function getNodePlatform(): string | null ``` ```ts function getNodePlatform(): string | null { if (typeof navigator !== 'undefined' && !isNodeNavigator(navigator)) return null if (typeof process === 'undefined' || typeof process.platform !== 'string') return null return process.platform } ``` **This is the load-bearing fix for Node.js 21+.** Node 21 started shipping its own synthetic global `navigator` (part of the fetch-API globals), whose `userAgent` is always exactly `"Node.js/"` — structurally distinct from any real browser or jsdom navigator (which always report a real product/version string). `getNodePlatform()` only returns `null` (deferring to the UA-based branches) when a **genuine** non-Node navigator is present; otherwise (no `navigator` at all, or Node's own fake one) it falls through to `process.platform`. An earlier version of this function (pre-2.0.0, confirmed via git history) used a bare `typeof navigator !== 'undefined'` guard, which — on Node 21+ where `navigator` always exists — permanently broke `getOS()`/`detectIsWindows`/ etc. for plain server-side Node scripts, silently returning `'unknown'`/ `false` for everything. Fixed and regression-tested (`isNodeNavigator` check added). ### 3.2 Environment matrix (verified against the test suite) | Signal | Real Node (no jsdom) | jsdom (Jest default) | Real browser | |---|---|---|---| | `typeof window` | `undefined` | `object` | `object` | | `typeof navigator` | `undefined` (<21) / synthetic `Node.js/` (21+) | `object` (jsdom's, real UA) | `object` (real) | | `getNodePlatform()` | `process.platform` | `null` | `null` | | `detectIsNode()` | `true` | **`true`** (jsdom runs on real Node underneath!) | `false` | | `detectIsBrowser()` | `false` | `true` | `true` | | `getRuntime()` | `'node'` | `'browser'` | `'browser'` | Under Jest+jsdom, `detectIsNode()` **and** `detectIsBrowser()` are both `true` simultaneously — jsdom is a real DOM shim running inside a real Node process, so `process.versions.node` genuinely exists there too. `getRuntime()`'s check order (webworker → browser → node) is what resolves this ambiguity to `'browser'`, the semantically correct answer for a DOM-shimmed test environment — this relies entirely on check *order*, not on the three runtime booleans being mutually exclusive. --- ## 4. `getOS()`, `isMobileDevice()`, `isDesktopDevice()` ```ts function getOS(): OS function isMobileDevice(): boolean // detectIsIOS() || detectIsAndroid() function isDesktopDevice(): boolean // detectIsMacOS() || detectIsWindows() || detectIsLinux() || detectIsChromeOS() ``` `getOS()` checks in this exact order: iOS → Android → ChromeOS → Linux → macOS → Windows → `'unknown'`. ChromeOS is checked before Linux because ChromeOS's own UA contains the literal substring `"Linux"` — though `detectIsLinux()` *itself* also independently excludes Android/ChromeOS on its UA-regex fallback branch (see 5.1), so this ordering is defense-in-depth, not the only thing preventing misclassification. **`isMobileDevice()`/`isDesktopDevice()` are NOT complements of each other** — an OS this package doesn't recognize returns `false` for *both*. Don't assume `!isMobileDevice() === isDesktopDevice()`. --- ## 5. Every OS/platform detector (`src/detectors/*.ts`) All are `memoizeBoolean(...)`-wrapped (see section 7) — computed once per process/page-load, cached forever until `resetDetectionCache()`. ### 5.1 `detectIsMacOS()` `getNodePlatform() === 'darwin'` → else `userAgentData.platform === 'macos'` → else `/Macintosh|MacIntel|MacPPC|Mac68K/.test(userAgent) && !detectIsIOS()` (the exclusion matters: iPadOS 13+ also sends `Macintosh` in its UA — see 5.4). ### 5.2 `detectIsAndroid()` `getNodePlatform() === 'android'` (for React Native / custom Node builds — desktop/server Node never reports this) → else `userAgentData.platform === 'android'` → else `/Android/.test(userAgent)`. ### 5.3 `detectIsChromeOS()` No Node path at all ("not detectable in Node.js"). `userAgentData.platform` matching `'chrome os'` **or** `'chromeos'` (both spellings handled) → else `/CrOS/.test(userAgent)`. ### 5.4 `detectIsLinux()` `getNodePlatform() === 'linux'` → else `userAgentData.platform === 'linux'` (trusted as-is — Client Hints never reports Android/ChromeOS as literal `'linux'` in practice, but this branch doesn't defensively re-check) → else `/Linux/.test(userAgent) && !detectIsAndroid() && !detectIsChromeOS()` (the UA-regex branch is the only one that explicitly excludes them — asymmetric with the `userAgentData` branch above it). ### 5.5 `detectIsWindows()` `getNodePlatform() === 'win32'` (covers both 32- and 64-bit Windows — Node's `process.platform` is always `'win32'` regardless of arch) → else `userAgentData.platform === 'windows'` → else `/Win32|Win64|Windows|WinCE/.test(userAgent)`. ### 5.6 `detectIsWindows11(): Promise` — the one async detector ```ts const WINDOWS_11_MIN_BUILD_NUMBER = 22000 // os.release()'s 3rd segment, Node path const WINDOWS_11_MIN_PLATFORM_VERSION_MAJOR = 13 // Client Hints platformVersion major, browser path async function detectIsWindows11(): Promise ``` Returns `false` immediately if `detectIsWindows()` is `false` (no detection attempted at all). Branch selection uses the **same** `isNodeNavigator`-based guard as `getNodePlatform()` — `inNode = typeof navigator === 'undefined' || isNodeNavigator(navigator)` — which is what keeps this jsdom-safe (jsdom's own navigator fails `isNodeNavigator`, so jsdom-based consumer tests correctly take the browser branch, never the Node one). - **Node path**: `await import('os')` (a genuinely dynamic import, kept out of the browser/UMD bundle's static graph — confirmed present verbatim, unresolved, in the compiled `dist/index.umd.js`; harmless only because `inNode` prevents this branch from ever executing in a real browser tab). Parses `os.release()`'s third dot-segment as the Windows build number; `>= 22000` → Windows 11. Any thrown error (including the dynamic import itself failing) → `false`. - **Browser path**: needs `navigator.userAgentData.getHighEntropyValues`. Missing entirely (Firefox, Safari, any non-Chromium browser) → `false`. `getHighEntropyValues(['platformVersion'])` with no `platformVersion` in the result → `false`. Parses only the major version segment; `>= 13` → Windows 11. - **Not memoized** — unlike every other detector, every call re-runs full detection, including a real Client Hints round-trip in the browser path. ### 5.7 `detectHasTouch()` Three-tier fallback: `navigator.maxTouchPoints > 0` → legacy `navigator.msMaxTouchPoints > 0` (old IE11/Edge) → `'ontouchstart' in window`. Independent of `getPrimaryInput()` — a touchscreen laptop with a mouse attached is `detectHasTouch() === true` regardless of which input is currently primary. ### 5.8 `detectIsTV()` `userAgentData.platform === 'tv'` (a reserved value some Android TV builds report) → else a broad UA regex: `SmartTV|SMART-TV|Web0S|WebOS| HbbTV|GoogleTV|Android TV|AFT[A-Z]|BRAVIA|VIDAA|NetCast|CrKey` (`AFT[A-Z]` matches Amazon Fire TV device codes). Deliberately best-effort/can-under-detect (some TV browsers ship near-generic Android/Chrome UAs with no reliable TV token) but designed to **never** over-detect a phone/tablet/desktop as TV. ### 5.9 `detectIsNode()`, `detectIsBrowser()`, `detectIsWebWorker()` ```ts detectIsNode: typeof process !== 'undefined' && typeof process.versions === 'object' && process.versions !== null && typeof process.versions.node === 'string' detectIsBrowser: typeof window !== 'undefined' && typeof document !== 'undefined' detectIsWebWorker: typeof self !== 'undefined' && typeof window === 'undefined' && typeof self.importScripts === 'function' ``` **`detectIsNode()` deliberately does NOT reuse `getNodePlatform()`'s navigator-based inference** — it must stay `true` even when a real browser-like `navigator` is *also* present, which is exactly the case for an Electron/NW.js renderer with Node integration enabled (has both `window`/`navigator` **and** `process.versions.node`). Conflating these two checks would break Electron/NW.js detection. `detectIsWebWorker` checks `importScripts` specifically (not just "`self` without `window`") because plain Node also lacks `window` but has neither `self` nor `importScripts` by default. ### 5.10 `detectIsElectron()` `process.versions.electron` is a string (present in the main process always; in a renderer only with Node integration enabled — cast through `Record` since `electron` isn't part of `@types/node`'s `ProcessVersions`) → else UA token `/Electron\//` (the fallback for a sandboxed/contextIsolated renderer where `process` isn't exposed to page JS at all, but Electron still appends its own UA token). ### 5.11 `detectIsPWA()` In order: `matchMedia('(display-mode: standalone)')` → `matchMedia('(display-mode: window-controls-overlay)')` → iOS Safari's legacy non-standard `navigator.standalone === true` → Trusted Web Activity via `document.referrer.startsWith('android-app://')`. `false` immediately if `window` is undefined. --- ## 6. Composite/environment functions (`src/index.ts`) ### 6.1 `getFormFactor(): FormFactor` ```ts const TABLET_MIN_WIDTH = 768 // iPad's portrait width / the classic Bootstrap-Tailwind tablet breakpoint function getFormFactor(): FormFactor { if (detectIsTV()) return 'tv' if (detectIsIOS() || detectIsAndroid()) { if (typeof screen === 'undefined') return 'unknown' return Math.min(screen.width, screen.height) >= TABLET_MIN_WIDTH ? 'tablet' : 'phone' } if (detectIsMacOS() || detectIsWindows() || detectIsLinux() || detectIsChromeOS()) return 'desktop' return 'unknown' } ``` - **TV is checked before mobile** — an Android TV device matching both the TV UA regex and `detectIsAndroid()`'s `/Android/` resolves to `'tv'`, not `'phone'`/`'tablet'`, purely due to check order. - Phone/tablet split uses `Math.min(width, height)` — orientation-independent by design — against the hardcoded `768` constant. - **Not driven by touch capability at all** — a touchscreen Windows laptop is `'desktop'`, always (explicitly tested). - Returns `'unknown'` for a mobile OS with no `screen` global available (Node/SSR — nothing to measure), even though the OS itself is known. ### 6.2 `getRuntime(): Runtime` — see section 3.2 for the disambiguation table. ### 6.3 `getPrimaryInput(): PrimaryInput` ```ts function getPrimaryInput(): PrimaryInput { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'unknown' if (window.matchMedia('(pointer: fine)').matches) return 'mouse' if (window.matchMedia('(pointer: coarse)').matches) return 'touch' return 'unknown' } ``` **Deliberately NOT cached/memoized**, unlike every boolean detector — this value is expected to change during a session (e.g. a Surface Pro's keyboard/mouse being attached/detached). Calling this function directly is a one-shot snapshot; use `usePrimaryInput()` (Vue/React) to track it live. ### 6.4 `getPixelRatio(): number` `window.devicePixelRatio` if a number, else `1` (Node/SSR fallback). Not cached — cheap to read live, and can change on desktop when a window is dragged between differently-scaled monitors. ### 6.5 `detectIsiOS()` — deprecated alias ```ts /** @deprecated Use detectIsIOS() instead. Will be removed in v3.0. */ function detectIsiOS(): boolean ``` Calls `console.warn(...)` on **every invocation**, not just once — the underlying `detectIsIOS()` result is cached, but the deprecation warning itself is not deduped. Calling this in a hot path before a consumer memoizes it would spam the console repeatedly. ### 6.6 Default export A plain object bundling all 23 named functions above (for UMD globals and convenient `require('os-detect')` default-import use) — same functions, same references, not a separate implementation. --- ## 7. Caching (`src/utils/cache.ts`) ```ts function memoizeBoolean(detect: () => boolean): () => boolean function resetDetectionCache(): void ``` Every boolean detector is wrapped once at module-load time; each wrapper registers its own reset closure in a shared module-level array. The cache is a plain closure variable — **computed once, shared by every caller**, for the lifetime of the loaded module (not per call-site, not per-component). `resetDetectionCache()` clears every registered detector's cache at once — intended for advanced cases only (a long-running Node process that changes environment, or a test suite simulating a different `navigator`/`process.platform` without re-importing the module). Not memoized at all: `getPrimaryInput()`, `getPixelRatio()` (deliberately live), and `detectIsWindows11()` (async, re-runs every call). --- ## 8. Vue composables (`os-detect/vue`) / React hooks (`os-detect/react`) Both entry points mirror each other 1:1 — five functions each, `useOS`/`useIsWindows11`/`useFormFactor`/`useRuntime`/`usePrimaryInput` — using Vue's `ref`/`onMounted`/`onUnmounted` vs. React's `useState`/`useEffect` idioms respectively. Vue's versions all return `readonly(ref)`. ```ts // Vue function useOS(): Readonly> function useIsWindows11(): Readonly> function useFormFactor(): Readonly> function useRuntime(): Readonly> function usePrimaryInput(): Readonly> // React — same names, same defaults function useOS(): OS function useIsWindows11(): boolean | null function useFormFactor(): FormFactor function useRuntime(): Runtime function usePrimaryInput(): PrimaryInput ``` - `useOS`/`useFormFactor`/`useRuntime`: React uses the **lazy initializer** form of `useState(() => getOS())` — detection runs once on mount/first-render only, not on every render. **Vue's equivalents compute the value eagerly at composable-*call*-time** (i.e. during `setup()`, not deferred to `onMounted`) — meaning on Vue SSR (Nuxt/Vite SSR) these run during the server render pass too. Since the underlying detectors are SSR-safe, this works, but the value is fixed at component-creation time and never automatically re-runs on the client after hydration. - `useIsWindows11()` (both frameworks): starts `null`, resolves via `detectIsWindows11().then(...)` inside `useEffect(..., [])` / `onMounted()`. **No unmount/cleanup guard around the promise** — if the component unmounts before it resolves, the setter still fires on an unmounted component in both bindings. - `usePrimaryInput()` (both frameworks): starts `'unknown'`, sets the real value and attaches `change` listeners on `matchMedia('(pointer: fine)')` + `matchMedia('(pointer: coarse)')` inside the mount effect (correctly deferred, not eager, unlike `useOS`/`useFormFactor`/`useRuntime` above) — both listeners call the same update function. Cleanup removes both. Bails out (no listeners, value stays whatever the initial `getPrimaryInput()` snapshot was) if `window`/`matchMedia` are unavailable. - **`useIsWindows11`/`usePrimaryInput` defer to mount; `useOS`/ `useFormFactor`/`useRuntime` don't** — a real asymmetry across the five composables/hooks, worth knowing since it affects SSR/hydration timing expectations differently per function. --- ## 9. Build/packaging facts worth knowing - Two separate `tsup` build jobs: (1) `index.ts`+`react.ts`+`vue.ts` → CJS+ESM+`.d.ts`, `external: ['react','vue','os']`; (2) `index.ts` only → IIFE (`dist/index.umd.js`, global `OsDetect`), `external: ['os']`, no declarations. Both target `es2017`. - `react.ts`/`vue.ts` are **not** built as IIFE/UMD — see the package-map note in section 1 for why (esbuild can't resolve an external import to a runtime global in `iife` format). - No CLI, no Vite plugin, no Nuxt module anywhere in this package — pure detection functions + two framework bindings. --- ## 10. Consolidated gotcha list 1. `getNodePlatform()`'s Node-21+-synthetic-`navigator` handling (`isNodeNavigator()`) is what makes `getOS()`/`detectIsWindows`/etc. work correctly on modern Node — an older, simpler `typeof navigator !== 'undefined'` guard (confirmed via git history as this package's own former implementation) would silently break all OS detection on Node 21+, since `navigator` always exists there now. Already fixed and regression-tested (section 3.1). 2. `detectIsNode()` deliberately does **not** reuse `getNodePlatform()`'s inference — it must report `true` in an Electron/NW.js renderer with Node integration enabled, which has a real `navigator` *and* `process.versions.node` simultaneously (5.9). 3. Under Jest+jsdom, `detectIsNode()` **and** `detectIsBrowser()` are both `true` at once (jsdom runs on a real Node process) — `getRuntime()`'s fixed check order (webworker → browser → node), not mutual exclusivity, is what resolves this to `'browser'` (3.2). 4. `detectIsWindows11()`'s Node-vs-browser branch uses the same `isNodeNavigator`-based guard as `getNodePlatform()` — this is specifically what keeps it safe to call from a jsdom-based test environment without misrouting into the Node `os.release()` path (5.6). 5. `detectIsWindows11()` is the **only** async, **only** non-memoized detector in the package — every call re-runs full detection, including a real Client Hints round-trip in the browser (5.6, 7). 6. The UMD/browser bundle contains a literal, unresolved `await import("os")` (verified in the compiled output) — harmless only because the `inNode` guard prevents that branch from ever executing in a real browser tab (5.6). 7. `getFormFactor()` checks TV *before* mobile — an Android TV device matching both the TV regex and the Android regex resolves to `'tv'`, never `'phone'`/`'tablet'`, due to check order alone (6.1). 8. `getFormFactor()`'s tablet/phone split depends on the `screen` global (unavailable in Node/SSR) even though the OS itself is already known from the UA — returns `'unknown'`, not a best-guess, in that case (6.1). 9. `isMobileDevice()`/`isDesktopDevice()` are not complements — both are `false` for an unrecognized OS (section 4). 10. `detectIsiOS()` (deprecated) logs a `console.warn` on **every** call, not just once — no de-dup, unlike the cached boolean result itself (6.5). 11. `useIsWindows11()` has no unmount-guard around its promise resolution in either the Vue or React binding — the setter can fire after the component is gone (section 8). 12. Vue's `useOS()`/`useFormFactor()`/`useRuntime()` compute their value eagerly at `setup()`-call time (including during SSR); Vue's `useIsWindows11()`/`usePrimaryInput()` correctly defer to `onMounted()` instead — an asymmetry across the five composables worth knowing for SSR/hydration timing (section 8). 13. `detectIsLinux()`'s Android/ChromeOS exclusion only applies on its UA-regex fallback branch, not on the `userAgentData.platform === 'linux'` branch — relies on Client Hints never literally reporting Android/ChromeOS as `'linux'`, which holds in practice but isn't defensively re-checked in code (5.4).