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