@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "generatedAt": "2026-05-22T10:34:42.191Z",
3
+ "generatedAt": "2026-05-24T15:50:15.563Z",
4
4
  "sdk": "@cross-deck/web",
5
5
  "codes": [
6
6
  {
package/dist/index.cjs CHANGED
@@ -97,9 +97,11 @@ function typeMapForStatus(status) {
97
97
  return "internal_error";
98
98
  }
99
99
 
100
- // src/http.ts
100
+ // src/_version.ts
101
+ var SDK_VERSION = "1.3.0";
101
102
  var SDK_NAME = "@cross-deck/web";
102
- var SDK_VERSION = "1.1.0";
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 {
@@ -530,8 +532,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
530
532
  const safeAttempts = Math.min(attempts, 30);
531
533
  const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
532
534
  const jittered = ceiling * random();
533
- if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
534
- return Math.min(max, retryAfterMs);
535
+ if (retryAfterMs !== void 0) {
536
+ const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
537
+ const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
538
+ if (honoured > jittered) return honoured;
535
539
  }
536
540
  return Math.max(0, Math.round(jittered));
537
541
  }
@@ -566,6 +570,27 @@ var EventQueue = class {
566
570
  constructor(cfg) {
567
571
  this.cfg = cfg;
568
572
  this.buffer = [];
573
+ /**
574
+ * In-flight events that have been spliced from `buffer` for the
575
+ * current batch but haven't yet been confirmed (success or permanent
576
+ * failure). On a retry-driven re-flush we re-use this slot alongside
577
+ * `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
578
+ * across retries of the SAME logical batch.
579
+ *
580
+ * Pre-fix the splice cleared the buffer AND we immediately
581
+ * `persistent.save(empty)` BEFORE awaiting the network call — a
582
+ * crash mid-flight wiped the persisted blob and the batch was lost
583
+ * with no replay on next boot. Now the persisted blob always carries
584
+ * `[...pendingBatch, ...buffer]` so the in-flight events survive
585
+ * until the server confirms them.
586
+ *
587
+ * Pre-fix every retry attempt also minted a NEW batchId via
588
+ * `splice + mintBatchId`, defeating the backend's
589
+ * `Idempotency-Key` dedup. Reuse via this slot brings web in
590
+ * lockstep with node (which already had the field).
591
+ */
592
+ this.pendingBatch = null;
593
+ this.pendingBatchId = null;
569
594
  this.dropped = 0;
570
595
  this.inFlight = 0;
571
596
  this.lastFlushAt = 0;
@@ -598,7 +623,7 @@ var EventQueue = class {
598
623
  this.cfg.onDrop?.(overflow);
599
624
  }
600
625
  this.cfg.onBufferChange?.(this.buffer.length);
601
- this.persistent?.save(this.buffer);
626
+ this.persistAll();
602
627
  if (this.buffer.length >= this.cfg.batchSize) {
603
628
  void this.flush();
604
629
  } else {
@@ -607,22 +632,44 @@ var EventQueue = class {
607
632
  }
608
633
  /**
609
634
  * Flush the buffer to /v1/events. Resolves when the network call
610
- * completes (success or failure). On failure, events stay in the
611
- * buffer for the next scheduled retry.
635
+ * completes (success or failure). On a retryable failure the batch
636
+ * stays in `pendingBatch` for the next scheduled retry — the SAME
637
+ * batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
638
+ *
639
+ * Three terminal states from one call:
640
+ * - 2xx success: pendingBatch cleared, persisted state collapses to
641
+ * just `buffer` (any new events that arrived during in-flight).
642
+ * - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
643
+ * counter increments, a `permanent_failure` signal fires. The
644
+ * server is telling us our request is malformed / our key is
645
+ * revoked / we lack permission — retrying with the same key
646
+ * forever just grows the queue while the customer thinks events
647
+ * are landing.
648
+ * - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
649
+ * schedules a retry; the next `flush()` re-uses both.
612
650
  *
613
651
  * `options.keepalive` marks the underlying fetch as keepalive so the
614
- * browser keeps the request alive past page unload. Use this for
615
- * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
652
+ * browser keeps the request alive past page unload. Use for terminal
653
+ * flushes (pagehide / visibilitychange→hidden / beforeunload).
616
654
  */
617
655
  async flush(options = {}) {
618
- if (this.buffer.length === 0) return null;
656
+ let batch;
657
+ let batchId;
658
+ if (this.pendingBatch !== null && this.pendingBatchId !== null) {
659
+ batch = this.pendingBatch;
660
+ batchId = this.pendingBatchId;
661
+ } else {
662
+ if (this.buffer.length === 0) return null;
663
+ batch = this.buffer.splice(0);
664
+ batchId = this.mintBatchId();
665
+ this.pendingBatch = batch;
666
+ this.pendingBatchId = batchId;
667
+ this.inFlight += batch.length;
668
+ this.cfg.onBufferChange?.(this.buffer.length);
669
+ this.persistAll();
670
+ }
619
671
  this.cancelTimerIfSet();
620
672
  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
673
  try {
627
674
  const env = this.cfg.envelope();
628
675
  const result = await this.cfg.http.request("POST", "/events", {
@@ -641,20 +688,33 @@ var EventQueue = class {
641
688
  this.lastFlushAt = Date.now();
642
689
  this.lastError = null;
643
690
  this.inFlight -= batch.length;
691
+ this.pendingBatch = null;
692
+ this.pendingBatchId = null;
644
693
  this.retry.recordSuccess();
645
- this.persistent?.save(this.buffer);
694
+ this.persistAll();
646
695
  if (!this.firstFlushFired) {
647
696
  this.firstFlushFired = true;
648
697
  this.cfg.onFirstFlushSuccess?.();
649
698
  }
650
699
  return result;
651
700
  } catch (err) {
652
- this.buffer.unshift(...batch);
653
- this.inFlight -= batch.length;
654
701
  const message = err instanceof Error ? err.message : String(err);
655
702
  this.lastError = message;
656
- this.persistent?.save(this.buffer);
657
- this.cfg.onBufferChange?.(this.buffer.length);
703
+ if (isPermanent4xx(err)) {
704
+ const droppedCount = batch.length;
705
+ this.pendingBatch = null;
706
+ this.pendingBatchId = null;
707
+ this.inFlight -= droppedCount;
708
+ this.dropped += droppedCount;
709
+ this.persistAll();
710
+ this.cfg.onDrop?.(droppedCount);
711
+ this.cfg.onPermanentFailure?.({
712
+ status: err.status ?? 0,
713
+ droppedCount,
714
+ lastError: message
715
+ });
716
+ return null;
717
+ }
658
718
  const retryAfterMs = extractRetryAfterMs(err);
659
719
  const delay = this.retry.nextDelay(retryAfterMs);
660
720
  this.scheduleRetry(delay);
@@ -672,6 +732,8 @@ var EventQueue = class {
672
732
  this.cancelTimerIfSet();
673
733
  this.nextRetryAt = null;
674
734
  this.buffer = [];
735
+ this.pendingBatch = null;
736
+ this.pendingBatchId = null;
675
737
  this.dropped = 0;
676
738
  this.inFlight = 0;
677
739
  this.lastError = null;
@@ -681,6 +743,10 @@ var EventQueue = class {
681
743
  }
682
744
  getStats() {
683
745
  return {
746
+ // `buffered` counts events waiting for their FIRST flush. The
747
+ // in-flight pendingBatch (retrying) is tracked separately via
748
+ // `inFlight` — surfacing both lets diagnostics show "we have
749
+ // events stuck retrying" distinct from "new events arriving".
684
750
  buffered: this.buffer.length,
685
751
  dropped: this.dropped,
686
752
  inFlight: this.inFlight,
@@ -690,6 +756,29 @@ var EventQueue = class {
690
756
  nextRetryAt: this.nextRetryAt
691
757
  };
692
758
  }
759
+ /**
760
+ * The Idempotency-Key of the in-flight pending batch (if any).
761
+ * Exposed for testing the Stripe-style retry-reuse contract.
762
+ * Production callers don't need this.
763
+ */
764
+ get pendingIdempotencyKey() {
765
+ return this.pendingBatchId;
766
+ }
767
+ /**
768
+ * Persist `[...pendingBatch, ...buffer]` — the full set of
769
+ * not-yet-confirmed events. The next boot rehydrates this exact set
770
+ * into `buffer` and replays. The server dedups via eventId
771
+ * (ReplacingMergeTree on the warehouse side), so re-sending an event
772
+ * that may have already landed is safe.
773
+ */
774
+ persistAll() {
775
+ if (!this.persistent) return;
776
+ if (this.pendingBatch === null) {
777
+ this.persistent.save(this.buffer);
778
+ return;
779
+ }
780
+ this.persistent.save([...this.pendingBatch, ...this.buffer]);
781
+ }
693
782
  // ---------- internal scheduling ----------
694
783
  scheduleIdleFlush() {
695
784
  this.cancelTimerIfSet();
@@ -723,6 +812,14 @@ function extractRetryAfterMs(err) {
723
812
  }
724
813
  return void 0;
725
814
  }
815
+ function isPermanent4xx(err) {
816
+ if (!err || typeof err !== "object") return false;
817
+ const status = err.status;
818
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
819
+ if (status < 400 || status >= 500) return false;
820
+ if (status === 408 || status === 429) return false;
821
+ return true;
822
+ }
726
823
  function defaultScheduler(fn, ms) {
727
824
  const id = setTimeout(fn, ms);
728
825
  if (typeof id.unref === "function") {
@@ -1064,6 +1161,7 @@ var AutoTracker = class {
1064
1161
  /** Exposed for tests + consumers that want to reset the session manually. */
1065
1162
  resetSession() {
1066
1163
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1164
+ this.pageviewId = null;
1067
1165
  this.session = this.startNewSession();
1068
1166
  this.emitSessionStart();
1069
1167
  }
@@ -1100,6 +1198,7 @@ var AutoTracker = class {
1100
1198
  const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1101
1199
  if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1102
1200
  this.emitSessionEnd();
1201
+ this.pageviewId = null;
1103
1202
  this.session = this.startNewSession();
1104
1203
  this.emitSessionStart();
1105
1204
  } else {
@@ -1473,7 +1572,7 @@ function validateEventProperties(input, options = {}) {
1473
1572
  const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1474
1573
  const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1475
1574
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1476
- const seen = /* @__PURE__ */ new WeakSet();
1575
+ const seen = /* @__PURE__ */ new Set();
1477
1576
  const visit = (value, key, depth) => {
1478
1577
  if (depth > maxDepth) {
1479
1578
  warnings.push({ kind: "depth_exceeded", key });
@@ -1561,6 +1660,7 @@ function validateEventProperties(input, options = {}) {
1561
1660
  const result = visit(value[i], `${key}[${i}]`, depth + 1);
1562
1661
  if (result.keep) out.push(result.value);
1563
1662
  }
1663
+ seen.delete(value);
1564
1664
  return { keep: true, value: out };
1565
1665
  }
1566
1666
  if (t === "object") {
@@ -1575,6 +1675,7 @@ function validateEventProperties(input, options = {}) {
1575
1675
  const result = visit(obj[k], `${key}.${k}`, depth + 1);
1576
1676
  if (result.keep) out[k] = result.value;
1577
1677
  }
1678
+ seen.delete(obj);
1578
1679
  return { keep: true, value: out };
1579
1680
  }
1580
1681
  warnings.push({ kind: "non_serialisable", key });
@@ -1917,35 +2018,27 @@ var ConsentManager = class {
1917
2018
  };
1918
2019
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1919
2020
  var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1920
- var REPLACEMENT_EMAIL = "[email]";
1921
- var REPLACEMENT_CARD = "[card]";
2021
+ var REPLACEMENT_EMAIL = "<email>";
2022
+ var REPLACEMENT_CARD = "<card>";
1922
2023
  function scrubPii(value) {
1923
2024
  if (!value) return value;
1924
- let out = value;
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;
2025
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
1934
2026
  }
1935
2027
  function scrubPiiFromProperties(properties) {
1936
2028
  const out = {};
1937
2029
  for (const k of Object.keys(properties)) {
1938
- const v = properties[k];
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
- }
2030
+ out[k] = scrubValue(properties[k]);
1946
2031
  }
1947
2032
  return out;
1948
2033
  }
2034
+ function scrubValue(v) {
2035
+ if (typeof v === "string") return scrubPii(v);
2036
+ if (Array.isArray(v)) return v.map(scrubValue);
2037
+ if (v && typeof v === "object" && v.constructor === Object) {
2038
+ return scrubPiiFromProperties(v);
2039
+ }
2040
+ return v;
2041
+ }
1949
2042
 
1950
2043
  // src/breadcrumbs.ts
1951
2044
  var BreadcrumbBuffer = class {
@@ -2235,16 +2328,18 @@ var ErrorTracker = class {
2235
2328
  const url = typeof input === "string" ? input : input?.url ?? "";
2236
2329
  const method = (init.method || "GET").toUpperCase();
2237
2330
  const start = Date.now();
2238
- this.opts.breadcrumbs.add({
2239
- timestamp: start,
2240
- category: "http",
2241
- message: `${method} ${url}`,
2242
- data: { url, method }
2243
- });
2331
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2332
+ this.opts.breadcrumbs.add({
2333
+ timestamp: start,
2334
+ category: "http",
2335
+ message: `${method} ${url}`,
2336
+ data: { url, method }
2337
+ });
2338
+ }
2244
2339
  try {
2245
2340
  const response = await origFetch(...args);
2246
2341
  if (response.status >= 500 && this.opts.isConsented()) {
2247
- if (!url.includes("api.cross-deck.com")) {
2342
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2248
2343
  this.captureHttp({
2249
2344
  url,
2250
2345
  method,
@@ -2293,7 +2388,7 @@ var ErrorTracker = class {
2293
2388
  try {
2294
2389
  if (xhr.status >= 500 && tracker.opts.isConsented()) {
2295
2390
  const url = xhr._cdUrl ?? "";
2296
- if (!url.includes("api.cross-deck.com")) {
2391
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
2297
2392
  tracker.captureHttp({
2298
2393
  url,
2299
2394
  method: (xhr._cdMethod ?? "GET").toUpperCase(),
@@ -2453,9 +2548,10 @@ var ErrorTracker = class {
2453
2548
  if (!this.passesSample(err)) return;
2454
2549
  if (!this.passesRateLimit(err)) return;
2455
2550
  let finalErr = err;
2456
- if (this.opts.beforeSend) {
2551
+ const hook = this.opts.beforeSend?.();
2552
+ if (hook) {
2457
2553
  try {
2458
- finalErr = this.opts.beforeSend(err);
2554
+ finalErr = hook(err);
2459
2555
  } catch {
2460
2556
  finalErr = err;
2461
2557
  }
@@ -2637,6 +2733,22 @@ function safeClone(v) {
2637
2733
  function safeStringify2(v) {
2638
2734
  return coerceErrorPayload(v).message;
2639
2735
  }
2736
+ function extractSelfHostname(baseUrl) {
2737
+ if (!baseUrl || typeof baseUrl !== "string") return null;
2738
+ try {
2739
+ return new URL(baseUrl).hostname.toLowerCase();
2740
+ } catch {
2741
+ return null;
2742
+ }
2743
+ }
2744
+ function isSelfRequest(requestUrl, selfHostname) {
2745
+ if (!selfHostname || !requestUrl) return false;
2746
+ try {
2747
+ return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
2748
+ } catch {
2749
+ return false;
2750
+ }
2751
+ }
2640
2752
 
2641
2753
  // src/crossdeck.ts
2642
2754
  var CrossdeckClient = class {
@@ -2653,6 +2765,24 @@ var CrossdeckClient = class {
2653
2765
  * mismatched env fails fast at boot rather than at first event-flush.
2654
2766
  */
2655
2767
  init(options) {
2768
+ if (this.state) {
2769
+ try {
2770
+ this.state.uninstallUnloadFlush?.();
2771
+ } catch {
2772
+ }
2773
+ try {
2774
+ this.state.autoTracker?.uninstall();
2775
+ } catch {
2776
+ }
2777
+ try {
2778
+ this.state.webVitals?.uninstall();
2779
+ } catch {
2780
+ }
2781
+ try {
2782
+ this.state.errors?.uninstall();
2783
+ } catch {
2784
+ }
2785
+ }
2656
2786
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2657
2787
  throw new CrossdeckError({
2658
2788
  type: "configuration_error",
@@ -2760,6 +2890,15 @@ var CrossdeckClient = class {
2760
2890
  `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2761
2891
  { ...info }
2762
2892
  );
2893
+ },
2894
+ onPermanentFailure: (info) => {
2895
+ const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
2896
+ console.error(headline);
2897
+ debug.emit(
2898
+ "sdk.flush_permanent_failure",
2899
+ headline,
2900
+ { ...info }
2901
+ );
2763
2902
  }
2764
2903
  });
2765
2904
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
@@ -2826,8 +2965,20 @@ var CrossdeckClient = class {
2826
2965
  report: (err) => this.reportError(err),
2827
2966
  getContext: () => ({ ...this.state.errorContext }),
2828
2967
  getTags: () => ({ ...this.state.errorTags }),
2829
- beforeSend: this.state.errorBeforeSend,
2830
- isConsented: () => this.state.consent.errors
2968
+ // GETTER, not a captured value — `setErrorBeforeSend()` mutates
2969
+ // `state.errorBeforeSend` after init() and the tracker MUST
2970
+ // pick up the new hook on the next error. The pre-fix shape
2971
+ // (`beforeSend: this.state!.errorBeforeSend`) snapshotted
2972
+ // `null` at construction and made the customer's PII scrubber
2973
+ // silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
2974
+ beforeSend: () => this.state.errorBeforeSend,
2975
+ isConsented: () => this.state.consent.errors,
2976
+ // Derived from the configured baseUrl at init() time. Used by
2977
+ // the fetch / XHR wrappers to skip captureHttp on Crossdeck's
2978
+ // own requests — pre-fix the skip was hardcoded to
2979
+ // `api.cross-deck.com` and broke for customers on staging /
2980
+ // regional / self-hosted base URLs (recursive capture loop).
2981
+ selfHostname: extractSelfHostname(opts.baseUrl)
2831
2982
  });
2832
2983
  this.state.errors = tracker;
2833
2984
  tracker.install();
@@ -2910,7 +3061,8 @@ var CrossdeckClient = class {
2910
3061
  body
2911
3062
  });
2912
3063
  const priorCdcust = s.identity.crossdeckCustomerId;
2913
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
3064
+ const cacheHasEntries = s.entitlements.list().length > 0;
3065
+ if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId || !priorCdcust && cacheHasEntries) {
2914
3066
  s.entitlements.clear();
2915
3067
  }
2916
3068
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
@@ -3352,8 +3504,9 @@ var CrossdeckClient = class {
3352
3504
  message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
3353
3505
  });
3354
3506
  }
3507
+ const rail = input.rail ?? "apple";
3355
3508
  const result = await s.http.request("POST", "/purchases/sync", {
3356
- body: { rail: input.rail ?? "apple", ...input }
3509
+ body: { ...input, rail }
3357
3510
  });
3358
3511
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3359
3512
  s.entitlements.setFromList(result.entitlements);
@@ -3419,6 +3572,8 @@ var CrossdeckClient = class {
3419
3572
  this.state.errorContext = {};
3420
3573
  this.state.errorTags = {};
3421
3574
  this.state.developerUserId = null;
3575
+ this.state.lastServerTime = null;
3576
+ this.state.lastClientTime = null;
3422
3577
  if (this.state.autoTracker) {
3423
3578
  const tracker = new AutoTracker(
3424
3579
  this.state.options.autoTrack,
@@ -3547,7 +3702,9 @@ function isLocalHostname() {
3547
3702
  const hostname = w?.location?.hostname;
3548
3703
  if (!hostname) return false;
3549
3704
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
3705
+ if (hostname === "0.0.0.0") return true;
3550
3706
  if (hostname === "::1" || hostname === "[::1]") return true;
3707
+ if (/^\[?fe80::/i.test(hostname)) return true;
3551
3708
  if (hostname.endsWith(".local")) return true;
3552
3709
  if (/^10\./.test(hostname)) return true;
3553
3710
  if (/^192\.168\./.test(hostname)) return true;