@cross-deck/web 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to `@cross-deck/web` will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.4.0] — 2026-05-09
6
+
7
+ Reactive entitlements. Pre-0.4.0, calling `Crossdeck.isEntitled("pro")` directly inside a React render path showed the empty-cache result forever — React had no way to know the cache had populated asynchronously after `init()`. This release closes that gap with a first-class subscribe API on the SDK and a React subpackage that uses it.
8
+
9
+ ### Added
10
+
11
+ - **`Crossdeck.onEntitlementsChange(listener)`** — synchronous subscribe API. Returns an idempotent unsubscribe function. Listeners fire AFTER each cache mutation (`getEntitlements`, `syncPurchases`, `reset`). Listener errors are swallowed. NOT fired on subscribe — read state inline if you need the initial value. See `sdks/SDK_TRUTH.md` for the full contract.
12
+ - **`@cross-deck/web/react` subpath export** — first-class React hooks built on top of the subscribe API:
13
+ - `useEntitlement(key): boolean` — re-renders the component the moment the cache mutates so a JSX snippet like `useEntitlement("pro") && <ProBadge />` actually works.
14
+ - `useEntitlements(): readonly string[]` — reactive list of all active entitlement keys.
15
+ - SSR-safe: hook returns `false` / `[]` on the server and hydrates correctly on the client. Pre-init returns the empty default until `Crossdeck.init()` runs and a cache mutation lands.
16
+ - **`EntitlementCache.subscribe(listener)`** — internal listener API on the cache itself. Powers `onEntitlementsChange`. Iterates over a snapshot of the listener set so listeners that unsubscribe themselves during dispatch don't break the iteration.
17
+ - **Tests** — 7 new cases covering listener semantics: fires on `setFromList`, fires on `clear`, NOT fired on subscribe, idempotent unsubscribe, listener errors are non-fatal, self-unsubscribe-during-dispatch is safe.
18
+
19
+ ### Why this exists
20
+
21
+ Without a subscribe API, every framework binding (React, SwiftUI, Compose, Vue, Solid) had to invent its own re-render trigger by polling or hooking into private SDK internals. The cache is the only place that knows precisely when `isEntitled()` would change its answer; making it the source of the notification is the correct contract. iOS and Android SDKs MUST adopt the same pattern internally before 1.0 and MUST expose framework bindings (`@Observable` / SwiftUI for iOS, `StateFlow<Boolean>` / Compose for Android) that mirror the React hook's semantics. See the SDK NorthStar Addendum §11.4.
22
+
23
+ ### Build
24
+
25
+ - `tsup` now emits two entry points (`dist/index.{cjs,mjs}` and `dist/react.{cjs,mjs}`) with a custom `outExtension` matching the `package.json` exports map.
26
+ - React is now an optional peer dependency (`react >=18`).
27
+
28
+ ### Compatibility
29
+
30
+ Source-compatible with 0.3.0. No breaking changes — `onEntitlementsChange` and the React hooks are purely additive.
31
+
5
32
  ## [0.3.0] — 2026-05-08
6
33
 
7
34
  This release reconciles the web SDK with the Crossdeck SDK NorthStar Addendum (§4 Shared Contract, §11.1 Web SDK pattern, §13.1 wire envelope, §15 sensitive properties, §16 debug signal vocabulary). The public surface now matches what the iOS, Android, and Node SDKs will expose — `init`, `flush`, `syncPurchases`, `setDebugMode`.
package/README.md CHANGED
@@ -11,28 +11,66 @@ npm install @cross-deck/web
11
11
  ```ts
12
12
  import { Crossdeck } from "@cross-deck/web";
13
13
 
14
- // 1. Boot once at app start
14
+ // 1. Boot once at app start. Synchronous and idempotent.
15
15
  Crossdeck.init({
16
16
  appId: "app_web_xxx", // from the Crossdeck dashboard
17
17
  publicKey: "cd_pub_live_…", // publishable key, safe in client code
18
18
  environment: "production", // "production" or "sandbox"
19
19
  });
20
20
 
21
- // 2. After the user logs in, link the device to your user ID
22
- await Crossdeck.identify("user_847");
21
+ // 2. Telemetry fire-and-forget, batched in the background.
22
+ Crossdeck.track("paywall_viewed", { variant: "v3" });
23
+
24
+ // 3. Auth + entitlements happen inside an async boot function (or a
25
+ // React useEffect, or any other async context). Top-level await is
26
+ // not portable across all bundlers.
27
+ async function bootCrossdeck() {
28
+ // Wire identify() to YOUR auth state — never hardcode a placeholder.
29
+ await Crossdeck.identify(currentUser.id);
30
+ await Crossdeck.getEntitlements(); // warm the local cache
31
+
32
+ // 4. Sync access checks (microsecond reads from cache).
33
+ if (Crossdeck.isEntitled("pro")) {
34
+ showProFeatures();
35
+ }
36
+ }
37
+ ```
23
38
 
24
- // 3. Read entitlements (warms the local cache)
25
- await Crossdeck.getEntitlements();
39
+ ### React quick start
26
40
 
27
- // 4. Sync access checks (microsecond reads from cache)
28
- if (Crossdeck.isEntitled("pro")) {
29
- showProFeatures();
41
+ For React apps, install Crossdeck once at the root and use the
42
+ `useEntitlement` hook from `@cross-deck/web/react` so components
43
+ re-render when entitlements arrive:
44
+
45
+ ```tsx
46
+ "use client"
47
+ import { useEffect } from "react";
48
+ import { Crossdeck } from "@cross-deck/web";
49
+ import { useEntitlement } from "@cross-deck/web/react";
50
+
51
+ export function CrossdeckProvider({ children }) {
52
+ useEffect(() => {
53
+ Crossdeck.init({
54
+ appId: "app_web_xxx",
55
+ publicKey: "cd_pub_live_…",
56
+ environment: "production",
57
+ });
58
+ Crossdeck.getEntitlements(); // warm the cache (fire-and-forget)
59
+ }, []);
60
+ return children;
30
61
  }
31
62
 
32
- // 5. Telemetry — fire-and-forget, batched in the background
33
- Crossdeck.track("paywall_viewed", { variant: "v3" });
63
+ export function ProBadge() {
64
+ const isPro = useEntitlement("pro");
65
+ return isPro ? <span className="badge">Pro</span> : null;
66
+ }
34
67
  ```
35
68
 
69
+ `useEntitlement` subscribes to the SDK's reactive cache via
70
+ `Crossdeck.onEntitlementsChange()`, so every component using the hook
71
+ re-renders the moment entitlements change. SSR-safe: returns `false`
72
+ on the server and hydrates correctly on the client.
73
+
36
74
  That's the full happy path.
37
75
 
38
76
  ## What it does
@@ -71,7 +109,7 @@ Every event's `properties` is enriched with whatever the SDK can detect:
71
109
  viewportWidth: 1440,
72
110
  viewportHeight: 900,
73
111
  devicePixelRatio: 2,
74
- appVersion: "1.2.3", // only when you set Crossdeck.start({ appVersion })
112
+ appVersion: "1.2.3", // only when you set Crossdeck.init({ appVersion })
75
113
  }
76
114
  ```
77
115
 
@@ -232,6 +232,7 @@ var EntitlementCache = class {
232
232
  this.active = /* @__PURE__ */ new Set();
233
233
  this.all = [];
234
234
  this.lastUpdated = 0;
235
+ this.listeners = /* @__PURE__ */ new Set();
235
236
  }
236
237
  /** Sync read — true iff the entitlement key is currently active. */
237
238
  isEntitled(key) {
@@ -249,20 +250,57 @@ var EntitlementCache = class {
249
250
  * Replace the cache with a fresh server response. The backend already
250
251
  * filters to active + env-matching, so we don't re-filter — just trust
251
252
  * what we got.
253
+ *
254
+ * Fires listeners AFTER the mutation so each listener sees the new state.
252
255
  */
253
256
  setFromList(entitlements) {
254
257
  this.all = entitlements.slice();
255
258
  this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));
256
259
  this.lastUpdated = Date.now();
260
+ this.notify();
257
261
  }
258
262
  /**
259
263
  * Wipe — used on reset() (logout). The SDK forgets everything until
260
264
  * the next identify + read.
265
+ *
266
+ * Fires listeners so React/SwiftUI/etc bindings re-render to the
267
+ * logged-out state immediately.
261
268
  */
262
269
  clear() {
263
270
  this.active.clear();
264
271
  this.all = [];
265
272
  this.lastUpdated = 0;
273
+ this.notify();
274
+ }
275
+ /**
276
+ * Subscribe to cache mutations. Returns an unsubscribe function.
277
+ *
278
+ * The listener is invoked AFTER setFromList() or clear() with the
279
+ * current snapshot. Throwing inside a listener is non-fatal — the
280
+ * error is swallowed and subsequent listeners still run.
281
+ *
282
+ * Used by `@cross-deck/web/react`'s `useEntitlement` hook to
283
+ * trigger re-renders when entitlements change.
284
+ */
285
+ subscribe(listener) {
286
+ this.listeners.add(listener);
287
+ let unsubscribed = false;
288
+ return () => {
289
+ if (unsubscribed) return;
290
+ unsubscribed = true;
291
+ this.listeners.delete(listener);
292
+ };
293
+ }
294
+ notify() {
295
+ if (this.listeners.size === 0) return;
296
+ const snapshot = this.all.slice();
297
+ const listenersSnapshot = [...this.listeners];
298
+ for (const listener of listenersSnapshot) {
299
+ try {
300
+ listener(snapshot);
301
+ } catch {
302
+ }
303
+ }
266
304
  }
267
305
  };
268
306
 
@@ -870,6 +908,37 @@ var CrossdeckClient = class {
870
908
  const s = this.requireStarted();
871
909
  return s.entitlements.list();
872
910
  }
911
+ /**
912
+ * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
913
+ *
914
+ * The listener is invoked AFTER the cache mutates — once after a
915
+ * successful `getEntitlements()` warms it, again after `syncPurchases()`
916
+ * delivers fresh entitlements, and once on `reset()` to fire the
917
+ * empty-cache state for logout flows.
918
+ *
919
+ * It is NOT invoked synchronously on subscribe. Callers that need
920
+ * the current state should read it via `isEntitled()` / `listEntitlements()`
921
+ * inline; the listener fires only on FUTURE changes.
922
+ *
923
+ * This is the foundation of the `useEntitlement` React hook in
924
+ * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
925
+ * / Vue) would have no way to re-render when entitlements arrive
926
+ * asynchronously after init. The naive pattern of calling
927
+ * `Crossdeck.isEntitled("pro")` directly inside a render path
928
+ * shows the empty-cache result forever; binding the result to
929
+ * component state via `onEntitlementsChange` is the correct
930
+ * pattern.
931
+ *
932
+ * Idempotent unsubscribe — calling the returned function multiple
933
+ * times is safe.
934
+ *
935
+ * Listener errors are swallowed (a buggy listener can't crash the
936
+ * SDK or other listeners).
937
+ */
938
+ onEntitlementsChange(listener) {
939
+ const s = this.requireStarted();
940
+ return s.entitlements.subscribe(listener);
941
+ }
873
942
  /**
874
943
  * Queue a telemetry event. Returns immediately — the network round-
875
944
  * trip happens in the background. To flush before the page unloads,
@@ -1113,4 +1182,4 @@ function resolveAutoTrack(input) {
1113
1182
  SDK_NAME,
1114
1183
  SDK_VERSION
1115
1184
  });
1116
- //# sourceMappingURL=index.js.map
1185
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/errors.ts","../src/http.ts","../src/identity.ts","../src/entitlement-cache.ts","../src/event-queue.ts","../src/storage.ts","../src/device-info.ts","../src/auto-track.ts","../src/debug.ts","../src/crossdeck.ts"],"sourcesContent":["/**\n * @cross-deck/web — public entry point.\n *\n * The default export is a singleton `Crossdeck` instance. Most apps want\n * exactly one client; instantiate `CrossdeckClient` directly if you need\n * isolated instances (e.g. one per tenant in a multi-tenant SaaS shell).\n */\n\nexport { Crossdeck, CrossdeckClient } from \"./crossdeck\";\nexport { CrossdeckError } from \"./errors\";\nexport { MemoryStorage } from \"./storage\";\nexport { SDK_NAME, SDK_VERSION, DEFAULT_BASE_URL } from \"./http\";\n\nexport type {\n CrossdeckOptions,\n IdentifyOptions,\n EventProperties,\n KeyValueStorage,\n PublicEntitlement,\n EntitlementsListResponse,\n AliasResult,\n PurchaseResult,\n HeartbeatResponse,\n Diagnostics,\n Environment,\n Platform,\n AuditRail,\n AutoTrackOptions,\n} from \"./types\";\nexport type { DeviceInfo } from \"./device-info\";\nexport type { CrossdeckErrorType, CrossdeckErrorPayload } from \"./errors\";\n","/**\n * Stripe-style error wrapper for @cross-deck/web.\n *\n * Mirrors the wire shape returned by the v1 backend (see\n * backend/src/api/v1-errors.ts) so SDK consumers can `catch`\n * with consistent fields:\n *\n * try {\n * await crossdeck.identify(\"user_847\");\n * } catch (err) {\n * if (err instanceof CrossdeckError && err.code === \"invalid_api_key\") {\n * // ...\n * }\n * }\n */\n\nexport type CrossdeckErrorType =\n | \"authentication_error\"\n | \"permission_error\"\n | \"invalid_request_error\"\n | \"rate_limit_error\"\n | \"internal_error\"\n | \"network_error\"\n | \"configuration_error\";\n\nexport interface CrossdeckErrorPayload {\n type: CrossdeckErrorType;\n code: string;\n message: string;\n /** Server-issued request ID. Echoed in support tickets. */\n requestId?: string;\n /** HTTP status code if the error came from an API response. */\n status?: number;\n}\n\nexport class CrossdeckError extends Error {\n public readonly type: CrossdeckErrorType;\n public readonly code: string;\n public readonly requestId?: string;\n public readonly status?: number;\n\n constructor(payload: CrossdeckErrorPayload) {\n super(payload.message);\n this.name = \"CrossdeckError\";\n this.type = payload.type;\n this.code = payload.code;\n this.requestId = payload.requestId;\n this.status = payload.status;\n // Restore prototype chain — needed when targeting ES5.\n Object.setPrototypeOf(this, CrossdeckError.prototype);\n }\n}\n\n/**\n * Build a CrossdeckError from a non-OK fetch Response. Reads the\n * Stripe-style envelope { error: { type, code, message, request_id } }.\n * Falls back to a generic shape if the body isn't valid JSON.\n */\nexport async function crossdeckErrorFromResponse(res: Response): Promise<CrossdeckError> {\n const requestId = res.headers.get(\"x-request-id\") ?? undefined;\n let body: unknown;\n try {\n body = await res.json();\n } catch {\n body = null;\n }\n const envelope = (body as { error?: Partial<CrossdeckErrorPayload> & { request_id?: string } })?.error;\n if (envelope && typeof envelope.type === \"string\" && typeof envelope.code === \"string\") {\n return new CrossdeckError({\n type: envelope.type as CrossdeckErrorType,\n code: envelope.code,\n message: envelope.message ?? `HTTP ${res.status}`,\n requestId: envelope.request_id ?? requestId,\n status: res.status,\n });\n }\n return new CrossdeckError({\n type: typeMapForStatus(res.status),\n code: `http_${res.status}`,\n message: `HTTP ${res.status} ${res.statusText || \"\"}`.trim(),\n requestId,\n status: res.status,\n });\n}\n\nfunction typeMapForStatus(status: number): CrossdeckErrorType {\n if (status === 401) return \"authentication_error\";\n if (status === 403) return \"permission_error\";\n if (status === 429) return \"rate_limit_error\";\n if (status >= 400 && status < 500) return \"invalid_request_error\";\n return \"internal_error\";\n}\n","/**\n * HTTP transport for the SDK. Single fetch wrapper used by every endpoint\n * call. Adds the Bearer token and SDK version header, parses responses,\n * normalises errors to CrossdeckError.\n *\n * Uses platform-native fetch (browser + Node 18+). No axios, no isomorphic-\n * fetch shim, no transitive deps.\n */\n\nimport { CrossdeckError, crossdeckErrorFromResponse } from \"./errors\";\n\nexport const SDK_NAME = \"@cross-deck/web\";\nexport const SDK_VERSION = \"0.3.0\";\nexport const DEFAULT_BASE_URL = \"https://api.cross-deck.com/v1\";\n\nexport interface HttpClientConfig {\n publicKey: string;\n baseUrl: string;\n sdkVersion: string;\n}\n\nexport class HttpClient {\n constructor(private readonly config: HttpClientConfig) {}\n\n /**\n * Issue a request. `path` is relative to the configured baseUrl\n * (\"/entitlements\", \"/identity/alias\", etc.).\n *\n * Throws CrossdeckError on:\n * - Network failure (`type: \"network_error\"`)\n * - Non-2xx response (typed from the body envelope)\n * - JSON parse failure on a 2xx (treated as `internal_error`)\n */\n async request<T>(\n method: \"GET\" | \"POST\",\n path: string,\n options: { body?: unknown; query?: Record<string, string | undefined> } = {}\n ): Promise<T> {\n const url = this.buildUrl(path, options.query);\n\n const headers: Record<string, string> = {\n Authorization: `Bearer ${this.config.publicKey}`,\n \"Crossdeck-Sdk-Version\": `${SDK_NAME}@${this.config.sdkVersion}`,\n Accept: \"application/json\",\n };\n let bodyInit: BodyInit | undefined;\n if (options.body !== undefined) {\n headers[\"Content-Type\"] = \"application/json\";\n bodyInit = JSON.stringify(options.body);\n }\n\n let response: Response;\n try {\n response = await fetch(url, {\n method,\n headers,\n body: bodyInit,\n });\n } catch (err) {\n throw new CrossdeckError({\n type: \"network_error\",\n code: \"fetch_failed\",\n message: err instanceof Error ? err.message : \"fetch failed\",\n });\n }\n\n if (!response.ok) {\n throw await crossdeckErrorFromResponse(response);\n }\n\n // 204 No Content / OPTIONS-like — return undefined cast as T (callers\n // that don't expect a body shouldn't read it).\n if (response.status === 204) return undefined as T;\n\n try {\n return (await response.json()) as T;\n } catch (err) {\n throw new CrossdeckError({\n type: \"internal_error\",\n code: \"invalid_json_response\",\n message: \"Server returned a 2xx with an unparseable body.\",\n requestId: response.headers.get(\"x-request-id\") ?? undefined,\n status: response.status,\n });\n }\n }\n\n private buildUrl(path: string, query?: Record<string, string | undefined>): string {\n const base = this.config.baseUrl.replace(/\\/+$/, \"\");\n const cleanPath = path.startsWith(\"/\") ? path : `/${path}`;\n let url = base + cleanPath;\n if (query) {\n const params = new URLSearchParams();\n for (const [k, v] of Object.entries(query)) {\n if (typeof v === \"string\" && v.length > 0) params.append(k, v);\n }\n const qs = params.toString();\n if (qs) url += (url.includes(\"?\") ? \"&\" : \"?\") + qs;\n }\n return url;\n }\n}\n","/**\n * Identity persistence for the SDK.\n *\n * Two values are tracked:\n * anonymousId — generated on first boot. Persists for the\n * install lifetime so pre-login events stay\n * attached to the same identity graph entry.\n * crossdeckCustomerId — populated after the first identify() or\n * getEntitlements() that resolves a customer.\n * Persisted so subsequent boots can read\n * entitlements directly without an alias call.\n */\n\nimport type { KeyValueStorage } from \"./types\";\n\nconst KEY_ANON = \"anon_id\";\nconst KEY_CDCUST = \"cdcust_id\";\n\nexport interface IdentityState {\n anonymousId: string;\n crossdeckCustomerId: string | null;\n}\n\nexport class IdentityStore {\n private state: IdentityState;\n\n constructor(\n private readonly storage: KeyValueStorage,\n private readonly prefix: string\n ) {\n const stored = {\n anon: storage.getItem(prefix + KEY_ANON),\n cdcust: storage.getItem(prefix + KEY_CDCUST),\n };\n this.state = {\n anonymousId: stored.anon ?? this.mintAnonymousId(),\n crossdeckCustomerId: stored.cdcust,\n };\n if (!stored.anon) {\n storage.setItem(prefix + KEY_ANON, this.state.anonymousId);\n }\n }\n\n /** Return the persisted anonymous device ID (always set). */\n get anonymousId(): string {\n return this.state.anonymousId;\n }\n\n /** Return the resolved cross­deckCustomerId once we have one, else null. */\n get crossdeckCustomerId(): string | null {\n return this.state.crossdeckCustomerId;\n }\n\n /** Persist a newly-resolved Crossdeck customer ID. */\n setCrossdeckCustomerId(value: string): void {\n this.state.crossdeckCustomerId = value;\n this.storage.setItem(this.prefix + KEY_CDCUST, value);\n }\n\n /**\n * Wipe persisted identity. Called by reset() — used when an end-user\n * logs out. After reset the SDK mints a new anonymousId so the next\n * pre-login session is a fresh customer in the identity graph.\n */\n reset(): void {\n this.storage.removeItem(this.prefix + KEY_ANON);\n this.storage.removeItem(this.prefix + KEY_CDCUST);\n this.state = {\n anonymousId: this.mintAnonymousId(),\n crossdeckCustomerId: null,\n };\n this.storage.setItem(this.prefix + KEY_ANON, this.state.anonymousId);\n }\n\n /**\n * Generate an anonymousId. Crockford-ish base32 timestamp + random\n * suffix. Same shape Stripe / Segment / others use — sortable, log-\n * friendly, no PII.\n */\n private mintAnonymousId(): string {\n const ts = Date.now().toString(36);\n const rand = randomChars(10);\n return `anon_${ts}${rand}`;\n }\n}\n\n/**\n * Generate a cryptographically-random short string. Uses\n * crypto.getRandomValues when available (browser + Node 18+ via webcrypto),\n * else falls back to Math.random — that fallback is safe here because\n * anonymousId entropy doesn't need to resist offline brute force; it\n * needs to be unique-with-overwhelming-probability across one device's\n * lifetime.\n *\n * Exported for unit testing (alphabet round-trip).\n */\nexport function randomChars(count: number): string {\n const alphabet = \"0123456789abcdefghijklmnopqrstuvwxyz\";\n const out: string[] = [];\n const cryptoApi = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array } }).crypto;\n if (cryptoApi?.getRandomValues) {\n const buf = new Uint8Array(count);\n cryptoApi.getRandomValues(buf);\n for (let i = 0; i < count; i++) {\n out.push(alphabet[buf[i]! % alphabet.length] ?? \"0\");\n }\n } else {\n for (let i = 0; i < count; i++) {\n out.push(alphabet[Math.floor(Math.random() * alphabet.length)] ?? \"0\");\n }\n }\n return out.join(\"\");\n}\n","/**\n * Local cache of active entitlements so isEntitled() can answer\n * synchronously after the first read. Cache is updated:\n * - On successful getEntitlements()\n * - On successful purchase()\n * - Manually via setFromList() (used by callers that batch updates)\n *\n * The cache holds only ACTIVE entitlements — inactive ones are excluded\n * by the backend before they hit us. isEntitled returns false for\n * anything not in the set.\n *\n * Reactive listener API\n * ---------------------\n * `subscribe(listener)` registers a callback that fires every time the\n * cache mutates (setFromList or clear). This is the foundation for the\n * `useEntitlement` React hook in `@cross-deck/web/react` and any other\n * framework binding consumers need: SwiftUI's `@Observable`, Vue's\n * `ref()`, Solid's signals, etc.\n *\n * Why we need it: isEntitled() is a sync cache read — but if a React\n * component calls it in a render path, React has no way to know when\n * the cache populates asynchronously after `getEntitlements()` lands.\n * Without a subscribe API the component shows the empty-cache result\n * forever (until something else triggers a re-render). With it, the\n * binding can re-render when the data actually arrives.\n *\n * Listener semantics:\n * - Fired AFTER the cache has been mutated (listener sees fresh state)\n * - Fire-and-forget: thrown errors in a listener don't crash the SDK\n * (they're swallowed; the next listener still runs)\n * - The unsubscribe function returned from subscribe() is idempotent\n * - Listeners are NOT fired on subscribe — caller is expected to\n * read current state synchronously from isEntitled()/list() if it\n * wants the initial render to reflect cached data\n *\n * Thread / re-entrancy safety: this is a synchronous in-memory Set with\n * no I/O. The async paths that update it are serialised through the\n * SDK's request queue — callers won't see torn reads.\n */\n\nimport type { PublicEntitlement } from \"./types\";\n\nexport type EntitlementsListener = (entitlements: PublicEntitlement[]) => void;\n\nexport class EntitlementCache {\n private active = new Set<string>();\n private all: PublicEntitlement[] = [];\n private lastUpdated = 0;\n private listeners = new Set<EntitlementsListener>();\n\n /** Sync read — true iff the entitlement key is currently active. */\n isEntitled(key: string): boolean {\n return this.active.has(key);\n }\n\n /** Full snapshot for callers that need source / validUntil details. */\n list(): PublicEntitlement[] {\n return this.all.slice();\n }\n\n /** When the cache was last refreshed. 0 means \"never\". */\n get freshness(): number {\n return this.lastUpdated;\n }\n\n /**\n * Replace the cache with a fresh server response. The backend already\n * filters to active + env-matching, so we don't re-filter — just trust\n * what we got.\n *\n * Fires listeners AFTER the mutation so each listener sees the new state.\n */\n setFromList(entitlements: PublicEntitlement[]): void {\n this.all = entitlements.slice();\n this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));\n this.lastUpdated = Date.now();\n this.notify();\n }\n\n /**\n * Wipe — used on reset() (logout). The SDK forgets everything until\n * the next identify + read.\n *\n * Fires listeners so React/SwiftUI/etc bindings re-render to the\n * logged-out state immediately.\n */\n clear(): void {\n this.active.clear();\n this.all = [];\n this.lastUpdated = 0;\n this.notify();\n }\n\n /**\n * Subscribe to cache mutations. Returns an unsubscribe function.\n *\n * The listener is invoked AFTER setFromList() or clear() with the\n * current snapshot. Throwing inside a listener is non-fatal — the\n * error is swallowed and subsequent listeners still run.\n *\n * Used by `@cross-deck/web/react`'s `useEntitlement` hook to\n * trigger re-renders when entitlements change.\n */\n subscribe(listener: EntitlementsListener): () => void {\n this.listeners.add(listener);\n let unsubscribed = false;\n return () => {\n if (unsubscribed) return;\n unsubscribed = true;\n this.listeners.delete(listener);\n };\n }\n\n private notify(): void {\n if (this.listeners.size === 0) return;\n const snapshot = this.all.slice();\n // Iterate over a snapshot of the listener set so a listener that\n // unsubscribes itself (or registers a new one) during dispatch\n // doesn't break the iteration.\n const listenersSnapshot = [...this.listeners];\n for (const listener of listenersSnapshot) {\n try {\n listener(snapshot);\n } catch {\n // Swallow listener errors — a buggy consumer shouldn't break\n // the SDK or other listeners.\n }\n }\n }\n}\n","/**\n * Local event queue + batched flush.\n *\n * Why a queue: track() is called from hot paths (button clicks, screen\n * views) and shouldn't block the UI on a network round-trip. Events go\n * into a local buffer, flushed in bursts.\n *\n * Flush triggers:\n * - Buffer reaches batchSize (default 20) → flush immediately\n * - intervalMs of inactivity (default 5000) → flush idle batch\n * - flush() called explicitly (e.g. before page unload)\n *\n * On network failure, the events stay in the buffer for the next flush\n * — bounded retry that doesn't drop events when the network blips.\n *\n * The cap on buffer size (1000 events) protects against runaway memory\n * if the network is permanently down — beyond that we drop the oldest\n * event and increment a dropped counter (exposed via getStats()).\n */\n\nimport type { HttpClient } from \"./http\";\nimport type { EventProperties, IngestResponse } from \"./types\";\n\nconst HARD_BUFFER_CAP = 1000;\n\nexport interface QueuedEvent {\n eventId: string;\n name: string;\n timestamp: number;\n properties: EventProperties;\n // identity hint — exactly one will be set\n developerUserId?: string;\n anonymousId?: string;\n crossdeckCustomerId?: string;\n}\n\nexport interface BatchEnvelope {\n appId: string;\n environment: \"production\" | \"sandbox\";\n sdk: { name: string; version: string };\n}\n\nexport interface EventQueueConfig {\n http: HttpClient;\n batchSize: number;\n intervalMs: number;\n /**\n * Returns the NorthStar §13.1 envelope to attach to each batch POST.\n * It's a function (not a value) so a future call to setDebugMode or a\n * config swap can update the envelope without re-instantiating the\n * queue.\n */\n envelope: () => BatchEnvelope;\n /** Schedule a function to run after `ms` ms. Default: setTimeout. Override for tests. */\n scheduler?: (fn: () => void, ms: number) => () => void;\n /** Called when the SDK drops events because the buffer is full. */\n onDrop?: (dropped: number) => void;\n /** Called once after the first successful flush — drives the §16 \"First event sent\" signal. */\n onFirstFlushSuccess?: () => void;\n}\n\nexport interface EventQueueStats {\n buffered: number;\n dropped: number;\n inFlight: number;\n lastFlushAt: number;\n lastError: string | null;\n}\n\nexport class EventQueue {\n private buffer: QueuedEvent[] = [];\n private dropped = 0;\n private inFlight = 0;\n private lastFlushAt = 0;\n private lastError: string | null = null;\n private cancelTimer: (() => void) | null = null;\n private firstFlushFired = false;\n\n constructor(private readonly cfg: EventQueueConfig) {}\n\n enqueue(event: QueuedEvent): void {\n this.buffer.push(event);\n if (this.buffer.length > HARD_BUFFER_CAP) {\n const overflow = this.buffer.length - HARD_BUFFER_CAP;\n this.buffer.splice(0, overflow);\n this.dropped += overflow;\n this.cfg.onDrop?.(overflow);\n }\n if (this.buffer.length >= this.cfg.batchSize) {\n void this.flush();\n } else {\n this.scheduleIdleFlush();\n }\n }\n\n /**\n * Flush the buffer to /v1/events. Resolves when the network call\n * completes (success or failure). On failure, events stay in the\n * buffer for the next flush attempt.\n */\n async flush(): Promise<IngestResponse | null> {\n if (this.buffer.length === 0) return null;\n this.cancelTimerIfSet();\n\n // Capture the current buffer; replace with a new array so concurrent\n // enqueue() calls during the in-flight request don't get lost.\n const batch = this.buffer.splice(0);\n this.inFlight += batch.length;\n\n try {\n const env = this.cfg.envelope();\n const result = await this.cfg.http.request<IngestResponse>(\"POST\", \"/events\", {\n body: {\n // NorthStar §13.1 batch envelope. The backend validates these\n // against the API-key-resolved app and rejects mismatches loudly\n // (env_mismatch).\n appId: env.appId,\n environment: env.environment,\n sdk: env.sdk,\n events: batch,\n },\n });\n this.lastFlushAt = Date.now();\n this.lastError = null;\n this.inFlight -= batch.length;\n if (!this.firstFlushFired) {\n this.firstFlushFired = true;\n this.cfg.onFirstFlushSuccess?.();\n }\n return result;\n } catch (err) {\n // Re-buffer at the front of the queue. Order matters less than\n // not losing events — the backend will dedupe on eventId.\n this.buffer.unshift(...batch);\n this.inFlight -= batch.length;\n this.lastError = err instanceof Error ? err.message : String(err);\n // Schedule another idle flush so a transient outage recovers.\n this.scheduleIdleFlush();\n return null;\n }\n }\n\n /** Cancel any pending timer and clear in-memory state. */\n reset(): void {\n this.cancelTimerIfSet();\n this.buffer = [];\n this.dropped = 0;\n this.inFlight = 0;\n this.lastError = null;\n // Note: we deliberately do NOT reset firstFlushFired — the\n // \"First event sent\" signal is a one-time onboarding moment per\n // SDK instance lifetime, not per-identity.\n }\n\n getStats(): EventQueueStats {\n return {\n buffered: this.buffer.length,\n dropped: this.dropped,\n inFlight: this.inFlight,\n lastFlushAt: this.lastFlushAt,\n lastError: this.lastError,\n };\n }\n\n private scheduleIdleFlush(): void {\n this.cancelTimerIfSet();\n const sched = this.cfg.scheduler ?? defaultScheduler;\n this.cancelTimer = sched(() => {\n void this.flush();\n }, this.cfg.intervalMs);\n }\n\n private cancelTimerIfSet(): void {\n if (this.cancelTimer) {\n this.cancelTimer();\n this.cancelTimer = null;\n }\n }\n}\n\nfunction defaultScheduler(fn: () => void, ms: number): () => void {\n // Use unref()-style behaviour where supported so a pending flush doesn't\n // block Node from exiting. setTimeout in browsers ignores .unref() —\n // that's fine.\n const id = setTimeout(fn, ms);\n if (typeof (id as unknown as { unref?: () => void }).unref === \"function\") {\n try {\n (id as unknown as { unref: () => void }).unref();\n } catch {\n // ignore — unref is best-effort\n }\n }\n return () => clearTimeout(id);\n}\n","/**\n * Storage adapters for SDK-persisted state.\n *\n * Two flavours:\n * - browser localStorage (default in browsers)\n * - in-memory (default in Node, or as an explicit fallback)\n *\n * Detection is at construction time, not at every call — picking the\n * adapter once means we don't hit `typeof window` checks on hot paths.\n */\n\nimport type { KeyValueStorage } from \"./types\";\n\n/**\n * In-memory storage. Cleared on process exit. Useful for Node runtimes\n * where you want session-scoped identity that doesn't persist to disk.\n */\nexport class MemoryStorage implements KeyValueStorage {\n private store = new Map<string, string>();\n getItem(key: string): string | null {\n return this.store.get(key) ?? null;\n }\n setItem(key: string, value: string): void {\n this.store.set(key, value);\n }\n removeItem(key: string): void {\n this.store.delete(key);\n }\n}\n\n/**\n * Pick the best available storage. Browser → localStorage if accessible,\n * else MemoryStorage. Node → MemoryStorage. Caller can override via\n * Crossdeck.start({ storage: ... }) for custom adapters (RN AsyncStorage,\n * Cookies, encrypted vaults, etc.).\n *\n * We probe localStorage with a try/catch because some environments\n * (private mode Safari, embedded webviews) define `localStorage` but\n * throw on every call — falling back to memory keeps us correct.\n */\nexport function detectDefaultStorage(): KeyValueStorage {\n try {\n const ls = (globalThis as { localStorage?: KeyValueStorage }).localStorage;\n if (ls) {\n // Probe with a no-op write to confirm we can actually use it.\n const probe = \"__crossdeck_probe__\";\n ls.setItem(probe, \"1\");\n ls.removeItem(probe);\n return ls;\n }\n } catch {\n // Private mode / sandboxed iframe / quota exceeded — fall through.\n }\n return new MemoryStorage();\n}\n","/**\n * Device + environment enrichment.\n *\n * Auto-attached to every event the SDK emits when `autoTrack.deviceInfo` is\n * enabled (default). Caller-supplied event properties always override\n * auto-detected ones (so a developer can manually set `app.version` per\n * event if they want to A/B between builds).\n *\n * Privacy posture:\n * - No fingerprinting (no canvas hashes, no font enumeration).\n * - No precise geolocation (only timezone + locale, both of which the\n * browser exposes to every page anyway).\n * - No IP collection — the backend logs the request IP for rate-limit\n * purposes; it isn't stored on the event document.\n * - All fields are typed enums or short strings; we never echo back\n * full User-Agent strings to avoid surfacing fingerprintable detail\n * in dashboards.\n */\n\nexport interface DeviceInfo {\n os?: string;\n osVersion?: string;\n browser?: string;\n browserVersion?: string;\n locale?: string;\n timezone?: string;\n screenWidth?: number;\n screenHeight?: number;\n viewportWidth?: number;\n viewportHeight?: number;\n devicePixelRatio?: number;\n /** Caller-supplied. Set via Crossdeck.start({ appVersion: \"1.2.3\" }). */\n appVersion?: string;\n}\n\n/**\n * Are we in a browser context? Pure function; no side effects.\n *\n * Detects: globalThis.window AND globalThis.document AND globalThis.navigator.\n * All three must be present — Workers / Service Workers have window but\n * no document, and Node 18+ now has navigator but no window.\n */\nexport function isBrowser(): boolean {\n return (\n typeof (globalThis as { window?: unknown }).window !== \"undefined\" &&\n typeof (globalThis as { document?: unknown }).document !== \"undefined\" &&\n typeof (globalThis as { navigator?: unknown }).navigator !== \"undefined\"\n );\n}\n\n/**\n * Collect every safe-to-attach environment field. Returns an empty object\n * outside browsers (Node, Workers) — caller can pass appVersion via the\n * `extra` argument for non-browser runtimes.\n */\nexport function collectDeviceInfo(extra?: { appVersion?: string }): DeviceInfo {\n const info: DeviceInfo = {};\n if (extra?.appVersion) info.appVersion = extra.appVersion;\n\n if (!isBrowser()) return info;\n\n const w = (globalThis as { window: Window }).window;\n const nav = (globalThis as { navigator: Navigator }).navigator;\n const doc = (globalThis as { document: Document }).document;\n\n // ----- Locale + timezone -----\n try {\n if (typeof nav.language === \"string\") info.locale = nav.language;\n } catch {}\n try {\n info.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;\n } catch {}\n\n // ----- Screen + viewport -----\n try {\n if (w.screen) {\n info.screenWidth = w.screen.width;\n info.screenHeight = w.screen.height;\n }\n info.viewportWidth = w.innerWidth;\n info.viewportHeight = w.innerHeight;\n info.devicePixelRatio = w.devicePixelRatio;\n } catch {}\n\n // ----- Browser + OS from User-Agent -----\n try {\n const ua = nav.userAgent ?? \"\";\n const parsed = parseUserAgent(ua);\n Object.assign(info, parsed);\n } catch {}\n\n // ua-ch hints (Chromium browsers expose these properly without UA-string parsing)\n try {\n const uaData = (nav as Navigator & {\n userAgentData?: { platform?: string; brands?: Array<{ brand: string; version: string }> };\n }).userAgentData;\n if (uaData?.platform && !info.os) info.os = uaData.platform;\n if (uaData?.brands && !info.browser) {\n // Pick the most-specific non-\"Not.A;Brand\" entry\n const real = uaData.brands.find(\n (b) => !/Not[ .;A]*Brand/i.test(b.brand) && !/Chromium/i.test(b.brand),\n );\n if (real) {\n info.browser = real.brand;\n info.browserVersion = real.version;\n }\n }\n } catch {}\n\n // Suppress empties (a doc not yet hydrated could leave fields undefined)\n void doc; // referenced only for the isBrowser narrowing\n return info;\n}\n\n/**\n * Tiny User-Agent parser — extracts os, osVersion, browser, browserVersion.\n *\n * Doesn't try to be a full UA database (Bowser, ua-parser-js are large\n * deps). Covers ~95% of real-world traffic by recognising the major\n * browsers and operating systems. Unknown UAs fall through silently.\n *\n * Exported for unit testing.\n */\nexport function parseUserAgent(ua: string): Partial<DeviceInfo> {\n const out: Partial<DeviceInfo> = {};\n\n // ----- Operating system -----\n // Order matters: iPad/iPhone before Mac (iPadOS 13+ UAs claim \"Macintosh\"),\n // Android before Linux (Android UAs contain \"Linux\").\n if (/iPad|iPhone|iPod/.test(ua)) {\n out.os = \"iOS\";\n const m = ua.match(/OS (\\d+[._]\\d+(?:[._]\\d+)?)/);\n if (m?.[1]) out.osVersion = m[1].replace(/_/g, \".\");\n } else if (/Android/.test(ua)) {\n out.os = \"Android\";\n const m = ua.match(/Android (\\d+(?:\\.\\d+)*)/);\n if (m?.[1]) out.osVersion = m[1];\n } else if (/Windows/.test(ua)) {\n out.os = \"Windows\";\n const m = ua.match(/Windows NT (\\d+\\.\\d+)/);\n if (m?.[1]) out.osVersion = m[1];\n } else if (/Mac OS X|Macintosh/.test(ua)) {\n out.os = \"macOS\";\n const m = ua.match(/Mac OS X (\\d+[._]\\d+(?:[._]\\d+)?)/);\n if (m?.[1]) out.osVersion = m[1].replace(/_/g, \".\");\n } else if (/Linux/.test(ua)) {\n out.os = \"Linux\";\n }\n\n // ----- Browser -----\n // Order matters: Edge before Chrome (Edge UA contains \"Chrome\"),\n // Chrome before Safari (Chrome UA contains \"Safari\").\n if (/Edg\\/(\\d+(?:\\.\\d+)*)/.test(ua)) {\n out.browser = \"Edge\";\n out.browserVersion = ua.match(/Edg\\/(\\d+(?:\\.\\d+)*)/)?.[1];\n } else if (/Firefox\\/(\\d+(?:\\.\\d+)*)/.test(ua)) {\n out.browser = \"Firefox\";\n out.browserVersion = ua.match(/Firefox\\/(\\d+(?:\\.\\d+)*)/)?.[1];\n } else if (/OPR\\/(\\d+(?:\\.\\d+)*)/.test(ua)) {\n out.browser = \"Opera\";\n out.browserVersion = ua.match(/OPR\\/(\\d+(?:\\.\\d+)*)/)?.[1];\n } else if (/Chrome\\/(\\d+(?:\\.\\d+)*)/.test(ua)) {\n out.browser = \"Chrome\";\n out.browserVersion = ua.match(/Chrome\\/(\\d+(?:\\.\\d+)*)/)?.[1];\n } else if (/Version\\/(\\d+(?:\\.\\d+)*).*Safari/.test(ua)) {\n out.browser = \"Safari\";\n out.browserVersion = ua.match(/Version\\/(\\d+(?:\\.\\d+)*)/)?.[1];\n }\n\n return out;\n}\n","/**\n * Auto-tracking — sessions and SPA page views, emitted via the same\n * track() pipeline as developer-instrumented events. No-op outside\n * browsers (Node, Workers).\n *\n * Sessions:\n * - One sessionId per \"alive in foreground\" window.\n * - `session.started` fires on install (after start()).\n * - `session.ended` fires on visibilitychange→hidden, beforeunload,\n * and pagehide. Multiple end-triggers are deduplicated so we don't\n * emit two `session.ended` events for one tab close.\n * - `session.duration_ms` attached on end.\n * - If the tab returns to foreground after >30 minutes idle, the next\n * visibilitychange→visible mints a NEW sessionId — matches the\n * 30-min session-window convention used by GA4 / Mixpanel / etc.\n *\n * Page views:\n * - `page.viewed` fires on initial install.\n * - Hooks into `history.pushState` / `history.replaceState` (monkey-\n * patched in a non-destructive way so other libraries that hook\n * them still see their events) and `popstate` for SPA navigation.\n * - Properties: path, url, title, referrer, search, hash.\n *\n * Privacy: this module emits names + properties only. The Crossdeck\n * client adds device info on top via track(). Nothing here collects\n * PII beyond the URL itself, and we don't even log query strings\n * separately from the path (developer can post-process if needed).\n */\n\nimport { randomChars } from \"./identity\";\n\nexport interface AutoTrackConfig {\n sessions: boolean;\n pageViews: boolean;\n /** Whether to enrich every event with device info. Lives on the client, not here, but documented together. */\n deviceInfo: boolean;\n}\n\nexport const DEFAULT_AUTO_TRACK: AutoTrackConfig = {\n sessions: true,\n pageViews: true,\n deviceInfo: true,\n};\n\n/** Reopen as a new session if the tab was hidden longer than this. */\nconst SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1000;\n\ntype TrackFn = (name: string, properties?: Record<string, unknown>) => void;\n\ninterface SessionState {\n sessionId: string;\n startedAt: number;\n hiddenAt: number | null;\n endedSent: boolean;\n}\n\nexport class AutoTracker {\n private session: SessionState | null = null;\n private cleanups: Array<() => void> = [];\n\n constructor(\n private readonly cfg: AutoTrackConfig,\n private readonly track: TrackFn,\n ) {}\n\n install(): void {\n if (!isBrowserSafe()) return;\n if (this.cfg.sessions) this.installSessionTracking();\n if (this.cfg.pageViews) this.installPageViewTracking();\n }\n\n uninstall(): void {\n while (this.cleanups.length) {\n const fn = this.cleanups.pop();\n try { fn?.(); } catch { /* ignore */ }\n }\n if (this.session && !this.session.endedSent) {\n this.emitSessionEnd();\n }\n this.session = null;\n }\n\n /** Exposed for tests + consumers that want to reset the session manually. */\n resetSession(): void {\n if (this.session && !this.session.endedSent) this.emitSessionEnd();\n this.session = this.startNewSession();\n this.emitSessionStart();\n }\n\n /** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */\n get currentSessionId(): string | null {\n return this.session?.sessionId ?? null;\n }\n\n // ---------- sessions ----------\n private installSessionTracking(): void {\n this.session = this.startNewSession();\n this.emitSessionStart();\n\n const onVisChange = (): void => {\n if (!this.session) return;\n const doc = (globalThis as { document: Document }).document;\n if (doc.visibilityState === \"hidden\") {\n // Quick tab switches and Cmd-Tabs land here, but the page is\n // still alive. Record the time; do NOT emit session.ended yet.\n // pagehide / beforeunload are the canonical end signals\n // (mobile backgrounding fires pagehide reliably). If we ended\n // here, returning to the tab seconds later would always start\n // a new session — defeating the 30-min session-window intent.\n this.session.hiddenAt = Date.now();\n } else if (doc.visibilityState === \"visible\") {\n const hiddenFor = this.session.hiddenAt\n ? Date.now() - this.session.hiddenAt\n : 0;\n if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {\n // Long idle → end the previous session, start a fresh one.\n this.emitSessionEnd();\n this.session = this.startNewSession();\n this.emitSessionStart();\n } else {\n // Quick return — same session continues.\n this.session.hiddenAt = null;\n }\n }\n };\n\n const onPageHide = (): void => this.emitSessionEnd();\n\n const w = (globalThis as { window: Window }).window;\n const doc = (globalThis as { document: Document }).document;\n doc.addEventListener(\"visibilitychange\", onVisChange);\n w.addEventListener(\"pagehide\", onPageHide);\n // beforeunload is unreliable on mobile; pagehide is the modern equivalent.\n // We listen to both for desktop-vs-mobile coverage.\n w.addEventListener(\"beforeunload\", onPageHide);\n\n this.cleanups.push(() => {\n doc.removeEventListener(\"visibilitychange\", onVisChange);\n w.removeEventListener(\"pagehide\", onPageHide);\n w.removeEventListener(\"beforeunload\", onPageHide);\n });\n }\n\n private startNewSession(): SessionState {\n return {\n sessionId: mintSessionId(),\n startedAt: Date.now(),\n hiddenAt: null,\n endedSent: false,\n };\n }\n\n private emitSessionStart(): void {\n if (!this.session) return;\n this.track(\"session.started\", { sessionId: this.session.sessionId });\n }\n\n private emitSessionEnd(): void {\n if (!this.session || this.session.endedSent) return;\n const duration = Date.now() - this.session.startedAt;\n this.track(\"session.ended\", {\n sessionId: this.session.sessionId,\n durationMs: duration,\n });\n this.session.endedSent = true;\n }\n\n // ---------- page views ----------\n private installPageViewTracking(): void {\n const w = (globalThis as { window: Window }).window;\n const doc = (globalThis as { document: Document }).document;\n\n const fire = (): void => {\n const loc = w.location;\n this.track(\"page.viewed\", {\n path: loc.pathname,\n url: loc.href,\n search: loc.search || undefined,\n hash: loc.hash || undefined,\n title: doc.title,\n // referrer only on the first hit of the session — afterward it's\n // always our previous URL, which isn't useful.\n referrer: doc.referrer || undefined,\n });\n };\n\n // Initial page view\n fire();\n\n // SPA navigation: monkey-patch pushState / replaceState. Capture the\n // BARE function references (not bound) so uninstall restores exactly\n // what was there. Bind chains accumulate without limit if every\n // install/uninstall cycle wraps with .bind() — over many cycles\n // pushState becomes [bound bound bound … pushState] and tests that\n // assert \"pushState restored to its previous value\" break.\n //\n // We use `function (this: History, ...args)` so JS's normal method-call\n // semantics bind `this` to history when our wrapper is invoked as\n // history.pushState(...). Then we forward via .apply(this, args) — no\n // pre-binding needed, no chain growth.\n type HistoryFn = (data: unknown, unused: string, url?: string | null) => void;\n const origPush = w.history.pushState as HistoryFn;\n const origReplace = w.history.replaceState as HistoryFn;\n\n function patchedPush(this: History, data: unknown, unused: string, url?: string | null): void {\n origPush.apply(this, [data, unused, url]);\n queueMicrotask(fire);\n }\n function patchedReplace(this: History, data: unknown, unused: string, url?: string | null): void {\n origReplace.apply(this, [data, unused, url]);\n queueMicrotask(fire);\n }\n\n (w.history.pushState as HistoryFn) = patchedPush;\n (w.history.replaceState as HistoryFn) = patchedReplace;\n\n const onPopState = (): void => fire();\n w.addEventListener(\"popstate\", onPopState);\n\n this.cleanups.push(() => {\n // Only restore if WE'RE still the active wrapper. If another tracker\n // installed on top of ours, blindly setting pushState back would\n // unwind their patch too. Conservative: only restore our slot.\n if (w.history.pushState === patchedPush) {\n (w.history.pushState as HistoryFn) = origPush;\n }\n if (w.history.replaceState === patchedReplace) {\n (w.history.replaceState as HistoryFn) = origReplace;\n }\n w.removeEventListener(\"popstate\", onPopState);\n });\n }\n}\n\n/**\n * Browser detection identical to device-info.ts isBrowser. Inlined here\n * so this module has zero internal imports — easier to tree-shake\n * out of Node-only consumers, and the function body is trivial.\n */\nfunction isBrowserSafe(): boolean {\n return (\n typeof (globalThis as { window?: unknown }).window !== \"undefined\" &&\n typeof (globalThis as { document?: unknown }).document !== \"undefined\"\n );\n}\n\nfunction mintSessionId(): string {\n // Inline the same shape used elsewhere — `<prefix>_<base32-ts><10-char-rand>`.\n const ts = Date.now().toString(36);\n return `sess_${ts}${randomChars(10)}`;\n}\n","/**\n * Debug signal vocabulary per NorthStar §16.\n *\n * The SDK speaks a small fixed vocabulary of signals so the dashboard's\n * onboarding checklist can show \"we saw your first event\" without having\n * to parse free-form console output. When debug mode is enabled the\n * signals are also logged to the console so a developer doing\n * copy-paste integration sees actionable feedback live.\n *\n * Signal names are STABLE — adding new ones is fine, renaming is a\n * breaking change because the dashboard onboarding step keys off them.\n */\n\nexport type DebugSignal =\n | \"sdk.configured\"\n | \"sdk.first_event_sent\"\n | \"sdk.invalid_key\"\n | \"sdk.no_identity\"\n | \"sdk.entitlement_cache_used\"\n | \"sdk.purchase_evidence_sent\"\n | \"sdk.environment_mismatch\"\n | \"sdk.sensitive_property_warning\";\n\nexport interface DebugContext {\n /** Free-form details surfaced under the signal — appId, key prefix, etc. */\n [key: string]: unknown;\n}\n\n/**\n * Names that almost always indicate PII or secret data. Used by track()\n * to warn the developer when a property key looks dangerous. Per\n * NorthStar §15 these are reject/warn-on-sight values; we warn rather\n * than reject because the developer might genuinely want a property\n * called e.g. \"tokens_remaining\".\n */\nconst SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [\n /^email$/i,\n /^password$/i,\n /^token$/i,\n /^secret$/i,\n /^card$/i,\n /^phone$/i,\n /password/i,\n /credit_?card/i,\n];\n\nexport function findSensitivePropertyKeys(\n properties: Record<string, unknown> | undefined,\n): string[] {\n if (!properties) return [];\n const hits: string[] = [];\n for (const k of Object.keys(properties)) {\n if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(k))) hits.push(k);\n }\n return hits;\n}\n\nexport interface DebugLogger {\n enabled: boolean;\n emit(signal: DebugSignal, message: string, context?: DebugContext): void;\n}\n\nexport class ConsoleDebugLogger implements DebugLogger {\n enabled = false;\n private seen = new Set<DebugSignal>();\n\n emit(signal: DebugSignal, message: string, context?: DebugContext): void {\n if (!this.enabled) return;\n // For one-shot signals (sdk.configured, sdk.first_event_sent,\n // sdk.environment_mismatch) suppress duplicates within a session\n // so a chatty app doesn't spam the console with the same message.\n if (ONCE_SIGNALS.has(signal)) {\n if (this.seen.has(signal)) return;\n this.seen.add(signal);\n }\n const ctx = context ? ` ${safeJson(context)}` : \"\";\n // eslint-disable-next-line no-console\n console.info(`[crossdeck:${signal}] ${message}${ctx}`);\n }\n}\n\nconst ONCE_SIGNALS = new Set<DebugSignal>([\n \"sdk.configured\",\n \"sdk.first_event_sent\",\n \"sdk.environment_mismatch\",\n]);\n\nfunction safeJson(obj: unknown): string {\n try {\n return JSON.stringify(obj);\n } catch {\n return \"[unserialisable context]\";\n }\n}\n","/**\n * Public API surface for @cross-deck/web.\n *\n * Usage (browser):\n *\n * import { Crossdeck } from \"@cross-deck/web\";\n *\n * Crossdeck.init({\n * appId: \"app_web_xxx\",\n * publicKey: \"cd_pub_live_…\",\n * environment: \"production\",\n * });\n *\n * await Crossdeck.identify(\"user_847\");\n * const ents = await Crossdeck.getEntitlements();\n * if (Crossdeck.isEntitled(\"pro\")) {\n * showPro();\n * }\n * Crossdeck.track(\"paywall_shown\", { variant: \"v3\" });\n *\n *\n * Usage (Node):\n *\n * import { Crossdeck, MemoryStorage } from \"@cross-deck/web\";\n *\n * Crossdeck.init({\n * appId: \"app_node_xxx\",\n * publicKey: \"cd_pub_test_…\",\n * environment: \"sandbox\",\n * storage: new MemoryStorage(), // session-only persistence\n * autoHeartbeat: false, // skip the boot ping in scripts\n * });\n */\n\nimport { CrossdeckError } from \"./errors\";\nimport { HttpClient, SDK_NAME, SDK_VERSION, DEFAULT_BASE_URL } from \"./http\";\nimport { IdentityStore } from \"./identity\";\nimport { EntitlementCache, type EntitlementsListener } from \"./entitlement-cache\";\nimport { EventQueue, type QueuedEvent } from \"./event-queue\";\nimport { detectDefaultStorage, MemoryStorage } from \"./storage\";\nimport { randomChars } from \"./identity\";\nimport { collectDeviceInfo, type DeviceInfo } from \"./device-info\";\nimport { AutoTracker, DEFAULT_AUTO_TRACK, type AutoTrackConfig } from \"./auto-track\";\nimport { ConsoleDebugLogger, findSensitivePropertyKeys, type DebugLogger } from \"./debug\";\nimport type {\n AliasResult,\n AutoTrackOptions,\n CrossdeckOptions,\n Diagnostics,\n EntitlementsListResponse,\n Environment,\n EventProperties,\n HeartbeatResponse,\n IdentifyOptions,\n PublicEntitlement,\n PurchaseResult,\n} from \"./types\";\n\ninterface InternalState {\n http: HttpClient;\n identity: IdentityStore;\n entitlements: EntitlementCache;\n events: EventQueue;\n autoTracker: AutoTracker | null;\n /** Cached enrichment payload merged into every event's properties. */\n deviceInfo: DeviceInfo;\n options: Required<\n Omit<\n CrossdeckOptions,\n \"storage\" | \"sdkVersion\" | \"autoTrack\" | \"appVersion\" | \"debug\"\n >\n > & {\n sdkVersion: string;\n autoTrack: AutoTrackConfig;\n appVersion: string | null;\n };\n debug: DebugLogger;\n developerUserId: string | null;\n}\n\nexport class CrossdeckClient {\n private state: InternalState | null = null;\n\n /**\n * Boot the SDK. Idempotent — calling init twice with the same options\n * is a no-op; calling with different options replaces the previous\n * configuration.\n *\n * NorthStar §11.1: signature is `Crossdeck.init({ appId, publicKey,\n * environment })`. The trio is validated up-front so a typo'd key or a\n * mismatched env fails fast at boot rather than at first event-flush.\n */\n init(options: CrossdeckOptions): void {\n if (!options.publicKey || !options.publicKey.startsWith(\"cd_pub_\")) {\n throw new CrossdeckError({\n type: \"configuration_error\",\n code: \"invalid_public_key\",\n message: \"Crossdeck.init requires a publishable key starting with cd_pub_.\",\n });\n }\n if (!options.appId) {\n throw new CrossdeckError({\n type: \"configuration_error\",\n code: \"missing_app_id\",\n message: \"Crossdeck.init requires an appId. Find yours in the Crossdeck dashboard.\",\n });\n }\n if (options.environment !== \"production\" && options.environment !== \"sandbox\") {\n throw new CrossdeckError({\n type: \"configuration_error\",\n code: \"invalid_environment\",\n message: 'Crossdeck.init requires environment: \"production\" | \"sandbox\".',\n });\n }\n // Key prefix must match the declared environment, otherwise prod\n // telemetry could silently route into sandbox dashboards (or vice\n // versa). NorthStar §15 calls this out as a \"fail loudly\" condition.\n const keyEnv = inferEnvFromKey(options.publicKey);\n if (keyEnv && keyEnv !== options.environment) {\n throw new CrossdeckError({\n type: \"configuration_error\",\n code: \"environment_mismatch\",\n message: `Crossdeck.init: environment \"${options.environment}\" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`,\n });\n }\n\n const storage = options.storage ?? detectDefaultStorage();\n const persistIdentity = options.persistIdentity ?? true;\n const autoTrack = resolveAutoTrack(options.autoTrack);\n const opts: InternalState[\"options\"] = {\n appId: options.appId,\n publicKey: options.publicKey,\n environment: options.environment,\n baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,\n persistIdentity,\n storagePrefix: options.storagePrefix ?? \"crossdeck:\",\n autoHeartbeat: options.autoHeartbeat ?? true,\n eventFlushBatchSize: options.eventFlushBatchSize ?? 20,\n eventFlushIntervalMs: options.eventFlushIntervalMs ?? 5000,\n sdkVersion: options.sdkVersion ?? SDK_VERSION,\n autoTrack,\n appVersion: options.appVersion ?? null,\n };\n\n const debug = new ConsoleDebugLogger();\n debug.enabled = options.debug === true;\n\n const http = new HttpClient({\n publicKey: opts.publicKey,\n baseUrl: opts.baseUrl,\n sdkVersion: opts.sdkVersion,\n });\n const effectiveStorage = persistIdentity ? storage : new MemoryStorage();\n const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);\n const entitlements = new EntitlementCache();\n const events = new EventQueue({\n http,\n batchSize: opts.eventFlushBatchSize,\n intervalMs: opts.eventFlushIntervalMs,\n envelope: () => ({\n appId: opts.appId,\n environment: opts.environment,\n sdk: { name: SDK_NAME, version: opts.sdkVersion },\n }),\n onFirstFlushSuccess: () => {\n debug.emit(\n \"sdk.first_event_sent\",\n \"First telemetry event received. View it in Live Events.\",\n { appId: opts.appId, environment: opts.environment },\n );\n },\n });\n\n // Collect device info ONCE at boot; cheap to re-use on every event.\n const deviceInfo: DeviceInfo = autoTrack.deviceInfo\n ? collectDeviceInfo({ appVersion: opts.appVersion ?? undefined })\n : opts.appVersion\n ? { appVersion: opts.appVersion }\n : {};\n\n this.state = {\n http,\n identity,\n entitlements,\n events,\n autoTracker: null,\n deviceInfo,\n options: opts,\n debug,\n developerUserId: null,\n };\n\n debug.emit(\"sdk.configured\", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {\n appId: opts.appId,\n environment: opts.environment,\n sdkVersion: opts.sdkVersion,\n });\n\n // Auto-tracker boots AFTER state is set so its initial track() calls\n // can resolve identity hints and device-info enrichment correctly.\n if (autoTrack.sessions || autoTrack.pageViews) {\n const tracker = new AutoTracker(autoTrack, (name, properties) =>\n this.track(name, properties),\n );\n this.state.autoTracker = tracker;\n tracker.install();\n }\n\n if (opts.autoHeartbeat) {\n // Fire-and-forget — heartbeat failure shouldn't block init().\n void this.heartbeat().catch(() => undefined);\n }\n }\n\n /**\n * @deprecated Use `init()` instead. NorthStar §4 standardised the\n * lifecycle method name across SDKs as `init` (formerly `start` /\n * `configure`). `start` will be removed in a future major version.\n */\n start(options: CrossdeckOptions): void {\n if (typeof console !== \"undefined\") {\n // eslint-disable-next-line no-console\n console.warn(\n \"[crossdeck] Crossdeck.start() is deprecated — use Crossdeck.init() instead. The signature is the same.\",\n );\n }\n this.init(options);\n }\n\n /**\n * Link the anonymous device to a developer-supplied user ID. Cache\n * the resolved Crossdeck customer for follow-up calls.\n */\n async identify(userId: string, _options?: IdentifyOptions): Promise<AliasResult> {\n const s = this.requireStarted();\n if (!userId) {\n throw new CrossdeckError({\n type: \"invalid_request_error\",\n code: \"missing_user_id\",\n message: \"identify(userId) requires a non-empty userId.\",\n });\n }\n const result = await s.http.request<AliasResult>(\"POST\", \"/identity/alias\", {\n body: { userId, anonymousId: s.identity.anonymousId },\n });\n s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);\n s.developerUserId = userId;\n return result;\n }\n\n /**\n * Read the current customer's active entitlements from the server.\n * Updates the local cache so subsequent isEntitled() calls answer\n * synchronously.\n */\n async getEntitlements(): Promise<PublicEntitlement[]> {\n const s = this.requireStarted();\n const query = this.identityQueryParams();\n const result = await s.http.request<EntitlementsListResponse>(\n \"GET\",\n \"/entitlements\",\n { query }\n );\n if (result.crossdeckCustomerId) {\n s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);\n }\n s.entitlements.setFromList(result.data);\n return result.data;\n }\n\n /**\n * Synchronous read from the local cache. Returns false if the cache\n * has never been populated (call getEntitlements first to warm it).\n */\n isEntitled(key: string): boolean {\n const s = this.requireStarted();\n return s.entitlements.isEntitled(key);\n }\n\n /** Snapshot of the local entitlement cache. */\n listEntitlements(): PublicEntitlement[] {\n const s = this.requireStarted();\n return s.entitlements.list();\n }\n\n /**\n * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.\n *\n * The listener is invoked AFTER the cache mutates — once after a\n * successful `getEntitlements()` warms it, again after `syncPurchases()`\n * delivers fresh entitlements, and once on `reset()` to fire the\n * empty-cache state for logout flows.\n *\n * It is NOT invoked synchronously on subscribe. Callers that need\n * the current state should read it via `isEntitled()` / `listEntitlements()`\n * inline; the listener fires only on FUTURE changes.\n *\n * This is the foundation of the `useEntitlement` React hook in\n * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose\n * / Vue) would have no way to re-render when entitlements arrive\n * asynchronously after init. The naive pattern of calling\n * `Crossdeck.isEntitled(\"pro\")` directly inside a render path\n * shows the empty-cache result forever; binding the result to\n * component state via `onEntitlementsChange` is the correct\n * pattern.\n *\n * Idempotent unsubscribe — calling the returned function multiple\n * times is safe.\n *\n * Listener errors are swallowed (a buggy listener can't crash the\n * SDK or other listeners).\n */\n onEntitlementsChange(listener: EntitlementsListener): () => void {\n const s = this.requireStarted();\n return s.entitlements.subscribe(listener);\n }\n\n /**\n * Queue a telemetry event. Returns immediately — the network round-\n * trip happens in the background. To flush before the page unloads,\n * call flush().\n */\n track(name: string, properties?: EventProperties): void {\n const s = this.requireStarted();\n if (!name) {\n throw new CrossdeckError({\n type: \"invalid_request_error\",\n code: \"missing_event_name\",\n message: \"track(name) requires a non-empty name.\",\n });\n }\n\n // NorthStar §15: warn (in debug mode) when a property name looks\n // dangerously like PII — email/password/token/secret/card/phone.\n // We don't strip the field; that's the developer's call. We just\n // surface the signal so they can spot accidental leaks early.\n if (s.debug.enabled && properties) {\n const flagged = findSensitivePropertyKeys(properties);\n if (flagged.length > 0) {\n s.debug.emit(\n \"sdk.sensitive_property_warning\",\n `Event \"${name}\" has potentially sensitive property names: ${flagged.join(\", \")}. Crossdeck is privacy-first — avoid sending PII unless intentional.`,\n { eventName: name, flagged },\n );\n }\n }\n\n // §16 \"No identity\" — only emit once per session so a chatty client\n // doesn't spam the log with every track() before identify().\n if (s.debug.enabled && !s.developerUserId && !s.identity.crossdeckCustomerId) {\n s.debug.emit(\n \"sdk.no_identity\",\n \"Using anonymous user until identify(userId) is called.\",\n );\n }\n\n // Enrichment policy: device info first, then auto-tracker context (e.g.\n // sessionId on session events), then caller-supplied properties last so\n // a developer can override anything the SDK auto-attached.\n const enriched: EventProperties = { ...s.deviceInfo };\n const sessionId = s.autoTracker?.currentSessionId;\n if (sessionId) enriched.sessionId = sessionId;\n if (properties) Object.assign(enriched, properties);\n\n const event: QueuedEvent = {\n eventId: this.mintEventId(),\n name,\n timestamp: Date.now(),\n properties: enriched,\n };\n Object.assign(event, this.identityHintForEvent());\n s.events.enqueue(event);\n }\n\n /**\n * Force-flush queued events. Useful to call from page-unload handlers.\n *\n * NorthStar §4: standard method name across all Crossdeck SDKs.\n */\n async flush(): Promise<void> {\n const s = this.requireStarted();\n await s.events.flush();\n }\n\n /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */\n async flushEvents(): Promise<void> {\n return this.flush();\n }\n\n /**\n * Forward purchase evidence to the backend for verification + entitlement\n * projection. NorthStar §4 + §13 canonical name.\n *\n * Today the web SDK only supports Apple StoreKit 2 forwarding (web apps\n * that sit alongside an iOS app). Stripe doesn't need this method —\n * Stripe webhooks deliver evidence server-side without a client round-trip.\n */\n async syncPurchases(input: {\n rail?: \"apple\";\n signedTransactionInfo: string;\n signedRenewalInfo?: string;\n appAccountToken?: string;\n }): Promise<PurchaseResult> {\n const s = this.requireStarted();\n if (!input.signedTransactionInfo) {\n throw new CrossdeckError({\n type: \"invalid_request_error\",\n code: \"missing_signed_transaction_info\",\n message: \"syncPurchases requires a signedTransactionInfo string from StoreKit 2.\",\n });\n }\n const result = await s.http.request<PurchaseResult>(\"POST\", \"/purchases/sync\", {\n body: { rail: input.rail ?? \"apple\", ...input },\n });\n s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);\n s.entitlements.setFromList(result.entitlements);\n s.debug.emit(\n \"sdk.purchase_evidence_sent\",\n \"StoreKit transaction forwarded. Waiting for backend verification.\",\n { rail: input.rail ?? \"apple\" },\n );\n return result;\n }\n\n /** @deprecated Use `syncPurchases()` instead. NorthStar §4 standardised the name. */\n async purchaseApple(input: {\n signedTransactionInfo: string;\n signedRenewalInfo?: string;\n appAccountToken?: string;\n }): Promise<PurchaseResult> {\n return this.syncPurchases({ rail: \"apple\", ...input });\n }\n\n /**\n * Toggle verbose diagnostic logging — NorthStar §16. When enabled, the\n * SDK emits a fixed vocabulary of debug signals to console.info that the\n * dashboard's onboarding checklist can also surface as live events.\n */\n setDebugMode(enabled: boolean): void {\n const s = this.requireStarted();\n s.debug.enabled = enabled;\n if (enabled) {\n s.debug.emit(\n \"sdk.configured\",\n `Debug mode enabled for ${s.options.appId} in ${s.options.environment} mode.`,\n { appId: s.options.appId, environment: s.options.environment },\n );\n }\n }\n\n /**\n * Send the boot heartbeat. Called automatically by start() unless\n * autoHeartbeat:false. Safe to call manually as a \"we're still here\" ping.\n */\n async heartbeat(): Promise<HeartbeatResponse> {\n const s = this.requireStarted();\n return await s.http.request<HeartbeatResponse>(\"GET\", \"/sdk/heartbeat\");\n }\n\n /**\n * Wipe persisted identity + entitlement cache. Use on logout. The\n * next pre-login session generates a fresh anonymousId and starts a\n * new identity-graph entry.\n */\n reset(): void {\n if (!this.state) return;\n // Tear down + reinstall the auto-tracker so the new session belongs\n // to the new identity, not the old one.\n this.state.autoTracker?.uninstall();\n this.state.identity.reset();\n this.state.entitlements.clear();\n this.state.events.reset();\n this.state.developerUserId = null;\n if (this.state.autoTracker) {\n const tracker = new AutoTracker(this.state.options.autoTrack, (name, props) =>\n this.track(name, props),\n );\n this.state.autoTracker = tracker;\n tracker.install();\n }\n }\n\n /**\n * Diagnostic: current state + queue stats. Useful for the dashboard's\n * heartbeat row and debugging in dev.\n *\n * Returns a stable shape regardless of whether start() has been called —\n * callers don't need to narrow on `started` to access `events` or\n * `entitlements`. Pre-start values are sensible empties.\n */\n diagnostics(): Diagnostics {\n if (!this.state) {\n return {\n started: false,\n anonymousId: null,\n crossdeckCustomerId: null,\n developerUserId: null,\n sdkVersion: null,\n baseUrl: null,\n entitlements: { count: 0, lastUpdated: 0 },\n events: {\n buffered: 0,\n dropped: 0,\n inFlight: 0,\n lastFlushAt: 0,\n lastError: null,\n },\n };\n }\n const s = this.state;\n return {\n started: true,\n anonymousId: s.identity.anonymousId,\n crossdeckCustomerId: s.identity.crossdeckCustomerId,\n developerUserId: s.developerUserId,\n sdkVersion: s.options.sdkVersion,\n baseUrl: s.options.baseUrl,\n entitlements: {\n count: s.entitlements.list().length,\n lastUpdated: s.entitlements.freshness,\n },\n events: s.events.getStats(),\n };\n }\n\n // ---------- private helpers ----------\n\n private requireStarted(): InternalState {\n if (!this.state) {\n throw new CrossdeckError({\n type: \"configuration_error\",\n code: \"not_initialized\",\n message:\n \"Call Crossdeck.init({ appId, publicKey, environment }) before any other method.\",\n });\n }\n return this.state;\n }\n\n /**\n * Build the identity query for /v1/entitlements. Priority:\n * crossdeckCustomerId > developerUserId > anonymousId\n * — matches the resolveCrossdeckCustomerId precedence on the server.\n */\n private identityQueryParams(): Record<string, string | undefined> {\n const s = this.requireStarted();\n if (s.identity.crossdeckCustomerId) {\n return { customerId: s.identity.crossdeckCustomerId };\n }\n if (s.developerUserId) return { userId: s.developerUserId };\n return { anonymousId: s.identity.anonymousId };\n }\n\n /** Pick the right identity hint to embed on a queued event. */\n private identityHintForEvent(): Pick<\n QueuedEvent,\n \"developerUserId\" | \"anonymousId\" | \"crossdeckCustomerId\"\n > {\n const s = this.requireStarted();\n if (s.identity.crossdeckCustomerId) {\n return { crossdeckCustomerId: s.identity.crossdeckCustomerId };\n }\n if (s.developerUserId) return { developerUserId: s.developerUserId };\n return { anonymousId: s.identity.anonymousId };\n }\n\n private mintEventId(): string {\n const ts = Date.now().toString(36);\n return `evt_${ts}${randomChars(8)}`;\n }\n}\n\n/**\n * Default singleton — most consumers want one SDK instance per app.\n * Creating extra instances is fine; just `new CrossdeckClient()`.\n */\nexport const Crossdeck = new CrossdeckClient();\n\n/**\n * Normalise the autoTrack option to a fully-resolved AutoTrackConfig.\n * undefined → all defaults (everything on in browsers)\n * true → all on (same as defaults)\n * false → all off\n * { sessions:false } → defaults for unspecified flags, override for specified ones\n */\n/**\n * Derive the env from a publishable key prefix.\n * cd_pub_test_… → \"sandbox\"\n * cd_pub_live_… → \"production\"\n * cd_pub_… → null (legacy / unprefixed — env can't be inferred)\n *\n * We treat the legacy form as \"no opinion\" so the developer's explicit\n * `environment` declaration always wins for unprefixed keys (e.g. dev\n * fixture keys in tests).\n */\nfunction inferEnvFromKey(publicKey: string): Environment | null {\n if (publicKey.startsWith(\"cd_pub_test_\")) return \"sandbox\";\n if (publicKey.startsWith(\"cd_pub_live_\")) return \"production\";\n return null;\n}\n\nfunction resolveAutoTrack(\n input: CrossdeckOptions[\"autoTrack\"],\n): AutoTrackConfig {\n if (input === false) {\n return { sessions: false, pageViews: false, deviceInfo: false };\n }\n if (input === undefined || input === true) {\n return { ...DEFAULT_AUTO_TRACK };\n }\n return {\n sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,\n pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,\n deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,\n };\n}\n"],"mappings":"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","names":["doc"]}
package/dist/index.d.mts CHANGED
@@ -188,6 +188,48 @@ interface Diagnostics {
188
188
  };
189
189
  }
190
190
 
191
+ /**
192
+ * Local cache of active entitlements so isEntitled() can answer
193
+ * synchronously after the first read. Cache is updated:
194
+ * - On successful getEntitlements()
195
+ * - On successful purchase()
196
+ * - Manually via setFromList() (used by callers that batch updates)
197
+ *
198
+ * The cache holds only ACTIVE entitlements — inactive ones are excluded
199
+ * by the backend before they hit us. isEntitled returns false for
200
+ * anything not in the set.
201
+ *
202
+ * Reactive listener API
203
+ * ---------------------
204
+ * `subscribe(listener)` registers a callback that fires every time the
205
+ * cache mutates (setFromList or clear). This is the foundation for the
206
+ * `useEntitlement` React hook in `@cross-deck/web/react` and any other
207
+ * framework binding consumers need: SwiftUI's `@Observable`, Vue's
208
+ * `ref()`, Solid's signals, etc.
209
+ *
210
+ * Why we need it: isEntitled() is a sync cache read — but if a React
211
+ * component calls it in a render path, React has no way to know when
212
+ * the cache populates asynchronously after `getEntitlements()` lands.
213
+ * Without a subscribe API the component shows the empty-cache result
214
+ * forever (until something else triggers a re-render). With it, the
215
+ * binding can re-render when the data actually arrives.
216
+ *
217
+ * Listener semantics:
218
+ * - Fired AFTER the cache has been mutated (listener sees fresh state)
219
+ * - Fire-and-forget: thrown errors in a listener don't crash the SDK
220
+ * (they're swallowed; the next listener still runs)
221
+ * - The unsubscribe function returned from subscribe() is idempotent
222
+ * - Listeners are NOT fired on subscribe — caller is expected to
223
+ * read current state synchronously from isEntitled()/list() if it
224
+ * wants the initial render to reflect cached data
225
+ *
226
+ * Thread / re-entrancy safety: this is a synchronous in-memory Set with
227
+ * no I/O. The async paths that update it are serialised through the
228
+ * SDK's request queue — callers won't see torn reads.
229
+ */
230
+
231
+ type EntitlementsListener = (entitlements: PublicEntitlement[]) => void;
232
+
191
233
  /**
192
234
  * Public API surface for @cross-deck/web.
193
235
  *
@@ -258,6 +300,34 @@ declare class CrossdeckClient {
258
300
  isEntitled(key: string): boolean;
259
301
  /** Snapshot of the local entitlement cache. */
260
302
  listEntitlements(): PublicEntitlement[];
303
+ /**
304
+ * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
305
+ *
306
+ * The listener is invoked AFTER the cache mutates — once after a
307
+ * successful `getEntitlements()` warms it, again after `syncPurchases()`
308
+ * delivers fresh entitlements, and once on `reset()` to fire the
309
+ * empty-cache state for logout flows.
310
+ *
311
+ * It is NOT invoked synchronously on subscribe. Callers that need
312
+ * the current state should read it via `isEntitled()` / `listEntitlements()`
313
+ * inline; the listener fires only on FUTURE changes.
314
+ *
315
+ * This is the foundation of the `useEntitlement` React hook in
316
+ * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
317
+ * / Vue) would have no way to re-render when entitlements arrive
318
+ * asynchronously after init. The naive pattern of calling
319
+ * `Crossdeck.isEntitled("pro")` directly inside a render path
320
+ * shows the empty-cache result forever; binding the result to
321
+ * component state via `onEntitlementsChange` is the correct
322
+ * pattern.
323
+ *
324
+ * Idempotent unsubscribe — calling the returned function multiple
325
+ * times is safe.
326
+ *
327
+ * Listener errors are swallowed (a buggy listener can't crash the
328
+ * SDK or other listeners).
329
+ */
330
+ onEntitlementsChange(listener: EntitlementsListener): () => void;
261
331
  /**
262
332
  * Queue a telemetry event. Returns immediately — the network round-
263
333
  * trip happens in the background. To flush before the page unloads,
package/dist/index.d.ts CHANGED
@@ -188,6 +188,48 @@ interface Diagnostics {
188
188
  };
189
189
  }
190
190
 
191
+ /**
192
+ * Local cache of active entitlements so isEntitled() can answer
193
+ * synchronously after the first read. Cache is updated:
194
+ * - On successful getEntitlements()
195
+ * - On successful purchase()
196
+ * - Manually via setFromList() (used by callers that batch updates)
197
+ *
198
+ * The cache holds only ACTIVE entitlements — inactive ones are excluded
199
+ * by the backend before they hit us. isEntitled returns false for
200
+ * anything not in the set.
201
+ *
202
+ * Reactive listener API
203
+ * ---------------------
204
+ * `subscribe(listener)` registers a callback that fires every time the
205
+ * cache mutates (setFromList or clear). This is the foundation for the
206
+ * `useEntitlement` React hook in `@cross-deck/web/react` and any other
207
+ * framework binding consumers need: SwiftUI's `@Observable`, Vue's
208
+ * `ref()`, Solid's signals, etc.
209
+ *
210
+ * Why we need it: isEntitled() is a sync cache read — but if a React
211
+ * component calls it in a render path, React has no way to know when
212
+ * the cache populates asynchronously after `getEntitlements()` lands.
213
+ * Without a subscribe API the component shows the empty-cache result
214
+ * forever (until something else triggers a re-render). With it, the
215
+ * binding can re-render when the data actually arrives.
216
+ *
217
+ * Listener semantics:
218
+ * - Fired AFTER the cache has been mutated (listener sees fresh state)
219
+ * - Fire-and-forget: thrown errors in a listener don't crash the SDK
220
+ * (they're swallowed; the next listener still runs)
221
+ * - The unsubscribe function returned from subscribe() is idempotent
222
+ * - Listeners are NOT fired on subscribe — caller is expected to
223
+ * read current state synchronously from isEntitled()/list() if it
224
+ * wants the initial render to reflect cached data
225
+ *
226
+ * Thread / re-entrancy safety: this is a synchronous in-memory Set with
227
+ * no I/O. The async paths that update it are serialised through the
228
+ * SDK's request queue — callers won't see torn reads.
229
+ */
230
+
231
+ type EntitlementsListener = (entitlements: PublicEntitlement[]) => void;
232
+
191
233
  /**
192
234
  * Public API surface for @cross-deck/web.
193
235
  *
@@ -258,6 +300,34 @@ declare class CrossdeckClient {
258
300
  isEntitled(key: string): boolean;
259
301
  /** Snapshot of the local entitlement cache. */
260
302
  listEntitlements(): PublicEntitlement[];
303
+ /**
304
+ * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
305
+ *
306
+ * The listener is invoked AFTER the cache mutates — once after a
307
+ * successful `getEntitlements()` warms it, again after `syncPurchases()`
308
+ * delivers fresh entitlements, and once on `reset()` to fire the
309
+ * empty-cache state for logout flows.
310
+ *
311
+ * It is NOT invoked synchronously on subscribe. Callers that need
312
+ * the current state should read it via `isEntitled()` / `listEntitlements()`
313
+ * inline; the listener fires only on FUTURE changes.
314
+ *
315
+ * This is the foundation of the `useEntitlement` React hook in
316
+ * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
317
+ * / Vue) would have no way to re-render when entitlements arrive
318
+ * asynchronously after init. The naive pattern of calling
319
+ * `Crossdeck.isEntitled("pro")` directly inside a render path
320
+ * shows the empty-cache result forever; binding the result to
321
+ * component state via `onEntitlementsChange` is the correct
322
+ * pattern.
323
+ *
324
+ * Idempotent unsubscribe — calling the returned function multiple
325
+ * times is safe.
326
+ *
327
+ * Listener errors are swallowed (a buggy listener can't crash the
328
+ * SDK or other listeners).
329
+ */
330
+ onEntitlementsChange(listener: EntitlementsListener): () => void;
261
331
  /**
262
332
  * Queue a telemetry event. Returns immediately — the network round-
263
333
  * trip happens in the background. To flush before the page unloads,