@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.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/react.ts
21
21
  var react_exports = {};
22
22
  __export(react_exports, {
23
+ CrossdeckProvider: () => CrossdeckProvider,
23
24
  useEntitlement: () => useEntitlement,
24
25
  useEntitlements: () => useEntitlements
25
26
  });
@@ -91,9 +92,11 @@ function typeMapForStatus(status) {
91
92
  return "internal_error";
92
93
  }
93
94
 
94
- // src/http.ts
95
+ // src/_version.ts
96
+ var SDK_VERSION = "1.3.0";
95
97
  var SDK_NAME = "@cross-deck/web";
96
- var SDK_VERSION = "1.1.0";
98
+
99
+ // src/http.ts
97
100
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
98
101
  var DEFAULT_TIMEOUT_MS = 15e3;
99
102
  var HttpClient = class {
@@ -524,8 +527,10 @@ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.ra
524
527
  const safeAttempts = Math.min(attempts, 30);
525
528
  const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
526
529
  const jittered = ceiling * random();
527
- if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
528
- 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;
529
534
  }
530
535
  return Math.max(0, Math.round(jittered));
531
536
  }
@@ -560,6 +565,27 @@ var EventQueue = class {
560
565
  constructor(cfg) {
561
566
  this.cfg = cfg;
562
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;
563
589
  this.dropped = 0;
564
590
  this.inFlight = 0;
565
591
  this.lastFlushAt = 0;
@@ -592,7 +618,7 @@ var EventQueue = class {
592
618
  this.cfg.onDrop?.(overflow);
593
619
  }
594
620
  this.cfg.onBufferChange?.(this.buffer.length);
595
- this.persistent?.save(this.buffer);
621
+ this.persistAll();
596
622
  if (this.buffer.length >= this.cfg.batchSize) {
597
623
  void this.flush();
598
624
  } else {
@@ -601,22 +627,44 @@ var EventQueue = class {
601
627
  }
602
628
  /**
603
629
  * 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.
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.
606
645
  *
607
646
  * `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).
647
+ * browser keeps the request alive past page unload. Use for terminal
648
+ * flushes (pagehide / visibilitychange→hidden / beforeunload).
610
649
  */
611
650
  async flush(options = {}) {
612
- 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
+ }
613
666
  this.cancelTimerIfSet();
614
667
  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
668
  try {
621
669
  const env = this.cfg.envelope();
622
670
  const result = await this.cfg.http.request("POST", "/events", {
@@ -635,20 +683,33 @@ var EventQueue = class {
635
683
  this.lastFlushAt = Date.now();
636
684
  this.lastError = null;
637
685
  this.inFlight -= batch.length;
686
+ this.pendingBatch = null;
687
+ this.pendingBatchId = null;
638
688
  this.retry.recordSuccess();
639
- this.persistent?.save(this.buffer);
689
+ this.persistAll();
640
690
  if (!this.firstFlushFired) {
641
691
  this.firstFlushFired = true;
642
692
  this.cfg.onFirstFlushSuccess?.();
643
693
  }
644
694
  return result;
645
695
  } catch (err) {
646
- this.buffer.unshift(...batch);
647
- this.inFlight -= batch.length;
648
696
  const message = err instanceof Error ? err.message : String(err);
649
697
  this.lastError = message;
650
- this.persistent?.save(this.buffer);
651
- 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
+ }
652
713
  const retryAfterMs = extractRetryAfterMs(err);
653
714
  const delay = this.retry.nextDelay(retryAfterMs);
654
715
  this.scheduleRetry(delay);
@@ -666,6 +727,8 @@ var EventQueue = class {
666
727
  this.cancelTimerIfSet();
667
728
  this.nextRetryAt = null;
668
729
  this.buffer = [];
730
+ this.pendingBatch = null;
731
+ this.pendingBatchId = null;
669
732
  this.dropped = 0;
670
733
  this.inFlight = 0;
671
734
  this.lastError = null;
@@ -675,6 +738,10 @@ var EventQueue = class {
675
738
  }
676
739
  getStats() {
677
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".
678
745
  buffered: this.buffer.length,
679
746
  dropped: this.dropped,
680
747
  inFlight: this.inFlight,
@@ -684,6 +751,29 @@ var EventQueue = class {
684
751
  nextRetryAt: this.nextRetryAt
685
752
  };
686
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
+ }
687
777
  // ---------- internal scheduling ----------
688
778
  scheduleIdleFlush() {
689
779
  this.cancelTimerIfSet();
@@ -717,6 +807,14 @@ function extractRetryAfterMs(err) {
717
807
  }
718
808
  return void 0;
719
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
+ }
720
818
  function defaultScheduler(fn, ms) {
721
819
  const id = setTimeout(fn, ms);
722
820
  if (typeof id.unref === "function") {
@@ -1058,6 +1156,7 @@ var AutoTracker = class {
1058
1156
  /** Exposed for tests + consumers that want to reset the session manually. */
1059
1157
  resetSession() {
1060
1158
  if (this.session && !this.session.endedSent) this.emitSessionEnd();
1159
+ this.pageviewId = null;
1061
1160
  this.session = this.startNewSession();
1062
1161
  this.emitSessionStart();
1063
1162
  }
@@ -1094,6 +1193,7 @@ var AutoTracker = class {
1094
1193
  const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
1095
1194
  if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
1096
1195
  this.emitSessionEnd();
1196
+ this.pageviewId = null;
1097
1197
  this.session = this.startNewSession();
1098
1198
  this.emitSessionStart();
1099
1199
  } else {
@@ -1467,7 +1567,7 @@ function validateEventProperties(input, options = {}) {
1467
1567
  const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1468
1568
  const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1469
1569
  const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1470
- const seen = /* @__PURE__ */ new WeakSet();
1570
+ const seen = /* @__PURE__ */ new Set();
1471
1571
  const visit = (value, key, depth) => {
1472
1572
  if (depth > maxDepth) {
1473
1573
  warnings.push({ kind: "depth_exceeded", key });
@@ -1555,6 +1655,7 @@ function validateEventProperties(input, options = {}) {
1555
1655
  const result = visit(value[i], `${key}[${i}]`, depth + 1);
1556
1656
  if (result.keep) out.push(result.value);
1557
1657
  }
1658
+ seen.delete(value);
1558
1659
  return { keep: true, value: out };
1559
1660
  }
1560
1661
  if (t === "object") {
@@ -1569,6 +1670,7 @@ function validateEventProperties(input, options = {}) {
1569
1670
  const result = visit(obj[k], `${key}.${k}`, depth + 1);
1570
1671
  if (result.keep) out[k] = result.value;
1571
1672
  }
1673
+ seen.delete(obj);
1572
1674
  return { keep: true, value: out };
1573
1675
  }
1574
1676
  warnings.push({ kind: "non_serialisable", key });
@@ -1911,35 +2013,27 @@ var ConsentManager = class {
1911
2013
  };
1912
2014
  var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1913
2015
  var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1914
- var REPLACEMENT_EMAIL = "[email]";
1915
- var REPLACEMENT_CARD = "[card]";
2016
+ var REPLACEMENT_EMAIL = "<email>";
2017
+ var REPLACEMENT_CARD = "<card>";
1916
2018
  function scrubPii(value) {
1917
2019
  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;
2020
+ return value.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL).replace(CARD_PATTERN, REPLACEMENT_CARD);
1928
2021
  }
1929
2022
  function scrubPiiFromProperties(properties) {
1930
2023
  const out = {};
1931
2024
  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
- }
2025
+ out[k] = scrubValue(properties[k]);
1940
2026
  }
1941
2027
  return out;
1942
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
+ }
1943
2037
 
1944
2038
  // src/breadcrumbs.ts
1945
2039
  var BreadcrumbBuffer = class {
@@ -2229,16 +2323,18 @@ var ErrorTracker = class {
2229
2323
  const url = typeof input === "string" ? input : input?.url ?? "";
2230
2324
  const method = (init.method || "GET").toUpperCase();
2231
2325
  const start = Date.now();
2232
- this.opts.breadcrumbs.add({
2233
- timestamp: start,
2234
- category: "http",
2235
- message: `${method} ${url}`,
2236
- data: { url, method }
2237
- });
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
+ }
2238
2334
  try {
2239
2335
  const response = await origFetch(...args);
2240
2336
  if (response.status >= 500 && this.opts.isConsented()) {
2241
- if (!url.includes("api.cross-deck.com")) {
2337
+ if (!isSelfRequest(url, this.opts.selfHostname)) {
2242
2338
  this.captureHttp({
2243
2339
  url,
2244
2340
  method,
@@ -2287,7 +2383,7 @@ var ErrorTracker = class {
2287
2383
  try {
2288
2384
  if (xhr.status >= 500 && tracker.opts.isConsented()) {
2289
2385
  const url = xhr._cdUrl ?? "";
2290
- if (!url.includes("api.cross-deck.com")) {
2386
+ if (!isSelfRequest(url, tracker.opts.selfHostname)) {
2291
2387
  tracker.captureHttp({
2292
2388
  url,
2293
2389
  method: (xhr._cdMethod ?? "GET").toUpperCase(),
@@ -2447,9 +2543,10 @@ var ErrorTracker = class {
2447
2543
  if (!this.passesSample(err)) return;
2448
2544
  if (!this.passesRateLimit(err)) return;
2449
2545
  let finalErr = err;
2450
- if (this.opts.beforeSend) {
2546
+ const hook = this.opts.beforeSend?.();
2547
+ if (hook) {
2451
2548
  try {
2452
- finalErr = this.opts.beforeSend(err);
2549
+ finalErr = hook(err);
2453
2550
  } catch {
2454
2551
  finalErr = err;
2455
2552
  }
@@ -2631,6 +2728,22 @@ function safeClone(v) {
2631
2728
  function safeStringify2(v) {
2632
2729
  return coerceErrorPayload(v).message;
2633
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
+ }
2634
2747
 
2635
2748
  // src/crossdeck.ts
2636
2749
  var CrossdeckClient = class {
@@ -2647,6 +2760,24 @@ var CrossdeckClient = class {
2647
2760
  * mismatched env fails fast at boot rather than at first event-flush.
2648
2761
  */
2649
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
+ }
2650
2781
  if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
2651
2782
  throw new CrossdeckError({
2652
2783
  type: "configuration_error",
@@ -2754,6 +2885,15 @@ var CrossdeckClient = class {
2754
2885
  `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
2755
2886
  { ...info }
2756
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
+ );
2757
2897
  }
2758
2898
  });
2759
2899
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
@@ -2820,8 +2960,20 @@ var CrossdeckClient = class {
2820
2960
  report: (err) => this.reportError(err),
2821
2961
  getContext: () => ({ ...this.state.errorContext }),
2822
2962
  getTags: () => ({ ...this.state.errorTags }),
2823
- beforeSend: this.state.errorBeforeSend,
2824
- 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)
2825
2977
  });
2826
2978
  this.state.errors = tracker;
2827
2979
  tracker.install();
@@ -2904,7 +3056,8 @@ var CrossdeckClient = class {
2904
3056
  body
2905
3057
  });
2906
3058
  const priorCdcust = s.identity.crossdeckCustomerId;
2907
- 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) {
2908
3061
  s.entitlements.clear();
2909
3062
  }
2910
3063
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
@@ -3346,8 +3499,9 @@ var CrossdeckClient = class {
3346
3499
  message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
3347
3500
  });
3348
3501
  }
3502
+ const rail = input.rail ?? "apple";
3349
3503
  const result = await s.http.request("POST", "/purchases/sync", {
3350
- body: { rail: input.rail ?? "apple", ...input }
3504
+ body: { ...input, rail }
3351
3505
  });
3352
3506
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
3353
3507
  s.entitlements.setFromList(result.entitlements);
@@ -3413,6 +3567,8 @@ var CrossdeckClient = class {
3413
3567
  this.state.errorContext = {};
3414
3568
  this.state.errorTags = {};
3415
3569
  this.state.developerUserId = null;
3570
+ this.state.lastServerTime = null;
3571
+ this.state.lastClientTime = null;
3416
3572
  if (this.state.autoTracker) {
3417
3573
  const tracker = new AutoTracker(
3418
3574
  this.state.options.autoTrack,
@@ -3541,7 +3697,9 @@ function isLocalHostname() {
3541
3697
  const hostname = w?.location?.hostname;
3542
3698
  if (!hostname) return false;
3543
3699
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
3700
+ if (hostname === "0.0.0.0") return true;
3544
3701
  if (hostname === "::1" || hostname === "[::1]") return true;
3702
+ if (/^\[?fe80::/i.test(hostname)) return true;
3545
3703
  if (hostname.endsWith(".local")) return true;
3546
3704
  if (/^10\./.test(hostname)) return true;
3547
3705
  if (/^192\.168\./.test(hostname)) return true;
@@ -3590,6 +3748,39 @@ function installUnloadFlush(onUnload) {
3590
3748
  }
3591
3749
 
3592
3750
  // src/react.ts
3751
+ var _moduleInitDone = false;
3752
+ function CrossdeckProvider(props) {
3753
+ const { userId, children, ...initOptions } = props;
3754
+ const lastUserIdRef = (0, import_react.useRef)(void 0);
3755
+ (0, import_react.useEffect)(() => {
3756
+ if (_moduleInitDone) return;
3757
+ try {
3758
+ Crossdeck.init(initOptions);
3759
+ _moduleInitDone = true;
3760
+ } catch (err) {
3761
+ if (typeof console !== "undefined") {
3762
+ console.error("[CrossdeckProvider] init failed:", err);
3763
+ }
3764
+ }
3765
+ }, []);
3766
+ (0, import_react.useEffect)(() => {
3767
+ if (!_moduleInitDone) return;
3768
+ if (lastUserIdRef.current === userId) return;
3769
+ lastUserIdRef.current = userId;
3770
+ try {
3771
+ if (userId) {
3772
+ void Crossdeck.identify(userId);
3773
+ } else {
3774
+ Crossdeck.reset();
3775
+ }
3776
+ } catch (err) {
3777
+ if (typeof console !== "undefined") {
3778
+ console.error("[CrossdeckProvider] identity sync failed:", err);
3779
+ }
3780
+ }
3781
+ }, [userId]);
3782
+ return children;
3783
+ }
3593
3784
  function useEntitlement(key) {
3594
3785
  const [isEntitled, setIsEntitled] = (0, import_react.useState)(() => safeIsEntitled(key));
3595
3786
  (0, import_react.useEffect)(() => {
@@ -3640,6 +3831,7 @@ function safeListKeys() {
3640
3831
  }
3641
3832
  // Annotate the CommonJS export names for ESM import in node:
3642
3833
  0 && (module.exports = {
3834
+ CrossdeckProvider,
3643
3835
  useEntitlement,
3644
3836
  useEntitlements
3645
3837
  });