@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/dist/react.mjs ADDED
@@ -0,0 +1,1200 @@
1
+ // src/react.ts
2
+ import { useEffect, useState } from "react";
3
+
4
+ // src/errors.ts
5
+ var CrossdeckError = class _CrossdeckError extends Error {
6
+ constructor(payload) {
7
+ super(payload.message);
8
+ this.name = "CrossdeckError";
9
+ this.type = payload.type;
10
+ this.code = payload.code;
11
+ this.requestId = payload.requestId;
12
+ this.status = payload.status;
13
+ Object.setPrototypeOf(this, _CrossdeckError.prototype);
14
+ }
15
+ };
16
+ async function crossdeckErrorFromResponse(res) {
17
+ const requestId = res.headers.get("x-request-id") ?? void 0;
18
+ let body;
19
+ try {
20
+ body = await res.json();
21
+ } catch {
22
+ body = null;
23
+ }
24
+ const envelope = body?.error;
25
+ if (envelope && typeof envelope.type === "string" && typeof envelope.code === "string") {
26
+ return new CrossdeckError({
27
+ type: envelope.type,
28
+ code: envelope.code,
29
+ message: envelope.message ?? `HTTP ${res.status}`,
30
+ requestId: envelope.request_id ?? requestId,
31
+ status: res.status
32
+ });
33
+ }
34
+ return new CrossdeckError({
35
+ type: typeMapForStatus(res.status),
36
+ code: `http_${res.status}`,
37
+ message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
38
+ requestId,
39
+ status: res.status
40
+ });
41
+ }
42
+ function typeMapForStatus(status) {
43
+ if (status === 401) return "authentication_error";
44
+ if (status === 403) return "permission_error";
45
+ if (status === 429) return "rate_limit_error";
46
+ if (status >= 400 && status < 500) return "invalid_request_error";
47
+ return "internal_error";
48
+ }
49
+
50
+ // src/http.ts
51
+ var SDK_NAME = "@cross-deck/web";
52
+ var SDK_VERSION = "0.3.0";
53
+ var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
54
+ var HttpClient = class {
55
+ constructor(config) {
56
+ this.config = config;
57
+ }
58
+ /**
59
+ * Issue a request. `path` is relative to the configured baseUrl
60
+ * ("/entitlements", "/identity/alias", etc.).
61
+ *
62
+ * Throws CrossdeckError on:
63
+ * - Network failure (`type: "network_error"`)
64
+ * - Non-2xx response (typed from the body envelope)
65
+ * - JSON parse failure on a 2xx (treated as `internal_error`)
66
+ */
67
+ async request(method, path, options = {}) {
68
+ const url = this.buildUrl(path, options.query);
69
+ const headers = {
70
+ Authorization: `Bearer ${this.config.publicKey}`,
71
+ "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
72
+ Accept: "application/json"
73
+ };
74
+ let bodyInit;
75
+ if (options.body !== void 0) {
76
+ headers["Content-Type"] = "application/json";
77
+ bodyInit = JSON.stringify(options.body);
78
+ }
79
+ let response;
80
+ try {
81
+ response = await fetch(url, {
82
+ method,
83
+ headers,
84
+ body: bodyInit
85
+ });
86
+ } catch (err) {
87
+ throw new CrossdeckError({
88
+ type: "network_error",
89
+ code: "fetch_failed",
90
+ message: err instanceof Error ? err.message : "fetch failed"
91
+ });
92
+ }
93
+ if (!response.ok) {
94
+ throw await crossdeckErrorFromResponse(response);
95
+ }
96
+ if (response.status === 204) return void 0;
97
+ try {
98
+ return await response.json();
99
+ } catch (err) {
100
+ throw new CrossdeckError({
101
+ type: "internal_error",
102
+ code: "invalid_json_response",
103
+ message: "Server returned a 2xx with an unparseable body.",
104
+ requestId: response.headers.get("x-request-id") ?? void 0,
105
+ status: response.status
106
+ });
107
+ }
108
+ }
109
+ buildUrl(path, query) {
110
+ const base = this.config.baseUrl.replace(/\/+$/, "");
111
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
112
+ let url = base + cleanPath;
113
+ if (query) {
114
+ const params = new URLSearchParams();
115
+ for (const [k, v] of Object.entries(query)) {
116
+ if (typeof v === "string" && v.length > 0) params.append(k, v);
117
+ }
118
+ const qs = params.toString();
119
+ if (qs) url += (url.includes("?") ? "&" : "?") + qs;
120
+ }
121
+ return url;
122
+ }
123
+ };
124
+
125
+ // src/identity.ts
126
+ var KEY_ANON = "anon_id";
127
+ var KEY_CDCUST = "cdcust_id";
128
+ var IdentityStore = class {
129
+ constructor(storage, prefix) {
130
+ this.storage = storage;
131
+ this.prefix = prefix;
132
+ const stored = {
133
+ anon: storage.getItem(prefix + KEY_ANON),
134
+ cdcust: storage.getItem(prefix + KEY_CDCUST)
135
+ };
136
+ this.state = {
137
+ anonymousId: stored.anon ?? this.mintAnonymousId(),
138
+ crossdeckCustomerId: stored.cdcust
139
+ };
140
+ if (!stored.anon) {
141
+ storage.setItem(prefix + KEY_ANON, this.state.anonymousId);
142
+ }
143
+ }
144
+ /** Return the persisted anonymous device ID (always set). */
145
+ get anonymousId() {
146
+ return this.state.anonymousId;
147
+ }
148
+ /** Return the resolved cross­deckCustomerId once we have one, else null. */
149
+ get crossdeckCustomerId() {
150
+ return this.state.crossdeckCustomerId;
151
+ }
152
+ /** Persist a newly-resolved Crossdeck customer ID. */
153
+ setCrossdeckCustomerId(value) {
154
+ this.state.crossdeckCustomerId = value;
155
+ this.storage.setItem(this.prefix + KEY_CDCUST, value);
156
+ }
157
+ /**
158
+ * Wipe persisted identity. Called by reset() — used when an end-user
159
+ * logs out. After reset the SDK mints a new anonymousId so the next
160
+ * pre-login session is a fresh customer in the identity graph.
161
+ */
162
+ reset() {
163
+ this.storage.removeItem(this.prefix + KEY_ANON);
164
+ this.storage.removeItem(this.prefix + KEY_CDCUST);
165
+ this.state = {
166
+ anonymousId: this.mintAnonymousId(),
167
+ crossdeckCustomerId: null
168
+ };
169
+ this.storage.setItem(this.prefix + KEY_ANON, this.state.anonymousId);
170
+ }
171
+ /**
172
+ * Generate an anonymousId. Crockford-ish base32 timestamp + random
173
+ * suffix. Same shape Stripe / Segment / others use — sortable, log-
174
+ * friendly, no PII.
175
+ */
176
+ mintAnonymousId() {
177
+ const ts = Date.now().toString(36);
178
+ const rand = randomChars(10);
179
+ return `anon_${ts}${rand}`;
180
+ }
181
+ };
182
+ function randomChars(count) {
183
+ const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
184
+ const out = [];
185
+ const cryptoApi = globalThis.crypto;
186
+ if (cryptoApi?.getRandomValues) {
187
+ const buf = new Uint8Array(count);
188
+ cryptoApi.getRandomValues(buf);
189
+ for (let i = 0; i < count; i++) {
190
+ out.push(alphabet[buf[i] % alphabet.length] ?? "0");
191
+ }
192
+ } else {
193
+ for (let i = 0; i < count; i++) {
194
+ out.push(alphabet[Math.floor(Math.random() * alphabet.length)] ?? "0");
195
+ }
196
+ }
197
+ return out.join("");
198
+ }
199
+
200
+ // src/entitlement-cache.ts
201
+ var EntitlementCache = class {
202
+ constructor() {
203
+ this.active = /* @__PURE__ */ new Set();
204
+ this.all = [];
205
+ this.lastUpdated = 0;
206
+ this.listeners = /* @__PURE__ */ new Set();
207
+ }
208
+ /** Sync read — true iff the entitlement key is currently active. */
209
+ isEntitled(key) {
210
+ return this.active.has(key);
211
+ }
212
+ /** Full snapshot for callers that need source / validUntil details. */
213
+ list() {
214
+ return this.all.slice();
215
+ }
216
+ /** When the cache was last refreshed. 0 means "never". */
217
+ get freshness() {
218
+ return this.lastUpdated;
219
+ }
220
+ /**
221
+ * Replace the cache with a fresh server response. The backend already
222
+ * filters to active + env-matching, so we don't re-filter — just trust
223
+ * what we got.
224
+ *
225
+ * Fires listeners AFTER the mutation so each listener sees the new state.
226
+ */
227
+ setFromList(entitlements) {
228
+ this.all = entitlements.slice();
229
+ this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));
230
+ this.lastUpdated = Date.now();
231
+ this.notify();
232
+ }
233
+ /**
234
+ * Wipe — used on reset() (logout). The SDK forgets everything until
235
+ * the next identify + read.
236
+ *
237
+ * Fires listeners so React/SwiftUI/etc bindings re-render to the
238
+ * logged-out state immediately.
239
+ */
240
+ clear() {
241
+ this.active.clear();
242
+ this.all = [];
243
+ this.lastUpdated = 0;
244
+ this.notify();
245
+ }
246
+ /**
247
+ * Subscribe to cache mutations. Returns an unsubscribe function.
248
+ *
249
+ * The listener is invoked AFTER setFromList() or clear() with the
250
+ * current snapshot. Throwing inside a listener is non-fatal — the
251
+ * error is swallowed and subsequent listeners still run.
252
+ *
253
+ * Used by `@cross-deck/web/react`'s `useEntitlement` hook to
254
+ * trigger re-renders when entitlements change.
255
+ */
256
+ subscribe(listener) {
257
+ this.listeners.add(listener);
258
+ let unsubscribed = false;
259
+ return () => {
260
+ if (unsubscribed) return;
261
+ unsubscribed = true;
262
+ this.listeners.delete(listener);
263
+ };
264
+ }
265
+ notify() {
266
+ if (this.listeners.size === 0) return;
267
+ const snapshot = this.all.slice();
268
+ const listenersSnapshot = [...this.listeners];
269
+ for (const listener of listenersSnapshot) {
270
+ try {
271
+ listener(snapshot);
272
+ } catch {
273
+ }
274
+ }
275
+ }
276
+ };
277
+
278
+ // src/event-queue.ts
279
+ var HARD_BUFFER_CAP = 1e3;
280
+ var EventQueue = class {
281
+ constructor(cfg) {
282
+ this.cfg = cfg;
283
+ this.buffer = [];
284
+ this.dropped = 0;
285
+ this.inFlight = 0;
286
+ this.lastFlushAt = 0;
287
+ this.lastError = null;
288
+ this.cancelTimer = null;
289
+ this.firstFlushFired = false;
290
+ }
291
+ enqueue(event) {
292
+ this.buffer.push(event);
293
+ if (this.buffer.length > HARD_BUFFER_CAP) {
294
+ const overflow = this.buffer.length - HARD_BUFFER_CAP;
295
+ this.buffer.splice(0, overflow);
296
+ this.dropped += overflow;
297
+ this.cfg.onDrop?.(overflow);
298
+ }
299
+ if (this.buffer.length >= this.cfg.batchSize) {
300
+ void this.flush();
301
+ } else {
302
+ this.scheduleIdleFlush();
303
+ }
304
+ }
305
+ /**
306
+ * Flush the buffer to /v1/events. Resolves when the network call
307
+ * completes (success or failure). On failure, events stay in the
308
+ * buffer for the next flush attempt.
309
+ */
310
+ async flush() {
311
+ if (this.buffer.length === 0) return null;
312
+ this.cancelTimerIfSet();
313
+ const batch = this.buffer.splice(0);
314
+ this.inFlight += batch.length;
315
+ try {
316
+ const env = this.cfg.envelope();
317
+ const result = await this.cfg.http.request("POST", "/events", {
318
+ body: {
319
+ // NorthStar §13.1 batch envelope. The backend validates these
320
+ // against the API-key-resolved app and rejects mismatches loudly
321
+ // (env_mismatch).
322
+ appId: env.appId,
323
+ environment: env.environment,
324
+ sdk: env.sdk,
325
+ events: batch
326
+ }
327
+ });
328
+ this.lastFlushAt = Date.now();
329
+ this.lastError = null;
330
+ this.inFlight -= batch.length;
331
+ if (!this.firstFlushFired) {
332
+ this.firstFlushFired = true;
333
+ this.cfg.onFirstFlushSuccess?.();
334
+ }
335
+ return result;
336
+ } catch (err) {
337
+ this.buffer.unshift(...batch);
338
+ this.inFlight -= batch.length;
339
+ this.lastError = err instanceof Error ? err.message : String(err);
340
+ this.scheduleIdleFlush();
341
+ return null;
342
+ }
343
+ }
344
+ /** Cancel any pending timer and clear in-memory state. */
345
+ reset() {
346
+ this.cancelTimerIfSet();
347
+ this.buffer = [];
348
+ this.dropped = 0;
349
+ this.inFlight = 0;
350
+ this.lastError = null;
351
+ }
352
+ getStats() {
353
+ return {
354
+ buffered: this.buffer.length,
355
+ dropped: this.dropped,
356
+ inFlight: this.inFlight,
357
+ lastFlushAt: this.lastFlushAt,
358
+ lastError: this.lastError
359
+ };
360
+ }
361
+ scheduleIdleFlush() {
362
+ this.cancelTimerIfSet();
363
+ const sched = this.cfg.scheduler ?? defaultScheduler;
364
+ this.cancelTimer = sched(() => {
365
+ void this.flush();
366
+ }, this.cfg.intervalMs);
367
+ }
368
+ cancelTimerIfSet() {
369
+ if (this.cancelTimer) {
370
+ this.cancelTimer();
371
+ this.cancelTimer = null;
372
+ }
373
+ }
374
+ };
375
+ function defaultScheduler(fn, ms) {
376
+ const id = setTimeout(fn, ms);
377
+ if (typeof id.unref === "function") {
378
+ try {
379
+ id.unref();
380
+ } catch {
381
+ }
382
+ }
383
+ return () => clearTimeout(id);
384
+ }
385
+
386
+ // src/storage.ts
387
+ var MemoryStorage = class {
388
+ constructor() {
389
+ this.store = /* @__PURE__ */ new Map();
390
+ }
391
+ getItem(key) {
392
+ return this.store.get(key) ?? null;
393
+ }
394
+ setItem(key, value) {
395
+ this.store.set(key, value);
396
+ }
397
+ removeItem(key) {
398
+ this.store.delete(key);
399
+ }
400
+ };
401
+ function detectDefaultStorage() {
402
+ try {
403
+ const ls = globalThis.localStorage;
404
+ if (ls) {
405
+ const probe = "__crossdeck_probe__";
406
+ ls.setItem(probe, "1");
407
+ ls.removeItem(probe);
408
+ return ls;
409
+ }
410
+ } catch {
411
+ }
412
+ return new MemoryStorage();
413
+ }
414
+
415
+ // src/device-info.ts
416
+ function isBrowser() {
417
+ return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined" && typeof globalThis.navigator !== "undefined";
418
+ }
419
+ function collectDeviceInfo(extra) {
420
+ const info = {};
421
+ if (extra?.appVersion) info.appVersion = extra.appVersion;
422
+ if (!isBrowser()) return info;
423
+ const w = globalThis.window;
424
+ const nav = globalThis.navigator;
425
+ const doc = globalThis.document;
426
+ try {
427
+ if (typeof nav.language === "string") info.locale = nav.language;
428
+ } catch {
429
+ }
430
+ try {
431
+ info.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
432
+ } catch {
433
+ }
434
+ try {
435
+ if (w.screen) {
436
+ info.screenWidth = w.screen.width;
437
+ info.screenHeight = w.screen.height;
438
+ }
439
+ info.viewportWidth = w.innerWidth;
440
+ info.viewportHeight = w.innerHeight;
441
+ info.devicePixelRatio = w.devicePixelRatio;
442
+ } catch {
443
+ }
444
+ try {
445
+ const ua = nav.userAgent ?? "";
446
+ const parsed = parseUserAgent(ua);
447
+ Object.assign(info, parsed);
448
+ } catch {
449
+ }
450
+ try {
451
+ const uaData = nav.userAgentData;
452
+ if (uaData?.platform && !info.os) info.os = uaData.platform;
453
+ if (uaData?.brands && !info.browser) {
454
+ const real = uaData.brands.find(
455
+ (b) => !/Not[ .;A]*Brand/i.test(b.brand) && !/Chromium/i.test(b.brand)
456
+ );
457
+ if (real) {
458
+ info.browser = real.brand;
459
+ info.browserVersion = real.version;
460
+ }
461
+ }
462
+ } catch {
463
+ }
464
+ void doc;
465
+ return info;
466
+ }
467
+ function parseUserAgent(ua) {
468
+ const out = {};
469
+ if (/iPad|iPhone|iPod/.test(ua)) {
470
+ out.os = "iOS";
471
+ const m = ua.match(/OS (\d+[._]\d+(?:[._]\d+)?)/);
472
+ if (m?.[1]) out.osVersion = m[1].replace(/_/g, ".");
473
+ } else if (/Android/.test(ua)) {
474
+ out.os = "Android";
475
+ const m = ua.match(/Android (\d+(?:\.\d+)*)/);
476
+ if (m?.[1]) out.osVersion = m[1];
477
+ } else if (/Windows/.test(ua)) {
478
+ out.os = "Windows";
479
+ const m = ua.match(/Windows NT (\d+\.\d+)/);
480
+ if (m?.[1]) out.osVersion = m[1];
481
+ } else if (/Mac OS X|Macintosh/.test(ua)) {
482
+ out.os = "macOS";
483
+ const m = ua.match(/Mac OS X (\d+[._]\d+(?:[._]\d+)?)/);
484
+ if (m?.[1]) out.osVersion = m[1].replace(/_/g, ".");
485
+ } else if (/Linux/.test(ua)) {
486
+ out.os = "Linux";
487
+ }
488
+ if (/Edg\/(\d+(?:\.\d+)*)/.test(ua)) {
489
+ out.browser = "Edge";
490
+ out.browserVersion = ua.match(/Edg\/(\d+(?:\.\d+)*)/)?.[1];
491
+ } else if (/Firefox\/(\d+(?:\.\d+)*)/.test(ua)) {
492
+ out.browser = "Firefox";
493
+ out.browserVersion = ua.match(/Firefox\/(\d+(?:\.\d+)*)/)?.[1];
494
+ } else if (/OPR\/(\d+(?:\.\d+)*)/.test(ua)) {
495
+ out.browser = "Opera";
496
+ out.browserVersion = ua.match(/OPR\/(\d+(?:\.\d+)*)/)?.[1];
497
+ } else if (/Chrome\/(\d+(?:\.\d+)*)/.test(ua)) {
498
+ out.browser = "Chrome";
499
+ out.browserVersion = ua.match(/Chrome\/(\d+(?:\.\d+)*)/)?.[1];
500
+ } else if (/Version\/(\d+(?:\.\d+)*).*Safari/.test(ua)) {
501
+ out.browser = "Safari";
502
+ out.browserVersion = ua.match(/Version\/(\d+(?:\.\d+)*)/)?.[1];
503
+ }
504
+ return out;
505
+ }
506
+
507
+ // src/auto-track.ts
508
+ var DEFAULT_AUTO_TRACK = {
509
+ sessions: true,
510
+ pageViews: true,
511
+ deviceInfo: true
512
+ };
513
+ var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
514
+ var AutoTracker = class {
515
+ constructor(cfg, track) {
516
+ this.cfg = cfg;
517
+ this.track = track;
518
+ this.session = null;
519
+ this.cleanups = [];
520
+ }
521
+ install() {
522
+ if (!isBrowserSafe()) return;
523
+ if (this.cfg.sessions) this.installSessionTracking();
524
+ if (this.cfg.pageViews) this.installPageViewTracking();
525
+ }
526
+ uninstall() {
527
+ while (this.cleanups.length) {
528
+ const fn = this.cleanups.pop();
529
+ try {
530
+ fn?.();
531
+ } catch {
532
+ }
533
+ }
534
+ if (this.session && !this.session.endedSent) {
535
+ this.emitSessionEnd();
536
+ }
537
+ this.session = null;
538
+ }
539
+ /** Exposed for tests + consumers that want to reset the session manually. */
540
+ resetSession() {
541
+ if (this.session && !this.session.endedSent) this.emitSessionEnd();
542
+ this.session = this.startNewSession();
543
+ this.emitSessionStart();
544
+ }
545
+ /** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
546
+ get currentSessionId() {
547
+ return this.session?.sessionId ?? null;
548
+ }
549
+ // ---------- sessions ----------
550
+ installSessionTracking() {
551
+ this.session = this.startNewSession();
552
+ this.emitSessionStart();
553
+ const onVisChange = () => {
554
+ if (!this.session) return;
555
+ const doc2 = globalThis.document;
556
+ if (doc2.visibilityState === "hidden") {
557
+ this.session.hiddenAt = Date.now();
558
+ } else if (doc2.visibilityState === "visible") {
559
+ const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
560
+ if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
561
+ this.emitSessionEnd();
562
+ this.session = this.startNewSession();
563
+ this.emitSessionStart();
564
+ } else {
565
+ this.session.hiddenAt = null;
566
+ }
567
+ }
568
+ };
569
+ const onPageHide = () => this.emitSessionEnd();
570
+ const w = globalThis.window;
571
+ const doc = globalThis.document;
572
+ doc.addEventListener("visibilitychange", onVisChange);
573
+ w.addEventListener("pagehide", onPageHide);
574
+ w.addEventListener("beforeunload", onPageHide);
575
+ this.cleanups.push(() => {
576
+ doc.removeEventListener("visibilitychange", onVisChange);
577
+ w.removeEventListener("pagehide", onPageHide);
578
+ w.removeEventListener("beforeunload", onPageHide);
579
+ });
580
+ }
581
+ startNewSession() {
582
+ return {
583
+ sessionId: mintSessionId(),
584
+ startedAt: Date.now(),
585
+ hiddenAt: null,
586
+ endedSent: false
587
+ };
588
+ }
589
+ emitSessionStart() {
590
+ if (!this.session) return;
591
+ this.track("session.started", { sessionId: this.session.sessionId });
592
+ }
593
+ emitSessionEnd() {
594
+ if (!this.session || this.session.endedSent) return;
595
+ const duration = Date.now() - this.session.startedAt;
596
+ this.track("session.ended", {
597
+ sessionId: this.session.sessionId,
598
+ durationMs: duration
599
+ });
600
+ this.session.endedSent = true;
601
+ }
602
+ // ---------- page views ----------
603
+ installPageViewTracking() {
604
+ const w = globalThis.window;
605
+ const doc = globalThis.document;
606
+ const fire = () => {
607
+ const loc = w.location;
608
+ this.track("page.viewed", {
609
+ path: loc.pathname,
610
+ url: loc.href,
611
+ search: loc.search || void 0,
612
+ hash: loc.hash || void 0,
613
+ title: doc.title,
614
+ // referrer only on the first hit of the session — afterward it's
615
+ // always our previous URL, which isn't useful.
616
+ referrer: doc.referrer || void 0
617
+ });
618
+ };
619
+ fire();
620
+ const origPush = w.history.pushState;
621
+ const origReplace = w.history.replaceState;
622
+ function patchedPush(data, unused, url) {
623
+ origPush.apply(this, [data, unused, url]);
624
+ queueMicrotask(fire);
625
+ }
626
+ function patchedReplace(data, unused, url) {
627
+ origReplace.apply(this, [data, unused, url]);
628
+ queueMicrotask(fire);
629
+ }
630
+ w.history.pushState = patchedPush;
631
+ w.history.replaceState = patchedReplace;
632
+ const onPopState = () => fire();
633
+ w.addEventListener("popstate", onPopState);
634
+ this.cleanups.push(() => {
635
+ if (w.history.pushState === patchedPush) {
636
+ w.history.pushState = origPush;
637
+ }
638
+ if (w.history.replaceState === patchedReplace) {
639
+ w.history.replaceState = origReplace;
640
+ }
641
+ w.removeEventListener("popstate", onPopState);
642
+ });
643
+ }
644
+ };
645
+ function isBrowserSafe() {
646
+ return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
647
+ }
648
+ function mintSessionId() {
649
+ const ts = Date.now().toString(36);
650
+ return `sess_${ts}${randomChars(10)}`;
651
+ }
652
+
653
+ // src/debug.ts
654
+ var SENSITIVE_KEY_PATTERNS = [
655
+ /^email$/i,
656
+ /^password$/i,
657
+ /^token$/i,
658
+ /^secret$/i,
659
+ /^card$/i,
660
+ /^phone$/i,
661
+ /password/i,
662
+ /credit_?card/i
663
+ ];
664
+ function findSensitivePropertyKeys(properties) {
665
+ if (!properties) return [];
666
+ const hits = [];
667
+ for (const k of Object.keys(properties)) {
668
+ if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(k))) hits.push(k);
669
+ }
670
+ return hits;
671
+ }
672
+ var ConsoleDebugLogger = class {
673
+ constructor() {
674
+ this.enabled = false;
675
+ this.seen = /* @__PURE__ */ new Set();
676
+ }
677
+ emit(signal, message, context) {
678
+ if (!this.enabled) return;
679
+ if (ONCE_SIGNALS.has(signal)) {
680
+ if (this.seen.has(signal)) return;
681
+ this.seen.add(signal);
682
+ }
683
+ const ctx = context ? ` ${safeJson(context)}` : "";
684
+ console.info(`[crossdeck:${signal}] ${message}${ctx}`);
685
+ }
686
+ };
687
+ var ONCE_SIGNALS = /* @__PURE__ */ new Set([
688
+ "sdk.configured",
689
+ "sdk.first_event_sent",
690
+ "sdk.environment_mismatch"
691
+ ]);
692
+ function safeJson(obj) {
693
+ try {
694
+ return JSON.stringify(obj);
695
+ } catch {
696
+ return "[unserialisable context]";
697
+ }
698
+ }
699
+
700
+ // src/crossdeck.ts
701
+ var CrossdeckClient = class {
702
+ constructor() {
703
+ this.state = null;
704
+ }
705
+ /**
706
+ * Boot the SDK. Idempotent — calling init twice with the same options
707
+ * is a no-op; calling with different options replaces the previous
708
+ * configuration.
709
+ *
710
+ * NorthStar §11.1: signature is `Crossdeck.init({ appId, publicKey,
711
+ * environment })`. The trio is validated up-front so a typo'd key or a
712
+ * mismatched env fails fast at boot rather than at first event-flush.
713
+ */
714
+ init(options) {
715
+ if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
716
+ throw new CrossdeckError({
717
+ type: "configuration_error",
718
+ code: "invalid_public_key",
719
+ message: "Crossdeck.init requires a publishable key starting with cd_pub_."
720
+ });
721
+ }
722
+ if (!options.appId) {
723
+ throw new CrossdeckError({
724
+ type: "configuration_error",
725
+ code: "missing_app_id",
726
+ message: "Crossdeck.init requires an appId. Find yours in the Crossdeck dashboard."
727
+ });
728
+ }
729
+ if (options.environment !== "production" && options.environment !== "sandbox") {
730
+ throw new CrossdeckError({
731
+ type: "configuration_error",
732
+ code: "invalid_environment",
733
+ message: 'Crossdeck.init requires environment: "production" | "sandbox".'
734
+ });
735
+ }
736
+ const keyEnv = inferEnvFromKey(options.publicKey);
737
+ if (keyEnv && keyEnv !== options.environment) {
738
+ throw new CrossdeckError({
739
+ type: "configuration_error",
740
+ code: "environment_mismatch",
741
+ message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
742
+ });
743
+ }
744
+ const storage = options.storage ?? detectDefaultStorage();
745
+ const persistIdentity = options.persistIdentity ?? true;
746
+ const autoTrack = resolveAutoTrack(options.autoTrack);
747
+ const opts = {
748
+ appId: options.appId,
749
+ publicKey: options.publicKey,
750
+ environment: options.environment,
751
+ baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
752
+ persistIdentity,
753
+ storagePrefix: options.storagePrefix ?? "crossdeck:",
754
+ autoHeartbeat: options.autoHeartbeat ?? true,
755
+ eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
756
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 5e3,
757
+ sdkVersion: options.sdkVersion ?? SDK_VERSION,
758
+ autoTrack,
759
+ appVersion: options.appVersion ?? null
760
+ };
761
+ const debug = new ConsoleDebugLogger();
762
+ debug.enabled = options.debug === true;
763
+ const http = new HttpClient({
764
+ publicKey: opts.publicKey,
765
+ baseUrl: opts.baseUrl,
766
+ sdkVersion: opts.sdkVersion
767
+ });
768
+ const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
769
+ const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
770
+ const entitlements = new EntitlementCache();
771
+ const events = new EventQueue({
772
+ http,
773
+ batchSize: opts.eventFlushBatchSize,
774
+ intervalMs: opts.eventFlushIntervalMs,
775
+ envelope: () => ({
776
+ appId: opts.appId,
777
+ environment: opts.environment,
778
+ sdk: { name: SDK_NAME, version: opts.sdkVersion }
779
+ }),
780
+ onFirstFlushSuccess: () => {
781
+ debug.emit(
782
+ "sdk.first_event_sent",
783
+ "First telemetry event received. View it in Live Events.",
784
+ { appId: opts.appId, environment: opts.environment }
785
+ );
786
+ }
787
+ });
788
+ const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
789
+ this.state = {
790
+ http,
791
+ identity,
792
+ entitlements,
793
+ events,
794
+ autoTracker: null,
795
+ deviceInfo,
796
+ options: opts,
797
+ debug,
798
+ developerUserId: null
799
+ };
800
+ debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
801
+ appId: opts.appId,
802
+ environment: opts.environment,
803
+ sdkVersion: opts.sdkVersion
804
+ });
805
+ if (autoTrack.sessions || autoTrack.pageViews) {
806
+ const tracker = new AutoTracker(
807
+ autoTrack,
808
+ (name, properties) => this.track(name, properties)
809
+ );
810
+ this.state.autoTracker = tracker;
811
+ tracker.install();
812
+ }
813
+ if (opts.autoHeartbeat) {
814
+ void this.heartbeat().catch(() => void 0);
815
+ }
816
+ }
817
+ /**
818
+ * @deprecated Use `init()` instead. NorthStar §4 standardised the
819
+ * lifecycle method name across SDKs as `init` (formerly `start` /
820
+ * `configure`). `start` will be removed in a future major version.
821
+ */
822
+ start(options) {
823
+ if (typeof console !== "undefined") {
824
+ console.warn(
825
+ "[crossdeck] Crossdeck.start() is deprecated \u2014 use Crossdeck.init() instead. The signature is the same."
826
+ );
827
+ }
828
+ this.init(options);
829
+ }
830
+ /**
831
+ * Link the anonymous device to a developer-supplied user ID. Cache
832
+ * the resolved Crossdeck customer for follow-up calls.
833
+ */
834
+ async identify(userId, _options) {
835
+ const s = this.requireStarted();
836
+ if (!userId) {
837
+ throw new CrossdeckError({
838
+ type: "invalid_request_error",
839
+ code: "missing_user_id",
840
+ message: "identify(userId) requires a non-empty userId."
841
+ });
842
+ }
843
+ const result = await s.http.request("POST", "/identity/alias", {
844
+ body: { userId, anonymousId: s.identity.anonymousId }
845
+ });
846
+ s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
847
+ s.developerUserId = userId;
848
+ return result;
849
+ }
850
+ /**
851
+ * Read the current customer's active entitlements from the server.
852
+ * Updates the local cache so subsequent isEntitled() calls answer
853
+ * synchronously.
854
+ */
855
+ async getEntitlements() {
856
+ const s = this.requireStarted();
857
+ const query = this.identityQueryParams();
858
+ const result = await s.http.request(
859
+ "GET",
860
+ "/entitlements",
861
+ { query }
862
+ );
863
+ if (result.crossdeckCustomerId) {
864
+ s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
865
+ }
866
+ s.entitlements.setFromList(result.data);
867
+ return result.data;
868
+ }
869
+ /**
870
+ * Synchronous read from the local cache. Returns false if the cache
871
+ * has never been populated (call getEntitlements first to warm it).
872
+ */
873
+ isEntitled(key) {
874
+ const s = this.requireStarted();
875
+ return s.entitlements.isEntitled(key);
876
+ }
877
+ /** Snapshot of the local entitlement cache. */
878
+ listEntitlements() {
879
+ const s = this.requireStarted();
880
+ return s.entitlements.list();
881
+ }
882
+ /**
883
+ * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
884
+ *
885
+ * The listener is invoked AFTER the cache mutates — once after a
886
+ * successful `getEntitlements()` warms it, again after `syncPurchases()`
887
+ * delivers fresh entitlements, and once on `reset()` to fire the
888
+ * empty-cache state for logout flows.
889
+ *
890
+ * It is NOT invoked synchronously on subscribe. Callers that need
891
+ * the current state should read it via `isEntitled()` / `listEntitlements()`
892
+ * inline; the listener fires only on FUTURE changes.
893
+ *
894
+ * This is the foundation of the `useEntitlement` React hook in
895
+ * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
896
+ * / Vue) would have no way to re-render when entitlements arrive
897
+ * asynchronously after init. The naive pattern of calling
898
+ * `Crossdeck.isEntitled("pro")` directly inside a render path
899
+ * shows the empty-cache result forever; binding the result to
900
+ * component state via `onEntitlementsChange` is the correct
901
+ * pattern.
902
+ *
903
+ * Idempotent unsubscribe — calling the returned function multiple
904
+ * times is safe.
905
+ *
906
+ * Listener errors are swallowed (a buggy listener can't crash the
907
+ * SDK or other listeners).
908
+ */
909
+ onEntitlementsChange(listener) {
910
+ const s = this.requireStarted();
911
+ return s.entitlements.subscribe(listener);
912
+ }
913
+ /**
914
+ * Queue a telemetry event. Returns immediately — the network round-
915
+ * trip happens in the background. To flush before the page unloads,
916
+ * call flush().
917
+ */
918
+ track(name, properties) {
919
+ const s = this.requireStarted();
920
+ if (!name) {
921
+ throw new CrossdeckError({
922
+ type: "invalid_request_error",
923
+ code: "missing_event_name",
924
+ message: "track(name) requires a non-empty name."
925
+ });
926
+ }
927
+ if (s.debug.enabled && properties) {
928
+ const flagged = findSensitivePropertyKeys(properties);
929
+ if (flagged.length > 0) {
930
+ s.debug.emit(
931
+ "sdk.sensitive_property_warning",
932
+ `Event "${name}" has potentially sensitive property names: ${flagged.join(", ")}. Crossdeck is privacy-first \u2014 avoid sending PII unless intentional.`,
933
+ { eventName: name, flagged }
934
+ );
935
+ }
936
+ }
937
+ if (s.debug.enabled && !s.developerUserId && !s.identity.crossdeckCustomerId) {
938
+ s.debug.emit(
939
+ "sdk.no_identity",
940
+ "Using anonymous user until identify(userId) is called."
941
+ );
942
+ }
943
+ const enriched = { ...s.deviceInfo };
944
+ const sessionId = s.autoTracker?.currentSessionId;
945
+ if (sessionId) enriched.sessionId = sessionId;
946
+ if (properties) Object.assign(enriched, properties);
947
+ const event = {
948
+ eventId: this.mintEventId(),
949
+ name,
950
+ timestamp: Date.now(),
951
+ properties: enriched
952
+ };
953
+ Object.assign(event, this.identityHintForEvent());
954
+ s.events.enqueue(event);
955
+ }
956
+ /**
957
+ * Force-flush queued events. Useful to call from page-unload handlers.
958
+ *
959
+ * NorthStar §4: standard method name across all Crossdeck SDKs.
960
+ */
961
+ async flush() {
962
+ const s = this.requireStarted();
963
+ await s.events.flush();
964
+ }
965
+ /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
966
+ async flushEvents() {
967
+ return this.flush();
968
+ }
969
+ /**
970
+ * Forward purchase evidence to the backend for verification + entitlement
971
+ * projection. NorthStar §4 + §13 canonical name.
972
+ *
973
+ * Today the web SDK only supports Apple StoreKit 2 forwarding (web apps
974
+ * that sit alongside an iOS app). Stripe doesn't need this method —
975
+ * Stripe webhooks deliver evidence server-side without a client round-trip.
976
+ */
977
+ async syncPurchases(input) {
978
+ const s = this.requireStarted();
979
+ if (!input.signedTransactionInfo) {
980
+ throw new CrossdeckError({
981
+ type: "invalid_request_error",
982
+ code: "missing_signed_transaction_info",
983
+ message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
984
+ });
985
+ }
986
+ const result = await s.http.request("POST", "/purchases/sync", {
987
+ body: { rail: input.rail ?? "apple", ...input }
988
+ });
989
+ s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
990
+ s.entitlements.setFromList(result.entitlements);
991
+ s.debug.emit(
992
+ "sdk.purchase_evidence_sent",
993
+ "StoreKit transaction forwarded. Waiting for backend verification.",
994
+ { rail: input.rail ?? "apple" }
995
+ );
996
+ return result;
997
+ }
998
+ /** @deprecated Use `syncPurchases()` instead. NorthStar §4 standardised the name. */
999
+ async purchaseApple(input) {
1000
+ return this.syncPurchases({ rail: "apple", ...input });
1001
+ }
1002
+ /**
1003
+ * Toggle verbose diagnostic logging — NorthStar §16. When enabled, the
1004
+ * SDK emits a fixed vocabulary of debug signals to console.info that the
1005
+ * dashboard's onboarding checklist can also surface as live events.
1006
+ */
1007
+ setDebugMode(enabled) {
1008
+ const s = this.requireStarted();
1009
+ s.debug.enabled = enabled;
1010
+ if (enabled) {
1011
+ s.debug.emit(
1012
+ "sdk.configured",
1013
+ `Debug mode enabled for ${s.options.appId} in ${s.options.environment} mode.`,
1014
+ { appId: s.options.appId, environment: s.options.environment }
1015
+ );
1016
+ }
1017
+ }
1018
+ /**
1019
+ * Send the boot heartbeat. Called automatically by start() unless
1020
+ * autoHeartbeat:false. Safe to call manually as a "we're still here" ping.
1021
+ */
1022
+ async heartbeat() {
1023
+ const s = this.requireStarted();
1024
+ return await s.http.request("GET", "/sdk/heartbeat");
1025
+ }
1026
+ /**
1027
+ * Wipe persisted identity + entitlement cache. Use on logout. The
1028
+ * next pre-login session generates a fresh anonymousId and starts a
1029
+ * new identity-graph entry.
1030
+ */
1031
+ reset() {
1032
+ if (!this.state) return;
1033
+ this.state.autoTracker?.uninstall();
1034
+ this.state.identity.reset();
1035
+ this.state.entitlements.clear();
1036
+ this.state.events.reset();
1037
+ this.state.developerUserId = null;
1038
+ if (this.state.autoTracker) {
1039
+ const tracker = new AutoTracker(
1040
+ this.state.options.autoTrack,
1041
+ (name, props) => this.track(name, props)
1042
+ );
1043
+ this.state.autoTracker = tracker;
1044
+ tracker.install();
1045
+ }
1046
+ }
1047
+ /**
1048
+ * Diagnostic: current state + queue stats. Useful for the dashboard's
1049
+ * heartbeat row and debugging in dev.
1050
+ *
1051
+ * Returns a stable shape regardless of whether start() has been called —
1052
+ * callers don't need to narrow on `started` to access `events` or
1053
+ * `entitlements`. Pre-start values are sensible empties.
1054
+ */
1055
+ diagnostics() {
1056
+ if (!this.state) {
1057
+ return {
1058
+ started: false,
1059
+ anonymousId: null,
1060
+ crossdeckCustomerId: null,
1061
+ developerUserId: null,
1062
+ sdkVersion: null,
1063
+ baseUrl: null,
1064
+ entitlements: { count: 0, lastUpdated: 0 },
1065
+ events: {
1066
+ buffered: 0,
1067
+ dropped: 0,
1068
+ inFlight: 0,
1069
+ lastFlushAt: 0,
1070
+ lastError: null
1071
+ }
1072
+ };
1073
+ }
1074
+ const s = this.state;
1075
+ return {
1076
+ started: true,
1077
+ anonymousId: s.identity.anonymousId,
1078
+ crossdeckCustomerId: s.identity.crossdeckCustomerId,
1079
+ developerUserId: s.developerUserId,
1080
+ sdkVersion: s.options.sdkVersion,
1081
+ baseUrl: s.options.baseUrl,
1082
+ entitlements: {
1083
+ count: s.entitlements.list().length,
1084
+ lastUpdated: s.entitlements.freshness
1085
+ },
1086
+ events: s.events.getStats()
1087
+ };
1088
+ }
1089
+ // ---------- private helpers ----------
1090
+ requireStarted() {
1091
+ if (!this.state) {
1092
+ throw new CrossdeckError({
1093
+ type: "configuration_error",
1094
+ code: "not_initialized",
1095
+ message: "Call Crossdeck.init({ appId, publicKey, environment }) before any other method."
1096
+ });
1097
+ }
1098
+ return this.state;
1099
+ }
1100
+ /**
1101
+ * Build the identity query for /v1/entitlements. Priority:
1102
+ * crossdeckCustomerId > developerUserId > anonymousId
1103
+ * — matches the resolveCrossdeckCustomerId precedence on the server.
1104
+ */
1105
+ identityQueryParams() {
1106
+ const s = this.requireStarted();
1107
+ if (s.identity.crossdeckCustomerId) {
1108
+ return { customerId: s.identity.crossdeckCustomerId };
1109
+ }
1110
+ if (s.developerUserId) return { userId: s.developerUserId };
1111
+ return { anonymousId: s.identity.anonymousId };
1112
+ }
1113
+ /** Pick the right identity hint to embed on a queued event. */
1114
+ identityHintForEvent() {
1115
+ const s = this.requireStarted();
1116
+ if (s.identity.crossdeckCustomerId) {
1117
+ return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
1118
+ }
1119
+ if (s.developerUserId) return { developerUserId: s.developerUserId };
1120
+ return { anonymousId: s.identity.anonymousId };
1121
+ }
1122
+ mintEventId() {
1123
+ const ts = Date.now().toString(36);
1124
+ return `evt_${ts}${randomChars(8)}`;
1125
+ }
1126
+ };
1127
+ var Crossdeck = new CrossdeckClient();
1128
+ function inferEnvFromKey(publicKey) {
1129
+ if (publicKey.startsWith("cd_pub_test_")) return "sandbox";
1130
+ if (publicKey.startsWith("cd_pub_live_")) return "production";
1131
+ return null;
1132
+ }
1133
+ function resolveAutoTrack(input) {
1134
+ if (input === false) {
1135
+ return { sessions: false, pageViews: false, deviceInfo: false };
1136
+ }
1137
+ if (input === void 0 || input === true) {
1138
+ return { ...DEFAULT_AUTO_TRACK };
1139
+ }
1140
+ return {
1141
+ sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1142
+ pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1143
+ deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1144
+ };
1145
+ }
1146
+
1147
+ // src/react.ts
1148
+ function useEntitlement(key) {
1149
+ const [isEntitled, setIsEntitled] = useState(() => safeIsEntitled(key));
1150
+ useEffect(() => {
1151
+ setIsEntitled(safeIsEntitled(key));
1152
+ let unsubscribe = null;
1153
+ try {
1154
+ unsubscribe = Crossdeck.onEntitlementsChange(() => {
1155
+ setIsEntitled(safeIsEntitled(key));
1156
+ });
1157
+ } catch {
1158
+ }
1159
+ return () => {
1160
+ if (unsubscribe) unsubscribe();
1161
+ };
1162
+ }, [key]);
1163
+ return isEntitled;
1164
+ }
1165
+ function useEntitlements() {
1166
+ const [keys, setKeys] = useState(() => safeListKeys());
1167
+ useEffect(() => {
1168
+ setKeys(safeListKeys());
1169
+ let unsubscribe = null;
1170
+ try {
1171
+ unsubscribe = Crossdeck.onEntitlementsChange((entitlements) => {
1172
+ setKeys(entitlements.filter((e) => e.isActive).map((e) => e.key));
1173
+ });
1174
+ } catch {
1175
+ }
1176
+ return () => {
1177
+ if (unsubscribe) unsubscribe();
1178
+ };
1179
+ }, []);
1180
+ return keys;
1181
+ }
1182
+ function safeIsEntitled(key) {
1183
+ try {
1184
+ return Crossdeck.isEntitled(key);
1185
+ } catch {
1186
+ return false;
1187
+ }
1188
+ }
1189
+ function safeListKeys() {
1190
+ try {
1191
+ return Crossdeck.listEntitlements().filter((e) => e.isActive).map((e) => e.key);
1192
+ } catch {
1193
+ return [];
1194
+ }
1195
+ }
1196
+ export {
1197
+ useEntitlement,
1198
+ useEntitlements
1199
+ };
1200
+ //# sourceMappingURL=react.mjs.map