@cross-deck/web 1.2.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -1
- package/README.md +13 -1
- package/dist/crossdeck.umd.min.js +2 -2
- package/dist/crossdeck.umd.min.js.map +1 -1
- package/dist/error-codes.json +106 -1
- package/dist/index.cjs +691 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +22 -4
- package/dist/index.d.ts +22 -4
- package/dist/index.mjs +691 -73
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +581 -73
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.mts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.mjs +581 -73
- package/dist/react.mjs.map +1 -1
- package/dist/{types-BzoKor4z.d.mts → types-Bu3jbmdq.d.mts} +4 -0
- package/dist/{types-BzoKor4z.d.ts → types-Bu3jbmdq.d.ts} +4 -0
- package/dist/vue.cjs +581 -73
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.mjs +581 -73
- package/dist/vue.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -63,9 +63,11 @@ function typeMapForStatus(status) {
|
|
|
63
63
|
return "internal_error";
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
// src/
|
|
66
|
+
// src/_version.ts
|
|
67
|
+
var SDK_VERSION = "1.4.2";
|
|
67
68
|
var SDK_NAME = "@cross-deck/web";
|
|
68
|
-
|
|
69
|
+
|
|
70
|
+
// src/http.ts
|
|
69
71
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
70
72
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
71
73
|
var HttpClient = class {
|
|
@@ -312,29 +314,258 @@ function randomChars(count) {
|
|
|
312
314
|
return out.join("");
|
|
313
315
|
}
|
|
314
316
|
|
|
317
|
+
// src/hash.ts
|
|
318
|
+
var K = new Uint32Array([
|
|
319
|
+
1116352408,
|
|
320
|
+
1899447441,
|
|
321
|
+
3049323471,
|
|
322
|
+
3921009573,
|
|
323
|
+
961987163,
|
|
324
|
+
1508970993,
|
|
325
|
+
2453635748,
|
|
326
|
+
2870763221,
|
|
327
|
+
3624381080,
|
|
328
|
+
310598401,
|
|
329
|
+
607225278,
|
|
330
|
+
1426881987,
|
|
331
|
+
1925078388,
|
|
332
|
+
2162078206,
|
|
333
|
+
2614888103,
|
|
334
|
+
3248222580,
|
|
335
|
+
3835390401,
|
|
336
|
+
4022224774,
|
|
337
|
+
264347078,
|
|
338
|
+
604807628,
|
|
339
|
+
770255983,
|
|
340
|
+
1249150122,
|
|
341
|
+
1555081692,
|
|
342
|
+
1996064986,
|
|
343
|
+
2554220882,
|
|
344
|
+
2821834349,
|
|
345
|
+
2952996808,
|
|
346
|
+
3210313671,
|
|
347
|
+
3336571891,
|
|
348
|
+
3584528711,
|
|
349
|
+
113926993,
|
|
350
|
+
338241895,
|
|
351
|
+
666307205,
|
|
352
|
+
773529912,
|
|
353
|
+
1294757372,
|
|
354
|
+
1396182291,
|
|
355
|
+
1695183700,
|
|
356
|
+
1986661051,
|
|
357
|
+
2177026350,
|
|
358
|
+
2456956037,
|
|
359
|
+
2730485921,
|
|
360
|
+
2820302411,
|
|
361
|
+
3259730800,
|
|
362
|
+
3345764771,
|
|
363
|
+
3516065817,
|
|
364
|
+
3600352804,
|
|
365
|
+
4094571909,
|
|
366
|
+
275423344,
|
|
367
|
+
430227734,
|
|
368
|
+
506948616,
|
|
369
|
+
659060556,
|
|
370
|
+
883997877,
|
|
371
|
+
958139571,
|
|
372
|
+
1322822218,
|
|
373
|
+
1537002063,
|
|
374
|
+
1747873779,
|
|
375
|
+
1955562222,
|
|
376
|
+
2024104815,
|
|
377
|
+
2227730452,
|
|
378
|
+
2361852424,
|
|
379
|
+
2428436474,
|
|
380
|
+
2756734187,
|
|
381
|
+
3204031479,
|
|
382
|
+
3329325298
|
|
383
|
+
]);
|
|
384
|
+
function utf8Bytes(input) {
|
|
385
|
+
if (typeof TextEncoder !== "undefined") {
|
|
386
|
+
return new TextEncoder().encode(input);
|
|
387
|
+
}
|
|
388
|
+
const out = [];
|
|
389
|
+
for (let i = 0; i < input.length; i++) {
|
|
390
|
+
let codePoint = input.charCodeAt(i);
|
|
391
|
+
if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
|
|
392
|
+
const next = input.charCodeAt(i + 1);
|
|
393
|
+
if (next >= 56320 && next <= 57343) {
|
|
394
|
+
codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
|
|
395
|
+
i++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (codePoint < 128) {
|
|
399
|
+
out.push(codePoint);
|
|
400
|
+
} else if (codePoint < 2048) {
|
|
401
|
+
out.push(192 | codePoint >> 6);
|
|
402
|
+
out.push(128 | codePoint & 63);
|
|
403
|
+
} else if (codePoint < 65536) {
|
|
404
|
+
out.push(224 | codePoint >> 12);
|
|
405
|
+
out.push(128 | codePoint >> 6 & 63);
|
|
406
|
+
out.push(128 | codePoint & 63);
|
|
407
|
+
} else {
|
|
408
|
+
out.push(240 | codePoint >> 18);
|
|
409
|
+
out.push(128 | codePoint >> 12 & 63);
|
|
410
|
+
out.push(128 | codePoint >> 6 & 63);
|
|
411
|
+
out.push(128 | codePoint & 63);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return new Uint8Array(out);
|
|
415
|
+
}
|
|
416
|
+
function sha256Hex(input) {
|
|
417
|
+
const bytes = utf8Bytes(input);
|
|
418
|
+
const bitLength = bytes.length * 8;
|
|
419
|
+
const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
|
|
420
|
+
const padded = new Uint8Array(blockCount * 64);
|
|
421
|
+
padded.set(bytes);
|
|
422
|
+
padded[bytes.length] = 128;
|
|
423
|
+
const high = Math.floor(bitLength / 4294967296);
|
|
424
|
+
const low = bitLength >>> 0;
|
|
425
|
+
const lenOffset = padded.length - 8;
|
|
426
|
+
padded[lenOffset + 0] = high >>> 24 & 255;
|
|
427
|
+
padded[lenOffset + 1] = high >>> 16 & 255;
|
|
428
|
+
padded[lenOffset + 2] = high >>> 8 & 255;
|
|
429
|
+
padded[lenOffset + 3] = high & 255;
|
|
430
|
+
padded[lenOffset + 4] = low >>> 24 & 255;
|
|
431
|
+
padded[lenOffset + 5] = low >>> 16 & 255;
|
|
432
|
+
padded[lenOffset + 6] = low >>> 8 & 255;
|
|
433
|
+
padded[lenOffset + 7] = low & 255;
|
|
434
|
+
const H = new Uint32Array([
|
|
435
|
+
1779033703,
|
|
436
|
+
3144134277,
|
|
437
|
+
1013904242,
|
|
438
|
+
2773480762,
|
|
439
|
+
1359893119,
|
|
440
|
+
2600822924,
|
|
441
|
+
528734635,
|
|
442
|
+
1541459225
|
|
443
|
+
]);
|
|
444
|
+
const W = new Uint32Array(64);
|
|
445
|
+
for (let block = 0; block < blockCount; block++) {
|
|
446
|
+
const offset = block * 64;
|
|
447
|
+
for (let t = 0; t < 16; t++) {
|
|
448
|
+
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;
|
|
449
|
+
}
|
|
450
|
+
for (let t = 16; t < 64; t++) {
|
|
451
|
+
const w15 = W[t - 15];
|
|
452
|
+
const w2 = W[t - 2];
|
|
453
|
+
const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
|
|
454
|
+
const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
|
|
455
|
+
W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
|
|
456
|
+
}
|
|
457
|
+
let a = H[0], b = H[1], c = H[2], d = H[3];
|
|
458
|
+
let e = H[4], f = H[5], g = H[6], h = H[7];
|
|
459
|
+
for (let t = 0; t < 64; t++) {
|
|
460
|
+
const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
|
|
461
|
+
const ch = e & f ^ ~e & g;
|
|
462
|
+
const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
|
|
463
|
+
const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
|
|
464
|
+
const maj = a & b ^ a & c ^ b & c;
|
|
465
|
+
const temp2 = S0 + maj >>> 0;
|
|
466
|
+
h = g;
|
|
467
|
+
g = f;
|
|
468
|
+
f = e;
|
|
469
|
+
e = d + temp1 >>> 0;
|
|
470
|
+
d = c;
|
|
471
|
+
c = b;
|
|
472
|
+
b = a;
|
|
473
|
+
a = temp1 + temp2 >>> 0;
|
|
474
|
+
}
|
|
475
|
+
H[0] = H[0] + a >>> 0;
|
|
476
|
+
H[1] = H[1] + b >>> 0;
|
|
477
|
+
H[2] = H[2] + c >>> 0;
|
|
478
|
+
H[3] = H[3] + d >>> 0;
|
|
479
|
+
H[4] = H[4] + e >>> 0;
|
|
480
|
+
H[5] = H[5] + f >>> 0;
|
|
481
|
+
H[6] = H[6] + g >>> 0;
|
|
482
|
+
H[7] = H[7] + h >>> 0;
|
|
483
|
+
}
|
|
484
|
+
let hex = "";
|
|
485
|
+
for (let i = 0; i < 8; i++) {
|
|
486
|
+
hex += H[i].toString(16).padStart(8, "0");
|
|
487
|
+
}
|
|
488
|
+
return hex;
|
|
489
|
+
}
|
|
490
|
+
|
|
315
491
|
// src/entitlement-cache.ts
|
|
316
492
|
var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
|
|
317
|
-
var
|
|
493
|
+
var ANON_SUFFIX = "_anon";
|
|
494
|
+
var INDEX_SUFFIX = "_index";
|
|
495
|
+
var EntitlementCache = class _EntitlementCache {
|
|
318
496
|
/**
|
|
319
|
-
* @param storage
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* @param
|
|
324
|
-
*
|
|
325
|
-
*
|
|
497
|
+
* @param storage Device storage adapter. When omitted (tests) or
|
|
498
|
+
* a MemoryStorage (strict-consent / no-persistence
|
|
499
|
+
* mode) the cache is session-only — durability is
|
|
500
|
+
* simply absent, never wrong.
|
|
501
|
+
* @param storageKeyPrefix Prefix used to derive per-user storage keys
|
|
502
|
+
* (`<prefix>:<sha256(userId)>`). Default
|
|
503
|
+
* `crossdeck:entitlements`. The trailing user
|
|
504
|
+
* suffix is filled at identify() / reset()
|
|
505
|
+
* time — see [[setUserKey]].
|
|
506
|
+
* @param staleAfterMs Age past which last-known-good is flagged stale
|
|
507
|
+
* even without a failed refresh. Default 24h.
|
|
326
508
|
*/
|
|
327
|
-
constructor(storage,
|
|
509
|
+
constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
|
|
328
510
|
this.all = [];
|
|
329
511
|
this.lastUpdated = 0;
|
|
330
512
|
this.lastRefreshFailedAt = 0;
|
|
331
513
|
this.listeners = /* @__PURE__ */ new Set();
|
|
332
514
|
this.listenerErrorCount = 0;
|
|
515
|
+
this.currentSuffix = ANON_SUFFIX;
|
|
333
516
|
this.storage = storage;
|
|
334
|
-
this.
|
|
517
|
+
this.storageKeyPrefix = storageKeyPrefix;
|
|
335
518
|
this.staleAfterMs = staleAfterMs;
|
|
336
519
|
this.hydrate();
|
|
337
520
|
}
|
|
521
|
+
/** The full storage key the current-user blob is persisted under. */
|
|
522
|
+
get storageKey() {
|
|
523
|
+
return `${this.storageKeyPrefix}:${this.currentSuffix}`;
|
|
524
|
+
}
|
|
525
|
+
/** Key of the index blob — a JSON array of every suffix we've
|
|
526
|
+
* written. Used by clearAll() to scope a logout-wipe. */
|
|
527
|
+
get indexKey() {
|
|
528
|
+
return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
|
|
529
|
+
}
|
|
530
|
+
/** Derive a stable suffix for a developerUserId via SHA-256. The
|
|
531
|
+
* raw userId never lands in the storage key — protects against
|
|
532
|
+
* accidentally leaking email-style identifiers through DevTools
|
|
533
|
+
* inspection. Pass `null` to switch back to the anonymous slot. */
|
|
534
|
+
static suffixForUserId(userId) {
|
|
535
|
+
if (userId == null || userId === "") return ANON_SUFFIX;
|
|
536
|
+
return sha256Hex(userId);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Switch the cache to a different user's storage slot. Bank-grade
|
|
540
|
+
* three-layer isolation:
|
|
541
|
+
* (a) Physical key separation — `<prefix>:<sha256(userId)>` so
|
|
542
|
+
* a user-switch can't physically read prior user's data
|
|
543
|
+
* even if the in-memory clear was skipped.
|
|
544
|
+
* (b) Unconditional in-memory clear — invoked whenever the
|
|
545
|
+
* active suffix changes, even on same-id re-identify.
|
|
546
|
+
* (c) Re-hydrate from the new slot — a returning user observes
|
|
547
|
+
* their last-known-good cache from storage immediately.
|
|
548
|
+
*
|
|
549
|
+
* Caller (identify() / reset()) MUST invoke this BEFORE the next
|
|
550
|
+
* setFromList() so the write lands under the right key.
|
|
551
|
+
*/
|
|
552
|
+
setUserKey(userId) {
|
|
553
|
+
const nextSuffix = _EntitlementCache.suffixForUserId(userId);
|
|
554
|
+
if (nextSuffix === this.currentSuffix) {
|
|
555
|
+
this.all = [];
|
|
556
|
+
this.lastUpdated = 0;
|
|
557
|
+
this.lastRefreshFailedAt = 0;
|
|
558
|
+
this.notify();
|
|
559
|
+
this.hydrate();
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
this.currentSuffix = nextSuffix;
|
|
563
|
+
this.all = [];
|
|
564
|
+
this.lastUpdated = 0;
|
|
565
|
+
this.lastRefreshFailedAt = 0;
|
|
566
|
+
this.hydrate();
|
|
567
|
+
this.notify();
|
|
568
|
+
}
|
|
338
569
|
/**
|
|
339
570
|
* Sync read — true iff the entitlement is currently granting access.
|
|
340
571
|
*
|
|
@@ -404,12 +635,13 @@ var EntitlementCache = class {
|
|
|
404
635
|
this.lastUpdated = Date.now();
|
|
405
636
|
this.lastRefreshFailedAt = 0;
|
|
406
637
|
this.persist();
|
|
638
|
+
this.recordSuffixInIndex(this.currentSuffix);
|
|
407
639
|
this.notify();
|
|
408
640
|
}
|
|
409
641
|
/**
|
|
410
|
-
* Wipe
|
|
411
|
-
*
|
|
412
|
-
*
|
|
642
|
+
* Wipe the CURRENT user's slot. Used internally when a single
|
|
643
|
+
* user's cache needs to be invalidated without affecting other
|
|
644
|
+
* persisted slots. The full-logout path is [[clearAll]].
|
|
413
645
|
*/
|
|
414
646
|
clear() {
|
|
415
647
|
this.all = [];
|
|
@@ -421,6 +653,40 @@ var EntitlementCache = class {
|
|
|
421
653
|
} catch {
|
|
422
654
|
}
|
|
423
655
|
}
|
|
656
|
+
this.removeSuffixFromIndex(this.currentSuffix);
|
|
657
|
+
this.notify();
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Logout-grade wipe — bank-grade contract: removes EVERY per-user
|
|
661
|
+
* entitlement slot the SDK has ever written on this device, then
|
|
662
|
+
* clears the index. Used by `Crossdeck.reset()` so a logout on a
|
|
663
|
+
* shared device can never leave another user's entitlements
|
|
664
|
+
* readable (layer (c) of the v1.4.0 isolation fix).
|
|
665
|
+
*
|
|
666
|
+
* After clearAll(), the cache is back to anonymous + empty.
|
|
667
|
+
*/
|
|
668
|
+
clearAll() {
|
|
669
|
+
this.all = [];
|
|
670
|
+
this.lastUpdated = 0;
|
|
671
|
+
this.lastRefreshFailedAt = 0;
|
|
672
|
+
this.currentSuffix = ANON_SUFFIX;
|
|
673
|
+
if (this.storage) {
|
|
674
|
+
const suffixes = this.readIndex();
|
|
675
|
+
for (const suffix of suffixes) {
|
|
676
|
+
try {
|
|
677
|
+
this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
|
|
678
|
+
} catch {
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
|
|
683
|
+
} catch {
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
this.storage.removeItem(this.indexKey);
|
|
687
|
+
} catch {
|
|
688
|
+
}
|
|
689
|
+
}
|
|
424
690
|
this.notify();
|
|
425
691
|
}
|
|
426
692
|
/**
|
|
@@ -470,6 +736,47 @@ var EntitlementCache = class {
|
|
|
470
736
|
} catch {
|
|
471
737
|
}
|
|
472
738
|
}
|
|
739
|
+
/** Read the index of all per-user suffixes the SDK has written. */
|
|
740
|
+
readIndex() {
|
|
741
|
+
if (!this.storage) return [];
|
|
742
|
+
try {
|
|
743
|
+
const raw = this.storage.getItem(this.indexKey);
|
|
744
|
+
if (!raw) return [];
|
|
745
|
+
const parsed = JSON.parse(raw);
|
|
746
|
+
if (Array.isArray(parsed)) {
|
|
747
|
+
return parsed.filter((x) => typeof x === "string");
|
|
748
|
+
}
|
|
749
|
+
return [];
|
|
750
|
+
} catch {
|
|
751
|
+
return [];
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/** Add a suffix to the persisted index. Idempotent. */
|
|
755
|
+
recordSuffixInIndex(suffix) {
|
|
756
|
+
if (!this.storage) return;
|
|
757
|
+
const existing = this.readIndex();
|
|
758
|
+
if (existing.includes(suffix)) return;
|
|
759
|
+
existing.push(suffix);
|
|
760
|
+
try {
|
|
761
|
+
this.storage.setItem(this.indexKey, JSON.stringify(existing));
|
|
762
|
+
} catch {
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
/** Remove a suffix from the persisted index. No-op if absent. */
|
|
766
|
+
removeSuffixFromIndex(suffix) {
|
|
767
|
+
if (!this.storage) return;
|
|
768
|
+
const existing = this.readIndex();
|
|
769
|
+
const next = existing.filter((s) => s !== suffix);
|
|
770
|
+
if (next.length === existing.length) return;
|
|
771
|
+
try {
|
|
772
|
+
if (next.length === 0) {
|
|
773
|
+
this.storage.removeItem(this.indexKey);
|
|
774
|
+
} else {
|
|
775
|
+
this.storage.setItem(this.indexKey, JSON.stringify(next));
|
|
776
|
+
}
|
|
777
|
+
} catch {
|
|
778
|
+
}
|
|
779
|
+
}
|
|
473
780
|
notify() {
|
|
474
781
|
if (this.listeners.size === 0) return;
|
|
475
782
|
const snapshot = this.all.slice();
|
|
@@ -484,6 +791,34 @@ var EntitlementCache = class {
|
|
|
484
791
|
}
|
|
485
792
|
};
|
|
486
793
|
|
|
794
|
+
// src/idempotency-key.ts
|
|
795
|
+
function formatAsUuid(hex) {
|
|
796
|
+
return [
|
|
797
|
+
hex.slice(0, 8),
|
|
798
|
+
hex.slice(8, 12),
|
|
799
|
+
hex.slice(12, 16),
|
|
800
|
+
hex.slice(16, 20),
|
|
801
|
+
hex.slice(20, 32)
|
|
802
|
+
].join("-");
|
|
803
|
+
}
|
|
804
|
+
function deriveIdempotencyKeyForPurchase(body) {
|
|
805
|
+
let identifier;
|
|
806
|
+
if (body.rail === "apple") {
|
|
807
|
+
identifier = body.signedTransactionInfo ?? "";
|
|
808
|
+
} else if (body.rail === "google") {
|
|
809
|
+
identifier = body.purchaseToken ?? "";
|
|
810
|
+
} else {
|
|
811
|
+
identifier = "";
|
|
812
|
+
}
|
|
813
|
+
if (!identifier) {
|
|
814
|
+
throw new Error(
|
|
815
|
+
`deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
|
|
819
|
+
return formatAsUuid(sha256Hex(namespaced));
|
|
820
|
+
}
|
|
821
|
+
|
|
487
822
|
// src/retry-policy.ts
|
|
488
823
|
var DEFAULT_BASE = 1e3;
|
|
489
824
|
var DEFAULT_MAX = 6e4;
|
|
@@ -496,8 +831,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
|
|
|
496
831
|
const safeAttempts = Math.min(attempts, 30);
|
|
497
832
|
const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
|
|
498
833
|
const jittered = ceiling * random();
|
|
499
|
-
if (retryAfterMs !== void 0
|
|
500
|
-
|
|
834
|
+
if (retryAfterMs !== void 0) {
|
|
835
|
+
const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
|
|
836
|
+
const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
|
|
837
|
+
if (honoured > jittered) return honoured;
|
|
501
838
|
}
|
|
502
839
|
return Math.max(0, Math.round(jittered));
|
|
503
840
|
}
|
|
@@ -532,6 +869,27 @@ var EventQueue = class {
|
|
|
532
869
|
constructor(cfg) {
|
|
533
870
|
this.cfg = cfg;
|
|
534
871
|
this.buffer = [];
|
|
872
|
+
/**
|
|
873
|
+
* In-flight events that have been spliced from `buffer` for the
|
|
874
|
+
* current batch but haven't yet been confirmed (success or permanent
|
|
875
|
+
* failure). On a retry-driven re-flush we re-use this slot alongside
|
|
876
|
+
* `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
|
|
877
|
+
* across retries of the SAME logical batch.
|
|
878
|
+
*
|
|
879
|
+
* Pre-fix the splice cleared the buffer AND we immediately
|
|
880
|
+
* `persistent.save(empty)` BEFORE awaiting the network call — a
|
|
881
|
+
* crash mid-flight wiped the persisted blob and the batch was lost
|
|
882
|
+
* with no replay on next boot. Now the persisted blob always carries
|
|
883
|
+
* `[...pendingBatch, ...buffer]` so the in-flight events survive
|
|
884
|
+
* until the server confirms them.
|
|
885
|
+
*
|
|
886
|
+
* Pre-fix every retry attempt also minted a NEW batchId via
|
|
887
|
+
* `splice + mintBatchId`, defeating the backend's
|
|
888
|
+
* `Idempotency-Key` dedup. Reuse via this slot brings web in
|
|
889
|
+
* lockstep with node (which already had the field).
|
|
890
|
+
*/
|
|
891
|
+
this.pendingBatch = null;
|
|
892
|
+
this.pendingBatchId = null;
|
|
535
893
|
this.dropped = 0;
|
|
536
894
|
this.inFlight = 0;
|
|
537
895
|
this.lastFlushAt = 0;
|
|
@@ -564,7 +922,7 @@ var EventQueue = class {
|
|
|
564
922
|
this.cfg.onDrop?.(overflow);
|
|
565
923
|
}
|
|
566
924
|
this.cfg.onBufferChange?.(this.buffer.length);
|
|
567
|
-
this.
|
|
925
|
+
this.persistAll();
|
|
568
926
|
if (this.buffer.length >= this.cfg.batchSize) {
|
|
569
927
|
void this.flush();
|
|
570
928
|
} else {
|
|
@@ -573,22 +931,44 @@ var EventQueue = class {
|
|
|
573
931
|
}
|
|
574
932
|
/**
|
|
575
933
|
* Flush the buffer to /v1/events. Resolves when the network call
|
|
576
|
-
* completes (success or failure). On
|
|
577
|
-
*
|
|
934
|
+
* completes (success or failure). On a retryable failure the batch
|
|
935
|
+
* stays in `pendingBatch` for the next scheduled retry — the SAME
|
|
936
|
+
* batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
|
|
937
|
+
*
|
|
938
|
+
* Three terminal states from one call:
|
|
939
|
+
* - 2xx success: pendingBatch cleared, persisted state collapses to
|
|
940
|
+
* just `buffer` (any new events that arrived during in-flight).
|
|
941
|
+
* - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
|
|
942
|
+
* counter increments, a `permanent_failure` signal fires. The
|
|
943
|
+
* server is telling us our request is malformed / our key is
|
|
944
|
+
* revoked / we lack permission — retrying with the same key
|
|
945
|
+
* forever just grows the queue while the customer thinks events
|
|
946
|
+
* are landing.
|
|
947
|
+
* - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
|
|
948
|
+
* schedules a retry; the next `flush()` re-uses both.
|
|
578
949
|
*
|
|
579
950
|
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
580
|
-
* browser keeps the request alive past page unload. Use
|
|
581
|
-
*
|
|
951
|
+
* browser keeps the request alive past page unload. Use for terminal
|
|
952
|
+
* flushes (pagehide / visibilitychange→hidden / beforeunload).
|
|
582
953
|
*/
|
|
583
954
|
async flush(options = {}) {
|
|
584
|
-
|
|
955
|
+
let batch;
|
|
956
|
+
let batchId;
|
|
957
|
+
if (this.pendingBatch !== null && this.pendingBatchId !== null) {
|
|
958
|
+
batch = this.pendingBatch;
|
|
959
|
+
batchId = this.pendingBatchId;
|
|
960
|
+
} else {
|
|
961
|
+
if (this.buffer.length === 0) return null;
|
|
962
|
+
batch = this.buffer.splice(0);
|
|
963
|
+
batchId = this.mintBatchId();
|
|
964
|
+
this.pendingBatch = batch;
|
|
965
|
+
this.pendingBatchId = batchId;
|
|
966
|
+
this.inFlight += batch.length;
|
|
967
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
968
|
+
this.persistAll();
|
|
969
|
+
}
|
|
585
970
|
this.cancelTimerIfSet();
|
|
586
971
|
this.nextRetryAt = null;
|
|
587
|
-
const batch = this.buffer.splice(0);
|
|
588
|
-
const batchId = this.mintBatchId();
|
|
589
|
-
this.inFlight += batch.length;
|
|
590
|
-
this.persistent?.save(this.buffer);
|
|
591
|
-
this.cfg.onBufferChange?.(this.buffer.length);
|
|
592
972
|
try {
|
|
593
973
|
const env = this.cfg.envelope();
|
|
594
974
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
@@ -607,20 +987,33 @@ var EventQueue = class {
|
|
|
607
987
|
this.lastFlushAt = Date.now();
|
|
608
988
|
this.lastError = null;
|
|
609
989
|
this.inFlight -= batch.length;
|
|
990
|
+
this.pendingBatch = null;
|
|
991
|
+
this.pendingBatchId = null;
|
|
610
992
|
this.retry.recordSuccess();
|
|
611
|
-
this.
|
|
993
|
+
this.persistAll();
|
|
612
994
|
if (!this.firstFlushFired) {
|
|
613
995
|
this.firstFlushFired = true;
|
|
614
996
|
this.cfg.onFirstFlushSuccess?.();
|
|
615
997
|
}
|
|
616
998
|
return result;
|
|
617
999
|
} catch (err) {
|
|
618
|
-
this.buffer.unshift(...batch);
|
|
619
|
-
this.inFlight -= batch.length;
|
|
620
1000
|
const message = err instanceof Error ? err.message : String(err);
|
|
621
1001
|
this.lastError = message;
|
|
622
|
-
|
|
623
|
-
|
|
1002
|
+
if (isPermanent4xx(err)) {
|
|
1003
|
+
const droppedCount = batch.length;
|
|
1004
|
+
this.pendingBatch = null;
|
|
1005
|
+
this.pendingBatchId = null;
|
|
1006
|
+
this.inFlight -= droppedCount;
|
|
1007
|
+
this.dropped += droppedCount;
|
|
1008
|
+
this.persistAll();
|
|
1009
|
+
this.cfg.onDrop?.(droppedCount);
|
|
1010
|
+
this.cfg.onPermanentFailure?.({
|
|
1011
|
+
status: err.status ?? 0,
|
|
1012
|
+
droppedCount,
|
|
1013
|
+
lastError: message
|
|
1014
|
+
});
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
624
1017
|
const retryAfterMs = extractRetryAfterMs(err);
|
|
625
1018
|
const delay = this.retry.nextDelay(retryAfterMs);
|
|
626
1019
|
this.scheduleRetry(delay);
|
|
@@ -638,6 +1031,8 @@ var EventQueue = class {
|
|
|
638
1031
|
this.cancelTimerIfSet();
|
|
639
1032
|
this.nextRetryAt = null;
|
|
640
1033
|
this.buffer = [];
|
|
1034
|
+
this.pendingBatch = null;
|
|
1035
|
+
this.pendingBatchId = null;
|
|
641
1036
|
this.dropped = 0;
|
|
642
1037
|
this.inFlight = 0;
|
|
643
1038
|
this.lastError = null;
|
|
@@ -647,6 +1042,10 @@ var EventQueue = class {
|
|
|
647
1042
|
}
|
|
648
1043
|
getStats() {
|
|
649
1044
|
return {
|
|
1045
|
+
// `buffered` counts events waiting for their FIRST flush. The
|
|
1046
|
+
// in-flight pendingBatch (retrying) is tracked separately via
|
|
1047
|
+
// `inFlight` — surfacing both lets diagnostics show "we have
|
|
1048
|
+
// events stuck retrying" distinct from "new events arriving".
|
|
650
1049
|
buffered: this.buffer.length,
|
|
651
1050
|
dropped: this.dropped,
|
|
652
1051
|
inFlight: this.inFlight,
|
|
@@ -656,6 +1055,29 @@ var EventQueue = class {
|
|
|
656
1055
|
nextRetryAt: this.nextRetryAt
|
|
657
1056
|
};
|
|
658
1057
|
}
|
|
1058
|
+
/**
|
|
1059
|
+
* The Idempotency-Key of the in-flight pending batch (if any).
|
|
1060
|
+
* Exposed for testing the Stripe-style retry-reuse contract.
|
|
1061
|
+
* Production callers don't need this.
|
|
1062
|
+
*/
|
|
1063
|
+
get pendingIdempotencyKey() {
|
|
1064
|
+
return this.pendingBatchId;
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Persist `[...pendingBatch, ...buffer]` — the full set of
|
|
1068
|
+
* not-yet-confirmed events. The next boot rehydrates this exact set
|
|
1069
|
+
* into `buffer` and replays. The server dedups via eventId
|
|
1070
|
+
* (ReplacingMergeTree on the warehouse side), so re-sending an event
|
|
1071
|
+
* that may have already landed is safe.
|
|
1072
|
+
*/
|
|
1073
|
+
persistAll() {
|
|
1074
|
+
if (!this.persistent) return;
|
|
1075
|
+
if (this.pendingBatch === null) {
|
|
1076
|
+
this.persistent.save(this.buffer);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
this.persistent.save([...this.pendingBatch, ...this.buffer]);
|
|
1080
|
+
}
|
|
659
1081
|
// ---------- internal scheduling ----------
|
|
660
1082
|
scheduleIdleFlush() {
|
|
661
1083
|
this.cancelTimerIfSet();
|
|
@@ -689,6 +1111,14 @@ function extractRetryAfterMs(err) {
|
|
|
689
1111
|
}
|
|
690
1112
|
return void 0;
|
|
691
1113
|
}
|
|
1114
|
+
function isPermanent4xx(err) {
|
|
1115
|
+
if (!err || typeof err !== "object") return false;
|
|
1116
|
+
const status = err.status;
|
|
1117
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
1118
|
+
if (status < 400 || status >= 500) return false;
|
|
1119
|
+
if (status === 408 || status === 429) return false;
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
692
1122
|
function defaultScheduler(fn, ms) {
|
|
693
1123
|
const id = setTimeout(fn, ms);
|
|
694
1124
|
if (typeof id.unref === "function") {
|
|
@@ -1030,6 +1460,7 @@ var AutoTracker = class {
|
|
|
1030
1460
|
/** Exposed for tests + consumers that want to reset the session manually. */
|
|
1031
1461
|
resetSession() {
|
|
1032
1462
|
if (this.session && !this.session.endedSent) this.emitSessionEnd();
|
|
1463
|
+
this.pageviewId = null;
|
|
1033
1464
|
this.session = this.startNewSession();
|
|
1034
1465
|
this.emitSessionStart();
|
|
1035
1466
|
}
|
|
@@ -1066,6 +1497,7 @@ var AutoTracker = class {
|
|
|
1066
1497
|
const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
|
|
1067
1498
|
if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
|
|
1068
1499
|
this.emitSessionEnd();
|
|
1500
|
+
this.pageviewId = null;
|
|
1069
1501
|
this.session = this.startNewSession();
|
|
1070
1502
|
this.emitSessionStart();
|
|
1071
1503
|
} else {
|
|
@@ -1439,7 +1871,7 @@ function validateEventProperties(input, options = {}) {
|
|
|
1439
1871
|
const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
|
|
1440
1872
|
const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
|
|
1441
1873
|
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1442
|
-
const seen = /* @__PURE__ */ new
|
|
1874
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1443
1875
|
const visit = (value, key, depth) => {
|
|
1444
1876
|
if (depth > maxDepth) {
|
|
1445
1877
|
warnings.push({ kind: "depth_exceeded", key });
|
|
@@ -1527,6 +1959,7 @@ function validateEventProperties(input, options = {}) {
|
|
|
1527
1959
|
const result = visit(value[i], `${key}[${i}]`, depth + 1);
|
|
1528
1960
|
if (result.keep) out.push(result.value);
|
|
1529
1961
|
}
|
|
1962
|
+
seen.delete(value);
|
|
1530
1963
|
return { keep: true, value: out };
|
|
1531
1964
|
}
|
|
1532
1965
|
if (t === "object") {
|
|
@@ -1541,6 +1974,7 @@ function validateEventProperties(input, options = {}) {
|
|
|
1541
1974
|
const result = visit(obj[k], `${key}.${k}`, depth + 1);
|
|
1542
1975
|
if (result.keep) out[k] = result.value;
|
|
1543
1976
|
}
|
|
1977
|
+
seen.delete(obj);
|
|
1544
1978
|
return { keep: true, value: out };
|
|
1545
1979
|
}
|
|
1546
1980
|
warnings.push({ kind: "non_serialisable", key });
|
|
@@ -1883,35 +2317,27 @@ var ConsentManager = class {
|
|
|
1883
2317
|
};
|
|
1884
2318
|
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
1885
2319
|
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
1886
|
-
var REPLACEMENT_EMAIL = "
|
|
1887
|
-
var REPLACEMENT_CARD = "
|
|
2320
|
+
var REPLACEMENT_EMAIL = "<email>";
|
|
2321
|
+
var REPLACEMENT_CARD = "<card>";
|
|
1888
2322
|
function scrubPii(value) {
|
|
1889
2323
|
if (!value) return value;
|
|
1890
|
-
|
|
1891
|
-
if (EMAIL_PATTERN.test(out)) {
|
|
1892
|
-
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
1893
|
-
}
|
|
1894
|
-
EMAIL_PATTERN.lastIndex = 0;
|
|
1895
|
-
if (CARD_PATTERN.test(out)) {
|
|
1896
|
-
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1897
|
-
}
|
|
1898
|
-
CARD_PATTERN.lastIndex = 0;
|
|
1899
|
-
return out;
|
|
2324
|
+
return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1900
2325
|
}
|
|
1901
2326
|
function scrubPiiFromProperties(properties) {
|
|
1902
2327
|
const out = {};
|
|
1903
2328
|
for (const k of Object.keys(properties)) {
|
|
1904
|
-
|
|
1905
|
-
if (typeof v === "string") {
|
|
1906
|
-
out[k] = scrubPii(v);
|
|
1907
|
-
} else if (Array.isArray(v)) {
|
|
1908
|
-
out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
|
|
1909
|
-
} else {
|
|
1910
|
-
out[k] = v;
|
|
1911
|
-
}
|
|
2329
|
+
out[k] = scrubValue(properties[k]);
|
|
1912
2330
|
}
|
|
1913
2331
|
return out;
|
|
1914
2332
|
}
|
|
2333
|
+
function scrubValue(v) {
|
|
2334
|
+
if (typeof v === "string") return scrubPii(v);
|
|
2335
|
+
if (Array.isArray(v)) return v.map(scrubValue);
|
|
2336
|
+
if (v && typeof v === "object" && v.constructor === Object) {
|
|
2337
|
+
return scrubPiiFromProperties(v);
|
|
2338
|
+
}
|
|
2339
|
+
return v;
|
|
2340
|
+
}
|
|
1915
2341
|
|
|
1916
2342
|
// src/breadcrumbs.ts
|
|
1917
2343
|
var BreadcrumbBuffer = class {
|
|
@@ -2201,16 +2627,18 @@ var ErrorTracker = class {
|
|
|
2201
2627
|
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
2202
2628
|
const method = (init.method || "GET").toUpperCase();
|
|
2203
2629
|
const start = Date.now();
|
|
2204
|
-
this.opts.
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2630
|
+
if (!isSelfRequest(url, this.opts.selfHostname)) {
|
|
2631
|
+
this.opts.breadcrumbs.add({
|
|
2632
|
+
timestamp: start,
|
|
2633
|
+
category: "http",
|
|
2634
|
+
message: `${method} ${url}`,
|
|
2635
|
+
data: { url, method }
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2210
2638
|
try {
|
|
2211
2639
|
const response = await origFetch(...args);
|
|
2212
2640
|
if (response.status >= 500 && this.opts.isConsented()) {
|
|
2213
|
-
if (!url.
|
|
2641
|
+
if (!isSelfRequest(url, this.opts.selfHostname)) {
|
|
2214
2642
|
this.captureHttp({
|
|
2215
2643
|
url,
|
|
2216
2644
|
method,
|
|
@@ -2259,7 +2687,7 @@ var ErrorTracker = class {
|
|
|
2259
2687
|
try {
|
|
2260
2688
|
if (xhr.status >= 500 && tracker.opts.isConsented()) {
|
|
2261
2689
|
const url = xhr._cdUrl ?? "";
|
|
2262
|
-
if (!url.
|
|
2690
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
2263
2691
|
tracker.captureHttp({
|
|
2264
2692
|
url,
|
|
2265
2693
|
method: (xhr._cdMethod ?? "GET").toUpperCase(),
|
|
@@ -2419,9 +2847,10 @@ var ErrorTracker = class {
|
|
|
2419
2847
|
if (!this.passesSample(err)) return;
|
|
2420
2848
|
if (!this.passesRateLimit(err)) return;
|
|
2421
2849
|
let finalErr = err;
|
|
2422
|
-
|
|
2850
|
+
const hook = this.opts.beforeSend?.();
|
|
2851
|
+
if (hook) {
|
|
2423
2852
|
try {
|
|
2424
|
-
finalErr =
|
|
2853
|
+
finalErr = hook(err);
|
|
2425
2854
|
} catch {
|
|
2426
2855
|
finalErr = err;
|
|
2427
2856
|
}
|
|
@@ -2603,6 +3032,22 @@ function safeClone(v) {
|
|
|
2603
3032
|
function safeStringify2(v) {
|
|
2604
3033
|
return coerceErrorPayload(v).message;
|
|
2605
3034
|
}
|
|
3035
|
+
function extractSelfHostname(baseUrl) {
|
|
3036
|
+
if (!baseUrl || typeof baseUrl !== "string") return null;
|
|
3037
|
+
try {
|
|
3038
|
+
return new URL(baseUrl).hostname.toLowerCase();
|
|
3039
|
+
} catch {
|
|
3040
|
+
return null;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
function isSelfRequest(requestUrl, selfHostname) {
|
|
3044
|
+
if (!selfHostname || !requestUrl) return false;
|
|
3045
|
+
try {
|
|
3046
|
+
return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
|
|
3047
|
+
} catch {
|
|
3048
|
+
return false;
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
2606
3051
|
|
|
2607
3052
|
// src/crossdeck.ts
|
|
2608
3053
|
var CrossdeckClient = class {
|
|
@@ -2619,6 +3064,28 @@ var CrossdeckClient = class {
|
|
|
2619
3064
|
* mismatched env fails fast at boot rather than at first event-flush.
|
|
2620
3065
|
*/
|
|
2621
3066
|
init(options) {
|
|
3067
|
+
if (this.state) {
|
|
3068
|
+
try {
|
|
3069
|
+
this.state.uninstallUnloadFlush?.();
|
|
3070
|
+
} catch {
|
|
3071
|
+
}
|
|
3072
|
+
try {
|
|
3073
|
+
this.state.autoTracker?.uninstall();
|
|
3074
|
+
} catch {
|
|
3075
|
+
}
|
|
3076
|
+
try {
|
|
3077
|
+
this.state.webVitals?.uninstall();
|
|
3078
|
+
} catch {
|
|
3079
|
+
}
|
|
3080
|
+
try {
|
|
3081
|
+
this.state.errors?.uninstall();
|
|
3082
|
+
} catch {
|
|
3083
|
+
}
|
|
3084
|
+
try {
|
|
3085
|
+
void this.state.events.flush({ keepalive: true });
|
|
3086
|
+
} catch {
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
2622
3089
|
if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
|
|
2623
3090
|
throw new CrossdeckError({
|
|
2624
3091
|
type: "configuration_error",
|
|
@@ -2665,7 +3132,12 @@ var CrossdeckClient = class {
|
|
|
2665
3132
|
// load still flushes if the user leaves quickly (the keepalive
|
|
2666
3133
|
// pagehide handler picks up anything that doesn't); long enough
|
|
2667
3134
|
// that bursts of clicks coalesce into one network round-trip.
|
|
2668
|
-
|
|
3135
|
+
// v1.4.0 Phase 3.3 — flush interval default parity. Pre-
|
|
3136
|
+
// v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
|
|
3137
|
+
// converged on 2000ms (the Stripe-adjacent industry norm)
|
|
3138
|
+
// so cross-platform funnels show events landing at the
|
|
3139
|
+
// same cadence on every SDK. Per-instance override stays.
|
|
3140
|
+
eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
|
|
2669
3141
|
sdkVersion: options.sdkVersion ?? SDK_VERSION,
|
|
2670
3142
|
autoTrack,
|
|
2671
3143
|
appVersion: options.appVersion ?? null
|
|
@@ -2726,6 +3198,15 @@ var CrossdeckClient = class {
|
|
|
2726
3198
|
`Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
|
|
2727
3199
|
{ ...info }
|
|
2728
3200
|
);
|
|
3201
|
+
},
|
|
3202
|
+
onPermanentFailure: (info) => {
|
|
3203
|
+
const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
|
|
3204
|
+
console.error(headline);
|
|
3205
|
+
debug.emit(
|
|
3206
|
+
"sdk.flush_permanent_failure",
|
|
3207
|
+
headline,
|
|
3208
|
+
{ ...info }
|
|
3209
|
+
);
|
|
2729
3210
|
}
|
|
2730
3211
|
});
|
|
2731
3212
|
const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
|
|
@@ -2792,8 +3273,20 @@ var CrossdeckClient = class {
|
|
|
2792
3273
|
report: (err) => this.reportError(err),
|
|
2793
3274
|
getContext: () => ({ ...this.state.errorContext }),
|
|
2794
3275
|
getTags: () => ({ ...this.state.errorTags }),
|
|
2795
|
-
|
|
2796
|
-
|
|
3276
|
+
// GETTER, not a captured value — `setErrorBeforeSend()` mutates
|
|
3277
|
+
// `state.errorBeforeSend` after init() and the tracker MUST
|
|
3278
|
+
// pick up the new hook on the next error. The pre-fix shape
|
|
3279
|
+
// (`beforeSend: this.state!.errorBeforeSend`) snapshotted
|
|
3280
|
+
// `null` at construction and made the customer's PII scrubber
|
|
3281
|
+
// silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
|
|
3282
|
+
beforeSend: () => this.state.errorBeforeSend,
|
|
3283
|
+
isConsented: () => this.state.consent.errors,
|
|
3284
|
+
// Derived from the configured baseUrl at init() time. Used by
|
|
3285
|
+
// the fetch / XHR wrappers to skip captureHttp on Crossdeck's
|
|
3286
|
+
// own requests — pre-fix the skip was hardcoded to
|
|
3287
|
+
// `api.cross-deck.com` and broke for customers on staging /
|
|
3288
|
+
// regional / self-hosted base URLs (recursive capture loop).
|
|
3289
|
+
selfHostname: extractSelfHostname(opts.baseUrl)
|
|
2797
3290
|
});
|
|
2798
3291
|
this.state.errors = tracker;
|
|
2799
3292
|
tracker.install();
|
|
@@ -2872,13 +3365,10 @@ var CrossdeckClient = class {
|
|
|
2872
3365
|
};
|
|
2873
3366
|
if (options?.email) body.email = options.email;
|
|
2874
3367
|
if (traits) body.traits = traits;
|
|
3368
|
+
s.entitlements.setUserKey(userId);
|
|
2875
3369
|
const result = await s.http.request("POST", "/identity/alias", {
|
|
2876
3370
|
body
|
|
2877
3371
|
});
|
|
2878
|
-
const priorCdcust = s.identity.crossdeckCustomerId;
|
|
2879
|
-
if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
|
|
2880
|
-
s.entitlements.clear();
|
|
2881
|
-
}
|
|
2882
3372
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
2883
3373
|
s.developerUserId = userId;
|
|
2884
3374
|
return result;
|
|
@@ -3318,11 +3808,25 @@ var CrossdeckClient = class {
|
|
|
3318
3808
|
message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
|
|
3319
3809
|
});
|
|
3320
3810
|
}
|
|
3811
|
+
const rail = input.rail ?? "apple";
|
|
3812
|
+
const body = { ...input, rail };
|
|
3813
|
+
const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
|
|
3321
3814
|
const result = await s.http.request("POST", "/purchases/sync", {
|
|
3322
|
-
body
|
|
3815
|
+
body,
|
|
3816
|
+
idempotencyKey
|
|
3323
3817
|
});
|
|
3324
3818
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
3325
3819
|
s.entitlements.setFromList(result.entitlements);
|
|
3820
|
+
try {
|
|
3821
|
+
const sourceProductId = result.entitlements[0]?.source.productId;
|
|
3822
|
+
const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
|
|
3823
|
+
const props = { rail };
|
|
3824
|
+
if (sourceProductId) props.productId = sourceProductId;
|
|
3825
|
+
if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
|
|
3826
|
+
if (result.idempotent_replay) props.idempotent_replay = true;
|
|
3827
|
+
this.track("purchase.completed", props);
|
|
3828
|
+
} catch {
|
|
3829
|
+
}
|
|
3326
3830
|
s.debug.emit(
|
|
3327
3831
|
"sdk.purchase_evidence_sent",
|
|
3328
3832
|
"StoreKit transaction forwarded. Waiting for backend verification.",
|
|
@@ -3378,13 +3882,15 @@ var CrossdeckClient = class {
|
|
|
3378
3882
|
}
|
|
3379
3883
|
this.state.autoTracker?.uninstall();
|
|
3380
3884
|
this.state.identity.reset();
|
|
3381
|
-
this.state.entitlements.
|
|
3885
|
+
this.state.entitlements.clearAll();
|
|
3382
3886
|
this.state.events.reset();
|
|
3383
3887
|
this.state.superProps.clear();
|
|
3384
3888
|
this.state.breadcrumbs.clear();
|
|
3385
3889
|
this.state.errorContext = {};
|
|
3386
3890
|
this.state.errorTags = {};
|
|
3387
3891
|
this.state.developerUserId = null;
|
|
3892
|
+
this.state.lastServerTime = null;
|
|
3893
|
+
this.state.lastClientTime = null;
|
|
3388
3894
|
if (this.state.autoTracker) {
|
|
3389
3895
|
const tracker = new AutoTracker(
|
|
3390
3896
|
this.state.options.autoTrack,
|
|
@@ -3513,7 +4019,9 @@ function isLocalHostname() {
|
|
|
3513
4019
|
const hostname = w?.location?.hostname;
|
|
3514
4020
|
if (!hostname) return false;
|
|
3515
4021
|
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
4022
|
+
if (hostname === "0.0.0.0") return true;
|
|
3516
4023
|
if (hostname === "::1" || hostname === "[::1]") return true;
|
|
4024
|
+
if (/^\[?fe80::/i.test(hostname)) return true;
|
|
3517
4025
|
if (hostname.endsWith(".local")) return true;
|
|
3518
4026
|
if (/^10\./.test(hostname)) return true;
|
|
3519
4027
|
if (/^192\.168\./.test(hostname)) return true;
|
|
@@ -3649,6 +4157,116 @@ var CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
3649
4157
|
description: "The server returned a 2xx with an unparseable body.",
|
|
3650
4158
|
resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
|
|
3651
4159
|
retryable: true
|
|
4160
|
+
},
|
|
4161
|
+
// ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
|
|
4162
|
+
// Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
|
|
4163
|
+
// hitting any of these on the wire can look them up via
|
|
4164
|
+
// getErrorCode(code) for a canonical remediation step instead of
|
|
4165
|
+
// hunting through Slack history.
|
|
4166
|
+
{
|
|
4167
|
+
code: "missing_api_key",
|
|
4168
|
+
type: "authentication_error",
|
|
4169
|
+
description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
|
|
4170
|
+
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.",
|
|
4171
|
+
retryable: false
|
|
4172
|
+
},
|
|
4173
|
+
{
|
|
4174
|
+
code: "invalid_api_key",
|
|
4175
|
+
type: "authentication_error",
|
|
4176
|
+
description: "The API key is malformed, unknown, or doesn't resolve to a project.",
|
|
4177
|
+
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.",
|
|
4178
|
+
retryable: false
|
|
4179
|
+
},
|
|
4180
|
+
{
|
|
4181
|
+
code: "key_revoked",
|
|
4182
|
+
type: "authentication_error",
|
|
4183
|
+
description: "The API key was revoked in the Crossdeck dashboard.",
|
|
4184
|
+
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.",
|
|
4185
|
+
retryable: false
|
|
4186
|
+
},
|
|
4187
|
+
{
|
|
4188
|
+
code: "identity_token_invalid",
|
|
4189
|
+
type: "authentication_error",
|
|
4190
|
+
description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
|
|
4191
|
+
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.",
|
|
4192
|
+
retryable: true
|
|
4193
|
+
},
|
|
4194
|
+
{
|
|
4195
|
+
code: "origin_not_allowed",
|
|
4196
|
+
type: "permission_error",
|
|
4197
|
+
description: "The Origin header isn't in the project's Allowed origins list.",
|
|
4198
|
+
resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
|
|
4199
|
+
retryable: false
|
|
4200
|
+
},
|
|
4201
|
+
{
|
|
4202
|
+
code: "bundle_id_not_allowed",
|
|
4203
|
+
type: "permission_error",
|
|
4204
|
+
description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
|
|
4205
|
+
resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
|
|
4206
|
+
retryable: false
|
|
4207
|
+
},
|
|
4208
|
+
{
|
|
4209
|
+
code: "package_name_not_allowed",
|
|
4210
|
+
type: "permission_error",
|
|
4211
|
+
description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
|
|
4212
|
+
resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
|
|
4213
|
+
retryable: false
|
|
4214
|
+
},
|
|
4215
|
+
{
|
|
4216
|
+
code: "env_mismatch",
|
|
4217
|
+
type: "permission_error",
|
|
4218
|
+
description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
|
|
4219
|
+
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.",
|
|
4220
|
+
retryable: false
|
|
4221
|
+
},
|
|
4222
|
+
{
|
|
4223
|
+
code: "idempotency_key_in_use",
|
|
4224
|
+
type: "invalid_request_error",
|
|
4225
|
+
description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
|
|
4226
|
+
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.",
|
|
4227
|
+
retryable: false
|
|
4228
|
+
},
|
|
4229
|
+
{
|
|
4230
|
+
code: "rate_limited",
|
|
4231
|
+
type: "rate_limit_error",
|
|
4232
|
+
description: "Request rate exceeded the project's per-second cap.",
|
|
4233
|
+
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.",
|
|
4234
|
+
retryable: true
|
|
4235
|
+
},
|
|
4236
|
+
{
|
|
4237
|
+
code: "internal_error",
|
|
4238
|
+
type: "internal_error",
|
|
4239
|
+
description: "Server-side issue. Safe to retry with backoff.",
|
|
4240
|
+
resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
|
|
4241
|
+
retryable: true
|
|
4242
|
+
},
|
|
4243
|
+
{
|
|
4244
|
+
code: "google_not_supported",
|
|
4245
|
+
type: "invalid_request_error",
|
|
4246
|
+
description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
|
|
4247
|
+
resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
|
|
4248
|
+
retryable: false
|
|
4249
|
+
},
|
|
4250
|
+
{
|
|
4251
|
+
code: "stripe_not_supported",
|
|
4252
|
+
type: "invalid_request_error",
|
|
4253
|
+
description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
|
|
4254
|
+
resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
|
|
4255
|
+
retryable: false
|
|
4256
|
+
},
|
|
4257
|
+
{
|
|
4258
|
+
code: "missing_required_param",
|
|
4259
|
+
type: "invalid_request_error",
|
|
4260
|
+
description: "A required field is absent from the request body.",
|
|
4261
|
+
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.",
|
|
4262
|
+
retryable: false
|
|
4263
|
+
},
|
|
4264
|
+
{
|
|
4265
|
+
code: "invalid_param_value",
|
|
4266
|
+
type: "invalid_request_error",
|
|
4267
|
+
description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
|
|
4268
|
+
resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
|
|
4269
|
+
retryable: false
|
|
3652
4270
|
}
|
|
3653
4271
|
]);
|
|
3654
4272
|
function getErrorCode(code) {
|