@cross-deck/web 0.7.0 → 0.10.0

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/index.cjs CHANGED
@@ -20,13 +20,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ CROSSDECK_ERROR_CODES: () => CROSSDECK_ERROR_CODES,
23
24
  Crossdeck: () => Crossdeck,
24
25
  CrossdeckClient: () => CrossdeckClient,
25
26
  CrossdeckError: () => CrossdeckError,
26
27
  DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
27
28
  MemoryStorage: () => MemoryStorage,
28
29
  SDK_NAME: () => SDK_NAME,
29
- SDK_VERSION: () => SDK_VERSION
30
+ SDK_VERSION: () => SDK_VERSION,
31
+ getErrorCode: () => getErrorCode
30
32
  });
31
33
  module.exports = __toCommonJS(index_exports);
32
34
 
@@ -39,11 +41,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
39
41
  this.code = payload.code;
40
42
  this.requestId = payload.requestId;
41
43
  this.status = payload.status;
44
+ this.retryAfterMs = payload.retryAfterMs;
42
45
  Object.setPrototypeOf(this, _CrossdeckError.prototype);
43
46
  }
44
47
  };
45
48
  async function crossdeckErrorFromResponse(res) {
46
49
  const requestId = res.headers.get("x-request-id") ?? void 0;
50
+ const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
47
51
  let body;
48
52
  try {
49
53
  body = await res.json();
@@ -57,7 +61,8 @@ async function crossdeckErrorFromResponse(res) {
57
61
  code: envelope.code,
58
62
  message: envelope.message ?? `HTTP ${res.status}`,
59
63
  requestId: envelope.request_id ?? requestId,
60
- status: res.status
64
+ status: res.status,
65
+ retryAfterMs
61
66
  });
62
67
  }
63
68
  return new CrossdeckError({
@@ -65,9 +70,25 @@ async function crossdeckErrorFromResponse(res) {
65
70
  code: `http_${res.status}`,
66
71
  message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
67
72
  requestId,
68
- status: res.status
73
+ status: res.status,
74
+ retryAfterMs
69
75
  });
70
76
  }
77
+ function parseRetryAfterHeader(value) {
78
+ if (!value) return void 0;
79
+ const trimmed = value.trim();
80
+ if (!trimmed) return void 0;
81
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
82
+ const secs = Number(trimmed);
83
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
84
+ return Math.round(secs * 1e3);
85
+ }
86
+ if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
87
+ const target = Date.parse(trimmed);
88
+ if (!Number.isFinite(target)) return void 0;
89
+ const delta = target - Date.now();
90
+ return delta > 0 ? delta : 0;
91
+ }
71
92
  function typeMapForStatus(status) {
72
93
  if (status === 401) return "authentication_error";
73
94
  if (status === 403) return "permission_error";
@@ -78,8 +99,9 @@ function typeMapForStatus(status) {
78
99
 
79
100
  // src/http.ts
80
101
  var SDK_NAME = "@cross-deck/web";
81
- var SDK_VERSION = "0.6.0";
102
+ var SDK_VERSION = "0.10.0";
82
103
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
104
+ var DEFAULT_TIMEOUT_MS = 15e3;
83
105
  var HttpClient = class {
84
106
  constructor(config) {
85
107
  this.config = config;
@@ -103,25 +125,38 @@ var HttpClient = class {
103
125
  "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
104
126
  Accept: "application/json"
105
127
  };
128
+ if (options.idempotencyKey) {
129
+ headers["Idempotency-Key"] = options.idempotencyKey;
130
+ }
106
131
  let bodyInit;
107
132
  if (options.body !== void 0) {
108
133
  headers["Content-Type"] = "application/json";
109
134
  bodyInit = JSON.stringify(options.body);
110
135
  }
136
+ const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
137
+ const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
138
+ let timeoutHandle = null;
139
+ if (controller && effectiveTimeout > 0) {
140
+ timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
141
+ }
111
142
  let response;
112
143
  try {
113
144
  response = await fetch(url, {
114
145
  method,
115
146
  headers,
116
147
  body: bodyInit,
117
- keepalive: options.keepalive === true
148
+ keepalive: options.keepalive === true,
149
+ signal: controller?.signal
118
150
  });
119
151
  } catch (err) {
152
+ const aborted = controller?.signal?.aborted === true;
120
153
  throw new CrossdeckError({
121
154
  type: "network_error",
122
- code: "fetch_failed",
123
- message: err instanceof Error ? err.message : "fetch failed"
155
+ code: aborted ? "request_timeout" : "fetch_failed",
156
+ message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
124
157
  });
158
+ } finally {
159
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
125
160
  }
126
161
  if (!response.ok) {
127
162
  throw await crossdeckErrorFromResponse(response);
@@ -318,6 +353,7 @@ var EntitlementCache = class {
318
353
  this.all = [];
319
354
  this.lastUpdated = 0;
320
355
  this.listeners = /* @__PURE__ */ new Set();
356
+ this.listenerErrorCount = 0;
321
357
  }
322
358
  /** Sync read — true iff the entitlement key is currently active. */
323
359
  isEntitled(key) {
@@ -331,6 +367,15 @@ var EntitlementCache = class {
331
367
  get freshness() {
332
368
  return this.lastUpdated;
333
369
  }
370
+ /**
371
+ * Cumulative count of listener invocations that threw. Listener errors
372
+ * are swallowed (a buggy consumer must not crash the SDK) but the
373
+ * counter lets diagnostics() surface "you have a broken subscriber"
374
+ * without putting the developer in a debug session.
375
+ */
376
+ get listenerErrors() {
377
+ return this.listenerErrorCount;
378
+ }
334
379
  /**
335
380
  * Replace the cache with a fresh server response. The backend already
336
381
  * filters to active + env-matching, so we don't re-filter — just trust
@@ -384,11 +429,54 @@ var EntitlementCache = class {
384
429
  try {
385
430
  listener(snapshot);
386
431
  } catch {
432
+ this.listenerErrorCount += 1;
387
433
  }
388
434
  }
389
435
  }
390
436
  };
391
437
 
438
+ // src/retry-policy.ts
439
+ var DEFAULT_BASE = 1e3;
440
+ var DEFAULT_MAX = 6e4;
441
+ var DEFAULT_FACTOR = 2;
442
+ var DEFAULT_WARN = 8;
443
+ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
444
+ const base = options.baseMs ?? DEFAULT_BASE;
445
+ const max = options.maxMs ?? DEFAULT_MAX;
446
+ const factor = options.factor ?? DEFAULT_FACTOR;
447
+ const safeAttempts = Math.min(attempts, 30);
448
+ const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
449
+ const jittered = ceiling * random();
450
+ if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
451
+ return Math.min(max, retryAfterMs);
452
+ }
453
+ return Math.max(0, Math.round(jittered));
454
+ }
455
+ var RetryPolicy = class {
456
+ constructor(options = {}) {
457
+ this.options = options;
458
+ this.attempts = 0;
459
+ }
460
+ /** How many consecutive failures since the last success. */
461
+ get consecutiveFailures() {
462
+ return this.attempts;
463
+ }
464
+ /** Whether we've crossed the failuresBeforeWarn threshold. */
465
+ get isWarning() {
466
+ return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
467
+ }
468
+ /** Schedule-time delay for the NEXT retry. Increments the counter. */
469
+ nextDelay(retryAfterMs, random = Math.random) {
470
+ const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
471
+ this.attempts += 1;
472
+ return delay;
473
+ }
474
+ /** Mark a successful flush — reset the counter. */
475
+ recordSuccess() {
476
+ this.attempts = 0;
477
+ }
478
+ };
479
+
392
480
  // src/event-queue.ts
393
481
  var HARD_BUFFER_CAP = 1e3;
394
482
  var EventQueue = class {
@@ -401,6 +489,22 @@ var EventQueue = class {
401
489
  this.lastError = null;
402
490
  this.cancelTimer = null;
403
491
  this.firstFlushFired = false;
492
+ this.nextRetryAt = null;
493
+ this.retry = new RetryPolicy(cfg.retry ?? {});
494
+ this.persistent = cfg.persistentStore ?? null;
495
+ if (this.persistent) {
496
+ const restored = this.persistent.load();
497
+ if (restored.length > 0) {
498
+ if (restored.length > HARD_BUFFER_CAP) {
499
+ this.dropped += restored.length - HARD_BUFFER_CAP;
500
+ this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
501
+ } else {
502
+ this.buffer = restored;
503
+ }
504
+ this.cfg.onBufferChange?.(this.buffer.length);
505
+ this.scheduleIdleFlush();
506
+ }
507
+ }
404
508
  }
405
509
  enqueue(event) {
406
510
  this.buffer.push(event);
@@ -410,6 +514,8 @@ var EventQueue = class {
410
514
  this.dropped += overflow;
411
515
  this.cfg.onDrop?.(overflow);
412
516
  }
517
+ this.cfg.onBufferChange?.(this.buffer.length);
518
+ this.persistent?.save(this.buffer);
413
519
  if (this.buffer.length >= this.cfg.batchSize) {
414
520
  void this.flush();
415
521
  } else {
@@ -419,7 +525,7 @@ var EventQueue = class {
419
525
  /**
420
526
  * Flush the buffer to /v1/events. Resolves when the network call
421
527
  * completes (success or failure). On failure, events stay in the
422
- * buffer for the next flush attempt.
528
+ * buffer for the next scheduled retry.
423
529
  *
424
530
  * `options.keepalive` marks the underlying fetch as keepalive so the
425
531
  * browser keeps the request alive past page unload. Use this for
@@ -428,25 +534,32 @@ var EventQueue = class {
428
534
  async flush(options = {}) {
429
535
  if (this.buffer.length === 0) return null;
430
536
  this.cancelTimerIfSet();
537
+ this.nextRetryAt = null;
431
538
  const batch = this.buffer.splice(0);
539
+ const batchId = this.mintBatchId();
432
540
  this.inFlight += batch.length;
541
+ this.persistent?.save(this.buffer);
542
+ this.cfg.onBufferChange?.(this.buffer.length);
433
543
  try {
434
544
  const env = this.cfg.envelope();
435
545
  const result = await this.cfg.http.request("POST", "/events", {
436
546
  body: {
437
547
  // NorthStar §13.1 batch envelope. The backend validates these
438
- // against the API-key-resolved app and rejects mismatches loudly
439
- // (env_mismatch).
548
+ // against the API-key-resolved app and rejects mismatches
549
+ // loudly (env_mismatch).
440
550
  appId: env.appId,
441
551
  environment: env.environment,
442
552
  sdk: env.sdk,
443
553
  events: batch
444
554
  },
445
- keepalive: options.keepalive === true
555
+ keepalive: options.keepalive === true,
556
+ idempotencyKey: batchId
446
557
  });
447
558
  this.lastFlushAt = Date.now();
448
559
  this.lastError = null;
449
560
  this.inFlight -= batch.length;
561
+ this.retry.recordSuccess();
562
+ this.persistent?.save(this.buffer);
450
563
  if (!this.firstFlushFired) {
451
564
  this.firstFlushFired = true;
452
565
  this.cfg.onFirstFlushSuccess?.();
@@ -455,18 +568,33 @@ var EventQueue = class {
455
568
  } catch (err) {
456
569
  this.buffer.unshift(...batch);
457
570
  this.inFlight -= batch.length;
458
- this.lastError = err instanceof Error ? err.message : String(err);
459
- this.scheduleIdleFlush();
571
+ const message = err instanceof Error ? err.message : String(err);
572
+ this.lastError = message;
573
+ this.persistent?.save(this.buffer);
574
+ this.cfg.onBufferChange?.(this.buffer.length);
575
+ const retryAfterMs = extractRetryAfterMs(err);
576
+ const delay = this.retry.nextDelay(retryAfterMs);
577
+ this.scheduleRetry(delay);
578
+ this.cfg.onRetryScheduled?.({
579
+ delayMs: delay,
580
+ consecutiveFailures: this.retry.consecutiveFailures,
581
+ retryAfterMs,
582
+ lastError: message
583
+ });
460
584
  return null;
461
585
  }
462
586
  }
463
- /** Cancel any pending timer and clear in-memory state. */
587
+ /** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
464
588
  reset() {
465
589
  this.cancelTimerIfSet();
590
+ this.nextRetryAt = null;
466
591
  this.buffer = [];
467
592
  this.dropped = 0;
468
593
  this.inFlight = 0;
469
594
  this.lastError = null;
595
+ this.retry.recordSuccess();
596
+ this.persistent?.clear();
597
+ this.cfg.onBufferChange?.(0);
470
598
  }
471
599
  getStats() {
472
600
  return {
@@ -474,9 +602,12 @@ var EventQueue = class {
474
602
  dropped: this.dropped,
475
603
  inFlight: this.inFlight,
476
604
  lastFlushAt: this.lastFlushAt,
477
- lastError: this.lastError
605
+ lastError: this.lastError,
606
+ consecutiveFailures: this.retry.consecutiveFailures,
607
+ nextRetryAt: this.nextRetryAt
478
608
  };
479
609
  }
610
+ // ---------- internal scheduling ----------
480
611
  scheduleIdleFlush() {
481
612
  this.cancelTimerIfSet();
482
613
  const sched = this.cfg.scheduler ?? defaultScheduler;
@@ -484,13 +615,31 @@ var EventQueue = class {
484
615
  void this.flush();
485
616
  }, this.cfg.intervalMs);
486
617
  }
618
+ scheduleRetry(delayMs) {
619
+ this.cancelTimerIfSet();
620
+ this.nextRetryAt = Date.now() + delayMs;
621
+ const sched = this.cfg.scheduler ?? defaultScheduler;
622
+ this.cancelTimer = sched(() => {
623
+ void this.flush();
624
+ }, delayMs);
625
+ }
487
626
  cancelTimerIfSet() {
488
627
  if (this.cancelTimer) {
489
628
  this.cancelTimer();
490
629
  this.cancelTimer = null;
491
630
  }
492
631
  }
632
+ mintBatchId() {
633
+ return `batch_${Date.now().toString(36)}${randomChars(10)}`;
634
+ }
493
635
  };
636
+ function extractRetryAfterMs(err) {
637
+ if (err && typeof err === "object" && "retryAfterMs" in err) {
638
+ const v = err.retryAfterMs;
639
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
640
+ }
641
+ return void 0;
642
+ }
494
643
  function defaultScheduler(fn, ms) {
495
644
  const id = setTimeout(fn, ms);
496
645
  if (typeof id.unref === "function") {
@@ -502,6 +651,87 @@ function defaultScheduler(fn, ms) {
502
651
  return () => clearTimeout(id);
503
652
  }
504
653
 
654
+ // src/event-storage.ts
655
+ var PersistentEventStore = class {
656
+ constructor(options) {
657
+ this.options = options;
658
+ this.writeScheduled = false;
659
+ // Pending events captured on the most recent write request. We keep
660
+ // the latest snapshot ref so a debounced write always picks up the
661
+ // freshest buffer state.
662
+ this.pendingSnapshot = null;
663
+ this.key = `${options.prefix}queue.v1`;
664
+ }
665
+ /**
666
+ * Read the persisted queue on boot. Returns an empty array (with no
667
+ * warning) when nothing is stored, the blob is malformed, or storage
668
+ * is unavailable. Caller is responsible for treating duplicates from
669
+ * the persisted queue as the SAME events (eventId-based dedup).
670
+ */
671
+ load() {
672
+ let raw;
673
+ try {
674
+ raw = this.options.storage.getItem(this.key);
675
+ } catch {
676
+ return [];
677
+ }
678
+ if (!raw) return [];
679
+ try {
680
+ const parsed = JSON.parse(raw);
681
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
682
+ return [];
683
+ }
684
+ return parsed.events;
685
+ } catch {
686
+ return [];
687
+ }
688
+ }
689
+ /**
690
+ * Schedule a write of the current buffer. Debounced via microtask so
691
+ * a burst of enqueue() calls coalesces into one persistence write.
692
+ * Writes are best-effort: if storage throws (quota, private mode),
693
+ * we swallow and rely on the in-memory buffer.
694
+ */
695
+ save(snapshot) {
696
+ this.pendingSnapshot = snapshot.slice();
697
+ if (this.writeScheduled) return;
698
+ this.writeScheduled = true;
699
+ queueMicrotask(() => this.flushWrite());
700
+ }
701
+ /** Synchronous variant for terminal flushes (pagehide / beforeunload). */
702
+ saveSync(snapshot) {
703
+ this.pendingSnapshot = snapshot.slice();
704
+ this.flushWrite();
705
+ }
706
+ /** Wipe the persisted blob. Used by reset() (logout). */
707
+ clear() {
708
+ this.pendingSnapshot = null;
709
+ this.writeScheduled = false;
710
+ try {
711
+ this.options.storage.removeItem(this.key);
712
+ } catch {
713
+ }
714
+ }
715
+ flushWrite() {
716
+ this.writeScheduled = false;
717
+ const snapshot = this.pendingSnapshot;
718
+ this.pendingSnapshot = null;
719
+ if (snapshot === null) return;
720
+ if (snapshot.length === 0) {
721
+ try {
722
+ this.options.storage.removeItem(this.key);
723
+ } catch {
724
+ }
725
+ return;
726
+ }
727
+ const blob = { version: 1, events: snapshot };
728
+ try {
729
+ this.options.storage.setItem(this.key, JSON.stringify(blob));
730
+ } catch {
731
+ }
732
+ }
733
+ };
734
+
505
735
  // src/storage.ts
506
736
  var MemoryStorage = class {
507
737
  constructor() {
@@ -692,7 +922,8 @@ var DEFAULT_AUTO_TRACK = {
692
922
  sessions: true,
693
923
  pageViews: true,
694
924
  deviceInfo: true,
695
- clicks: true
925
+ clicks: true,
926
+ webVitals: true
696
927
  };
697
928
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
698
929
  var EMPTY_ACQUISITION = {
@@ -701,7 +932,13 @@ var EMPTY_ACQUISITION = {
701
932
  utm_campaign: "",
702
933
  utm_content: "",
703
934
  utm_term: "",
704
- referrer: ""
935
+ referrer: "",
936
+ gclid: "",
937
+ fbclid: "",
938
+ msclkid: "",
939
+ ttclid: "",
940
+ li_fat_id: "",
941
+ twclid: ""
705
942
  };
706
943
  var AutoTracker = class {
707
944
  constructor(cfg, track) {
@@ -709,6 +946,17 @@ var AutoTracker = class {
709
946
  this.track = track;
710
947
  this.session = null;
711
948
  this.cleanups = [];
949
+ /**
950
+ * Stable per-page-view identifier. Minted at every `page.viewed`
951
+ * emission and attached to every subsequent event until the next
952
+ * `page.viewed`. Lets dashboards correlate "user clicked X" to
953
+ * "user viewed page Y" without timestamp arithmetic — the canonical
954
+ * Mixpanel `$current_url` / Segment `pageId` pattern.
955
+ *
956
+ * Null until the first `page.viewed` fires (which happens at SDK
957
+ * install if `autoTrack.pageViews !== false`).
958
+ */
959
+ this.pageviewId = null;
712
960
  }
713
961
  install() {
714
962
  if (!isBrowserSafe()) return;
@@ -739,6 +987,10 @@ var AutoTracker = class {
739
987
  get currentSessionId() {
740
988
  return this.session?.sessionId ?? null;
741
989
  }
990
+ /** Stable per-page-view ID. Null before the first page.viewed has fired. */
991
+ get currentPageviewId() {
992
+ return this.pageviewId;
993
+ }
742
994
  /**
743
995
  * Per-session acquisition context — utm_* + referrer, captured once
744
996
  * at session start. Returns empty strings when there's no session
@@ -819,7 +1071,9 @@ var AutoTracker = class {
819
1071
  if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
820
1072
  lastFiredAt = now;
821
1073
  lastFiredUrl = url;
1074
+ this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
822
1075
  this.track("page.viewed", {
1076
+ pageviewId: this.pageviewId,
823
1077
  path: loc.pathname,
824
1078
  url,
825
1079
  search: loc.search || void 0,
@@ -1023,6 +1277,12 @@ function captureAcquisition() {
1023
1277
  result.utm_campaign = params.get("utm_campaign") ?? "";
1024
1278
  result.utm_content = params.get("utm_content") ?? "";
1025
1279
  result.utm_term = params.get("utm_term") ?? "";
1280
+ result.gclid = params.get("gclid") ?? "";
1281
+ result.fbclid = params.get("fbclid") ?? "";
1282
+ result.msclkid = params.get("msclkid") ?? "";
1283
+ result.ttclid = params.get("ttclid") ?? "";
1284
+ result.li_fat_id = params.get("li_fat_id") ?? "";
1285
+ result.twclid = params.get("twclid") ?? "";
1026
1286
  } catch {
1027
1287
  }
1028
1288
  try {
@@ -1080,6 +1340,490 @@ function safeJson(obj) {
1080
1340
  }
1081
1341
  }
1082
1342
 
1343
+ // src/event-validation.ts
1344
+ var DEFAULT_MAX_STRING = 1024;
1345
+ var DEFAULT_MAX_BYTES = 8 * 1024;
1346
+ var DEFAULT_MAX_DEPTH = 5;
1347
+ function validateEventProperties(input, options = {}) {
1348
+ const warnings = [];
1349
+ if (!input) return { properties: {}, warnings };
1350
+ const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1351
+ const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1352
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1353
+ const seen = /* @__PURE__ */ new WeakSet();
1354
+ const visit = (value, key, depth) => {
1355
+ if (depth > maxDepth) {
1356
+ warnings.push({ kind: "depth_exceeded", key });
1357
+ return { keep: true, value: "[depth-exceeded]" };
1358
+ }
1359
+ if (value === null) return { keep: true, value: null };
1360
+ const t = typeof value;
1361
+ if (t === "string") {
1362
+ const s = value;
1363
+ if (s.length > maxStringLength) {
1364
+ warnings.push({ kind: "truncated_string", key });
1365
+ return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
1366
+ }
1367
+ return { keep: true, value: s };
1368
+ }
1369
+ if (t === "number") {
1370
+ if (!Number.isFinite(value)) {
1371
+ warnings.push({ kind: "non_serialisable", key });
1372
+ return { keep: true, value: null };
1373
+ }
1374
+ return { keep: true, value };
1375
+ }
1376
+ if (t === "boolean") return { keep: true, value };
1377
+ if (t === "bigint") {
1378
+ warnings.push({ kind: "coerced_bigint", key });
1379
+ return { keep: true, value: value.toString() };
1380
+ }
1381
+ if (t === "function") {
1382
+ warnings.push({ kind: "dropped_function", key });
1383
+ return { keep: false, value: void 0 };
1384
+ }
1385
+ if (t === "symbol") {
1386
+ warnings.push({ kind: "dropped_symbol", key });
1387
+ return { keep: false, value: void 0 };
1388
+ }
1389
+ if (t === "undefined") {
1390
+ warnings.push({ kind: "dropped_undefined", key });
1391
+ return { keep: false, value: void 0 };
1392
+ }
1393
+ if (value instanceof Date) {
1394
+ warnings.push({ kind: "coerced_date", key });
1395
+ const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
1396
+ return { keep: true, value: iso };
1397
+ }
1398
+ if (value instanceof Error) {
1399
+ warnings.push({ kind: "coerced_error", key });
1400
+ return {
1401
+ keep: true,
1402
+ value: {
1403
+ name: value.name,
1404
+ message: value.message,
1405
+ stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
1406
+ }
1407
+ };
1408
+ }
1409
+ if (value instanceof Map) {
1410
+ warnings.push({ kind: "coerced_map", key });
1411
+ const obj = {};
1412
+ for (const [k, v] of value.entries()) {
1413
+ const subKey = typeof k === "string" ? k : String(k);
1414
+ const result = visit(v, `${key}.${subKey}`, depth + 1);
1415
+ if (result.keep) obj[subKey] = result.value;
1416
+ }
1417
+ return { keep: true, value: obj };
1418
+ }
1419
+ if (value instanceof Set) {
1420
+ warnings.push({ kind: "coerced_set", key });
1421
+ const arr = [];
1422
+ let i = 0;
1423
+ for (const v of value.values()) {
1424
+ const result = visit(v, `${key}[${i}]`, depth + 1);
1425
+ if (result.keep) arr.push(result.value);
1426
+ i++;
1427
+ }
1428
+ return { keep: true, value: arr };
1429
+ }
1430
+ if (Array.isArray(value)) {
1431
+ if (seen.has(value)) {
1432
+ warnings.push({ kind: "circular_reference", key });
1433
+ return { keep: true, value: "[circular]" };
1434
+ }
1435
+ seen.add(value);
1436
+ const out = [];
1437
+ for (let i = 0; i < value.length; i++) {
1438
+ const result = visit(value[i], `${key}[${i}]`, depth + 1);
1439
+ if (result.keep) out.push(result.value);
1440
+ }
1441
+ return { keep: true, value: out };
1442
+ }
1443
+ if (t === "object") {
1444
+ const obj = value;
1445
+ if (seen.has(obj)) {
1446
+ warnings.push({ kind: "circular_reference", key });
1447
+ return { keep: true, value: "[circular]" };
1448
+ }
1449
+ seen.add(obj);
1450
+ const out = {};
1451
+ for (const k of Object.keys(obj)) {
1452
+ const result = visit(obj[k], `${key}.${k}`, depth + 1);
1453
+ if (result.keep) out[k] = result.value;
1454
+ }
1455
+ return { keep: true, value: out };
1456
+ }
1457
+ warnings.push({ kind: "non_serialisable", key });
1458
+ try {
1459
+ return { keep: true, value: String(value) };
1460
+ } catch {
1461
+ return { keep: false, value: void 0 };
1462
+ }
1463
+ };
1464
+ const cleaned = {};
1465
+ for (const k of Object.keys(input)) {
1466
+ const result = visit(input[k], k, 0);
1467
+ if (result.keep) cleaned[k] = result.value;
1468
+ }
1469
+ const serialised = safeStringify(cleaned);
1470
+ if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
1471
+ warnings.push({ kind: "size_cap_exceeded", key: "*" });
1472
+ const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
1473
+ let currentSize = byteLength(serialised);
1474
+ for (const { k } of sizes) {
1475
+ if (currentSize <= maxBatchPropertyBytes) break;
1476
+ currentSize -= sizes.find((s) => s.k === k).size;
1477
+ delete cleaned[k];
1478
+ }
1479
+ cleaned.__truncated = true;
1480
+ }
1481
+ return { properties: cleaned, warnings };
1482
+ }
1483
+ function safeStringify(v) {
1484
+ try {
1485
+ return JSON.stringify(v) ?? null;
1486
+ } catch {
1487
+ return null;
1488
+ }
1489
+ }
1490
+ function byteLength(s) {
1491
+ if (typeof TextEncoder !== "undefined") {
1492
+ return new TextEncoder().encode(s).length;
1493
+ }
1494
+ return s.length * 4;
1495
+ }
1496
+
1497
+ // src/super-properties.ts
1498
+ var KEY_SUPER = "super_props";
1499
+ var KEY_GROUPS = "groups";
1500
+ var SuperPropertyStore = class {
1501
+ constructor(storage, prefix) {
1502
+ this.storage = storage;
1503
+ this.prefix = prefix;
1504
+ this.superProps = {};
1505
+ this.groups = {};
1506
+ this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
1507
+ this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
1508
+ }
1509
+ // ---------- super properties ----------
1510
+ /**
1511
+ * Merge new keys into the super-property bag. Returns a snapshot of
1512
+ * the resulting bag. Values that are `null` are deleted (Mixpanel
1513
+ * semantics — explicit null = "stop tracking this key").
1514
+ */
1515
+ register(props) {
1516
+ for (const [k, v] of Object.entries(props)) {
1517
+ if (v === null) {
1518
+ delete this.superProps[k];
1519
+ } else if (v !== void 0) {
1520
+ this.superProps[k] = v;
1521
+ }
1522
+ }
1523
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1524
+ return { ...this.superProps };
1525
+ }
1526
+ /** Remove a single super-property key. Idempotent. */
1527
+ unregister(key) {
1528
+ if (key in this.superProps) {
1529
+ delete this.superProps[key];
1530
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1531
+ }
1532
+ }
1533
+ /** Snapshot of the current super-property bag. */
1534
+ getSuperProperties() {
1535
+ return { ...this.superProps };
1536
+ }
1537
+ // ---------- groups ----------
1538
+ /**
1539
+ * Set a group membership. Passing `id: null` clears the membership
1540
+ * for that group type — the SDK stops attaching it to events.
1541
+ */
1542
+ setGroup(type, id, traits) {
1543
+ if (id === null) {
1544
+ delete this.groups[type];
1545
+ } else {
1546
+ this.groups[type] = traits !== void 0 ? { id, traits } : { id };
1547
+ }
1548
+ writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
1549
+ }
1550
+ /**
1551
+ * Snapshot of the current groups map, keyed by group type. Returned
1552
+ * shape mirrors what the SDK attaches to every event as
1553
+ * `$groups.{type}`. The `traits` sub-object is the most-recent
1554
+ * traits payload passed to `setGroup` for that type; null when none.
1555
+ */
1556
+ getGroups() {
1557
+ return JSON.parse(JSON.stringify(this.groups));
1558
+ }
1559
+ /**
1560
+ * The flat `{ type: id }` projection used for event-attachment. Stable
1561
+ * for fast every-event merge — we don't want to JSON-clone on each
1562
+ * track() call.
1563
+ */
1564
+ getGroupIds() {
1565
+ const out = {};
1566
+ for (const [type, info] of Object.entries(this.groups)) {
1567
+ out[type] = info.id;
1568
+ }
1569
+ return out;
1570
+ }
1571
+ /** Wipe both bags. Called by Crossdeck.reset() (logout). */
1572
+ clear() {
1573
+ this.superProps = {};
1574
+ this.groups = {};
1575
+ try {
1576
+ this.storage.removeItem(this.prefix + KEY_SUPER);
1577
+ } catch {
1578
+ }
1579
+ try {
1580
+ this.storage.removeItem(this.prefix + KEY_GROUPS);
1581
+ } catch {
1582
+ }
1583
+ }
1584
+ };
1585
+ function readJson(storage, key) {
1586
+ let raw;
1587
+ try {
1588
+ raw = storage.getItem(key);
1589
+ } catch {
1590
+ return null;
1591
+ }
1592
+ if (!raw) return null;
1593
+ try {
1594
+ return JSON.parse(raw);
1595
+ } catch {
1596
+ return null;
1597
+ }
1598
+ }
1599
+ function writeJson(storage, key, value) {
1600
+ try {
1601
+ storage.setItem(key, JSON.stringify(value));
1602
+ } catch {
1603
+ }
1604
+ }
1605
+
1606
+ // src/web-vitals.ts
1607
+ var WebVitalsTracker = class {
1608
+ constructor(cfg, report) {
1609
+ this.cfg = cfg;
1610
+ this.report = report;
1611
+ this.observers = [];
1612
+ this.flushed = /* @__PURE__ */ new Set();
1613
+ this.cls = 0;
1614
+ this.clsEntries = [];
1615
+ this.inp = 0;
1616
+ this.cleanups = [];
1617
+ }
1618
+ install() {
1619
+ if (!this.cfg.enabled) return;
1620
+ if (typeof PerformanceObserver === "undefined") return;
1621
+ if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
1622
+ const doc = globalThis.document;
1623
+ try {
1624
+ const navObserver = new PerformanceObserver((list) => {
1625
+ for (const entry of list.getEntries()) {
1626
+ const e = entry;
1627
+ if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
1628
+ this.flushed.add("ttfb");
1629
+ this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
1630
+ }
1631
+ }
1632
+ });
1633
+ navObserver.observe({ type: "navigation", buffered: true });
1634
+ this.observers.push(navObserver);
1635
+ } catch {
1636
+ }
1637
+ try {
1638
+ const paintObserver = new PerformanceObserver((list) => {
1639
+ for (const entry of list.getEntries()) {
1640
+ if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
1641
+ this.flushed.add("fcp");
1642
+ this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
1643
+ }
1644
+ }
1645
+ });
1646
+ paintObserver.observe({ type: "paint", buffered: true });
1647
+ this.observers.push(paintObserver);
1648
+ } catch {
1649
+ }
1650
+ let lcpValue = 0;
1651
+ try {
1652
+ const lcpObserver = new PerformanceObserver((list) => {
1653
+ const entries = list.getEntries();
1654
+ const last = entries[entries.length - 1];
1655
+ if (last) lcpValue = last.startTime;
1656
+ });
1657
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
1658
+ this.observers.push(lcpObserver);
1659
+ } catch {
1660
+ }
1661
+ try {
1662
+ const clsObserver = new PerformanceObserver((list) => {
1663
+ for (const entry of list.getEntries()) {
1664
+ const e = entry;
1665
+ if (typeof e.value === "number" && !e.hadRecentInput) {
1666
+ this.cls += e.value;
1667
+ this.clsEntries.push(entry);
1668
+ }
1669
+ }
1670
+ });
1671
+ clsObserver.observe({ type: "layout-shift", buffered: true });
1672
+ this.observers.push(clsObserver);
1673
+ } catch {
1674
+ }
1675
+ try {
1676
+ const eventObserver = new PerformanceObserver((list) => {
1677
+ for (const entry of list.getEntries()) {
1678
+ const e = entry;
1679
+ if (e.interactionId && e.duration > this.inp) {
1680
+ this.inp = e.duration;
1681
+ }
1682
+ }
1683
+ });
1684
+ try {
1685
+ eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
1686
+ } catch {
1687
+ eventObserver.observe({ type: "first-input", buffered: true });
1688
+ }
1689
+ this.observers.push(eventObserver);
1690
+ } catch {
1691
+ }
1692
+ const flush = () => {
1693
+ if (lcpValue > 0 && !this.flushed.has("lcp")) {
1694
+ this.flushed.add("lcp");
1695
+ this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
1696
+ }
1697
+ if (this.cls > 0 && !this.flushed.has("cls")) {
1698
+ this.flushed.add("cls");
1699
+ this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
1700
+ }
1701
+ if (this.inp > 0 && !this.flushed.has("inp")) {
1702
+ this.flushed.add("inp");
1703
+ this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
1704
+ }
1705
+ };
1706
+ const onHidden = () => {
1707
+ if (doc.visibilityState === "hidden") flush();
1708
+ };
1709
+ doc.addEventListener("visibilitychange", onHidden);
1710
+ globalThis.window.addEventListener("pagehide", flush);
1711
+ this.cleanups.push(() => {
1712
+ doc.removeEventListener("visibilitychange", onHidden);
1713
+ globalThis.window.removeEventListener("pagehide", flush);
1714
+ });
1715
+ }
1716
+ uninstall() {
1717
+ for (const o of this.observers) {
1718
+ try {
1719
+ o.disconnect();
1720
+ } catch {
1721
+ }
1722
+ }
1723
+ this.observers = [];
1724
+ for (const fn of this.cleanups.splice(0)) {
1725
+ try {
1726
+ fn();
1727
+ } catch {
1728
+ }
1729
+ }
1730
+ }
1731
+ };
1732
+
1733
+ // src/consent.ts
1734
+ var ALL_GRANTED = {
1735
+ analytics: true,
1736
+ marketing: true,
1737
+ errors: true
1738
+ };
1739
+ var ConsentManager = class {
1740
+ constructor(options) {
1741
+ this.state = { ...ALL_GRANTED };
1742
+ this.dntDenied = false;
1743
+ if (options?.respectDnt && this.detectDnt()) {
1744
+ this.dntDenied = true;
1745
+ this.state = { analytics: false, marketing: false, errors: false };
1746
+ }
1747
+ }
1748
+ /**
1749
+ * Merge new dimensions onto the current state. Returns the resulting
1750
+ * snapshot. DNT-derived denies cannot be flipped back on by a `set`
1751
+ * call — once the browser says "don't track", we don't track even if
1752
+ * the developer code disagrees. That's the contract.
1753
+ */
1754
+ set(partial) {
1755
+ if (this.dntDenied) return { ...this.state };
1756
+ for (const k of Object.keys(partial)) {
1757
+ const v = partial[k];
1758
+ if (typeof v === "boolean") this.state[k] = v;
1759
+ }
1760
+ return { ...this.state };
1761
+ }
1762
+ /** Snapshot of the current state. */
1763
+ get() {
1764
+ return { ...this.state };
1765
+ }
1766
+ /** Convenience getters for hot paths. */
1767
+ get analytics() {
1768
+ return this.state.analytics;
1769
+ }
1770
+ get marketing() {
1771
+ return this.state.marketing;
1772
+ }
1773
+ get errors() {
1774
+ return this.state.errors;
1775
+ }
1776
+ /** True iff the constructor detected and applied DNT. */
1777
+ get isDntDenied() {
1778
+ return this.dntDenied;
1779
+ }
1780
+ detectDnt() {
1781
+ try {
1782
+ const nav = globalThis.navigator;
1783
+ if (!nav) return false;
1784
+ const sources = [
1785
+ nav.doNotTrack,
1786
+ nav.msDoNotTrack,
1787
+ globalThis.doNotTrack
1788
+ ];
1789
+ return sources.some((v) => v === "1" || v === "yes");
1790
+ } catch {
1791
+ return false;
1792
+ }
1793
+ }
1794
+ };
1795
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1796
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1797
+ var REPLACEMENT_EMAIL = "[email]";
1798
+ var REPLACEMENT_CARD = "[card]";
1799
+ function scrubPii(value) {
1800
+ if (!value) return value;
1801
+ let out = value;
1802
+ if (EMAIL_PATTERN.test(out)) {
1803
+ out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1804
+ }
1805
+ EMAIL_PATTERN.lastIndex = 0;
1806
+ if (CARD_PATTERN.test(out)) {
1807
+ out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1808
+ }
1809
+ CARD_PATTERN.lastIndex = 0;
1810
+ return out;
1811
+ }
1812
+ function scrubPiiFromProperties(properties) {
1813
+ const out = {};
1814
+ for (const k of Object.keys(properties)) {
1815
+ const v = properties[k];
1816
+ if (typeof v === "string") {
1817
+ out[k] = scrubPii(v);
1818
+ } else if (Array.isArray(v)) {
1819
+ out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1820
+ } else {
1821
+ out[k] = v;
1822
+ }
1823
+ }
1824
+ return out;
1825
+ }
1826
+
1083
1827
  // src/crossdeck.ts
1084
1828
  var CrossdeckClient = class {
1085
1829
  constructor() {
@@ -1169,6 +1913,13 @@ var CrossdeckClient = class {
1169
1913
  const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
1170
1914
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
1171
1915
  const entitlements = new EntitlementCache();
1916
+ const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
1917
+ if (persistentEvents) {
1918
+ debug.emit(
1919
+ "sdk.queue_restored",
1920
+ "Restored persisted event queue from a prior session."
1921
+ );
1922
+ }
1172
1923
  const events = new EventQueue({
1173
1924
  http,
1174
1925
  batchSize: opts.eventFlushBatchSize,
@@ -1178,26 +1929,51 @@ var CrossdeckClient = class {
1178
1929
  environment: opts.environment,
1179
1930
  sdk: { name: SDK_NAME, version: opts.sdkVersion }
1180
1931
  }),
1932
+ persistentStore: persistentEvents ?? void 0,
1181
1933
  onFirstFlushSuccess: () => {
1182
1934
  debug.emit(
1183
1935
  "sdk.first_event_sent",
1184
1936
  "First telemetry event received. View it in Live Events.",
1185
1937
  { appId: opts.appId, environment: opts.environment }
1186
1938
  );
1939
+ },
1940
+ onRetryScheduled: (info) => {
1941
+ debug.emit(
1942
+ "sdk.flush_retry_scheduled",
1943
+ `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
1944
+ { ...info }
1945
+ );
1187
1946
  }
1188
1947
  });
1189
1948
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
1949
+ const superProps = new SuperPropertyStore(
1950
+ persistIdentity ? effectiveStorage : new MemoryStorage(),
1951
+ opts.storagePrefix
1952
+ );
1953
+ const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
1954
+ if (consent.isDntDenied) {
1955
+ debug.emit(
1956
+ "sdk.consent_dnt_applied",
1957
+ "Do Not Track detected \u2014 all tracking dimensions denied at init."
1958
+ );
1959
+ }
1190
1960
  this.state = {
1191
1961
  http,
1192
1962
  identity,
1193
1963
  entitlements,
1194
1964
  events,
1195
1965
  autoTracker: null,
1966
+ webVitals: null,
1967
+ superProps,
1968
+ consent,
1969
+ scrubPii: options.scrubPii !== false,
1196
1970
  deviceInfo,
1197
1971
  options: opts,
1198
1972
  debug,
1199
1973
  developerUserId: null,
1200
- uninstallUnloadFlush: null
1974
+ uninstallUnloadFlush: null,
1975
+ lastServerTime: null,
1976
+ lastClientTime: null
1201
1977
  };
1202
1978
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
1203
1979
  appId: opts.appId,
@@ -1212,6 +1988,14 @@ var CrossdeckClient = class {
1212
1988
  this.state.autoTracker = tracker;
1213
1989
  tracker.install();
1214
1990
  }
1991
+ if (autoTrack.webVitals) {
1992
+ const vitals = new WebVitalsTracker(
1993
+ { enabled: true },
1994
+ (name, properties) => this.track(name, properties)
1995
+ );
1996
+ this.state.webVitals = vitals;
1997
+ vitals.install();
1998
+ }
1215
1999
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
1216
2000
  void this.flush({ keepalive: true }).catch(() => void 0);
1217
2001
  });
@@ -1235,8 +2019,19 @@ var CrossdeckClient = class {
1235
2019
  /**
1236
2020
  * Link the anonymous device to a developer-supplied user ID. Cache
1237
2021
  * the resolved Crossdeck customer for follow-up calls.
2022
+ *
2023
+ * v0.9.0+ accepts an optional `traits` bag — profile data (name,
2024
+ * plan, signupDate, role) persisted on the Crossdeck customer record
2025
+ * and queryable from dashboards. Traits are sanitised through the
2026
+ * same validator that gates `track()` properties, so a `{ avatar:
2027
+ * <File>, onSave: () => {} }` payload can't corrupt the alias call.
2028
+ *
2029
+ * Crossdeck.identify("user_847", {
2030
+ * email: "wes@pinet.co.za",
2031
+ * traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
2032
+ * });
1238
2033
  */
1239
- async identify(userId, _options) {
2034
+ async identify(userId, options) {
1240
2035
  const s = this.requireStarted();
1241
2036
  if (!userId) {
1242
2037
  throw new CrossdeckError({
@@ -1245,13 +2040,163 @@ var CrossdeckClient = class {
1245
2040
  message: "identify(userId) requires a non-empty userId."
1246
2041
  });
1247
2042
  }
2043
+ if (!s.consent.analytics) {
2044
+ s.debug.emit(
2045
+ "sdk.consent_denied",
2046
+ `identify() skipped \u2014 consent denied for analytics.`
2047
+ );
2048
+ return {
2049
+ object: "alias_result",
2050
+ crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
2051
+ linked: [],
2052
+ mergePending: false,
2053
+ env: s.options.environment
2054
+ };
2055
+ }
2056
+ const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
2057
+ const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
2058
+ if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
2059
+ for (const w of traitsValidation.warnings) {
2060
+ s.debug.emit(
2061
+ "sdk.property_coerced",
2062
+ `identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2063
+ { key: w.key, kind: w.kind }
2064
+ );
2065
+ }
2066
+ }
2067
+ const body = {
2068
+ userId,
2069
+ anonymousId: s.identity.anonymousId
2070
+ };
2071
+ if (options?.email) body.email = options.email;
2072
+ if (traits) body.traits = traits;
1248
2073
  const result = await s.http.request("POST", "/identity/alias", {
1249
- body: { userId, anonymousId: s.identity.anonymousId }
2074
+ body
1250
2075
  });
1251
2076
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
1252
2077
  s.developerUserId = userId;
1253
2078
  return result;
1254
2079
  }
2080
+ /**
2081
+ * Register super-properties — Mixpanel pattern. Once set, every
2082
+ * subsequent event of THIS SDK instance carries these keys on its
2083
+ * properties bag automatically.
2084
+ *
2085
+ * Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
2086
+ * Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
2087
+ *
2088
+ * Values that are `null` are deleted (the explicit "stop tracking
2089
+ * this key" idiom). Returns the resulting bag.
2090
+ *
2091
+ * Sanitised through `validateEventProperties` so a `{ avatar: File }`
2092
+ * payload can't poison the queue at flush time.
2093
+ */
2094
+ register(properties) {
2095
+ const s = this.requireStarted();
2096
+ const validation = validateEventProperties(properties);
2097
+ return s.superProps.register(validation.properties);
2098
+ }
2099
+ /** Remove a single super-property key. Idempotent. */
2100
+ unregister(key) {
2101
+ const s = this.requireStarted();
2102
+ s.superProps.unregister(key);
2103
+ }
2104
+ /** Snapshot of the current super-property bag. */
2105
+ getSuperProperties() {
2106
+ if (!this.state) return {};
2107
+ return this.state.superProps.getSuperProperties();
2108
+ }
2109
+ /**
2110
+ * Associate the current user with a group (org, team, account, etc.).
2111
+ * Mixpanel / Segment "Group Analytics" pattern.
2112
+ *
2113
+ * Crossdeck.group("org", "acme_inc");
2114
+ * Crossdeck.group("team", "design", { headcount: 12 });
2115
+ *
2116
+ * Once set, every subsequent event carries `$groups.<type>: id` on
2117
+ * its properties bag, enabling B2B dashboards ("how is Acme using
2118
+ * the product"). Pass `id: null` to clear a group membership.
2119
+ */
2120
+ group(type, id, traits) {
2121
+ const s = this.requireStarted();
2122
+ if (!type) {
2123
+ throw new CrossdeckError({
2124
+ type: "invalid_request_error",
2125
+ code: "missing_group_type",
2126
+ message: "group(type, id) requires a non-empty type."
2127
+ });
2128
+ }
2129
+ const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
2130
+ s.superProps.setGroup(type, id, sanitisedTraits);
2131
+ }
2132
+ /** Snapshot of the current groups map keyed by type. */
2133
+ getGroups() {
2134
+ if (!this.state) return {};
2135
+ return this.state.superProps.getGroups();
2136
+ }
2137
+ /**
2138
+ * Update consent state. Three independent dimensions:
2139
+ *
2140
+ * analytics — track() + identify() + auto-emissions
2141
+ * marketing — paid-traffic click IDs + referrer URL on events
2142
+ * errors — Web Vitals + (future) error reporting
2143
+ *
2144
+ * Each defaults to `true` (granted). Pass partial state — only the
2145
+ * keys you provide are changed.
2146
+ *
2147
+ * Crossdeck.consent({ analytics: false });
2148
+ * Crossdeck.consent({ marketing: true, errors: true });
2149
+ *
2150
+ * DNT-derived denies cannot be flipped back on; if the browser said
2151
+ * "don't track" we don't track even if the developer code disagrees.
2152
+ */
2153
+ consent(state) {
2154
+ const s = this.requireStarted();
2155
+ const next = s.consent.set(state);
2156
+ s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
2157
+ return next;
2158
+ }
2159
+ /** Snapshot of the current consent state. */
2160
+ consentStatus() {
2161
+ if (!this.state) {
2162
+ return { analytics: true, marketing: true, errors: true };
2163
+ }
2164
+ return this.state.consent.get();
2165
+ }
2166
+ /**
2167
+ * GDPR/CCPA "right to be forgotten" — calls the backend's
2168
+ * /v1/identity/forget endpoint to schedule a server-side deletion of
2169
+ * the customer's events and profile, then wipes all local state
2170
+ * (identity, entitlements, queue, super-props, persistent stores).
2171
+ *
2172
+ * Idempotent. Safe to call when no identity has been established
2173
+ * (it just wipes the empty local state).
2174
+ *
2175
+ * After forget() resolves, the SDK is in the same shape as if the
2176
+ * developer had called `Crossdeck.reset()` — a fresh anonymousId is
2177
+ * minted and the next session is a brand new identity-graph entry.
2178
+ */
2179
+ async forget() {
2180
+ const s = this.requireStarted();
2181
+ const identityQuery = this.identityQueryParams();
2182
+ try {
2183
+ await s.http.request("POST", "/identity/forget", {
2184
+ body: {
2185
+ // Send every identity hint we hold; the server resolves the
2186
+ // canonical customer record and queues deletion. Missing
2187
+ // endpoint (older backend) gracefully degrades — local state
2188
+ // still wipes via the reset() call below.
2189
+ ...identityQuery
2190
+ }
2191
+ });
2192
+ } catch (err) {
2193
+ s.debug.emit(
2194
+ "sdk.consent_denied",
2195
+ `forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
2196
+ );
2197
+ }
2198
+ this.reset();
2199
+ }
1255
2200
  /**
1256
2201
  * Read the current customer's active entitlements from the server.
1257
2202
  * Updates the local cache so subsequent isEntitled() calls answer
@@ -1329,6 +2274,17 @@ var CrossdeckClient = class {
1329
2274
  message: "track(name) requires a non-empty name."
1330
2275
  });
1331
2276
  }
2277
+ const isWebVital = name.startsWith("webvitals.");
2278
+ const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
2279
+ if (!consentGateOk) {
2280
+ if (s.debug.enabled) {
2281
+ s.debug.emit(
2282
+ "sdk.consent_denied",
2283
+ `Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
2284
+ );
2285
+ }
2286
+ return;
2287
+ }
1332
2288
  if (s.debug.enabled && properties) {
1333
2289
  const flagged = findSensitivePropertyKeys(properties);
1334
2290
  if (flagged.length > 0) {
@@ -1345,9 +2301,21 @@ var CrossdeckClient = class {
1345
2301
  "Using anonymous user until identify(userId) is called."
1346
2302
  );
1347
2303
  }
2304
+ const validation = validateEventProperties(properties);
2305
+ if (s.debug.enabled && validation.warnings.length > 0) {
2306
+ for (const w of validation.warnings) {
2307
+ s.debug.emit(
2308
+ "sdk.property_coerced",
2309
+ `Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2310
+ { eventName: name, key: w.key, kind: w.kind }
2311
+ );
2312
+ }
2313
+ }
1348
2314
  const enriched = { ...s.deviceInfo };
1349
2315
  const sessionId = s.autoTracker?.currentSessionId;
1350
2316
  if (sessionId) enriched.sessionId = sessionId;
2317
+ const pageviewId = s.autoTracker?.currentPageviewId;
2318
+ if (pageviewId) enriched.pageviewId = pageviewId;
1351
2319
  const acquisition = s.autoTracker?.currentAcquisition;
1352
2320
  if (acquisition) {
1353
2321
  if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
@@ -1355,14 +2323,31 @@ var CrossdeckClient = class {
1355
2323
  if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1356
2324
  if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1357
2325
  if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1358
- if (acquisition.referrer) enriched.referrer = acquisition.referrer;
2326
+ if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
2327
+ if (s.consent.marketing) {
2328
+ if (acquisition.gclid) enriched.gclid = acquisition.gclid;
2329
+ if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
2330
+ if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
2331
+ if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
2332
+ if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
2333
+ if (acquisition.twclid) enriched.twclid = acquisition.twclid;
2334
+ }
2335
+ }
2336
+ const supers = s.superProps.getSuperProperties();
2337
+ for (const k of Object.keys(supers)) {
2338
+ if (!(k in enriched)) enriched[k] = supers[k];
2339
+ }
2340
+ const groupIds = s.superProps.getGroupIds();
2341
+ if (Object.keys(groupIds).length > 0) {
2342
+ enriched.$groups = groupIds;
1359
2343
  }
1360
- if (properties) Object.assign(enriched, properties);
2344
+ Object.assign(enriched, validation.properties);
2345
+ const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
1361
2346
  const event = {
1362
2347
  eventId: this.mintEventId(),
1363
2348
  name,
1364
2349
  timestamp: Date.now(),
1365
- properties: enriched
2350
+ properties: finalProperties
1366
2351
  };
1367
2352
  Object.assign(event, this.identityHintForEvent());
1368
2353
  s.events.enqueue(event);
@@ -1440,7 +2425,12 @@ var CrossdeckClient = class {
1440
2425
  */
1441
2426
  async heartbeat() {
1442
2427
  const s = this.requireStarted();
1443
- return await s.http.request("GET", "/sdk/heartbeat");
2428
+ const result = await s.http.request("GET", "/sdk/heartbeat");
2429
+ if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
2430
+ s.lastServerTime = result.serverTime;
2431
+ s.lastClientTime = Date.now();
2432
+ }
2433
+ return result;
1444
2434
  }
1445
2435
  /**
1446
2436
  * Wipe persisted identity + entitlement cache. Use on logout. The
@@ -1459,6 +2449,7 @@ var CrossdeckClient = class {
1459
2449
  this.state.identity.reset();
1460
2450
  this.state.entitlements.clear();
1461
2451
  this.state.events.reset();
2452
+ this.state.superProps.clear();
1462
2453
  this.state.developerUserId = null;
1463
2454
  if (this.state.autoTracker) {
1464
2455
  const tracker = new AutoTracker(
@@ -1486,17 +2477,21 @@ var CrossdeckClient = class {
1486
2477
  developerUserId: null,
1487
2478
  sdkVersion: null,
1488
2479
  baseUrl: null,
1489
- entitlements: { count: 0, lastUpdated: 0 },
2480
+ clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
2481
+ entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
1490
2482
  events: {
1491
2483
  buffered: 0,
1492
2484
  dropped: 0,
1493
2485
  inFlight: 0,
1494
2486
  lastFlushAt: 0,
1495
- lastError: null
2487
+ lastError: null,
2488
+ consecutiveFailures: 0,
2489
+ nextRetryAt: null
1496
2490
  }
1497
2491
  };
1498
2492
  }
1499
2493
  const s = this.state;
2494
+ const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
1500
2495
  return {
1501
2496
  started: true,
1502
2497
  anonymousId: s.identity.anonymousId,
@@ -1504,9 +2499,15 @@ var CrossdeckClient = class {
1504
2499
  developerUserId: s.developerUserId,
1505
2500
  sdkVersion: s.options.sdkVersion,
1506
2501
  baseUrl: s.options.baseUrl,
2502
+ clock: {
2503
+ lastServerTime: s.lastServerTime,
2504
+ lastClientTime: s.lastClientTime,
2505
+ skewMs
2506
+ },
1507
2507
  entitlements: {
1508
2508
  count: s.entitlements.list().length,
1509
- lastUpdated: s.entitlements.freshness
2509
+ lastUpdated: s.entitlements.freshness,
2510
+ listenerErrors: s.entitlements.listenerErrors
1510
2511
  },
1511
2512
  events: s.events.getStats()
1512
2513
  };
@@ -1573,6 +2574,7 @@ function inferEnvFromKey(publicKey) {
1573
2574
  }
1574
2575
  function isLocalHostname() {
1575
2576
  const w = globalThis.window;
2577
+ if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
1576
2578
  const hostname = w?.location?.hostname;
1577
2579
  if (!hostname) return false;
1578
2580
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
@@ -1585,7 +2587,13 @@ function isLocalHostname() {
1585
2587
  }
1586
2588
  function resolveAutoTrack(input) {
1587
2589
  if (input === false) {
1588
- return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
2590
+ return {
2591
+ sessions: false,
2592
+ pageViews: false,
2593
+ deviceInfo: false,
2594
+ clicks: false,
2595
+ webVitals: false
2596
+ };
1589
2597
  }
1590
2598
  if (input === void 0 || input === true) {
1591
2599
  return { ...DEFAULT_AUTO_TRACK };
@@ -1594,7 +2602,8 @@ function resolveAutoTrack(input) {
1594
2602
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1595
2603
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1596
2604
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1597
- clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
2605
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
2606
+ webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
1598
2607
  };
1599
2608
  }
1600
2609
  function installUnloadFlush(onUnload) {
@@ -1614,14 +2623,110 @@ function installUnloadFlush(onUnload) {
1614
2623
  w.removeEventListener("beforeunload", onTerminal);
1615
2624
  };
1616
2625
  }
2626
+
2627
+ // src/error-codes.ts
2628
+ var CROSSDECK_ERROR_CODES = Object.freeze([
2629
+ // ----- Configuration -----
2630
+ {
2631
+ code: "invalid_public_key",
2632
+ type: "configuration_error",
2633
+ description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
2634
+ resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
2635
+ retryable: false
2636
+ },
2637
+ {
2638
+ code: "missing_app_id",
2639
+ type: "configuration_error",
2640
+ description: "Crossdeck.init() was called without an appId.",
2641
+ resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
2642
+ retryable: false
2643
+ },
2644
+ {
2645
+ code: "invalid_environment",
2646
+ type: "configuration_error",
2647
+ description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
2648
+ resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
2649
+ retryable: false
2650
+ },
2651
+ {
2652
+ code: "environment_mismatch",
2653
+ type: "configuration_error",
2654
+ description: "The publishable key's env prefix doesn't match the declared environment option.",
2655
+ resolution: "Either change `environment` to match the key prefix (cd_pub_test_ \u2194 sandbox, cd_pub_live_ \u2194 production), or swap the key for one minted in the right env.",
2656
+ retryable: false
2657
+ },
2658
+ {
2659
+ code: "not_initialized",
2660
+ type: "configuration_error",
2661
+ description: "An SDK method was called before Crossdeck.init().",
2662
+ resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
2663
+ retryable: false
2664
+ },
2665
+ // ----- Identify / track / purchase argument validation -----
2666
+ {
2667
+ code: "missing_user_id",
2668
+ type: "invalid_request_error",
2669
+ description: "identify() was called with an empty userId.",
2670
+ resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
2671
+ retryable: false
2672
+ },
2673
+ {
2674
+ code: "missing_event_name",
2675
+ type: "invalid_request_error",
2676
+ description: "track() was called without an event name.",
2677
+ resolution: "Pass a non-empty string as the first argument.",
2678
+ retryable: false
2679
+ },
2680
+ {
2681
+ code: "missing_group_type",
2682
+ type: "invalid_request_error",
2683
+ description: "group() was called without a group type.",
2684
+ resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
2685
+ retryable: false
2686
+ },
2687
+ {
2688
+ code: "missing_signed_transaction_info",
2689
+ type: "invalid_request_error",
2690
+ description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
2691
+ resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
2692
+ retryable: false
2693
+ },
2694
+ // ----- Network / transport -----
2695
+ {
2696
+ code: "fetch_failed",
2697
+ type: "network_error",
2698
+ description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
2699
+ resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
2700
+ retryable: true
2701
+ },
2702
+ {
2703
+ code: "request_timeout",
2704
+ type: "network_error",
2705
+ description: "A request was aborted after the configured timeoutMs (default 15s).",
2706
+ resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
2707
+ retryable: true
2708
+ },
2709
+ {
2710
+ code: "invalid_json_response",
2711
+ type: "internal_error",
2712
+ description: "The server returned a 2xx with an unparseable body.",
2713
+ resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
2714
+ retryable: true
2715
+ }
2716
+ ]);
2717
+ function getErrorCode(code) {
2718
+ return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
2719
+ }
1617
2720
  // Annotate the CommonJS export names for ESM import in node:
1618
2721
  0 && (module.exports = {
2722
+ CROSSDECK_ERROR_CODES,
1619
2723
  Crossdeck,
1620
2724
  CrossdeckClient,
1621
2725
  CrossdeckError,
1622
2726
  DEFAULT_BASE_URL,
1623
2727
  MemoryStorage,
1624
2728
  SDK_NAME,
1625
- SDK_VERSION
2729
+ SDK_VERSION,
2730
+ getErrorCode
1626
2731
  });
1627
2732
  //# sourceMappingURL=index.cjs.map