@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.mjs CHANGED
@@ -10,11 +10,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
10
10
  this.code = payload.code;
11
11
  this.requestId = payload.requestId;
12
12
  this.status = payload.status;
13
+ this.retryAfterMs = payload.retryAfterMs;
13
14
  Object.setPrototypeOf(this, _CrossdeckError.prototype);
14
15
  }
15
16
  };
16
17
  async function crossdeckErrorFromResponse(res) {
17
18
  const requestId = res.headers.get("x-request-id") ?? void 0;
19
+ const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
18
20
  let body;
19
21
  try {
20
22
  body = await res.json();
@@ -28,7 +30,8 @@ async function crossdeckErrorFromResponse(res) {
28
30
  code: envelope.code,
29
31
  message: envelope.message ?? `HTTP ${res.status}`,
30
32
  requestId: envelope.request_id ?? requestId,
31
- status: res.status
33
+ status: res.status,
34
+ retryAfterMs
32
35
  });
33
36
  }
34
37
  return new CrossdeckError({
@@ -36,9 +39,25 @@ async function crossdeckErrorFromResponse(res) {
36
39
  code: `http_${res.status}`,
37
40
  message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
38
41
  requestId,
39
- status: res.status
42
+ status: res.status,
43
+ retryAfterMs
40
44
  });
41
45
  }
46
+ function parseRetryAfterHeader(value) {
47
+ if (!value) return void 0;
48
+ const trimmed = value.trim();
49
+ if (!trimmed) return void 0;
50
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
51
+ const secs = Number(trimmed);
52
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
53
+ return Math.round(secs * 1e3);
54
+ }
55
+ if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
56
+ const target = Date.parse(trimmed);
57
+ if (!Number.isFinite(target)) return void 0;
58
+ const delta = target - Date.now();
59
+ return delta > 0 ? delta : 0;
60
+ }
42
61
  function typeMapForStatus(status) {
43
62
  if (status === 401) return "authentication_error";
44
63
  if (status === 403) return "permission_error";
@@ -49,8 +68,9 @@ function typeMapForStatus(status) {
49
68
 
50
69
  // src/http.ts
51
70
  var SDK_NAME = "@cross-deck/web";
52
- var SDK_VERSION = "0.6.0";
71
+ var SDK_VERSION = "0.10.0";
53
72
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
73
+ var DEFAULT_TIMEOUT_MS = 15e3;
54
74
  var HttpClient = class {
55
75
  constructor(config) {
56
76
  this.config = config;
@@ -74,25 +94,38 @@ var HttpClient = class {
74
94
  "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
75
95
  Accept: "application/json"
76
96
  };
97
+ if (options.idempotencyKey) {
98
+ headers["Idempotency-Key"] = options.idempotencyKey;
99
+ }
77
100
  let bodyInit;
78
101
  if (options.body !== void 0) {
79
102
  headers["Content-Type"] = "application/json";
80
103
  bodyInit = JSON.stringify(options.body);
81
104
  }
105
+ const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
106
+ const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
107
+ let timeoutHandle = null;
108
+ if (controller && effectiveTimeout > 0) {
109
+ timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
110
+ }
82
111
  let response;
83
112
  try {
84
113
  response = await fetch(url, {
85
114
  method,
86
115
  headers,
87
116
  body: bodyInit,
88
- keepalive: options.keepalive === true
117
+ keepalive: options.keepalive === true,
118
+ signal: controller?.signal
89
119
  });
90
120
  } catch (err) {
121
+ const aborted = controller?.signal?.aborted === true;
91
122
  throw new CrossdeckError({
92
123
  type: "network_error",
93
- code: "fetch_failed",
94
- message: err instanceof Error ? err.message : "fetch failed"
124
+ code: aborted ? "request_timeout" : "fetch_failed",
125
+ message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
95
126
  });
127
+ } finally {
128
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
96
129
  }
97
130
  if (!response.ok) {
98
131
  throw await crossdeckErrorFromResponse(response);
@@ -289,6 +322,7 @@ var EntitlementCache = class {
289
322
  this.all = [];
290
323
  this.lastUpdated = 0;
291
324
  this.listeners = /* @__PURE__ */ new Set();
325
+ this.listenerErrorCount = 0;
292
326
  }
293
327
  /** Sync read — true iff the entitlement key is currently active. */
294
328
  isEntitled(key) {
@@ -302,6 +336,15 @@ var EntitlementCache = class {
302
336
  get freshness() {
303
337
  return this.lastUpdated;
304
338
  }
339
+ /**
340
+ * Cumulative count of listener invocations that threw. Listener errors
341
+ * are swallowed (a buggy consumer must not crash the SDK) but the
342
+ * counter lets diagnostics() surface "you have a broken subscriber"
343
+ * without putting the developer in a debug session.
344
+ */
345
+ get listenerErrors() {
346
+ return this.listenerErrorCount;
347
+ }
305
348
  /**
306
349
  * Replace the cache with a fresh server response. The backend already
307
350
  * filters to active + env-matching, so we don't re-filter — just trust
@@ -355,11 +398,54 @@ var EntitlementCache = class {
355
398
  try {
356
399
  listener(snapshot);
357
400
  } catch {
401
+ this.listenerErrorCount += 1;
358
402
  }
359
403
  }
360
404
  }
361
405
  };
362
406
 
407
+ // src/retry-policy.ts
408
+ var DEFAULT_BASE = 1e3;
409
+ var DEFAULT_MAX = 6e4;
410
+ var DEFAULT_FACTOR = 2;
411
+ var DEFAULT_WARN = 8;
412
+ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
413
+ const base = options.baseMs ?? DEFAULT_BASE;
414
+ const max = options.maxMs ?? DEFAULT_MAX;
415
+ const factor = options.factor ?? DEFAULT_FACTOR;
416
+ const safeAttempts = Math.min(attempts, 30);
417
+ const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
418
+ const jittered = ceiling * random();
419
+ if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
420
+ return Math.min(max, retryAfterMs);
421
+ }
422
+ return Math.max(0, Math.round(jittered));
423
+ }
424
+ var RetryPolicy = class {
425
+ constructor(options = {}) {
426
+ this.options = options;
427
+ this.attempts = 0;
428
+ }
429
+ /** How many consecutive failures since the last success. */
430
+ get consecutiveFailures() {
431
+ return this.attempts;
432
+ }
433
+ /** Whether we've crossed the failuresBeforeWarn threshold. */
434
+ get isWarning() {
435
+ return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
436
+ }
437
+ /** Schedule-time delay for the NEXT retry. Increments the counter. */
438
+ nextDelay(retryAfterMs, random = Math.random) {
439
+ const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
440
+ this.attempts += 1;
441
+ return delay;
442
+ }
443
+ /** Mark a successful flush — reset the counter. */
444
+ recordSuccess() {
445
+ this.attempts = 0;
446
+ }
447
+ };
448
+
363
449
  // src/event-queue.ts
364
450
  var HARD_BUFFER_CAP = 1e3;
365
451
  var EventQueue = class {
@@ -372,6 +458,22 @@ var EventQueue = class {
372
458
  this.lastError = null;
373
459
  this.cancelTimer = null;
374
460
  this.firstFlushFired = false;
461
+ this.nextRetryAt = null;
462
+ this.retry = new RetryPolicy(cfg.retry ?? {});
463
+ this.persistent = cfg.persistentStore ?? null;
464
+ if (this.persistent) {
465
+ const restored = this.persistent.load();
466
+ if (restored.length > 0) {
467
+ if (restored.length > HARD_BUFFER_CAP) {
468
+ this.dropped += restored.length - HARD_BUFFER_CAP;
469
+ this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
470
+ } else {
471
+ this.buffer = restored;
472
+ }
473
+ this.cfg.onBufferChange?.(this.buffer.length);
474
+ this.scheduleIdleFlush();
475
+ }
476
+ }
375
477
  }
376
478
  enqueue(event) {
377
479
  this.buffer.push(event);
@@ -381,6 +483,8 @@ var EventQueue = class {
381
483
  this.dropped += overflow;
382
484
  this.cfg.onDrop?.(overflow);
383
485
  }
486
+ this.cfg.onBufferChange?.(this.buffer.length);
487
+ this.persistent?.save(this.buffer);
384
488
  if (this.buffer.length >= this.cfg.batchSize) {
385
489
  void this.flush();
386
490
  } else {
@@ -390,7 +494,7 @@ var EventQueue = class {
390
494
  /**
391
495
  * Flush the buffer to /v1/events. Resolves when the network call
392
496
  * completes (success or failure). On failure, events stay in the
393
- * buffer for the next flush attempt.
497
+ * buffer for the next scheduled retry.
394
498
  *
395
499
  * `options.keepalive` marks the underlying fetch as keepalive so the
396
500
  * browser keeps the request alive past page unload. Use this for
@@ -399,25 +503,32 @@ var EventQueue = class {
399
503
  async flush(options = {}) {
400
504
  if (this.buffer.length === 0) return null;
401
505
  this.cancelTimerIfSet();
506
+ this.nextRetryAt = null;
402
507
  const batch = this.buffer.splice(0);
508
+ const batchId = this.mintBatchId();
403
509
  this.inFlight += batch.length;
510
+ this.persistent?.save(this.buffer);
511
+ this.cfg.onBufferChange?.(this.buffer.length);
404
512
  try {
405
513
  const env = this.cfg.envelope();
406
514
  const result = await this.cfg.http.request("POST", "/events", {
407
515
  body: {
408
516
  // NorthStar §13.1 batch envelope. The backend validates these
409
- // against the API-key-resolved app and rejects mismatches loudly
410
- // (env_mismatch).
517
+ // against the API-key-resolved app and rejects mismatches
518
+ // loudly (env_mismatch).
411
519
  appId: env.appId,
412
520
  environment: env.environment,
413
521
  sdk: env.sdk,
414
522
  events: batch
415
523
  },
416
- keepalive: options.keepalive === true
524
+ keepalive: options.keepalive === true,
525
+ idempotencyKey: batchId
417
526
  });
418
527
  this.lastFlushAt = Date.now();
419
528
  this.lastError = null;
420
529
  this.inFlight -= batch.length;
530
+ this.retry.recordSuccess();
531
+ this.persistent?.save(this.buffer);
421
532
  if (!this.firstFlushFired) {
422
533
  this.firstFlushFired = true;
423
534
  this.cfg.onFirstFlushSuccess?.();
@@ -426,18 +537,33 @@ var EventQueue = class {
426
537
  } catch (err) {
427
538
  this.buffer.unshift(...batch);
428
539
  this.inFlight -= batch.length;
429
- this.lastError = err instanceof Error ? err.message : String(err);
430
- this.scheduleIdleFlush();
540
+ const message = err instanceof Error ? err.message : String(err);
541
+ this.lastError = message;
542
+ this.persistent?.save(this.buffer);
543
+ this.cfg.onBufferChange?.(this.buffer.length);
544
+ const retryAfterMs = extractRetryAfterMs(err);
545
+ const delay = this.retry.nextDelay(retryAfterMs);
546
+ this.scheduleRetry(delay);
547
+ this.cfg.onRetryScheduled?.({
548
+ delayMs: delay,
549
+ consecutiveFailures: this.retry.consecutiveFailures,
550
+ retryAfterMs,
551
+ lastError: message
552
+ });
431
553
  return null;
432
554
  }
433
555
  }
434
- /** Cancel any pending timer and clear in-memory state. */
556
+ /** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
435
557
  reset() {
436
558
  this.cancelTimerIfSet();
559
+ this.nextRetryAt = null;
437
560
  this.buffer = [];
438
561
  this.dropped = 0;
439
562
  this.inFlight = 0;
440
563
  this.lastError = null;
564
+ this.retry.recordSuccess();
565
+ this.persistent?.clear();
566
+ this.cfg.onBufferChange?.(0);
441
567
  }
442
568
  getStats() {
443
569
  return {
@@ -445,9 +571,12 @@ var EventQueue = class {
445
571
  dropped: this.dropped,
446
572
  inFlight: this.inFlight,
447
573
  lastFlushAt: this.lastFlushAt,
448
- lastError: this.lastError
574
+ lastError: this.lastError,
575
+ consecutiveFailures: this.retry.consecutiveFailures,
576
+ nextRetryAt: this.nextRetryAt
449
577
  };
450
578
  }
579
+ // ---------- internal scheduling ----------
451
580
  scheduleIdleFlush() {
452
581
  this.cancelTimerIfSet();
453
582
  const sched = this.cfg.scheduler ?? defaultScheduler;
@@ -455,13 +584,31 @@ var EventQueue = class {
455
584
  void this.flush();
456
585
  }, this.cfg.intervalMs);
457
586
  }
587
+ scheduleRetry(delayMs) {
588
+ this.cancelTimerIfSet();
589
+ this.nextRetryAt = Date.now() + delayMs;
590
+ const sched = this.cfg.scheduler ?? defaultScheduler;
591
+ this.cancelTimer = sched(() => {
592
+ void this.flush();
593
+ }, delayMs);
594
+ }
458
595
  cancelTimerIfSet() {
459
596
  if (this.cancelTimer) {
460
597
  this.cancelTimer();
461
598
  this.cancelTimer = null;
462
599
  }
463
600
  }
601
+ mintBatchId() {
602
+ return `batch_${Date.now().toString(36)}${randomChars(10)}`;
603
+ }
464
604
  };
605
+ function extractRetryAfterMs(err) {
606
+ if (err && typeof err === "object" && "retryAfterMs" in err) {
607
+ const v = err.retryAfterMs;
608
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
609
+ }
610
+ return void 0;
611
+ }
465
612
  function defaultScheduler(fn, ms) {
466
613
  const id = setTimeout(fn, ms);
467
614
  if (typeof id.unref === "function") {
@@ -473,6 +620,87 @@ function defaultScheduler(fn, ms) {
473
620
  return () => clearTimeout(id);
474
621
  }
475
622
 
623
+ // src/event-storage.ts
624
+ var PersistentEventStore = class {
625
+ constructor(options) {
626
+ this.options = options;
627
+ this.writeScheduled = false;
628
+ // Pending events captured on the most recent write request. We keep
629
+ // the latest snapshot ref so a debounced write always picks up the
630
+ // freshest buffer state.
631
+ this.pendingSnapshot = null;
632
+ this.key = `${options.prefix}queue.v1`;
633
+ }
634
+ /**
635
+ * Read the persisted queue on boot. Returns an empty array (with no
636
+ * warning) when nothing is stored, the blob is malformed, or storage
637
+ * is unavailable. Caller is responsible for treating duplicates from
638
+ * the persisted queue as the SAME events (eventId-based dedup).
639
+ */
640
+ load() {
641
+ let raw;
642
+ try {
643
+ raw = this.options.storage.getItem(this.key);
644
+ } catch {
645
+ return [];
646
+ }
647
+ if (!raw) return [];
648
+ try {
649
+ const parsed = JSON.parse(raw);
650
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
651
+ return [];
652
+ }
653
+ return parsed.events;
654
+ } catch {
655
+ return [];
656
+ }
657
+ }
658
+ /**
659
+ * Schedule a write of the current buffer. Debounced via microtask so
660
+ * a burst of enqueue() calls coalesces into one persistence write.
661
+ * Writes are best-effort: if storage throws (quota, private mode),
662
+ * we swallow and rely on the in-memory buffer.
663
+ */
664
+ save(snapshot) {
665
+ this.pendingSnapshot = snapshot.slice();
666
+ if (this.writeScheduled) return;
667
+ this.writeScheduled = true;
668
+ queueMicrotask(() => this.flushWrite());
669
+ }
670
+ /** Synchronous variant for terminal flushes (pagehide / beforeunload). */
671
+ saveSync(snapshot) {
672
+ this.pendingSnapshot = snapshot.slice();
673
+ this.flushWrite();
674
+ }
675
+ /** Wipe the persisted blob. Used by reset() (logout). */
676
+ clear() {
677
+ this.pendingSnapshot = null;
678
+ this.writeScheduled = false;
679
+ try {
680
+ this.options.storage.removeItem(this.key);
681
+ } catch {
682
+ }
683
+ }
684
+ flushWrite() {
685
+ this.writeScheduled = false;
686
+ const snapshot = this.pendingSnapshot;
687
+ this.pendingSnapshot = null;
688
+ if (snapshot === null) return;
689
+ if (snapshot.length === 0) {
690
+ try {
691
+ this.options.storage.removeItem(this.key);
692
+ } catch {
693
+ }
694
+ return;
695
+ }
696
+ const blob = { version: 1, events: snapshot };
697
+ try {
698
+ this.options.storage.setItem(this.key, JSON.stringify(blob));
699
+ } catch {
700
+ }
701
+ }
702
+ };
703
+
476
704
  // src/storage.ts
477
705
  var MemoryStorage = class {
478
706
  constructor() {
@@ -663,7 +891,8 @@ var DEFAULT_AUTO_TRACK = {
663
891
  sessions: true,
664
892
  pageViews: true,
665
893
  deviceInfo: true,
666
- clicks: true
894
+ clicks: true,
895
+ webVitals: true
667
896
  };
668
897
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
669
898
  var EMPTY_ACQUISITION = {
@@ -672,7 +901,13 @@ var EMPTY_ACQUISITION = {
672
901
  utm_campaign: "",
673
902
  utm_content: "",
674
903
  utm_term: "",
675
- referrer: ""
904
+ referrer: "",
905
+ gclid: "",
906
+ fbclid: "",
907
+ msclkid: "",
908
+ ttclid: "",
909
+ li_fat_id: "",
910
+ twclid: ""
676
911
  };
677
912
  var AutoTracker = class {
678
913
  constructor(cfg, track) {
@@ -680,6 +915,17 @@ var AutoTracker = class {
680
915
  this.track = track;
681
916
  this.session = null;
682
917
  this.cleanups = [];
918
+ /**
919
+ * Stable per-page-view identifier. Minted at every `page.viewed`
920
+ * emission and attached to every subsequent event until the next
921
+ * `page.viewed`. Lets dashboards correlate "user clicked X" to
922
+ * "user viewed page Y" without timestamp arithmetic — the canonical
923
+ * Mixpanel `$current_url` / Segment `pageId` pattern.
924
+ *
925
+ * Null until the first `page.viewed` fires (which happens at SDK
926
+ * install if `autoTrack.pageViews !== false`).
927
+ */
928
+ this.pageviewId = null;
683
929
  }
684
930
  install() {
685
931
  if (!isBrowserSafe()) return;
@@ -710,6 +956,10 @@ var AutoTracker = class {
710
956
  get currentSessionId() {
711
957
  return this.session?.sessionId ?? null;
712
958
  }
959
+ /** Stable per-page-view ID. Null before the first page.viewed has fired. */
960
+ get currentPageviewId() {
961
+ return this.pageviewId;
962
+ }
713
963
  /**
714
964
  * Per-session acquisition context — utm_* + referrer, captured once
715
965
  * at session start. Returns empty strings when there's no session
@@ -790,7 +1040,9 @@ var AutoTracker = class {
790
1040
  if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
791
1041
  lastFiredAt = now;
792
1042
  lastFiredUrl = url;
1043
+ this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
793
1044
  this.track("page.viewed", {
1045
+ pageviewId: this.pageviewId,
794
1046
  path: loc.pathname,
795
1047
  url,
796
1048
  search: loc.search || void 0,
@@ -994,6 +1246,12 @@ function captureAcquisition() {
994
1246
  result.utm_campaign = params.get("utm_campaign") ?? "";
995
1247
  result.utm_content = params.get("utm_content") ?? "";
996
1248
  result.utm_term = params.get("utm_term") ?? "";
1249
+ result.gclid = params.get("gclid") ?? "";
1250
+ result.fbclid = params.get("fbclid") ?? "";
1251
+ result.msclkid = params.get("msclkid") ?? "";
1252
+ result.ttclid = params.get("ttclid") ?? "";
1253
+ result.li_fat_id = params.get("li_fat_id") ?? "";
1254
+ result.twclid = params.get("twclid") ?? "";
997
1255
  } catch {
998
1256
  }
999
1257
  try {
@@ -1051,6 +1309,490 @@ function safeJson(obj) {
1051
1309
  }
1052
1310
  }
1053
1311
 
1312
+ // src/event-validation.ts
1313
+ var DEFAULT_MAX_STRING = 1024;
1314
+ var DEFAULT_MAX_BYTES = 8 * 1024;
1315
+ var DEFAULT_MAX_DEPTH = 5;
1316
+ function validateEventProperties(input, options = {}) {
1317
+ const warnings = [];
1318
+ if (!input) return { properties: {}, warnings };
1319
+ const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1320
+ const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1321
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1322
+ const seen = /* @__PURE__ */ new WeakSet();
1323
+ const visit = (value, key, depth) => {
1324
+ if (depth > maxDepth) {
1325
+ warnings.push({ kind: "depth_exceeded", key });
1326
+ return { keep: true, value: "[depth-exceeded]" };
1327
+ }
1328
+ if (value === null) return { keep: true, value: null };
1329
+ const t = typeof value;
1330
+ if (t === "string") {
1331
+ const s = value;
1332
+ if (s.length > maxStringLength) {
1333
+ warnings.push({ kind: "truncated_string", key });
1334
+ return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
1335
+ }
1336
+ return { keep: true, value: s };
1337
+ }
1338
+ if (t === "number") {
1339
+ if (!Number.isFinite(value)) {
1340
+ warnings.push({ kind: "non_serialisable", key });
1341
+ return { keep: true, value: null };
1342
+ }
1343
+ return { keep: true, value };
1344
+ }
1345
+ if (t === "boolean") return { keep: true, value };
1346
+ if (t === "bigint") {
1347
+ warnings.push({ kind: "coerced_bigint", key });
1348
+ return { keep: true, value: value.toString() };
1349
+ }
1350
+ if (t === "function") {
1351
+ warnings.push({ kind: "dropped_function", key });
1352
+ return { keep: false, value: void 0 };
1353
+ }
1354
+ if (t === "symbol") {
1355
+ warnings.push({ kind: "dropped_symbol", key });
1356
+ return { keep: false, value: void 0 };
1357
+ }
1358
+ if (t === "undefined") {
1359
+ warnings.push({ kind: "dropped_undefined", key });
1360
+ return { keep: false, value: void 0 };
1361
+ }
1362
+ if (value instanceof Date) {
1363
+ warnings.push({ kind: "coerced_date", key });
1364
+ const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
1365
+ return { keep: true, value: iso };
1366
+ }
1367
+ if (value instanceof Error) {
1368
+ warnings.push({ kind: "coerced_error", key });
1369
+ return {
1370
+ keep: true,
1371
+ value: {
1372
+ name: value.name,
1373
+ message: value.message,
1374
+ stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
1375
+ }
1376
+ };
1377
+ }
1378
+ if (value instanceof Map) {
1379
+ warnings.push({ kind: "coerced_map", key });
1380
+ const obj = {};
1381
+ for (const [k, v] of value.entries()) {
1382
+ const subKey = typeof k === "string" ? k : String(k);
1383
+ const result = visit(v, `${key}.${subKey}`, depth + 1);
1384
+ if (result.keep) obj[subKey] = result.value;
1385
+ }
1386
+ return { keep: true, value: obj };
1387
+ }
1388
+ if (value instanceof Set) {
1389
+ warnings.push({ kind: "coerced_set", key });
1390
+ const arr = [];
1391
+ let i = 0;
1392
+ for (const v of value.values()) {
1393
+ const result = visit(v, `${key}[${i}]`, depth + 1);
1394
+ if (result.keep) arr.push(result.value);
1395
+ i++;
1396
+ }
1397
+ return { keep: true, value: arr };
1398
+ }
1399
+ if (Array.isArray(value)) {
1400
+ if (seen.has(value)) {
1401
+ warnings.push({ kind: "circular_reference", key });
1402
+ return { keep: true, value: "[circular]" };
1403
+ }
1404
+ seen.add(value);
1405
+ const out = [];
1406
+ for (let i = 0; i < value.length; i++) {
1407
+ const result = visit(value[i], `${key}[${i}]`, depth + 1);
1408
+ if (result.keep) out.push(result.value);
1409
+ }
1410
+ return { keep: true, value: out };
1411
+ }
1412
+ if (t === "object") {
1413
+ const obj = value;
1414
+ if (seen.has(obj)) {
1415
+ warnings.push({ kind: "circular_reference", key });
1416
+ return { keep: true, value: "[circular]" };
1417
+ }
1418
+ seen.add(obj);
1419
+ const out = {};
1420
+ for (const k of Object.keys(obj)) {
1421
+ const result = visit(obj[k], `${key}.${k}`, depth + 1);
1422
+ if (result.keep) out[k] = result.value;
1423
+ }
1424
+ return { keep: true, value: out };
1425
+ }
1426
+ warnings.push({ kind: "non_serialisable", key });
1427
+ try {
1428
+ return { keep: true, value: String(value) };
1429
+ } catch {
1430
+ return { keep: false, value: void 0 };
1431
+ }
1432
+ };
1433
+ const cleaned = {};
1434
+ for (const k of Object.keys(input)) {
1435
+ const result = visit(input[k], k, 0);
1436
+ if (result.keep) cleaned[k] = result.value;
1437
+ }
1438
+ const serialised = safeStringify(cleaned);
1439
+ if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
1440
+ warnings.push({ kind: "size_cap_exceeded", key: "*" });
1441
+ const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
1442
+ let currentSize = byteLength(serialised);
1443
+ for (const { k } of sizes) {
1444
+ if (currentSize <= maxBatchPropertyBytes) break;
1445
+ currentSize -= sizes.find((s) => s.k === k).size;
1446
+ delete cleaned[k];
1447
+ }
1448
+ cleaned.__truncated = true;
1449
+ }
1450
+ return { properties: cleaned, warnings };
1451
+ }
1452
+ function safeStringify(v) {
1453
+ try {
1454
+ return JSON.stringify(v) ?? null;
1455
+ } catch {
1456
+ return null;
1457
+ }
1458
+ }
1459
+ function byteLength(s) {
1460
+ if (typeof TextEncoder !== "undefined") {
1461
+ return new TextEncoder().encode(s).length;
1462
+ }
1463
+ return s.length * 4;
1464
+ }
1465
+
1466
+ // src/super-properties.ts
1467
+ var KEY_SUPER = "super_props";
1468
+ var KEY_GROUPS = "groups";
1469
+ var SuperPropertyStore = class {
1470
+ constructor(storage, prefix) {
1471
+ this.storage = storage;
1472
+ this.prefix = prefix;
1473
+ this.superProps = {};
1474
+ this.groups = {};
1475
+ this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
1476
+ this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
1477
+ }
1478
+ // ---------- super properties ----------
1479
+ /**
1480
+ * Merge new keys into the super-property bag. Returns a snapshot of
1481
+ * the resulting bag. Values that are `null` are deleted (Mixpanel
1482
+ * semantics — explicit null = "stop tracking this key").
1483
+ */
1484
+ register(props) {
1485
+ for (const [k, v] of Object.entries(props)) {
1486
+ if (v === null) {
1487
+ delete this.superProps[k];
1488
+ } else if (v !== void 0) {
1489
+ this.superProps[k] = v;
1490
+ }
1491
+ }
1492
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1493
+ return { ...this.superProps };
1494
+ }
1495
+ /** Remove a single super-property key. Idempotent. */
1496
+ unregister(key) {
1497
+ if (key in this.superProps) {
1498
+ delete this.superProps[key];
1499
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1500
+ }
1501
+ }
1502
+ /** Snapshot of the current super-property bag. */
1503
+ getSuperProperties() {
1504
+ return { ...this.superProps };
1505
+ }
1506
+ // ---------- groups ----------
1507
+ /**
1508
+ * Set a group membership. Passing `id: null` clears the membership
1509
+ * for that group type — the SDK stops attaching it to events.
1510
+ */
1511
+ setGroup(type, id, traits) {
1512
+ if (id === null) {
1513
+ delete this.groups[type];
1514
+ } else {
1515
+ this.groups[type] = traits !== void 0 ? { id, traits } : { id };
1516
+ }
1517
+ writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
1518
+ }
1519
+ /**
1520
+ * Snapshot of the current groups map, keyed by group type. Returned
1521
+ * shape mirrors what the SDK attaches to every event as
1522
+ * `$groups.{type}`. The `traits` sub-object is the most-recent
1523
+ * traits payload passed to `setGroup` for that type; null when none.
1524
+ */
1525
+ getGroups() {
1526
+ return JSON.parse(JSON.stringify(this.groups));
1527
+ }
1528
+ /**
1529
+ * The flat `{ type: id }` projection used for event-attachment. Stable
1530
+ * for fast every-event merge — we don't want to JSON-clone on each
1531
+ * track() call.
1532
+ */
1533
+ getGroupIds() {
1534
+ const out = {};
1535
+ for (const [type, info] of Object.entries(this.groups)) {
1536
+ out[type] = info.id;
1537
+ }
1538
+ return out;
1539
+ }
1540
+ /** Wipe both bags. Called by Crossdeck.reset() (logout). */
1541
+ clear() {
1542
+ this.superProps = {};
1543
+ this.groups = {};
1544
+ try {
1545
+ this.storage.removeItem(this.prefix + KEY_SUPER);
1546
+ } catch {
1547
+ }
1548
+ try {
1549
+ this.storage.removeItem(this.prefix + KEY_GROUPS);
1550
+ } catch {
1551
+ }
1552
+ }
1553
+ };
1554
+ function readJson(storage, key) {
1555
+ let raw;
1556
+ try {
1557
+ raw = storage.getItem(key);
1558
+ } catch {
1559
+ return null;
1560
+ }
1561
+ if (!raw) return null;
1562
+ try {
1563
+ return JSON.parse(raw);
1564
+ } catch {
1565
+ return null;
1566
+ }
1567
+ }
1568
+ function writeJson(storage, key, value) {
1569
+ try {
1570
+ storage.setItem(key, JSON.stringify(value));
1571
+ } catch {
1572
+ }
1573
+ }
1574
+
1575
+ // src/web-vitals.ts
1576
+ var WebVitalsTracker = class {
1577
+ constructor(cfg, report) {
1578
+ this.cfg = cfg;
1579
+ this.report = report;
1580
+ this.observers = [];
1581
+ this.flushed = /* @__PURE__ */ new Set();
1582
+ this.cls = 0;
1583
+ this.clsEntries = [];
1584
+ this.inp = 0;
1585
+ this.cleanups = [];
1586
+ }
1587
+ install() {
1588
+ if (!this.cfg.enabled) return;
1589
+ if (typeof PerformanceObserver === "undefined") return;
1590
+ if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
1591
+ const doc = globalThis.document;
1592
+ try {
1593
+ const navObserver = new PerformanceObserver((list) => {
1594
+ for (const entry of list.getEntries()) {
1595
+ const e = entry;
1596
+ if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
1597
+ this.flushed.add("ttfb");
1598
+ this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
1599
+ }
1600
+ }
1601
+ });
1602
+ navObserver.observe({ type: "navigation", buffered: true });
1603
+ this.observers.push(navObserver);
1604
+ } catch {
1605
+ }
1606
+ try {
1607
+ const paintObserver = new PerformanceObserver((list) => {
1608
+ for (const entry of list.getEntries()) {
1609
+ if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
1610
+ this.flushed.add("fcp");
1611
+ this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
1612
+ }
1613
+ }
1614
+ });
1615
+ paintObserver.observe({ type: "paint", buffered: true });
1616
+ this.observers.push(paintObserver);
1617
+ } catch {
1618
+ }
1619
+ let lcpValue = 0;
1620
+ try {
1621
+ const lcpObserver = new PerformanceObserver((list) => {
1622
+ const entries = list.getEntries();
1623
+ const last = entries[entries.length - 1];
1624
+ if (last) lcpValue = last.startTime;
1625
+ });
1626
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
1627
+ this.observers.push(lcpObserver);
1628
+ } catch {
1629
+ }
1630
+ try {
1631
+ const clsObserver = new PerformanceObserver((list) => {
1632
+ for (const entry of list.getEntries()) {
1633
+ const e = entry;
1634
+ if (typeof e.value === "number" && !e.hadRecentInput) {
1635
+ this.cls += e.value;
1636
+ this.clsEntries.push(entry);
1637
+ }
1638
+ }
1639
+ });
1640
+ clsObserver.observe({ type: "layout-shift", buffered: true });
1641
+ this.observers.push(clsObserver);
1642
+ } catch {
1643
+ }
1644
+ try {
1645
+ const eventObserver = new PerformanceObserver((list) => {
1646
+ for (const entry of list.getEntries()) {
1647
+ const e = entry;
1648
+ if (e.interactionId && e.duration > this.inp) {
1649
+ this.inp = e.duration;
1650
+ }
1651
+ }
1652
+ });
1653
+ try {
1654
+ eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
1655
+ } catch {
1656
+ eventObserver.observe({ type: "first-input", buffered: true });
1657
+ }
1658
+ this.observers.push(eventObserver);
1659
+ } catch {
1660
+ }
1661
+ const flush = () => {
1662
+ if (lcpValue > 0 && !this.flushed.has("lcp")) {
1663
+ this.flushed.add("lcp");
1664
+ this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
1665
+ }
1666
+ if (this.cls > 0 && !this.flushed.has("cls")) {
1667
+ this.flushed.add("cls");
1668
+ this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
1669
+ }
1670
+ if (this.inp > 0 && !this.flushed.has("inp")) {
1671
+ this.flushed.add("inp");
1672
+ this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
1673
+ }
1674
+ };
1675
+ const onHidden = () => {
1676
+ if (doc.visibilityState === "hidden") flush();
1677
+ };
1678
+ doc.addEventListener("visibilitychange", onHidden);
1679
+ globalThis.window.addEventListener("pagehide", flush);
1680
+ this.cleanups.push(() => {
1681
+ doc.removeEventListener("visibilitychange", onHidden);
1682
+ globalThis.window.removeEventListener("pagehide", flush);
1683
+ });
1684
+ }
1685
+ uninstall() {
1686
+ for (const o of this.observers) {
1687
+ try {
1688
+ o.disconnect();
1689
+ } catch {
1690
+ }
1691
+ }
1692
+ this.observers = [];
1693
+ for (const fn of this.cleanups.splice(0)) {
1694
+ try {
1695
+ fn();
1696
+ } catch {
1697
+ }
1698
+ }
1699
+ }
1700
+ };
1701
+
1702
+ // src/consent.ts
1703
+ var ALL_GRANTED = {
1704
+ analytics: true,
1705
+ marketing: true,
1706
+ errors: true
1707
+ };
1708
+ var ConsentManager = class {
1709
+ constructor(options) {
1710
+ this.state = { ...ALL_GRANTED };
1711
+ this.dntDenied = false;
1712
+ if (options?.respectDnt && this.detectDnt()) {
1713
+ this.dntDenied = true;
1714
+ this.state = { analytics: false, marketing: false, errors: false };
1715
+ }
1716
+ }
1717
+ /**
1718
+ * Merge new dimensions onto the current state. Returns the resulting
1719
+ * snapshot. DNT-derived denies cannot be flipped back on by a `set`
1720
+ * call — once the browser says "don't track", we don't track even if
1721
+ * the developer code disagrees. That's the contract.
1722
+ */
1723
+ set(partial) {
1724
+ if (this.dntDenied) return { ...this.state };
1725
+ for (const k of Object.keys(partial)) {
1726
+ const v = partial[k];
1727
+ if (typeof v === "boolean") this.state[k] = v;
1728
+ }
1729
+ return { ...this.state };
1730
+ }
1731
+ /** Snapshot of the current state. */
1732
+ get() {
1733
+ return { ...this.state };
1734
+ }
1735
+ /** Convenience getters for hot paths. */
1736
+ get analytics() {
1737
+ return this.state.analytics;
1738
+ }
1739
+ get marketing() {
1740
+ return this.state.marketing;
1741
+ }
1742
+ get errors() {
1743
+ return this.state.errors;
1744
+ }
1745
+ /** True iff the constructor detected and applied DNT. */
1746
+ get isDntDenied() {
1747
+ return this.dntDenied;
1748
+ }
1749
+ detectDnt() {
1750
+ try {
1751
+ const nav = globalThis.navigator;
1752
+ if (!nav) return false;
1753
+ const sources = [
1754
+ nav.doNotTrack,
1755
+ nav.msDoNotTrack,
1756
+ globalThis.doNotTrack
1757
+ ];
1758
+ return sources.some((v) => v === "1" || v === "yes");
1759
+ } catch {
1760
+ return false;
1761
+ }
1762
+ }
1763
+ };
1764
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1765
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1766
+ var REPLACEMENT_EMAIL = "[email]";
1767
+ var REPLACEMENT_CARD = "[card]";
1768
+ function scrubPii(value) {
1769
+ if (!value) return value;
1770
+ let out = value;
1771
+ if (EMAIL_PATTERN.test(out)) {
1772
+ out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1773
+ }
1774
+ EMAIL_PATTERN.lastIndex = 0;
1775
+ if (CARD_PATTERN.test(out)) {
1776
+ out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1777
+ }
1778
+ CARD_PATTERN.lastIndex = 0;
1779
+ return out;
1780
+ }
1781
+ function scrubPiiFromProperties(properties) {
1782
+ const out = {};
1783
+ for (const k of Object.keys(properties)) {
1784
+ const v = properties[k];
1785
+ if (typeof v === "string") {
1786
+ out[k] = scrubPii(v);
1787
+ } else if (Array.isArray(v)) {
1788
+ out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1789
+ } else {
1790
+ out[k] = v;
1791
+ }
1792
+ }
1793
+ return out;
1794
+ }
1795
+
1054
1796
  // src/crossdeck.ts
1055
1797
  var CrossdeckClient = class {
1056
1798
  constructor() {
@@ -1140,6 +1882,13 @@ var CrossdeckClient = class {
1140
1882
  const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
1141
1883
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
1142
1884
  const entitlements = new EntitlementCache();
1885
+ const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
1886
+ if (persistentEvents) {
1887
+ debug.emit(
1888
+ "sdk.queue_restored",
1889
+ "Restored persisted event queue from a prior session."
1890
+ );
1891
+ }
1143
1892
  const events = new EventQueue({
1144
1893
  http,
1145
1894
  batchSize: opts.eventFlushBatchSize,
@@ -1149,26 +1898,51 @@ var CrossdeckClient = class {
1149
1898
  environment: opts.environment,
1150
1899
  sdk: { name: SDK_NAME, version: opts.sdkVersion }
1151
1900
  }),
1901
+ persistentStore: persistentEvents ?? void 0,
1152
1902
  onFirstFlushSuccess: () => {
1153
1903
  debug.emit(
1154
1904
  "sdk.first_event_sent",
1155
1905
  "First telemetry event received. View it in Live Events.",
1156
1906
  { appId: opts.appId, environment: opts.environment }
1157
1907
  );
1908
+ },
1909
+ onRetryScheduled: (info) => {
1910
+ debug.emit(
1911
+ "sdk.flush_retry_scheduled",
1912
+ `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
1913
+ { ...info }
1914
+ );
1158
1915
  }
1159
1916
  });
1160
1917
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
1918
+ const superProps = new SuperPropertyStore(
1919
+ persistIdentity ? effectiveStorage : new MemoryStorage(),
1920
+ opts.storagePrefix
1921
+ );
1922
+ const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
1923
+ if (consent.isDntDenied) {
1924
+ debug.emit(
1925
+ "sdk.consent_dnt_applied",
1926
+ "Do Not Track detected \u2014 all tracking dimensions denied at init."
1927
+ );
1928
+ }
1161
1929
  this.state = {
1162
1930
  http,
1163
1931
  identity,
1164
1932
  entitlements,
1165
1933
  events,
1166
1934
  autoTracker: null,
1935
+ webVitals: null,
1936
+ superProps,
1937
+ consent,
1938
+ scrubPii: options.scrubPii !== false,
1167
1939
  deviceInfo,
1168
1940
  options: opts,
1169
1941
  debug,
1170
1942
  developerUserId: null,
1171
- uninstallUnloadFlush: null
1943
+ uninstallUnloadFlush: null,
1944
+ lastServerTime: null,
1945
+ lastClientTime: null
1172
1946
  };
1173
1947
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
1174
1948
  appId: opts.appId,
@@ -1183,6 +1957,14 @@ var CrossdeckClient = class {
1183
1957
  this.state.autoTracker = tracker;
1184
1958
  tracker.install();
1185
1959
  }
1960
+ if (autoTrack.webVitals) {
1961
+ const vitals = new WebVitalsTracker(
1962
+ { enabled: true },
1963
+ (name, properties) => this.track(name, properties)
1964
+ );
1965
+ this.state.webVitals = vitals;
1966
+ vitals.install();
1967
+ }
1186
1968
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
1187
1969
  void this.flush({ keepalive: true }).catch(() => void 0);
1188
1970
  });
@@ -1206,8 +1988,19 @@ var CrossdeckClient = class {
1206
1988
  /**
1207
1989
  * Link the anonymous device to a developer-supplied user ID. Cache
1208
1990
  * the resolved Crossdeck customer for follow-up calls.
1991
+ *
1992
+ * v0.9.0+ accepts an optional `traits` bag — profile data (name,
1993
+ * plan, signupDate, role) persisted on the Crossdeck customer record
1994
+ * and queryable from dashboards. Traits are sanitised through the
1995
+ * same validator that gates `track()` properties, so a `{ avatar:
1996
+ * <File>, onSave: () => {} }` payload can't corrupt the alias call.
1997
+ *
1998
+ * Crossdeck.identify("user_847", {
1999
+ * email: "wes@pinet.co.za",
2000
+ * traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
2001
+ * });
1209
2002
  */
1210
- async identify(userId, _options) {
2003
+ async identify(userId, options) {
1211
2004
  const s = this.requireStarted();
1212
2005
  if (!userId) {
1213
2006
  throw new CrossdeckError({
@@ -1216,13 +2009,163 @@ var CrossdeckClient = class {
1216
2009
  message: "identify(userId) requires a non-empty userId."
1217
2010
  });
1218
2011
  }
2012
+ if (!s.consent.analytics) {
2013
+ s.debug.emit(
2014
+ "sdk.consent_denied",
2015
+ `identify() skipped \u2014 consent denied for analytics.`
2016
+ );
2017
+ return {
2018
+ object: "alias_result",
2019
+ crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
2020
+ linked: [],
2021
+ mergePending: false,
2022
+ env: s.options.environment
2023
+ };
2024
+ }
2025
+ const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
2026
+ const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
2027
+ if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
2028
+ for (const w of traitsValidation.warnings) {
2029
+ s.debug.emit(
2030
+ "sdk.property_coerced",
2031
+ `identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2032
+ { key: w.key, kind: w.kind }
2033
+ );
2034
+ }
2035
+ }
2036
+ const body = {
2037
+ userId,
2038
+ anonymousId: s.identity.anonymousId
2039
+ };
2040
+ if (options?.email) body.email = options.email;
2041
+ if (traits) body.traits = traits;
1219
2042
  const result = await s.http.request("POST", "/identity/alias", {
1220
- body: { userId, anonymousId: s.identity.anonymousId }
2043
+ body
1221
2044
  });
1222
2045
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
1223
2046
  s.developerUserId = userId;
1224
2047
  return result;
1225
2048
  }
2049
+ /**
2050
+ * Register super-properties — Mixpanel pattern. Once set, every
2051
+ * subsequent event of THIS SDK instance carries these keys on its
2052
+ * properties bag automatically.
2053
+ *
2054
+ * Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
2055
+ * Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
2056
+ *
2057
+ * Values that are `null` are deleted (the explicit "stop tracking
2058
+ * this key" idiom). Returns the resulting bag.
2059
+ *
2060
+ * Sanitised through `validateEventProperties` so a `{ avatar: File }`
2061
+ * payload can't poison the queue at flush time.
2062
+ */
2063
+ register(properties) {
2064
+ const s = this.requireStarted();
2065
+ const validation = validateEventProperties(properties);
2066
+ return s.superProps.register(validation.properties);
2067
+ }
2068
+ /** Remove a single super-property key. Idempotent. */
2069
+ unregister(key) {
2070
+ const s = this.requireStarted();
2071
+ s.superProps.unregister(key);
2072
+ }
2073
+ /** Snapshot of the current super-property bag. */
2074
+ getSuperProperties() {
2075
+ if (!this.state) return {};
2076
+ return this.state.superProps.getSuperProperties();
2077
+ }
2078
+ /**
2079
+ * Associate the current user with a group (org, team, account, etc.).
2080
+ * Mixpanel / Segment "Group Analytics" pattern.
2081
+ *
2082
+ * Crossdeck.group("org", "acme_inc");
2083
+ * Crossdeck.group("team", "design", { headcount: 12 });
2084
+ *
2085
+ * Once set, every subsequent event carries `$groups.<type>: id` on
2086
+ * its properties bag, enabling B2B dashboards ("how is Acme using
2087
+ * the product"). Pass `id: null` to clear a group membership.
2088
+ */
2089
+ group(type, id, traits) {
2090
+ const s = this.requireStarted();
2091
+ if (!type) {
2092
+ throw new CrossdeckError({
2093
+ type: "invalid_request_error",
2094
+ code: "missing_group_type",
2095
+ message: "group(type, id) requires a non-empty type."
2096
+ });
2097
+ }
2098
+ const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
2099
+ s.superProps.setGroup(type, id, sanitisedTraits);
2100
+ }
2101
+ /** Snapshot of the current groups map keyed by type. */
2102
+ getGroups() {
2103
+ if (!this.state) return {};
2104
+ return this.state.superProps.getGroups();
2105
+ }
2106
+ /**
2107
+ * Update consent state. Three independent dimensions:
2108
+ *
2109
+ * analytics — track() + identify() + auto-emissions
2110
+ * marketing — paid-traffic click IDs + referrer URL on events
2111
+ * errors — Web Vitals + (future) error reporting
2112
+ *
2113
+ * Each defaults to `true` (granted). Pass partial state — only the
2114
+ * keys you provide are changed.
2115
+ *
2116
+ * Crossdeck.consent({ analytics: false });
2117
+ * Crossdeck.consent({ marketing: true, errors: true });
2118
+ *
2119
+ * DNT-derived denies cannot be flipped back on; if the browser said
2120
+ * "don't track" we don't track even if the developer code disagrees.
2121
+ */
2122
+ consent(state) {
2123
+ const s = this.requireStarted();
2124
+ const next = s.consent.set(state);
2125
+ s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
2126
+ return next;
2127
+ }
2128
+ /** Snapshot of the current consent state. */
2129
+ consentStatus() {
2130
+ if (!this.state) {
2131
+ return { analytics: true, marketing: true, errors: true };
2132
+ }
2133
+ return this.state.consent.get();
2134
+ }
2135
+ /**
2136
+ * GDPR/CCPA "right to be forgotten" — calls the backend's
2137
+ * /v1/identity/forget endpoint to schedule a server-side deletion of
2138
+ * the customer's events and profile, then wipes all local state
2139
+ * (identity, entitlements, queue, super-props, persistent stores).
2140
+ *
2141
+ * Idempotent. Safe to call when no identity has been established
2142
+ * (it just wipes the empty local state).
2143
+ *
2144
+ * After forget() resolves, the SDK is in the same shape as if the
2145
+ * developer had called `Crossdeck.reset()` — a fresh anonymousId is
2146
+ * minted and the next session is a brand new identity-graph entry.
2147
+ */
2148
+ async forget() {
2149
+ const s = this.requireStarted();
2150
+ const identityQuery = this.identityQueryParams();
2151
+ try {
2152
+ await s.http.request("POST", "/identity/forget", {
2153
+ body: {
2154
+ // Send every identity hint we hold; the server resolves the
2155
+ // canonical customer record and queues deletion. Missing
2156
+ // endpoint (older backend) gracefully degrades — local state
2157
+ // still wipes via the reset() call below.
2158
+ ...identityQuery
2159
+ }
2160
+ });
2161
+ } catch (err) {
2162
+ s.debug.emit(
2163
+ "sdk.consent_denied",
2164
+ `forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
2165
+ );
2166
+ }
2167
+ this.reset();
2168
+ }
1226
2169
  /**
1227
2170
  * Read the current customer's active entitlements from the server.
1228
2171
  * Updates the local cache so subsequent isEntitled() calls answer
@@ -1300,6 +2243,17 @@ var CrossdeckClient = class {
1300
2243
  message: "track(name) requires a non-empty name."
1301
2244
  });
1302
2245
  }
2246
+ const isWebVital = name.startsWith("webvitals.");
2247
+ const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
2248
+ if (!consentGateOk) {
2249
+ if (s.debug.enabled) {
2250
+ s.debug.emit(
2251
+ "sdk.consent_denied",
2252
+ `Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
2253
+ );
2254
+ }
2255
+ return;
2256
+ }
1303
2257
  if (s.debug.enabled && properties) {
1304
2258
  const flagged = findSensitivePropertyKeys(properties);
1305
2259
  if (flagged.length > 0) {
@@ -1316,9 +2270,21 @@ var CrossdeckClient = class {
1316
2270
  "Using anonymous user until identify(userId) is called."
1317
2271
  );
1318
2272
  }
2273
+ const validation = validateEventProperties(properties);
2274
+ if (s.debug.enabled && validation.warnings.length > 0) {
2275
+ for (const w of validation.warnings) {
2276
+ s.debug.emit(
2277
+ "sdk.property_coerced",
2278
+ `Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2279
+ { eventName: name, key: w.key, kind: w.kind }
2280
+ );
2281
+ }
2282
+ }
1319
2283
  const enriched = { ...s.deviceInfo };
1320
2284
  const sessionId = s.autoTracker?.currentSessionId;
1321
2285
  if (sessionId) enriched.sessionId = sessionId;
2286
+ const pageviewId = s.autoTracker?.currentPageviewId;
2287
+ if (pageviewId) enriched.pageviewId = pageviewId;
1322
2288
  const acquisition = s.autoTracker?.currentAcquisition;
1323
2289
  if (acquisition) {
1324
2290
  if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
@@ -1326,14 +2292,31 @@ var CrossdeckClient = class {
1326
2292
  if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1327
2293
  if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1328
2294
  if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1329
- if (acquisition.referrer) enriched.referrer = acquisition.referrer;
2295
+ if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
2296
+ if (s.consent.marketing) {
2297
+ if (acquisition.gclid) enriched.gclid = acquisition.gclid;
2298
+ if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
2299
+ if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
2300
+ if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
2301
+ if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
2302
+ if (acquisition.twclid) enriched.twclid = acquisition.twclid;
2303
+ }
2304
+ }
2305
+ const supers = s.superProps.getSuperProperties();
2306
+ for (const k of Object.keys(supers)) {
2307
+ if (!(k in enriched)) enriched[k] = supers[k];
1330
2308
  }
1331
- if (properties) Object.assign(enriched, properties);
2309
+ const groupIds = s.superProps.getGroupIds();
2310
+ if (Object.keys(groupIds).length > 0) {
2311
+ enriched.$groups = groupIds;
2312
+ }
2313
+ Object.assign(enriched, validation.properties);
2314
+ const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
1332
2315
  const event = {
1333
2316
  eventId: this.mintEventId(),
1334
2317
  name,
1335
2318
  timestamp: Date.now(),
1336
- properties: enriched
2319
+ properties: finalProperties
1337
2320
  };
1338
2321
  Object.assign(event, this.identityHintForEvent());
1339
2322
  s.events.enqueue(event);
@@ -1411,7 +2394,12 @@ var CrossdeckClient = class {
1411
2394
  */
1412
2395
  async heartbeat() {
1413
2396
  const s = this.requireStarted();
1414
- return await s.http.request("GET", "/sdk/heartbeat");
2397
+ const result = await s.http.request("GET", "/sdk/heartbeat");
2398
+ if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
2399
+ s.lastServerTime = result.serverTime;
2400
+ s.lastClientTime = Date.now();
2401
+ }
2402
+ return result;
1415
2403
  }
1416
2404
  /**
1417
2405
  * Wipe persisted identity + entitlement cache. Use on logout. The
@@ -1430,6 +2418,7 @@ var CrossdeckClient = class {
1430
2418
  this.state.identity.reset();
1431
2419
  this.state.entitlements.clear();
1432
2420
  this.state.events.reset();
2421
+ this.state.superProps.clear();
1433
2422
  this.state.developerUserId = null;
1434
2423
  if (this.state.autoTracker) {
1435
2424
  const tracker = new AutoTracker(
@@ -1457,17 +2446,21 @@ var CrossdeckClient = class {
1457
2446
  developerUserId: null,
1458
2447
  sdkVersion: null,
1459
2448
  baseUrl: null,
1460
- entitlements: { count: 0, lastUpdated: 0 },
2449
+ clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
2450
+ entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
1461
2451
  events: {
1462
2452
  buffered: 0,
1463
2453
  dropped: 0,
1464
2454
  inFlight: 0,
1465
2455
  lastFlushAt: 0,
1466
- lastError: null
2456
+ lastError: null,
2457
+ consecutiveFailures: 0,
2458
+ nextRetryAt: null
1467
2459
  }
1468
2460
  };
1469
2461
  }
1470
2462
  const s = this.state;
2463
+ const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
1471
2464
  return {
1472
2465
  started: true,
1473
2466
  anonymousId: s.identity.anonymousId,
@@ -1475,9 +2468,15 @@ var CrossdeckClient = class {
1475
2468
  developerUserId: s.developerUserId,
1476
2469
  sdkVersion: s.options.sdkVersion,
1477
2470
  baseUrl: s.options.baseUrl,
2471
+ clock: {
2472
+ lastServerTime: s.lastServerTime,
2473
+ lastClientTime: s.lastClientTime,
2474
+ skewMs
2475
+ },
1478
2476
  entitlements: {
1479
2477
  count: s.entitlements.list().length,
1480
- lastUpdated: s.entitlements.freshness
2478
+ lastUpdated: s.entitlements.freshness,
2479
+ listenerErrors: s.entitlements.listenerErrors
1481
2480
  },
1482
2481
  events: s.events.getStats()
1483
2482
  };
@@ -1544,6 +2543,7 @@ function inferEnvFromKey(publicKey) {
1544
2543
  }
1545
2544
  function isLocalHostname() {
1546
2545
  const w = globalThis.window;
2546
+ if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
1547
2547
  const hostname = w?.location?.hostname;
1548
2548
  if (!hostname) return false;
1549
2549
  if (hostname === "localhost" || hostname === "127.0.0.1") return true;
@@ -1556,7 +2556,13 @@ function isLocalHostname() {
1556
2556
  }
1557
2557
  function resolveAutoTrack(input) {
1558
2558
  if (input === false) {
1559
- return { sessions: false, pageViews: false, deviceInfo: false, clicks: false };
2559
+ return {
2560
+ sessions: false,
2561
+ pageViews: false,
2562
+ deviceInfo: false,
2563
+ clicks: false,
2564
+ webVitals: false
2565
+ };
1560
2566
  }
1561
2567
  if (input === void 0 || input === true) {
1562
2568
  return { ...DEFAULT_AUTO_TRACK };
@@ -1565,7 +2571,8 @@ function resolveAutoTrack(input) {
1565
2571
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1566
2572
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1567
2573
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
1568
- clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
2574
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
2575
+ webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
1569
2576
  };
1570
2577
  }
1571
2578
  function installUnloadFlush(onUnload) {