@cross-deck/web 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs CHANGED
@@ -92,9 +92,11 @@ function typeMapForStatus(status) {
92
92
  return "internal_error";
93
93
  }
94
94
 
95
- // src/http.ts
95
+ // src/_version.ts
96
+ var SDK_VERSION = "1.3.0";
96
97
  var SDK_NAME = "@cross-deck/web";
97
- var SDK_VERSION = "1.1.0";
98
+
99
+ // src/http.ts
98
100
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
99
101
  var DEFAULT_TIMEOUT_MS = 15e3;
100
102
  var HttpClient = class {
@@ -525,8 +527,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
525
527
  const safeAttempts = Math.min(attempts, 30);
526
528
  const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
527
529
  const jittered = ceiling * random();
528
- if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
529
- return Math.min(max, retryAfterMs);
530
+ if (retryAfterMs !== void 0) {
531
+ const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
532
+ const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
533
+ if (honoured > jittered) return honoured;
530
534
  }
531
535
  return Math.max(0, Math.round(jittered));
532
536
  }
@@ -561,6 +565,27 @@ var EventQueue = class {
561
565
  constructor(cfg) {
562
566
  this.cfg = cfg;
563
567
  this.buffer = [];
568
+ /**
569
+ * In-flight events that have been spliced from `buffer` for the
570
+ * current batch but haven't yet been confirmed (success or permanent
571
+ * failure). On a retry-driven re-flush we re-use this slot alongside
572
+ * `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
573
+ * across retries of the SAME logical batch.
574
+ *
575
+ * Pre-fix the splice cleared the buffer AND we immediately
576
+ * `persistent.save(empty)` BEFORE awaiting the network call — a
577
+ * crash mid-flight wiped the persisted blob and the batch was lost
578
+ * with no replay on next boot. Now the persisted blob always carries
579
+ * `[...pendingBatch, ...buffer]` so the in-flight events survive
580
+ * until the server confirms them.
581
+ *
582
+ * Pre-fix every retry attempt also minted a NEW batchId via
583
+ * `splice + mintBatchId`, defeating the backend's
584
+ * `Idempotency-Key` dedup. Reuse via this slot brings web in
585
+ * lockstep with node (which already had the field).
586
+ */
587
+ this.pendingBatch = null;
588
+ this.pendingBatchId = null;
564
589
  this.dropped = 0;
565
590
  this.inFlight = 0;
566
591
  this.lastFlushAt = 0;
@@ -593,7 +618,7 @@ var EventQueue = class {
593
618
  this.cfg.onDrop?.(overflow);
594
619
  }
595
620
  this.cfg.onBufferChange?.(this.buffer.length);
596
- this.persistent?.save(this.buffer);
621
+ this.persistAll();
597
622
  if (this.buffer.length >= this.cfg.batchSize) {
598
623
  void this.flush();
599
624
  } else {
@@ -602,22 +627,44 @@ var EventQueue = class {
602
627
  }
603
628
  /**
604
629
  * Flush the buffer to /v1/events. Resolves when the network call
605
- * completes (success or failure). On failure, events stay in the
606
- * buffer for the next scheduled retry.
630
+ * completes (success or failure). On a retryable failure the batch
631
+ * stays in `pendingBatch` for the next scheduled retry — the SAME
632
+ * batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
633
+ *
634
+ * Three terminal states from one call:
635
+ * - 2xx success: pendingBatch cleared, persisted state collapses to
636
+ * just `buffer` (any new events that arrived during in-flight).
637
+ * - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
638
+ * counter increments, a `permanent_failure` signal fires. The
639
+ * server is telling us our request is malformed / our key is
640
+ * revoked / we lack permission — retrying with the same key
641
+ * forever just grows the queue while the customer thinks events
642
+ * are landing.
643
+ * - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
644
+ * schedules a retry; the next `flush()` re-uses both.
607
645
  *
608
646
  * `options.keepalive` marks the underlying fetch as keepalive so the
609
- * browser keeps the request alive past page unload. Use this for
610
- * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
647
+ * browser keeps the request alive past page unload. Use for terminal
648
+ * flushes (pagehide / visibilitychange→hidden / beforeunload).
611
649
  */
612
650
  async flush(options = {}) {
613
- if (this.buffer.length === 0) return null;
651
+ let batch;
652
+ let batchId;
653
+ if (this.pendingBatch !== null && this.pendingBatchId !== null) {
654
+ batch = this.pendingBatch;
655
+ batchId = this.pendingBatchId;
656
+ } else {
657
+ if (this.buffer.length === 0) return null;
658
+ batch = this.buffer.splice(0);
659
+ batchId = this.mintBatchId();
660
+ this.pendingBatch = batch;
661
+ this.pendingBatchId = batchId;
662
+ this.inFlight += batch.length;
663
+ this.cfg.onBufferChange?.(this.buffer.length);
664
+ this.persistAll();
665
+ }
614
666
  this.cancelTimerIfSet();
615
667
  this.nextRetryAt = null;
616
- const batch = this.buffer.splice(0);
617
- const batchId = this.mintBatchId();
618
- this.inFlight += batch.length;
619
- this.persistent?.save(this.buffer);
620
- this.cfg.onBufferChange?.(this.buffer.length);
621
668
  try {
622
669
  const env = this.cfg.envelope();
623
670
  const result = await this.cfg.http.request("POST", "/events", {
@@ -636,20 +683,33 @@ var EventQueue = class {
636
683
  this.lastFlushAt = Date.now();
637
684
  this.lastError = null;
638
685
  this.inFlight -= batch.length;
686
+ this.pendingBatch = null;
687
+ this.pendingBatchId = null;
639
688
  this.retry.recordSuccess();
640
- this.persistent?.save(this.buffer);
689
+ this.persistAll();
641
690
  if (!this.firstFlushFired) {
642
691
  this.firstFlushFired = true;
643
692
  this.cfg.onFirstFlushSuccess?.();
644
693
  }
645
694
  return result;
646
695
  } catch (err) {
647
- this.buffer.unshift(...batch);
648
- this.inFlight -= batch.length;
649
696
  const message = err instanceof Error ? err.message : String(err);
650
697
  this.lastError = message;
651
- this.persistent?.save(this.buffer);
652
- this.cfg.onBufferChange?.(this.buffer.length);
698
+ if (isPermanent4xx(err)) {
699
+ const droppedCount = batch.length;
700
+ this.pendingBatch = null;
701
+ this.pendingBatchId = null;
702
+ this.inFlight -= droppedCount;
703
+ this.dropped += droppedCount;
704
+ this.persistAll();
705
+ this.cfg.onDrop?.(droppedCount);
706
+ this.cfg.onPermanentFailure?.({
707
+ status: err.status ?? 0,
708
+ droppedCount,
709
+ lastError: message
710
+ });
711
+ return null;
712
+ }
653
713
  const retryAfterMs = extractRetryAfterMs(err);
654
714
  const delay = this.retry.nextDelay(retryAfterMs);
655
715
  this.scheduleRetry(delay);
@@ -667,6 +727,8 @@ var EventQueue = class {
667
727
  this.cancelTimerIfSet();
668
728
  this.nextRetryAt = null;
669
729
  this.buffer = [];
730
+ this.pendingBatch = null;
731
+ this.pendingBatchId = null;
670
732
  this.dropped = 0;
671
733
  this.inFlight = 0;
672
734
  this.lastError = null;
@@ -676,6 +738,10 @@ var EventQueue = class {
676
738
  }
677
739
  getStats() {
678
740
  return {
741
+ // `buffered` counts events waiting for their FIRST flush. The
742
+ // in-flight pendingBatch (retrying) is tracked separately via
743
+ // `inFlight` — surfacing both lets diagnostics show "we have
744
+ // events stuck retrying" distinct from "new events arriving".
679
745
  buffered: this.buffer.length,
680
746
  dropped: this.dropped,
681
747
  inFlight: this.inFlight,
@@ -685,6 +751,29 @@ var EventQueue = class {
685
751
  nextRetryAt: this.nextRetryAt
686
752
  };
687
753
  }
754
+ /**
755
+ * The Idempotency-Key of the in-flight pending batch (if any).
756
+ * Exposed for testing the Stripe-style retry-reuse contract.
757
+ * Production callers don't need this.
758
+ */
759
+ get pendingIdempotencyKey() {
760
+ return this.pendingBatchId;
761
+ }
762
+ /**
763
+ * Persist `[...pendingBatch, ...buffer]` — the full set of
764
+ * not-yet-confirmed events. The next boot rehydrates this exact set
765
+ * into `buffer` and replays. The server dedups via eventId
766
+ * (ReplacingMergeTree on the warehouse side), so re-sending an event
767
+ * that may have already landed is safe.
768
+ */
769
+ persistAll() {
770
+ if (!this.persistent) return;
771
+ if (this.pendingBatch === null) {
772
+ this.persistent.save(this.buffer);
773
+ return;
774
+ }
775
+ this.persistent.save([...this.pendingBatch, ...this.buffer]);
776
+ }
688
777
  // ---------- internal scheduling ----------
689
778
  scheduleIdleFlush() {
690
779
  this.cancelTimerIfSet();
@@ -718,6 +807,14 @@ function extractRetryAfterMs(err) {
718
807
  }
719
808
  return void 0;
720
809
  }
810
+ function isPermanent4xx(err) {
811
+ if (!err || typeof err !== "object") return false;
812
+ const status = err.status;
813
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
814
+ if (status < 400 || status >= 500) return false;
815
+ if (status === 408 || status === 429) return false;
816
+ return true;
817
+ }
721
818
  function defaultScheduler(fn, ms) {
722
819
  const id = setTimeout(fn, ms);
723
820
  if (typeof id.unref === "function") {
@@ -1059,6 +1156,7 @@ var AutoTracker = class {
1059
1156
  /** Exposed for tests + consumers that want to reset the session manually. */
1060
1157
  resetSession() {
1061
1158
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1159
+ this.pageviewId = null;
1062
1160
  this.session = this.startNewSession();
1063
1161
  this.emitSessionStart();
1064
1162
  }
@@ -1095,6 +1193,7 @@ var AutoTracker = class {
1095
1193
  const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1096
1194
  if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1097
1195
  this.emitSessionEnd();
1196
+ this.pageviewId = null;
1098
1197
  this.session = this.startNewSession();
1099
1198
  this.emitSessionStart();
1100
1199
  } else {
@@ -1468,7 +1567,7 @@ function validateEventProperties(input, options = {}) {
1468
1567
  const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1469
1568
  const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1470
1569
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1471
- const seen = /* @__PURE__ */ new WeakSet();
1570
+ const seen = /* @__PURE__ */ new Set();
1472
1571
  const visit = (value, key, depth) => {
1473
1572
  if (depth > maxDepth) {
1474
1573
  warnings.push({ kind: "depth_exceeded", key });
@@ -1556,6 +1655,7 @@ function validateEventProperties(input, options = {}) {
1556
1655
  const result = visit(value[i], `${key}[${i}]`, depth + 1);
1557
1656
  if (result.keep) out.push(result.value);
1558
1657
  }
1658
+ seen.delete(value);
1559
1659
  return { keep: true, value: out };
1560
1660
  }
1561
1661
  if (t === "object") {
@@ -1570,6 +1670,7 @@ function validateEventProperties(input, options = {}) {
1570
1670
  const result = visit(obj[k], `${key}.${k}`, depth + 1);
1571
1671
  if (result.keep) out[k] = result.value;
1572
1672
  }
1673
+ seen.delete(obj);
1573
1674
  return { keep: true, value: out };
1574
1675
  }
1575
1676
  warnings.push({ kind: "non_serialisable", key });
@@ -1912,35 +2013,27 @@ var ConsentManager = class {
1912
2013
  };
1913
2014
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1914
2015
  var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1915
- var REPLACEMENT_EMAIL = "[email]";
1916
- var REPLACEMENT_CARD = "[card]";
2016
+ var REPLACEMENT_EMAIL = "<email>";
2017
+ var REPLACEMENT_CARD = "<card>";
1917
2018
  function scrubPii(value) {
1918
2019
  if (!value) return value;
1919
- let out = value;
1920
- if (EMAIL_PATTERN.test(out)) {
1921
- out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1922
- }
1923
- EMAIL_PATTERN.lastIndex = 0;
1924
- if (CARD_PATTERN.test(out)) {
1925
- out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1926
- }
1927
- CARD_PATTERN.lastIndex = 0;
1928
- return out;
2020
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
1929
2021
  }
1930
2022
  function scrubPiiFromProperties(properties) {
1931
2023
  const out = {};
1932
2024
  for (const k of Object.keys(properties)) {
1933
- const v = properties[k];
1934
- if (typeof v === "string") {
1935
- out[k] = scrubPii(v);
1936
- } else if (Array.isArray(v)) {
1937
- out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1938
- } else {
1939
- out[k] = v;
1940
- }
2025
+ out[k] = scrubValue(properties[k]);
1941
2026
  }
1942
2027
  return out;
1943
2028
  }
2029
+ function scrubValue(v) {
2030
+ if (typeof v === "string") return scrubPii(v);
2031
+ if (Array.isArray(v)) return v.map(scrubValue);
2032
+ if (v && typeof v === "object" && v.constructor === Object) {
2033
+ return scrubPiiFromProperties(v);
2034
+ }
2035
+ return v;
2036
+ }
1944
2037
 
1945
2038
  // src/breadcrumbs.ts
1946
2039
  var BreadcrumbBuffer = class {
@@ -2230,16 +2323,18 @@ var ErrorTracker = class {
2230
2323
  const url = typeof input === "string" ? input : input?.url ?? "";
2231
2324
  const method = (init.method || "GET").toUpperCase();
2232
2325
  const start = Date.now();
2233
- this.opts.breadcrumbs.add({
2234
- timestamp: start,
2235
- category: "http",
2236
- message: `${method} ${url}`,
2237
- data: { url, method }
2238
- });
2326
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2327
+ this.opts.breadcrumbs.add({
2328
+ timestamp: start,
2329
+ category: "http",
2330
+ message: `${method} ${url}`,
2331
+ data: { url, method }
2332
+ });
2333
+ }
2239
2334
  try {
2240
2335
  const response = await origFetch(...args);
2241
2336
  if (response.status >= 500 && this.opts.isConsented()) {
2242
- if (!url.includes("api.cross-deck.com")) {
2337
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2243
2338
  this.captureHttp({
2244
2339
  url,
2245
2340
  method,
@@ -2288,7 +2383,7 @@ var ErrorTracker = class {
2288
2383
  try {
2289
2384
  if (xhr.status >= 500 && tracker.opts.isConsented()) {
2290
2385
  const url = xhr._cdUrl ?? "";
2291
- if (!url.includes("api.cross-deck.com")) {
2386
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
2292
2387
  tracker.captureHttp({
2293
2388
  url,
2294
2389
  method: (xhr._cdMethod ?? "GET").toUpperCase(),
@@ -2448,9 +2543,10 @@ var ErrorTracker = class {
2448
2543
  if (!this.passesSample(err)) return;
2449
2544
  if (!this.passesRateLimit(err)) return;
2450
2545
  let finalErr = err;
2451
- if (this.opts.beforeSend) {
2546
+ const hook = this.opts.beforeSend?.();
2547
+ if (hook) {
2452
2548
  try {
2453
- finalErr = this.opts.beforeSend(err);
2549
+ finalErr = hook(err);
2454
2550
  } catch {
2455
2551
  finalErr = err;
2456
2552
  }
@@ -2632,6 +2728,22 @@ function safeClone(v) {
2632
2728
  function safeStringify2(v) {
2633
2729
  return coerceErrorPayload(v).message;
2634
2730
  }
2731
+ function extractSelfHostname(baseUrl) {
2732
+ if (!baseUrl || typeof baseUrl !== "string") return null;
2733
+ try {
2734
+ return new URL(baseUrl).hostname.toLowerCase();
2735
+ } catch {
2736
+ return null;
2737
+ }
2738
+ }
2739
+ function isSelfRequest(requestUrl, selfHostname) {
2740
+ if (!selfHostname || !requestUrl) return false;
2741
+ try {
2742
+ return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
2743
+ } catch {
2744
+ return false;
2745
+ }
2746
+ }
2635
2747
 
2636
2748
  // src/crossdeck.ts
2637
2749
  var CrossdeckClient = class {
@@ -2648,6 +2760,24 @@ var CrossdeckClient = class {
2648
2760
  * mismatched env fails fast at boot rather than at first event-flush.
2649
2761
  */
2650
2762
  init(options) {
2763
+ if (this.state) {
2764
+ try {
2765
+ this.state.uninstallUnloadFlush?.();
2766
+ } catch {
2767
+ }
2768
+ try {
2769
+ this.state.autoTracker?.uninstall();
2770
+ } catch {
2771
+ }
2772
+ try {
2773
+ this.state.webVitals?.uninstall();
2774
+ } catch {
2775
+ }
2776
+ try {
2777
+ this.state.errors?.uninstall();
2778
+ } catch {
2779
+ }
2780
+ }
2651
2781
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2652
2782
  throw new CrossdeckError({
2653
2783
  type: "configuration_error",
@@ -2755,6 +2885,15 @@ var CrossdeckClient = class {
2755
2885
  `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2756
2886
  { ...info }
2757
2887
  );
2888
+ },
2889
+ onPermanentFailure: (info) => {
2890
+ const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
2891
+ console.error(headline);
2892
+ debug.emit(
2893
+ "sdk.flush_permanent_failure",
2894
+ headline,
2895
+ { ...info }
2896
+ );
2758
2897
  }
2759
2898
  });
2760
2899
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
@@ -2821,8 +2960,20 @@ var CrossdeckClient = class {
2821
2960
  report: (err) => this.reportError(err),
2822
2961
  getContext: () => ({ ...this.state.errorContext }),
2823
2962
  getTags: () => ({ ...this.state.errorTags }),
2824
- beforeSend: this.state.errorBeforeSend,
2825
- isConsented: () => this.state.consent.errors
2963
+ // GETTER, not a captured value — `setErrorBeforeSend()` mutates
2964
+ // `state.errorBeforeSend` after init() and the tracker MUST
2965
+ // pick up the new hook on the next error. The pre-fix shape
2966
+ // (`beforeSend: this.state!.errorBeforeSend`) snapshotted
2967
+ // `null` at construction and made the customer's PII scrubber
2968
+ // silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
2969
+ beforeSend: () => this.state.errorBeforeSend,
2970
+ isConsented: () => this.state.consent.errors,
2971
+ // Derived from the configured baseUrl at init() time. Used by
2972
+ // the fetch / XHR wrappers to skip captureHttp on Crossdeck's
2973
+ // own requests — pre-fix the skip was hardcoded to
2974
+ // `api.cross-deck.com` and broke for customers on staging /
2975
+ // regional / self-hosted base URLs (recursive capture loop).
2976
+ selfHostname: extractSelfHostname(opts.baseUrl)
2826
2977
  });
2827
2978
  this.state.errors = tracker;
2828
2979
  tracker.install();
@@ -2905,7 +3056,8 @@ var CrossdeckClient = class {
2905
3056
  body
2906
3057
  });
2907
3058
  const priorCdcust = s.identity.crossdeckCustomerId;
2908
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
3059
+ const cacheHasEntries = s.entitlements.list().length > 0;
3060
+ if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId || !priorCdcust && cacheHasEntries) {
2909
3061
  s.entitlements.clear();
2910
3062
  }
2911
3063
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
@@ -3347,8 +3499,9 @@ var CrossdeckClient = class {
3347
3499
  message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
3348
3500
  });
3349
3501
  }
3502
+ const rail = input.rail ?? "apple";
3350
3503
  const result = await s.http.request("POST", "/purchases/sync", {
3351
- body: { rail: input.rail ?? "apple", ...input }
3504
+ body: { ...input, rail }
3352
3505
  });
3353
3506
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3354
3507
  s.entitlements.setFromList(result.entitlements);
@@ -3414,6 +3567,8 @@ var CrossdeckClient = class {
3414
3567
  this.state.errorContext = {};
3415
3568
  this.state.errorTags = {};
3416
3569
  this.state.developerUserId = null;
3570
+ this.state.lastServerTime = null;
3571
+ this.state.lastClientTime = null;
3417
3572
  if (this.state.autoTracker) {
3418
3573
  const tracker = new AutoTracker(
3419
3574
  this.state.options.autoTrack,
@@ -3542,7 +3697,9 @@ function isLocalHostname() {
3542
3697
  const hostname = w?.location?.hostname;
3543
3698
  if (!hostname) return false;
3544
3699
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
3700
+ if (hostname === "0.0.0.0") return true;
3545
3701
  if (hostname === "::1" || hostname === "[::1]") return true;
3702
+ if (/^\[?fe80::/i.test(hostname)) return true;
3546
3703
  if (hostname.endsWith(".local")) return true;
3547
3704
  if (/^10\./.test(hostname)) return true;
3548
3705
  if (/^192\.168\./.test(hostname)) return true;