@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.cjs
CHANGED
|
@@ -97,9 +97,11 @@ function typeMapForStatus(status) {
|
|
|
97
97
|
return "internal_error";
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// src/
|
|
100
|
+
// src/_version.ts
|
|
101
|
+
var SDK_VERSION = "1.4.2";
|
|
101
102
|
var SDK_NAME = "@cross-deck/web";
|
|
102
|
-
|
|
103
|
+
|
|
104
|
+
// src/http.ts
|
|
103
105
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
104
106
|
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
105
107
|
var HttpClient = class {
|
|
@@ -346,29 +348,258 @@ function randomChars(count) {
|
|
|
346
348
|
return out.join("");
|
|
347
349
|
}
|
|
348
350
|
|
|
351
|
+
// src/hash.ts
|
|
352
|
+
var K = new Uint32Array([
|
|
353
|
+
1116352408,
|
|
354
|
+
1899447441,
|
|
355
|
+
3049323471,
|
|
356
|
+
3921009573,
|
|
357
|
+
961987163,
|
|
358
|
+
1508970993,
|
|
359
|
+
2453635748,
|
|
360
|
+
2870763221,
|
|
361
|
+
3624381080,
|
|
362
|
+
310598401,
|
|
363
|
+
607225278,
|
|
364
|
+
1426881987,
|
|
365
|
+
1925078388,
|
|
366
|
+
2162078206,
|
|
367
|
+
2614888103,
|
|
368
|
+
3248222580,
|
|
369
|
+
3835390401,
|
|
370
|
+
4022224774,
|
|
371
|
+
264347078,
|
|
372
|
+
604807628,
|
|
373
|
+
770255983,
|
|
374
|
+
1249150122,
|
|
375
|
+
1555081692,
|
|
376
|
+
1996064986,
|
|
377
|
+
2554220882,
|
|
378
|
+
2821834349,
|
|
379
|
+
2952996808,
|
|
380
|
+
3210313671,
|
|
381
|
+
3336571891,
|
|
382
|
+
3584528711,
|
|
383
|
+
113926993,
|
|
384
|
+
338241895,
|
|
385
|
+
666307205,
|
|
386
|
+
773529912,
|
|
387
|
+
1294757372,
|
|
388
|
+
1396182291,
|
|
389
|
+
1695183700,
|
|
390
|
+
1986661051,
|
|
391
|
+
2177026350,
|
|
392
|
+
2456956037,
|
|
393
|
+
2730485921,
|
|
394
|
+
2820302411,
|
|
395
|
+
3259730800,
|
|
396
|
+
3345764771,
|
|
397
|
+
3516065817,
|
|
398
|
+
3600352804,
|
|
399
|
+
4094571909,
|
|
400
|
+
275423344,
|
|
401
|
+
430227734,
|
|
402
|
+
506948616,
|
|
403
|
+
659060556,
|
|
404
|
+
883997877,
|
|
405
|
+
958139571,
|
|
406
|
+
1322822218,
|
|
407
|
+
1537002063,
|
|
408
|
+
1747873779,
|
|
409
|
+
1955562222,
|
|
410
|
+
2024104815,
|
|
411
|
+
2227730452,
|
|
412
|
+
2361852424,
|
|
413
|
+
2428436474,
|
|
414
|
+
2756734187,
|
|
415
|
+
3204031479,
|
|
416
|
+
3329325298
|
|
417
|
+
]);
|
|
418
|
+
function utf8Bytes(input) {
|
|
419
|
+
if (typeof TextEncoder !== "undefined") {
|
|
420
|
+
return new TextEncoder().encode(input);
|
|
421
|
+
}
|
|
422
|
+
const out = [];
|
|
423
|
+
for (let i = 0; i < input.length; i++) {
|
|
424
|
+
let codePoint = input.charCodeAt(i);
|
|
425
|
+
if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
|
|
426
|
+
const next = input.charCodeAt(i + 1);
|
|
427
|
+
if (next >= 56320 && next <= 57343) {
|
|
428
|
+
codePoint = 65536 + (codePoint - 55296 << 10) + (next - 56320);
|
|
429
|
+
i++;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (codePoint < 128) {
|
|
433
|
+
out.push(codePoint);
|
|
434
|
+
} else if (codePoint < 2048) {
|
|
435
|
+
out.push(192 | codePoint >> 6);
|
|
436
|
+
out.push(128 | codePoint & 63);
|
|
437
|
+
} else if (codePoint < 65536) {
|
|
438
|
+
out.push(224 | codePoint >> 12);
|
|
439
|
+
out.push(128 | codePoint >> 6 & 63);
|
|
440
|
+
out.push(128 | codePoint & 63);
|
|
441
|
+
} else {
|
|
442
|
+
out.push(240 | codePoint >> 18);
|
|
443
|
+
out.push(128 | codePoint >> 12 & 63);
|
|
444
|
+
out.push(128 | codePoint >> 6 & 63);
|
|
445
|
+
out.push(128 | codePoint & 63);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return new Uint8Array(out);
|
|
449
|
+
}
|
|
450
|
+
function sha256Hex(input) {
|
|
451
|
+
const bytes = utf8Bytes(input);
|
|
452
|
+
const bitLength = bytes.length * 8;
|
|
453
|
+
const blockCount = Math.floor((bytes.length + 9 + 63) / 64);
|
|
454
|
+
const padded = new Uint8Array(blockCount * 64);
|
|
455
|
+
padded.set(bytes);
|
|
456
|
+
padded[bytes.length] = 128;
|
|
457
|
+
const high = Math.floor(bitLength / 4294967296);
|
|
458
|
+
const low = bitLength >>> 0;
|
|
459
|
+
const lenOffset = padded.length - 8;
|
|
460
|
+
padded[lenOffset + 0] = high >>> 24 & 255;
|
|
461
|
+
padded[lenOffset + 1] = high >>> 16 & 255;
|
|
462
|
+
padded[lenOffset + 2] = high >>> 8 & 255;
|
|
463
|
+
padded[lenOffset + 3] = high & 255;
|
|
464
|
+
padded[lenOffset + 4] = low >>> 24 & 255;
|
|
465
|
+
padded[lenOffset + 5] = low >>> 16 & 255;
|
|
466
|
+
padded[lenOffset + 6] = low >>> 8 & 255;
|
|
467
|
+
padded[lenOffset + 7] = low & 255;
|
|
468
|
+
const H = new Uint32Array([
|
|
469
|
+
1779033703,
|
|
470
|
+
3144134277,
|
|
471
|
+
1013904242,
|
|
472
|
+
2773480762,
|
|
473
|
+
1359893119,
|
|
474
|
+
2600822924,
|
|
475
|
+
528734635,
|
|
476
|
+
1541459225
|
|
477
|
+
]);
|
|
478
|
+
const W = new Uint32Array(64);
|
|
479
|
+
for (let block = 0; block < blockCount; block++) {
|
|
480
|
+
const offset = block * 64;
|
|
481
|
+
for (let t = 0; t < 16; t++) {
|
|
482
|
+
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;
|
|
483
|
+
}
|
|
484
|
+
for (let t = 16; t < 64; t++) {
|
|
485
|
+
const w15 = W[t - 15];
|
|
486
|
+
const w2 = W[t - 2];
|
|
487
|
+
const s0 = (w15 >>> 7 | w15 << 25) ^ (w15 >>> 18 | w15 << 14) ^ w15 >>> 3;
|
|
488
|
+
const s1 = (w2 >>> 17 | w2 << 15) ^ (w2 >>> 19 | w2 << 13) ^ w2 >>> 10;
|
|
489
|
+
W[t] = W[t - 16] + s0 + W[t - 7] + s1 >>> 0;
|
|
490
|
+
}
|
|
491
|
+
let a = H[0], b = H[1], c = H[2], d = H[3];
|
|
492
|
+
let e = H[4], f = H[5], g = H[6], h = H[7];
|
|
493
|
+
for (let t = 0; t < 64; t++) {
|
|
494
|
+
const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7);
|
|
495
|
+
const ch = e & f ^ ~e & g;
|
|
496
|
+
const temp1 = h + S1 + ch + K[t] + W[t] >>> 0;
|
|
497
|
+
const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10);
|
|
498
|
+
const maj = a & b ^ a & c ^ b & c;
|
|
499
|
+
const temp2 = S0 + maj >>> 0;
|
|
500
|
+
h = g;
|
|
501
|
+
g = f;
|
|
502
|
+
f = e;
|
|
503
|
+
e = d + temp1 >>> 0;
|
|
504
|
+
d = c;
|
|
505
|
+
c = b;
|
|
506
|
+
b = a;
|
|
507
|
+
a = temp1 + temp2 >>> 0;
|
|
508
|
+
}
|
|
509
|
+
H[0] = H[0] + a >>> 0;
|
|
510
|
+
H[1] = H[1] + b >>> 0;
|
|
511
|
+
H[2] = H[2] + c >>> 0;
|
|
512
|
+
H[3] = H[3] + d >>> 0;
|
|
513
|
+
H[4] = H[4] + e >>> 0;
|
|
514
|
+
H[5] = H[5] + f >>> 0;
|
|
515
|
+
H[6] = H[6] + g >>> 0;
|
|
516
|
+
H[7] = H[7] + h >>> 0;
|
|
517
|
+
}
|
|
518
|
+
let hex = "";
|
|
519
|
+
for (let i = 0; i < 8; i++) {
|
|
520
|
+
hex += H[i].toString(16).padStart(8, "0");
|
|
521
|
+
}
|
|
522
|
+
return hex;
|
|
523
|
+
}
|
|
524
|
+
|
|
349
525
|
// src/entitlement-cache.ts
|
|
350
526
|
var DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1e3;
|
|
351
|
-
var
|
|
527
|
+
var ANON_SUFFIX = "_anon";
|
|
528
|
+
var INDEX_SUFFIX = "_index";
|
|
529
|
+
var EntitlementCache = class _EntitlementCache {
|
|
352
530
|
/**
|
|
353
|
-
* @param storage
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* @param
|
|
358
|
-
*
|
|
359
|
-
*
|
|
531
|
+
* @param storage Device storage adapter. When omitted (tests) or
|
|
532
|
+
* a MemoryStorage (strict-consent / no-persistence
|
|
533
|
+
* mode) the cache is session-only — durability is
|
|
534
|
+
* simply absent, never wrong.
|
|
535
|
+
* @param storageKeyPrefix Prefix used to derive per-user storage keys
|
|
536
|
+
* (`<prefix>:<sha256(userId)>`). Default
|
|
537
|
+
* `crossdeck:entitlements`. The trailing user
|
|
538
|
+
* suffix is filled at identify() / reset()
|
|
539
|
+
* time — see [[setUserKey]].
|
|
540
|
+
* @param staleAfterMs Age past which last-known-good is flagged stale
|
|
541
|
+
* even without a failed refresh. Default 24h.
|
|
360
542
|
*/
|
|
361
|
-
constructor(storage,
|
|
543
|
+
constructor(storage, storageKeyPrefix = "crossdeck:entitlements", staleAfterMs = DEFAULT_STALE_AFTER_MS) {
|
|
362
544
|
this.all = [];
|
|
363
545
|
this.lastUpdated = 0;
|
|
364
546
|
this.lastRefreshFailedAt = 0;
|
|
365
547
|
this.listeners = /* @__PURE__ */ new Set();
|
|
366
548
|
this.listenerErrorCount = 0;
|
|
549
|
+
this.currentSuffix = ANON_SUFFIX;
|
|
367
550
|
this.storage = storage;
|
|
368
|
-
this.
|
|
551
|
+
this.storageKeyPrefix = storageKeyPrefix;
|
|
369
552
|
this.staleAfterMs = staleAfterMs;
|
|
370
553
|
this.hydrate();
|
|
371
554
|
}
|
|
555
|
+
/** The full storage key the current-user blob is persisted under. */
|
|
556
|
+
get storageKey() {
|
|
557
|
+
return `${this.storageKeyPrefix}:${this.currentSuffix}`;
|
|
558
|
+
}
|
|
559
|
+
/** Key of the index blob — a JSON array of every suffix we've
|
|
560
|
+
* written. Used by clearAll() to scope a logout-wipe. */
|
|
561
|
+
get indexKey() {
|
|
562
|
+
return `${this.storageKeyPrefix}:${INDEX_SUFFIX}`;
|
|
563
|
+
}
|
|
564
|
+
/** Derive a stable suffix for a developerUserId via SHA-256. The
|
|
565
|
+
* raw userId never lands in the storage key — protects against
|
|
566
|
+
* accidentally leaking email-style identifiers through DevTools
|
|
567
|
+
* inspection. Pass `null` to switch back to the anonymous slot. */
|
|
568
|
+
static suffixForUserId(userId) {
|
|
569
|
+
if (userId == null || userId === "") return ANON_SUFFIX;
|
|
570
|
+
return sha256Hex(userId);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Switch the cache to a different user's storage slot. Bank-grade
|
|
574
|
+
* three-layer isolation:
|
|
575
|
+
* (a) Physical key separation — `<prefix>:<sha256(userId)>` so
|
|
576
|
+
* a user-switch can't physically read prior user's data
|
|
577
|
+
* even if the in-memory clear was skipped.
|
|
578
|
+
* (b) Unconditional in-memory clear — invoked whenever the
|
|
579
|
+
* active suffix changes, even on same-id re-identify.
|
|
580
|
+
* (c) Re-hydrate from the new slot — a returning user observes
|
|
581
|
+
* their last-known-good cache from storage immediately.
|
|
582
|
+
*
|
|
583
|
+
* Caller (identify() / reset()) MUST invoke this BEFORE the next
|
|
584
|
+
* setFromList() so the write lands under the right key.
|
|
585
|
+
*/
|
|
586
|
+
setUserKey(userId) {
|
|
587
|
+
const nextSuffix = _EntitlementCache.suffixForUserId(userId);
|
|
588
|
+
if (nextSuffix === this.currentSuffix) {
|
|
589
|
+
this.all = [];
|
|
590
|
+
this.lastUpdated = 0;
|
|
591
|
+
this.lastRefreshFailedAt = 0;
|
|
592
|
+
this.notify();
|
|
593
|
+
this.hydrate();
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
this.currentSuffix = nextSuffix;
|
|
597
|
+
this.all = [];
|
|
598
|
+
this.lastUpdated = 0;
|
|
599
|
+
this.lastRefreshFailedAt = 0;
|
|
600
|
+
this.hydrate();
|
|
601
|
+
this.notify();
|
|
602
|
+
}
|
|
372
603
|
/**
|
|
373
604
|
* Sync read — true iff the entitlement is currently granting access.
|
|
374
605
|
*
|
|
@@ -438,12 +669,13 @@ var EntitlementCache = class {
|
|
|
438
669
|
this.lastUpdated = Date.now();
|
|
439
670
|
this.lastRefreshFailedAt = 0;
|
|
440
671
|
this.persist();
|
|
672
|
+
this.recordSuffixInIndex(this.currentSuffix);
|
|
441
673
|
this.notify();
|
|
442
674
|
}
|
|
443
675
|
/**
|
|
444
|
-
* Wipe
|
|
445
|
-
*
|
|
446
|
-
*
|
|
676
|
+
* Wipe the CURRENT user's slot. Used internally when a single
|
|
677
|
+
* user's cache needs to be invalidated without affecting other
|
|
678
|
+
* persisted slots. The full-logout path is [[clearAll]].
|
|
447
679
|
*/
|
|
448
680
|
clear() {
|
|
449
681
|
this.all = [];
|
|
@@ -455,6 +687,40 @@ var EntitlementCache = class {
|
|
|
455
687
|
} catch {
|
|
456
688
|
}
|
|
457
689
|
}
|
|
690
|
+
this.removeSuffixFromIndex(this.currentSuffix);
|
|
691
|
+
this.notify();
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Logout-grade wipe — bank-grade contract: removes EVERY per-user
|
|
695
|
+
* entitlement slot the SDK has ever written on this device, then
|
|
696
|
+
* clears the index. Used by `Crossdeck.reset()` so a logout on a
|
|
697
|
+
* shared device can never leave another user's entitlements
|
|
698
|
+
* readable (layer (c) of the v1.4.0 isolation fix).
|
|
699
|
+
*
|
|
700
|
+
* After clearAll(), the cache is back to anonymous + empty.
|
|
701
|
+
*/
|
|
702
|
+
clearAll() {
|
|
703
|
+
this.all = [];
|
|
704
|
+
this.lastUpdated = 0;
|
|
705
|
+
this.lastRefreshFailedAt = 0;
|
|
706
|
+
this.currentSuffix = ANON_SUFFIX;
|
|
707
|
+
if (this.storage) {
|
|
708
|
+
const suffixes = this.readIndex();
|
|
709
|
+
for (const suffix of suffixes) {
|
|
710
|
+
try {
|
|
711
|
+
this.storage.removeItem(`${this.storageKeyPrefix}:${suffix}`);
|
|
712
|
+
} catch {
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
this.storage.removeItem(`${this.storageKeyPrefix}:${ANON_SUFFIX}`);
|
|
717
|
+
} catch {
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
this.storage.removeItem(this.indexKey);
|
|
721
|
+
} catch {
|
|
722
|
+
}
|
|
723
|
+
}
|
|
458
724
|
this.notify();
|
|
459
725
|
}
|
|
460
726
|
/**
|
|
@@ -504,6 +770,47 @@ var EntitlementCache = class {
|
|
|
504
770
|
} catch {
|
|
505
771
|
}
|
|
506
772
|
}
|
|
773
|
+
/** Read the index of all per-user suffixes the SDK has written. */
|
|
774
|
+
readIndex() {
|
|
775
|
+
if (!this.storage) return [];
|
|
776
|
+
try {
|
|
777
|
+
const raw = this.storage.getItem(this.indexKey);
|
|
778
|
+
if (!raw) return [];
|
|
779
|
+
const parsed = JSON.parse(raw);
|
|
780
|
+
if (Array.isArray(parsed)) {
|
|
781
|
+
return parsed.filter((x) => typeof x === "string");
|
|
782
|
+
}
|
|
783
|
+
return [];
|
|
784
|
+
} catch {
|
|
785
|
+
return [];
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
/** Add a suffix to the persisted index. Idempotent. */
|
|
789
|
+
recordSuffixInIndex(suffix) {
|
|
790
|
+
if (!this.storage) return;
|
|
791
|
+
const existing = this.readIndex();
|
|
792
|
+
if (existing.includes(suffix)) return;
|
|
793
|
+
existing.push(suffix);
|
|
794
|
+
try {
|
|
795
|
+
this.storage.setItem(this.indexKey, JSON.stringify(existing));
|
|
796
|
+
} catch {
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/** Remove a suffix from the persisted index. No-op if absent. */
|
|
800
|
+
removeSuffixFromIndex(suffix) {
|
|
801
|
+
if (!this.storage) return;
|
|
802
|
+
const existing = this.readIndex();
|
|
803
|
+
const next = existing.filter((s) => s !== suffix);
|
|
804
|
+
if (next.length === existing.length) return;
|
|
805
|
+
try {
|
|
806
|
+
if (next.length === 0) {
|
|
807
|
+
this.storage.removeItem(this.indexKey);
|
|
808
|
+
} else {
|
|
809
|
+
this.storage.setItem(this.indexKey, JSON.stringify(next));
|
|
810
|
+
}
|
|
811
|
+
} catch {
|
|
812
|
+
}
|
|
813
|
+
}
|
|
507
814
|
notify() {
|
|
508
815
|
if (this.listeners.size === 0) return;
|
|
509
816
|
const snapshot = this.all.slice();
|
|
@@ -518,6 +825,34 @@ var EntitlementCache = class {
|
|
|
518
825
|
}
|
|
519
826
|
};
|
|
520
827
|
|
|
828
|
+
// src/idempotency-key.ts
|
|
829
|
+
function formatAsUuid(hex) {
|
|
830
|
+
return [
|
|
831
|
+
hex.slice(0, 8),
|
|
832
|
+
hex.slice(8, 12),
|
|
833
|
+
hex.slice(12, 16),
|
|
834
|
+
hex.slice(16, 20),
|
|
835
|
+
hex.slice(20, 32)
|
|
836
|
+
].join("-");
|
|
837
|
+
}
|
|
838
|
+
function deriveIdempotencyKeyForPurchase(body) {
|
|
839
|
+
let identifier;
|
|
840
|
+
if (body.rail === "apple") {
|
|
841
|
+
identifier = body.signedTransactionInfo ?? "";
|
|
842
|
+
} else if (body.rail === "google") {
|
|
843
|
+
identifier = body.purchaseToken ?? "";
|
|
844
|
+
} else {
|
|
845
|
+
identifier = "";
|
|
846
|
+
}
|
|
847
|
+
if (!identifier) {
|
|
848
|
+
throw new Error(
|
|
849
|
+
`deriveIdempotencyKeyForPurchase: no stable identifier in body (rail=${body.rail}). Apple needs signedTransactionInfo; Google needs purchaseToken.`
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
const namespaced = `crossdeck:purchases/sync:${body.rail}:${identifier}`;
|
|
853
|
+
return formatAsUuid(sha256Hex(namespaced));
|
|
854
|
+
}
|
|
855
|
+
|
|
521
856
|
// src/retry-policy.ts
|
|
522
857
|
var DEFAULT_BASE = 1e3;
|
|
523
858
|
var DEFAULT_MAX = 6e4;
|
|
@@ -530,8 +865,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
|
|
|
530
865
|
const safeAttempts = Math.min(attempts, 30);
|
|
531
866
|
const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
|
|
532
867
|
const jittered = ceiling * random();
|
|
533
|
-
if (retryAfterMs !== void 0
|
|
534
|
-
|
|
868
|
+
if (retryAfterMs !== void 0) {
|
|
869
|
+
const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
|
|
870
|
+
const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
|
|
871
|
+
if (honoured > jittered) return honoured;
|
|
535
872
|
}
|
|
536
873
|
return Math.max(0, Math.round(jittered));
|
|
537
874
|
}
|
|
@@ -566,6 +903,27 @@ var EventQueue = class {
|
|
|
566
903
|
constructor(cfg) {
|
|
567
904
|
this.cfg = cfg;
|
|
568
905
|
this.buffer = [];
|
|
906
|
+
/**
|
|
907
|
+
* In-flight events that have been spliced from `buffer` for the
|
|
908
|
+
* current batch but haven't yet been confirmed (success or permanent
|
|
909
|
+
* failure). On a retry-driven re-flush we re-use this slot alongside
|
|
910
|
+
* `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
|
|
911
|
+
* across retries of the SAME logical batch.
|
|
912
|
+
*
|
|
913
|
+
* Pre-fix the splice cleared the buffer AND we immediately
|
|
914
|
+
* `persistent.save(empty)` BEFORE awaiting the network call — a
|
|
915
|
+
* crash mid-flight wiped the persisted blob and the batch was lost
|
|
916
|
+
* with no replay on next boot. Now the persisted blob always carries
|
|
917
|
+
* `[...pendingBatch, ...buffer]` so the in-flight events survive
|
|
918
|
+
* until the server confirms them.
|
|
919
|
+
*
|
|
920
|
+
* Pre-fix every retry attempt also minted a NEW batchId via
|
|
921
|
+
* `splice + mintBatchId`, defeating the backend's
|
|
922
|
+
* `Idempotency-Key` dedup. Reuse via this slot brings web in
|
|
923
|
+
* lockstep with node (which already had the field).
|
|
924
|
+
*/
|
|
925
|
+
this.pendingBatch = null;
|
|
926
|
+
this.pendingBatchId = null;
|
|
569
927
|
this.dropped = 0;
|
|
570
928
|
this.inFlight = 0;
|
|
571
929
|
this.lastFlushAt = 0;
|
|
@@ -598,7 +956,7 @@ var EventQueue = class {
|
|
|
598
956
|
this.cfg.onDrop?.(overflow);
|
|
599
957
|
}
|
|
600
958
|
this.cfg.onBufferChange?.(this.buffer.length);
|
|
601
|
-
this.
|
|
959
|
+
this.persistAll();
|
|
602
960
|
if (this.buffer.length >= this.cfg.batchSize) {
|
|
603
961
|
void this.flush();
|
|
604
962
|
} else {
|
|
@@ -607,22 +965,44 @@ var EventQueue = class {
|
|
|
607
965
|
}
|
|
608
966
|
/**
|
|
609
967
|
* Flush the buffer to /v1/events. Resolves when the network call
|
|
610
|
-
* completes (success or failure). On
|
|
611
|
-
*
|
|
968
|
+
* completes (success or failure). On a retryable failure the batch
|
|
969
|
+
* stays in `pendingBatch` for the next scheduled retry — the SAME
|
|
970
|
+
* batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
|
|
971
|
+
*
|
|
972
|
+
* Three terminal states from one call:
|
|
973
|
+
* - 2xx success: pendingBatch cleared, persisted state collapses to
|
|
974
|
+
* just `buffer` (any new events that arrived during in-flight).
|
|
975
|
+
* - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
|
|
976
|
+
* counter increments, a `permanent_failure` signal fires. The
|
|
977
|
+
* server is telling us our request is malformed / our key is
|
|
978
|
+
* revoked / we lack permission — retrying with the same key
|
|
979
|
+
* forever just grows the queue while the customer thinks events
|
|
980
|
+
* are landing.
|
|
981
|
+
* - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
|
|
982
|
+
* schedules a retry; the next `flush()` re-uses both.
|
|
612
983
|
*
|
|
613
984
|
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
614
|
-
* browser keeps the request alive past page unload. Use
|
|
615
|
-
*
|
|
985
|
+
* browser keeps the request alive past page unload. Use for terminal
|
|
986
|
+
* flushes (pagehide / visibilitychange→hidden / beforeunload).
|
|
616
987
|
*/
|
|
617
988
|
async flush(options = {}) {
|
|
618
|
-
|
|
989
|
+
let batch;
|
|
990
|
+
let batchId;
|
|
991
|
+
if (this.pendingBatch !== null && this.pendingBatchId !== null) {
|
|
992
|
+
batch = this.pendingBatch;
|
|
993
|
+
batchId = this.pendingBatchId;
|
|
994
|
+
} else {
|
|
995
|
+
if (this.buffer.length === 0) return null;
|
|
996
|
+
batch = this.buffer.splice(0);
|
|
997
|
+
batchId = this.mintBatchId();
|
|
998
|
+
this.pendingBatch = batch;
|
|
999
|
+
this.pendingBatchId = batchId;
|
|
1000
|
+
this.inFlight += batch.length;
|
|
1001
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
1002
|
+
this.persistAll();
|
|
1003
|
+
}
|
|
619
1004
|
this.cancelTimerIfSet();
|
|
620
1005
|
this.nextRetryAt = null;
|
|
621
|
-
const batch = this.buffer.splice(0);
|
|
622
|
-
const batchId = this.mintBatchId();
|
|
623
|
-
this.inFlight += batch.length;
|
|
624
|
-
this.persistent?.save(this.buffer);
|
|
625
|
-
this.cfg.onBufferChange?.(this.buffer.length);
|
|
626
1006
|
try {
|
|
627
1007
|
const env = this.cfg.envelope();
|
|
628
1008
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
@@ -641,20 +1021,33 @@ var EventQueue = class {
|
|
|
641
1021
|
this.lastFlushAt = Date.now();
|
|
642
1022
|
this.lastError = null;
|
|
643
1023
|
this.inFlight -= batch.length;
|
|
1024
|
+
this.pendingBatch = null;
|
|
1025
|
+
this.pendingBatchId = null;
|
|
644
1026
|
this.retry.recordSuccess();
|
|
645
|
-
this.
|
|
1027
|
+
this.persistAll();
|
|
646
1028
|
if (!this.firstFlushFired) {
|
|
647
1029
|
this.firstFlushFired = true;
|
|
648
1030
|
this.cfg.onFirstFlushSuccess?.();
|
|
649
1031
|
}
|
|
650
1032
|
return result;
|
|
651
1033
|
} catch (err) {
|
|
652
|
-
this.buffer.unshift(...batch);
|
|
653
|
-
this.inFlight -= batch.length;
|
|
654
1034
|
const message = err instanceof Error ? err.message : String(err);
|
|
655
1035
|
this.lastError = message;
|
|
656
|
-
|
|
657
|
-
|
|
1036
|
+
if (isPermanent4xx(err)) {
|
|
1037
|
+
const droppedCount = batch.length;
|
|
1038
|
+
this.pendingBatch = null;
|
|
1039
|
+
this.pendingBatchId = null;
|
|
1040
|
+
this.inFlight -= droppedCount;
|
|
1041
|
+
this.dropped += droppedCount;
|
|
1042
|
+
this.persistAll();
|
|
1043
|
+
this.cfg.onDrop?.(droppedCount);
|
|
1044
|
+
this.cfg.onPermanentFailure?.({
|
|
1045
|
+
status: err.status ?? 0,
|
|
1046
|
+
droppedCount,
|
|
1047
|
+
lastError: message
|
|
1048
|
+
});
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
658
1051
|
const retryAfterMs = extractRetryAfterMs(err);
|
|
659
1052
|
const delay = this.retry.nextDelay(retryAfterMs);
|
|
660
1053
|
this.scheduleRetry(delay);
|
|
@@ -672,6 +1065,8 @@ var EventQueue = class {
|
|
|
672
1065
|
this.cancelTimerIfSet();
|
|
673
1066
|
this.nextRetryAt = null;
|
|
674
1067
|
this.buffer = [];
|
|
1068
|
+
this.pendingBatch = null;
|
|
1069
|
+
this.pendingBatchId = null;
|
|
675
1070
|
this.dropped = 0;
|
|
676
1071
|
this.inFlight = 0;
|
|
677
1072
|
this.lastError = null;
|
|
@@ -681,6 +1076,10 @@ var EventQueue = class {
|
|
|
681
1076
|
}
|
|
682
1077
|
getStats() {
|
|
683
1078
|
return {
|
|
1079
|
+
// `buffered` counts events waiting for their FIRST flush. The
|
|
1080
|
+
// in-flight pendingBatch (retrying) is tracked separately via
|
|
1081
|
+
// `inFlight` — surfacing both lets diagnostics show "we have
|
|
1082
|
+
// events stuck retrying" distinct from "new events arriving".
|
|
684
1083
|
buffered: this.buffer.length,
|
|
685
1084
|
dropped: this.dropped,
|
|
686
1085
|
inFlight: this.inFlight,
|
|
@@ -690,6 +1089,29 @@ var EventQueue = class {
|
|
|
690
1089
|
nextRetryAt: this.nextRetryAt
|
|
691
1090
|
};
|
|
692
1091
|
}
|
|
1092
|
+
/**
|
|
1093
|
+
* The Idempotency-Key of the in-flight pending batch (if any).
|
|
1094
|
+
* Exposed for testing the Stripe-style retry-reuse contract.
|
|
1095
|
+
* Production callers don't need this.
|
|
1096
|
+
*/
|
|
1097
|
+
get pendingIdempotencyKey() {
|
|
1098
|
+
return this.pendingBatchId;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Persist `[...pendingBatch, ...buffer]` — the full set of
|
|
1102
|
+
* not-yet-confirmed events. The next boot rehydrates this exact set
|
|
1103
|
+
* into `buffer` and replays. The server dedups via eventId
|
|
1104
|
+
* (ReplacingMergeTree on the warehouse side), so re-sending an event
|
|
1105
|
+
* that may have already landed is safe.
|
|
1106
|
+
*/
|
|
1107
|
+
persistAll() {
|
|
1108
|
+
if (!this.persistent) return;
|
|
1109
|
+
if (this.pendingBatch === null) {
|
|
1110
|
+
this.persistent.save(this.buffer);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
this.persistent.save([...this.pendingBatch, ...this.buffer]);
|
|
1114
|
+
}
|
|
693
1115
|
// ---------- internal scheduling ----------
|
|
694
1116
|
scheduleIdleFlush() {
|
|
695
1117
|
this.cancelTimerIfSet();
|
|
@@ -723,6 +1145,14 @@ function extractRetryAfterMs(err) {
|
|
|
723
1145
|
}
|
|
724
1146
|
return void 0;
|
|
725
1147
|
}
|
|
1148
|
+
function isPermanent4xx(err) {
|
|
1149
|
+
if (!err || typeof err !== "object") return false;
|
|
1150
|
+
const status = err.status;
|
|
1151
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
1152
|
+
if (status < 400 || status >= 500) return false;
|
|
1153
|
+
if (status === 408 || status === 429) return false;
|
|
1154
|
+
return true;
|
|
1155
|
+
}
|
|
726
1156
|
function defaultScheduler(fn, ms) {
|
|
727
1157
|
const id = setTimeout(fn, ms);
|
|
728
1158
|
if (typeof id.unref === "function") {
|
|
@@ -1064,6 +1494,7 @@ var AutoTracker = class {
|
|
|
1064
1494
|
/** Exposed for tests + consumers that want to reset the session manually. */
|
|
1065
1495
|
resetSession() {
|
|
1066
1496
|
if (this.session && !this.session.endedSent) this.emitSessionEnd();
|
|
1497
|
+
this.pageviewId = null;
|
|
1067
1498
|
this.session = this.startNewSession();
|
|
1068
1499
|
this.emitSessionStart();
|
|
1069
1500
|
}
|
|
@@ -1100,6 +1531,7 @@ var AutoTracker = class {
|
|
|
1100
1531
|
const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
|
|
1101
1532
|
if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
|
|
1102
1533
|
this.emitSessionEnd();
|
|
1534
|
+
this.pageviewId = null;
|
|
1103
1535
|
this.session = this.startNewSession();
|
|
1104
1536
|
this.emitSessionStart();
|
|
1105
1537
|
} else {
|
|
@@ -1473,7 +1905,7 @@ function validateEventProperties(input, options = {}) {
|
|
|
1473
1905
|
const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
|
|
1474
1906
|
const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
|
|
1475
1907
|
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1476
|
-
const seen = /* @__PURE__ */ new
|
|
1908
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1477
1909
|
const visit = (value, key, depth) => {
|
|
1478
1910
|
if (depth > maxDepth) {
|
|
1479
1911
|
warnings.push({ kind: "depth_exceeded", key });
|
|
@@ -1561,6 +1993,7 @@ function validateEventProperties(input, options = {}) {
|
|
|
1561
1993
|
const result = visit(value[i], `${key}[${i}]`, depth + 1);
|
|
1562
1994
|
if (result.keep) out.push(result.value);
|
|
1563
1995
|
}
|
|
1996
|
+
seen.delete(value);
|
|
1564
1997
|
return { keep: true, value: out };
|
|
1565
1998
|
}
|
|
1566
1999
|
if (t === "object") {
|
|
@@ -1575,6 +2008,7 @@ function validateEventProperties(input, options = {}) {
|
|
|
1575
2008
|
const result = visit(obj[k], `${key}.${k}`, depth + 1);
|
|
1576
2009
|
if (result.keep) out[k] = result.value;
|
|
1577
2010
|
}
|
|
2011
|
+
seen.delete(obj);
|
|
1578
2012
|
return { keep: true, value: out };
|
|
1579
2013
|
}
|
|
1580
2014
|
warnings.push({ kind: "non_serialisable", key });
|
|
@@ -1917,35 +2351,27 @@ var ConsentManager = class {
|
|
|
1917
2351
|
};
|
|
1918
2352
|
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
1919
2353
|
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
1920
|
-
var REPLACEMENT_EMAIL = "
|
|
1921
|
-
var REPLACEMENT_CARD = "
|
|
2354
|
+
var REPLACEMENT_EMAIL = "<email>";
|
|
2355
|
+
var REPLACEMENT_CARD = "<card>";
|
|
1922
2356
|
function scrubPii(value) {
|
|
1923
2357
|
if (!value) return value;
|
|
1924
|
-
|
|
1925
|
-
if (EMAIL_PATTERN.test(out)) {
|
|
1926
|
-
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
1927
|
-
}
|
|
1928
|
-
EMAIL_PATTERN.lastIndex = 0;
|
|
1929
|
-
if (CARD_PATTERN.test(out)) {
|
|
1930
|
-
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1931
|
-
}
|
|
1932
|
-
CARD_PATTERN.lastIndex = 0;
|
|
1933
|
-
return out;
|
|
2358
|
+
return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1934
2359
|
}
|
|
1935
2360
|
function scrubPiiFromProperties(properties) {
|
|
1936
2361
|
const out = {};
|
|
1937
2362
|
for (const k of Object.keys(properties)) {
|
|
1938
|
-
|
|
1939
|
-
if (typeof v === "string") {
|
|
1940
|
-
out[k] = scrubPii(v);
|
|
1941
|
-
} else if (Array.isArray(v)) {
|
|
1942
|
-
out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
|
|
1943
|
-
} else {
|
|
1944
|
-
out[k] = v;
|
|
1945
|
-
}
|
|
2363
|
+
out[k] = scrubValue(properties[k]);
|
|
1946
2364
|
}
|
|
1947
2365
|
return out;
|
|
1948
2366
|
}
|
|
2367
|
+
function scrubValue(v) {
|
|
2368
|
+
if (typeof v === "string") return scrubPii(v);
|
|
2369
|
+
if (Array.isArray(v)) return v.map(scrubValue);
|
|
2370
|
+
if (v && typeof v === "object" && v.constructor === Object) {
|
|
2371
|
+
return scrubPiiFromProperties(v);
|
|
2372
|
+
}
|
|
2373
|
+
return v;
|
|
2374
|
+
}
|
|
1949
2375
|
|
|
1950
2376
|
// src/breadcrumbs.ts
|
|
1951
2377
|
var BreadcrumbBuffer = class {
|
|
@@ -2235,16 +2661,18 @@ var ErrorTracker = class {
|
|
|
2235
2661
|
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
2236
2662
|
const method = (init.method || "GET").toUpperCase();
|
|
2237
2663
|
const start = Date.now();
|
|
2238
|
-
this.opts.
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2664
|
+
if (!isSelfRequest(url, this.opts.selfHostname)) {
|
|
2665
|
+
this.opts.breadcrumbs.add({
|
|
2666
|
+
timestamp: start,
|
|
2667
|
+
category: "http",
|
|
2668
|
+
message: `${method} ${url}`,
|
|
2669
|
+
data: { url, method }
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2244
2672
|
try {
|
|
2245
2673
|
const response = await origFetch(...args);
|
|
2246
2674
|
if (response.status >= 500 && this.opts.isConsented()) {
|
|
2247
|
-
if (!url.
|
|
2675
|
+
if (!isSelfRequest(url, this.opts.selfHostname)) {
|
|
2248
2676
|
this.captureHttp({
|
|
2249
2677
|
url,
|
|
2250
2678
|
method,
|
|
@@ -2293,7 +2721,7 @@ var ErrorTracker = class {
|
|
|
2293
2721
|
try {
|
|
2294
2722
|
if (xhr.status >= 500 && tracker.opts.isConsented()) {
|
|
2295
2723
|
const url = xhr._cdUrl ?? "";
|
|
2296
|
-
if (!url.
|
|
2724
|
+
if (!isSelfRequest(url, tracker.opts.selfHostname)) {
|
|
2297
2725
|
tracker.captureHttp({
|
|
2298
2726
|
url,
|
|
2299
2727
|
method: (xhr._cdMethod ?? "GET").toUpperCase(),
|
|
@@ -2453,9 +2881,10 @@ var ErrorTracker = class {
|
|
|
2453
2881
|
if (!this.passesSample(err)) return;
|
|
2454
2882
|
if (!this.passesRateLimit(err)) return;
|
|
2455
2883
|
let finalErr = err;
|
|
2456
|
-
|
|
2884
|
+
const hook = this.opts.beforeSend?.();
|
|
2885
|
+
if (hook) {
|
|
2457
2886
|
try {
|
|
2458
|
-
finalErr =
|
|
2887
|
+
finalErr = hook(err);
|
|
2459
2888
|
} catch {
|
|
2460
2889
|
finalErr = err;
|
|
2461
2890
|
}
|
|
@@ -2637,6 +3066,22 @@ function safeClone(v) {
|
|
|
2637
3066
|
function safeStringify2(v) {
|
|
2638
3067
|
return coerceErrorPayload(v).message;
|
|
2639
3068
|
}
|
|
3069
|
+
function extractSelfHostname(baseUrl) {
|
|
3070
|
+
if (!baseUrl || typeof baseUrl !== "string") return null;
|
|
3071
|
+
try {
|
|
3072
|
+
return new URL(baseUrl).hostname.toLowerCase();
|
|
3073
|
+
} catch {
|
|
3074
|
+
return null;
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
function isSelfRequest(requestUrl, selfHostname) {
|
|
3078
|
+
if (!selfHostname || !requestUrl) return false;
|
|
3079
|
+
try {
|
|
3080
|
+
return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
|
|
3081
|
+
} catch {
|
|
3082
|
+
return false;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
2640
3085
|
|
|
2641
3086
|
// src/crossdeck.ts
|
|
2642
3087
|
var CrossdeckClient = class {
|
|
@@ -2653,6 +3098,28 @@ var CrossdeckClient = class {
|
|
|
2653
3098
|
* mismatched env fails fast at boot rather than at first event-flush.
|
|
2654
3099
|
*/
|
|
2655
3100
|
init(options) {
|
|
3101
|
+
if (this.state) {
|
|
3102
|
+
try {
|
|
3103
|
+
this.state.uninstallUnloadFlush?.();
|
|
3104
|
+
} catch {
|
|
3105
|
+
}
|
|
3106
|
+
try {
|
|
3107
|
+
this.state.autoTracker?.uninstall();
|
|
3108
|
+
} catch {
|
|
3109
|
+
}
|
|
3110
|
+
try {
|
|
3111
|
+
this.state.webVitals?.uninstall();
|
|
3112
|
+
} catch {
|
|
3113
|
+
}
|
|
3114
|
+
try {
|
|
3115
|
+
this.state.errors?.uninstall();
|
|
3116
|
+
} catch {
|
|
3117
|
+
}
|
|
3118
|
+
try {
|
|
3119
|
+
void this.state.events.flush({ keepalive: true });
|
|
3120
|
+
} catch {
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
2656
3123
|
if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
|
|
2657
3124
|
throw new CrossdeckError({
|
|
2658
3125
|
type: "configuration_error",
|
|
@@ -2699,7 +3166,12 @@ var CrossdeckClient = class {
|
|
|
2699
3166
|
// load still flushes if the user leaves quickly (the keepalive
|
|
2700
3167
|
// pagehide handler picks up anything that doesn't); long enough
|
|
2701
3168
|
// that bursts of clicks coalesce into one network round-trip.
|
|
2702
|
-
|
|
3169
|
+
// v1.4.0 Phase 3.3 — flush interval default parity. Pre-
|
|
3170
|
+
// v1.4.0: Web/Node 1500ms, RN/Swift/Android 5000ms. All
|
|
3171
|
+
// converged on 2000ms (the Stripe-adjacent industry norm)
|
|
3172
|
+
// so cross-platform funnels show events landing at the
|
|
3173
|
+
// same cadence on every SDK. Per-instance override stays.
|
|
3174
|
+
eventFlushIntervalMs: options.eventFlushIntervalMs ?? 2e3,
|
|
2703
3175
|
sdkVersion: options.sdkVersion ?? SDK_VERSION,
|
|
2704
3176
|
autoTrack,
|
|
2705
3177
|
appVersion: options.appVersion ?? null
|
|
@@ -2760,6 +3232,15 @@ var CrossdeckClient = class {
|
|
|
2760
3232
|
`Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
|
|
2761
3233
|
{ ...info }
|
|
2762
3234
|
);
|
|
3235
|
+
},
|
|
3236
|
+
onPermanentFailure: (info) => {
|
|
3237
|
+
const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
|
|
3238
|
+
console.error(headline);
|
|
3239
|
+
debug.emit(
|
|
3240
|
+
"sdk.flush_permanent_failure",
|
|
3241
|
+
headline,
|
|
3242
|
+
{ ...info }
|
|
3243
|
+
);
|
|
2763
3244
|
}
|
|
2764
3245
|
});
|
|
2765
3246
|
const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
|
|
@@ -2826,8 +3307,20 @@ var CrossdeckClient = class {
|
|
|
2826
3307
|
report: (err) => this.reportError(err),
|
|
2827
3308
|
getContext: () => ({ ...this.state.errorContext }),
|
|
2828
3309
|
getTags: () => ({ ...this.state.errorTags }),
|
|
2829
|
-
|
|
2830
|
-
|
|
3310
|
+
// GETTER, not a captured value — `setErrorBeforeSend()` mutates
|
|
3311
|
+
// `state.errorBeforeSend` after init() and the tracker MUST
|
|
3312
|
+
// pick up the new hook on the next error. The pre-fix shape
|
|
3313
|
+
// (`beforeSend: this.state!.errorBeforeSend`) snapshotted
|
|
3314
|
+
// `null` at construction and made the customer's PII scrubber
|
|
3315
|
+
// silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
|
|
3316
|
+
beforeSend: () => this.state.errorBeforeSend,
|
|
3317
|
+
isConsented: () => this.state.consent.errors,
|
|
3318
|
+
// Derived from the configured baseUrl at init() time. Used by
|
|
3319
|
+
// the fetch / XHR wrappers to skip captureHttp on Crossdeck's
|
|
3320
|
+
// own requests — pre-fix the skip was hardcoded to
|
|
3321
|
+
// `api.cross-deck.com` and broke for customers on staging /
|
|
3322
|
+
// regional / self-hosted base URLs (recursive capture loop).
|
|
3323
|
+
selfHostname: extractSelfHostname(opts.baseUrl)
|
|
2831
3324
|
});
|
|
2832
3325
|
this.state.errors = tracker;
|
|
2833
3326
|
tracker.install();
|
|
@@ -2906,13 +3399,10 @@ var CrossdeckClient = class {
|
|
|
2906
3399
|
};
|
|
2907
3400
|
if (options?.email) body.email = options.email;
|
|
2908
3401
|
if (traits) body.traits = traits;
|
|
3402
|
+
s.entitlements.setUserKey(userId);
|
|
2909
3403
|
const result = await s.http.request("POST", "/identity/alias", {
|
|
2910
3404
|
body
|
|
2911
3405
|
});
|
|
2912
|
-
const priorCdcust = s.identity.crossdeckCustomerId;
|
|
2913
|
-
if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
|
|
2914
|
-
s.entitlements.clear();
|
|
2915
|
-
}
|
|
2916
3406
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
2917
3407
|
s.developerUserId = userId;
|
|
2918
3408
|
return result;
|
|
@@ -3352,11 +3842,25 @@ var CrossdeckClient = class {
|
|
|
3352
3842
|
message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
|
|
3353
3843
|
});
|
|
3354
3844
|
}
|
|
3845
|
+
const rail = input.rail ?? "apple";
|
|
3846
|
+
const body = { ...input, rail };
|
|
3847
|
+
const idempotencyKey = deriveIdempotencyKeyForPurchase(body);
|
|
3355
3848
|
const result = await s.http.request("POST", "/purchases/sync", {
|
|
3356
|
-
body
|
|
3849
|
+
body,
|
|
3850
|
+
idempotencyKey
|
|
3357
3851
|
});
|
|
3358
3852
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
3359
3853
|
s.entitlements.setFromList(result.entitlements);
|
|
3854
|
+
try {
|
|
3855
|
+
const sourceProductId = result.entitlements[0]?.source.productId;
|
|
3856
|
+
const sourceSubscriptionId = result.entitlements[0]?.source.subscriptionId;
|
|
3857
|
+
const props = { rail };
|
|
3858
|
+
if (sourceProductId) props.productId = sourceProductId;
|
|
3859
|
+
if (sourceSubscriptionId) props.subscriptionId = sourceSubscriptionId;
|
|
3860
|
+
if (result.idempotent_replay) props.idempotent_replay = true;
|
|
3861
|
+
this.track("purchase.completed", props);
|
|
3862
|
+
} catch {
|
|
3863
|
+
}
|
|
3360
3864
|
s.debug.emit(
|
|
3361
3865
|
"sdk.purchase_evidence_sent",
|
|
3362
3866
|
"StoreKit transaction forwarded. Waiting for backend verification.",
|
|
@@ -3412,13 +3916,15 @@ var CrossdeckClient = class {
|
|
|
3412
3916
|
}
|
|
3413
3917
|
this.state.autoTracker?.uninstall();
|
|
3414
3918
|
this.state.identity.reset();
|
|
3415
|
-
this.state.entitlements.
|
|
3919
|
+
this.state.entitlements.clearAll();
|
|
3416
3920
|
this.state.events.reset();
|
|
3417
3921
|
this.state.superProps.clear();
|
|
3418
3922
|
this.state.breadcrumbs.clear();
|
|
3419
3923
|
this.state.errorContext = {};
|
|
3420
3924
|
this.state.errorTags = {};
|
|
3421
3925
|
this.state.developerUserId = null;
|
|
3926
|
+
this.state.lastServerTime = null;
|
|
3927
|
+
this.state.lastClientTime = null;
|
|
3422
3928
|
if (this.state.autoTracker) {
|
|
3423
3929
|
const tracker = new AutoTracker(
|
|
3424
3930
|
this.state.options.autoTrack,
|
|
@@ -3547,7 +4053,9 @@ function isLocalHostname() {
|
|
|
3547
4053
|
const hostname = w?.location?.hostname;
|
|
3548
4054
|
if (!hostname) return false;
|
|
3549
4055
|
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
4056
|
+
if (hostname === "0.0.0.0") return true;
|
|
3550
4057
|
if (hostname === "::1" || hostname === "[::1]") return true;
|
|
4058
|
+
if (/^\[?fe80::/i.test(hostname)) return true;
|
|
3551
4059
|
if (hostname.endsWith(".local")) return true;
|
|
3552
4060
|
if (/^10\./.test(hostname)) return true;
|
|
3553
4061
|
if (/^192\.168\./.test(hostname)) return true;
|
|
@@ -3683,6 +4191,116 @@ var CROSSDECK_ERROR_CODES = Object.freeze([
|
|
|
3683
4191
|
description: "The server returned a 2xx with an unparseable body.",
|
|
3684
4192
|
resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
|
|
3685
4193
|
retryable: true
|
|
4194
|
+
},
|
|
4195
|
+
// ----- Backend-emitted codes (v1.4.0 Phase 6.2 backfill) -----
|
|
4196
|
+
// Mirror of backend/src/api/v1-errors.ts ApiErrorCode. A developer
|
|
4197
|
+
// hitting any of these on the wire can look them up via
|
|
4198
|
+
// getErrorCode(code) for a canonical remediation step instead of
|
|
4199
|
+
// hunting through Slack history.
|
|
4200
|
+
{
|
|
4201
|
+
code: "missing_api_key",
|
|
4202
|
+
type: "authentication_error",
|
|
4203
|
+
description: "No Authorization header (or Crossdeck-Api-Key header) on the request.",
|
|
4204
|
+
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.",
|
|
4205
|
+
retryable: false
|
|
4206
|
+
},
|
|
4207
|
+
{
|
|
4208
|
+
code: "invalid_api_key",
|
|
4209
|
+
type: "authentication_error",
|
|
4210
|
+
description: "The API key is malformed, unknown, or doesn't resolve to a project.",
|
|
4211
|
+
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.",
|
|
4212
|
+
retryable: false
|
|
4213
|
+
},
|
|
4214
|
+
{
|
|
4215
|
+
code: "key_revoked",
|
|
4216
|
+
type: "authentication_error",
|
|
4217
|
+
description: "The API key was revoked in the Crossdeck dashboard.",
|
|
4218
|
+
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.",
|
|
4219
|
+
retryable: false
|
|
4220
|
+
},
|
|
4221
|
+
{
|
|
4222
|
+
code: "identity_token_invalid",
|
|
4223
|
+
type: "authentication_error",
|
|
4224
|
+
description: "The Firebase / Apple / Google ID token supplied with the request didn't verify against the dashboard's configured signers.",
|
|
4225
|
+
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.",
|
|
4226
|
+
retryable: true
|
|
4227
|
+
},
|
|
4228
|
+
{
|
|
4229
|
+
code: "origin_not_allowed",
|
|
4230
|
+
type: "permission_error",
|
|
4231
|
+
description: "The Origin header isn't in the project's Allowed origins list.",
|
|
4232
|
+
resolution: "Add the origin (e.g. https://app.example.com) under dashboard \u2192 Settings \u2192 Allowed origins. Wildcards like https://*.example.com are supported.",
|
|
4233
|
+
retryable: false
|
|
4234
|
+
},
|
|
4235
|
+
{
|
|
4236
|
+
code: "bundle_id_not_allowed",
|
|
4237
|
+
type: "permission_error",
|
|
4238
|
+
description: "The iOS bundle ID sent via X-Crossdeck-Bundle-Id isn't registered under this app's Apple identity lock.",
|
|
4239
|
+
resolution: "Add the bundle ID under dashboard \u2192 Apps \u2192 <your app> \u2192 iOS bundle IDs.",
|
|
4240
|
+
retryable: false
|
|
4241
|
+
},
|
|
4242
|
+
{
|
|
4243
|
+
code: "package_name_not_allowed",
|
|
4244
|
+
type: "permission_error",
|
|
4245
|
+
description: "The Android package name sent via X-Crossdeck-Package-Name isn't registered under this app's Android identity lock.",
|
|
4246
|
+
resolution: "Add the package name under dashboard \u2192 Apps \u2192 <your app> \u2192 Android package names.",
|
|
4247
|
+
retryable: false
|
|
4248
|
+
},
|
|
4249
|
+
{
|
|
4250
|
+
code: "env_mismatch",
|
|
4251
|
+
type: "permission_error",
|
|
4252
|
+
description: "The request env (inferred from key prefix) doesn't match the resolved app's configured env.",
|
|
4253
|
+
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.",
|
|
4254
|
+
retryable: false
|
|
4255
|
+
},
|
|
4256
|
+
{
|
|
4257
|
+
code: "idempotency_key_in_use",
|
|
4258
|
+
type: "invalid_request_error",
|
|
4259
|
+
description: "An Idempotency-Key was reused for a request with a different body (Stripe-grade contract).",
|
|
4260
|
+
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.",
|
|
4261
|
+
retryable: false
|
|
4262
|
+
},
|
|
4263
|
+
{
|
|
4264
|
+
code: "rate_limited",
|
|
4265
|
+
type: "rate_limit_error",
|
|
4266
|
+
description: "Request rate exceeded the project's per-second cap.",
|
|
4267
|
+
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.",
|
|
4268
|
+
retryable: true
|
|
4269
|
+
},
|
|
4270
|
+
{
|
|
4271
|
+
code: "internal_error",
|
|
4272
|
+
type: "internal_error",
|
|
4273
|
+
description: "Server-side issue. Safe to retry with backoff.",
|
|
4274
|
+
resolution: "The SDK retries automatically. If your code paths through to this error, contact support with the requestId from the response envelope.",
|
|
4275
|
+
retryable: true
|
|
4276
|
+
},
|
|
4277
|
+
{
|
|
4278
|
+
code: "google_not_supported",
|
|
4279
|
+
type: "invalid_request_error",
|
|
4280
|
+
description: "POST /purchases/sync with rail=google is gated until the Play Developer API reconciliation worker ships.",
|
|
4281
|
+
resolution: "Until v1.5+, Google Play purchases verify via Real-time Developer Notifications. The SDK auto-track path handles this transparently for Android consumers.",
|
|
4282
|
+
retryable: false
|
|
4283
|
+
},
|
|
4284
|
+
{
|
|
4285
|
+
code: "stripe_not_supported",
|
|
4286
|
+
type: "invalid_request_error",
|
|
4287
|
+
description: "POST /purchases/sync with rail=stripe is unsupported \u2014 Stripe Checkout's redirect flow uses platform webhooks instead.",
|
|
4288
|
+
resolution: "Wire Stripe via the standard Checkout / Customer Portal flow; Crossdeck reconciles via the platform webhook automatically. No SDK call needed.",
|
|
4289
|
+
retryable: false
|
|
4290
|
+
},
|
|
4291
|
+
{
|
|
4292
|
+
code: "missing_required_param",
|
|
4293
|
+
type: "invalid_request_error",
|
|
4294
|
+
description: "A required field is absent from the request body.",
|
|
4295
|
+
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.",
|
|
4296
|
+
retryable: false
|
|
4297
|
+
},
|
|
4298
|
+
{
|
|
4299
|
+
code: "invalid_param_value",
|
|
4300
|
+
type: "invalid_request_error",
|
|
4301
|
+
description: "A field is present but the value failed validation (wrong shape, wrong length, wrong enum value).",
|
|
4302
|
+
resolution: "Read error.message for the specific field + reason. SDK-managed call sites should never emit this \u2014 file a bug if you do.",
|
|
4303
|
+
retryable: false
|
|
3686
4304
|
}
|
|
3687
4305
|
]);
|
|
3688
4306
|
function getErrorCode(code) {
|