@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/react.d.mts CHANGED
@@ -1,3 +1,6 @@
1
+ import { ReactNode } from 'react';
2
+ import { C as CrossdeckOptions } from './types-BzoKor4z.mjs';
3
+
1
4
  /**
2
5
  * @cross-deck/web/react — React hooks for the Crossdeck SDK.
3
6
  *
@@ -27,6 +30,26 @@
27
30
  * ships). Android => Compose `State<Boolean>` wrapper (when Android
28
31
  * SDK ships).
29
32
  */
33
+
34
+ interface CrossdeckProviderProps extends Omit<CrossdeckOptions, "userId"> {
35
+ /**
36
+ * Optional. When defined, the provider calls Crossdeck.identify(userId)
37
+ * after init and on every change. When the prop flips back to undefined
38
+ * (logout), the provider calls Crossdeck.reset().
39
+ *
40
+ * Pass your auth library's stable user id directly:
41
+ * <CrossdeckProvider userId={session?.user?.id} … /> // NextAuth
42
+ * <CrossdeckProvider userId={user?.uid} … /> // Firebase
43
+ * <CrossdeckProvider userId={supabase.auth.user()?.id} … /> // Supabase
44
+ *
45
+ * Anonymous (pre-login) traffic stays anonymous until userId becomes
46
+ * defined — the SDK's anonymousId follows the same user record once
47
+ * identify lands, so attribution survives sign-up.
48
+ */
49
+ userId?: string | null | undefined;
50
+ children: ReactNode;
51
+ }
52
+ declare function CrossdeckProvider(props: CrossdeckProviderProps): ReactNode;
30
53
  /**
31
54
  * Subscribe a React component to a single entitlement key.
32
55
  *
@@ -65,4 +88,4 @@ declare function useEntitlement(key: string): boolean;
65
88
  */
66
89
  declare function useEntitlements(): readonly string[];
67
90
 
68
- export { useEntitlement, useEntitlements };
91
+ export { CrossdeckProvider, useEntitlement, useEntitlements };
package/dist/react.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { ReactNode } from 'react';
2
+ import { C as CrossdeckOptions } from './types-BzoKor4z.js';
3
+
1
4
  /**
2
5
  * @cross-deck/web/react — React hooks for the Crossdeck SDK.
3
6
  *
@@ -27,6 +30,26 @@
27
30
  * ships). Android => Compose `State<Boolean>` wrapper (when Android
28
31
  * SDK ships).
29
32
  */
33
+
34
+ interface CrossdeckProviderProps extends Omit<CrossdeckOptions, "userId"> {
35
+ /**
36
+ * Optional. When defined, the provider calls Crossdeck.identify(userId)
37
+ * after init and on every change. When the prop flips back to undefined
38
+ * (logout), the provider calls Crossdeck.reset().
39
+ *
40
+ * Pass your auth library's stable user id directly:
41
+ * <CrossdeckProvider userId={session?.user?.id} … /> // NextAuth
42
+ * <CrossdeckProvider userId={user?.uid} … /> // Firebase
43
+ * <CrossdeckProvider userId={supabase.auth.user()?.id} … /> // Supabase
44
+ *
45
+ * Anonymous (pre-login) traffic stays anonymous until userId becomes
46
+ * defined — the SDK's anonymousId follows the same user record once
47
+ * identify lands, so attribution survives sign-up.
48
+ */
49
+ userId?: string | null | undefined;
50
+ children: ReactNode;
51
+ }
52
+ declare function CrossdeckProvider(props: CrossdeckProviderProps): ReactNode;
30
53
  /**
31
54
  * Subscribe a React component to a single entitlement key.
32
55
  *
@@ -65,4 +88,4 @@ declare function useEntitlement(key: string): boolean;
65
88
  */
66
89
  declare function useEntitlements(): readonly string[];
67
90
 
68
- export { useEntitlement, useEntitlements };
91
+ export { CrossdeckProvider, useEntitlement, useEntitlements };
package/dist/react.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/react.ts
2
- import { useEffect, useState } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
3
 
4
4
  // src/errors.ts
5
5
  var CrossdeckError = class _CrossdeckError extends Error {
@@ -66,9 +66,11 @@ function typeMapForStatus(status) {
66
66
  return "internal_error";
67
67
  }
68
68
 
69
- // src/http.ts
69
+ // src/_version.ts
70
+ var SDK_VERSION = "1.3.0";
70
71
  var SDK_NAME = "@cross-deck/web";
71
- var SDK_VERSION = "1.1.0";
72
+
73
+ // src/http.ts
72
74
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
73
75
  var DEFAULT_TIMEOUT_MS = 15e3;
74
76
  var HttpClient = class {
@@ -499,8 +501,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
499
501
  const safeAttempts = Math.min(attempts, 30);
500
502
  const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
501
503
  const jittered = ceiling * random();
502
- if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
503
- return Math.min(max, retryAfterMs);
504
+ if (retryAfterMs !== void 0) {
505
+ const ABSOLUTE_MAX_MS = 24 * 60 * 60 * 1e3;
506
+ const honoured = Math.min(ABSOLUTE_MAX_MS, retryAfterMs);
507
+ if (honoured > jittered) return honoured;
504
508
  }
505
509
  return Math.max(0, Math.round(jittered));
506
510
  }
@@ -535,6 +539,27 @@ var EventQueue = class {
535
539
  constructor(cfg) {
536
540
  this.cfg = cfg;
537
541
  this.buffer = [];
542
+ /**
543
+ * In-flight events that have been spliced from `buffer` for the
544
+ * current batch but haven't yet been confirmed (success or permanent
545
+ * failure). On a retry-driven re-flush we re-use this slot alongside
546
+ * `pendingBatchId` so the Stripe-style Idempotency-Key is preserved
547
+ * across retries of the SAME logical batch.
548
+ *
549
+ * Pre-fix the splice cleared the buffer AND we immediately
550
+ * `persistent.save(empty)` BEFORE awaiting the network call — a
551
+ * crash mid-flight wiped the persisted blob and the batch was lost
552
+ * with no replay on next boot. Now the persisted blob always carries
553
+ * `[...pendingBatch, ...buffer]` so the in-flight events survive
554
+ * until the server confirms them.
555
+ *
556
+ * Pre-fix every retry attempt also minted a NEW batchId via
557
+ * `splice + mintBatchId`, defeating the backend's
558
+ * `Idempotency-Key` dedup. Reuse via this slot brings web in
559
+ * lockstep with node (which already had the field).
560
+ */
561
+ this.pendingBatch = null;
562
+ this.pendingBatchId = null;
538
563
  this.dropped = 0;
539
564
  this.inFlight = 0;
540
565
  this.lastFlushAt = 0;
@@ -567,7 +592,7 @@ var EventQueue = class {
567
592
  this.cfg.onDrop?.(overflow);
568
593
  }
569
594
  this.cfg.onBufferChange?.(this.buffer.length);
570
- this.persistent?.save(this.buffer);
595
+ this.persistAll();
571
596
  if (this.buffer.length >= this.cfg.batchSize) {
572
597
  void this.flush();
573
598
  } else {
@@ -576,22 +601,44 @@ var EventQueue = class {
576
601
  }
577
602
  /**
578
603
  * Flush the buffer to /v1/events. Resolves when the network call
579
- * completes (success or failure). On failure, events stay in the
580
- * buffer for the next scheduled retry.
604
+ * completes (success or failure). On a retryable failure the batch
605
+ * stays in `pendingBatch` for the next scheduled retry — the SAME
606
+ * batch with the SAME `Idempotency-Key` is re-sent (Stripe pattern).
607
+ *
608
+ * Three terminal states from one call:
609
+ * - 2xx success: pendingBatch cleared, persisted state collapses to
610
+ * just `buffer` (any new events that arrived during in-flight).
611
+ * - 4xx permanent (except 408/429): pendingBatch DROPPED, `dropped`
612
+ * counter increments, a `permanent_failure` signal fires. The
613
+ * server is telling us our request is malformed / our key is
614
+ * revoked / we lack permission — retrying with the same key
615
+ * forever just grows the queue while the customer thinks events
616
+ * are landing.
617
+ * - 5xx / network / 408 / 429: pendingBatch + batchId stay; backoff
618
+ * schedules a retry; the next `flush()` re-uses both.
581
619
  *
582
620
  * `options.keepalive` marks the underlying fetch as keepalive so the
583
- * browser keeps the request alive past page unload. Use this for
584
- * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
621
+ * browser keeps the request alive past page unload. Use for terminal
622
+ * flushes (pagehide / visibilitychange→hidden / beforeunload).
585
623
  */
586
624
  async flush(options = {}) {
587
- if (this.buffer.length === 0) return null;
625
+ let batch;
626
+ let batchId;
627
+ if (this.pendingBatch !== null && this.pendingBatchId !== null) {
628
+ batch = this.pendingBatch;
629
+ batchId = this.pendingBatchId;
630
+ } else {
631
+ if (this.buffer.length === 0) return null;
632
+ batch = this.buffer.splice(0);
633
+ batchId = this.mintBatchId();
634
+ this.pendingBatch = batch;
635
+ this.pendingBatchId = batchId;
636
+ this.inFlight += batch.length;
637
+ this.cfg.onBufferChange?.(this.buffer.length);
638
+ this.persistAll();
639
+ }
588
640
  this.cancelTimerIfSet();
589
641
  this.nextRetryAt = null;
590
- const batch = this.buffer.splice(0);
591
- const batchId = this.mintBatchId();
592
- this.inFlight += batch.length;
593
- this.persistent?.save(this.buffer);
594
- this.cfg.onBufferChange?.(this.buffer.length);
595
642
  try {
596
643
  const env = this.cfg.envelope();
597
644
  const result = await this.cfg.http.request("POST", "/events", {
@@ -610,20 +657,33 @@ var EventQueue = class {
610
657
  this.lastFlushAt = Date.now();
611
658
  this.lastError = null;
612
659
  this.inFlight -= batch.length;
660
+ this.pendingBatch = null;
661
+ this.pendingBatchId = null;
613
662
  this.retry.recordSuccess();
614
- this.persistent?.save(this.buffer);
663
+ this.persistAll();
615
664
  if (!this.firstFlushFired) {
616
665
  this.firstFlushFired = true;
617
666
  this.cfg.onFirstFlushSuccess?.();
618
667
  }
619
668
  return result;
620
669
  } catch (err) {
621
- this.buffer.unshift(...batch);
622
- this.inFlight -= batch.length;
623
670
  const message = err instanceof Error ? err.message : String(err);
624
671
  this.lastError = message;
625
- this.persistent?.save(this.buffer);
626
- this.cfg.onBufferChange?.(this.buffer.length);
672
+ if (isPermanent4xx(err)) {
673
+ const droppedCount = batch.length;
674
+ this.pendingBatch = null;
675
+ this.pendingBatchId = null;
676
+ this.inFlight -= droppedCount;
677
+ this.dropped += droppedCount;
678
+ this.persistAll();
679
+ this.cfg.onDrop?.(droppedCount);
680
+ this.cfg.onPermanentFailure?.({
681
+ status: err.status ?? 0,
682
+ droppedCount,
683
+ lastError: message
684
+ });
685
+ return null;
686
+ }
627
687
  const retryAfterMs = extractRetryAfterMs(err);
628
688
  const delay = this.retry.nextDelay(retryAfterMs);
629
689
  this.scheduleRetry(delay);
@@ -641,6 +701,8 @@ var EventQueue = class {
641
701
  this.cancelTimerIfSet();
642
702
  this.nextRetryAt = null;
643
703
  this.buffer = [];
704
+ this.pendingBatch = null;
705
+ this.pendingBatchId = null;
644
706
  this.dropped = 0;
645
707
  this.inFlight = 0;
646
708
  this.lastError = null;
@@ -650,6 +712,10 @@ var EventQueue = class {
650
712
  }
651
713
  getStats() {
652
714
  return {
715
+ // `buffered` counts events waiting for their FIRST flush. The
716
+ // in-flight pendingBatch (retrying) is tracked separately via
717
+ // `inFlight` — surfacing both lets diagnostics show "we have
718
+ // events stuck retrying" distinct from "new events arriving".
653
719
  buffered: this.buffer.length,
654
720
  dropped: this.dropped,
655
721
  inFlight: this.inFlight,
@@ -659,6 +725,29 @@ var EventQueue = class {
659
725
  nextRetryAt: this.nextRetryAt
660
726
  };
661
727
  }
728
+ /**
729
+ * The Idempotency-Key of the in-flight pending batch (if any).
730
+ * Exposed for testing the Stripe-style retry-reuse contract.
731
+ * Production callers don't need this.
732
+ */
733
+ get pendingIdempotencyKey() {
734
+ return this.pendingBatchId;
735
+ }
736
+ /**
737
+ * Persist `[...pendingBatch, ...buffer]` — the full set of
738
+ * not-yet-confirmed events. The next boot rehydrates this exact set
739
+ * into `buffer` and replays. The server dedups via eventId
740
+ * (ReplacingMergeTree on the warehouse side), so re-sending an event
741
+ * that may have already landed is safe.
742
+ */
743
+ persistAll() {
744
+ if (!this.persistent) return;
745
+ if (this.pendingBatch === null) {
746
+ this.persistent.save(this.buffer);
747
+ return;
748
+ }
749
+ this.persistent.save([...this.pendingBatch, ...this.buffer]);
750
+ }
662
751
  // ---------- internal scheduling ----------
663
752
  scheduleIdleFlush() {
664
753
  this.cancelTimerIfSet();
@@ -692,6 +781,14 @@ function extractRetryAfterMs(err) {
692
781
  }
693
782
  return void 0;
694
783
  }
784
+ function isPermanent4xx(err) {
785
+ if (!err || typeof err !== "object") return false;
786
+ const status = err.status;
787
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
788
+ if (status < 400 || status >= 500) return false;
789
+ if (status === 408 || status === 429) return false;
790
+ return true;
791
+ }
695
792
  function defaultScheduler(fn, ms) {
696
793
  const id = setTimeout(fn, ms);
697
794
  if (typeof id.unref === "function") {
@@ -1033,6 +1130,7 @@ var AutoTracker = class {
1033
1130
  /** Exposed for tests + consumers that want to reset the session manually. */
1034
1131
  resetSession() {
1035
1132
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1133
+ this.pageviewId = null;
1036
1134
  this.session = this.startNewSession();
1037
1135
  this.emitSessionStart();
1038
1136
  }
@@ -1069,6 +1167,7 @@ var AutoTracker = class {
1069
1167
  const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1070
1168
  if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1071
1169
  this.emitSessionEnd();
1170
+ this.pageviewId = null;
1072
1171
  this.session = this.startNewSession();
1073
1172
  this.emitSessionStart();
1074
1173
  } else {
@@ -1442,7 +1541,7 @@ function validateEventProperties(input, options = {}) {
1442
1541
  const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1443
1542
  const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1444
1543
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1445
- const seen = /* @__PURE__ */ new WeakSet();
1544
+ const seen = /* @__PURE__ */ new Set();
1446
1545
  const visit = (value, key, depth) => {
1447
1546
  if (depth > maxDepth) {
1448
1547
  warnings.push({ kind: "depth_exceeded", key });
@@ -1530,6 +1629,7 @@ function validateEventProperties(input, options = {}) {
1530
1629
  const result = visit(value[i], `${key}[${i}]`, depth + 1);
1531
1630
  if (result.keep) out.push(result.value);
1532
1631
  }
1632
+ seen.delete(value);
1533
1633
  return { keep: true, value: out };
1534
1634
  }
1535
1635
  if (t === "object") {
@@ -1544,6 +1644,7 @@ function validateEventProperties(input, options = {}) {
1544
1644
  const result = visit(obj[k], `${key}.${k}`, depth + 1);
1545
1645
  if (result.keep) out[k] = result.value;
1546
1646
  }
1647
+ seen.delete(obj);
1547
1648
  return { keep: true, value: out };
1548
1649
  }
1549
1650
  warnings.push({ kind: "non_serialisable", key });
@@ -1886,35 +1987,27 @@ var ConsentManager = class {
1886
1987
  };
1887
1988
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1888
1989
  var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1889
- var REPLACEMENT_EMAIL = "[email]";
1890
- var REPLACEMENT_CARD = "[card]";
1990
+ var REPLACEMENT_EMAIL = "<email>";
1991
+ var REPLACEMENT_CARD = "<card>";
1891
1992
  function scrubPii(value) {
1892
1993
  if (!value) return value;
1893
- let out = value;
1894
- if (EMAIL_PATTERN.test(out)) {
1895
- out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1896
- }
1897
- EMAIL_PATTERN.lastIndex = 0;
1898
- if (CARD_PATTERN.test(out)) {
1899
- out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1900
- }
1901
- CARD_PATTERN.lastIndex = 0;
1902
- return out;
1994
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
1903
1995
  }
1904
1996
  function scrubPiiFromProperties(properties) {
1905
1997
  const out = {};
1906
1998
  for (const k of Object.keys(properties)) {
1907
- const v = properties[k];
1908
- if (typeof v === "string") {
1909
- out[k] = scrubPii(v);
1910
- } else if (Array.isArray(v)) {
1911
- out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1912
- } else {
1913
- out[k] = v;
1914
- }
1999
+ out[k] = scrubValue(properties[k]);
1915
2000
  }
1916
2001
  return out;
1917
2002
  }
2003
+ function scrubValue(v) {
2004
+ if (typeof v === "string") return scrubPii(v);
2005
+ if (Array.isArray(v)) return v.map(scrubValue);
2006
+ if (v && typeof v === "object" && v.constructor === Object) {
2007
+ return scrubPiiFromProperties(v);
2008
+ }
2009
+ return v;
2010
+ }
1918
2011
 
1919
2012
  // src/breadcrumbs.ts
1920
2013
  var BreadcrumbBuffer = class {
@@ -2204,16 +2297,18 @@ var ErrorTracker = class {
2204
2297
  const url = typeof input === "string" ? input : input?.url ?? "";
2205
2298
  const method = (init.method || "GET").toUpperCase();
2206
2299
  const start = Date.now();
2207
- this.opts.breadcrumbs.add({
2208
- timestamp: start,
2209
- category: "http",
2210
- message: `${method} ${url}`,
2211
- data: { url, method }
2212
- });
2300
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2301
+ this.opts.breadcrumbs.add({
2302
+ timestamp: start,
2303
+ category: "http",
2304
+ message: `${method} ${url}`,
2305
+ data: { url, method }
2306
+ });
2307
+ }
2213
2308
  try {
2214
2309
  const response = await origFetch(...args);
2215
2310
  if (response.status >= 500 && this.opts.isConsented()) {
2216
- if (!url.includes("api.cross-deck.com")) {
2311
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2217
2312
  this.captureHttp({
2218
2313
  url,
2219
2314
  method,
@@ -2262,7 +2357,7 @@ var ErrorTracker = class {
2262
2357
  try {
2263
2358
  if (xhr.status >= 500 && tracker.opts.isConsented()) {
2264
2359
  const url = xhr._cdUrl ?? "";
2265
- if (!url.includes("api.cross-deck.com")) {
2360
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
2266
2361
  tracker.captureHttp({
2267
2362
  url,
2268
2363
  method: (xhr._cdMethod ?? "GET").toUpperCase(),
@@ -2422,9 +2517,10 @@ var ErrorTracker = class {
2422
2517
  if (!this.passesSample(err)) return;
2423
2518
  if (!this.passesRateLimit(err)) return;
2424
2519
  let finalErr = err;
2425
- if (this.opts.beforeSend) {
2520
+ const hook = this.opts.beforeSend?.();
2521
+ if (hook) {
2426
2522
  try {
2427
- finalErr = this.opts.beforeSend(err);
2523
+ finalErr = hook(err);
2428
2524
  } catch {
2429
2525
  finalErr = err;
2430
2526
  }
@@ -2606,6 +2702,22 @@ function safeClone(v) {
2606
2702
  function safeStringify2(v) {
2607
2703
  return coerceErrorPayload(v).message;
2608
2704
  }
2705
+ function extractSelfHostname(baseUrl) {
2706
+ if (!baseUrl || typeof baseUrl !== "string") return null;
2707
+ try {
2708
+ return new URL(baseUrl).hostname.toLowerCase();
2709
+ } catch {
2710
+ return null;
2711
+ }
2712
+ }
2713
+ function isSelfRequest(requestUrl, selfHostname) {
2714
+ if (!selfHostname || !requestUrl) return false;
2715
+ try {
2716
+ return new URL(requestUrl).hostname.toLowerCase() === selfHostname;
2717
+ } catch {
2718
+ return false;
2719
+ }
2720
+ }
2609
2721
 
2610
2722
  // src/crossdeck.ts
2611
2723
  var CrossdeckClient = class {
@@ -2622,6 +2734,24 @@ var CrossdeckClient = class {
2622
2734
  * mismatched env fails fast at boot rather than at first event-flush.
2623
2735
  */
2624
2736
  init(options) {
2737
+ if (this.state) {
2738
+ try {
2739
+ this.state.uninstallUnloadFlush?.();
2740
+ } catch {
2741
+ }
2742
+ try {
2743
+ this.state.autoTracker?.uninstall();
2744
+ } catch {
2745
+ }
2746
+ try {
2747
+ this.state.webVitals?.uninstall();
2748
+ } catch {
2749
+ }
2750
+ try {
2751
+ this.state.errors?.uninstall();
2752
+ } catch {
2753
+ }
2754
+ }
2625
2755
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2626
2756
  throw new CrossdeckError({
2627
2757
  type: "configuration_error",
@@ -2729,6 +2859,15 @@ var CrossdeckClient = class {
2729
2859
  `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2730
2860
  { ...info }
2731
2861
  );
2862
+ },
2863
+ onPermanentFailure: (info) => {
2864
+ const headline = `[crossdeck] Event batch DROPPED (status ${info.status}): ${info.lastError}. ${info.droppedCount} event(s) lost \u2014 check your publishable key + app config.`;
2865
+ console.error(headline);
2866
+ debug.emit(
2867
+ "sdk.flush_permanent_failure",
2868
+ headline,
2869
+ { ...info }
2870
+ );
2732
2871
  }
2733
2872
  });
2734
2873
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
@@ -2795,8 +2934,20 @@ var CrossdeckClient = class {
2795
2934
  report: (err) => this.reportError(err),
2796
2935
  getContext: () => ({ ...this.state.errorContext }),
2797
2936
  getTags: () => ({ ...this.state.errorTags }),
2798
- beforeSend: this.state.errorBeforeSend,
2799
- isConsented: () => this.state.consent.errors
2937
+ // GETTER, not a captured value — `setErrorBeforeSend()` mutates
2938
+ // `state.errorBeforeSend` after init() and the tracker MUST
2939
+ // pick up the new hook on the next error. The pre-fix shape
2940
+ // (`beforeSend: this.state!.errorBeforeSend`) snapshotted
2941
+ // `null` at construction and made the customer's PII scrubber
2942
+ // silently inert. See error-capture.ts:ErrorTrackerOptions.beforeSend.
2943
+ beforeSend: () => this.state.errorBeforeSend,
2944
+ isConsented: () => this.state.consent.errors,
2945
+ // Derived from the configured baseUrl at init() time. Used by
2946
+ // the fetch / XHR wrappers to skip captureHttp on Crossdeck's
2947
+ // own requests — pre-fix the skip was hardcoded to
2948
+ // `api.cross-deck.com` and broke for customers on staging /
2949
+ // regional / self-hosted base URLs (recursive capture loop).
2950
+ selfHostname: extractSelfHostname(opts.baseUrl)
2800
2951
  });
2801
2952
  this.state.errors = tracker;
2802
2953
  tracker.install();
@@ -2879,7 +3030,8 @@ var CrossdeckClient = class {
2879
3030
  body
2880
3031
  });
2881
3032
  const priorCdcust = s.identity.crossdeckCustomerId;
2882
- if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId) {
3033
+ const cacheHasEntries = s.entitlements.list().length > 0;
3034
+ if (priorCdcust && result.crossdeckCustomerId && priorCdcust !== result.crossdeckCustomerId || !priorCdcust && cacheHasEntries) {
2883
3035
  s.entitlements.clear();
2884
3036
  }
2885
3037
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
@@ -3321,8 +3473,9 @@ var CrossdeckClient = class {
3321
3473
  message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
3322
3474
  });
3323
3475
  }
3476
+ const rail = input.rail ?? "apple";
3324
3477
  const result = await s.http.request("POST", "/purchases/sync", {
3325
- body: { rail: input.rail ?? "apple", ...input }
3478
+ body: { ...input, rail }
3326
3479
  });
3327
3480
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3328
3481
  s.entitlements.setFromList(result.entitlements);
@@ -3388,6 +3541,8 @@ var CrossdeckClient = class {
3388
3541
  this.state.errorContext = {};
3389
3542
  this.state.errorTags = {};
3390
3543
  this.state.developerUserId = null;
3544
+ this.state.lastServerTime = null;
3545
+ this.state.lastClientTime = null;
3391
3546
  if (this.state.autoTracker) {
3392
3547
  const tracker = new AutoTracker(
3393
3548
  this.state.options.autoTrack,
@@ -3516,7 +3671,9 @@ function isLocalHostname() {
3516
3671
  const hostname = w?.location?.hostname;
3517
3672
  if (!hostname) return false;
3518
3673
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
3674
+ if (hostname === "0.0.0.0") return true;
3519
3675
  if (hostname === "::1" || hostname === "[::1]") return true;
3676
+ if (/^\[?fe80::/i.test(hostname)) return true;
3520
3677
  if (hostname.endsWith(".local")) return true;
3521
3678
  if (/^10\./.test(hostname)) return true;
3522
3679
  if (/^192\.168\./.test(hostname)) return true;
@@ -3565,6 +3722,39 @@ function installUnloadFlush(onUnload) {
3565
3722
  }
3566
3723
 
3567
3724
  // src/react.ts
3725
+ var _moduleInitDone = false;
3726
+ function CrossdeckProvider(props) {
3727
+ const { userId, children, ...initOptions } = props;
3728
+ const lastUserIdRef = useRef(void 0);
3729
+ useEffect(() => {
3730
+ if (_moduleInitDone) return;
3731
+ try {
3732
+ Crossdeck.init(initOptions);
3733
+ _moduleInitDone = true;
3734
+ } catch (err) {
3735
+ if (typeof console !== "undefined") {
3736
+ console.error("[CrossdeckProvider] init failed:", err);
3737
+ }
3738
+ }
3739
+ }, []);
3740
+ useEffect(() => {
3741
+ if (!_moduleInitDone) return;
3742
+ if (lastUserIdRef.current === userId) return;
3743
+ lastUserIdRef.current = userId;
3744
+ try {
3745
+ if (userId) {
3746
+ void Crossdeck.identify(userId);
3747
+ } else {
3748
+ Crossdeck.reset();
3749
+ }
3750
+ } catch (err) {
3751
+ if (typeof console !== "undefined") {
3752
+ console.error("[CrossdeckProvider] identity sync failed:", err);
3753
+ }
3754
+ }
3755
+ }, [userId]);
3756
+ return children;
3757
+ }
3568
3758
  function useEntitlement(key) {
3569
3759
  const [isEntitled, setIsEntitled] = useState(() => safeIsEntitled(key));
3570
3760
  useEffect(() => {
@@ -3614,6 +3804,7 @@ function safeListKeys() {
3614
3804
  }
3615
3805
  }
3616
3806
  export {
3807
+ CrossdeckProvider,
3617
3808
  useEntitlement,
3618
3809
  useEntitlements
3619
3810
  };