@desert-ant-labs/desert-ant-web 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,10 @@ usage.recordCall(); // once per inference; rides the session's load even
19
19
 
20
20
  - One `load` per device per session (30-min idle timeout); the server dedups by
21
21
  device per month and sums `callCount` across events.
22
+ - Auto-collects low-entropy device context (`locale`, `osName`, `osVersion`,
23
+ `browserName`, `browserVersion`, `formFactor`) from User-Agent Client Hints /
24
+ the UA string — no high-entropy hints, no permission prompt. Pass `appVersion`
25
+ via `context` (it can't be auto-detected).
22
26
  - Zero dependencies, best-effort, never throws into the host.
23
27
  - `initUsage({ key?, endpoint?, callCount?, context?, disabled? })` — `endpoint`
24
28
  overrides the default (e.g. staging). `@desert-ant-labs/desert-ant-web/core`
package/dist/core.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export declare const SDK_NAME = "desert-ant-web";
2
- export declare const SDK_VERSION = "0.1.0";
2
+ export declare const SDK_VERSION = "0.2.0";
3
3
  export declare const DAY_MS: number;
4
4
  export declare const WEB_SESSION_MS: number;
5
5
  export type LoadContext = Record<string, unknown>;
package/dist/core.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // across events, so emitting an extra `load` never over-bills and a session's
11
11
  // calls can be split across several events and still add up.
12
12
  export const SDK_NAME = "desert-ant-web";
13
- export const SDK_VERSION = "0.1.0"; // keep in sync with package.json
13
+ export const SDK_VERSION = "0.2.0"; // keep in sync with package.json
14
14
  // Re-emit windows. A node/mobile install is persistent, so a device re-emits at
15
15
  // most once a DAY. Web is session-shaped and its identity is ephemeral, so it
16
16
  // re-emits per SESSION — a new session being the first activity after this much
@@ -0,0 +1,10 @@
1
+ export interface DeviceContext {
2
+ osName?: string;
3
+ osVersion?: string;
4
+ browserName?: string;
5
+ browserVersion?: string;
6
+ formFactor?: "desktop" | "mobile";
7
+ }
8
+ /** Device context, computed once — it's stable for the page's lifetime. */
9
+ export declare function deviceContext(): DeviceContext;
10
+ export declare function parseUserAgent(ua: string): DeviceContext;
package/dist/device.js ADDED
@@ -0,0 +1,119 @@
1
+ // Low-entropy device context for web `load` events.
2
+ //
3
+ // Populated from User-Agent Client Hints where available (Chromium) and a
4
+ // minimal `navigator.userAgent` parse everywhere else. Both are already
5
+ // broadcast on every HTTP request the browser makes, so reading them client-side
6
+ // adds no fingerprinting entropy beyond what the server already sees. We
7
+ // deliberately never request *high-entropy* hints (exact OS version, device
8
+ // model): that keeps MAD's "count devices, not users" stance and avoids a
9
+ // permission-worthy surface. Consequences, by design:
10
+ // - `osVersion` is coarse (UA reduction freezes it — e.g. macOS reports 10.15.7).
11
+ // - `deviceModel` is omitted on web (browsers don't expose it low-entropy).
12
+ // - `formFactor` is `desktop` | `mobile` only (tablet needs a high-entropy hint).
13
+ const major = (version) => version.split(".")[0];
14
+ /** Device context, computed once — it's stable for the page's lifetime. */
15
+ export function deviceContext() {
16
+ if (typeof navigator === "undefined")
17
+ return {};
18
+ const nav = navigator;
19
+ const ua = typeof nav.userAgent === "string" ? nav.userAgent : "";
20
+ // Base from the UA string (works in every browser, incl. Safari/Firefox which
21
+ // have no Client Hints), then refine with structured hints where present.
22
+ const ctx = parseUserAgent(ua);
23
+ const uaData = nav.userAgentData;
24
+ if (uaData) {
25
+ if (uaData.platform)
26
+ ctx.osName = normalizeOsName(uaData.platform);
27
+ if (typeof uaData.mobile === "boolean") {
28
+ ctx.formFactor = uaData.mobile ? "mobile" : "desktop";
29
+ }
30
+ const brand = uaData.brands ? pickBrand(uaData.brands) : undefined;
31
+ if (brand) {
32
+ ctx.browserName = brand.name;
33
+ ctx.browserVersion = major(brand.version);
34
+ }
35
+ // osVersion stays from the UA parse on purpose: the precise value lives in
36
+ // the high-entropy `platformVersion` hint, which we don't request.
37
+ }
38
+ return ctx;
39
+ }
40
+ // Choose the meaningful browser brand from the Client Hints list: drop the
41
+ // "greased" decoy entries (random tokens the spec injects, e.g. "Not/A)Brand")
42
+ // and prefer a specific vendor brand over the generic "Chromium" engine brand.
43
+ function pickBrand(brands) {
44
+ const real = brands.filter((b) => !/not.?a.?brand/i.test(b.brand));
45
+ if (!real.length)
46
+ return undefined;
47
+ const pick = real.find((b) => b.brand !== "Chromium") ?? real[0];
48
+ return { name: pick.brand, version: pick.version };
49
+ }
50
+ // userAgentData.platform values ("Chrome OS"/"Chromium OS") → our UA-parse form.
51
+ function normalizeOsName(platform) {
52
+ return /^chrom(e|ium) os$/i.test(platform) ? "ChromeOS" : platform;
53
+ }
54
+ // Windows kernel version → marketing name (the UA never carries "11").
55
+ const WINDOWS_NAMES = {
56
+ "10.0": "10",
57
+ "6.3": "8.1",
58
+ "6.2": "8",
59
+ "6.1": "7",
60
+ };
61
+ // Exported for unit testing; prefer `deviceContext()` at call sites.
62
+ export function parseUserAgent(ua) {
63
+ const ctx = {};
64
+ let m;
65
+ // OS name + (coarse) version. iOS/Android still carry a real version; Windows
66
+ // and macOS are frozen by UA reduction.
67
+ if ((m = ua.match(/Windows NT ([\d.]+)/))) {
68
+ ctx.osName = "Windows";
69
+ ctx.osVersion = WINDOWS_NAMES[m[1]] ?? m[1];
70
+ }
71
+ else if ((m = ua.match(/(?:iPhone OS|CPU OS) (\d+[_\d]*)/))) {
72
+ ctx.osName = "iOS";
73
+ ctx.osVersion = m[1].replace(/_/g, ".");
74
+ }
75
+ else if ((m = ua.match(/Mac OS X (\d+[_\d]*)/))) {
76
+ ctx.osName = "macOS";
77
+ ctx.osVersion = m[1].replace(/_/g, ".");
78
+ }
79
+ else if ((m = ua.match(/Android (\d+[.\d]*)/))) {
80
+ ctx.osName = "Android";
81
+ ctx.osVersion = m[1];
82
+ }
83
+ else if (/CrOS/.test(ua)) {
84
+ ctx.osName = "ChromeOS";
85
+ }
86
+ else if (/Linux/.test(ua)) {
87
+ ctx.osName = "Linux";
88
+ }
89
+ // Browser. Order matters: Edge/Opera masquerade with a "Chrome/" token, and
90
+ // Chrome's UA also contains "Safari", so check the specific brands first.
91
+ if ((m = ua.match(/Edg(?:e|A|iOS)?\/([\d.]+)/))) {
92
+ ctx.browserName = "Microsoft Edge";
93
+ ctx.browserVersion = major(m[1]);
94
+ }
95
+ else if ((m = ua.match(/(?:OPR|Opera)\/([\d.]+)/))) {
96
+ ctx.browserName = "Opera";
97
+ ctx.browserVersion = major(m[1]);
98
+ }
99
+ else if ((m = ua.match(/Firefox\/([\d.]+)/))) {
100
+ ctx.browserName = "Firefox";
101
+ ctx.browserVersion = major(m[1]);
102
+ }
103
+ else if ((m = ua.match(/Chrome\/([\d.]+)/))) {
104
+ ctx.browserName = "Chrome";
105
+ ctx.browserVersion = major(m[1]);
106
+ }
107
+ else if ((m = ua.match(/Version\/([\d.]+).*Safari/))) {
108
+ ctx.browserName = "Safari";
109
+ ctx.browserVersion = major(m[1]);
110
+ }
111
+ else if (/Safari\//.test(ua)) {
112
+ ctx.browserName = "Safari";
113
+ }
114
+ // Form factor: low-entropy phone-vs-not. Tablets read as desktop here (a
115
+ // high-entropy hint would be needed to split them out), matching our stance.
116
+ if (ua)
117
+ ctx.formFactor = /Mobi|iPhone|iPod/.test(ua) ? "mobile" : "desktop";
118
+ return ctx;
119
+ }
@@ -8,7 +8,12 @@ export interface UsageConfig {
8
8
  endpoint?: string;
9
9
  /** Authoritative call count, read at emit time. Alternative to recordCall(). */
10
10
  callCount?: () => number;
11
- /** Extra context merged onto each load (appVersion, etc.). */
11
+ /**
12
+ * Extra context merged onto each load and wins over auto-collected fields.
13
+ * The SDK already fills in low-entropy device context (locale, osName,
14
+ * osVersion, browserName, browserVersion, formFactor); pass `appVersion` (your
15
+ * site's release string) here — it can't be detected automatically.
16
+ */
12
17
  context?: LoadContext | (() => LoadContext | undefined);
13
18
  /** Disable entirely (returns an inert client). */
14
19
  disabled?: boolean;
@@ -1,6 +1,7 @@
1
1
  // Browser entry. Batteries-included: wires the localStorage identity, the
2
2
  // beacon/fetch transport, and the page lifecycle into the core client.
3
3
  import { createClient, WEB_SESSION_MS } from "./core.js";
4
+ import { deviceContext } from "./device.js";
4
5
  import { getDeviceId, loadState, saveState } from "./identity.js";
5
6
  import { makeSend } from "./transport.js";
6
7
  export { DAY_MS, WEB_SESSION_MS, createClient, uuid } from "./core.js";
@@ -42,9 +43,15 @@ export function initUsage(config = {}) {
42
43
  return client;
43
44
  }
44
45
  function contextProvider(c) {
46
+ // Auto-collected, low-entropy device context — computed once, stable for the
47
+ // page. Host-supplied context (e.g. appVersion) is merged on top and wins.
45
48
  const base = {};
46
49
  if (typeof navigator !== "undefined" && navigator.language)
47
50
  base.locale = navigator.language;
51
+ for (const [k, v] of Object.entries(deviceContext())) {
52
+ if (v !== undefined)
53
+ base[k] = v;
54
+ }
48
55
  if (typeof c === "function")
49
56
  return () => merge(base, c());
50
57
  if (c)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desert-ant-labs/desert-ant-web",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Internal shared browser/JS code for Desert Ant on-device SDKs (currently: usage reporting — active-device 'load' events to the ingestion API).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.node.js",