@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/react.cjs CHANGED
@@ -35,11 +35,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
35
35
  this.code = payload.code;
36
36
  this.requestId = payload.requestId;
37
37
  this.status = payload.status;
38
+ this.retryAfterMs = payload.retryAfterMs;
38
39
  Object.setPrototypeOf(this, _CrossdeckError.prototype);
39
40
  }
40
41
  };
41
42
  async function crossdeckErrorFromResponse(res) {
42
43
  const requestId = res.headers.get("x-request-id") ?? void 0;
44
+ const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
43
45
  let body;
44
46
  try {
45
47
  body = await res.json();
@@ -53,7 +55,8 @@ async function crossdeckErrorFromResponse(res) {
53
55
  code: envelope.code,
54
56
  message: envelope.message ?? `HTTP ${res.status}`,
55
57
  requestId: envelope.request_id ?? requestId,
56
- status: res.status
58
+ status: res.status,
59
+ retryAfterMs
57
60
  });
58
61
  }
59
62
  return new CrossdeckError({
@@ -61,9 +64,25 @@ async function crossdeckErrorFromResponse(res) {
61
64
  code: `http_${res.status}`,
62
65
  message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
63
66
  requestId,
64
- status: res.status
67
+ status: res.status,
68
+ retryAfterMs
65
69
  });
66
70
  }
71
+ function parseRetryAfterHeader(value) {
72
+ if (!value) return void 0;
73
+ const trimmed = value.trim();
74
+ if (!trimmed) return void 0;
75
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
76
+ const secs = Number(trimmed);
77
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
78
+ return Math.round(secs * 1e3);
79
+ }
80
+ if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
81
+ const target = Date.parse(trimmed);
82
+ if (!Number.isFinite(target)) return void 0;
83
+ const delta = target - Date.now();
84
+ return delta > 0 ? delta : 0;
85
+ }
67
86
  function typeMapForStatus(status) {
68
87
  if (status === 401) return "authentication_error";
69
88
  if (status === 403) return "permission_error";
@@ -74,8 +93,9 @@ function typeMapForStatus(status) {
74
93
 
75
94
  // src/http.ts
76
95
  var SDK_NAME = "@cross-deck/web";
77
- var SDK_VERSION = "0.6.0";
96
+ var SDK_VERSION = "0.10.0";
78
97
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
98
+ var DEFAULT_TIMEOUT_MS = 15e3;
79
99
  var HttpClient = class {
80
100
  constructor(config) {
81
101
  this.config = config;
@@ -99,25 +119,38 @@ var HttpClient = class {
99
119
  "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
100
120
  Accept: "application/json"
101
121
  };
122
+ if (options.idempotencyKey) {
123
+ headers["Idempotency-Key"] = options.idempotencyKey;
124
+ }
102
125
  let bodyInit;
103
126
  if (options.body !== void 0) {
104
127
  headers["Content-Type"] = "application/json";
105
128
  bodyInit = JSON.stringify(options.body);
106
129
  }
130
+ const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
131
+ const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
132
+ let timeoutHandle = null;
133
+ if (controller && effectiveTimeout > 0) {
134
+ timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
135
+ }
107
136
  let response;
108
137
  try {
109
138
  response = await fetch(url, {
110
139
  method,
111
140
  headers,
112
141
  body: bodyInit,
113
- keepalive: options.keepalive === true
142
+ keepalive: options.keepalive === true,
143
+ signal: controller?.signal
114
144
  });
115
145
  } catch (err) {
146
+ const aborted = controller?.signal?.aborted === true;
116
147
  throw new CrossdeckError({
117
148
  type: "network_error",
118
- code: "fetch_failed",
119
- message: err instanceof Error ? err.message : "fetch failed"
149
+ code: aborted ? "request_timeout" : "fetch_failed",
150
+ message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
120
151
  });
152
+ } finally {
153
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
121
154
  }
122
155
  if (!response.ok) {
123
156
  throw await crossdeckErrorFromResponse(response);
@@ -314,6 +347,7 @@ var EntitlementCache = class {
314
347
  this.all = [];
315
348
  this.lastUpdated = 0;
316
349
  this.listeners = /* @__PURE__ */ new Set();
350
+ this.listenerErrorCount = 0;
317
351
  }
318
352
  /** Sync read — true iff the entitlement key is currently active. */
319
353
  isEntitled(key) {
@@ -327,6 +361,15 @@ var EntitlementCache = class {
327
361
  get freshness() {
328
362
  return this.lastUpdated;
329
363
  }
364
+ /**
365
+ * Cumulative count of listener invocations that threw. Listener errors
366
+ * are swallowed (a buggy consumer must not crash the SDK) but the
367
+ * counter lets diagnostics() surface "you have a broken subscriber"
368
+ * without putting the developer in a debug session.
369
+ */
370
+ get listenerErrors() {
371
+ return this.listenerErrorCount;
372
+ }
330
373
  /**
331
374
  * Replace the cache with a fresh server response. The backend already
332
375
  * filters to active + env-matching, so we don't re-filter — just trust
@@ -380,11 +423,54 @@ var EntitlementCache = class {
380
423
  try {
381
424
  listener(snapshot);
382
425
  } catch {
426
+ this.listenerErrorCount += 1;
383
427
  }
384
428
  }
385
429
  }
386
430
  };
387
431
 
432
+ // src/retry-policy.ts
433
+ var DEFAULT_BASE = 1e3;
434
+ var DEFAULT_MAX = 6e4;
435
+ var DEFAULT_FACTOR = 2;
436
+ var DEFAULT_WARN = 8;
437
+ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
438
+ const base = options.baseMs ?? DEFAULT_BASE;
439
+ const max = options.maxMs ?? DEFAULT_MAX;
440
+ const factor = options.factor ?? DEFAULT_FACTOR;
441
+ const safeAttempts = Math.min(attempts, 30);
442
+ const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
443
+ const jittered = ceiling * random();
444
+ if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
445
+ return Math.min(max, retryAfterMs);
446
+ }
447
+ return Math.max(0, Math.round(jittered));
448
+ }
449
+ var RetryPolicy = class {
450
+ constructor(options = {}) {
451
+ this.options = options;
452
+ this.attempts = 0;
453
+ }
454
+ /** How many consecutive failures since the last success. */
455
+ get consecutiveFailures() {
456
+ return this.attempts;
457
+ }
458
+ /** Whether we've crossed the failuresBeforeWarn threshold. */
459
+ get isWarning() {
460
+ return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
461
+ }
462
+ /** Schedule-time delay for the NEXT retry. Increments the counter. */
463
+ nextDelay(retryAfterMs, random = Math.random) {
464
+ const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
465
+ this.attempts += 1;
466
+ return delay;
467
+ }
468
+ /** Mark a successful flush — reset the counter. */
469
+ recordSuccess() {
470
+ this.attempts = 0;
471
+ }
472
+ };
473
+
388
474
  // src/event-queue.ts
389
475
  var HARD_BUFFER_CAP = 1e3;
390
476
  var EventQueue = class {
@@ -397,6 +483,22 @@ var EventQueue = class {
397
483
  this.lastError = null;
398
484
  this.cancelTimer = null;
399
485
  this.firstFlushFired = false;
486
+ this.nextRetryAt = null;
487
+ this.retry = new RetryPolicy(cfg.retry ?? {});
488
+ this.persistent = cfg.persistentStore ?? null;
489
+ if (this.persistent) {
490
+ const restored = this.persistent.load();
491
+ if (restored.length > 0) {
492
+ if (restored.length > HARD_BUFFER_CAP) {
493
+ this.dropped += restored.length - HARD_BUFFER_CAP;
494
+ this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
495
+ } else {
496
+ this.buffer = restored;
497
+ }
498
+ this.cfg.onBufferChange?.(this.buffer.length);
499
+ this.scheduleIdleFlush();
500
+ }
501
+ }
400
502
  }
401
503
  enqueue(event) {
402
504
  this.buffer.push(event);
@@ -406,6 +508,8 @@ var EventQueue = class {
406
508
  this.dropped += overflow;
407
509
  this.cfg.onDrop?.(overflow);
408
510
  }
511
+ this.cfg.onBufferChange?.(this.buffer.length);
512
+ this.persistent?.save(this.buffer);
409
513
  if (this.buffer.length >= this.cfg.batchSize) {
410
514
  void this.flush();
411
515
  } else {
@@ -415,7 +519,7 @@ var EventQueue = class {
415
519
  /**
416
520
  * Flush the buffer to /v1/events. Resolves when the network call
417
521
  * completes (success or failure). On failure, events stay in the
418
- * buffer for the next flush attempt.
522
+ * buffer for the next scheduled retry.
419
523
  *
420
524
  * `options.keepalive` marks the underlying fetch as keepalive so the
421
525
  * browser keeps the request alive past page unload. Use this for
@@ -424,25 +528,32 @@ var EventQueue = class {
424
528
  async flush(options = {}) {
425
529
  if (this.buffer.length === 0) return null;
426
530
  this.cancelTimerIfSet();
531
+ this.nextRetryAt = null;
427
532
  const batch = this.buffer.splice(0);
533
+ const batchId = this.mintBatchId();
428
534
  this.inFlight += batch.length;
535
+ this.persistent?.save(this.buffer);
536
+ this.cfg.onBufferChange?.(this.buffer.length);
429
537
  try {
430
538
  const env = this.cfg.envelope();
431
539
  const result = await this.cfg.http.request("POST", "/events", {
432
540
  body: {
433
541
  // NorthStar §13.1 batch envelope. The backend validates these
434
- // against the API-key-resolved app and rejects mismatches loudly
435
- // (env_mismatch).
542
+ // against the API-key-resolved app and rejects mismatches
543
+ // loudly (env_mismatch).
436
544
  appId: env.appId,
437
545
  environment: env.environment,
438
546
  sdk: env.sdk,
439
547
  events: batch
440
548
  },
441
- keepalive: options.keepalive === true
549
+ keepalive: options.keepalive === true,
550
+ idempotencyKey: batchId
442
551
  });
443
552
  this.lastFlushAt = Date.now();
444
553
  this.lastError = null;
445
554
  this.inFlight -= batch.length;
555
+ this.retry.recordSuccess();
556
+ this.persistent?.save(this.buffer);
446
557
  if (!this.firstFlushFired) {
447
558
  this.firstFlushFired = true;
448
559
  this.cfg.onFirstFlushSuccess?.();
@@ -451,18 +562,33 @@ var EventQueue = class {
451
562
  } catch (err) {
452
563
  this.buffer.unshift(...batch);
453
564
  this.inFlight -= batch.length;
454
- this.lastError = err instanceof Error ? err.message : String(err);
455
- this.scheduleIdleFlush();
565
+ const message = err instanceof Error ? err.message : String(err);
566
+ this.lastError = message;
567
+ this.persistent?.save(this.buffer);
568
+ this.cfg.onBufferChange?.(this.buffer.length);
569
+ const retryAfterMs = extractRetryAfterMs(err);
570
+ const delay = this.retry.nextDelay(retryAfterMs);
571
+ this.scheduleRetry(delay);
572
+ this.cfg.onRetryScheduled?.({
573
+ delayMs: delay,
574
+ consecutiveFailures: this.retry.consecutiveFailures,
575
+ retryAfterMs,
576
+ lastError: message
577
+ });
456
578
  return null;
457
579
  }
458
580
  }
459
- /** Cancel any pending timer and clear in-memory state. */
581
+ /** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
460
582
  reset() {
461
583
  this.cancelTimerIfSet();
584
+ this.nextRetryAt = null;
462
585
  this.buffer = [];
463
586
  this.dropped = 0;
464
587
  this.inFlight = 0;
465
588
  this.lastError = null;
589
+ this.retry.recordSuccess();
590
+ this.persistent?.clear();
591
+ this.cfg.onBufferChange?.(0);
466
592
  }
467
593
  getStats() {
468
594
  return {
@@ -470,9 +596,12 @@ var EventQueue = class {
470
596
  dropped: this.dropped,
471
597
  inFlight: this.inFlight,
472
598
  lastFlushAt: this.lastFlushAt,
473
- lastError: this.lastError
599
+ lastError: this.lastError,
600
+ consecutiveFailures: this.retry.consecutiveFailures,
601
+ nextRetryAt: this.nextRetryAt
474
602
  };
475
603
  }
604
+ // ---------- internal scheduling ----------
476
605
  scheduleIdleFlush() {
477
606
  this.cancelTimerIfSet();
478
607
  const sched = this.cfg.scheduler ?? defaultScheduler;
@@ -480,13 +609,31 @@ var EventQueue = class {
480
609
  void this.flush();
481
610
  }, this.cfg.intervalMs);
482
611
  }
612
+ scheduleRetry(delayMs) {
613
+ this.cancelTimerIfSet();
614
+ this.nextRetryAt = Date.now() + delayMs;
615
+ const sched = this.cfg.scheduler ?? defaultScheduler;
616
+ this.cancelTimer = sched(() => {
617
+ void this.flush();
618
+ }, delayMs);
619
+ }
483
620
  cancelTimerIfSet() {
484
621
  if (this.cancelTimer) {
485
622
  this.cancelTimer();
486
623
  this.cancelTimer = null;
487
624
  }
488
625
  }
626
+ mintBatchId() {
627
+ return `batch_${Date.now().toString(36)}${randomChars(10)}`;
628
+ }
489
629
  };
630
+ function extractRetryAfterMs(err) {
631
+ if (err && typeof err === "object" && "retryAfterMs" in err) {
632
+ const v = err.retryAfterMs;
633
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
634
+ }
635
+ return void 0;
636
+ }
490
637
  function defaultScheduler(fn, ms) {
491
638
  const id = setTimeout(fn, ms);
492
639
  if (typeof id.unref === "function") {
@@ -498,6 +645,87 @@ function defaultScheduler(fn, ms) {
498
645
  return () => clearTimeout(id);
499
646
  }
500
647
 
648
+ // src/event-storage.ts
649
+ var PersistentEventStore = class {
650
+ constructor(options) {
651
+ this.options = options;
652
+ this.writeScheduled = false;
653
+ // Pending events captured on the most recent write request. We keep
654
+ // the latest snapshot ref so a debounced write always picks up the
655
+ // freshest buffer state.
656
+ this.pendingSnapshot = null;
657
+ this.key = `${options.prefix}queue.v1`;
658
+ }
659
+ /**
660
+ * Read the persisted queue on boot. Returns an empty array (with no
661
+ * warning) when nothing is stored, the blob is malformed, or storage
662
+ * is unavailable. Caller is responsible for treating duplicates from
663
+ * the persisted queue as the SAME events (eventId-based dedup).
664
+ */
665
+ load() {
666
+ let raw;
667
+ try {
668
+ raw = this.options.storage.getItem(this.key);
669
+ } catch {
670
+ return [];
671
+ }
672
+ if (!raw) return [];
673
+ try {
674
+ const parsed = JSON.parse(raw);
675
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
676
+ return [];
677
+ }
678
+ return parsed.events;
679
+ } catch {
680
+ return [];
681
+ }
682
+ }
683
+ /**
684
+ * Schedule a write of the current buffer. Debounced via microtask so
685
+ * a burst of enqueue() calls coalesces into one persistence write.
686
+ * Writes are best-effort: if storage throws (quota, private mode),
687
+ * we swallow and rely on the in-memory buffer.
688
+ */
689
+ save(snapshot) {
690
+ this.pendingSnapshot = snapshot.slice();
691
+ if (this.writeScheduled) return;
692
+ this.writeScheduled = true;
693
+ queueMicrotask(() => this.flushWrite());
694
+ }
695
+ /** Synchronous variant for terminal flushes (pagehide / beforeunload). */
696
+ saveSync(snapshot) {
697
+ this.pendingSnapshot = snapshot.slice();
698
+ this.flushWrite();
699
+ }
700
+ /** Wipe the persisted blob. Used by reset() (logout). */
701
+ clear() {
702
+ this.pendingSnapshot = null;
703
+ this.writeScheduled = false;
704
+ try {
705
+ this.options.storage.removeItem(this.key);
706
+ } catch {
707
+ }
708
+ }
709
+ flushWrite() {
710
+ this.writeScheduled = false;
711
+ const snapshot = this.pendingSnapshot;
712
+ this.pendingSnapshot = null;
713
+ if (snapshot === null) return;
714
+ if (snapshot.length === 0) {
715
+ try {
716
+ this.options.storage.removeItem(this.key);
717
+ } catch {
718
+ }
719
+ return;
720
+ }
721
+ const blob = { version: 1, events: snapshot };
722
+ try {
723
+ this.options.storage.setItem(this.key, JSON.stringify(blob));
724
+ } catch {
725
+ }
726
+ }
727
+ };
728
+
501
729
  // src/storage.ts
502
730
  var MemoryStorage = class {
503
731
  constructor() {
@@ -688,7 +916,8 @@ var DEFAULT_AUTO_TRACK = {
688
916
  sessions: true,
689
917
  pageViews: true,
690
918
  deviceInfo: true,
691
- clicks: true
919
+ clicks: true,
920
+ webVitals: true
692
921
  };
693
922
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
694
923
  var EMPTY_ACQUISITION = {
@@ -697,7 +926,13 @@ var EMPTY_ACQUISITION = {
697
926
  utm_campaign: "",
698
927
  utm_content: "",
699
928
  utm_term: "",
700
- referrer: ""
929
+ referrer: "",
930
+ gclid: "",
931
+ fbclid: "",
932
+ msclkid: "",
933
+ ttclid: "",
934
+ li_fat_id: "",
935
+ twclid: ""
701
936
  };
702
937
  var AutoTracker = class {
703
938
  constructor(cfg, track) {
@@ -705,6 +940,17 @@ var AutoTracker = class {
705
940
  this.track = track;
706
941
  this.session = null;
707
942
  this.cleanups = [];
943
+ /**
944
+ * Stable per-page-view identifier. Minted at every `page.viewed`
945
+ * emission and attached to every subsequent event until the next
946
+ * `page.viewed`. Lets dashboards correlate "user clicked X" to
947
+ * "user viewed page Y" without timestamp arithmetic — the canonical
948
+ * Mixpanel `$current_url` / Segment `pageId` pattern.
949
+ *
950
+ * Null until the first `page.viewed` fires (which happens at SDK
951
+ * install if `autoTrack.pageViews !== false`).
952
+ */
953
+ this.pageviewId = null;
708
954
  }
709
955
  install() {
710
956
  if (!isBrowserSafe()) return;
@@ -735,6 +981,10 @@ var AutoTracker = class {
735
981
  get currentSessionId() {
736
982
  return this.session?.sessionId ?? null;
737
983
  }
984
+ /** Stable per-page-view ID. Null before the first page.viewed has fired. */
985
+ get currentPageviewId() {
986
+ return this.pageviewId;
987
+ }
738
988
  /**
739
989
  * Per-session acquisition context — utm_* + referrer, captured once
740
990
  * at session start. Returns empty strings when there's no session
@@ -815,7 +1065,9 @@ var AutoTracker = class {
815
1065
  if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
816
1066
  lastFiredAt = now;
817
1067
  lastFiredUrl = url;
1068
+ this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
818
1069
  this.track("page.viewed", {
1070
+ pageviewId: this.pageviewId,
819
1071
  path: loc.pathname,
820
1072
  url,
821
1073
  search: loc.search || void 0,
@@ -1019,6 +1271,12 @@ function captureAcquisition() {
1019
1271
  result.utm_campaign = params.get("utm_campaign") ?? "";
1020
1272
  result.utm_content = params.get("utm_content") ?? "";
1021
1273
  result.utm_term = params.get("utm_term") ?? "";
1274
+ result.gclid = params.get("gclid") ?? "";
1275
+ result.fbclid = params.get("fbclid") ?? "";
1276
+ result.msclkid = params.get("msclkid") ?? "";
1277
+ result.ttclid = params.get("ttclid") ?? "";
1278
+ result.li_fat_id = params.get("li_fat_id") ?? "";
1279
+ result.twclid = params.get("twclid") ?? "";
1022
1280
  } catch {
1023
1281
  }
1024
1282
  try {
@@ -1076,6 +1334,490 @@ function safeJson(obj) {
1076
1334
  }
1077
1335
  }
1078
1336
 
1337
+ // src/event-validation.ts
1338
+ var DEFAULT_MAX_STRING = 1024;
1339
+ var DEFAULT_MAX_BYTES = 8 * 1024;
1340
+ var DEFAULT_MAX_DEPTH = 5;
1341
+ function validateEventProperties(input, options = {}) {
1342
+ const warnings = [];
1343
+ if (!input) return { properties: {}, warnings };
1344
+ const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1345
+ const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1346
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1347
+ const seen = /* @__PURE__ */ new WeakSet();
1348
+ const visit = (value, key, depth) => {
1349
+ if (depth > maxDepth) {
1350
+ warnings.push({ kind: "depth_exceeded", key });
1351
+ return { keep: true, value: "[depth-exceeded]" };
1352
+ }
1353
+ if (value === null) return { keep: true, value: null };
1354
+ const t = typeof value;
1355
+ if (t === "string") {
1356
+ const s = value;
1357
+ if (s.length > maxStringLength) {
1358
+ warnings.push({ kind: "truncated_string", key });
1359
+ return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
1360
+ }
1361
+ return { keep: true, value: s };
1362
+ }
1363
+ if (t === "number") {
1364
+ if (!Number.isFinite(value)) {
1365
+ warnings.push({ kind: "non_serialisable", key });
1366
+ return { keep: true, value: null };
1367
+ }
1368
+ return { keep: true, value };
1369
+ }
1370
+ if (t === "boolean") return { keep: true, value };
1371
+ if (t === "bigint") {
1372
+ warnings.push({ kind: "coerced_bigint", key });
1373
+ return { keep: true, value: value.toString() };
1374
+ }
1375
+ if (t === "function") {
1376
+ warnings.push({ kind: "dropped_function", key });
1377
+ return { keep: false, value: void 0 };
1378
+ }
1379
+ if (t === "symbol") {
1380
+ warnings.push({ kind: "dropped_symbol", key });
1381
+ return { keep: false, value: void 0 };
1382
+ }
1383
+ if (t === "undefined") {
1384
+ warnings.push({ kind: "dropped_undefined", key });
1385
+ return { keep: false, value: void 0 };
1386
+ }
1387
+ if (value instanceof Date) {
1388
+ warnings.push({ kind: "coerced_date", key });
1389
+ const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
1390
+ return { keep: true, value: iso };
1391
+ }
1392
+ if (value instanceof Error) {
1393
+ warnings.push({ kind: "coerced_error", key });
1394
+ return {
1395
+ keep: true,
1396
+ value: {
1397
+ name: value.name,
1398
+ message: value.message,
1399
+ stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
1400
+ }
1401
+ };
1402
+ }
1403
+ if (value instanceof Map) {
1404
+ warnings.push({ kind: "coerced_map", key });
1405
+ const obj = {};
1406
+ for (const [k, v] of value.entries()) {
1407
+ const subKey = typeof k === "string" ? k : String(k);
1408
+ const result = visit(v, `${key}.${subKey}`, depth + 1);
1409
+ if (result.keep) obj[subKey] = result.value;
1410
+ }
1411
+ return { keep: true, value: obj };
1412
+ }
1413
+ if (value instanceof Set) {
1414
+ warnings.push({ kind: "coerced_set", key });
1415
+ const arr = [];
1416
+ let i = 0;
1417
+ for (const v of value.values()) {
1418
+ const result = visit(v, `${key}[${i}]`, depth + 1);
1419
+ if (result.keep) arr.push(result.value);
1420
+ i++;
1421
+ }
1422
+ return { keep: true, value: arr };
1423
+ }
1424
+ if (Array.isArray(value)) {
1425
+ if (seen.has(value)) {
1426
+ warnings.push({ kind: "circular_reference", key });
1427
+ return { keep: true, value: "[circular]" };
1428
+ }
1429
+ seen.add(value);
1430
+ const out = [];
1431
+ for (let i = 0; i < value.length; i++) {
1432
+ const result = visit(value[i], `${key}[${i}]`, depth + 1);
1433
+ if (result.keep) out.push(result.value);
1434
+ }
1435
+ return { keep: true, value: out };
1436
+ }
1437
+ if (t === "object") {
1438
+ const obj = value;
1439
+ if (seen.has(obj)) {
1440
+ warnings.push({ kind: "circular_reference", key });
1441
+ return { keep: true, value: "[circular]" };
1442
+ }
1443
+ seen.add(obj);
1444
+ const out = {};
1445
+ for (const k of Object.keys(obj)) {
1446
+ const result = visit(obj[k], `${key}.${k}`, depth + 1);
1447
+ if (result.keep) out[k] = result.value;
1448
+ }
1449
+ return { keep: true, value: out };
1450
+ }
1451
+ warnings.push({ kind: "non_serialisable", key });
1452
+ try {
1453
+ return { keep: true, value: String(value) };
1454
+ } catch {
1455
+ return { keep: false, value: void 0 };
1456
+ }
1457
+ };
1458
+ const cleaned = {};
1459
+ for (const k of Object.keys(input)) {
1460
+ const result = visit(input[k], k, 0);
1461
+ if (result.keep) cleaned[k] = result.value;
1462
+ }
1463
+ const serialised = safeStringify(cleaned);
1464
+ if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
1465
+ warnings.push({ kind: "size_cap_exceeded", key: "*" });
1466
+ const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
1467
+ let currentSize = byteLength(serialised);
1468
+ for (const { k } of sizes) {
1469
+ if (currentSize <= maxBatchPropertyBytes) break;
1470
+ currentSize -= sizes.find((s) => s.k === k).size;
1471
+ delete cleaned[k];
1472
+ }
1473
+ cleaned.__truncated = true;
1474
+ }
1475
+ return { properties: cleaned, warnings };
1476
+ }
1477
+ function safeStringify(v) {
1478
+ try {
1479
+ return JSON.stringify(v) ?? null;
1480
+ } catch {
1481
+ return null;
1482
+ }
1483
+ }
1484
+ function byteLength(s) {
1485
+ if (typeof TextEncoder !== "undefined") {
1486
+ return new TextEncoder().encode(s).length;
1487
+ }
1488
+ return s.length * 4;
1489
+ }
1490
+
1491
+ // src/super-properties.ts
1492
+ var KEY_SUPER = "super_props";
1493
+ var KEY_GROUPS = "groups";
1494
+ var SuperPropertyStore = class {
1495
+ constructor(storage, prefix) {
1496
+ this.storage = storage;
1497
+ this.prefix = prefix;
1498
+ this.superProps = {};
1499
+ this.groups = {};
1500
+ this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
1501
+ this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
1502
+ }
1503
+ // ---------- super properties ----------
1504
+ /**
1505
+ * Merge new keys into the super-property bag. Returns a snapshot of
1506
+ * the resulting bag. Values that are `null` are deleted (Mixpanel
1507
+ * semantics — explicit null = "stop tracking this key").
1508
+ */
1509
+ register(props) {
1510
+ for (const [k, v] of Object.entries(props)) {
1511
+ if (v === null) {
1512
+ delete this.superProps[k];
1513
+ } else if (v !== void 0) {
1514
+ this.superProps[k] = v;
1515
+ }
1516
+ }
1517
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1518
+ return { ...this.superProps };
1519
+ }
1520
+ /** Remove a single super-property key. Idempotent. */
1521
+ unregister(key) {
1522
+ if (key in this.superProps) {
1523
+ delete this.superProps[key];
1524
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1525
+ }
1526
+ }
1527
+ /** Snapshot of the current super-property bag. */
1528
+ getSuperProperties() {
1529
+ return { ...this.superProps };
1530
+ }
1531
+ // ---------- groups ----------
1532
+ /**
1533
+ * Set a group membership. Passing `id: null` clears the membership
1534
+ * for that group type — the SDK stops attaching it to events.
1535
+ */
1536
+ setGroup(type, id, traits) {
1537
+ if (id === null) {
1538
+ delete this.groups[type];
1539
+ } else {
1540
+ this.groups[type] = traits !== void 0 ? { id, traits } : { id };
1541
+ }
1542
+ writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
1543
+ }
1544
+ /**
1545
+ * Snapshot of the current groups map, keyed by group type. Returned
1546
+ * shape mirrors what the SDK attaches to every event as
1547
+ * `$groups.{type}`. The `traits` sub-object is the most-recent
1548
+ * traits payload passed to `setGroup` for that type; null when none.
1549
+ */
1550
+ getGroups() {
1551
+ return JSON.parse(JSON.stringify(this.groups));
1552
+ }
1553
+ /**
1554
+ * The flat `{ type: id }` projection used for event-attachment. Stable
1555
+ * for fast every-event merge — we don't want to JSON-clone on each
1556
+ * track() call.
1557
+ */
1558
+ getGroupIds() {
1559
+ const out = {};
1560
+ for (const [type, info] of Object.entries(this.groups)) {
1561
+ out[type] = info.id;
1562
+ }
1563
+ return out;
1564
+ }
1565
+ /** Wipe both bags. Called by Crossdeck.reset() (logout). */
1566
+ clear() {
1567
+ this.superProps = {};
1568
+ this.groups = {};
1569
+ try {
1570
+ this.storage.removeItem(this.prefix + KEY_SUPER);
1571
+ } catch {
1572
+ }
1573
+ try {
1574
+ this.storage.removeItem(this.prefix + KEY_GROUPS);
1575
+ } catch {
1576
+ }
1577
+ }
1578
+ };
1579
+ function readJson(storage, key) {
1580
+ let raw;
1581
+ try {
1582
+ raw = storage.getItem(key);
1583
+ } catch {
1584
+ return null;
1585
+ }
1586
+ if (!raw) return null;
1587
+ try {
1588
+ return JSON.parse(raw);
1589
+ } catch {
1590
+ return null;
1591
+ }
1592
+ }
1593
+ function writeJson(storage, key, value) {
1594
+ try {
1595
+ storage.setItem(key, JSON.stringify(value));
1596
+ } catch {
1597
+ }
1598
+ }
1599
+
1600
+ // src/web-vitals.ts
1601
+ var WebVitalsTracker = class {
1602
+ constructor(cfg, report) {
1603
+ this.cfg = cfg;
1604
+ this.report = report;
1605
+ this.observers = [];
1606
+ this.flushed = /* @__PURE__ */ new Set();
1607
+ this.cls = 0;
1608
+ this.clsEntries = [];
1609
+ this.inp = 0;
1610
+ this.cleanups = [];
1611
+ }
1612
+ install() {
1613
+ if (!this.cfg.enabled) return;
1614
+ if (typeof PerformanceObserver === "undefined") return;
1615
+ if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
1616
+ const doc = globalThis.document;
1617
+ try {
1618
+ const navObserver = new PerformanceObserver((list) => {
1619
+ for (const entry of list.getEntries()) {
1620
+ const e = entry;
1621
+ if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
1622
+ this.flushed.add("ttfb");
1623
+ this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
1624
+ }
1625
+ }
1626
+ });
1627
+ navObserver.observe({ type: "navigation", buffered: true });
1628
+ this.observers.push(navObserver);
1629
+ } catch {
1630
+ }
1631
+ try {
1632
+ const paintObserver = new PerformanceObserver((list) => {
1633
+ for (const entry of list.getEntries()) {
1634
+ if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
1635
+ this.flushed.add("fcp");
1636
+ this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
1637
+ }
1638
+ }
1639
+ });
1640
+ paintObserver.observe({ type: "paint", buffered: true });
1641
+ this.observers.push(paintObserver);
1642
+ } catch {
1643
+ }
1644
+ let lcpValue = 0;
1645
+ try {
1646
+ const lcpObserver = new PerformanceObserver((list) => {
1647
+ const entries = list.getEntries();
1648
+ const last = entries[entries.length - 1];
1649
+ if (last) lcpValue = last.startTime;
1650
+ });
1651
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
1652
+ this.observers.push(lcpObserver);
1653
+ } catch {
1654
+ }
1655
+ try {
1656
+ const clsObserver = new PerformanceObserver((list) => {
1657
+ for (const entry of list.getEntries()) {
1658
+ const e = entry;
1659
+ if (typeof e.value === "number" && !e.hadRecentInput) {
1660
+ this.cls += e.value;
1661
+ this.clsEntries.push(entry);
1662
+ }
1663
+ }
1664
+ });
1665
+ clsObserver.observe({ type: "layout-shift", buffered: true });
1666
+ this.observers.push(clsObserver);
1667
+ } catch {
1668
+ }
1669
+ try {
1670
+ const eventObserver = new PerformanceObserver((list) => {
1671
+ for (const entry of list.getEntries()) {
1672
+ const e = entry;
1673
+ if (e.interactionId && e.duration > this.inp) {
1674
+ this.inp = e.duration;
1675
+ }
1676
+ }
1677
+ });
1678
+ try {
1679
+ eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
1680
+ } catch {
1681
+ eventObserver.observe({ type: "first-input", buffered: true });
1682
+ }
1683
+ this.observers.push(eventObserver);
1684
+ } catch {
1685
+ }
1686
+ const flush = () => {
1687
+ if (lcpValue > 0 && !this.flushed.has("lcp")) {
1688
+ this.flushed.add("lcp");
1689
+ this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
1690
+ }
1691
+ if (this.cls > 0 && !this.flushed.has("cls")) {
1692
+ this.flushed.add("cls");
1693
+ this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
1694
+ }
1695
+ if (this.inp > 0 && !this.flushed.has("inp")) {
1696
+ this.flushed.add("inp");
1697
+ this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
1698
+ }
1699
+ };
1700
+ const onHidden = () => {
1701
+ if (doc.visibilityState === "hidden") flush();
1702
+ };
1703
+ doc.addEventListener("visibilitychange", onHidden);
1704
+ globalThis.window.addEventListener("pagehide", flush);
1705
+ this.cleanups.push(() => {
1706
+ doc.removeEventListener("visibilitychange", onHidden);
1707
+ globalThis.window.removeEventListener("pagehide", flush);
1708
+ });
1709
+ }
1710
+ uninstall() {
1711
+ for (const o of this.observers) {
1712
+ try {
1713
+ o.disconnect();
1714
+ } catch {
1715
+ }
1716
+ }
1717
+ this.observers = [];
1718
+ for (const fn of this.cleanups.splice(0)) {
1719
+ try {
1720
+ fn();
1721
+ } catch {
1722
+ }
1723
+ }
1724
+ }
1725
+ };
1726
+
1727
+ // src/consent.ts
1728
+ var ALL_GRANTED = {
1729
+ analytics: true,
1730
+ marketing: true,
1731
+ errors: true
1732
+ };
1733
+ var ConsentManager = class {
1734
+ constructor(options) {
1735
+ this.state = { ...ALL_GRANTED };
1736
+ this.dntDenied = false;
1737
+ if (options?.respectDnt && this.detectDnt()) {
1738
+ this.dntDenied = true;
1739
+ this.state = { analytics: false, marketing: false, errors: false };
1740
+ }
1741
+ }
1742
+ /**
1743
+ * Merge new dimensions onto the current state. Returns the resulting
1744
+ * snapshot. DNT-derived denies cannot be flipped back on by a `set`
1745
+ * call — once the browser says "don't track", we don't track even if
1746
+ * the developer code disagrees. That's the contract.
1747
+ */
1748
+ set(partial) {
1749
+ if (this.dntDenied) return { ...this.state };
1750
+ for (const k of Object.keys(partial)) {
1751
+ const v = partial[k];
1752
+ if (typeof v === "boolean") this.state[k] = v;
1753
+ }
1754
+ return { ...this.state };
1755
+ }
1756
+ /** Snapshot of the current state. */
1757
+ get() {
1758
+ return { ...this.state };
1759
+ }
1760
+ /** Convenience getters for hot paths. */
1761
+ get analytics() {
1762
+ return this.state.analytics;
1763
+ }
1764
+ get marketing() {
1765
+ return this.state.marketing;
1766
+ }
1767
+ get errors() {
1768
+ return this.state.errors;
1769
+ }
1770
+ /** True iff the constructor detected and applied DNT. */
1771
+ get isDntDenied() {
1772
+ return this.dntDenied;
1773
+ }
1774
+ detectDnt() {
1775
+ try {
1776
+ const nav = globalThis.navigator;
1777
+ if (!nav) return false;
1778
+ const sources = [
1779
+ nav.doNotTrack,
1780
+ nav.msDoNotTrack,
1781
+ globalThis.doNotTrack
1782
+ ];
1783
+ return sources.some((v) => v === "1" || v === "yes");
1784
+ } catch {
1785
+ return false;
1786
+ }
1787
+ }
1788
+ };
1789
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1790
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1791
+ var REPLACEMENT_EMAIL = "[email]";
1792
+ var REPLACEMENT_CARD = "[card]";
1793
+ function scrubPii(value) {
1794
+ if (!value) return value;
1795
+ let out = value;
1796
+ if (EMAIL_PATTERN.test(out)) {
1797
+ out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1798
+ }
1799
+ EMAIL_PATTERN.lastIndex = 0;
1800
+ if (CARD_PATTERN.test(out)) {
1801
+ out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1802
+ }
1803
+ CARD_PATTERN.lastIndex = 0;
1804
+ return out;
1805
+ }
1806
+ function scrubPiiFromProperties(properties) {
1807
+ const out = {};
1808
+ for (const k of Object.keys(properties)) {
1809
+ const v = properties[k];
1810
+ if (typeof v === "string") {
1811
+ out[k] = scrubPii(v);
1812
+ } else if (Array.isArray(v)) {
1813
+ out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1814
+ } else {
1815
+ out[k] = v;
1816
+ }
1817
+ }
1818
+ return out;
1819
+ }
1820
+
1079
1821
  // src/crossdeck.ts
1080
1822
  var CrossdeckClient = class {
1081
1823
  constructor() {
@@ -1165,6 +1907,13 @@ var CrossdeckClient = class {
1165
1907
  const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
1166
1908
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
1167
1909
  const entitlements = new EntitlementCache();
1910
+ const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
1911
+ if (persistentEvents) {
1912
+ debug.emit(
1913
+ "sdk.queue_restored",
1914
+ "Restored persisted event queue from a prior session."
1915
+ );
1916
+ }
1168
1917
  const events = new EventQueue({
1169
1918
  http,
1170
1919
  batchSize: opts.eventFlushBatchSize,
@@ -1174,26 +1923,51 @@ var CrossdeckClient = class {
1174
1923
  environment: opts.environment,
1175
1924
  sdk: { name: SDK_NAME, version: opts.sdkVersion }
1176
1925
  }),
1926
+ persistentStore: persistentEvents ?? void 0,
1177
1927
  onFirstFlushSuccess: () => {
1178
1928
  debug.emit(
1179
1929
  "sdk.first_event_sent",
1180
1930
  "First telemetry event received. View it in Live Events.",
1181
1931
  { appId: opts.appId, environment: opts.environment }
1182
1932
  );
1933
+ },
1934
+ onRetryScheduled: (info) => {
1935
+ debug.emit(
1936
+ "sdk.flush_retry_scheduled",
1937
+ `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
1938
+ { ...info }
1939
+ );
1183
1940
  }
1184
1941
  });
1185
1942
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
1943
+ const superProps = new SuperPropertyStore(
1944
+ persistIdentity ? effectiveStorage : new MemoryStorage(),
1945
+ opts.storagePrefix
1946
+ );
1947
+ const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
1948
+ if (consent.isDntDenied) {
1949
+ debug.emit(
1950
+ "sdk.consent_dnt_applied",
1951
+ "Do Not Track detected \u2014 all tracking dimensions denied at init."
1952
+ );
1953
+ }
1186
1954
  this.state = {
1187
1955
  http,
1188
1956
  identity,
1189
1957
  entitlements,
1190
1958
  events,
1191
1959
  autoTracker: null,
1960
+ webVitals: null,
1961
+ superProps,
1962
+ consent,
1963
+ scrubPii: options.scrubPii !== false,
1192
1964
  deviceInfo,
1193
1965
  options: opts,
1194
1966
  debug,
1195
1967
  developerUserId: null,
1196
- uninstallUnloadFlush: null
1968
+ uninstallUnloadFlush: null,
1969
+ lastServerTime: null,
1970
+ lastClientTime: null
1197
1971
  };
1198
1972
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
1199
1973
  appId: opts.appId,
@@ -1208,6 +1982,14 @@ var CrossdeckClient = class {
1208
1982
  this.state.autoTracker = tracker;
1209
1983
  tracker.install();
1210
1984
  }
1985
+ if (autoTrack.webVitals) {
1986
+ const vitals = new WebVitalsTracker(
1987
+ { enabled: true },
1988
+ (name, properties) => this.track(name, properties)
1989
+ );
1990
+ this.state.webVitals = vitals;
1991
+ vitals.install();
1992
+ }
1211
1993
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
1212
1994
  void this.flush({ keepalive: true }).catch(() => void 0);
1213
1995
  });
@@ -1231,8 +2013,19 @@ var CrossdeckClient = class {
1231
2013
  /**
1232
2014
  * Link the anonymous device to a developer-supplied user ID. Cache
1233
2015
  * the resolved Crossdeck customer for follow-up calls.
2016
+ *
2017
+ * v0.9.0+ accepts an optional `traits` bag — profile data (name,
2018
+ * plan, signupDate, role) persisted on the Crossdeck customer record
2019
+ * and queryable from dashboards. Traits are sanitised through the
2020
+ * same validator that gates `track()` properties, so a `{ avatar:
2021
+ * <File>, onSave: () => {} }` payload can't corrupt the alias call.
2022
+ *
2023
+ * Crossdeck.identify("user_847", {
2024
+ * email: "wes@pinet.co.za",
2025
+ * traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
2026
+ * });
1234
2027
  */
1235
- async identify(userId, _options) {
2028
+ async identify(userId, options) {
1236
2029
  const s = this.requireStarted();
1237
2030
  if (!userId) {
1238
2031
  throw new CrossdeckError({
@@ -1241,13 +2034,163 @@ var CrossdeckClient = class {
1241
2034
  message: "identify(userId) requires a non-empty userId."
1242
2035
  });
1243
2036
  }
2037
+ if (!s.consent.analytics) {
2038
+ s.debug.emit(
2039
+ "sdk.consent_denied",
2040
+ `identify() skipped \u2014 consent denied for analytics.`
2041
+ );
2042
+ return {
2043
+ object: "alias_result",
2044
+ crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
2045
+ linked: [],
2046
+ mergePending: false,
2047
+ env: s.options.environment
2048
+ };
2049
+ }
2050
+ const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
2051
+ const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
2052
+ if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
2053
+ for (const w of traitsValidation.warnings) {
2054
+ s.debug.emit(
2055
+ "sdk.property_coerced",
2056
+ `identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2057
+ { key: w.key, kind: w.kind }
2058
+ );
2059
+ }
2060
+ }
2061
+ const body = {
2062
+ userId,
2063
+ anonymousId: s.identity.anonymousId
2064
+ };
2065
+ if (options?.email) body.email = options.email;
2066
+ if (traits) body.traits = traits;
1244
2067
  const result = await s.http.request("POST", "/identity/alias", {
1245
- body: { userId, anonymousId: s.identity.anonymousId }
2068
+ body
1246
2069
  });
1247
2070
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
1248
2071
  s.developerUserId = userId;
1249
2072
  return result;
1250
2073
  }
2074
+ /**
2075
+ * Register super-properties — Mixpanel pattern. Once set, every
2076
+ * subsequent event of THIS SDK instance carries these keys on its
2077
+ * properties bag automatically.
2078
+ *
2079
+ * Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
2080
+ * Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
2081
+ *
2082
+ * Values that are `null` are deleted (the explicit "stop tracking
2083
+ * this key" idiom). Returns the resulting bag.
2084
+ *
2085
+ * Sanitised through `validateEventProperties` so a `{ avatar: File }`
2086
+ * payload can't poison the queue at flush time.
2087
+ */
2088
+ register(properties) {
2089
+ const s = this.requireStarted();
2090
+ const validation = validateEventProperties(properties);
2091
+ return s.superProps.register(validation.properties);
2092
+ }
2093
+ /** Remove a single super-property key. Idempotent. */
2094
+ unregister(key) {
2095
+ const s = this.requireStarted();
2096
+ s.superProps.unregister(key);
2097
+ }
2098
+ /** Snapshot of the current super-property bag. */
2099
+ getSuperProperties() {
2100
+ if (!this.state) return {};
2101
+ return this.state.superProps.getSuperProperties();
2102
+ }
2103
+ /**
2104
+ * Associate the current user with a group (org, team, account, etc.).
2105
+ * Mixpanel / Segment "Group Analytics" pattern.
2106
+ *
2107
+ * Crossdeck.group("org", "acme_inc");
2108
+ * Crossdeck.group("team", "design", { headcount: 12 });
2109
+ *
2110
+ * Once set, every subsequent event carries `$groups.<type>: id` on
2111
+ * its properties bag, enabling B2B dashboards ("how is Acme using
2112
+ * the product"). Pass `id: null` to clear a group membership.
2113
+ */
2114
+ group(type, id, traits) {
2115
+ const s = this.requireStarted();
2116
+ if (!type) {
2117
+ throw new CrossdeckError({
2118
+ type: "invalid_request_error",
2119
+ code: "missing_group_type",
2120
+ message: "group(type, id) requires a non-empty type."
2121
+ });
2122
+ }
2123
+ const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
2124
+ s.superProps.setGroup(type, id, sanitisedTraits);
2125
+ }
2126
+ /** Snapshot of the current groups map keyed by type. */
2127
+ getGroups() {
2128
+ if (!this.state) return {};
2129
+ return this.state.superProps.getGroups();
2130
+ }
2131
+ /**
2132
+ * Update consent state. Three independent dimensions:
2133
+ *
2134
+ * analytics — track() + identify() + auto-emissions
2135
+ * marketing — paid-traffic click IDs + referrer URL on events
2136
+ * errors — Web Vitals + (future) error reporting
2137
+ *
2138
+ * Each defaults to `true` (granted). Pass partial state — only the
2139
+ * keys you provide are changed.
2140
+ *
2141
+ * Crossdeck.consent({ analytics: false });
2142
+ * Crossdeck.consent({ marketing: true, errors: true });
2143
+ *
2144
+ * DNT-derived denies cannot be flipped back on; if the browser said
2145
+ * "don't track" we don't track even if the developer code disagrees.
2146
+ */
2147
+ consent(state) {
2148
+ const s = this.requireStarted();
2149
+ const next = s.consent.set(state);
2150
+ s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
2151
+ return next;
2152
+ }
2153
+ /** Snapshot of the current consent state. */
2154
+ consentStatus() {
2155
+ if (!this.state) {
2156
+ return { analytics: true, marketing: true, errors: true };
2157
+ }
2158
+ return this.state.consent.get();
2159
+ }
2160
+ /**
2161
+ * GDPR/CCPA "right to be forgotten" — calls the backend's
2162
+ * /v1/identity/forget endpoint to schedule a server-side deletion of
2163
+ * the customer's events and profile, then wipes all local state
2164
+ * (identity, entitlements, queue, super-props, persistent stores).
2165
+ *
2166
+ * Idempotent. Safe to call when no identity has been established
2167
+ * (it just wipes the empty local state).
2168
+ *
2169
+ * After forget() resolves, the SDK is in the same shape as if the
2170
+ * developer had called `Crossdeck.reset()` — a fresh anonymousId is
2171
+ * minted and the next session is a brand new identity-graph entry.
2172
+ */
2173
+ async forget() {
2174
+ const s = this.requireStarted();
2175
+ const identityQuery = this.identityQueryParams();
2176
+ try {
2177
+ await s.http.request("POST", "/identity/forget", {
2178
+ body: {
2179
+ // Send every identity hint we hold; the server resolves the
2180
+ // canonical customer record and queues deletion. Missing
2181
+ // endpoint (older backend) gracefully degrades — local state
2182
+ // still wipes via the reset() call below.
2183
+ ...identityQuery
2184
+ }
2185
+ });
2186
+ } catch (err) {
2187
+ s.debug.emit(
2188
+ "sdk.consent_denied",
2189
+ `forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
2190
+ );
2191
+ }
2192
+ this.reset();
2193
+ }
1251
2194
  /**
1252
2195
  * Read the current customer's active entitlements from the server.
1253
2196
  * Updates the local cache so subsequent isEntitled() calls answer
@@ -1325,6 +2268,17 @@ var CrossdeckClient = class {
1325
2268
  message: "track(name) requires a non-empty name."
1326
2269
  });
1327
2270
  }
2271
+ const isWebVital = name.startsWith("webvitals.");
2272
+ const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
2273
+ if (!consentGateOk) {
2274
+ if (s.debug.enabled) {
2275
+ s.debug.emit(
2276
+ "sdk.consent_denied",
2277
+ `Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
2278
+ );
2279
+ }
2280
+ return;
2281
+ }
1328
2282
  if (s.debug.enabled && properties) {
1329
2283
  const flagged = findSensitivePropertyKeys(properties);
1330
2284
  if (flagged.length > 0) {
@@ -1341,9 +2295,21 @@ var CrossdeckClient = class {
1341
2295
  "Using anonymous user until identify(userId) is called."
1342
2296
  );
1343
2297
  }
2298
+ const validation = validateEventProperties(properties);
2299
+ if (s.debug.enabled && validation.warnings.length > 0) {
2300
+ for (const w of validation.warnings) {
2301
+ s.debug.emit(
2302
+ "sdk.property_coerced",
2303
+ `Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2304
+ { eventName: name, key: w.key, kind: w.kind }
2305
+ );
2306
+ }
2307
+ }
1344
2308
  const enriched = { ...s.deviceInfo };
1345
2309
  const sessionId = s.autoTracker?.currentSessionId;
1346
2310
  if (sessionId) enriched.sessionId = sessionId;
2311
+ const pageviewId = s.autoTracker?.currentPageviewId;
2312
+ if (pageviewId) enriched.pageviewId = pageviewId;
1347
2313
  const acquisition = s.autoTracker?.currentAcquisition;
1348
2314
  if (acquisition) {
1349
2315
  if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
@@ -1351,14 +2317,31 @@ var CrossdeckClient = class {
1351
2317
  if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1352
2318
  if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1353
2319
  if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1354
- if (acquisition.referrer) enriched.referrer = acquisition.referrer;
2320
+ if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
2321
+ if (s.consent.marketing) {
2322
+ if (acquisition.gclid) enriched.gclid = acquisition.gclid;
2323
+ if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
2324
+ if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
2325
+ if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
2326
+ if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
2327
+ if (acquisition.twclid) enriched.twclid = acquisition.twclid;
2328
+ }
2329
+ }
2330
+ const supers = s.superProps.getSuperProperties();
2331
+ for (const k of Object.keys(supers)) {
2332
+ if (!(k in enriched)) enriched[k] = supers[k];
1355
2333
  }
1356
- if (properties) Object.assign(enriched, properties);
2334
+ const groupIds = s.superProps.getGroupIds();
2335
+ if (Object.keys(groupIds).length > 0) {
2336
+ enriched.$groups = groupIds;
2337
+ }
2338
+ Object.assign(enriched, validation.properties);
2339
+ const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
1357
2340
  const event = {
1358
2341
  eventId: this.mintEventId(),
1359
2342
  name,
1360
2343
  timestamp: Date.now(),
1361
- properties: enriched
2344
+ properties: finalProperties
1362
2345
  };
1363
2346
  Object.assign(event, this.identityHintForEvent());
1364
2347
  s.events.enqueue(event);
@@ -1436,7 +2419,12 @@ var CrossdeckClient = class {
1436
2419
  */
1437
2420
  async heartbeat() {
1438
2421
  const s = this.requireStarted();
1439
- return await s.http.request("GET", "/sdk/heartbeat");
2422
+ const result = await s.http.request("GET", "/sdk/heartbeat");
2423
+ if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
2424
+ s.lastServerTime = result.serverTime;
2425
+ s.lastClientTime = Date.now();
2426
+ }
2427
+ return result;
1440
2428
  }
1441
2429
  /**
1442
2430
  * Wipe persisted identity + entitlement cache. Use on logout. The
@@ -1455,6 +2443,7 @@ var CrossdeckClient = class {
1455
2443
  this.state.identity.reset();
1456
2444
  this.state.entitlements.clear();
1457
2445
  this.state.events.reset();
2446
+ this.state.superProps.clear();
1458
2447
  this.state.developerUserId = null;
1459
2448
  if (this.state.autoTracker) {
1460
2449
  const tracker = new AutoTracker(
@@ -1482,17 +2471,21 @@ var CrossdeckClient = class {
1482
2471
  developerUserId: null,
1483
2472
  sdkVersion: null,
1484
2473
  baseUrl: null,
1485
- entitlements: { count: 0, lastUpdated: 0 },
2474
+ clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
2475
+ entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
1486
2476
  events: {
1487
2477
  buffered: 0,
1488
2478
  dropped: 0,
1489
2479
  inFlight: 0,
1490
2480
  lastFlushAt: 0,
1491
- lastError: null
2481
+ lastError: null,
2482
+ consecutiveFailures: 0,
2483
+ nextRetryAt: null
1492
2484
  }
1493
2485
  };
1494
2486
  }
1495
2487
  const s = this.state;
2488
+ const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
1496
2489
  return {
1497
2490
  started: true,
1498
2491
  anonymousId: s.identity.anonymousId,
@@ -1500,9 +2493,15 @@ var CrossdeckClient = class {
1500
2493
  developerUserId: s.developerUserId,
1501
2494
  sdkVersion: s.options.sdkVersion,
1502
2495
  baseUrl: s.options.baseUrl,
2496
+ clock: {
2497
+ lastServerTime: s.lastServerTime,
2498
+ lastClientTime: s.lastClientTime,
2499
+ skewMs
2500
+ },
1503
2501
  entitlements: {
1504
2502
  count: s.entitlements.list().length,
1505
- lastUpdated: s.entitlements.freshness
2503
+ lastUpdated: s.entitlements.freshness,
2504
+ listenerErrors: s.entitlements.listenerErrors
1506
2505
  },
1507
2506
  events: s.events.getStats()
1508
2507
  };
@@ -1569,6 +2568,7 @@ function inferEnvFromKey(publicKey) {
1569
2568
  }
1570
2569
  function isLocalHostname() {
1571
2570
  const w = globalThis.window;
2571
+ if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
1572
2572
  const hostname = w?.location?.hostname;
1573
2573
  if (!hostname) return false;
1574
2574
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
@@ -1581,7 +2581,13 @@ function isLocalHostname() {
1581
2581
  }
1582
2582
  function resolveAutoTrack(input) {
1583
2583
  if (input === false) {
1584
- return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
2584
+ return {
2585
+ sessions: false,
2586
+ pageViews: false,
2587
+ deviceInfo: false,
2588
+ clicks: false,
2589
+ webVitals: false
2590
+ };
1585
2591
  }
1586
2592
  if (input === void 0 || input === true) {
1587
2593
  return { ...DEFAULT_AUTO_TRACK };
@@ -1590,7 +2596,8 @@ function resolveAutoTrack(input) {
1590
2596
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1591
2597
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1592
2598
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1593
- clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
2599
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
2600
+ webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
1594
2601
  };
1595
2602
  }
1596
2603
  function installUnloadFlush(onUnload) {