@cross-deck/web 0.7.0 → 0.10.0

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