@cross-deck/web 1.5.1 → 1.5.3

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.
Files changed (47) hide show
  1. package/dist/contracts.json +133 -11
  2. package/dist/crossdeck.umd.min.js +2 -2
  3. package/dist/crossdeck.umd.min.js.map +1 -1
  4. package/dist/error-codes.json +1 -1
  5. package/dist/index.cjs +1081 -25
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.mts +51 -16
  8. package/dist/index.d.ts +51 -16
  9. package/dist/index.mjs +1081 -25
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/react.cjs +950 -16
  12. package/dist/react.cjs.map +1 -1
  13. package/dist/react.d.mts +1 -1
  14. package/dist/react.d.ts +1 -1
  15. package/dist/react.mjs +950 -16
  16. package/dist/react.mjs.map +1 -1
  17. package/dist/{types-Bu3jbmdq.d.ts → types-B9sxUuKh.d.mts} +71 -0
  18. package/dist/{types-Bu3jbmdq.d 2.mts → types-B9sxUuKh.d.ts} +71 -0
  19. package/dist/vue.cjs +950 -16
  20. package/dist/vue.cjs.map +1 -1
  21. package/dist/vue.mjs +950 -16
  22. package/dist/vue.mjs.map +1 -1
  23. package/package.json +1 -1
  24. package/dist/contracts 2.json +0 -378
  25. package/dist/crossdeck.umd.min 2.js +0 -3
  26. package/dist/crossdeck.umd.min.js 2.map +0 -1
  27. package/dist/error-codes 2.json +0 -196
  28. package/dist/index 2.cjs +0 -4778
  29. package/dist/index 2.mjs +0 -4742
  30. package/dist/index.cjs 2.map +0 -1
  31. package/dist/index.d 2.mts +0 -938
  32. package/dist/index.d 2.ts +0 -938
  33. package/dist/index.mjs 2.map +0 -1
  34. package/dist/react 2.cjs +0 -4220
  35. package/dist/react 2.mjs +0 -4193
  36. package/dist/react.cjs 2.map +0 -1
  37. package/dist/react.d 2.mts +0 -91
  38. package/dist/react.d 2.ts +0 -91
  39. package/dist/react.mjs 2.map +0 -1
  40. package/dist/types-Bu3jbmdq.d 2.ts +0 -314
  41. package/dist/types-Bu3jbmdq.d.mts +0 -314
  42. package/dist/vue 2.cjs +0 -4185
  43. package/dist/vue 2.mjs +0 -4159
  44. package/dist/vue.cjs 2.map +0 -1
  45. package/dist/vue.d 2.mts +0 -37
  46. package/dist/vue.d 2.ts +0 -37
  47. package/dist/vue.mjs 2.map +0 -1
@@ -1,4778 +0,0 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
-
20
- // src/index.ts
21
- var index_exports = {};
22
- __export(index_exports, {
23
- CROSSDECK_ERROR_CODES: () => CROSSDECK_ERROR_CODES,
24
- Crossdeck: () => Crossdeck,
25
- CrossdeckClient: () => CrossdeckClient,
26
- CrossdeckContracts: () => CrossdeckContracts,
27
- CrossdeckError: () => CrossdeckError,
28
- DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
29
- MemoryStorage: () => MemoryStorage,
30
- SDK_NAME: () => SDK_NAME,
31
- SDK_VERSION: () => SDK_VERSION,
32
- getErrorCode: () => getErrorCode
33
- });
34
- module.exports = __toCommonJS(index_exports);
35
-
36
- // src/errors.ts
37
- var CrossdeckError = class _CrossdeckError extends Error {
38
- constructor(payload) {
39
- super(payload.message);
40
- this.name = "CrossdeckError";
41
- this.type = payload.type;
42
- this.code = payload.code;
43
- this.requestId = payload.requestId;
44
- this.status = payload.status;
45
- this.retryAfterMs = payload.retryAfterMs;
46
- Object.setPrototypeOf(this, _CrossdeckError.prototype);
47
- }
48
- };
49
- async function crossdeckErrorFromResponse(res) {
50
- const requestId = res.headers.get("x-request-id") ?? void 0;
51
- const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
52
- let body;
53
- try {
54
- body = await res.json();
55
- } catch {
56
- body = null;
57
- }
58
- const envelope = body?.error;
59
- if (envelope && typeof envelope.type === "string" && typeof envelope.code === "string") {
60
- return new CrossdeckError({
61
- type: envelope.type,
62
- code: envelope.code,
63
- message: envelope.message ?? `HTTP ${res.status}`,
64
- requestId: envelope.request_id ?? requestId,
65
- status: res.status,
66
- retryAfterMs
67
- });
68
- }
69
- return new CrossdeckError({
70
- type: typeMapForStatus(res.status),
71
- code: `http_${res.status}`,
72
- message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
73
- requestId,
74
- status: res.status,
75
- retryAfterMs
76
- });
77
- }
78
- function parseRetryAfterHeader(value) {
79
- if (!value) return void 0;
80
- const trimmed = value.trim();
81
- if (!trimmed) return void 0;
82
- if (/^\d+(\.\d+)?$/.test(trimmed)) {
83
- const secs = Number(trimmed);
84
- if (!Number.isFinite(secs) || secs < 0) return void 0;
85
- return Math.round(secs * 1e3);
86
- }
87
- if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
88
- const target = Date.parse(trimmed);
89
- if (!Number.isFinite(target)) return void 0;
90
- const delta = target - Date.now();
91
- return delta > 0 ? delta : 0;
92
- }
93
- function typeMapForStatus(status) {
94
- if (status === 401) return "authentication_error";
95
- if (status === 403) return "permission_error";
96
- if (status === 429) return "rate_limit_error";
97
- if (status >= 400 && status < 500) return "invalid_request_error";
98
- return "internal_error";
99
- }
100
-
101
- // src/_version.ts
102
- var SDK_VERSION = "1.4.2";
103
- var SDK_NAME = "@cross-deck/web";
104
-
105
- // src/http.ts
106
- var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
107
- var DEFAULT_TIMEOUT_MS = 15e3;
108
- var HttpClient = class {
109
- constructor(config) {
110
- this.config = config;
111
- }
112
- /**
113
- * Issue a request. `path` is relative to the configured baseUrl
114
- * ("/entitlements", "/identity/alias", etc.).
115
- *
116
- * Throws CrossdeckError on:
117
- * - Network failure (`type: "network_error"`)
118
- * - Non-2xx response (typed from the body envelope)
119
- * - JSON parse failure on a 2xx (treated as `internal_error`)
120
- */
121
- async request(method, path, options = {}) {
122
- if (this.config.localDevMode) {
123
- return synthesizeLocalDevResponse(path);
124
- }
125
- const url = this.buildUrl(path, options.query);
126
- const headers = {
127
- Authorization: `Bearer ${this.config.publicKey}`,
128
- "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
129
- Accept: "application/json"
130
- };
131
- if (options.idempotencyKey) {
132
- headers["Idempotency-Key"] = options.idempotencyKey;
133
- }
134
- let bodyInit;
135
- if (options.body !== void 0) {
136
- headers["Content-Type"] = "application/json";
137
- bodyInit = JSON.stringify(options.body);
138
- }
139
- const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
140
- const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
141
- let timeoutHandle = null;
142
- if (controller && effectiveTimeout > 0) {
143
- timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
144
- }
145
- let response;
146
- try {
147
- response = await fetch(url, {
148
- method,
149
- headers,
150
- body: bodyInit,
151
- keepalive: options.keepalive === true,
152
- signal: controller?.signal
153
- });
154
- } catch (err) {
155
- const aborted = controller?.signal?.aborted === true;
156
- throw new CrossdeckError({
157
- type: "network_error",
158
- code: aborted ? "request_timeout" : "fetch_failed",
159
- message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
160
- });
161
- } finally {
162
- if (timeoutHandle !== null) clearTimeout(timeoutHandle);
163
- }
164
- if (!response.ok) {
165
- throw await crossdeckErrorFromResponse(response);
166
- }
167
- if (response.status === 204) return void 0;
168
- try {
169
- return await response.json();
170
- } catch (err) {
171
- throw new CrossdeckError({
172
- type: "internal_error",
173
- code: "invalid_json_response",
174
- message: "Server returned a 2xx with an unparseable body.",
175
- requestId: response.headers.get("x-request-id") ?? void 0,
176
- status: response.status
177
- });
178
- }
179
- }
180
- /**
181
- * Whether this client is in localhost dev-mode short-circuit. Used
182
- * by other SDK pieces (event-queue) to skip network-bound work
183
- * entirely rather than going through synthesizeLocalDevResponse.
184
- */
185
- get isLocalDevMode() {
186
- return this.config.localDevMode === true;
187
- }
188
- buildUrl(path, query) {
189
- const base = this.config.baseUrl.replace(/\/+$/, "");
190
- const cleanPath = path.startsWith("/") ? path : `/${path}`;
191
- let url = base + cleanPath;
192
- if (query) {
193
- const params = new URLSearchParams();
194
- for (const [k, v] of Object.entries(query)) {
195
- if (typeof v === "string" && v.length > 0) params.append(k, v);
196
- }
197
- const qs = params.toString();
198
- if (qs) url += (url.includes("?") ? "&" : "?") + qs;
199
- }
200
- return url;
201
- }
202
- };
203
- var cachedLocalCdcust = null;
204
- function synthesizeLocalDevResponse(path) {
205
- if (path.startsWith("/sdk/heartbeat")) {
206
- return {
207
- object: "heartbeat",
208
- ok: true,
209
- projectId: "proj_local_dev",
210
- appId: "app_local_dev",
211
- platform: "web",
212
- env: "sandbox",
213
- serverTime: Date.now()
214
- };
215
- }
216
- if (path.startsWith("/identity/alias")) {
217
- if (!cachedLocalCdcust) {
218
- const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
219
- cachedLocalCdcust = `cdcust_local_${tail}`;
220
- }
221
- return {
222
- object: "alias_result",
223
- crossdeckCustomerId: cachedLocalCdcust,
224
- linked: [],
225
- mergePending: false,
226
- env: "sandbox"
227
- };
228
- }
229
- if (path.startsWith("/entitlements")) {
230
- return {
231
- object: "list",
232
- data: [],
233
- crossdeckCustomerId: cachedLocalCdcust ?? "",
234
- env: "sandbox"
235
- };
236
- }
237
- if (path.startsWith("/events")) {
238
- return {
239
- object: "list",
240
- received: 0,
241
- env: "sandbox"
242
- };
243
- }
244
- return {};
245
- }
246
-
247
- // src/identity.ts
248
- var KEY_ANON = "anon_id";
249
- var KEY_CDCUST = "cdcust_id";
250
- var IdentityStore = class {
251
- constructor(primary, prefix, secondary) {
252
- this.primary = primary;
253
- this.prefix = prefix;
254
- this.secondary = secondary ?? null;
255
- const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
256
- const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
257
- const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
258
- const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
259
- const anon = anonFromPrimary ?? anonFromSecondary;
260
- const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
261
- this.state = {
262
- anonymousId: anon ?? this.mintAnonymousId(),
263
- crossdeckCustomerId: cdcust
264
- };
265
- if (!anonFromPrimary || !anonFromSecondary) {
266
- this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
267
- }
268
- if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
269
- this.writeBoth(prefix + KEY_CDCUST, cdcust);
270
- }
271
- }
272
- /** Return the persisted anonymous device ID (always set). */
273
- get anonymousId() {
274
- return this.state.anonymousId;
275
- }
276
- /** Return the resolved cross­deckCustomerId once we have one, else null. */
277
- get crossdeckCustomerId() {
278
- return this.state.crossdeckCustomerId;
279
- }
280
- /** Persist a newly-resolved Crossdeck customer ID. */
281
- setCrossdeckCustomerId(value) {
282
- this.state.crossdeckCustomerId = value;
283
- this.writeBoth(this.prefix + KEY_CDCUST, value);
284
- }
285
- /**
286
- * Wipe persisted identity. Called by reset() — used when an end-user
287
- * logs out. After reset the SDK mints a new anonymousId so the next
288
- * pre-login session is a fresh customer in the identity graph.
289
- */
290
- reset() {
291
- this.deleteBoth(this.prefix + KEY_ANON);
292
- this.deleteBoth(this.prefix + KEY_CDCUST);
293
- this.state = {
294
- anonymousId: this.mintAnonymousId(),
295
- crossdeckCustomerId: null
296
- };
297
- this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
298
- }
299
- /**
300
- * Generate an anonymousId. Crockford-ish base32 timestamp + random
301
- * suffix. Same shape Stripe / Segment / others use — sortable, log-
302
- * friendly, no PII.
303
- */
304
- mintAnonymousId() {
305
- const ts = Date.now().toString(36);
306
- const rand = randomChars(10);
307
- return `anon_${ts}${rand}`;
308
- }
309
- writeBoth(key, value) {
310
- try {
311
- this.primary.setItem(key, value);
312
- } catch {
313
- }
314
- if (this.secondary) {
315
- try {
316
- this.secondary.setItem(key, value);
317
- } catch {
318
- }
319
- }
320
- }
321
- deleteBoth(key) {
322
- try {
323
- this.primary.removeItem(key);
324
- } catch {
325
- }
326
- if (this.secondary) {
327
- try {
328
- this.secondary.removeItem(key);
329
- } catch {
330
- }
331
- }
332
- }
333
- };
334
- function randomChars(count) {
335
- const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
336
- const out = [];
337
- const cryptoApi = globalThis.crypto;
338
- if (cryptoApi?.getRandomValues) {
339
- const buf = new Uint8Array(count);
340
- cryptoApi.getRandomValues(buf);
341
- for (let i = 0; i < count; i++) {
342
- out.push(alphabet[buf[i] % alphabet.length] ?? "0");
343
- }
344
- } else {
345
- for (let i = 0; i < count; i++) {
346
- out.push(alphabet[Math.floor(Math.random() * alphabet.length)] ?? "0");
347
- }
348
- }
349
- return out.join("");
350
- }
351
-
352
- // src/hash.ts
353
- var K = new Uint32Array([
354
- 1116352408,
355
- 1899447441,
356
- 3049323471,
357
- 3921009573,
358
- 961987163,
359
- 1508970993,
360
- 2453635748,
361
- 2870763221,
362
- 3624381080,
363
- 310598401,
364
- 607225278,
365
- 1426881987,
366
- 1925078388,
367
- 2162078206,
368
- 2614888103,
369
- 3248222580,
370
- 3835390401,
371
- 4022224774,
372
- 264347078,
373
- 604807628,
374
- 770255983,
375
- 1249150122,
376
- 1555081692,
377
- 1996064986,
378
- 2554220882,
379
- 2821834349,
380
- 2952996808,
381
- 3210313671,
382
- 3336571891,
383
- 3584528711,
384
- 113926993,
385
- 338241895,
386
- 666307205,
387
- 773529912,
388
- 1294757372,
389
- 1396182291,
390
- 1695183700,
391
- 1986661051,
392
- 2177026350,
393
- 2456956037,
394
- 2730485921,
395
- 2820302411,
396
- 3259730800,
397
- 3345764771,
398
- 3516065817,
399
- 3600352804,
400
- 4094571909,
401
- 275423344,
402
- 430227734,
403
- 506948616,
404
- 659060556,
405
- 883997877,
406
- 958139571,
407
- 1322822218,
408
- 1537002063,
409
- 1747873779,
410
- 1955562222,
411
- 2024104815,
412
- 2227730452,
413
- 2361852424,
414
- 2428436474,
415
- 2756734187,
416
- 3204031479,
417
- 3329325298
418
- ]);
419
- function utf8Bytes(input) {
420
- if (typeof TextEncoder !== "undefined") {
421
- return new TextEncoder().encode(input);
422
- }
423
- const out = [];
424
- for (let i = 0; i < input.length; i++) {
425
- let codePoint = input.charCodeAt(i);
426
- if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
427
- const next = input.charCodeAt(i + 1);
428
- if (next >= 56320 && next <= 57343) {
429
- codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
430
- i++;
431
- }
432
- }
433
- if (codePoint < 128) {
434
- out.push(codePoint);
435
- } else if (codePoint < 2048) {
436
- out.push(192 | codePoint >> 6);
437
- out.push(128 | codePoint & 63);
438
- } else if (codePoint < 65536) {
439
- out.push(224 | codePoint >> 12);
440
- out.push(128 | codePoint >> 6 & 63);
441
- out.push(128 | codePoint & 63);
442
- } else {
443
- out.push(240 | codePoint >> 18);
444
- out.push(128 | codePoint >> 12 & 63);
445
- out.push(128 | codePoint >> 6 & 63);
446
- out.push(128 | codePoint & 63);
447
- }
448
- }
449
- return new Uint8Array(out);
450
- }
451
- function sha256Hex(input) {
452
- const bytes = utf8Bytes(input);
453
- const bitLength = bytes.length * 8;
454
- const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
455
- const padded = new Uint8Array(blockCount * 64);
456
- padded.set(bytes);
457
- padded[bytes.length] = 128;
458
- const high = Math.floor(bitLength / 4294967296);
459
- const low = bitLength >>> 0;
460
- const lenOffset = padded.length - 8;
461
- padded[lenOffset + 0] = high >>> 24 & 255;
462
- padded[lenOffset + 1] = high >>> 16 & 255;
463
- padded[lenOffset + 2] = high >>> 8 & 255;
464
- padded[lenOffset + 3] = high & 255;
465
- padded[lenOffset + 4] = low >>> 24 & 255;
466
- padded[lenOffset + 5] = low >>> 16 & 255;
467
- padded[lenOffset + 6] = low >>> 8 & 255;
468
- padded[lenOffset + 7] = low & 255;
469
- const H = new Uint32Array([
470
- 1779033703,
471
- 3144134277,
472
- 1013904242,
473
- 2773480762,
474
- 1359893119,
475
- 2600822924,
476
- 528734635,
477
- 1541459225
478
- ]);
479
- const W = new Uint32Array(64);
480
- for (let block = 0; block < blockCount; block++) {
481
- const offset = block * 64;
482
- for (let t = 0; t < 16; t++) {
483
- W[t] = (padded[offset + t * 4] << 24 | padded[offset + t * 4 + 1] << 16 | padded[offset + t * 4 + 2] << 8 | padded[offset + t * 4 + 3]) >>> 0;
484
- }
485
- for (let t = 16; t < 64; t++) {
486
- const w15 = W[t - 15];
487
- const w2 = W[t - 2];
488
- const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
489
- const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
490
- W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
491
- }
492
- let a = H[0], b = H[1], c = H[2], d = H[3];
493
- let e = H[4], f = H[5], g = H[6], h = H[7];
494
- for (let t = 0; t < 64; t++) {
495
- const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
496
- const ch = e & f ^ ~e & g;
497
- const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
498
- const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
499
- const maj = a & b ^ a & c ^ b & c;
500
- const temp2 = S0 + maj >>> 0;
501
- h = g;
502
- g = f;
503
- f = e;
504
- e = d + temp1 >>> 0;
505
- d = c;
506
- c = b;
507
- b = a;
508
- a = temp1 + temp2 >>> 0;
509
- }
510
- H[0] = H[0] + a >>> 0;
511
- H[1] = H[1] + b >>> 0;
512
- H[2] = H[2] + c >>> 0;
513
- H[3] = H[3] + d >>> 0;
514
- H[4] = H[4] + e >>> 0;
515
- H[5] = H[5] + f >>> 0;
516
- H[6] = H[6] + g >>> 0;
517
- H[7] = H[7] + h >>> 0;
518
- }
519
- let hex = "";
520
- for (let i = 0; i < 8; i++) {
521
- hex += H[i].toString(16).padStart(8, "0");
522
- }
523
- return hex;
524
- }
525
-
526
- // src/entitlement-cache.ts
527
- var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
528
- var ANON_SUFFIX = "_anon";
529
- var INDEX_SUFFIX = "_index";
530
- var EntitlementCache = class _EntitlementCache {
531
- /**
532
- * @param storage Device storage adapter. When omitted (tests) or
533
- * a MemoryStorage (strict-consent / no-persistence
534
- * mode) the cache is session-only — durability is
535
- * simply absent, never wrong.
536
- * @param storageKeyPrefix Prefix used to derive per-user storage keys
537
- * (`<prefix>:<sha256(userId)>`). Default
538
- * `crossdeck:entitlements`. The trailing user
539
- * suffix is filled at identify() / reset()
540
- * time — see [[setUserKey]].
541
- * @param staleAfterMs Age past which last-known-good is flagged stale
542
- * even without a failed refresh. Default 24h.
543
- */
544
- constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
545
- this.all = [];
546
- this.lastUpdated = 0;
547
- this.lastRefreshFailedAt = 0;
548
- this.listeners = /* @__PURE__ */ new Set();
549
- this.listenerErrorCount = 0;
550
- this.currentSuffix = ANON_SUFFIX;
551
- this.storage = storage;
552
- this.storageKeyPrefix = storageKeyPrefix;
553
- this.staleAfterMs = staleAfterMs;
554
- this.hydrate();
555
- }
556
- /** The full storage key the current-user blob is persisted under. */
557
- get storageKey() {
558
- return `${this.storageKeyPrefix}:${this.currentSuffix}`;
559
- }
560
- /** Key of the index blob — a JSON array of every suffix we've
561
- * written. Used by clearAll() to scope a logout-wipe. */
562
- get indexKey() {
563
- return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
564
- }
565
- /** Derive a stable suffix for a developerUserId via SHA-256. The
566
- * raw userId never lands in the storage key — protects against
567
- * accidentally leaking email-style identifiers through DevTools
568
- * inspection. Pass `null` to switch back to the anonymous slot. */
569
- static suffixForUserId(userId) {
570
- if (userId == null || userId === "") return ANON_SUFFIX;
571
- return sha256Hex(userId);
572
- }
573
- /**
574
- * Switch the cache to a different user's storage slot. Bank-grade
575
- * three-layer isolation:
576
- * (a) Physical key separation — `<prefix>:<sha256(userId)>` so
577
- * a user-switch can't physically read prior user's data
578
- * even if the in-memory clear was skipped.
579
- * (b) Unconditional in-memory clear — invoked whenever the
580
- * active suffix changes, even on same-id re-identify.
581
- * (c) Re-hydrate from the new slot — a returning user observes
582
- * their last-known-good cache from storage immediately.
583
- *
584
- * Caller (identify() / reset()) MUST invoke this BEFORE the next
585
- * setFromList() so the write lands under the right key.
586
- */
587
- setUserKey(userId) {
588
- const nextSuffix = _EntitlementCache.suffixForUserId(userId);
589
- if (nextSuffix === this.currentSuffix) {
590
- this.all = [];
591
- this.lastUpdated = 0;
592
- this.lastRefreshFailedAt = 0;
593
- this.notify();
594
- this.hydrate();
595
- return;
596
- }
597
- this.currentSuffix = nextSuffix;
598
- this.all = [];
599
- this.lastUpdated = 0;
600
- this.lastRefreshFailedAt = 0;
601
- this.hydrate();
602
- this.notify();
603
- }
604
- /**
605
- * Sync read — true iff the entitlement is currently granting access.
606
- *
607
- * Served from last-known-good: a stale cache (server unreachable since
608
- * the last successful fetch) still answers true for a still-valid
609
- * entitlement. The ONLY thing that turns it false is the entitlement's
610
- * own expiry (validUntil) — never overall cache staleness.
611
- */
612
- isEntitled(key) {
613
- const nowSec = Date.now() / 1e3;
614
- return this.all.some(
615
- (e) => e.key === key && e.isActive && (e.validUntil == null || e.validUntil > nowSec)
616
- );
617
- }
618
- /** Full snapshot for callers that need source / validUntil details. */
619
- list() {
620
- return this.all.slice();
621
- }
622
- /** When the cache was last refreshed from the server. 0 means "never". */
623
- get freshness() {
624
- return this.lastUpdated;
625
- }
626
- /**
627
- * Whether the cache is knowingly serving older-than-trustworthy data.
628
- *
629
- * True when the most recent refresh ATTEMPT failed (Crossdeck
630
- * unreachable since the last success — the outage case, distinct from
631
- * a benign idle tab that simply hasn't re-fetched), OR when
632
- * last-known-good has aged past staleAfterMs.
633
- *
634
- * isStale never changes what isEntitled() returns — the cache still
635
- * serves last-known-good. It exists so the staleness is observable
636
- * (diagnostics()) instead of an unbounded silent window where a
637
- * revoked customer holds access with nobody able to see it.
638
- */
639
- get isStale() {
640
- if (this.lastRefreshFailedAt > this.lastUpdated) return true;
641
- return this.lastUpdated > 0 && Date.now() - this.lastUpdated > this.staleAfterMs;
642
- }
643
- /** Epoch ms of the last failed refresh attempt. 0 if none since the last success. */
644
- get refreshFailedAt() {
645
- return this.lastRefreshFailedAt;
646
- }
647
- get listenerErrors() {
648
- return this.listenerErrorCount;
649
- }
650
- /**
651
- * Record that a refresh attempt failed (Crossdeck unreachable / a
652
- * transient error). The SDK's getEntitlements() calls this in its
653
- * catch path. It does NOT touch the cached entitlements — last-known-
654
- * good keeps serving — it only flips isStale so the staleness shows
655
- * up in diagnostics() rather than being silent.
656
- */
657
- markRefreshFailed() {
658
- this.lastRefreshFailedAt = Date.now();
659
- }
660
- /**
661
- * Replace the cache with a fresh server response and persist it to
662
- * device storage so it survives a reload / app restart.
663
- *
664
- * Called ONLY after a successful server read — a failed fetch throws
665
- * before it reaches here, so last-known-good is preserved through an
666
- * outage. A success also clears the stale flag.
667
- */
668
- setFromList(entitlements) {
669
- this.all = entitlements.slice();
670
- this.lastUpdated = Date.now();
671
- this.lastRefreshFailedAt = 0;
672
- this.persist();
673
- this.recordSuffixInIndex(this.currentSuffix);
674
- this.notify();
675
- }
676
- /**
677
- * Wipe the CURRENT user's slot. Used internally when a single
678
- * user's cache needs to be invalidated without affecting other
679
- * persisted slots. The full-logout path is [[clearAll]].
680
- */
681
- clear() {
682
- this.all = [];
683
- this.lastUpdated = 0;
684
- this.lastRefreshFailedAt = 0;
685
- if (this.storage) {
686
- try {
687
- this.storage.removeItem(this.storageKey);
688
- } catch {
689
- }
690
- }
691
- this.removeSuffixFromIndex(this.currentSuffix);
692
- this.notify();
693
- }
694
- /**
695
- * Logout-grade wipe — bank-grade contract: removes EVERY per-user
696
- * entitlement slot the SDK has ever written on this device, then
697
- * clears the index. Used by `Crossdeck.reset()` so a logout on a
698
- * shared device can never leave another user's entitlements
699
- * readable (layer (c) of the v1.4.0 isolation fix).
700
- *
701
- * After clearAll(), the cache is back to anonymous + empty.
702
- */
703
- clearAll() {
704
- this.all = [];
705
- this.lastUpdated = 0;
706
- this.lastRefreshFailedAt = 0;
707
- this.currentSuffix = ANON_SUFFIX;
708
- if (this.storage) {
709
- const suffixes = this.readIndex();
710
- for (const suffix of suffixes) {
711
- try {
712
- this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
713
- } catch {
714
- }
715
- }
716
- try {
717
- this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
718
- } catch {
719
- }
720
- try {
721
- this.storage.removeItem(this.indexKey);
722
- } catch {
723
- }
724
- }
725
- this.notify();
726
- }
727
- /**
728
- * Subscribe to cache mutations. Returns an idempotent unsubscribe fn.
729
- * The listener fires AFTER setFromList() or clear() with the current
730
- * snapshot. Used by `@cross-deck/web/react`'s `useEntitlement` hook.
731
- */
732
- subscribe(listener) {
733
- this.listeners.add(listener);
734
- let unsubscribed = false;
735
- return () => {
736
- if (unsubscribed) return;
737
- unsubscribed = true;
738
- this.listeners.delete(listener);
739
- };
740
- }
741
- // ----- Durable persistence -----
742
- /**
743
- * Load last-known-good from device storage. Runs once in the
744
- * constructor, synchronously, so isEntitled() is correct from boot.
745
- * Any corrupt / unparseable blob degrades silently to an empty cache —
746
- * boot must never throw.
747
- */
748
- hydrate() {
749
- if (!this.storage) return;
750
- try {
751
- const raw = this.storage.getItem(this.storageKey);
752
- if (!raw) return;
753
- const parsed = JSON.parse(raw);
754
- if (parsed && parsed.v === 1 && Array.isArray(parsed.entitlements)) {
755
- this.all = parsed.entitlements;
756
- this.lastUpdated = typeof parsed.lastUpdated === "number" ? parsed.lastUpdated : 0;
757
- }
758
- } catch {
759
- }
760
- }
761
- /** Write last-known-good to device storage. Best-effort. */
762
- persist() {
763
- if (!this.storage) return;
764
- try {
765
- const blob = {
766
- v: 1,
767
- entitlements: this.all,
768
- lastUpdated: this.lastUpdated
769
- };
770
- this.storage.setItem(this.storageKey, JSON.stringify(blob));
771
- } catch {
772
- }
773
- }
774
- /** Read the index of all per-user suffixes the SDK has written. */
775
- readIndex() {
776
- if (!this.storage) return [];
777
- try {
778
- const raw = this.storage.getItem(this.indexKey);
779
- if (!raw) return [];
780
- const parsed = JSON.parse(raw);
781
- if (Array.isArray(parsed)) {
782
- return parsed.filter((x) => typeof x === "string");
783
- }
784
- return [];
785
- } catch {
786
- return [];
787
- }
788
- }
789
- /** Add a suffix to the persisted index. Idempotent. */
790
- recordSuffixInIndex(suffix) {
791
- if (!this.storage) return;
792
- const existing = this.readIndex();
793
- if (existing.includes(suffix)) return;
794
- existing.push(suffix);
795
- try {
796
- this.storage.setItem(this.indexKey, JSON.stringify(existing));
797
- } catch {
798
- }
799
- }
800
- /** Remove a suffix from the persisted index. No-op if absent. */
801
- removeSuffixFromIndex(suffix) {
802
- if (!this.storage) return;
803
- const existing = this.readIndex();
804
- const next = existing.filter((s) => s !== suffix);
805
- if (next.length === existing.length) return;
806
- try {
807
- if (next.length === 0) {
808
- this.storage.removeItem(this.indexKey);
809
- } else {
810
- this.storage.setItem(this.indexKey, JSON.stringify(next));
811
- }
812
- } catch {
813
- }
814
- }
815
- notify() {
816
- if (this.listeners.size === 0) return;
817
- const snapshot = this.all.slice();
818
- const listenersSnapshot = [...this.listeners];
819
- for (const listener of listenersSnapshot) {
820
- try {
821
- listener(snapshot);
822
- } catch {
823
- this.listenerErrorCount += 1;
824
- }
825
- }
826
- }
827
- };
828
-
829
- // src/idempotency-key.ts
830
- function formatAsUuid(hex) {
831
- return [
832
- hex.slice(0, 8),
833
- hex.slice(8, 12),
834
- hex.slice(12, 16),
835
- hex.slice(16, 20),
836
- hex.slice(20, 32)
837
- ].join("-");
838
- }
839
- function deriveIdempotencyKeyForPurchase(body) {
840
- let identifier;
841
- if (body.rail === "apple") {
842
- identifier = body.signedTransactionInfo ?? "";
843
- } else if (body.rail === "google") {
844
- identifier = body.purchaseToken ?? "";
845
- } else {
846
- identifier = "";
847
- }
848
- if (!identifier) {
849
- throw new Error(
850
- `deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
851
- );
852
- }
853
- const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
854
- return formatAsUuid(sha256Hex(namespaced));
855
- }
856
-
857
- // src/retry-policy.ts
858
- var DEFAULT_BASE = 1e3;
859
- var DEFAULT_MAX = 6e4;
860
- var DEFAULT_FACTOR = 2;
861
- var DEFAULT_WARN = 8;
862
- function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
863
- const base = options.baseMs ?? DEFAULT_BASE;
864
- const max = options.maxMs ?? DEFAULT_MAX;
865
- const factor = options.factor ?? DEFAULT_FACTOR;
866
- const safeAttempts = Math.min(attempts, 30);
867
- const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
868
- const jittered = ceiling * random();
869
- if (retryAfterMs !== void 0) {
870
- const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
871
- const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
872
- if (honoured > jittered) return honoured;
873
- }
874
- return Math.max(0, Math.round(jittered));
875
- }
876
- var RetryPolicy = class {
877
- constructor(options = {}) {
878
- this.options = options;
879
- this.attempts = 0;
880
- }
881
- /** How many consecutive failures since the last success. */
882
- get consecutiveFailures() {
883
- return this.attempts;
884
- }
885
- /** Whether we've crossed the failuresBeforeWarn threshold. */
886
- get isWarning() {
887
- return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
888
- }
889
- /** Schedule-time delay for the NEXT retry. Increments the counter. */
890
- nextDelay(retryAfterMs, random = Math.random) {
891
- const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
892
- this.attempts += 1;
893
- return delay;
894
- }
895
- /** Mark a successful flush — reset the counter. */
896
- recordSuccess() {
897
- this.attempts = 0;
898
- }
899
- };
900
-
901
- // src/event-queue.ts
902
- var HARD_BUFFER_CAP = 1e3;
903
- var EventQueue = class {
904
- constructor(cfg) {
905
- this.cfg = cfg;
906
- this.buffer = [];
907
- /**
908
- * In-flight events that have been spliced from `buffer` for the
909
- * current batch but haven't yet been confirmed (success or permanent
910
- * failure). On a retry-driven re-flush we re-use this slot alongside
911
- * `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
912
- * across retries of the SAME logical batch.
913
- *
914
- * Pre-fix the splice cleared the buffer AND we immediately
915
- * `persistent.save(empty)` BEFORE awaiting the network call — a
916
- * crash mid-flight wiped the persisted blob and the batch was lost
917
- * with no replay on next boot. Now the persisted blob always carries
918
- * `[...pendingBatch, ...buffer]` so the in-flight events survive
919
- * until the server confirms them.
920
- *
921
- * Pre-fix every retry attempt also minted a NEW batchId via
922
- * `splice + mintBatchId`, defeating the backend's
923
- * `Idempotency-Key` dedup. Reuse via this slot brings web in
924
- * lockstep with node (which already had the field).
925
- */
926
- this.pendingBatch = null;
927
- this.pendingBatchId = null;
928
- this.dropped = 0;
929
- this.inFlight = 0;
930
- this.lastFlushAt = 0;
931
- this.lastError = null;
932
- this.cancelTimer = null;
933
- this.firstFlushFired = false;
934
- this.nextRetryAt = null;
935
- this.retry = new RetryPolicy(cfg.retry ?? {});
936
- this.persistent = cfg.persistentStore ?? null;
937
- if (this.persistent) {
938
- const restored = this.persistent.load();
939
- if (restored.length > 0) {
940
- if (restored.length > HARD_BUFFER_CAP) {
941
- this.dropped += restored.length - HARD_BUFFER_CAP;
942
- this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
943
- } else {
944
- this.buffer = restored;
945
- }
946
- this.cfg.onBufferChange?.(this.buffer.length);
947
- this.scheduleIdleFlush();
948
- }
949
- }
950
- }
951
- enqueue(event) {
952
- this.buffer.push(event);
953
- if (this.buffer.length > HARD_BUFFER_CAP) {
954
- const overflow = this.buffer.length - HARD_BUFFER_CAP;
955
- this.buffer.splice(0, overflow);
956
- this.dropped += overflow;
957
- this.cfg.onDrop?.(overflow);
958
- }
959
- this.cfg.onBufferChange?.(this.buffer.length);
960
- this.persistAll();
961
- if (this.buffer.length >= this.cfg.batchSize) {
962
- void this.flush();
963
- } else {
964
- this.scheduleIdleFlush();
965
- }
966
- }
967
- /**
968
- * Flush the buffer to /v1/events. Resolves when the network call
969
- * completes (success or failure). On a retryable failure the batch
970
- * stays in `pendingBatch` for the next scheduled retry — the SAME
971
- * batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
972
- *
973
- * Three terminal states from one call:
974
- * - 2xx success: pendingBatch cleared, persisted state collapses to
975
- * just `buffer` (any new events that arrived during in-flight).
976
- * - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
977
- * counter increments, a `permanent_failure` signal fires. The
978
- * server is telling us our request is malformed / our key is
979
- * revoked / we lack permission — retrying with the same key
980
- * forever just grows the queue while the customer thinks events
981
- * are landing.
982
- * - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
983
- * schedules a retry; the next `flush()` re-uses both.
984
- *
985
- * `options.keepalive` marks the underlying fetch as keepalive so the
986
- * browser keeps the request alive past page unload. Use for terminal
987
- * flushes (pagehide / visibilitychange→hidden / beforeunload).
988
- */
989
- async flush(options = {}) {
990
- let batch;
991
- let batchId;
992
- if (this.pendingBatch !== null && this.pendingBatchId !== null) {
993
- batch = this.pendingBatch;
994
- batchId = this.pendingBatchId;
995
- } else {
996
- if (this.buffer.length === 0) return null;
997
- batch = this.buffer.splice(0);
998
- batchId = this.mintBatchId();
999
- this.pendingBatch = batch;
1000
- this.pendingBatchId = batchId;
1001
- this.inFlight += batch.length;
1002
- this.cfg.onBufferChange?.(this.buffer.length);
1003
- this.persistAll();
1004
- }
1005
- this.cancelTimerIfSet();
1006
- this.nextRetryAt = null;
1007
- try {
1008
- const env = this.cfg.envelope();
1009
- const result = await this.cfg.http.request("POST", "/events", {
1010
- body: {
1011
- // NorthStar §13.1 batch envelope. The backend validates these
1012
- // against the API-key-resolved app and rejects mismatches
1013
- // loudly (env_mismatch).
1014
- appId: env.appId,
1015
- environment: env.environment,
1016
- sdk: env.sdk,
1017
- events: batch
1018
- },
1019
- keepalive: options.keepalive === true,
1020
- idempotencyKey: batchId
1021
- });
1022
- this.lastFlushAt = Date.now();
1023
- this.lastError = null;
1024
- this.inFlight -= batch.length;
1025
- this.pendingBatch = null;
1026
- this.pendingBatchId = null;
1027
- this.retry.recordSuccess();
1028
- this.persistAll();
1029
- if (!this.firstFlushFired) {
1030
- this.firstFlushFired = true;
1031
- this.cfg.onFirstFlushSuccess?.();
1032
- }
1033
- return result;
1034
- } catch (err) {
1035
- const message = err instanceof Error ? err.message : String(err);
1036
- this.lastError = message;
1037
- if (isPermanent4xx(err)) {
1038
- const droppedCount = batch.length;
1039
- this.pendingBatch = null;
1040
- this.pendingBatchId = null;
1041
- this.inFlight -= droppedCount;
1042
- this.dropped += droppedCount;
1043
- this.persistAll();
1044
- this.cfg.onDrop?.(droppedCount);
1045
- this.cfg.onPermanentFailure?.({
1046
- status: err.status ?? 0,
1047
- droppedCount,
1048
- lastError: message
1049
- });
1050
- return null;
1051
- }
1052
- const retryAfterMs = extractRetryAfterMs(err);
1053
- const delay = this.retry.nextDelay(retryAfterMs);
1054
- this.scheduleRetry(delay);
1055
- this.cfg.onRetryScheduled?.({
1056
- delayMs: delay,
1057
- consecutiveFailures: this.retry.consecutiveFailures,
1058
- retryAfterMs,
1059
- lastError: message
1060
- });
1061
- return null;
1062
- }
1063
- }
1064
- /** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
1065
- reset() {
1066
- this.cancelTimerIfSet();
1067
- this.nextRetryAt = null;
1068
- this.buffer = [];
1069
- this.pendingBatch = null;
1070
- this.pendingBatchId = null;
1071
- this.dropped = 0;
1072
- this.inFlight = 0;
1073
- this.lastError = null;
1074
- this.retry.recordSuccess();
1075
- this.persistent?.clear();
1076
- this.cfg.onBufferChange?.(0);
1077
- }
1078
- getStats() {
1079
- return {
1080
- // `buffered` counts events waiting for their FIRST flush. The
1081
- // in-flight pendingBatch (retrying) is tracked separately via
1082
- // `inFlight` — surfacing both lets diagnostics show "we have
1083
- // events stuck retrying" distinct from "new events arriving".
1084
- buffered: this.buffer.length,
1085
- dropped: this.dropped,
1086
- inFlight: this.inFlight,
1087
- lastFlushAt: this.lastFlushAt,
1088
- lastError: this.lastError,
1089
- consecutiveFailures: this.retry.consecutiveFailures,
1090
- nextRetryAt: this.nextRetryAt
1091
- };
1092
- }
1093
- /**
1094
- * The Idempotency-Key of the in-flight pending batch (if any).
1095
- * Exposed for testing the Stripe-style retry-reuse contract.
1096
- * Production callers don't need this.
1097
- */
1098
- get pendingIdempotencyKey() {
1099
- return this.pendingBatchId;
1100
- }
1101
- /**
1102
- * Persist `[...pendingBatch, ...buffer]` — the full set of
1103
- * not-yet-confirmed events. The next boot rehydrates this exact set
1104
- * into `buffer` and replays. The server dedups via eventId
1105
- * (ReplacingMergeTree on the warehouse side), so re-sending an event
1106
- * that may have already landed is safe.
1107
- */
1108
- persistAll() {
1109
- if (!this.persistent) return;
1110
- if (this.pendingBatch === null) {
1111
- this.persistent.save(this.buffer);
1112
- return;
1113
- }
1114
- this.persistent.save([...this.pendingBatch, ...this.buffer]);
1115
- }
1116
- // ---------- internal scheduling ----------
1117
- scheduleIdleFlush() {
1118
- this.cancelTimerIfSet();
1119
- const sched = this.cfg.scheduler ?? defaultScheduler;
1120
- this.cancelTimer = sched(() => {
1121
- void this.flush();
1122
- }, this.cfg.intervalMs);
1123
- }
1124
- scheduleRetry(delayMs) {
1125
- this.cancelTimerIfSet();
1126
- this.nextRetryAt = Date.now() + delayMs;
1127
- const sched = this.cfg.scheduler ?? defaultScheduler;
1128
- this.cancelTimer = sched(() => {
1129
- void this.flush();
1130
- }, delayMs);
1131
- }
1132
- cancelTimerIfSet() {
1133
- if (this.cancelTimer) {
1134
- this.cancelTimer();
1135
- this.cancelTimer = null;
1136
- }
1137
- }
1138
- mintBatchId() {
1139
- return `batch_${Date.now().toString(36)}${randomChars(10)}`;
1140
- }
1141
- };
1142
- function extractRetryAfterMs(err) {
1143
- if (err && typeof err === "object" && "retryAfterMs" in err) {
1144
- const v = err.retryAfterMs;
1145
- return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
1146
- }
1147
- return void 0;
1148
- }
1149
- function isPermanent4xx(err) {
1150
- if (!err || typeof err !== "object") return false;
1151
- const status = err.status;
1152
- if (typeof status !== "number" || !Number.isFinite(status)) return false;
1153
- if (status < 400 || status >= 500) return false;
1154
- if (status === 408 || status === 429) return false;
1155
- return true;
1156
- }
1157
- function defaultScheduler(fn, ms) {
1158
- const id = setTimeout(fn, ms);
1159
- if (typeof id.unref === "function") {
1160
- try {
1161
- id.unref();
1162
- } catch {
1163
- }
1164
- }
1165
- return () => clearTimeout(id);
1166
- }
1167
-
1168
- // src/event-storage.ts
1169
- var PersistentEventStore = class {
1170
- constructor(options) {
1171
- this.options = options;
1172
- this.writeScheduled = false;
1173
- // Pending events captured on the most recent write request. We keep
1174
- // the latest snapshot ref so a debounced write always picks up the
1175
- // freshest buffer state.
1176
- this.pendingSnapshot = null;
1177
- this.key = `${options.prefix}queue.v1`;
1178
- }
1179
- /**
1180
- * Read the persisted queue on boot. Returns an empty array (with no
1181
- * warning) when nothing is stored, the blob is malformed, or storage
1182
- * is unavailable. Caller is responsible for treating duplicates from
1183
- * the persisted queue as the SAME events (eventId-based dedup).
1184
- */
1185
- load() {
1186
- let raw;
1187
- try {
1188
- raw = this.options.storage.getItem(this.key);
1189
- } catch {
1190
- return [];
1191
- }
1192
- if (!raw) return [];
1193
- try {
1194
- const parsed = JSON.parse(raw);
1195
- if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
1196
- return [];
1197
- }
1198
- return parsed.events;
1199
- } catch {
1200
- return [];
1201
- }
1202
- }
1203
- /**
1204
- * Schedule a write of the current buffer. Debounced via microtask so
1205
- * a burst of enqueue() calls coalesces into one persistence write.
1206
- * Writes are best-effort: if storage throws (quota, private mode),
1207
- * we swallow and rely on the in-memory buffer.
1208
- */
1209
- save(snapshot) {
1210
- this.pendingSnapshot = snapshot.slice();
1211
- if (this.writeScheduled) return;
1212
- this.writeScheduled = true;
1213
- queueMicrotask(() => this.flushWrite());
1214
- }
1215
- /** Synchronous variant for terminal flushes (pagehide / beforeunload). */
1216
- saveSync(snapshot) {
1217
- this.pendingSnapshot = snapshot.slice();
1218
- this.flushWrite();
1219
- }
1220
- /** Wipe the persisted blob. Used by reset() (logout). */
1221
- clear() {
1222
- this.pendingSnapshot = null;
1223
- this.writeScheduled = false;
1224
- try {
1225
- this.options.storage.removeItem(this.key);
1226
- } catch {
1227
- }
1228
- }
1229
- flushWrite() {
1230
- this.writeScheduled = false;
1231
- const snapshot = this.pendingSnapshot;
1232
- this.pendingSnapshot = null;
1233
- if (snapshot === null) return;
1234
- if (snapshot.length === 0) {
1235
- try {
1236
- this.options.storage.removeItem(this.key);
1237
- } catch {
1238
- }
1239
- return;
1240
- }
1241
- const blob = { version: 1, events: snapshot };
1242
- try {
1243
- this.options.storage.setItem(this.key, JSON.stringify(blob));
1244
- } catch {
1245
- }
1246
- }
1247
- };
1248
-
1249
- // src/storage.ts
1250
- var MemoryStorage = class {
1251
- constructor() {
1252
- this.store = /* @__PURE__ */ new Map();
1253
- }
1254
- getItem(key) {
1255
- return this.store.get(key) ?? null;
1256
- }
1257
- setItem(key, value) {
1258
- this.store.set(key, value);
1259
- }
1260
- removeItem(key) {
1261
- this.store.delete(key);
1262
- }
1263
- };
1264
- var CookieStorage = class {
1265
- constructor(options) {
1266
- this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
1267
- this.secure = options?.secure ?? defaultSecure();
1268
- this.sameSite = options?.sameSite ?? "Lax";
1269
- }
1270
- getItem(key) {
1271
- if (!hasDocument()) return null;
1272
- const doc = globalThis.document;
1273
- const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
1274
- const prefix = encodeURIComponent(key) + "=";
1275
- for (const c of cookies) {
1276
- if (c.startsWith(prefix)) {
1277
- try {
1278
- return decodeURIComponent(c.slice(prefix.length));
1279
- } catch {
1280
- return null;
1281
- }
1282
- }
1283
- }
1284
- return null;
1285
- }
1286
- setItem(key, value) {
1287
- if (!hasDocument()) return;
1288
- const doc = globalThis.document;
1289
- const parts = [
1290
- `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
1291
- "Path=/",
1292
- `Max-Age=${this.maxAgeSec}`,
1293
- `SameSite=${this.sameSite}`
1294
- ];
1295
- if (this.secure) parts.push("Secure");
1296
- try {
1297
- doc.cookie = parts.join("; ");
1298
- } catch {
1299
- }
1300
- }
1301
- removeItem(key) {
1302
- if (!hasDocument()) return;
1303
- const doc = globalThis.document;
1304
- const parts = [
1305
- `${encodeURIComponent(key)}=`,
1306
- "Path=/",
1307
- "Max-Age=0",
1308
- `SameSite=${this.sameSite}`
1309
- ];
1310
- if (this.secure) parts.push("Secure");
1311
- try {
1312
- doc.cookie = parts.join("; ");
1313
- } catch {
1314
- }
1315
- }
1316
- };
1317
- function detectDefaultStorage() {
1318
- try {
1319
- const ls = globalThis.localStorage;
1320
- if (ls) {
1321
- const probe = "__crossdeck_probe__";
1322
- ls.setItem(probe, "1");
1323
- ls.removeItem(probe);
1324
- return ls;
1325
- }
1326
- } catch {
1327
- }
1328
- return new MemoryStorage();
1329
- }
1330
- function defaultSecure() {
1331
- try {
1332
- const loc = globalThis.location;
1333
- return loc?.protocol === "https:";
1334
- } catch {
1335
- return false;
1336
- }
1337
- }
1338
- function hasDocument() {
1339
- return typeof globalThis.document !== "undefined";
1340
- }
1341
-
1342
- // src/device-info.ts
1343
- function isBrowser() {
1344
- return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined" && typeof globalThis.navigator !== "undefined";
1345
- }
1346
- function collectDeviceInfo(extra) {
1347
- const info = {};
1348
- if (extra?.appVersion) info.appVersion = extra.appVersion;
1349
- if (!isBrowser()) return info;
1350
- const w = globalThis.window;
1351
- const nav = globalThis.navigator;
1352
- const doc = globalThis.document;
1353
- try {
1354
- if (typeof nav.language === "string") info.locale = nav.language;
1355
- } catch {
1356
- }
1357
- try {
1358
- info.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
1359
- } catch {
1360
- }
1361
- try {
1362
- if (w.screen) {
1363
- info.screenWidth = w.screen.width;
1364
- info.screenHeight = w.screen.height;
1365
- }
1366
- info.viewportWidth = w.innerWidth;
1367
- info.viewportHeight = w.innerHeight;
1368
- info.devicePixelRatio = w.devicePixelRatio;
1369
- } catch {
1370
- }
1371
- try {
1372
- const ua = nav.userAgent ?? "";
1373
- const parsed = parseUserAgent(ua);
1374
- Object.assign(info, parsed);
1375
- } catch {
1376
- }
1377
- try {
1378
- const uaData = nav.userAgentData;
1379
- if (uaData?.platform && !info.os) info.os = uaData.platform;
1380
- if (uaData?.brands && !info.browser) {
1381
- const real = uaData.brands.find(
1382
- (b) => !/Not[ .;A]*Brand/i.test(b.brand) && !/Chromium/i.test(b.brand)
1383
- );
1384
- if (real) {
1385
- info.browser = real.brand;
1386
- info.browserVersion = real.version;
1387
- }
1388
- }
1389
- } catch {
1390
- }
1391
- void doc;
1392
- return info;
1393
- }
1394
- function parseUserAgent(ua) {
1395
- const out = {};
1396
- if (/iPad|iPhone|iPod/.test(ua)) {
1397
- out.os = "iOS";
1398
- const m = ua.match(/OS (\d+[._]\d+(?:[._]\d+)?)/);
1399
- if (m?.[1]) out.osVersion = m[1].replace(/_/g, ".");
1400
- } else if (/Android/.test(ua)) {
1401
- out.os = "Android";
1402
- const m = ua.match(/Android (\d+(?:\.\d+)*)/);
1403
- if (m?.[1]) out.osVersion = m[1];
1404
- } else if (/Windows/.test(ua)) {
1405
- out.os = "Windows";
1406
- const m = ua.match(/Windows NT (\d+\.\d+)/);
1407
- if (m?.[1]) out.osVersion = m[1];
1408
- } else if (/Mac OS X|Macintosh/.test(ua)) {
1409
- out.os = "macOS";
1410
- const m = ua.match(/Mac OS X (\d+[._]\d+(?:[._]\d+)?)/);
1411
- if (m?.[1]) out.osVersion = m[1].replace(/_/g, ".");
1412
- } else if (/Linux/.test(ua)) {
1413
- out.os = "Linux";
1414
- }
1415
- if (/Edg\/(\d+(?:\.\d+)*)/.test(ua)) {
1416
- out.browser = "Edge";
1417
- out.browserVersion = ua.match(/Edg\/(\d+(?:\.\d+)*)/)?.[1];
1418
- } else if (/Firefox\/(\d+(?:\.\d+)*)/.test(ua)) {
1419
- out.browser = "Firefox";
1420
- out.browserVersion = ua.match(/Firefox\/(\d+(?:\.\d+)*)/)?.[1];
1421
- } else if (/OPR\/(\d+(?:\.\d+)*)/.test(ua)) {
1422
- out.browser = "Opera";
1423
- out.browserVersion = ua.match(/OPR\/(\d+(?:\.\d+)*)/)?.[1];
1424
- } else if (/Chrome\/(\d+(?:\.\d+)*)/.test(ua)) {
1425
- out.browser = "Chrome";
1426
- out.browserVersion = ua.match(/Chrome\/(\d+(?:\.\d+)*)/)?.[1];
1427
- } else if (/Version\/(\d+(?:\.\d+)*).*Safari/.test(ua)) {
1428
- out.browser = "Safari";
1429
- out.browserVersion = ua.match(/Version\/(\d+(?:\.\d+)*)/)?.[1];
1430
- }
1431
- return out;
1432
- }
1433
-
1434
- // src/auto-track.ts
1435
- var DEFAULT_AUTO_TRACK = {
1436
- sessions: true,
1437
- pageViews: true,
1438
- deviceInfo: true,
1439
- clicks: true,
1440
- webVitals: true,
1441
- errors: true
1442
- };
1443
- var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
1444
- var EMPTY_ACQUISITION = {
1445
- utm_source: "",
1446
- utm_medium: "",
1447
- utm_campaign: "",
1448
- utm_content: "",
1449
- utm_term: "",
1450
- referrer: "",
1451
- gclid: "",
1452
- fbclid: "",
1453
- msclkid: "",
1454
- ttclid: "",
1455
- li_fat_id: "",
1456
- twclid: ""
1457
- };
1458
- var AutoTracker = class {
1459
- constructor(cfg, track) {
1460
- this.cfg = cfg;
1461
- this.track = track;
1462
- this.session = null;
1463
- this.cleanups = [];
1464
- /**
1465
- * Stable per-page-view identifier. Minted at every `page.viewed`
1466
- * emission and attached to every subsequent event until the next
1467
- * `page.viewed`. Lets dashboards correlate "user clicked X" to
1468
- * "user viewed page Y" without timestamp arithmetic — the canonical
1469
- * Mixpanel `$current_url` / Segment `pageId` pattern.
1470
- *
1471
- * Null until the first `page.viewed` fires (which happens at SDK
1472
- * install if `autoTrack.pageViews !== false`).
1473
- */
1474
- this.pageviewId = null;
1475
- }
1476
- install() {
1477
- if (!isBrowserSafe()) return;
1478
- if (this.cfg.sessions) this.installSessionTracking();
1479
- if (this.cfg.pageViews) this.installPageViewTracking();
1480
- if (this.cfg.clicks) this.installClickTracking();
1481
- }
1482
- uninstall() {
1483
- while (this.cleanups.length) {
1484
- const fn = this.cleanups.pop();
1485
- try {
1486
- fn?.();
1487
- } catch {
1488
- }
1489
- }
1490
- if (this.session && !this.session.endedSent) {
1491
- this.emitSessionEnd();
1492
- }
1493
- this.session = null;
1494
- }
1495
- /** Exposed for tests + consumers that want to reset the session manually. */
1496
- resetSession() {
1497
- if (this.session && !this.session.endedSent) this.emitSessionEnd();
1498
- this.pageviewId = null;
1499
- this.session = this.startNewSession();
1500
- this.emitSessionStart();
1501
- }
1502
- /** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
1503
- get currentSessionId() {
1504
- return this.session?.sessionId ?? null;
1505
- }
1506
- /** Stable per-page-view ID. Null before the first page.viewed has fired. */
1507
- get currentPageviewId() {
1508
- return this.pageviewId;
1509
- }
1510
- /**
1511
- * Per-session acquisition context — utm_* + referrer, captured once
1512
- * at session start. Returns empty strings when there's no session
1513
- * (Node, before init, after uninstall) so callers can spread without
1514
- * conditional logic. Bank-grade rule: capture once, attach to every
1515
- * event of the session, don't re-read on every track() (the URL
1516
- * changes via SPA pushState; the source-of-record is the URL we
1517
- * landed on).
1518
- */
1519
- get currentAcquisition() {
1520
- return this.session?.acquisition ?? EMPTY_ACQUISITION;
1521
- }
1522
- // ---------- sessions ----------
1523
- installSessionTracking() {
1524
- this.session = this.startNewSession();
1525
- this.emitSessionStart();
1526
- const onVisChange = () => {
1527
- if (!this.session) return;
1528
- const doc2 = globalThis.document;
1529
- if (doc2.visibilityState === "hidden") {
1530
- this.session.hiddenAt = Date.now();
1531
- } else if (doc2.visibilityState === "visible") {
1532
- const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1533
- if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1534
- this.emitSessionEnd();
1535
- this.pageviewId = null;
1536
- this.session = this.startNewSession();
1537
- this.emitSessionStart();
1538
- } else {
1539
- this.session.hiddenAt = null;
1540
- }
1541
- }
1542
- };
1543
- const onPageHide = () => this.emitSessionEnd();
1544
- const w = globalThis.window;
1545
- const doc = globalThis.document;
1546
- doc.addEventListener("visibilitychange", onVisChange);
1547
- w.addEventListener("pagehide", onPageHide);
1548
- w.addEventListener("beforeunload", onPageHide);
1549
- this.cleanups.push(() => {
1550
- doc.removeEventListener("visibilitychange", onVisChange);
1551
- w.removeEventListener("pagehide", onPageHide);
1552
- w.removeEventListener("beforeunload", onPageHide);
1553
- });
1554
- }
1555
- startNewSession() {
1556
- return {
1557
- sessionId: mintSessionId(),
1558
- startedAt: Date.now(),
1559
- hiddenAt: null,
1560
- endedSent: false,
1561
- acquisition: captureAcquisition()
1562
- };
1563
- }
1564
- emitSessionStart() {
1565
- if (!this.session) return;
1566
- this.track("session.started", { sessionId: this.session.sessionId });
1567
- }
1568
- emitSessionEnd() {
1569
- if (!this.session || this.session.endedSent) return;
1570
- const duration = Date.now() - this.session.startedAt;
1571
- this.track("session.ended", {
1572
- sessionId: this.session.sessionId,
1573
- durationMs: duration
1574
- });
1575
- this.session.endedSent = true;
1576
- }
1577
- // ---------- page views ----------
1578
- installPageViewTracking() {
1579
- const w = globalThis.window;
1580
- const doc = globalThis.document;
1581
- let lastFiredAt = 0;
1582
- let lastFiredUrl = "";
1583
- const DEDUP_WINDOW_MS = 250;
1584
- const fire = (force = false) => {
1585
- const loc = w.location;
1586
- const url = loc.href;
1587
- const now = Date.now();
1588
- if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
1589
- lastFiredAt = now;
1590
- lastFiredUrl = url;
1591
- this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
1592
- this.track("page.viewed", {
1593
- pageviewId: this.pageviewId,
1594
- path: loc.pathname,
1595
- url,
1596
- search: loc.search || void 0,
1597
- hash: loc.hash || void 0,
1598
- title: doc.title,
1599
- // referrer only on the first hit of the session — afterward it's
1600
- // always our previous URL, which isn't useful.
1601
- referrer: doc.referrer || void 0
1602
- });
1603
- };
1604
- fire();
1605
- const origPush = w.history.pushState;
1606
- const origReplace = w.history.replaceState;
1607
- function patchedPush(data, unused, url) {
1608
- origPush.apply(this, [data, unused, url]);
1609
- queueMicrotask(fire);
1610
- }
1611
- function patchedReplace(data, unused, url) {
1612
- origReplace.apply(this, [data, unused, url]);
1613
- queueMicrotask(fire);
1614
- }
1615
- w.history.pushState = patchedPush;
1616
- w.history.replaceState = patchedReplace;
1617
- const onPopState = () => fire(true);
1618
- w.addEventListener("popstate", onPopState);
1619
- this.cleanups.push(() => {
1620
- if (w.history.pushState === patchedPush) {
1621
- w.history.pushState = origPush;
1622
- }
1623
- if (w.history.replaceState === patchedReplace) {
1624
- w.history.replaceState = origReplace;
1625
- }
1626
- w.removeEventListener("popstate", onPopState);
1627
- });
1628
- }
1629
- // ---------- click autocapture ----------
1630
- /**
1631
- * Global click tracking — Mixpanel / Amplitude style autocapture.
1632
- * Fires `element.clicked` for every interactive click with the
1633
- * target element's selector path, text content, tag, href, data-*
1634
- * attributes, and viewport coordinates. Powers the funnel /
1635
- * attribution USP: "users who clicked X then converted within
1636
- * 7 days." Default ON because behavioural attribution is the
1637
- * core product promise.
1638
- *
1639
- * Privacy guardrails:
1640
- * - Skip clicks ON inputs / textareas / selects (form interaction
1641
- * isn't button telemetry; the dev should track form submits
1642
- * deliberately via track('form_submitted'))
1643
- * - Skip clicks INSIDE [type="password"] and password-class
1644
- * elements
1645
- * - Skip clicks inside elements opted out via class="cd-noTrack"
1646
- * or data-cd-noTrack attribute (Mixpanel's exact opt-out
1647
- * idiom — most devs already know it)
1648
- * - Capture text content but cap at 64 chars and trim — never
1649
- * more than what you'd see on a button label
1650
- *
1651
- * Volume guardrails:
1652
- * - Coalesce double-clicks within 100ms (React's synthetic click
1653
- * pattern + browser's native dblclick can fire twice)
1654
- * - Listen on document at capture phase so we see the click
1655
- * before any framework's own handlers stop propagation
1656
- */
1657
- installClickTracking() {
1658
- const w = globalThis.window;
1659
- const doc = globalThis.document;
1660
- let lastFiredAt = 0;
1661
- let lastFiredTarget = null;
1662
- const COALESCE_MS = 100;
1663
- const TEXT_CAP = 64;
1664
- const onClick = (ev) => {
1665
- const target = ev.target;
1666
- if (!target || !(target instanceof Element)) return;
1667
- const now = Date.now();
1668
- if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
1669
- lastFiredAt = now;
1670
- lastFiredTarget = target;
1671
- const actionable = closestActionable(target);
1672
- const clicked = actionable || target;
1673
- if (isFormInput(clicked)) return;
1674
- if (isInOptedOut(clicked)) return;
1675
- if (isInsidePasswordField(clicked)) return;
1676
- const tag = clicked.tagName.toLowerCase();
1677
- const text = trimText(extractText(clicked), TEXT_CAP);
1678
- const href = clicked.href || void 0;
1679
- const linkTarget = clicked.target || void 0;
1680
- const elementId = clicked.id || void 0;
1681
- const role = clicked.getAttribute("role") || void 0;
1682
- const ariaLabel = clicked.getAttribute("aria-label") || void 0;
1683
- const selector = buildSelector(clicked);
1684
- const dataAttrs = collectDataAttrs(clicked);
1685
- const isLink = tag === "a" && !!href;
1686
- const explicitName = clicked.getAttribute("data-cd-event");
1687
- const props = {
1688
- selector,
1689
- tag,
1690
- text,
1691
- elementId,
1692
- role,
1693
- ariaLabel,
1694
- href,
1695
- isLink,
1696
- linkTarget,
1697
- viewportX: ev.clientX,
1698
- viewportY: ev.clientY,
1699
- pageX: ev.pageX,
1700
- pageY: ev.pageY,
1701
- ...dataAttrs
1702
- };
1703
- for (const k of Object.keys(props)) {
1704
- if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
1705
- }
1706
- this.track(explicitName || "element.clicked", props);
1707
- };
1708
- doc.addEventListener("click", onClick, { capture: true, passive: true });
1709
- this.cleanups.push(() => {
1710
- doc.removeEventListener("click", onClick, { capture: true });
1711
- });
1712
- }
1713
- };
1714
- function closestActionable(el) {
1715
- return el.closest("[data-cd-event]") || el.closest("[data-cd-noTrack]") || el.closest("button, a, [role='button'], [role='link'], input[type='button'], input[type='submit']") || null;
1716
- }
1717
- function isFormInput(el) {
1718
- if (!(el instanceof HTMLElement)) return false;
1719
- const tag = el.tagName.toLowerCase();
1720
- if (tag === "textarea" || tag === "select") return true;
1721
- if (tag === "input") {
1722
- const type = (el.type || "").toLowerCase();
1723
- return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
1724
- }
1725
- return false;
1726
- }
1727
- function isInOptedOut(el) {
1728
- if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
1729
- return false;
1730
- }
1731
- function isInsidePasswordField(el) {
1732
- if (el.closest('input[type="password"]')) return true;
1733
- return false;
1734
- }
1735
- function extractText(el) {
1736
- const clean = (s) => s.replace(/\s+/g, " ").trim();
1737
- const explicit = el.getAttribute("data-cd-track") || el.getAttribute("data-track") || el.getAttribute("data-testid");
1738
- if (explicit) {
1739
- const t = clean(explicit);
1740
- if (t) return t;
1741
- }
1742
- const aria = el.getAttribute("aria-label");
1743
- if (aria) {
1744
- const t = clean(aria);
1745
- if (t) return t;
1746
- }
1747
- const labelledBy = el.getAttribute("aria-labelledby");
1748
- if (labelledBy && typeof el.ownerDocument?.getElementById === "function") {
1749
- const parts = [];
1750
- for (const id of labelledBy.split(/\s+/)) {
1751
- const ref = el.ownerDocument.getElementById(id);
1752
- const t = ref?.textContent ? clean(ref.textContent) : "";
1753
- if (t) parts.push(t);
1754
- }
1755
- if (parts.length > 0) return parts.join(" ");
1756
- }
1757
- if (el instanceof HTMLInputElement && el.value) {
1758
- const t = clean(el.value);
1759
- if (t) return t;
1760
- }
1761
- const text = clean(el.textContent || "");
1762
- if (text) return text;
1763
- const title = el.getAttribute("title");
1764
- if (title) {
1765
- const t = clean(title);
1766
- if (t) return t;
1767
- }
1768
- const img = el.querySelector("img[alt]");
1769
- if (img) {
1770
- const alt = img.getAttribute("alt") ?? "";
1771
- const t = clean(alt);
1772
- if (t) return t;
1773
- }
1774
- const svgTitle = el.querySelector("svg title");
1775
- if (svgTitle?.textContent) {
1776
- const t = clean(svgTitle.textContent);
1777
- if (t) return t;
1778
- }
1779
- return "";
1780
- }
1781
- function trimText(s, cap) {
1782
- if (s.length <= cap) return s;
1783
- return s.slice(0, cap - 1) + "\u2026";
1784
- }
1785
- function buildSelector(el) {
1786
- const parts = [];
1787
- let cur = el;
1788
- let depth = 0;
1789
- while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
1790
- let part = cur.nodeName.toLowerCase();
1791
- if (cur.id) {
1792
- parts.unshift(`${part}#${cur.id}`);
1793
- break;
1794
- }
1795
- if (cur.classList.length > 0) {
1796
- const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
1797
- if (cls) part += `.${cls}`;
1798
- }
1799
- parts.unshift(part);
1800
- cur = cur.parentElement;
1801
- depth++;
1802
- }
1803
- return parts.join(" > ");
1804
- }
1805
- function collectDataAttrs(el) {
1806
- const out = {};
1807
- if (!(el instanceof HTMLElement)) return out;
1808
- for (const name of el.getAttributeNames()) {
1809
- if (!name.startsWith("data-")) continue;
1810
- if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
1811
- if (name === "data-cd-event") continue;
1812
- const value = el.getAttribute(name) || "";
1813
- const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
1814
- out[key] = value;
1815
- }
1816
- return out;
1817
- }
1818
- function isBrowserSafe() {
1819
- return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
1820
- }
1821
- function mintSessionId() {
1822
- const ts = Date.now().toString(36);
1823
- return `sess_${ts}${randomChars(10)}`;
1824
- }
1825
- function captureAcquisition() {
1826
- if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
1827
- const result = { ...EMPTY_ACQUISITION };
1828
- try {
1829
- const w = globalThis.window;
1830
- const params = new URLSearchParams(w.location.search ?? "");
1831
- result.utm_source = params.get("utm_source") ?? "";
1832
- result.utm_medium = params.get("utm_medium") ?? "";
1833
- result.utm_campaign = params.get("utm_campaign") ?? "";
1834
- result.utm_content = params.get("utm_content") ?? "";
1835
- result.utm_term = params.get("utm_term") ?? "";
1836
- result.gclid = params.get("gclid") ?? "";
1837
- result.fbclid = params.get("fbclid") ?? "";
1838
- result.msclkid = params.get("msclkid") ?? "";
1839
- result.ttclid = params.get("ttclid") ?? "";
1840
- result.li_fat_id = params.get("li_fat_id") ?? "";
1841
- result.twclid = params.get("twclid") ?? "";
1842
- } catch {
1843
- }
1844
- try {
1845
- const doc = globalThis.document;
1846
- if (typeof doc.referrer === "string") result.referrer = doc.referrer;
1847
- } catch {
1848
- }
1849
- return result;
1850
- }
1851
-
1852
- // src/debug.ts
1853
- var SENSITIVE_KEY_PATTERNS = [
1854
- /^email$/i,
1855
- /^password$/i,
1856
- /^token$/i,
1857
- /^secret$/i,
1858
- /^card$/i,
1859
- /^phone$/i,
1860
- /password/i,
1861
- /credit_?card/i
1862
- ];
1863
- function findSensitivePropertyKeys(properties) {
1864
- if (!properties) return [];
1865
- const hits = [];
1866
- for (const k of Object.keys(properties)) {
1867
- if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(k))) hits.push(k);
1868
- }
1869
- return hits;
1870
- }
1871
- var ConsoleDebugLogger = class {
1872
- constructor() {
1873
- this.enabled = false;
1874
- this.seen = /* @__PURE__ */ new Set();
1875
- }
1876
- emit(signal, message, context) {
1877
- if (!this.enabled) return;
1878
- if (ONCE_SIGNALS.has(signal)) {
1879
- if (this.seen.has(signal)) return;
1880
- this.seen.add(signal);
1881
- }
1882
- const ctx = context ? ` ${safeJson(context)}` : "";
1883
- console.info(`[crossdeck:${signal}] ${message}${ctx}`);
1884
- }
1885
- };
1886
- var ONCE_SIGNALS = /* @__PURE__ */ new Set([
1887
- "sdk.configured",
1888
- "sdk.first_event_sent",
1889
- "sdk.environment_mismatch"
1890
- ]);
1891
- function safeJson(obj) {
1892
- try {
1893
- return JSON.stringify(obj);
1894
- } catch {
1895
- return "[unserialisable context]";
1896
- }
1897
- }
1898
-
1899
- // src/event-validation.ts
1900
- var DEFAULT_MAX_STRING = 1024;
1901
- var DEFAULT_MAX_BYTES = 8 * 1024;
1902
- var DEFAULT_MAX_DEPTH = 5;
1903
- function validateEventProperties(input, options = {}) {
1904
- const warnings = [];
1905
- if (!input) return { properties: {}, warnings };
1906
- const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1907
- const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1908
- const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1909
- const seen = /* @__PURE__ */ new Set();
1910
- const visit = (value, key, depth) => {
1911
- if (depth > maxDepth) {
1912
- warnings.push({ kind: "depth_exceeded", key });
1913
- return { keep: true, value: "[depth-exceeded]" };
1914
- }
1915
- if (value === null) return { keep: true, value: null };
1916
- const t = typeof value;
1917
- if (t === "string") {
1918
- const s = value;
1919
- if (s.length > maxStringLength) {
1920
- warnings.push({ kind: "truncated_string", key });
1921
- return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
1922
- }
1923
- return { keep: true, value: s };
1924
- }
1925
- if (t === "number") {
1926
- if (!Number.isFinite(value)) {
1927
- warnings.push({ kind: "non_serialisable", key });
1928
- return { keep: true, value: null };
1929
- }
1930
- return { keep: true, value };
1931
- }
1932
- if (t === "boolean") return { keep: true, value };
1933
- if (t === "bigint") {
1934
- warnings.push({ kind: "coerced_bigint", key });
1935
- return { keep: true, value: value.toString() };
1936
- }
1937
- if (t === "function") {
1938
- warnings.push({ kind: "dropped_function", key });
1939
- return { keep: false, value: void 0 };
1940
- }
1941
- if (t === "symbol") {
1942
- warnings.push({ kind: "dropped_symbol", key });
1943
- return { keep: false, value: void 0 };
1944
- }
1945
- if (t === "undefined") {
1946
- warnings.push({ kind: "dropped_undefined", key });
1947
- return { keep: false, value: void 0 };
1948
- }
1949
- if (value instanceof Date) {
1950
- warnings.push({ kind: "coerced_date", key });
1951
- const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
1952
- return { keep: true, value: iso };
1953
- }
1954
- if (value instanceof Error) {
1955
- warnings.push({ kind: "coerced_error", key });
1956
- return {
1957
- keep: true,
1958
- value: {
1959
- name: value.name,
1960
- message: value.message,
1961
- stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
1962
- }
1963
- };
1964
- }
1965
- if (value instanceof Map) {
1966
- warnings.push({ kind: "coerced_map", key });
1967
- const obj = {};
1968
- for (const [k, v] of value.entries()) {
1969
- const subKey = typeof k === "string" ? k : String(k);
1970
- const result = visit(v, `${key}.${subKey}`, depth + 1);
1971
- if (result.keep) obj[subKey] = result.value;
1972
- }
1973
- return { keep: true, value: obj };
1974
- }
1975
- if (value instanceof Set) {
1976
- warnings.push({ kind: "coerced_set", key });
1977
- const arr = [];
1978
- let i = 0;
1979
- for (const v of value.values()) {
1980
- const result = visit(v, `${key}[${i}]`, depth + 1);
1981
- if (result.keep) arr.push(result.value);
1982
- i++;
1983
- }
1984
- return { keep: true, value: arr };
1985
- }
1986
- if (Array.isArray(value)) {
1987
- if (seen.has(value)) {
1988
- warnings.push({ kind: "circular_reference", key });
1989
- return { keep: true, value: "[circular]" };
1990
- }
1991
- seen.add(value);
1992
- const out = [];
1993
- for (let i = 0; i < value.length; i++) {
1994
- const result = visit(value[i], `${key}[${i}]`, depth + 1);
1995
- if (result.keep) out.push(result.value);
1996
- }
1997
- seen.delete(value);
1998
- return { keep: true, value: out };
1999
- }
2000
- if (t === "object") {
2001
- const obj = value;
2002
- if (seen.has(obj)) {
2003
- warnings.push({ kind: "circular_reference", key });
2004
- return { keep: true, value: "[circular]" };
2005
- }
2006
- seen.add(obj);
2007
- const out = {};
2008
- for (const k of Object.keys(obj)) {
2009
- const result = visit(obj[k], `${key}.${k}`, depth + 1);
2010
- if (result.keep) out[k] = result.value;
2011
- }
2012
- seen.delete(obj);
2013
- return { keep: true, value: out };
2014
- }
2015
- warnings.push({ kind: "non_serialisable", key });
2016
- try {
2017
- return { keep: true, value: String(value) };
2018
- } catch {
2019
- return { keep: false, value: void 0 };
2020
- }
2021
- };
2022
- const cleaned = {};
2023
- for (const k of Object.keys(input)) {
2024
- const result = visit(input[k], k, 0);
2025
- if (result.keep) cleaned[k] = result.value;
2026
- }
2027
- const serialised = safeStringify(cleaned);
2028
- if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
2029
- warnings.push({ kind: "size_cap_exceeded", key: "*" });
2030
- const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
2031
- let currentSize = byteLength(serialised);
2032
- for (const { k } of sizes) {
2033
- if (currentSize <= maxBatchPropertyBytes) break;
2034
- currentSize -= sizes.find((s) => s.k === k).size;
2035
- delete cleaned[k];
2036
- }
2037
- cleaned.__truncated = true;
2038
- }
2039
- return { properties: cleaned, warnings };
2040
- }
2041
- function safeStringify(v) {
2042
- try {
2043
- return JSON.stringify(v) ?? null;
2044
- } catch {
2045
- return null;
2046
- }
2047
- }
2048
- function byteLength(s) {
2049
- if (typeof TextEncoder !== "undefined") {
2050
- return new TextEncoder().encode(s).length;
2051
- }
2052
- return s.length * 4;
2053
- }
2054
-
2055
- // src/super-properties.ts
2056
- var KEY_SUPER = "super_props";
2057
- var KEY_GROUPS = "groups";
2058
- var SuperPropertyStore = class {
2059
- constructor(storage, prefix) {
2060
- this.storage = storage;
2061
- this.prefix = prefix;
2062
- this.superProps = {};
2063
- this.groups = {};
2064
- this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
2065
- this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
2066
- }
2067
- // ---------- super properties ----------
2068
- /**
2069
- * Merge new keys into the super-property bag. Returns a snapshot of
2070
- * the resulting bag. Values that are `null` are deleted (Mixpanel
2071
- * semantics — explicit null = "stop tracking this key").
2072
- */
2073
- register(props) {
2074
- for (const [k, v] of Object.entries(props)) {
2075
- if (v === null) {
2076
- delete this.superProps[k];
2077
- } else if (v !== void 0) {
2078
- this.superProps[k] = v;
2079
- }
2080
- }
2081
- writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
2082
- return { ...this.superProps };
2083
- }
2084
- /** Remove a single super-property key. Idempotent. */
2085
- unregister(key) {
2086
- if (key in this.superProps) {
2087
- delete this.superProps[key];
2088
- writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
2089
- }
2090
- }
2091
- /** Snapshot of the current super-property bag. */
2092
- getSuperProperties() {
2093
- return { ...this.superProps };
2094
- }
2095
- // ---------- groups ----------
2096
- /**
2097
- * Set a group membership. Passing `id: null` clears the membership
2098
- * for that group type — the SDK stops attaching it to events.
2099
- */
2100
- setGroup(type, id, traits) {
2101
- if (id === null) {
2102
- delete this.groups[type];
2103
- } else {
2104
- this.groups[type] = traits !== void 0 ? { id, traits } : { id };
2105
- }
2106
- writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
2107
- }
2108
- /**
2109
- * Snapshot of the current groups map, keyed by group type. Returned
2110
- * shape mirrors what the SDK attaches to every event as
2111
- * `$groups.{type}`. The `traits` sub-object is the most-recent
2112
- * traits payload passed to `setGroup` for that type; null when none.
2113
- */
2114
- getGroups() {
2115
- return JSON.parse(JSON.stringify(this.groups));
2116
- }
2117
- /**
2118
- * The flat `{ type: id }` projection used for event-attachment. Stable
2119
- * for fast every-event merge — we don't want to JSON-clone on each
2120
- * track() call.
2121
- */
2122
- getGroupIds() {
2123
- const out = {};
2124
- for (const [type, info] of Object.entries(this.groups)) {
2125
- out[type] = info.id;
2126
- }
2127
- return out;
2128
- }
2129
- /** Wipe both bags. Called by Crossdeck.reset() (logout). */
2130
- clear() {
2131
- this.superProps = {};
2132
- this.groups = {};
2133
- try {
2134
- this.storage.removeItem(this.prefix + KEY_SUPER);
2135
- } catch {
2136
- }
2137
- try {
2138
- this.storage.removeItem(this.prefix + KEY_GROUPS);
2139
- } catch {
2140
- }
2141
- }
2142
- };
2143
- function readJson(storage, key) {
2144
- let raw;
2145
- try {
2146
- raw = storage.getItem(key);
2147
- } catch {
2148
- return null;
2149
- }
2150
- if (!raw) return null;
2151
- try {
2152
- return JSON.parse(raw);
2153
- } catch {
2154
- return null;
2155
- }
2156
- }
2157
- function writeJson(storage, key, value) {
2158
- try {
2159
- storage.setItem(key, JSON.stringify(value));
2160
- } catch {
2161
- }
2162
- }
2163
-
2164
- // src/web-vitals.ts
2165
- var WebVitalsTracker = class {
2166
- constructor(cfg, report) {
2167
- this.cfg = cfg;
2168
- this.report = report;
2169
- this.observers = [];
2170
- this.flushed = /* @__PURE__ */ new Set();
2171
- this.cls = 0;
2172
- this.clsEntries = [];
2173
- this.inp = 0;
2174
- this.cleanups = [];
2175
- }
2176
- install() {
2177
- if (!this.cfg.enabled) return;
2178
- if (typeof PerformanceObserver === "undefined") return;
2179
- if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
2180
- const doc = globalThis.document;
2181
- try {
2182
- const navObserver = new PerformanceObserver((list) => {
2183
- for (const entry of list.getEntries()) {
2184
- const e = entry;
2185
- if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
2186
- this.flushed.add("ttfb");
2187
- this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
2188
- }
2189
- }
2190
- });
2191
- navObserver.observe({ type: "navigation", buffered: true });
2192
- this.observers.push(navObserver);
2193
- } catch {
2194
- }
2195
- try {
2196
- const paintObserver = new PerformanceObserver((list) => {
2197
- for (const entry of list.getEntries()) {
2198
- if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
2199
- this.flushed.add("fcp");
2200
- this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
2201
- }
2202
- }
2203
- });
2204
- paintObserver.observe({ type: "paint", buffered: true });
2205
- this.observers.push(paintObserver);
2206
- } catch {
2207
- }
2208
- let lcpValue = 0;
2209
- try {
2210
- const lcpObserver = new PerformanceObserver((list) => {
2211
- const entries = list.getEntries();
2212
- const last = entries[entries.length - 1];
2213
- if (last) lcpValue = last.startTime;
2214
- });
2215
- lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
2216
- this.observers.push(lcpObserver);
2217
- } catch {
2218
- }
2219
- try {
2220
- const clsObserver = new PerformanceObserver((list) => {
2221
- for (const entry of list.getEntries()) {
2222
- const e = entry;
2223
- if (typeof e.value === "number" && !e.hadRecentInput) {
2224
- this.cls += e.value;
2225
- this.clsEntries.push(entry);
2226
- }
2227
- }
2228
- });
2229
- clsObserver.observe({ type: "layout-shift", buffered: true });
2230
- this.observers.push(clsObserver);
2231
- } catch {
2232
- }
2233
- try {
2234
- const eventObserver = new PerformanceObserver((list) => {
2235
- for (const entry of list.getEntries()) {
2236
- const e = entry;
2237
- if (e.interactionId && e.duration > this.inp) {
2238
- this.inp = e.duration;
2239
- }
2240
- }
2241
- });
2242
- try {
2243
- eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
2244
- } catch {
2245
- eventObserver.observe({ type: "first-input", buffered: true });
2246
- }
2247
- this.observers.push(eventObserver);
2248
- } catch {
2249
- }
2250
- const flush = () => {
2251
- if (lcpValue > 0 && !this.flushed.has("lcp")) {
2252
- this.flushed.add("lcp");
2253
- this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
2254
- }
2255
- if (this.cls > 0 && !this.flushed.has("cls")) {
2256
- this.flushed.add("cls");
2257
- this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
2258
- }
2259
- if (this.inp > 0 && !this.flushed.has("inp")) {
2260
- this.flushed.add("inp");
2261
- this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
2262
- }
2263
- };
2264
- const onHidden = () => {
2265
- if (doc.visibilityState === "hidden") flush();
2266
- };
2267
- doc.addEventListener("visibilitychange", onHidden);
2268
- globalThis.window.addEventListener("pagehide", flush);
2269
- this.cleanups.push(() => {
2270
- doc.removeEventListener("visibilitychange", onHidden);
2271
- globalThis.window.removeEventListener("pagehide", flush);
2272
- });
2273
- }
2274
- uninstall() {
2275
- for (const o of this.observers) {
2276
- try {
2277
- o.disconnect();
2278
- } catch {
2279
- }
2280
- }
2281
- this.observers = [];
2282
- for (const fn of this.cleanups.splice(0)) {
2283
- try {
2284
- fn();
2285
- } catch {
2286
- }
2287
- }
2288
- }
2289
- };
2290
-
2291
- // src/consent.ts
2292
- var ALL_GRANTED = {
2293
- analytics: true,
2294
- marketing: true,
2295
- errors: true
2296
- };
2297
- var ConsentManager = class {
2298
- constructor(options) {
2299
- this.state = { ...ALL_GRANTED };
2300
- this.dntDenied = false;
2301
- if (options?.respectDnt && this.detectDnt()) {
2302
- this.dntDenied = true;
2303
- this.state = { analytics: false, marketing: false, errors: false };
2304
- }
2305
- }
2306
- /**
2307
- * Merge new dimensions onto the current state. Returns the resulting
2308
- * snapshot. DNT-derived denies cannot be flipped back on by a `set`
2309
- * call — once the browser says "don't track", we don't track even if
2310
- * the developer code disagrees. That's the contract.
2311
- */
2312
- set(partial) {
2313
- if (this.dntDenied) return { ...this.state };
2314
- for (const k of Object.keys(partial)) {
2315
- const v = partial[k];
2316
- if (typeof v === "boolean") this.state[k] = v;
2317
- }
2318
- return { ...this.state };
2319
- }
2320
- /** Snapshot of the current state. */
2321
- get() {
2322
- return { ...this.state };
2323
- }
2324
- /** Convenience getters for hot paths. */
2325
- get analytics() {
2326
- return this.state.analytics;
2327
- }
2328
- get marketing() {
2329
- return this.state.marketing;
2330
- }
2331
- get errors() {
2332
- return this.state.errors;
2333
- }
2334
- /** True iff the constructor detected and applied DNT. */
2335
- get isDntDenied() {
2336
- return this.dntDenied;
2337
- }
2338
- detectDnt() {
2339
- try {
2340
- const nav = globalThis.navigator;
2341
- if (!nav) return false;
2342
- const sources = [
2343
- nav.doNotTrack,
2344
- nav.msDoNotTrack,
2345
- globalThis.doNotTrack
2346
- ];
2347
- return sources.some((v) => v === "1" || v === "yes");
2348
- } catch {
2349
- return false;
2350
- }
2351
- }
2352
- };
2353
- var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
2354
- var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
2355
- var REPLACEMENT_EMAIL = "<email>";
2356
- var REPLACEMENT_CARD = "<card>";
2357
- function scrubPii(value) {
2358
- if (!value) return value;
2359
- return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
2360
- }
2361
- function scrubPiiFromProperties(properties) {
2362
- const out = {};
2363
- for (const k of Object.keys(properties)) {
2364
- out[k] = scrubValue(properties[k]);
2365
- }
2366
- return out;
2367
- }
2368
- function scrubValue(v) {
2369
- if (typeof v === "string") return scrubPii(v);
2370
- if (Array.isArray(v)) return v.map(scrubValue);
2371
- if (v && typeof v === "object" && v.constructor === Object) {
2372
- return scrubPiiFromProperties(v);
2373
- }
2374
- return v;
2375
- }
2376
-
2377
- // src/breadcrumbs.ts
2378
- var BreadcrumbBuffer = class {
2379
- constructor(maxSize = 50) {
2380
- this.maxSize = maxSize;
2381
- this.items = [];
2382
- }
2383
- add(crumb) {
2384
- this.items.push(crumb);
2385
- if (this.items.length > this.maxSize) {
2386
- this.items.shift();
2387
- }
2388
- }
2389
- /** Defensive copy — caller can read freely without mutating buffer state. */
2390
- snapshot() {
2391
- return this.items.slice();
2392
- }
2393
- clear() {
2394
- this.items = [];
2395
- }
2396
- get size() {
2397
- return this.items.length;
2398
- }
2399
- };
2400
-
2401
- // src/stack-parser.ts
2402
- function parseStack(stack) {
2403
- if (!stack || typeof stack !== "string") return [];
2404
- const lines = stack.split("\n");
2405
- const frames = [];
2406
- for (const line of lines) {
2407
- const trimmed = line.trim();
2408
- if (!trimmed) continue;
2409
- const frame = parseLine(trimmed);
2410
- if (frame) frames.push(frame);
2411
- }
2412
- return frames;
2413
- }
2414
- function parseLine(line) {
2415
- let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
2416
- if (m) {
2417
- return buildFrame({
2418
- function: m[1],
2419
- filename: m[2],
2420
- lineno: parseInt(m[3], 10),
2421
- colno: parseInt(m[4], 10),
2422
- raw: line
2423
- });
2424
- }
2425
- m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
2426
- if (m) {
2427
- return buildFrame({
2428
- function: "?",
2429
- filename: m[1],
2430
- lineno: parseInt(m[2], 10),
2431
- colno: parseInt(m[3], 10),
2432
- raw: line
2433
- });
2434
- }
2435
- m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
2436
- if (m) {
2437
- return buildFrame({
2438
- function: m[1] || "?",
2439
- filename: m[2],
2440
- lineno: parseInt(m[3], 10),
2441
- colno: parseInt(m[4], 10),
2442
- raw: line
2443
- });
2444
- }
2445
- if (/^\w*Error/.test(line) || !line.includes(":")) {
2446
- return null;
2447
- }
2448
- return {
2449
- function: "?",
2450
- filename: "",
2451
- lineno: 0,
2452
- colno: 0,
2453
- in_app: true,
2454
- raw: line
2455
- };
2456
- }
2457
- function buildFrame(input) {
2458
- return {
2459
- function: input.function || "?",
2460
- filename: input.filename,
2461
- lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
2462
- colno: Number.isFinite(input.colno) ? input.colno : 0,
2463
- in_app: isInAppFrame(input.filename),
2464
- raw: input.raw
2465
- };
2466
- }
2467
- function isInAppFrame(filename) {
2468
- if (!filename) return true;
2469
- if (/^(?:chrome|moz|safari|webkit)-extension:\/\//.test(filename)) return false;
2470
- if (/\bcdn\.jsdelivr\.net\b/.test(filename)) return false;
2471
- if (/\bunpkg\.com\b/.test(filename)) return false;
2472
- if (/\bgoogletagmanager\.com\b/.test(filename)) return false;
2473
- if (/\bgoogle-analytics\.com\b/.test(filename)) return false;
2474
- if (/\b@cross-deck\/web\b/.test(filename)) return false;
2475
- if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
2476
- return true;
2477
- }
2478
- function fingerprintError(message, frames, location) {
2479
- const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
2480
- const parts = [
2481
- (message || "").slice(0, 200),
2482
- ...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
2483
- ];
2484
- if (inAppFrames.length === 0 && location) {
2485
- const loc = [
2486
- location.errorType ?? "",
2487
- location.filename ?? "",
2488
- location.lineno ?? "",
2489
- location.colno ?? ""
2490
- ].join(":");
2491
- if (loc !== ":::") parts.push(loc);
2492
- }
2493
- return djb2Hex(parts.join("|"));
2494
- }
2495
- function djb2Hex(input) {
2496
- let h = 5381;
2497
- for (let i = 0; i < input.length; i++) {
2498
- h = (h << 5) + h + input.charCodeAt(i) | 0;
2499
- }
2500
- return (h >>> 0).toString(16).padStart(8, "0");
2501
- }
2502
-
2503
- // src/error-capture.ts
2504
- var DEFAULT_ERROR_CAPTURE = {
2505
- enabled: true,
2506
- onError: true,
2507
- onUnhandledRejection: true,
2508
- wrapFetch: true,
2509
- wrapXhr: true,
2510
- captureConsole: false,
2511
- ignoreErrors: [
2512
- // Classic browser noise. These aren't application bugs.
2513
- "ResizeObserver loop limit exceeded",
2514
- "ResizeObserver loop completed with undelivered notifications",
2515
- "Non-Error promise rejection captured"
2516
- // NOTE: We deliberately do NOT drop cross-origin "Script error."
2517
- // events here. They used to be silently filtered as noise, but
2518
- // silent drops are the opposite of "developer sleeps well at
2519
- // night". We now capture them with a clear label and the
2520
- // `cross_origin` tag so the dashboard surfaces them as a
2521
- // distinct, actionable category (the fix is always the same —
2522
- // add `crossorigin="anonymous"` to the script tag + CORS
2523
- // headers on the script's origin). Apps that genuinely want
2524
- // them muted can re-add "Script error" to ignoreErrors via
2525
- // init config.
2526
- ],
2527
- allowUrls: [],
2528
- denyUrls: [
2529
- // Common third-party extensions that pollute error streams.
2530
- /^chrome-extension:\/\//,
2531
- /^moz-extension:\/\//,
2532
- /^safari-extension:\/\//,
2533
- /^webkit-extension:\/\//,
2534
- /^safari-web-extension:\/\//
2535
- ],
2536
- sampleRate: 1,
2537
- maxPerFingerprintPerMinute: 5,
2538
- maxPerSession: 100
2539
- };
2540
- var ErrorTracker = class {
2541
- constructor(opts) {
2542
- this.opts = opts;
2543
- this.installed = false;
2544
- this.cleanups = [];
2545
- this._reporting = false;
2546
- this.sessionCount = 0;
2547
- this.fingerprintWindow = /* @__PURE__ */ new Map();
2548
- }
2549
- install() {
2550
- if (this.installed) return;
2551
- if (!this.opts.config.enabled) return;
2552
- if (typeof globalThis === "undefined" || !("window" in globalThis)) return;
2553
- const w = globalThis.window;
2554
- if (this.opts.config.onError) this.installOnErrorListener(w);
2555
- if (this.opts.config.onUnhandledRejection) this.installRejectionListener(w);
2556
- if (this.opts.config.wrapFetch) this.installFetchWrap(w);
2557
- if (this.opts.config.wrapXhr) this.installXhrWrap(w);
2558
- if (this.opts.config.captureConsole) this.installConsoleWrap();
2559
- this.installed = true;
2560
- }
2561
- uninstall() {
2562
- for (const fn of this.cleanups.splice(0)) {
2563
- try {
2564
- fn();
2565
- } catch {
2566
- }
2567
- }
2568
- this.installed = false;
2569
- }
2570
- /**
2571
- * Manual API. Either an Error instance or any unknown value (we
2572
- * coerce). Returns silently — never throws.
2573
- */
2574
- captureError(error, options) {
2575
- if (!this.opts.isConsented()) return;
2576
- try {
2577
- const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
2578
- if (options?.context) captured.context = { ...captured.context, ...options.context };
2579
- if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
2580
- this.maybeReport(captured);
2581
- } catch {
2582
- }
2583
- }
2584
- /**
2585
- * Capture a non-error event as an issue. For "we hit a soft-warning
2586
- * code path" / "deprecated API used" kinds of signals. Pairs with
2587
- * Sentry's captureMessage().
2588
- */
2589
- captureMessage(message, level = "info") {
2590
- if (!this.opts.isConsented()) return;
2591
- try {
2592
- const captured = {
2593
- timestamp: Date.now(),
2594
- kind: "error.message",
2595
- level,
2596
- message,
2597
- errorType: null,
2598
- frames: [],
2599
- rawStack: null,
2600
- filename: null,
2601
- lineno: null,
2602
- colno: null,
2603
- fingerprint: fingerprintError(message, []),
2604
- breadcrumbs: this.opts.breadcrumbs.snapshot(),
2605
- context: this.opts.getContext(),
2606
- tags: this.opts.getTags()
2607
- };
2608
- this.maybeReport(captured);
2609
- } catch {
2610
- }
2611
- }
2612
- // ============================================================
2613
- // Listener installation
2614
- // ============================================================
2615
- installOnErrorListener(w) {
2616
- const handler = (event) => {
2617
- if (this._reporting) return;
2618
- if (!this.opts.isConsented()) return;
2619
- try {
2620
- this._reporting = true;
2621
- const captured = this.buildFromErrorEvent(event);
2622
- this.maybeReport(captured);
2623
- } catch {
2624
- } finally {
2625
- this._reporting = false;
2626
- }
2627
- };
2628
- w.addEventListener("error", handler, true);
2629
- this.cleanups.push(() => w.removeEventListener("error", handler, true));
2630
- }
2631
- installRejectionListener(w) {
2632
- const handler = (event) => {
2633
- if (this._reporting) return;
2634
- if (!this.opts.isConsented()) return;
2635
- try {
2636
- this._reporting = true;
2637
- const captured = this.buildFromUnknown(
2638
- event.reason,
2639
- "error.unhandledrejection",
2640
- "error"
2641
- );
2642
- this.maybeReport(captured);
2643
- } catch {
2644
- } finally {
2645
- this._reporting = false;
2646
- }
2647
- };
2648
- w.addEventListener("unhandledrejection", handler);
2649
- this.cleanups.push(() => w.removeEventListener("unhandledrejection", handler));
2650
- }
2651
- /**
2652
- * Wrap fetch() so failed HTTP requests get auto-captured. We do NOT
2653
- * call this an "error" for 4xx (those are often expected — auth
2654
- * required, validation failed). Only 5xx + network failures fire.
2655
- */
2656
- installFetchWrap(w) {
2657
- const origFetch = w.fetch?.bind(w);
2658
- if (!origFetch) return;
2659
- const wrapped = async (...args) => {
2660
- const input = args[0];
2661
- const init = args[1] ?? {};
2662
- const url = typeof input === "string" ? input : input?.url ?? "";
2663
- const method = (init.method || "GET").toUpperCase();
2664
- const start = Date.now();
2665
- if (!isSelfRequest(url, this.opts.selfHostname)) {
2666
- this.opts.breadcrumbs.add({
2667
- timestamp: start,
2668
- category: "http",
2669
- message: `${method} ${url}`,
2670
- data: { url, method }
2671
- });
2672
- }
2673
- try {
2674
- const response = await origFetch(...args);
2675
- if (response.status >= 500 && this.opts.isConsented()) {
2676
- if (!isSelfRequest(url, this.opts.selfHostname)) {
2677
- this.captureHttp({
2678
- url,
2679
- method,
2680
- status: response.status,
2681
- statusText: response.statusText
2682
- });
2683
- }
2684
- }
2685
- return response;
2686
- } catch (err) {
2687
- if (this.opts.isConsented() && !url.includes("api.cross-deck.com")) {
2688
- this.captureHttp({
2689
- url,
2690
- method,
2691
- status: 0,
2692
- statusText: err instanceof Error ? err.message : "network error"
2693
- });
2694
- }
2695
- throw err;
2696
- }
2697
- };
2698
- w.fetch = wrapped;
2699
- this.cleanups.push(() => {
2700
- if (w.fetch === wrapped) w.fetch = origFetch;
2701
- });
2702
- }
2703
- /**
2704
- * Wrap XMLHttpRequest for legacy consumers (jQuery $.ajax under the
2705
- * hood, older bundlers). Same capture semantics as fetch.
2706
- */
2707
- installXhrWrap(w) {
2708
- const xhrCtor = w.XMLHttpRequest;
2709
- const proto = xhrCtor?.prototype;
2710
- if (!proto) return;
2711
- const origOpen = proto.open;
2712
- const origSend = proto.send;
2713
- const tracker = this;
2714
- proto.open = function(method, url, ...rest) {
2715
- this._cdMethod = method;
2716
- this._cdUrl = url;
2717
- return origOpen.apply(this, [method, url, ...rest]);
2718
- };
2719
- proto.send = function(body) {
2720
- const xhr = this;
2721
- const onLoad = () => {
2722
- try {
2723
- if (xhr.status >= 500 && tracker.opts.isConsented()) {
2724
- const url = xhr._cdUrl ?? "";
2725
- if (!isSelfRequest(url, tracker.opts.selfHostname)) {
2726
- tracker.captureHttp({
2727
- url,
2728
- method: (xhr._cdMethod ?? "GET").toUpperCase(),
2729
- status: xhr.status,
2730
- statusText: xhr.statusText
2731
- });
2732
- }
2733
- }
2734
- } catch {
2735
- }
2736
- };
2737
- xhr.addEventListener("loadend", onLoad);
2738
- return origSend.apply(this, [body ?? null]);
2739
- };
2740
- this.cleanups.push(() => {
2741
- proto.open = origOpen;
2742
- proto.send = origSend;
2743
- });
2744
- }
2745
- installConsoleWrap() {
2746
- const console2 = globalThis.console;
2747
- if (!console2) return;
2748
- const orig = console2.error.bind(console2);
2749
- console2.error = (...args) => {
2750
- try {
2751
- if (this.opts.isConsented()) {
2752
- this.captureMessage(args.map((a) => safeStringify2(a)).join(" "), "error");
2753
- }
2754
- } catch {
2755
- }
2756
- return orig(...args);
2757
- };
2758
- this.cleanups.push(() => {
2759
- console2.error = orig;
2760
- });
2761
- }
2762
- // ============================================================
2763
- // Builders
2764
- // ============================================================
2765
- buildFromErrorEvent(event) {
2766
- const err = event.error;
2767
- const filename = event.filename || null;
2768
- const lineno = typeof event.lineno === "number" && event.lineno > 0 ? event.lineno : null;
2769
- const colno = typeof event.colno === "number" && event.colno > 0 ? event.colno : null;
2770
- const isCrossOriginStripped = err == null && !filename && lineno == null && (event.message === "Script error." || event.message === "Script error" || !event.message);
2771
- if (isCrossOriginStripped) {
2772
- const message2 = "Cross-origin script error (browser hid details \u2014 script needs crossorigin attribute + CORS headers)";
2773
- return {
2774
- timestamp: Date.now(),
2775
- kind: "error.unhandled",
2776
- level: "error",
2777
- message: message2,
2778
- errorType: "ScriptError",
2779
- frames: [],
2780
- rawStack: null,
2781
- filename: null,
2782
- lineno: null,
2783
- colno: null,
2784
- // No location to fingerprint by — all of these will share one
2785
- // group, which is correct: developer fixes them all with the
2786
- // same CORS change.
2787
- fingerprint: fingerprintError(message2, []),
2788
- breadcrumbs: this.opts.breadcrumbs.snapshot(),
2789
- context: this.opts.getContext(),
2790
- tags: { ...this.opts.getTags(), cross_origin: "true" }
2791
- };
2792
- }
2793
- const payload = coerceErrorPayload(err);
2794
- const message = (payload.message || event.message || "Unknown error").slice(0, 1024);
2795
- const stack = err instanceof Error ? err.stack ?? null : null;
2796
- const frames = parseStack(stack);
2797
- const errorType = payload.errorType ?? null;
2798
- const context = payload.extras ? { ...this.opts.getContext(), __error_extras: payload.extras } : this.opts.getContext();
2799
- return {
2800
- timestamp: Date.now(),
2801
- kind: "error.unhandled",
2802
- level: "error",
2803
- message,
2804
- errorType,
2805
- frames,
2806
- rawStack: stack,
2807
- filename,
2808
- lineno,
2809
- colno,
2810
- // Location fallback ensures distinct call sites stay separate
2811
- // even when the message is generic ("Unknown error",
2812
- // "[object Object]") and there are no parseable frames.
2813
- fingerprint: fingerprintError(message, frames, {
2814
- filename,
2815
- lineno,
2816
- colno,
2817
- errorType
2818
- }),
2819
- breadcrumbs: this.opts.breadcrumbs.snapshot(),
2820
- context,
2821
- tags: this.opts.getTags()
2822
- };
2823
- }
2824
- buildFromUnknown(err, kind, level) {
2825
- const payload = coerceErrorPayload(err);
2826
- const message = (payload.message || "Unknown error").slice(0, 1024);
2827
- const stack = err instanceof Error ? err.stack ?? null : null;
2828
- const frames = parseStack(stack);
2829
- const errorType = payload.errorType ?? null;
2830
- const context = payload.extras ? { ...this.opts.getContext(), __error_extras: payload.extras } : this.opts.getContext();
2831
- return {
2832
- timestamp: Date.now(),
2833
- kind,
2834
- level,
2835
- message,
2836
- errorType,
2837
- frames,
2838
- rawStack: stack,
2839
- filename: null,
2840
- lineno: null,
2841
- colno: null,
2842
- fingerprint: fingerprintError(message, frames, { errorType }),
2843
- breadcrumbs: this.opts.breadcrumbs.snapshot(),
2844
- context,
2845
- tags: this.opts.getTags()
2846
- };
2847
- }
2848
- captureHttp(info) {
2849
- try {
2850
- const message = `HTTP ${info.status} ${info.method} ${info.url}`;
2851
- const captured = {
2852
- timestamp: Date.now(),
2853
- kind: "error.http",
2854
- level: "error",
2855
- message,
2856
- errorType: `HTTPError`,
2857
- frames: [],
2858
- rawStack: null,
2859
- filename: info.url,
2860
- lineno: null,
2861
- colno: null,
2862
- fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, [], {
2863
- filename: info.url,
2864
- errorType: "HTTPError"
2865
- }),
2866
- breadcrumbs: this.opts.breadcrumbs.snapshot(),
2867
- context: this.opts.getContext(),
2868
- tags: this.opts.getTags(),
2869
- http: info
2870
- };
2871
- this.maybeReport(captured);
2872
- } catch {
2873
- }
2874
- }
2875
- // ============================================================
2876
- // Reporting pipeline — filter / sample / rate-limit / send
2877
- // ============================================================
2878
- maybeReport(err) {
2879
- if (this.sessionCount >= this.opts.config.maxPerSession) return;
2880
- if (this.shouldIgnore(err)) return;
2881
- if (!this.passesUrlGate(err)) return;
2882
- if (!this.passesSample(err)) return;
2883
- if (!this.passesRateLimit(err)) return;
2884
- let finalErr = err;
2885
- const hook = this.opts.beforeSend?.();
2886
- if (hook) {
2887
- try {
2888
- finalErr = hook(err);
2889
- } catch {
2890
- finalErr = err;
2891
- }
2892
- if (!finalErr) return;
2893
- }
2894
- this.sessionCount += 1;
2895
- try {
2896
- this.opts.report(finalErr);
2897
- } catch {
2898
- }
2899
- }
2900
- shouldIgnore(err) {
2901
- for (const pat of this.opts.config.ignoreErrors) {
2902
- if (typeof pat === "string" && err.message.includes(pat)) return true;
2903
- if (pat instanceof RegExp && pat.test(err.message)) return true;
2904
- }
2905
- return false;
2906
- }
2907
- passesUrlGate(err) {
2908
- const topFrame = err.frames.find((f) => f.filename) ?? null;
2909
- const url = topFrame?.filename ?? err.filename ?? "";
2910
- if (!url) return true;
2911
- for (const pat of this.opts.config.denyUrls) {
2912
- if (typeof pat === "string" && url.includes(pat)) return false;
2913
- if (pat instanceof RegExp && pat.test(url)) return false;
2914
- }
2915
- if (this.opts.config.allowUrls.length > 0) {
2916
- for (const pat of this.opts.config.allowUrls) {
2917
- if (typeof pat === "string" && url.includes(pat)) return true;
2918
- if (pat instanceof RegExp && pat.test(url)) return true;
2919
- }
2920
- return false;
2921
- }
2922
- return true;
2923
- }
2924
- passesSample(err) {
2925
- if (this.opts.config.sampleRate >= 1) return true;
2926
- if (this.opts.config.sampleRate <= 0) return false;
2927
- const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
2928
- return hashByte / 255 < this.opts.config.sampleRate;
2929
- }
2930
- passesRateLimit(err) {
2931
- const windowMs = 6e4;
2932
- const now = Date.now();
2933
- const max = this.opts.config.maxPerFingerprintPerMinute;
2934
- const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
2935
- const fresh = arr.filter((t) => now - t < windowMs);
2936
- if (fresh.length >= max) {
2937
- this.fingerprintWindow.set(err.fingerprint, fresh);
2938
- return false;
2939
- }
2940
- fresh.push(now);
2941
- this.fingerprintWindow.set(err.fingerprint, fresh);
2942
- return true;
2943
- }
2944
- };
2945
- function coerceErrorPayload(v) {
2946
- if (v === null) return { message: "(thrown: null)", errorType: null, extras: null };
2947
- if (v === void 0) return { message: "(thrown: undefined)", errorType: null, extras: null };
2948
- if (typeof v === "string") {
2949
- return { message: v, errorType: null, extras: null };
2950
- }
2951
- if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") {
2952
- return { message: String(v), errorType: typeof v, extras: null };
2953
- }
2954
- if (typeof v === "symbol") {
2955
- return { message: v.toString(), errorType: "symbol", extras: null };
2956
- }
2957
- if (typeof v === "function") {
2958
- return { message: `(thrown function: ${v.name || "anonymous"})`, errorType: "function", extras: null };
2959
- }
2960
- if (v instanceof Error) {
2961
- const errorType = v.name || v.constructor?.name || "Error";
2962
- const message = typeof v.message === "string" && v.message.length > 0 ? v.message : safeToString(v) || errorType;
2963
- const extras = {};
2964
- const causeChain = collectCauseChain(v);
2965
- if (causeChain.length > 0) extras.cause = causeChain;
2966
- for (const key of ["code", "status", "statusCode", "errno", "response", "data", "detail", "details"]) {
2967
- const val = v[key];
2968
- if (val !== void 0 && typeof val !== "function") {
2969
- extras[key] = safeClone(val);
2970
- }
2971
- }
2972
- for (const key of Object.keys(v)) {
2973
- if (key === "message" || key === "stack" || key === "name" || key === "cause") continue;
2974
- if (key in extras) continue;
2975
- const val = v[key];
2976
- if (typeof val === "function") continue;
2977
- extras[key] = safeClone(val);
2978
- }
2979
- return {
2980
- message,
2981
- errorType,
2982
- extras: Object.keys(extras).length > 0 ? extras : null
2983
- };
2984
- }
2985
- if (typeof Response !== "undefined" && v instanceof Response) {
2986
- return {
2987
- message: `HTTP ${v.status} ${v.statusText || ""}${v.url ? ` ${v.url}` : ""}`.trim(),
2988
- errorType: "Response",
2989
- extras: { status: v.status, statusText: v.statusText, url: v.url, type: v.type }
2990
- };
2991
- }
2992
- if (typeof v === "object") {
2993
- const obj = v;
2994
- const ctorName = obj.constructor && typeof obj.constructor === "function" && obj.constructor.name || null;
2995
- const ownMessage = typeof obj.message === "string" && obj.message ? obj.message : null;
2996
- const ownName = typeof obj.name === "string" && obj.name ? obj.name : null;
2997
- let jsonForm = null;
2998
- try {
2999
- const serialised = JSON.stringify(obj);
3000
- jsonForm = serialised === "{}" ? null : serialised;
3001
- } catch {
3002
- jsonForm = null;
3003
- }
3004
- const fallbackString = safeToString(obj);
3005
- const message = ownMessage ?? jsonForm ?? (fallbackString && fallbackString !== "[object Object]" ? fallbackString : null) ?? (ctorName ? `(thrown ${ctorName} with no message)` : "(thrown object with no message)");
3006
- const errorType = ownName ?? ctorName ?? null;
3007
- const extras = {};
3008
- let count = 0;
3009
- for (const key of Object.keys(obj)) {
3010
- if (count >= 20) break;
3011
- if (key === "message" || key === "name") continue;
3012
- const val = obj[key];
3013
- if (typeof val === "function") continue;
3014
- extras[key] = safeClone(val);
3015
- count++;
3016
- }
3017
- return {
3018
- message,
3019
- errorType,
3020
- extras: Object.keys(extras).length > 0 ? extras : null
3021
- };
3022
- }
3023
- return { message: safeToString(v) || "(unstringifiable thrown value)", errorType: null, extras: null };
3024
- }
3025
- function collectCauseChain(err) {
3026
- const out = [];
3027
- let cur = err.cause;
3028
- let depth = 0;
3029
- while (cur != null && depth < 5) {
3030
- if (cur instanceof Error) {
3031
- out.push({ name: cur.name || "Error", message: cur.message || "" });
3032
- cur = cur.cause;
3033
- } else {
3034
- out.push({ name: "non-Error", message: safeToString(cur) });
3035
- cur = null;
3036
- }
3037
- depth++;
3038
- }
3039
- return out;
3040
- }
3041
- function safeToString(v) {
3042
- try {
3043
- const s = Object.prototype.toString.call(v);
3044
- if (s !== "[object Object]") return s;
3045
- const own = v?.toString;
3046
- if (typeof own === "function" && own !== Object.prototype.toString) {
3047
- const r = own.call(v);
3048
- if (typeof r === "string") return r;
3049
- }
3050
- return s;
3051
- } catch {
3052
- return "(throwing toString)";
3053
- }
3054
- }
3055
- function safeClone(v) {
3056
- if (v == null) return v;
3057
- const t = typeof v;
3058
- if (t === "string" || t === "number" || t === "boolean") return v;
3059
- if (t === "bigint") return String(v);
3060
- try {
3061
- const s = JSON.stringify(v);
3062
- return s === void 0 ? safeToString(v) : JSON.parse(s);
3063
- } catch {
3064
- return safeToString(v);
3065
- }
3066
- }
3067
- function safeStringify2(v) {
3068
- return coerceErrorPayload(v).message;
3069
- }
3070
- function extractSelfHostname(baseUrl) {
3071
- if (!baseUrl || typeof baseUrl !== "string") return null;
3072
- try {
3073
- return new URL(baseUrl).hostname.toLowerCase();
3074
- } catch {
3075
- return null;
3076
- }
3077
- }
3078
- function isSelfRequest(requestUrl, selfHostname) {
3079
- if (!selfHostname || !requestUrl) return false;
3080
- try {
3081
- return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
3082
- } catch {
3083
- return false;
3084
- }
3085
- }
3086
-
3087
- // src/crossdeck.ts
3088
- var CrossdeckClient = class {
3089
- constructor() {
3090
- this.state = null;
3091
- }
3092
- /**
3093
- * Boot the SDK. Idempotent — calling init twice with the same options
3094
- * is a no-op; calling with different options replaces the previous
3095
- * configuration.
3096
- *
3097
- * NorthStar §11.1: signature is `Crossdeck.init({ appId, publicKey,
3098
- * environment })`. The trio is validated up-front so a typo'd key or a
3099
- * mismatched env fails fast at boot rather than at first event-flush.
3100
- */
3101
- init(options) {
3102
- if (this.state) {
3103
- try {
3104
- this.state.uninstallUnloadFlush?.();
3105
- } catch {
3106
- }
3107
- try {
3108
- this.state.autoTracker?.uninstall();
3109
- } catch {
3110
- }
3111
- try {
3112
- this.state.webVitals?.uninstall();
3113
- } catch {
3114
- }
3115
- try {
3116
- this.state.errors?.uninstall();
3117
- } catch {
3118
- }
3119
- try {
3120
- void this.state.events.flush({ keepalive: true });
3121
- } catch {
3122
- }
3123
- }
3124
- if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
3125
- throw new CrossdeckError({
3126
- type: "configuration_error",
3127
- code: "invalid_public_key",
3128
- message: "Crossdeck.init requires a publishable key starting with cd_pub_."
3129
- });
3130
- }
3131
- if (!options.appId) {
3132
- throw new CrossdeckError({
3133
- type: "configuration_error",
3134
- code: "missing_app_id",
3135
- message: "Crossdeck.init requires an appId. Find yours in the Crossdeck dashboard."
3136
- });
3137
- }
3138
- if (options.environment !== "production" && options.environment !== "sandbox") {
3139
- throw new CrossdeckError({
3140
- type: "configuration_error",
3141
- code: "invalid_environment",
3142
- message: 'Crossdeck.init requires environment: "production" | "sandbox".'
3143
- });
3144
- }
3145
- const keyEnv = inferEnvFromKey(options.publicKey);
3146
- if (keyEnv && keyEnv !== options.environment) {
3147
- throw new CrossdeckError({
3148
- type: "configuration_error",
3149
- code: "environment_mismatch",
3150
- message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
3151
- });
3152
- }
3153
- const localDevMode = isLocalHostname();
3154
- const storage = options.storage ?? detectDefaultStorage();
3155
- const persistIdentity = options.persistIdentity ?? true;
3156
- const autoTrack = resolveAutoTrack(options.autoTrack);
3157
- const opts = {
3158
- appId: options.appId,
3159
- publicKey: options.publicKey,
3160
- environment: options.environment,
3161
- baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
3162
- persistIdentity,
3163
- storagePrefix: options.storagePrefix ?? "crossdeck:",
3164
- autoHeartbeat: options.autoHeartbeat ?? true,
3165
- eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
3166
- // 1500ms idle window. Short enough that an event queued on page
3167
- // load still flushes if the user leaves quickly (the keepalive
3168
- // pagehide handler picks up anything that doesn't); long enough
3169
- // that bursts of clicks coalesce into one network round-trip.
3170
- // v1.4.0 Phase 3.3 — flush interval default parity. Pre-
3171
- // v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
3172
- // converged on 2000ms (the Stripe-adjacent industry norm)
3173
- // so cross-platform funnels show events landing at the
3174
- // same cadence on every SDK. Per-instance override stays.
3175
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
3176
- sdkVersion: options.sdkVersion ?? SDK_VERSION,
3177
- autoTrack,
3178
- appVersion: options.appVersion ?? null
3179
- };
3180
- const debug = new ConsoleDebugLogger();
3181
- debug.enabled = options.debug === true;
3182
- const http = new HttpClient({
3183
- publicKey: opts.publicKey,
3184
- baseUrl: opts.baseUrl,
3185
- sdkVersion: opts.sdkVersion,
3186
- // Localhost auto-route: HttpClient short-circuits every request
3187
- // to a successful no-op response when localDevMode is set.
3188
- // SDK methods continue to work locally; nothing reaches the
3189
- // server.
3190
- localDevMode
3191
- });
3192
- if (localDevMode) {
3193
- console.log(
3194
- "[crossdeck] Localhost detected \u2014 running in dev mode (no network calls). Set publicKey: 'cd_pub_test_\u2026' and deploy to a real domain to test against the Crossdeck Sandbox."
3195
- );
3196
- }
3197
- const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
3198
- const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
3199
- typeof globalThis.document !== "undefined";
3200
- const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
3201
- const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
3202
- const entitlements = new EntitlementCache(
3203
- effectiveStorage,
3204
- opts.storagePrefix + "entitlements"
3205
- );
3206
- const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
3207
- if (persistentEvents) {
3208
- debug.emit(
3209
- "sdk.queue_restored",
3210
- "Restored persisted event queue from a prior session."
3211
- );
3212
- }
3213
- const events = new EventQueue({
3214
- http,
3215
- batchSize: opts.eventFlushBatchSize,
3216
- intervalMs: opts.eventFlushIntervalMs,
3217
- envelope: () => ({
3218
- appId: opts.appId,
3219
- environment: opts.environment,
3220
- sdk: { name: SDK_NAME, version: opts.sdkVersion }
3221
- }),
3222
- persistentStore: persistentEvents ?? void 0,
3223
- onFirstFlushSuccess: () => {
3224
- debug.emit(
3225
- "sdk.first_event_sent",
3226
- "First telemetry event received. View it in Live Events.",
3227
- { appId: opts.appId, environment: opts.environment }
3228
- );
3229
- },
3230
- onRetryScheduled: (info) => {
3231
- debug.emit(
3232
- "sdk.flush_retry_scheduled",
3233
- `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
3234
- { ...info }
3235
- );
3236
- },
3237
- onPermanentFailure: (info) => {
3238
- const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
3239
- console.error(headline);
3240
- debug.emit(
3241
- "sdk.flush_permanent_failure",
3242
- headline,
3243
- { ...info }
3244
- );
3245
- }
3246
- });
3247
- const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
3248
- const superProps = new SuperPropertyStore(
3249
- persistIdentity ? effectiveStorage : new MemoryStorage(),
3250
- opts.storagePrefix
3251
- );
3252
- const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
3253
- if (consent.isDntDenied) {
3254
- debug.emit(
3255
- "sdk.consent_dnt_applied",
3256
- "Do Not Track detected \u2014 all tracking dimensions denied at init."
3257
- );
3258
- }
3259
- const breadcrumbs = new BreadcrumbBuffer(50);
3260
- this.state = {
3261
- http,
3262
- identity,
3263
- entitlements,
3264
- events,
3265
- autoTracker: null,
3266
- webVitals: null,
3267
- errors: null,
3268
- breadcrumbs,
3269
- errorContext: {},
3270
- errorTags: {},
3271
- errorBeforeSend: null,
3272
- superProps,
3273
- consent,
3274
- scrubPii: options.scrubPii !== false,
3275
- deviceInfo,
3276
- options: opts,
3277
- debug,
3278
- developerUserId: null,
3279
- uninstallUnloadFlush: null,
3280
- lastServerTime: null,
3281
- lastClientTime: null
3282
- };
3283
- debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
3284
- appId: opts.appId,
3285
- environment: opts.environment,
3286
- sdkVersion: opts.sdkVersion
3287
- });
3288
- if (autoTrack.sessions || autoTrack.pageViews) {
3289
- const tracker = new AutoTracker(
3290
- autoTrack,
3291
- (name, properties) => this.track(name, properties)
3292
- );
3293
- this.state.autoTracker = tracker;
3294
- tracker.install();
3295
- }
3296
- if (autoTrack.webVitals) {
3297
- const vitals = new WebVitalsTracker(
3298
- { enabled: true },
3299
- (name, properties) => this.track(name, properties)
3300
- );
3301
- this.state.webVitals = vitals;
3302
- vitals.install();
3303
- }
3304
- if (autoTrack.errors) {
3305
- const tracker = new ErrorTracker({
3306
- config: { ...DEFAULT_ERROR_CAPTURE, enabled: true },
3307
- breadcrumbs,
3308
- report: (err) => this.reportError(err),
3309
- getContext: () => ({ ...this.state.errorContext }),
3310
- getTags: () => ({ ...this.state.errorTags }),
3311
- // GETTER, not a captured value — `setErrorBeforeSend()` mutates
3312
- // `state.errorBeforeSend` after init() and the tracker MUST
3313
- // pick up the new hook on the next error. The pre-fix shape
3314
- // (`beforeSend: this.state!.errorBeforeSend`) snapshotted
3315
- // `null` at construction and made the customer's PII scrubber
3316
- // silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
3317
- beforeSend: () => this.state.errorBeforeSend,
3318
- isConsented: () => this.state.consent.errors,
3319
- // Derived from the configured baseUrl at init() time. Used by
3320
- // the fetch / XHR wrappers to skip captureHttp on Crossdeck's
3321
- // own requests — pre-fix the skip was hardcoded to
3322
- // `api.cross-deck.com` and broke for customers on staging /
3323
- // regional / self-hosted base URLs (recursive capture loop).
3324
- selfHostname: extractSelfHostname(opts.baseUrl)
3325
- });
3326
- this.state.errors = tracker;
3327
- tracker.install();
3328
- }
3329
- this.state.uninstallUnloadFlush = installUnloadFlush(() => {
3330
- void this.flush({ keepalive: true }).catch(() => void 0);
3331
- });
3332
- if (opts.autoHeartbeat && !localDevMode) {
3333
- void this.heartbeat().catch(() => void 0);
3334
- }
3335
- }
3336
- /**
3337
- * @deprecated Use `init()` instead. NorthStar §4 standardised the
3338
- * lifecycle method name across SDKs as `init` (formerly `start` /
3339
- * `configure`). `start` will be removed in a future major version.
3340
- */
3341
- start(options) {
3342
- if (typeof console !== "undefined") {
3343
- console.warn(
3344
- "[crossdeck] Crossdeck.start() is deprecated \u2014 use Crossdeck.init() instead. The signature is the same."
3345
- );
3346
- }
3347
- this.init(options);
3348
- }
3349
- /**
3350
- * Link the anonymous device to a developer-supplied user ID. Cache
3351
- * the resolved Crossdeck customer for follow-up calls.
3352
- *
3353
- * v0.9.0+ accepts an optional `traits` bag — profile data (name,
3354
- * plan, signupDate, role) persisted on the Crossdeck customer record
3355
- * and queryable from dashboards. Traits are sanitised through the
3356
- * same validator that gates `track()` properties, so a `{ avatar:
3357
- * <File>, onSave: () => {} }` payload can't corrupt the alias call.
3358
- *
3359
- * Crossdeck.identify("user_847", {
3360
- * email: "wes@pinet.co.za",
3361
- * traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
3362
- * });
3363
- */
3364
- async identify(userId, options) {
3365
- const s = this.requireStarted();
3366
- if (!userId) {
3367
- throw new CrossdeckError({
3368
- type: "invalid_request_error",
3369
- code: "missing_user_id",
3370
- message: "identify(userId) requires a non-empty userId."
3371
- });
3372
- }
3373
- if (!s.consent.analytics) {
3374
- s.debug.emit(
3375
- "sdk.consent_denied",
3376
- `identify() skipped \u2014 consent denied for analytics.`
3377
- );
3378
- return {
3379
- object: "alias_result",
3380
- crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
3381
- linked: [],
3382
- mergePending: false,
3383
- env: s.options.environment
3384
- };
3385
- }
3386
- const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
3387
- const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
3388
- if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
3389
- for (const w of traitsValidation.warnings) {
3390
- s.debug.emit(
3391
- "sdk.property_coerced",
3392
- `identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
3393
- { key: w.key, kind: w.kind }
3394
- );
3395
- }
3396
- }
3397
- const body = {
3398
- userId,
3399
- anonymousId: s.identity.anonymousId
3400
- };
3401
- if (options?.email) body.email = options.email;
3402
- if (traits) body.traits = traits;
3403
- s.entitlements.setUserKey(userId);
3404
- const result = await s.http.request("POST", "/identity/alias", {
3405
- body
3406
- });
3407
- s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3408
- s.developerUserId = userId;
3409
- return result;
3410
- }
3411
- /**
3412
- * Register super-properties — Mixpanel pattern. Once set, every
3413
- * subsequent event of THIS SDK instance carries these keys on its
3414
- * properties bag automatically.
3415
- *
3416
- * Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
3417
- * Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
3418
- *
3419
- * Values that are `null` are deleted (the explicit "stop tracking
3420
- * this key" idiom). Returns the resulting bag.
3421
- *
3422
- * Sanitised through `validateEventProperties` so a `{ avatar: File }`
3423
- * payload can't poison the queue at flush time.
3424
- */
3425
- register(properties) {
3426
- const s = this.requireStarted();
3427
- const validation = validateEventProperties(properties);
3428
- return s.superProps.register(validation.properties);
3429
- }
3430
- /** Remove a single super-property key. Idempotent. */
3431
- unregister(key) {
3432
- const s = this.requireStarted();
3433
- s.superProps.unregister(key);
3434
- }
3435
- /** Snapshot of the current super-property bag. */
3436
- getSuperProperties() {
3437
- if (!this.state) return {};
3438
- return this.state.superProps.getSuperProperties();
3439
- }
3440
- /**
3441
- * Associate the current user with a group (org, team, account, etc.).
3442
- * Mixpanel / Segment "Group Analytics" pattern.
3443
- *
3444
- * Crossdeck.group("org", "acme_inc");
3445
- * Crossdeck.group("team", "design", { headcount: 12 });
3446
- *
3447
- * Once set, every subsequent event carries `$groups.<type>: id` on
3448
- * its properties bag, enabling B2B dashboards ("how is Acme using
3449
- * the product"). Pass `id: null` to clear a group membership.
3450
- */
3451
- group(type, id, traits) {
3452
- const s = this.requireStarted();
3453
- if (!type) {
3454
- throw new CrossdeckError({
3455
- type: "invalid_request_error",
3456
- code: "missing_group_type",
3457
- message: "group(type, id) requires a non-empty type."
3458
- });
3459
- }
3460
- const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
3461
- s.superProps.setGroup(type, id, sanitisedTraits);
3462
- }
3463
- /** Snapshot of the current groups map keyed by type. */
3464
- getGroups() {
3465
- if (!this.state) return {};
3466
- return this.state.superProps.getGroups();
3467
- }
3468
- /**
3469
- * Update consent state. Three independent dimensions:
3470
- *
3471
- * analytics — track() + identify() + auto-emissions
3472
- * marketing — paid-traffic click IDs + referrer URL on events
3473
- * errors — Web Vitals + (future) error reporting
3474
- *
3475
- * Each defaults to `true` (granted). Pass partial state — only the
3476
- * keys you provide are changed.
3477
- *
3478
- * Crossdeck.consent({ analytics: false });
3479
- * Crossdeck.consent({ marketing: true, errors: true });
3480
- *
3481
- * DNT-derived denies cannot be flipped back on; if the browser said
3482
- * "don't track" we don't track even if the developer code disagrees.
3483
- */
3484
- consent(state) {
3485
- const s = this.requireStarted();
3486
- const next = s.consent.set(state);
3487
- s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
3488
- return next;
3489
- }
3490
- /** Snapshot of the current consent state. */
3491
- consentStatus() {
3492
- if (!this.state) {
3493
- return { analytics: true, marketing: true, errors: true };
3494
- }
3495
- return this.state.consent.get();
3496
- }
3497
- // ============================================================
3498
- // Error capture surface (v1.0.0+)
3499
- // ============================================================
3500
- /**
3501
- * Manually capture an error from a try/catch block.
3502
- *
3503
- * try { …risky… } catch (err) {
3504
- * Crossdeck.captureError(err, { context: { plan: "pro" } });
3505
- * }
3506
- *
3507
- * The error is shipped through the same event queue as analytics
3508
- * (durable, retried, rate-limited per fingerprint). Sends are gated
3509
- * by `consent.errors`. Returns silently — never throws, even if the
3510
- * SDK isn't initialised yet.
3511
- */
3512
- captureError(error, options) {
3513
- if (!this.state?.errors) return;
3514
- this.state.errors.captureError(error, options);
3515
- }
3516
- /**
3517
- * Capture a non-error event you want to surface as an issue
3518
- * ("deprecated path hit", "we entered the slow code path"). Sentry
3519
- * captureMessage pattern. Returns silently if not initialised.
3520
- */
3521
- captureMessage(message, level = "info") {
3522
- if (!this.state?.errors) return;
3523
- this.state.errors.captureMessage(message, level);
3524
- }
3525
- /**
3526
- * Attach a tag to every subsequent error report. Tags are key/value
3527
- * strings (Sentry pattern): `setTag("flow", "checkout")` → every
3528
- * error from this point on carries `tags.flow === "checkout"`.
3529
- */
3530
- setTag(key, value) {
3531
- if (!this.state) return;
3532
- this.state.errorTags[key] = value;
3533
- }
3534
- /** Bulk-set tags. Merges with existing tags. */
3535
- setTags(tags) {
3536
- if (!this.state) return;
3537
- Object.assign(this.state.errorTags, tags);
3538
- }
3539
- /**
3540
- * Attach a structured context blob to every subsequent error report.
3541
- * Unlike tags (flat key/value), context is a named bag of arbitrary
3542
- * data: `setContext("cart", { items: 3, total: 42.99 })`.
3543
- */
3544
- setContext(name, data) {
3545
- if (!this.state) return;
3546
- this.state.errorContext[name] = data;
3547
- }
3548
- /**
3549
- * Add a custom breadcrumb to the rolling buffer. Useful for marking
3550
- * domain-meaningful moments ("user opened paywall") that aren't
3551
- * already auto-captured. The buffer caps at 50 entries; old ones
3552
- * evict.
3553
- */
3554
- addBreadcrumb(crumb) {
3555
- if (!this.state) return;
3556
- this.state.breadcrumbs.add(crumb);
3557
- }
3558
- /**
3559
- * Install a pre-send hook for errors. Return null to drop, or a
3560
- * modified CapturedError to scrub / rewrite. Sentry's beforeSend
3561
- * pattern — the only way to redact app-specific PII (auth tokens
3562
- * in URLs, etc.) before the report leaves the browser.
3563
- */
3564
- setErrorBeforeSend(hook) {
3565
- if (!this.state) return;
3566
- this.state.errorBeforeSend = hook;
3567
- }
3568
- /**
3569
- * Internal: turn a CapturedError into a Crossdeck event and enqueue
3570
- * it. Goes through the same queue / persistence / consent / scrub
3571
- * pipeline as analytics events.
3572
- */
3573
- reportError(err) {
3574
- const properties = {
3575
- // Identifiers
3576
- fingerprint: err.fingerprint,
3577
- level: err.level,
3578
- // Error shape
3579
- errorType: err.errorType,
3580
- message: err.message,
3581
- // Stack
3582
- stack: err.rawStack ?? void 0,
3583
- frames: err.frames,
3584
- filename: err.filename ?? void 0,
3585
- lineno: err.lineno ?? void 0,
3586
- colno: err.colno ?? void 0,
3587
- // Context
3588
- tags: err.tags,
3589
- context: err.context,
3590
- breadcrumbs: err.breadcrumbs,
3591
- // HTTP (only when applicable)
3592
- http: err.http
3593
- };
3594
- for (const k of Object.keys(properties)) {
3595
- if (properties[k] === void 0) delete properties[k];
3596
- }
3597
- this.track(err.kind, properties);
3598
- }
3599
- /**
3600
- * GDPR/CCPA "right to be forgotten" — calls the backend's
3601
- * /v1/identity/forget endpoint to schedule a server-side deletion of
3602
- * the customer's events and profile, then wipes all local state
3603
- * (identity, entitlements, queue, super-props, persistent stores).
3604
- *
3605
- * Idempotent. Safe to call when no identity has been established
3606
- * (it just wipes the empty local state).
3607
- *
3608
- * After forget() resolves, the SDK is in the same shape as if the
3609
- * developer had called `Crossdeck.reset()` — a fresh anonymousId is
3610
- * minted and the next session is a brand new identity-graph entry.
3611
- */
3612
- async forget() {
3613
- const s = this.requireStarted();
3614
- const identityQuery = this.identityQueryParams();
3615
- try {
3616
- await s.http.request("POST", "/identity/forget", {
3617
- body: {
3618
- // Send every identity hint we hold; the server resolves the
3619
- // canonical customer record and queues deletion. Missing
3620
- // endpoint (older backend) gracefully degrades — local state
3621
- // still wipes via the reset() call below.
3622
- ...identityQuery
3623
- }
3624
- });
3625
- } catch (err) {
3626
- s.debug.emit(
3627
- "sdk.consent_denied",
3628
- `forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
3629
- );
3630
- }
3631
- this.reset();
3632
- }
3633
- /**
3634
- * Read the current customer's active entitlements from the server.
3635
- * Updates the local cache so subsequent isEntitled() calls answer
3636
- * synchronously.
3637
- */
3638
- async getEntitlements() {
3639
- const s = this.requireStarted();
3640
- const query = this.identityQueryParams();
3641
- let result;
3642
- try {
3643
- result = await s.http.request(
3644
- "GET",
3645
- "/entitlements",
3646
- { query }
3647
- );
3648
- } catch (err) {
3649
- s.entitlements.markRefreshFailed();
3650
- throw err;
3651
- }
3652
- if (result.crossdeckCustomerId) {
3653
- s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3654
- }
3655
- s.entitlements.setFromList(result.data);
3656
- return result.data;
3657
- }
3658
- /**
3659
- * Synchronous read from the durable local cache — answers from
3660
- * last-known-good. The cache hydrates from device storage on boot and
3661
- * survives a Crossdeck outage, so a returning paying customer reads
3662
- * true even before the session's first network round-trip. Returns
3663
- * false only for a genuinely new install that has never completed a
3664
- * getEntitlements(), or for an entitlement past its own validUntil.
3665
- */
3666
- isEntitled(key) {
3667
- const s = this.requireStarted();
3668
- return s.entitlements.isEntitled(key);
3669
- }
3670
- /** Snapshot of the local entitlement cache. */
3671
- listEntitlements() {
3672
- const s = this.requireStarted();
3673
- return s.entitlements.list();
3674
- }
3675
- /**
3676
- * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
3677
- *
3678
- * The listener is invoked AFTER the cache mutates — once after a
3679
- * successful `getEntitlements()` warms it, again after `syncPurchases()`
3680
- * delivers fresh entitlements, and once on `reset()` to fire the
3681
- * empty-cache state for logout flows.
3682
- *
3683
- * It is NOT invoked synchronously on subscribe. Callers that need
3684
- * the current state should read it via `isEntitled()` / `listEntitlements()`
3685
- * inline; the listener fires only on FUTURE changes.
3686
- *
3687
- * This is the foundation of the `useEntitlement` React hook in
3688
- * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
3689
- * / Vue) would have no way to re-render when entitlements arrive
3690
- * asynchronously after init. The naive pattern of calling
3691
- * `Crossdeck.isEntitled("pro")` directly inside a render path
3692
- * shows the empty-cache result forever; binding the result to
3693
- * component state via `onEntitlementsChange` is the correct
3694
- * pattern.
3695
- *
3696
- * Idempotent unsubscribe — calling the returned function multiple
3697
- * times is safe.
3698
- *
3699
- * Listener errors are swallowed (a buggy listener can't crash the
3700
- * SDK or other listeners).
3701
- */
3702
- onEntitlementsChange(listener) {
3703
- const s = this.requireStarted();
3704
- return s.entitlements.subscribe(listener);
3705
- }
3706
- /**
3707
- * Queue a telemetry event. Returns immediately — the network round-
3708
- * trip happens in the background. To flush before the page unloads,
3709
- * call flush().
3710
- */
3711
- /**
3712
- * Emit `crossdeck.contract_failed` with the canonical property
3713
- * shape (`contract_id`, `sdk_version`, `sdk_platform`,
3714
- * `failure_reason`, `run_context`, `run_id`). Goes through the
3715
- * standard track() pipeline — same consent gate, same queue,
3716
- * same ingest, no new endpoint.
3717
- *
3718
- * Wire the call from a test hook, dogfood failure path, or
3719
- * customer contract-verification harness; see
3720
- * `contracts/README.md` for the per-test-framework hook recipes.
3721
- */
3722
- reportContractFailure(input) {
3723
- const props = {
3724
- contract_id: input.contractId,
3725
- sdk_version: SDK_VERSION,
3726
- sdk_platform: "web",
3727
- failure_reason: input.failureReason,
3728
- run_context: input.runContext,
3729
- run_id: input.runId
3730
- };
3731
- if (input.testRef) {
3732
- props.test_file = input.testRef.file;
3733
- props.test_name = input.testRef.name;
3734
- }
3735
- if (input.extra) {
3736
- for (const [k, v] of Object.entries(input.extra)) {
3737
- if (props[k] === void 0) props[k] = v;
3738
- }
3739
- }
3740
- this.track("crossdeck.contract_failed", props);
3741
- }
3742
- track(name, properties) {
3743
- const s = this.requireStarted();
3744
- if (!name) {
3745
- throw new CrossdeckError({
3746
- type: "invalid_request_error",
3747
- code: "missing_event_name",
3748
- message: "track(name) requires a non-empty name."
3749
- });
3750
- }
3751
- const isError = name.startsWith("error.");
3752
- const isWebVital = name.startsWith("webvitals.");
3753
- const consentGateOk = isError || isWebVital ? s.consent.errors : s.consent.analytics;
3754
- if (!consentGateOk) {
3755
- if (s.debug.enabled) {
3756
- s.debug.emit(
3757
- "sdk.consent_denied",
3758
- `Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
3759
- );
3760
- }
3761
- return;
3762
- }
3763
- if (s.debug.enabled && properties) {
3764
- const flagged = findSensitivePropertyKeys(properties);
3765
- if (flagged.length > 0) {
3766
- s.debug.emit(
3767
- "sdk.sensitive_property_warning",
3768
- `Event "${name}" has potentially sensitive property names: ${flagged.join(", ")}. Crossdeck is privacy-first \u2014 avoid sending PII unless intentional.`,
3769
- { eventName: name, flagged }
3770
- );
3771
- }
3772
- }
3773
- if (s.debug.enabled && !s.developerUserId && !s.identity.crossdeckCustomerId) {
3774
- s.debug.emit(
3775
- "sdk.no_identity",
3776
- "Using anonymous user until identify(userId) is called."
3777
- );
3778
- }
3779
- const validation = validateEventProperties(properties);
3780
- if (s.debug.enabled && validation.warnings.length > 0) {
3781
- for (const w of validation.warnings) {
3782
- s.debug.emit(
3783
- "sdk.property_coerced",
3784
- `Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
3785
- { eventName: name, key: w.key, kind: w.kind }
3786
- );
3787
- }
3788
- }
3789
- const enriched = { ...s.deviceInfo };
3790
- const sessionId = s.autoTracker?.currentSessionId;
3791
- if (sessionId) enriched.sessionId = sessionId;
3792
- const pageviewId = s.autoTracker?.currentPageviewId;
3793
- if (pageviewId) enriched.pageviewId = pageviewId;
3794
- const acquisition = s.autoTracker?.currentAcquisition;
3795
- if (acquisition) {
3796
- if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
3797
- if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
3798
- if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
3799
- if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
3800
- if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
3801
- if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
3802
- if (s.consent.marketing) {
3803
- if (acquisition.gclid) enriched.gclid = acquisition.gclid;
3804
- if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
3805
- if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
3806
- if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
3807
- if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
3808
- if (acquisition.twclid) enriched.twclid = acquisition.twclid;
3809
- }
3810
- }
3811
- const supers = s.superProps.getSuperProperties();
3812
- for (const k of Object.keys(supers)) {
3813
- if (!(k in enriched)) enriched[k] = supers[k];
3814
- }
3815
- const groupIds = s.superProps.getGroupIds();
3816
- if (Object.keys(groupIds).length > 0) {
3817
- enriched.$groups = groupIds;
3818
- }
3819
- Object.assign(enriched, validation.properties);
3820
- const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
3821
- const event = {
3822
- eventId: this.mintEventId(),
3823
- name,
3824
- timestamp: Date.now(),
3825
- properties: finalProperties
3826
- };
3827
- Object.assign(event, this.identityHintForEvent());
3828
- s.events.enqueue(event);
3829
- if (!isError && !isWebVital) {
3830
- const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
3831
- s.breadcrumbs.add({
3832
- timestamp: event.timestamp,
3833
- category,
3834
- message: name,
3835
- // Strip the device-info / session bloat from the breadcrumb
3836
- // payload — only the caller-supplied properties belong in
3837
- // the user-readable trail.
3838
- data: properties ? { ...properties } : void 0
3839
- });
3840
- }
3841
- }
3842
- /**
3843
- * Force-flush queued events. Useful to call from page-unload handlers.
3844
- *
3845
- * Pass `{ keepalive: true }` from terminal handlers (pagehide /
3846
- * visibilitychange→hidden / beforeunload). The browser keeps the
3847
- * request alive after the page tears down, so the final batch
3848
- * actually lands instead of being cancelled with the unload.
3849
- *
3850
- * NorthStar §4: standard method name across all Crossdeck SDKs.
3851
- */
3852
- async flush(options = {}) {
3853
- const s = this.requireStarted();
3854
- await s.events.flush(options);
3855
- }
3856
- /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
3857
- async flushEvents() {
3858
- return this.flush();
3859
- }
3860
- /**
3861
- * Forward purchase evidence to the backend for verification + entitlement
3862
- * projection. NorthStar §4 + §13 canonical name.
3863
- *
3864
- * Today the web SDK only supports Apple StoreKit 2 forwarding (web apps
3865
- * that sit alongside an iOS app). Stripe doesn't need this method —
3866
- * Stripe webhooks deliver evidence server-side without a client round-trip.
3867
- */
3868
- async syncPurchases(input) {
3869
- const s = this.requireStarted();
3870
- if (!input.signedTransactionInfo) {
3871
- throw new CrossdeckError({
3872
- type: "invalid_request_error",
3873
- code: "missing_signed_transaction_info",
3874
- message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
3875
- });
3876
- }
3877
- const rail = input.rail ?? "apple";
3878
- const body = { ...input, rail };
3879
- const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
3880
- const result = await s.http.request("POST", "/purchases/sync", {
3881
- body,
3882
- idempotencyKey
3883
- });
3884
- s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3885
- s.entitlements.setFromList(result.entitlements);
3886
- try {
3887
- const sourceProductId = result.entitlements[0]?.source.productId;
3888
- const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
3889
- const props = { rail };
3890
- if (sourceProductId) props.productId = sourceProductId;
3891
- if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
3892
- if (result.idempotent_replay) props.idempotent_replay = true;
3893
- this.track("purchase.completed", props);
3894
- } catch {
3895
- }
3896
- s.debug.emit(
3897
- "sdk.purchase_evidence_sent",
3898
- "StoreKit transaction forwarded. Waiting for backend verification.",
3899
- { rail: input.rail ?? "apple" }
3900
- );
3901
- return result;
3902
- }
3903
- /** @deprecated Use `syncPurchases()` instead. NorthStar §4 standardised the name. */
3904
- async purchaseApple(input) {
3905
- return this.syncPurchases({ rail: "apple", ...input });
3906
- }
3907
- /**
3908
- * Toggle verbose diagnostic logging — NorthStar §16. When enabled, the
3909
- * SDK emits a fixed vocabulary of debug signals to console.info that the
3910
- * dashboard's onboarding checklist can also surface as live events.
3911
- */
3912
- setDebugMode(enabled) {
3913
- const s = this.requireStarted();
3914
- s.debug.enabled = enabled;
3915
- if (enabled) {
3916
- s.debug.emit(
3917
- "sdk.configured",
3918
- `Debug mode enabled for ${s.options.appId} in ${s.options.environment} mode.`,
3919
- { appId: s.options.appId, environment: s.options.environment }
3920
- );
3921
- }
3922
- }
3923
- /**
3924
- * Send the boot heartbeat. Called automatically by start() unless
3925
- * autoHeartbeat:false. Safe to call manually as a "we're still here" ping.
3926
- */
3927
- async heartbeat() {
3928
- const s = this.requireStarted();
3929
- const result = await s.http.request("GET", "/sdk/heartbeat");
3930
- if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
3931
- s.lastServerTime = result.serverTime;
3932
- s.lastClientTime = Date.now();
3933
- }
3934
- return result;
3935
- }
3936
- /**
3937
- * Wipe persisted identity + entitlement cache. Use on logout. The
3938
- * next pre-login session generates a fresh anonymousId and starts a
3939
- * new identity-graph entry.
3940
- */
3941
- reset() {
3942
- if (!this.state) return;
3943
- if (this.state.developerUserId) {
3944
- try {
3945
- this.track("user.signed_out", { auto: true });
3946
- } catch {
3947
- }
3948
- }
3949
- this.state.autoTracker?.uninstall();
3950
- this.state.identity.reset();
3951
- this.state.entitlements.clearAll();
3952
- this.state.events.reset();
3953
- this.state.superProps.clear();
3954
- this.state.breadcrumbs.clear();
3955
- this.state.errorContext = {};
3956
- this.state.errorTags = {};
3957
- this.state.developerUserId = null;
3958
- this.state.lastServerTime = null;
3959
- this.state.lastClientTime = null;
3960
- if (this.state.autoTracker) {
3961
- const tracker = new AutoTracker(
3962
- this.state.options.autoTrack,
3963
- (name, props) => this.track(name, props)
3964
- );
3965
- this.state.autoTracker = tracker;
3966
- tracker.install();
3967
- }
3968
- }
3969
- /**
3970
- * Diagnostic: current state + queue stats. Useful for the dashboard's
3971
- * heartbeat row and debugging in dev.
3972
- *
3973
- * Returns a stable shape regardless of whether start() has been called —
3974
- * callers don't need to narrow on `started` to access `events` or
3975
- * `entitlements`. Pre-start values are sensible empties.
3976
- */
3977
- diagnostics() {
3978
- if (!this.state) {
3979
- return {
3980
- started: false,
3981
- anonymousId: null,
3982
- crossdeckCustomerId: null,
3983
- developerUserId: null,
3984
- sdkVersion: null,
3985
- baseUrl: null,
3986
- clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
3987
- entitlements: { count: 0, lastUpdated: 0, stale: false, listenerErrors: 0 },
3988
- events: {
3989
- buffered: 0,
3990
- dropped: 0,
3991
- inFlight: 0,
3992
- lastFlushAt: 0,
3993
- lastError: null,
3994
- consecutiveFailures: 0,
3995
- nextRetryAt: null
3996
- }
3997
- };
3998
- }
3999
- const s = this.state;
4000
- const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
4001
- return {
4002
- started: true,
4003
- anonymousId: s.identity.anonymousId,
4004
- crossdeckCustomerId: s.identity.crossdeckCustomerId,
4005
- developerUserId: s.developerUserId,
4006
- sdkVersion: s.options.sdkVersion,
4007
- baseUrl: s.options.baseUrl,
4008
- clock: {
4009
- lastServerTime: s.lastServerTime,
4010
- lastClientTime: s.lastClientTime,
4011
- skewMs
4012
- },
4013
- entitlements: {
4014
- count: s.entitlements.list().length,
4015
- lastUpdated: s.entitlements.freshness,
4016
- stale: s.entitlements.isStale,
4017
- listenerErrors: s.entitlements.listenerErrors
4018
- },
4019
- events: s.events.getStats()
4020
- };
4021
- }
4022
- // ---------- private helpers ----------
4023
- requireStarted() {
4024
- if (!this.state) {
4025
- throw new CrossdeckError({
4026
- type: "configuration_error",
4027
- code: "not_initialized",
4028
- message: "Call Crossdeck.init({ appId, publicKey, environment }) before any other method."
4029
- });
4030
- }
4031
- return this.state;
4032
- }
4033
- /**
4034
- * Build the identity query for /v1/entitlements. Priority:
4035
- * crossdeckCustomerId > developerUserId > anonymousId
4036
- * — matches the resolveCrossdeckCustomerId precedence on the server.
4037
- */
4038
- identityQueryParams() {
4039
- const s = this.requireStarted();
4040
- if (s.identity.crossdeckCustomerId) {
4041
- return { customerId: s.identity.crossdeckCustomerId };
4042
- }
4043
- if (s.developerUserId) return { userId: s.developerUserId };
4044
- return { anonymousId: s.identity.anonymousId };
4045
- }
4046
- /**
4047
- * Embed every known identity axis on the event. Earlier this returned
4048
- * just the highest-priority hint (cdcust → developerUserId → anonymousId)
4049
- * to keep payloads small, but that leaked into analytics: once a user
4050
- * was logged in, every subsequent page.viewed shipped without
4051
- * anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
4052
- * counted 0 visitors for the entire authenticated app.
4053
- *
4054
- * Bank-grade rule: the server is the single source of truth on
4055
- * dedup. Send everything we know; let CH count by whichever axis
4056
- * matches the question. Each field is at most 32 bytes — sending
4057
- * three on every event costs ~80 bytes per request, which is
4058
- * trivial compared to the analytics correctness it buys.
4059
- */
4060
- identityHintForEvent() {
4061
- const s = this.requireStarted();
4062
- const hint = {
4063
- anonymousId: s.identity.anonymousId
4064
- };
4065
- if (s.developerUserId) hint.developerUserId = s.developerUserId;
4066
- if (s.identity.crossdeckCustomerId) {
4067
- hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
4068
- }
4069
- return hint;
4070
- }
4071
- mintEventId() {
4072
- const ts = Date.now().toString(36);
4073
- return `evt_${ts}${randomChars(8)}`;
4074
- }
4075
- };
4076
- var Crossdeck = new CrossdeckClient();
4077
- function inferEnvFromKey(publicKey) {
4078
- if (publicKey.startsWith("cd_pub_test_")) return "sandbox";
4079
- if (publicKey.startsWith("cd_pub_live_")) return "production";
4080
- return null;
4081
- }
4082
- function isLocalHostname() {
4083
- const w = globalThis.window;
4084
- if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
4085
- const hostname = w?.location?.hostname;
4086
- if (!hostname) return false;
4087
- if (hostname === "localhost" || hostname === "127.0.0.1") return true;
4088
- if (hostname === "0.0.0.0") return true;
4089
- if (hostname === "::1" || hostname === "[::1]") return true;
4090
- if (/^\[?fe80::/i.test(hostname)) return true;
4091
- if (hostname.endsWith(".local")) return true;
4092
- if (/^10\./.test(hostname)) return true;
4093
- if (/^192\.168\./.test(hostname)) return true;
4094
- if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
4095
- return false;
4096
- }
4097
- function resolveAutoTrack(input) {
4098
- if (input === false) {
4099
- return {
4100
- sessions: false,
4101
- pageViews: false,
4102
- deviceInfo: false,
4103
- clicks: false,
4104
- webVitals: false,
4105
- errors: false
4106
- };
4107
- }
4108
- if (input === void 0 || input === true) {
4109
- return { ...DEFAULT_AUTO_TRACK };
4110
- }
4111
- return {
4112
- sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
4113
- pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
4114
- deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
4115
- clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
4116
- webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals,
4117
- errors: input.errors ?? DEFAULT_AUTO_TRACK.errors
4118
- };
4119
- }
4120
- function installUnloadFlush(onUnload) {
4121
- const w = globalThis.window;
4122
- const doc = globalThis.document;
4123
- if (!w || !doc) return () => void 0;
4124
- const onVisChange = () => {
4125
- if (doc.visibilityState === "hidden") onUnload();
4126
- };
4127
- const onTerminal = () => onUnload();
4128
- doc.addEventListener("visibilitychange", onVisChange);
4129
- w.addEventListener("pagehide", onTerminal);
4130
- w.addEventListener("beforeunload", onTerminal);
4131
- return () => {
4132
- doc.removeEventListener("visibilitychange", onVisChange);
4133
- w.removeEventListener("pagehide", onTerminal);
4134
- w.removeEventListener("beforeunload", onTerminal);
4135
- };
4136
- }
4137
-
4138
- // src/error-codes.ts
4139
- var CROSSDECK_ERROR_CODES = Object.freeze([
4140
- // ----- Configuration -----
4141
- {
4142
- code: "invalid_public_key",
4143
- type: "configuration_error",
4144
- description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
4145
- resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
4146
- retryable: false
4147
- },
4148
- {
4149
- code: "missing_app_id",
4150
- type: "configuration_error",
4151
- description: "Crossdeck.init() was called without an appId.",
4152
- resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
4153
- retryable: false
4154
- },
4155
- {
4156
- code: "invalid_environment",
4157
- type: "configuration_error",
4158
- description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
4159
- resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
4160
- retryable: false
4161
- },
4162
- {
4163
- code: "environment_mismatch",
4164
- type: "configuration_error",
4165
- description: "The publishable key's env prefix doesn't match the declared environment option.",
4166
- resolution: "Either change `environment` to match the key prefix (cd_pub_test_ \u2194 sandbox, cd_pub_live_ \u2194 production), or swap the key for one minted in the right env.",
4167
- retryable: false
4168
- },
4169
- {
4170
- code: "not_initialized",
4171
- type: "configuration_error",
4172
- description: "An SDK method was called before Crossdeck.init().",
4173
- resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
4174
- retryable: false
4175
- },
4176
- // ----- Identify / track / purchase argument validation -----
4177
- {
4178
- code: "missing_user_id",
4179
- type: "invalid_request_error",
4180
- description: "identify() was called with an empty userId.",
4181
- resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
4182
- retryable: false
4183
- },
4184
- {
4185
- code: "missing_event_name",
4186
- type: "invalid_request_error",
4187
- description: "track() was called without an event name.",
4188
- resolution: "Pass a non-empty string as the first argument.",
4189
- retryable: false
4190
- },
4191
- {
4192
- code: "missing_group_type",
4193
- type: "invalid_request_error",
4194
- description: "group() was called without a group type.",
4195
- resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
4196
- retryable: false
4197
- },
4198
- {
4199
- code: "missing_signed_transaction_info",
4200
- type: "invalid_request_error",
4201
- description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
4202
- resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
4203
- retryable: false
4204
- },
4205
- // ----- Network / transport -----
4206
- {
4207
- code: "fetch_failed",
4208
- type: "network_error",
4209
- description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
4210
- resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
4211
- retryable: true
4212
- },
4213
- {
4214
- code: "request_timeout",
4215
- type: "network_error",
4216
- description: "A request was aborted after the configured timeoutMs (default 15s).",
4217
- resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
4218
- retryable: true
4219
- },
4220
- {
4221
- code: "invalid_json_response",
4222
- type: "internal_error",
4223
- description: "The server returned a 2xx with an unparseable body.",
4224
- resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
4225
- retryable: true
4226
- },
4227
- // ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
4228
- // Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
4229
- // hitting any of these on the wire can look them up via
4230
- // getErrorCode(code) for a canonical remediation step instead of
4231
- // hunting through Slack history.
4232
- {
4233
- code: "missing_api_key",
4234
- type: "authentication_error",
4235
- description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
4236
- resolution: "Make sure Crossdeck.init({ publicKey }) was called with a cd_pub_\u2026 key before the request fired. Re-check your env-vars in CI / Docker if the key is empty at runtime.",
4237
- retryable: false
4238
- },
4239
- {
4240
- code: "invalid_api_key",
4241
- type: "authentication_error",
4242
- description: "The API key is malformed, unknown, or doesn't resolve to a project.",
4243
- resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys. Confirm prefix is cd_pub_test_ / cd_pub_live_ for client SDKs and cd_sk_test_ / cd_sk_live_ for the Node server SDK.",
4244
- retryable: false
4245
- },
4246
- {
4247
- code: "key_revoked",
4248
- type: "authentication_error",
4249
- description: "The API key was revoked in the Crossdeck dashboard.",
4250
- resolution: "Mint a fresh key in the dashboard \u2192 API keys \u2192 Create new, swap it in, and redeploy. The revoked key cannot be reactivated.",
4251
- retryable: false
4252
- },
4253
- {
4254
- code: "identity_token_invalid",
4255
- type: "authentication_error",
4256
- description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
4257
- resolution: "Refresh the token client-side (Firebase auth.currentUser.getIdToken(true)) and retry. If the failure persists, confirm the signer is registered under dashboard \u2192 Authentication \u2192 Identity sources.",
4258
- retryable: true
4259
- },
4260
- {
4261
- code: "origin_not_allowed",
4262
- type: "permission_error",
4263
- description: "The Origin header isn't in the project's Allowed origins list.",
4264
- resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
4265
- retryable: false
4266
- },
4267
- {
4268
- code: "bundle_id_not_allowed",
4269
- type: "permission_error",
4270
- description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
4271
- resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
4272
- retryable: false
4273
- },
4274
- {
4275
- code: "package_name_not_allowed",
4276
- type: "permission_error",
4277
- description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
4278
- resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
4279
- retryable: false
4280
- },
4281
- {
4282
- code: "env_mismatch",
4283
- type: "permission_error",
4284
- description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
4285
- resolution: "Use a cd_pub_live_ / cd_sk_live_ key with a production app, cd_pub_test_ / cd_sk_test_ with a sandbox app. The two cannot cross.",
4286
- retryable: false
4287
- },
4288
- {
4289
- code: "idempotency_key_in_use",
4290
- type: "invalid_request_error",
4291
- description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
4292
- resolution: "Generate a fresh key for a different transaction, or reuse the key only with the EXACT same body. The v1.4.0 SDKs derive keys deterministically from the body so this should never fire on SDK-managed calls.",
4293
- retryable: false
4294
- },
4295
- {
4296
- code: "rate_limited",
4297
- type: "rate_limit_error",
4298
- description: "Request rate exceeded the project's per-second cap.",
4299
- resolution: "Honour the Retry-After header \u2014 the SDK does this automatically on managed retries. If you're hitting it from a custom code path, throttle to <100 req/s/key.",
4300
- retryable: true
4301
- },
4302
- {
4303
- code: "internal_error",
4304
- type: "internal_error",
4305
- description: "Server-side issue. Safe to retry with backoff.",
4306
- resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
4307
- retryable: true
4308
- },
4309
- {
4310
- code: "google_not_supported",
4311
- type: "invalid_request_error",
4312
- description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
4313
- resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
4314
- retryable: false
4315
- },
4316
- {
4317
- code: "stripe_not_supported",
4318
- type: "invalid_request_error",
4319
- description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
4320
- resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
4321
- retryable: false
4322
- },
4323
- {
4324
- code: "missing_required_param",
4325
- type: "invalid_request_error",
4326
- description: "A required field is absent from the request body.",
4327
- resolution: "Inspect the error.message \u2014 the missing field name is included verbatim. Refer to the SDK's TypeScript types for the canonical request shape.",
4328
- retryable: false
4329
- },
4330
- {
4331
- code: "invalid_param_value",
4332
- type: "invalid_request_error",
4333
- description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
4334
- resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
4335
- retryable: false
4336
- }
4337
- ]);
4338
- function getErrorCode(code) {
4339
- return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
4340
- }
4341
-
4342
- // src/_contracts-bundled.ts
4343
- var BUNDLED_IN = "@cross-deck/web@1.5.0";
4344
- var SDK_VERSION2 = "1.5.0";
4345
- var BUNDLED_CONTRACTS = Object.freeze([
4346
- {
4347
- "id": "error-envelope-shape",
4348
- "pillar": "errors",
4349
- "status": "enforced",
4350
- "claim": "Every v1 REST endpoint returns errors in a Stripe-shape envelope: `{ error: { type, code, message, request_id } }` where `type` is one of authentication_error / permission_error / invalid_request_error / rate_limit_error / internal_error (the wire vocabulary in backend/src/api/v1-errors.ts ApiErrorType). HTTP status parity: invalid_request_error \u2192 400, authentication_error \u2192 401, permission_error \u2192 403, rate_limit_error \u2192 429, internal_error \u2192 500. SDK-side clients parse this shape via `crossdeckErrorFromResponse` (Web/Node/RN) / `crossdeckErrorFrom(response:)` (Swift) / `crossdeckErrorFromResponse` (Android) and surface the request_id verbatim so support traces are end-to-end joinable. Firebase callable endpoints (managed-keys / dashboard auth) use the Firebase HttpsError envelope instead \u2014 this contract applies to REST /v1/* only.",
4351
- "appliesTo": [
4352
- "web",
4353
- "node",
4354
- "react-native",
4355
- "swift",
4356
- "android",
4357
- "backend"
4358
- ],
4359
- "codeRef": [
4360
- "backend/src/api/v1-errors.ts",
4361
- "sdks/web/src/errors.ts",
4362
- "sdks/node/src/errors.ts",
4363
- "sdks/react-native/src/errors.ts",
4364
- "sdks/swift/Sources/Crossdeck/Errors.swift",
4365
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Errors.kt"
4366
- ],
4367
- "testRef": [
4368
- {
4369
- "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
4370
- "name": "test_errorEnvelope_fallsBackOnGarbageBody"
4371
- },
4372
- {
4373
- "file": "sdks/swift/Tests/CrossdeckTests/ErrorsTests.swift",
4374
- "name": "test_errorEnvelope_reads_XRequestId_fallback"
4375
- },
4376
- {
4377
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/ErrorTypeWireVocabTest.kt",
4378
- "name": "backend 500 response parses to INTERNAL_ERROR"
4379
- }
4380
- ],
4381
- "registeredAt": "2026-05-26",
4382
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 8 (codifies existing contract)",
4383
- "bundledIn": "@cross-deck/web@1.5.0"
4384
- },
4385
- {
4386
- "id": "flush-interval-parity",
4387
- "pillar": "analytics",
4388
- "status": "enforced",
4389
- "claim": "Every Crossdeck SDK defaults its event-queue flush interval to 2000ms \u2014 the Stripe-adjacent industry norm. Pre-v1.4.0 the defaults disagreed (Web/Node 1500ms; RN/Swift/Android 5000ms), so cross-platform funnels saw events landing at different cadences. Per-instance override stays \u2014 call sites can still tune it freely.",
4390
- "appliesTo": [
4391
- "web",
4392
- "node",
4393
- "react-native",
4394
- "swift",
4395
- "android"
4396
- ],
4397
- "codeRef": [
4398
- "sdks/web/src/crossdeck.ts",
4399
- "sdks/node/src/crossdeck-server.ts",
4400
- "sdks/react-native/src/crossdeck.ts",
4401
- "sdks/swift/Sources/Crossdeck/EventQueue.swift",
4402
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt"
4403
- ],
4404
- "testRef": [
4405
- {
4406
- "file": "sdks/swift/Sources/Crossdeck/EventQueue.swift",
4407
- "name": "flushIntervalMs: Int = 2_000"
4408
- },
4409
- {
4410
- "file": "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EventQueue.kt",
4411
- "name": "flushIntervalMs: Long = 2_000L"
4412
- },
4413
- {
4414
- "file": "sdks/web/src/crossdeck.ts",
4415
- "name": "options.eventFlushIntervalMs ?? 2000"
4416
- },
4417
- {
4418
- "file": "sdks/node/src/crossdeck-server.ts",
4419
- "name": "options.eventFlushIntervalMs ?? 2000"
4420
- },
4421
- {
4422
- "file": "sdks/react-native/src/crossdeck.ts",
4423
- "name": "options.eventFlushIntervalMs ?? 2000"
4424
- }
4425
- ],
4426
- "registeredAt": "2026-05-26",
4427
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.3",
4428
- "bundledIn": "@cross-deck/web@1.5.0"
4429
- },
4430
- {
4431
- "id": "idempotency-key-deterministic",
4432
- "pillar": "revenue",
4433
- "status": "enforced",
4434
- "claim": "syncPurchases() on every SDK derives a deterministic Idempotency-Key from the request body (UUID-shaped SHA-256 of `crossdeck:purchases/sync:<rail>:<jws|token>`). Same input -> same key. Backend short-circuits same-key-same-body retries by returning the cached response (status + body) with `idempotent_replay: true` flag in the body AND `Idempotent-Replayed: true` response header. Same-key-different-body returns 400 `idempotency_key_in_use`. 24-hour TTL matches Stripe. Cache only stores 2xx responses \u2014 4xx/5xx pass through so callers can fix bugs and retry. Helper returns nil/throws on missing identifier (no silent random fallback). Cross-SDK parity is CI-pinned: deriveForPurchase('apple', 'eyJ.jws.sig') MUST equal 'a66b1640-efaf-bb4d-1261-6650033bf111' on every SDK.",
4435
- "appliesTo": [
4436
- "web",
4437
- "node",
4438
- "react-native",
4439
- "swift",
4440
- "android",
4441
- "backend"
4442
- ],
4443
- "codeRef": [
4444
- "sdks/web/src/idempotency-key.ts",
4445
- "sdks/web/src/crossdeck.ts",
4446
- "sdks/react-native/src/idempotency-key.ts",
4447
- "sdks/react-native/src/crossdeck.ts",
4448
- "sdks/node/src/idempotency-key.ts",
4449
- "sdks/node/src/crossdeck-server.ts",
4450
- "sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
4451
- "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4452
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
4453
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt",
4454
- "backend/src/lib/idempotency-response-cache.ts",
4455
- "backend/src/api/v1-purchases.ts"
4456
- ],
4457
- "testRef": [
4458
- {
4459
- "file": "sdks/web/tests/idempotency-key.test.ts",
4460
- "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
4461
- },
4462
- {
4463
- "file": "sdks/web/tests/idempotency-key.test.ts",
4464
- "name": "is deterministic: same body twice -> identical key"
4465
- },
4466
- {
4467
- "file": "sdks/web/tests/idempotency-key.test.ts",
4468
- "name": "same identifier under different rails -> different keys"
4469
- },
4470
- {
4471
- "file": "sdks/web/tests/idempotency-key.test.ts",
4472
- "name": "never silently falls back to a random key on missing identifier"
4473
- },
4474
- {
4475
- "file": "sdks/react-native/tests/idempotency-key.test.ts",
4476
- "name": "is deterministic"
4477
- },
4478
- {
4479
- "file": "sdks/react-native/tests/idempotency-key.test.ts",
4480
- "name": "cross-SDK oracle \u2014 apple JWS pins canonical vector"
4481
- },
4482
- {
4483
- "file": "sdks/node/tests/idempotency-key.test.ts",
4484
- "name": "is deterministic"
4485
- },
4486
- {
4487
- "file": "sdks/node/tests/idempotency-key.test.ts",
4488
- "name": "rail namespacing prevents cross-rail collisions"
4489
- },
4490
- {
4491
- "file": "sdks/node/tests/idempotency-key.test.ts",
4492
- "name": "apple JWS produces the canonical pinned UUID across all 5 SDKs"
4493
- },
4494
- {
4495
- "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4496
- "name": "is deterministic for the same input"
4497
- },
4498
- {
4499
- "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4500
- "name": "injects idempotent_replay: true into a JSON object body"
4501
- },
4502
- {
4503
- "file": "backend/tests/unit/idempotency-response-cache.test.ts",
4504
- "name": "matches Stripe's 24-hour idempotency window"
4505
- },
4506
- {
4507
- "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4508
- "name": "test_crossSdkOracle_appleJWS"
4509
- },
4510
- {
4511
- "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4512
- "name": "test_railNamespacing_preventsCrossRailCollisions"
4513
- },
4514
- {
4515
- "file": "sdks/swift/Tests/CrossdeckTests/IdempotencyKeyTests.swift",
4516
- "name": "test_missingIdentifier_returnsNil"
4517
- },
4518
- {
4519
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4520
- "name": "cross-SDK oracle for apple JWS"
4521
- },
4522
- {
4523
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4524
- "name": "rail namespacing prevents cross-rail collisions"
4525
- },
4526
- {
4527
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/IdempotencyKeyTest.kt",
4528
- "name": "missing identifier returns null - never silent random fallback"
4529
- }
4530
- ],
4531
- "registeredAt": "2026-05-26",
4532
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 2.2.a + 2.2.b + 2.2.c",
4533
- "bundledIn": "@cross-deck/web@1.5.0"
4534
- },
4535
- {
4536
- "id": "init-reentry-drains-prior-queue",
4537
- "pillar": "lifecycle",
4538
- "status": "enforced",
4539
- "claim": "Web + RN init() re-entry drains the prior EventQueue's pending setTimeout BEFORE replacing this.state. Pre-v1.4.0 the teardown handled autoTracker/webVitals/errors/unloadFlush but NOT events, so the prior queue's timer would fire AFTER the state swap \u2014 sending old-init events against new-init http + identity references (cross-identity leak during HMR / config swap / multi-tenant SDK shells). The teardown CANNOT call persistent.clear() \u2014 the durable queue belongs to the SDK lifetime, not the init() lifetime, and a survived crash mid-flush re-hydrates on the next init.",
4540
- "appliesTo": [
4541
- "web",
4542
- "react-native"
4543
- ],
4544
- "codeRef": [
4545
- "sdks/web/src/crossdeck.ts",
4546
- "sdks/react-native/src/crossdeck.ts"
4547
- ],
4548
- "testRef": [
4549
- {
4550
- "file": "sdks/web/tests/init-reentry.test.ts",
4551
- "name": "re-init drains the prior queue's pending timer before swapping state"
4552
- },
4553
- {
4554
- "file": "sdks/web/tests/init-reentry.test.ts",
4555
- "name": "re-init does NOT wipe the durable event store"
4556
- }
4557
- ],
4558
- "registeredAt": "2026-05-26",
4559
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 5.5",
4560
- "bundledIn": "@cross-deck/web@1.5.0"
4561
- },
4562
- {
4563
- "id": "per-user-cache-isolation",
4564
- "pillar": "entitlements",
4565
- "status": "enforced",
4566
- "claim": "Every identify(userId) switches the entitlement cache to a physically separate per-user storage slot \u2014 `crossdeck:entitlements:<sha256(userId)>` \u2014 and unconditionally wipes the in-memory snapshot. A user-switch on a shared device CANNOT cross-read a prior user's cached entitlements, even if the in-memory clear is somehow skipped, because the storage keys are physically separate. reset() wipes every per-user slot via the persisted index.",
4567
- "appliesTo": [
4568
- "web",
4569
- "react-native",
4570
- "swift",
4571
- "android"
4572
- ],
4573
- "codeRef": [
4574
- "sdks/web/src/entitlement-cache.ts",
4575
- "sdks/web/src/hash.ts",
4576
- "sdks/web/src/crossdeck.ts",
4577
- "sdks/react-native/src/entitlement-cache.ts",
4578
- "sdks/react-native/src/hash.ts",
4579
- "sdks/react-native/src/crossdeck.ts",
4580
- "sdks/swift/Sources/Crossdeck/EntitlementCache.swift",
4581
- "sdks/swift/Sources/Crossdeck/IdempotencyKey.swift",
4582
- "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4583
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/EntitlementCache.kt",
4584
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/IdempotencyKey.kt",
4585
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
4586
- ],
4587
- "testRef": [
4588
- {
4589
- "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
4590
- "name": "identify(B) makes A's entitlements unreachable from in-memory"
4591
- },
4592
- {
4593
- "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
4594
- "name": "clearAll() removes every per-user storage key plus the index"
4595
- },
4596
- {
4597
- "file": "sdks/web/tests/entitlement-cache-isolation.test.ts",
4598
- "name": "a second cache instance reading A's storage suffix CANNOT see B's data"
4599
- },
4600
- {
4601
- "file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
4602
- "name": "identify(B) makes A's entitlements unreachable from in-memory"
4603
- },
4604
- {
4605
- "file": "sdks/react-native/tests/entitlement-cache-isolation.test.ts",
4606
- "name": "removes every per-user storage key plus the index"
4607
- },
4608
- {
4609
- "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
4610
- "name": "test_identifyB_makesAEntitlementsUnreachable"
4611
- },
4612
- {
4613
- "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
4614
- "name": "test_identifiedWritesLandUnderPerUserSha256Key"
4615
- },
4616
- {
4617
- "file": "sdks/swift/Tests/CrossdeckTests/EntitlementCacheIsolationTests.swift",
4618
- "name": "test_clearAll_removesEveryPerUserStorageKeyPlusIndex"
4619
- },
4620
- {
4621
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
4622
- "name": "identified writes land under per-user sha256 key"
4623
- },
4624
- {
4625
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
4626
- "name": "identify B makes A entitlements unreachable from in-memory"
4627
- },
4628
- {
4629
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
4630
- "name": "clearAll removes every per-user storage key plus the index"
4631
- },
4632
- {
4633
- "file": "sdks/android/crossdeck/src/test/kotlin/com/crossdeck/EntitlementCacheIsolationTest.kt",
4634
- "name": "a fresh cache bound to A's key CANNOT read B's blob"
4635
- }
4636
- ],
4637
- "registeredAt": "2026-05-26",
4638
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 1.3 (web/RN) + dogfood-gap fix (swift + android)",
4639
- "bundledIn": "@cross-deck/web@1.5.0"
4640
- },
4641
- {
4642
- "id": "sdk-error-codes-catalogue",
4643
- "pillar": "errors",
4644
- "status": "enforced",
4645
- "claim": "Web + Node SDK error-codes catalogues include EVERY backend-emitted ApiErrorCode with a description + resolution. Pre-v1.4.0 the catalogues documented codes the SDK threw ITSELF but ZERO backend codes \u2014 a developer hitting `invalid_api_key` / `origin_not_allowed` / `bundle_id_not_allowed` / `env_mismatch` / `idempotency_key_in_use` etc. got `undefined` from getErrorCode() and had to hunt for guidance. v1.4.0 backfills the catalogue from backend/src/api/v1-errors.ts so every wire code has a canonical 'what does this mean / what should I do' answer Stripe-style.",
4646
- "appliesTo": [
4647
- "web",
4648
- "node"
4649
- ],
4650
- "codeRef": [
4651
- "sdks/web/src/error-codes.ts",
4652
- "sdks/node/src/error-codes.ts",
4653
- "backend/src/api/v1-errors.ts"
4654
- ],
4655
- "testRef": [
4656
- {
4657
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4658
- "name": "includes backend code"
4659
- },
4660
- {
4661
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4662
- "name": "invalid_api_key resolution points at the dashboard"
4663
- },
4664
- {
4665
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4666
- "name": "idempotency_key_in_use resolution mentions Stripe-grade contract"
4667
- },
4668
- {
4669
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4670
- "name": "identity-lock codes carry permission_error type"
4671
- },
4672
- {
4673
- "file": "sdks/web/tests/error-codes-backfill.test.ts",
4674
- "name": "no entry has an empty description or resolution"
4675
- }
4676
- ],
4677
- "registeredAt": "2026-05-26",
4678
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 6.2",
4679
- "bundledIn": "@cross-deck/web@1.5.0"
4680
- },
4681
- {
4682
- "id": "sync-purchases-funnel-parity",
4683
- "pillar": "analytics",
4684
- "status": "enforced",
4685
- "claim": "Manual syncPurchases() emits a `purchase.completed` analytics event on success across ALL SDKs (Web / Node / RN / Swift / Android). Pre-v1.4.0 only Swift/Android auto-track emitted it \u2014 Web/Node/RN manual calls + Swift/Android manual calls fired ZERO analytics. Schema mirrors the auto-track event name + rail/productId/subscriptionId so cross-platform funnels reconcile on every payment path. When the backend short-circuits via the v1.4.0 idempotency cache, the event also carries `idempotent_replay: true`.",
4686
- "appliesTo": [
4687
- "web",
4688
- "node",
4689
- "react-native",
4690
- "swift",
4691
- "android"
4692
- ],
4693
- "codeRef": [
4694
- "sdks/web/src/crossdeck.ts",
4695
- "sdks/node/src/crossdeck-server.ts",
4696
- "sdks/react-native/src/crossdeck.ts",
4697
- "sdks/swift/Sources/Crossdeck/Crossdeck.swift",
4698
- "sdks/android/crossdeck/src/main/kotlin/com/crossdeck/Crossdeck.kt"
4699
- ],
4700
- "testRef": [
4701
- {
4702
- "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
4703
- "name": "emits purchase.completed after a successful sync"
4704
- },
4705
- {
4706
- "file": "sdks/web/tests/sync-purchases-funnel.test.ts",
4707
- "name": "carries idempotent_replay=true when backend replied from cache"
4708
- }
4709
- ],
4710
- "registeredAt": "2026-05-26",
4711
- "firstRegisteredIn": "bank-grade reconciliation v1.4.0 \u2014 phase 3.5",
4712
- "bundledIn": "@cross-deck/web@1.5.0"
4713
- }
4714
- ]);
4715
-
4716
- // src/contracts.ts
4717
- var CrossdeckContracts = {
4718
- /** Every contract that applies to this SDK and is currently enforced. */
4719
- all() {
4720
- return BUNDLED_CONTRACTS.filter((c) => c.status === "enforced");
4721
- },
4722
- /**
4723
- * Every contract bundled with this SDK release, including
4724
- * `proposed` and `retired` entries. Use `all()` for the
4725
- * enforced-only view.
4726
- */
4727
- allIncludingHistorical() {
4728
- return BUNDLED_CONTRACTS;
4729
- },
4730
- /** Look up a contract by its stable `id`. */
4731
- byId(id) {
4732
- return BUNDLED_CONTRACTS.find((c) => c.id === id);
4733
- },
4734
- /** Every enforced contract within a pillar. */
4735
- byPillar(pillar) {
4736
- return BUNDLED_CONTRACTS.filter(
4737
- (c) => c.pillar === pillar && c.status === "enforced"
4738
- );
4739
- },
4740
- /** Filter by lifecycle status. */
4741
- withStatus(status) {
4742
- return BUNDLED_CONTRACTS.filter((c) => c.status === status);
4743
- },
4744
- /** Semver of the SDK release these contracts were bundled with. */
4745
- sdkVersion: SDK_VERSION2,
4746
- /** Fully-qualified bundle identifier — e.g. `@cross-deck/web@1.4.2`. */
4747
- bundledIn: BUNDLED_IN,
4748
- /**
4749
- * Resolve a failing test back to the contract it exercises.
4750
- * Used by test-framework hooks (Vitest `afterEach`, XCTest
4751
- * observation, JUnit `TestWatcher`) to find the contract id of
4752
- * a failed contract test so `reportContractFailure(...)` can
4753
- * stamp the right `contract_id` on the emitted event.
4754
- *
4755
- * Match is on `testRef.name` (case-sensitive, exact). Returns
4756
- * the first contract whose `testRef` list contains a matching
4757
- * entry, regardless of pillar or status.
4758
- */
4759
- findByTestName(name) {
4760
- return BUNDLED_CONTRACTS.find(
4761
- (c) => c.testRef.some((ref) => ref.name === name)
4762
- );
4763
- }
4764
- };
4765
- // Annotate the CommonJS export names for ESM import in node:
4766
- 0 && (module.exports = {
4767
- CROSSDECK_ERROR_CODES,
4768
- Crossdeck,
4769
- CrossdeckClient,
4770
- CrossdeckContracts,
4771
- CrossdeckError,
4772
- DEFAULT_BASE_URL,
4773
- MemoryStorage,
4774
- SDK_NAME,
4775
- SDK_VERSION,
4776
- getErrorCode
4777
- });
4778
- //# sourceMappingURL=index.cjs.map