@cross-deck/web 0.6.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs CHANGED
@@ -35,11 +35,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
35
35
  this.code = payload.code;
36
36
  this.requestId = payload.requestId;
37
37
  this.status = payload.status;
38
+ this.retryAfterMs = payload.retryAfterMs;
38
39
  Object.setPrototypeOf(this, _CrossdeckError.prototype);
39
40
  }
40
41
  };
41
42
  async function crossdeckErrorFromResponse(res) {
42
43
  const requestId = res.headers.get("x-request-id") ?? void 0;
44
+ const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
43
45
  let body;
44
46
  try {
45
47
  body = await res.json();
@@ -53,7 +55,8 @@ async function crossdeckErrorFromResponse(res) {
53
55
  code: envelope.code,
54
56
  message: envelope.message ?? `HTTP ${res.status}`,
55
57
  requestId: envelope.request_id ?? requestId,
56
- status: res.status
58
+ status: res.status,
59
+ retryAfterMs
57
60
  });
58
61
  }
59
62
  return new CrossdeckError({
@@ -61,9 +64,25 @@ async function crossdeckErrorFromResponse(res) {
61
64
  code: `http_${res.status}`,
62
65
  message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
63
66
  requestId,
64
- status: res.status
67
+ status: res.status,
68
+ retryAfterMs
65
69
  });
66
70
  }
71
+ function parseRetryAfterHeader(value) {
72
+ if (!value) return void 0;
73
+ const trimmed = value.trim();
74
+ if (!trimmed) return void 0;
75
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
76
+ const secs = Number(trimmed);
77
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
78
+ return Math.round(secs * 1e3);
79
+ }
80
+ if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
81
+ const target = Date.parse(trimmed);
82
+ if (!Number.isFinite(target)) return void 0;
83
+ const delta = target - Date.now();
84
+ return delta > 0 ? delta : 0;
85
+ }
67
86
  function typeMapForStatus(status) {
68
87
  if (status === 401) return "authentication_error";
69
88
  if (status === 403) return "permission_error";
@@ -74,8 +93,9 @@ function typeMapForStatus(status) {
74
93
 
75
94
  // src/http.ts
76
95
  var SDK_NAME = "@cross-deck/web";
77
- var SDK_VERSION = "0.6.0";
96
+ var SDK_VERSION = "0.10.0";
78
97
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
98
+ var DEFAULT_TIMEOUT_MS = 15e3;
79
99
  var HttpClient = class {
80
100
  constructor(config) {
81
101
  this.config = config;
@@ -90,31 +110,47 @@ var HttpClient = class {
90
110
  * - JSON parse failure on a 2xx (treated as `internal_error`)
91
111
  */
92
112
  async request(method, path, options = {}) {
113
+ if (this.config.localDevMode) {
114
+ return synthesizeLocalDevResponse(path);
115
+ }
93
116
  const url = this.buildUrl(path, options.query);
94
117
  const headers = {
95
118
  Authorization: `Bearer ${this.config.publicKey}`,
96
119
  "Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
97
120
  Accept: "application/json"
98
121
  };
122
+ if (options.idempotencyKey) {
123
+ headers["Idempotency-Key"] = options.idempotencyKey;
124
+ }
99
125
  let bodyInit;
100
126
  if (options.body !== void 0) {
101
127
  headers["Content-Type"] = "application/json";
102
128
  bodyInit = JSON.stringify(options.body);
103
129
  }
130
+ const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
131
+ const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
132
+ let timeoutHandle = null;
133
+ if (controller && effectiveTimeout > 0) {
134
+ timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
135
+ }
104
136
  let response;
105
137
  try {
106
138
  response = await fetch(url, {
107
139
  method,
108
140
  headers,
109
141
  body: bodyInit,
110
- keepalive: options.keepalive === true
142
+ keepalive: options.keepalive === true,
143
+ signal: controller?.signal
111
144
  });
112
145
  } catch (err) {
146
+ const aborted = controller?.signal?.aborted === true;
113
147
  throw new CrossdeckError({
114
148
  type: "network_error",
115
- code: "fetch_failed",
116
- message: err instanceof Error ? err.message : "fetch failed"
149
+ code: aborted ? "request_timeout" : "fetch_failed",
150
+ message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
117
151
  });
152
+ } finally {
153
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
118
154
  }
119
155
  if (!response.ok) {
120
156
  throw await crossdeckErrorFromResponse(response);
@@ -132,6 +168,14 @@ var HttpClient = class {
132
168
  });
133
169
  }
134
170
  }
171
+ /**
172
+ * Whether this client is in localhost dev-mode short-circuit. Used
173
+ * by other SDK pieces (event-queue) to skip network-bound work
174
+ * entirely rather than going through synthesizeLocalDevResponse.
175
+ */
176
+ get isLocalDevMode() {
177
+ return this.config.localDevMode === true;
178
+ }
135
179
  buildUrl(path, query) {
136
180
  const base = this.config.baseUrl.replace(/\/+$/, "");
137
181
  const cleanPath = path.startsWith("/") ? path : `/${path}`;
@@ -147,6 +191,49 @@ var HttpClient = class {
147
191
  return url;
148
192
  }
149
193
  };
194
+ var cachedLocalCdcust = null;
195
+ function synthesizeLocalDevResponse(path) {
196
+ if (path.startsWith("/sdk/heartbeat")) {
197
+ return {
198
+ object: "heartbeat",
199
+ ok: true,
200
+ projectId: "proj_local_dev",
201
+ appId: "app_local_dev",
202
+ platform: "web",
203
+ env: "sandbox",
204
+ serverTime: Date.now()
205
+ };
206
+ }
207
+ if (path.startsWith("/identity/alias")) {
208
+ if (!cachedLocalCdcust) {
209
+ const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
210
+ cachedLocalCdcust = `cdcust_local_${tail}`;
211
+ }
212
+ return {
213
+ object: "alias_result",
214
+ crossdeckCustomerId: cachedLocalCdcust,
215
+ linked: [],
216
+ mergePending: false,
217
+ env: "sandbox"
218
+ };
219
+ }
220
+ if (path.startsWith("/entitlements")) {
221
+ return {
222
+ object: "list",
223
+ data: [],
224
+ crossdeckCustomerId: cachedLocalCdcust ?? "",
225
+ env: "sandbox"
226
+ };
227
+ }
228
+ if (path.startsWith("/events")) {
229
+ return {
230
+ object: "list",
231
+ received: 0,
232
+ env: "sandbox"
233
+ };
234
+ }
235
+ return {};
236
+ }
150
237
 
151
238
  // src/identity.ts
152
239
  var KEY_ANON = "anon_id";
@@ -260,6 +347,7 @@ var EntitlementCache = class {
260
347
  this.all = [];
261
348
  this.lastUpdated = 0;
262
349
  this.listeners = /* @__PURE__ */ new Set();
350
+ this.listenerErrorCount = 0;
263
351
  }
264
352
  /** Sync read — true iff the entitlement key is currently active. */
265
353
  isEntitled(key) {
@@ -273,6 +361,15 @@ var EntitlementCache = class {
273
361
  get freshness() {
274
362
  return this.lastUpdated;
275
363
  }
364
+ /**
365
+ * Cumulative count of listener invocations that threw. Listener errors
366
+ * are swallowed (a buggy consumer must not crash the SDK) but the
367
+ * counter lets diagnostics() surface "you have a broken subscriber"
368
+ * without putting the developer in a debug session.
369
+ */
370
+ get listenerErrors() {
371
+ return this.listenerErrorCount;
372
+ }
276
373
  /**
277
374
  * Replace the cache with a fresh server response. The backend already
278
375
  * filters to active + env-matching, so we don't re-filter — just trust
@@ -326,11 +423,54 @@ var EntitlementCache = class {
326
423
  try {
327
424
  listener(snapshot);
328
425
  } catch {
426
+ this.listenerErrorCount += 1;
329
427
  }
330
428
  }
331
429
  }
332
430
  };
333
431
 
432
+ // src/retry-policy.ts
433
+ var DEFAULT_BASE = 1e3;
434
+ var DEFAULT_MAX = 6e4;
435
+ var DEFAULT_FACTOR = 2;
436
+ var DEFAULT_WARN = 8;
437
+ function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
438
+ const base = options.baseMs ?? DEFAULT_BASE;
439
+ const max = options.maxMs ?? DEFAULT_MAX;
440
+ const factor = options.factor ?? DEFAULT_FACTOR;
441
+ const safeAttempts = Math.min(attempts, 30);
442
+ const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
443
+ const jittered = ceiling * random();
444
+ if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
445
+ return Math.min(max, retryAfterMs);
446
+ }
447
+ return Math.max(0, Math.round(jittered));
448
+ }
449
+ var RetryPolicy = class {
450
+ constructor(options = {}) {
451
+ this.options = options;
452
+ this.attempts = 0;
453
+ }
454
+ /** How many consecutive failures since the last success. */
455
+ get consecutiveFailures() {
456
+ return this.attempts;
457
+ }
458
+ /** Whether we've crossed the failuresBeforeWarn threshold. */
459
+ get isWarning() {
460
+ return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
461
+ }
462
+ /** Schedule-time delay for the NEXT retry. Increments the counter. */
463
+ nextDelay(retryAfterMs, random = Math.random) {
464
+ const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
465
+ this.attempts += 1;
466
+ return delay;
467
+ }
468
+ /** Mark a successful flush — reset the counter. */
469
+ recordSuccess() {
470
+ this.attempts = 0;
471
+ }
472
+ };
473
+
334
474
  // src/event-queue.ts
335
475
  var HARD_BUFFER_CAP = 1e3;
336
476
  var EventQueue = class {
@@ -343,6 +483,22 @@ var EventQueue = class {
343
483
  this.lastError = null;
344
484
  this.cancelTimer = null;
345
485
  this.firstFlushFired = false;
486
+ this.nextRetryAt = null;
487
+ this.retry = new RetryPolicy(cfg.retry ?? {});
488
+ this.persistent = cfg.persistentStore ?? null;
489
+ if (this.persistent) {
490
+ const restored = this.persistent.load();
491
+ if (restored.length > 0) {
492
+ if (restored.length > HARD_BUFFER_CAP) {
493
+ this.dropped += restored.length - HARD_BUFFER_CAP;
494
+ this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
495
+ } else {
496
+ this.buffer = restored;
497
+ }
498
+ this.cfg.onBufferChange?.(this.buffer.length);
499
+ this.scheduleIdleFlush();
500
+ }
501
+ }
346
502
  }
347
503
  enqueue(event) {
348
504
  this.buffer.push(event);
@@ -352,6 +508,8 @@ var EventQueue = class {
352
508
  this.dropped += overflow;
353
509
  this.cfg.onDrop?.(overflow);
354
510
  }
511
+ this.cfg.onBufferChange?.(this.buffer.length);
512
+ this.persistent?.save(this.buffer);
355
513
  if (this.buffer.length >= this.cfg.batchSize) {
356
514
  void this.flush();
357
515
  } else {
@@ -361,7 +519,7 @@ var EventQueue = class {
361
519
  /**
362
520
  * Flush the buffer to /v1/events. Resolves when the network call
363
521
  * completes (success or failure). On failure, events stay in the
364
- * buffer for the next flush attempt.
522
+ * buffer for the next scheduled retry.
365
523
  *
366
524
  * `options.keepalive` marks the underlying fetch as keepalive so the
367
525
  * browser keeps the request alive past page unload. Use this for
@@ -370,25 +528,32 @@ var EventQueue = class {
370
528
  async flush(options = {}) {
371
529
  if (this.buffer.length === 0) return null;
372
530
  this.cancelTimerIfSet();
531
+ this.nextRetryAt = null;
373
532
  const batch = this.buffer.splice(0);
533
+ const batchId = this.mintBatchId();
374
534
  this.inFlight += batch.length;
535
+ this.persistent?.save(this.buffer);
536
+ this.cfg.onBufferChange?.(this.buffer.length);
375
537
  try {
376
538
  const env = this.cfg.envelope();
377
539
  const result = await this.cfg.http.request("POST", "/events", {
378
540
  body: {
379
541
  // NorthStar §13.1 batch envelope. The backend validates these
380
- // against the API-key-resolved app and rejects mismatches loudly
381
- // (env_mismatch).
542
+ // against the API-key-resolved app and rejects mismatches
543
+ // loudly (env_mismatch).
382
544
  appId: env.appId,
383
545
  environment: env.environment,
384
546
  sdk: env.sdk,
385
547
  events: batch
386
548
  },
387
- keepalive: options.keepalive === true
549
+ keepalive: options.keepalive === true,
550
+ idempotencyKey: batchId
388
551
  });
389
552
  this.lastFlushAt = Date.now();
390
553
  this.lastError = null;
391
554
  this.inFlight -= batch.length;
555
+ this.retry.recordSuccess();
556
+ this.persistent?.save(this.buffer);
392
557
  if (!this.firstFlushFired) {
393
558
  this.firstFlushFired = true;
394
559
  this.cfg.onFirstFlushSuccess?.();
@@ -397,18 +562,33 @@ var EventQueue = class {
397
562
  } catch (err) {
398
563
  this.buffer.unshift(...batch);
399
564
  this.inFlight -= batch.length;
400
- this.lastError = err instanceof Error ? err.message : String(err);
401
- this.scheduleIdleFlush();
565
+ const message = err instanceof Error ? err.message : String(err);
566
+ this.lastError = message;
567
+ this.persistent?.save(this.buffer);
568
+ this.cfg.onBufferChange?.(this.buffer.length);
569
+ const retryAfterMs = extractRetryAfterMs(err);
570
+ const delay = this.retry.nextDelay(retryAfterMs);
571
+ this.scheduleRetry(delay);
572
+ this.cfg.onRetryScheduled?.({
573
+ delayMs: delay,
574
+ consecutiveFailures: this.retry.consecutiveFailures,
575
+ retryAfterMs,
576
+ lastError: message
577
+ });
402
578
  return null;
403
579
  }
404
580
  }
405
- /** Cancel any pending timer and clear in-memory state. */
581
+ /** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
406
582
  reset() {
407
583
  this.cancelTimerIfSet();
584
+ this.nextRetryAt = null;
408
585
  this.buffer = [];
409
586
  this.dropped = 0;
410
587
  this.inFlight = 0;
411
588
  this.lastError = null;
589
+ this.retry.recordSuccess();
590
+ this.persistent?.clear();
591
+ this.cfg.onBufferChange?.(0);
412
592
  }
413
593
  getStats() {
414
594
  return {
@@ -416,9 +596,12 @@ var EventQueue = class {
416
596
  dropped: this.dropped,
417
597
  inFlight: this.inFlight,
418
598
  lastFlushAt: this.lastFlushAt,
419
- lastError: this.lastError
599
+ lastError: this.lastError,
600
+ consecutiveFailures: this.retry.consecutiveFailures,
601
+ nextRetryAt: this.nextRetryAt
420
602
  };
421
603
  }
604
+ // ---------- internal scheduling ----------
422
605
  scheduleIdleFlush() {
423
606
  this.cancelTimerIfSet();
424
607
  const sched = this.cfg.scheduler ?? defaultScheduler;
@@ -426,13 +609,31 @@ var EventQueue = class {
426
609
  void this.flush();
427
610
  }, this.cfg.intervalMs);
428
611
  }
612
+ scheduleRetry(delayMs) {
613
+ this.cancelTimerIfSet();
614
+ this.nextRetryAt = Date.now() + delayMs;
615
+ const sched = this.cfg.scheduler ?? defaultScheduler;
616
+ this.cancelTimer = sched(() => {
617
+ void this.flush();
618
+ }, delayMs);
619
+ }
429
620
  cancelTimerIfSet() {
430
621
  if (this.cancelTimer) {
431
622
  this.cancelTimer();
432
623
  this.cancelTimer = null;
433
624
  }
434
625
  }
626
+ mintBatchId() {
627
+ return `batch_${Date.now().toString(36)}${randomChars(10)}`;
628
+ }
435
629
  };
630
+ function extractRetryAfterMs(err) {
631
+ if (err && typeof err === "object" && "retryAfterMs" in err) {
632
+ const v = err.retryAfterMs;
633
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
634
+ }
635
+ return void 0;
636
+ }
436
637
  function defaultScheduler(fn, ms) {
437
638
  const id = setTimeout(fn, ms);
438
639
  if (typeof id.unref === "function") {
@@ -444,6 +645,87 @@ function defaultScheduler(fn, ms) {
444
645
  return () => clearTimeout(id);
445
646
  }
446
647
 
648
+ // src/event-storage.ts
649
+ var PersistentEventStore = class {
650
+ constructor(options) {
651
+ this.options = options;
652
+ this.writeScheduled = false;
653
+ // Pending events captured on the most recent write request. We keep
654
+ // the latest snapshot ref so a debounced write always picks up the
655
+ // freshest buffer state.
656
+ this.pendingSnapshot = null;
657
+ this.key = `${options.prefix}queue.v1`;
658
+ }
659
+ /**
660
+ * Read the persisted queue on boot. Returns an empty array (with no
661
+ * warning) when nothing is stored, the blob is malformed, or storage
662
+ * is unavailable. Caller is responsible for treating duplicates from
663
+ * the persisted queue as the SAME events (eventId-based dedup).
664
+ */
665
+ load() {
666
+ let raw;
667
+ try {
668
+ raw = this.options.storage.getItem(this.key);
669
+ } catch {
670
+ return [];
671
+ }
672
+ if (!raw) return [];
673
+ try {
674
+ const parsed = JSON.parse(raw);
675
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
676
+ return [];
677
+ }
678
+ return parsed.events;
679
+ } catch {
680
+ return [];
681
+ }
682
+ }
683
+ /**
684
+ * Schedule a write of the current buffer. Debounced via microtask so
685
+ * a burst of enqueue() calls coalesces into one persistence write.
686
+ * Writes are best-effort: if storage throws (quota, private mode),
687
+ * we swallow and rely on the in-memory buffer.
688
+ */
689
+ save(snapshot) {
690
+ this.pendingSnapshot = snapshot.slice();
691
+ if (this.writeScheduled) return;
692
+ this.writeScheduled = true;
693
+ queueMicrotask(() => this.flushWrite());
694
+ }
695
+ /** Synchronous variant for terminal flushes (pagehide / beforeunload). */
696
+ saveSync(snapshot) {
697
+ this.pendingSnapshot = snapshot.slice();
698
+ this.flushWrite();
699
+ }
700
+ /** Wipe the persisted blob. Used by reset() (logout). */
701
+ clear() {
702
+ this.pendingSnapshot = null;
703
+ this.writeScheduled = false;
704
+ try {
705
+ this.options.storage.removeItem(this.key);
706
+ } catch {
707
+ }
708
+ }
709
+ flushWrite() {
710
+ this.writeScheduled = false;
711
+ const snapshot = this.pendingSnapshot;
712
+ this.pendingSnapshot = null;
713
+ if (snapshot === null) return;
714
+ if (snapshot.length === 0) {
715
+ try {
716
+ this.options.storage.removeItem(this.key);
717
+ } catch {
718
+ }
719
+ return;
720
+ }
721
+ const blob = { version: 1, events: snapshot };
722
+ try {
723
+ this.options.storage.setItem(this.key, JSON.stringify(blob));
724
+ } catch {
725
+ }
726
+ }
727
+ };
728
+
447
729
  // src/storage.ts
448
730
  var MemoryStorage = class {
449
731
  constructor() {
@@ -633,7 +915,9 @@ function parseUserAgent(ua) {
633
915
  var DEFAULT_AUTO_TRACK = {
634
916
  sessions: true,
635
917
  pageViews: true,
636
- deviceInfo: true
918
+ deviceInfo: true,
919
+ clicks: true,
920
+ webVitals: true
637
921
  };
638
922
  var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
639
923
  var EMPTY_ACQUISITION = {
@@ -642,7 +926,13 @@ var EMPTY_ACQUISITION = {
642
926
  utm_campaign: "",
643
927
  utm_content: "",
644
928
  utm_term: "",
645
- referrer: ""
929
+ referrer: "",
930
+ gclid: "",
931
+ fbclid: "",
932
+ msclkid: "",
933
+ ttclid: "",
934
+ li_fat_id: "",
935
+ twclid: ""
646
936
  };
647
937
  var AutoTracker = class {
648
938
  constructor(cfg, track) {
@@ -650,11 +940,23 @@ var AutoTracker = class {
650
940
  this.track = track;
651
941
  this.session = null;
652
942
  this.cleanups = [];
943
+ /**
944
+ * Stable per-page-view identifier. Minted at every `page.viewed`
945
+ * emission and attached to every subsequent event until the next
946
+ * `page.viewed`. Lets dashboards correlate "user clicked X" to
947
+ * "user viewed page Y" without timestamp arithmetic — the canonical
948
+ * Mixpanel `$current_url` / Segment `pageId` pattern.
949
+ *
950
+ * Null until the first `page.viewed` fires (which happens at SDK
951
+ * install if `autoTrack.pageViews !== false`).
952
+ */
953
+ this.pageviewId = null;
653
954
  }
654
955
  install() {
655
956
  if (!isBrowserSafe()) return;
656
957
  if (this.cfg.sessions) this.installSessionTracking();
657
958
  if (this.cfg.pageViews) this.installPageViewTracking();
959
+ if (this.cfg.clicks) this.installClickTracking();
658
960
  }
659
961
  uninstall() {
660
962
  while (this.cleanups.length) {
@@ -679,6 +981,10 @@ var AutoTracker = class {
679
981
  get currentSessionId() {
680
982
  return this.session?.sessionId ?? null;
681
983
  }
984
+ /** Stable per-page-view ID. Null before the first page.viewed has fired. */
985
+ get currentPageviewId() {
986
+ return this.pageviewId;
987
+ }
682
988
  /**
683
989
  * Per-session acquisition context — utm_* + referrer, captured once
684
990
  * at session start. Returns empty strings when there's no session
@@ -749,11 +1055,21 @@ var AutoTracker = class {
749
1055
  installPageViewTracking() {
750
1056
  const w = globalThis.window;
751
1057
  const doc = globalThis.document;
752
- const fire = () => {
1058
+ let lastFiredAt = 0;
1059
+ let lastFiredUrl = "";
1060
+ const DEDUP_WINDOW_MS = 250;
1061
+ const fire = (force = false) => {
753
1062
  const loc = w.location;
1063
+ const url = loc.href;
1064
+ const now = Date.now();
1065
+ if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
1066
+ lastFiredAt = now;
1067
+ lastFiredUrl = url;
1068
+ this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
754
1069
  this.track("page.viewed", {
1070
+ pageviewId: this.pageviewId,
755
1071
  path: loc.pathname,
756
- url: loc.href,
1072
+ url,
757
1073
  search: loc.search || void 0,
758
1074
  hash: loc.hash || void 0,
759
1075
  title: doc.title,
@@ -775,7 +1091,7 @@ var AutoTracker = class {
775
1091
  }
776
1092
  w.history.pushState = patchedPush;
777
1093
  w.history.replaceState = patchedReplace;
778
- const onPopState = () => fire();
1094
+ const onPopState = () => fire(true);
779
1095
  w.addEventListener("popstate", onPopState);
780
1096
  this.cleanups.push(() => {
781
1097
  if (w.history.pushState === patchedPush) {
@@ -787,7 +1103,156 @@ var AutoTracker = class {
787
1103
  w.removeEventListener("popstate", onPopState);
788
1104
  });
789
1105
  }
1106
+ // ---------- click autocapture ----------
1107
+ /**
1108
+ * Global click tracking — Mixpanel / Amplitude style autocapture.
1109
+ * Fires `element.clicked` for every interactive click with the
1110
+ * target element's selector path, text content, tag, href, data-*
1111
+ * attributes, and viewport coordinates. Powers the funnel /
1112
+ * attribution USP: "users who clicked X then converted within
1113
+ * 7 days." Default ON because behavioural attribution is the
1114
+ * core product promise.
1115
+ *
1116
+ * Privacy guardrails:
1117
+ * - Skip clicks ON inputs / textareas / selects (form interaction
1118
+ * isn't button telemetry; the dev should track form submits
1119
+ * deliberately via track('form_submitted'))
1120
+ * - Skip clicks INSIDE [type="password"] and password-class
1121
+ * elements
1122
+ * - Skip clicks inside elements opted out via class="cd-noTrack"
1123
+ * or data-cd-noTrack attribute (Mixpanel's exact opt-out
1124
+ * idiom — most devs already know it)
1125
+ * - Capture text content but cap at 64 chars and trim — never
1126
+ * more than what you'd see on a button label
1127
+ *
1128
+ * Volume guardrails:
1129
+ * - Coalesce double-clicks within 100ms (React's synthetic click
1130
+ * pattern + browser's native dblclick can fire twice)
1131
+ * - Listen on document at capture phase so we see the click
1132
+ * before any framework's own handlers stop propagation
1133
+ */
1134
+ installClickTracking() {
1135
+ const w = globalThis.window;
1136
+ const doc = globalThis.document;
1137
+ let lastFiredAt = 0;
1138
+ let lastFiredTarget = null;
1139
+ const COALESCE_MS = 100;
1140
+ const TEXT_CAP = 64;
1141
+ const onClick = (ev) => {
1142
+ const target = ev.target;
1143
+ if (!target || !(target instanceof Element)) return;
1144
+ const now = Date.now();
1145
+ if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
1146
+ lastFiredAt = now;
1147
+ lastFiredTarget = target;
1148
+ const actionable = closestActionable(target);
1149
+ const clicked = actionable || target;
1150
+ if (isFormInput(clicked)) return;
1151
+ if (isInOptedOut(clicked)) return;
1152
+ if (isInsidePasswordField(clicked)) return;
1153
+ const tag = clicked.tagName.toLowerCase();
1154
+ const text = trimText(extractText(clicked), TEXT_CAP);
1155
+ const href = clicked.href || void 0;
1156
+ const linkTarget = clicked.target || void 0;
1157
+ const elementId = clicked.id || void 0;
1158
+ const role = clicked.getAttribute("role") || void 0;
1159
+ const ariaLabel = clicked.getAttribute("aria-label") || void 0;
1160
+ const selector = buildSelector(clicked);
1161
+ const dataAttrs = collectDataAttrs(clicked);
1162
+ const isLink = tag === "a" && !!href;
1163
+ const explicitName = clicked.getAttribute("data-cd-event");
1164
+ const props = {
1165
+ selector,
1166
+ tag,
1167
+ text,
1168
+ elementId,
1169
+ role,
1170
+ ariaLabel,
1171
+ href,
1172
+ isLink,
1173
+ linkTarget,
1174
+ viewportX: ev.clientX,
1175
+ viewportY: ev.clientY,
1176
+ pageX: ev.pageX,
1177
+ pageY: ev.pageY,
1178
+ ...dataAttrs
1179
+ };
1180
+ for (const k of Object.keys(props)) {
1181
+ if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
1182
+ }
1183
+ this.track(explicitName || "element.clicked", props);
1184
+ };
1185
+ doc.addEventListener("click", onClick, { capture: true, passive: true });
1186
+ this.cleanups.push(() => {
1187
+ doc.removeEventListener("click", onClick, { capture: true });
1188
+ });
1189
+ }
790
1190
  };
1191
+ function closestActionable(el) {
1192
+ return el.closest("[data-cd-event]") || el.closest("[data-cd-noTrack]") || el.closest("button, a, [role='button'], [role='link'], input[type='button'], input[type='submit']") || null;
1193
+ }
1194
+ function isFormInput(el) {
1195
+ if (!(el instanceof HTMLElement)) return false;
1196
+ const tag = el.tagName.toLowerCase();
1197
+ if (tag === "textarea" || tag === "select") return true;
1198
+ if (tag === "input") {
1199
+ const type = (el.type || "").toLowerCase();
1200
+ return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
1201
+ }
1202
+ return false;
1203
+ }
1204
+ function isInOptedOut(el) {
1205
+ if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
1206
+ return false;
1207
+ }
1208
+ function isInsidePasswordField(el) {
1209
+ if (el.closest('input[type="password"]')) return true;
1210
+ return false;
1211
+ }
1212
+ function extractText(el) {
1213
+ const aria = el.getAttribute("aria-label");
1214
+ if (aria) return aria.replace(/\s+/g, " ").trim();
1215
+ if (el instanceof HTMLInputElement && el.value) return el.value;
1216
+ const text = (el.textContent || "").replace(/\s+/g, " ").trim();
1217
+ return text;
1218
+ }
1219
+ function trimText(s, cap) {
1220
+ if (s.length <= cap) return s;
1221
+ return s.slice(0, cap - 1) + "\u2026";
1222
+ }
1223
+ function buildSelector(el) {
1224
+ const parts = [];
1225
+ let cur = el;
1226
+ let depth = 0;
1227
+ while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
1228
+ let part = cur.nodeName.toLowerCase();
1229
+ if (cur.id) {
1230
+ parts.unshift(`${part}#${cur.id}`);
1231
+ break;
1232
+ }
1233
+ if (cur.classList.length > 0) {
1234
+ const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
1235
+ if (cls) part += `.${cls}`;
1236
+ }
1237
+ parts.unshift(part);
1238
+ cur = cur.parentElement;
1239
+ depth++;
1240
+ }
1241
+ return parts.join(" > ");
1242
+ }
1243
+ function collectDataAttrs(el) {
1244
+ const out = {};
1245
+ if (!(el instanceof HTMLElement)) return out;
1246
+ for (const name of el.getAttributeNames()) {
1247
+ if (!name.startsWith("data-")) continue;
1248
+ if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
1249
+ if (name === "data-cd-event") continue;
1250
+ const value = el.getAttribute(name) || "";
1251
+ const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
1252
+ out[key] = value;
1253
+ }
1254
+ return out;
1255
+ }
791
1256
  function isBrowserSafe() {
792
1257
  return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
793
1258
  }
@@ -806,6 +1271,12 @@ function captureAcquisition() {
806
1271
  result.utm_campaign = params.get("utm_campaign") ?? "";
807
1272
  result.utm_content = params.get("utm_content") ?? "";
808
1273
  result.utm_term = params.get("utm_term") ?? "";
1274
+ result.gclid = params.get("gclid") ?? "";
1275
+ result.fbclid = params.get("fbclid") ?? "";
1276
+ result.msclkid = params.get("msclkid") ?? "";
1277
+ result.ttclid = params.get("ttclid") ?? "";
1278
+ result.li_fat_id = params.get("li_fat_id") ?? "";
1279
+ result.twclid = params.get("twclid") ?? "";
809
1280
  } catch {
810
1281
  }
811
1282
  try {
@@ -863,6 +1334,490 @@ function safeJson(obj) {
863
1334
  }
864
1335
  }
865
1336
 
1337
+ // src/event-validation.ts
1338
+ var DEFAULT_MAX_STRING = 1024;
1339
+ var DEFAULT_MAX_BYTES = 8 * 1024;
1340
+ var DEFAULT_MAX_DEPTH = 5;
1341
+ function validateEventProperties(input, options = {}) {
1342
+ const warnings = [];
1343
+ if (!input) return { properties: {}, warnings };
1344
+ const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
1345
+ const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
1346
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
1347
+ const seen = /* @__PURE__ */ new WeakSet();
1348
+ const visit = (value, key, depth) => {
1349
+ if (depth > maxDepth) {
1350
+ warnings.push({ kind: "depth_exceeded", key });
1351
+ return { keep: true, value: "[depth-exceeded]" };
1352
+ }
1353
+ if (value === null) return { keep: true, value: null };
1354
+ const t = typeof value;
1355
+ if (t === "string") {
1356
+ const s = value;
1357
+ if (s.length > maxStringLength) {
1358
+ warnings.push({ kind: "truncated_string", key });
1359
+ return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
1360
+ }
1361
+ return { keep: true, value: s };
1362
+ }
1363
+ if (t === "number") {
1364
+ if (!Number.isFinite(value)) {
1365
+ warnings.push({ kind: "non_serialisable", key });
1366
+ return { keep: true, value: null };
1367
+ }
1368
+ return { keep: true, value };
1369
+ }
1370
+ if (t === "boolean") return { keep: true, value };
1371
+ if (t === "bigint") {
1372
+ warnings.push({ kind: "coerced_bigint", key });
1373
+ return { keep: true, value: value.toString() };
1374
+ }
1375
+ if (t === "function") {
1376
+ warnings.push({ kind: "dropped_function", key });
1377
+ return { keep: false, value: void 0 };
1378
+ }
1379
+ if (t === "symbol") {
1380
+ warnings.push({ kind: "dropped_symbol", key });
1381
+ return { keep: false, value: void 0 };
1382
+ }
1383
+ if (t === "undefined") {
1384
+ warnings.push({ kind: "dropped_undefined", key });
1385
+ return { keep: false, value: void 0 };
1386
+ }
1387
+ if (value instanceof Date) {
1388
+ warnings.push({ kind: "coerced_date", key });
1389
+ const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
1390
+ return { keep: true, value: iso };
1391
+ }
1392
+ if (value instanceof Error) {
1393
+ warnings.push({ kind: "coerced_error", key });
1394
+ return {
1395
+ keep: true,
1396
+ value: {
1397
+ name: value.name,
1398
+ message: value.message,
1399
+ stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
1400
+ }
1401
+ };
1402
+ }
1403
+ if (value instanceof Map) {
1404
+ warnings.push({ kind: "coerced_map", key });
1405
+ const obj = {};
1406
+ for (const [k, v] of value.entries()) {
1407
+ const subKey = typeof k === "string" ? k : String(k);
1408
+ const result = visit(v, `${key}.${subKey}`, depth + 1);
1409
+ if (result.keep) obj[subKey] = result.value;
1410
+ }
1411
+ return { keep: true, value: obj };
1412
+ }
1413
+ if (value instanceof Set) {
1414
+ warnings.push({ kind: "coerced_set", key });
1415
+ const arr = [];
1416
+ let i = 0;
1417
+ for (const v of value.values()) {
1418
+ const result = visit(v, `${key}[${i}]`, depth + 1);
1419
+ if (result.keep) arr.push(result.value);
1420
+ i++;
1421
+ }
1422
+ return { keep: true, value: arr };
1423
+ }
1424
+ if (Array.isArray(value)) {
1425
+ if (seen.has(value)) {
1426
+ warnings.push({ kind: "circular_reference", key });
1427
+ return { keep: true, value: "[circular]" };
1428
+ }
1429
+ seen.add(value);
1430
+ const out = [];
1431
+ for (let i = 0; i < value.length; i++) {
1432
+ const result = visit(value[i], `${key}[${i}]`, depth + 1);
1433
+ if (result.keep) out.push(result.value);
1434
+ }
1435
+ return { keep: true, value: out };
1436
+ }
1437
+ if (t === "object") {
1438
+ const obj = value;
1439
+ if (seen.has(obj)) {
1440
+ warnings.push({ kind: "circular_reference", key });
1441
+ return { keep: true, value: "[circular]" };
1442
+ }
1443
+ seen.add(obj);
1444
+ const out = {};
1445
+ for (const k of Object.keys(obj)) {
1446
+ const result = visit(obj[k], `${key}.${k}`, depth + 1);
1447
+ if (result.keep) out[k] = result.value;
1448
+ }
1449
+ return { keep: true, value: out };
1450
+ }
1451
+ warnings.push({ kind: "non_serialisable", key });
1452
+ try {
1453
+ return { keep: true, value: String(value) };
1454
+ } catch {
1455
+ return { keep: false, value: void 0 };
1456
+ }
1457
+ };
1458
+ const cleaned = {};
1459
+ for (const k of Object.keys(input)) {
1460
+ const result = visit(input[k], k, 0);
1461
+ if (result.keep) cleaned[k] = result.value;
1462
+ }
1463
+ const serialised = safeStringify(cleaned);
1464
+ if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
1465
+ warnings.push({ kind: "size_cap_exceeded", key: "*" });
1466
+ const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
1467
+ let currentSize = byteLength(serialised);
1468
+ for (const { k } of sizes) {
1469
+ if (currentSize <= maxBatchPropertyBytes) break;
1470
+ currentSize -= sizes.find((s) => s.k === k).size;
1471
+ delete cleaned[k];
1472
+ }
1473
+ cleaned.__truncated = true;
1474
+ }
1475
+ return { properties: cleaned, warnings };
1476
+ }
1477
+ function safeStringify(v) {
1478
+ try {
1479
+ return JSON.stringify(v) ?? null;
1480
+ } catch {
1481
+ return null;
1482
+ }
1483
+ }
1484
+ function byteLength(s) {
1485
+ if (typeof TextEncoder !== "undefined") {
1486
+ return new TextEncoder().encode(s).length;
1487
+ }
1488
+ return s.length * 4;
1489
+ }
1490
+
1491
+ // src/super-properties.ts
1492
+ var KEY_SUPER = "super_props";
1493
+ var KEY_GROUPS = "groups";
1494
+ var SuperPropertyStore = class {
1495
+ constructor(storage, prefix) {
1496
+ this.storage = storage;
1497
+ this.prefix = prefix;
1498
+ this.superProps = {};
1499
+ this.groups = {};
1500
+ this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
1501
+ this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
1502
+ }
1503
+ // ---------- super properties ----------
1504
+ /**
1505
+ * Merge new keys into the super-property bag. Returns a snapshot of
1506
+ * the resulting bag. Values that are `null` are deleted (Mixpanel
1507
+ * semantics — explicit null = "stop tracking this key").
1508
+ */
1509
+ register(props) {
1510
+ for (const [k, v] of Object.entries(props)) {
1511
+ if (v === null) {
1512
+ delete this.superProps[k];
1513
+ } else if (v !== void 0) {
1514
+ this.superProps[k] = v;
1515
+ }
1516
+ }
1517
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1518
+ return { ...this.superProps };
1519
+ }
1520
+ /** Remove a single super-property key. Idempotent. */
1521
+ unregister(key) {
1522
+ if (key in this.superProps) {
1523
+ delete this.superProps[key];
1524
+ writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
1525
+ }
1526
+ }
1527
+ /** Snapshot of the current super-property bag. */
1528
+ getSuperProperties() {
1529
+ return { ...this.superProps };
1530
+ }
1531
+ // ---------- groups ----------
1532
+ /**
1533
+ * Set a group membership. Passing `id: null` clears the membership
1534
+ * for that group type — the SDK stops attaching it to events.
1535
+ */
1536
+ setGroup(type, id, traits) {
1537
+ if (id === null) {
1538
+ delete this.groups[type];
1539
+ } else {
1540
+ this.groups[type] = traits !== void 0 ? { id, traits } : { id };
1541
+ }
1542
+ writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
1543
+ }
1544
+ /**
1545
+ * Snapshot of the current groups map, keyed by group type. Returned
1546
+ * shape mirrors what the SDK attaches to every event as
1547
+ * `$groups.{type}`. The `traits` sub-object is the most-recent
1548
+ * traits payload passed to `setGroup` for that type; null when none.
1549
+ */
1550
+ getGroups() {
1551
+ return JSON.parse(JSON.stringify(this.groups));
1552
+ }
1553
+ /**
1554
+ * The flat `{ type: id }` projection used for event-attachment. Stable
1555
+ * for fast every-event merge — we don't want to JSON-clone on each
1556
+ * track() call.
1557
+ */
1558
+ getGroupIds() {
1559
+ const out = {};
1560
+ for (const [type, info] of Object.entries(this.groups)) {
1561
+ out[type] = info.id;
1562
+ }
1563
+ return out;
1564
+ }
1565
+ /** Wipe both bags. Called by Crossdeck.reset() (logout). */
1566
+ clear() {
1567
+ this.superProps = {};
1568
+ this.groups = {};
1569
+ try {
1570
+ this.storage.removeItem(this.prefix + KEY_SUPER);
1571
+ } catch {
1572
+ }
1573
+ try {
1574
+ this.storage.removeItem(this.prefix + KEY_GROUPS);
1575
+ } catch {
1576
+ }
1577
+ }
1578
+ };
1579
+ function readJson(storage, key) {
1580
+ let raw;
1581
+ try {
1582
+ raw = storage.getItem(key);
1583
+ } catch {
1584
+ return null;
1585
+ }
1586
+ if (!raw) return null;
1587
+ try {
1588
+ return JSON.parse(raw);
1589
+ } catch {
1590
+ return null;
1591
+ }
1592
+ }
1593
+ function writeJson(storage, key, value) {
1594
+ try {
1595
+ storage.setItem(key, JSON.stringify(value));
1596
+ } catch {
1597
+ }
1598
+ }
1599
+
1600
+ // src/web-vitals.ts
1601
+ var WebVitalsTracker = class {
1602
+ constructor(cfg, report) {
1603
+ this.cfg = cfg;
1604
+ this.report = report;
1605
+ this.observers = [];
1606
+ this.flushed = /* @__PURE__ */ new Set();
1607
+ this.cls = 0;
1608
+ this.clsEntries = [];
1609
+ this.inp = 0;
1610
+ this.cleanups = [];
1611
+ }
1612
+ install() {
1613
+ if (!this.cfg.enabled) return;
1614
+ if (typeof PerformanceObserver === "undefined") return;
1615
+ if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
1616
+ const doc = globalThis.document;
1617
+ try {
1618
+ const navObserver = new PerformanceObserver((list) => {
1619
+ for (const entry of list.getEntries()) {
1620
+ const e = entry;
1621
+ if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
1622
+ this.flushed.add("ttfb");
1623
+ this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
1624
+ }
1625
+ }
1626
+ });
1627
+ navObserver.observe({ type: "navigation", buffered: true });
1628
+ this.observers.push(navObserver);
1629
+ } catch {
1630
+ }
1631
+ try {
1632
+ const paintObserver = new PerformanceObserver((list) => {
1633
+ for (const entry of list.getEntries()) {
1634
+ if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
1635
+ this.flushed.add("fcp");
1636
+ this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
1637
+ }
1638
+ }
1639
+ });
1640
+ paintObserver.observe({ type: "paint", buffered: true });
1641
+ this.observers.push(paintObserver);
1642
+ } catch {
1643
+ }
1644
+ let lcpValue = 0;
1645
+ try {
1646
+ const lcpObserver = new PerformanceObserver((list) => {
1647
+ const entries = list.getEntries();
1648
+ const last = entries[entries.length - 1];
1649
+ if (last) lcpValue = last.startTime;
1650
+ });
1651
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
1652
+ this.observers.push(lcpObserver);
1653
+ } catch {
1654
+ }
1655
+ try {
1656
+ const clsObserver = new PerformanceObserver((list) => {
1657
+ for (const entry of list.getEntries()) {
1658
+ const e = entry;
1659
+ if (typeof e.value === "number" && !e.hadRecentInput) {
1660
+ this.cls += e.value;
1661
+ this.clsEntries.push(entry);
1662
+ }
1663
+ }
1664
+ });
1665
+ clsObserver.observe({ type: "layout-shift", buffered: true });
1666
+ this.observers.push(clsObserver);
1667
+ } catch {
1668
+ }
1669
+ try {
1670
+ const eventObserver = new PerformanceObserver((list) => {
1671
+ for (const entry of list.getEntries()) {
1672
+ const e = entry;
1673
+ if (e.interactionId && e.duration > this.inp) {
1674
+ this.inp = e.duration;
1675
+ }
1676
+ }
1677
+ });
1678
+ try {
1679
+ eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
1680
+ } catch {
1681
+ eventObserver.observe({ type: "first-input", buffered: true });
1682
+ }
1683
+ this.observers.push(eventObserver);
1684
+ } catch {
1685
+ }
1686
+ const flush = () => {
1687
+ if (lcpValue > 0 && !this.flushed.has("lcp")) {
1688
+ this.flushed.add("lcp");
1689
+ this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
1690
+ }
1691
+ if (this.cls > 0 && !this.flushed.has("cls")) {
1692
+ this.flushed.add("cls");
1693
+ this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
1694
+ }
1695
+ if (this.inp > 0 && !this.flushed.has("inp")) {
1696
+ this.flushed.add("inp");
1697
+ this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
1698
+ }
1699
+ };
1700
+ const onHidden = () => {
1701
+ if (doc.visibilityState === "hidden") flush();
1702
+ };
1703
+ doc.addEventListener("visibilitychange", onHidden);
1704
+ globalThis.window.addEventListener("pagehide", flush);
1705
+ this.cleanups.push(() => {
1706
+ doc.removeEventListener("visibilitychange", onHidden);
1707
+ globalThis.window.removeEventListener("pagehide", flush);
1708
+ });
1709
+ }
1710
+ uninstall() {
1711
+ for (const o of this.observers) {
1712
+ try {
1713
+ o.disconnect();
1714
+ } catch {
1715
+ }
1716
+ }
1717
+ this.observers = [];
1718
+ for (const fn of this.cleanups.splice(0)) {
1719
+ try {
1720
+ fn();
1721
+ } catch {
1722
+ }
1723
+ }
1724
+ }
1725
+ };
1726
+
1727
+ // src/consent.ts
1728
+ var ALL_GRANTED = {
1729
+ analytics: true,
1730
+ marketing: true,
1731
+ errors: true
1732
+ };
1733
+ var ConsentManager = class {
1734
+ constructor(options) {
1735
+ this.state = { ...ALL_GRANTED };
1736
+ this.dntDenied = false;
1737
+ if (options?.respectDnt && this.detectDnt()) {
1738
+ this.dntDenied = true;
1739
+ this.state = { analytics: false, marketing: false, errors: false };
1740
+ }
1741
+ }
1742
+ /**
1743
+ * Merge new dimensions onto the current state. Returns the resulting
1744
+ * snapshot. DNT-derived denies cannot be flipped back on by a `set`
1745
+ * call — once the browser says "don't track", we don't track even if
1746
+ * the developer code disagrees. That's the contract.
1747
+ */
1748
+ set(partial) {
1749
+ if (this.dntDenied) return { ...this.state };
1750
+ for (const k of Object.keys(partial)) {
1751
+ const v = partial[k];
1752
+ if (typeof v === "boolean") this.state[k] = v;
1753
+ }
1754
+ return { ...this.state };
1755
+ }
1756
+ /** Snapshot of the current state. */
1757
+ get() {
1758
+ return { ...this.state };
1759
+ }
1760
+ /** Convenience getters for hot paths. */
1761
+ get analytics() {
1762
+ return this.state.analytics;
1763
+ }
1764
+ get marketing() {
1765
+ return this.state.marketing;
1766
+ }
1767
+ get errors() {
1768
+ return this.state.errors;
1769
+ }
1770
+ /** True iff the constructor detected and applied DNT. */
1771
+ get isDntDenied() {
1772
+ return this.dntDenied;
1773
+ }
1774
+ detectDnt() {
1775
+ try {
1776
+ const nav = globalThis.navigator;
1777
+ if (!nav) return false;
1778
+ const sources = [
1779
+ nav.doNotTrack,
1780
+ nav.msDoNotTrack,
1781
+ globalThis.doNotTrack
1782
+ ];
1783
+ return sources.some((v) => v === "1" || v === "yes");
1784
+ } catch {
1785
+ return false;
1786
+ }
1787
+ }
1788
+ };
1789
+ var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
1790
+ var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
1791
+ var REPLACEMENT_EMAIL = "[email]";
1792
+ var REPLACEMENT_CARD = "[card]";
1793
+ function scrubPii(value) {
1794
+ if (!value) return value;
1795
+ let out = value;
1796
+ if (EMAIL_PATTERN.test(out)) {
1797
+ out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
1798
+ }
1799
+ EMAIL_PATTERN.lastIndex = 0;
1800
+ if (CARD_PATTERN.test(out)) {
1801
+ out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
1802
+ }
1803
+ CARD_PATTERN.lastIndex = 0;
1804
+ return out;
1805
+ }
1806
+ function scrubPiiFromProperties(properties) {
1807
+ const out = {};
1808
+ for (const k of Object.keys(properties)) {
1809
+ const v = properties[k];
1810
+ if (typeof v === "string") {
1811
+ out[k] = scrubPii(v);
1812
+ } else if (Array.isArray(v)) {
1813
+ out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
1814
+ } else {
1815
+ out[k] = v;
1816
+ }
1817
+ }
1818
+ return out;
1819
+ }
1820
+
866
1821
  // src/crossdeck.ts
867
1822
  var CrossdeckClient = class {
868
1823
  constructor() {
@@ -907,6 +1862,7 @@ var CrossdeckClient = class {
907
1862
  message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
908
1863
  });
909
1864
  }
1865
+ const localDevMode = isLocalHostname();
910
1866
  const storage = options.storage ?? detectDefaultStorage();
911
1867
  const persistIdentity = options.persistIdentity ?? true;
912
1868
  const autoTrack = resolveAutoTrack(options.autoTrack);
@@ -933,14 +1889,31 @@ var CrossdeckClient = class {
933
1889
  const http = new HttpClient({
934
1890
  publicKey: opts.publicKey,
935
1891
  baseUrl: opts.baseUrl,
936
- sdkVersion: opts.sdkVersion
1892
+ sdkVersion: opts.sdkVersion,
1893
+ // Localhost auto-route: HttpClient short-circuits every request
1894
+ // to a successful no-op response when localDevMode is set.
1895
+ // SDK methods continue to work locally; nothing reaches the
1896
+ // server.
1897
+ localDevMode
937
1898
  });
1899
+ if (localDevMode) {
1900
+ console.log(
1901
+ "[crossdeck] Localhost detected \u2014 running in dev mode (no network calls). Set publicKey: 'cd_pub_test_\u2026' and deploy to a real domain to test against the Crossdeck Sandbox."
1902
+ );
1903
+ }
938
1904
  const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
939
1905
  const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
940
1906
  typeof globalThis.document !== "undefined";
941
1907
  const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
942
1908
  const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
943
1909
  const entitlements = new EntitlementCache();
1910
+ const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
1911
+ if (persistentEvents) {
1912
+ debug.emit(
1913
+ "sdk.queue_restored",
1914
+ "Restored persisted event queue from a prior session."
1915
+ );
1916
+ }
944
1917
  const events = new EventQueue({
945
1918
  http,
946
1919
  batchSize: opts.eventFlushBatchSize,
@@ -950,26 +1923,51 @@ var CrossdeckClient = class {
950
1923
  environment: opts.environment,
951
1924
  sdk: { name: SDK_NAME, version: opts.sdkVersion }
952
1925
  }),
1926
+ persistentStore: persistentEvents ?? void 0,
953
1927
  onFirstFlushSuccess: () => {
954
1928
  debug.emit(
955
1929
  "sdk.first_event_sent",
956
1930
  "First telemetry event received. View it in Live Events.",
957
1931
  { appId: opts.appId, environment: opts.environment }
958
1932
  );
1933
+ },
1934
+ onRetryScheduled: (info) => {
1935
+ debug.emit(
1936
+ "sdk.flush_retry_scheduled",
1937
+ `Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
1938
+ { ...info }
1939
+ );
959
1940
  }
960
1941
  });
961
1942
  const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
1943
+ const superProps = new SuperPropertyStore(
1944
+ persistIdentity ? effectiveStorage : new MemoryStorage(),
1945
+ opts.storagePrefix
1946
+ );
1947
+ const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
1948
+ if (consent.isDntDenied) {
1949
+ debug.emit(
1950
+ "sdk.consent_dnt_applied",
1951
+ "Do Not Track detected \u2014 all tracking dimensions denied at init."
1952
+ );
1953
+ }
962
1954
  this.state = {
963
1955
  http,
964
1956
  identity,
965
1957
  entitlements,
966
1958
  events,
967
1959
  autoTracker: null,
1960
+ webVitals: null,
1961
+ superProps,
1962
+ consent,
1963
+ scrubPii: options.scrubPii !== false,
968
1964
  deviceInfo,
969
1965
  options: opts,
970
1966
  debug,
971
1967
  developerUserId: null,
972
- uninstallUnloadFlush: null
1968
+ uninstallUnloadFlush: null,
1969
+ lastServerTime: null,
1970
+ lastClientTime: null
973
1971
  };
974
1972
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
975
1973
  appId: opts.appId,
@@ -984,10 +1982,18 @@ var CrossdeckClient = class {
984
1982
  this.state.autoTracker = tracker;
985
1983
  tracker.install();
986
1984
  }
1985
+ if (autoTrack.webVitals) {
1986
+ const vitals = new WebVitalsTracker(
1987
+ { enabled: true },
1988
+ (name, properties) => this.track(name, properties)
1989
+ );
1990
+ this.state.webVitals = vitals;
1991
+ vitals.install();
1992
+ }
987
1993
  this.state.uninstallUnloadFlush = installUnloadFlush(() => {
988
1994
  void this.flush({ keepalive: true }).catch(() => void 0);
989
1995
  });
990
- if (opts.autoHeartbeat) {
1996
+ if (opts.autoHeartbeat && !localDevMode) {
991
1997
  void this.heartbeat().catch(() => void 0);
992
1998
  }
993
1999
  }
@@ -1007,8 +2013,19 @@ var CrossdeckClient = class {
1007
2013
  /**
1008
2014
  * Link the anonymous device to a developer-supplied user ID. Cache
1009
2015
  * the resolved Crossdeck customer for follow-up calls.
2016
+ *
2017
+ * v0.9.0+ accepts an optional `traits` bag — profile data (name,
2018
+ * plan, signupDate, role) persisted on the Crossdeck customer record
2019
+ * and queryable from dashboards. Traits are sanitised through the
2020
+ * same validator that gates `track()` properties, so a `{ avatar:
2021
+ * <File>, onSave: () => {} }` payload can't corrupt the alias call.
2022
+ *
2023
+ * Crossdeck.identify("user_847", {
2024
+ * email: "wes@pinet.co.za",
2025
+ * traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
2026
+ * });
1010
2027
  */
1011
- async identify(userId, _options) {
2028
+ async identify(userId, options) {
1012
2029
  const s = this.requireStarted();
1013
2030
  if (!userId) {
1014
2031
  throw new CrossdeckError({
@@ -1017,13 +2034,163 @@ var CrossdeckClient = class {
1017
2034
  message: "identify(userId) requires a non-empty userId."
1018
2035
  });
1019
2036
  }
2037
+ if (!s.consent.analytics) {
2038
+ s.debug.emit(
2039
+ "sdk.consent_denied",
2040
+ `identify() skipped \u2014 consent denied for analytics.`
2041
+ );
2042
+ return {
2043
+ object: "alias_result",
2044
+ crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
2045
+ linked: [],
2046
+ mergePending: false,
2047
+ env: s.options.environment
2048
+ };
2049
+ }
2050
+ const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
2051
+ const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
2052
+ if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
2053
+ for (const w of traitsValidation.warnings) {
2054
+ s.debug.emit(
2055
+ "sdk.property_coerced",
2056
+ `identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2057
+ { key: w.key, kind: w.kind }
2058
+ );
2059
+ }
2060
+ }
2061
+ const body = {
2062
+ userId,
2063
+ anonymousId: s.identity.anonymousId
2064
+ };
2065
+ if (options?.email) body.email = options.email;
2066
+ if (traits) body.traits = traits;
1020
2067
  const result = await s.http.request("POST", "/identity/alias", {
1021
- body: { userId, anonymousId: s.identity.anonymousId }
2068
+ body
1022
2069
  });
1023
2070
  s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
1024
2071
  s.developerUserId = userId;
1025
2072
  return result;
1026
2073
  }
2074
+ /**
2075
+ * Register super-properties — Mixpanel pattern. Once set, every
2076
+ * subsequent event of THIS SDK instance carries these keys on its
2077
+ * properties bag automatically.
2078
+ *
2079
+ * Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
2080
+ * Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
2081
+ *
2082
+ * Values that are `null` are deleted (the explicit "stop tracking
2083
+ * this key" idiom). Returns the resulting bag.
2084
+ *
2085
+ * Sanitised through `validateEventProperties` so a `{ avatar: File }`
2086
+ * payload can't poison the queue at flush time.
2087
+ */
2088
+ register(properties) {
2089
+ const s = this.requireStarted();
2090
+ const validation = validateEventProperties(properties);
2091
+ return s.superProps.register(validation.properties);
2092
+ }
2093
+ /** Remove a single super-property key. Idempotent. */
2094
+ unregister(key) {
2095
+ const s = this.requireStarted();
2096
+ s.superProps.unregister(key);
2097
+ }
2098
+ /** Snapshot of the current super-property bag. */
2099
+ getSuperProperties() {
2100
+ if (!this.state) return {};
2101
+ return this.state.superProps.getSuperProperties();
2102
+ }
2103
+ /**
2104
+ * Associate the current user with a group (org, team, account, etc.).
2105
+ * Mixpanel / Segment "Group Analytics" pattern.
2106
+ *
2107
+ * Crossdeck.group("org", "acme_inc");
2108
+ * Crossdeck.group("team", "design", { headcount: 12 });
2109
+ *
2110
+ * Once set, every subsequent event carries `$groups.<type>: id` on
2111
+ * its properties bag, enabling B2B dashboards ("how is Acme using
2112
+ * the product"). Pass `id: null` to clear a group membership.
2113
+ */
2114
+ group(type, id, traits) {
2115
+ const s = this.requireStarted();
2116
+ if (!type) {
2117
+ throw new CrossdeckError({
2118
+ type: "invalid_request_error",
2119
+ code: "missing_group_type",
2120
+ message: "group(type, id) requires a non-empty type."
2121
+ });
2122
+ }
2123
+ const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
2124
+ s.superProps.setGroup(type, id, sanitisedTraits);
2125
+ }
2126
+ /** Snapshot of the current groups map keyed by type. */
2127
+ getGroups() {
2128
+ if (!this.state) return {};
2129
+ return this.state.superProps.getGroups();
2130
+ }
2131
+ /**
2132
+ * Update consent state. Three independent dimensions:
2133
+ *
2134
+ * analytics — track() + identify() + auto-emissions
2135
+ * marketing — paid-traffic click IDs + referrer URL on events
2136
+ * errors — Web Vitals + (future) error reporting
2137
+ *
2138
+ * Each defaults to `true` (granted). Pass partial state — only the
2139
+ * keys you provide are changed.
2140
+ *
2141
+ * Crossdeck.consent({ analytics: false });
2142
+ * Crossdeck.consent({ marketing: true, errors: true });
2143
+ *
2144
+ * DNT-derived denies cannot be flipped back on; if the browser said
2145
+ * "don't track" we don't track even if the developer code disagrees.
2146
+ */
2147
+ consent(state) {
2148
+ const s = this.requireStarted();
2149
+ const next = s.consent.set(state);
2150
+ s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
2151
+ return next;
2152
+ }
2153
+ /** Snapshot of the current consent state. */
2154
+ consentStatus() {
2155
+ if (!this.state) {
2156
+ return { analytics: true, marketing: true, errors: true };
2157
+ }
2158
+ return this.state.consent.get();
2159
+ }
2160
+ /**
2161
+ * GDPR/CCPA "right to be forgotten" — calls the backend's
2162
+ * /v1/identity/forget endpoint to schedule a server-side deletion of
2163
+ * the customer's events and profile, then wipes all local state
2164
+ * (identity, entitlements, queue, super-props, persistent stores).
2165
+ *
2166
+ * Idempotent. Safe to call when no identity has been established
2167
+ * (it just wipes the empty local state).
2168
+ *
2169
+ * After forget() resolves, the SDK is in the same shape as if the
2170
+ * developer had called `Crossdeck.reset()` — a fresh anonymousId is
2171
+ * minted and the next session is a brand new identity-graph entry.
2172
+ */
2173
+ async forget() {
2174
+ const s = this.requireStarted();
2175
+ const identityQuery = this.identityQueryParams();
2176
+ try {
2177
+ await s.http.request("POST", "/identity/forget", {
2178
+ body: {
2179
+ // Send every identity hint we hold; the server resolves the
2180
+ // canonical customer record and queues deletion. Missing
2181
+ // endpoint (older backend) gracefully degrades — local state
2182
+ // still wipes via the reset() call below.
2183
+ ...identityQuery
2184
+ }
2185
+ });
2186
+ } catch (err) {
2187
+ s.debug.emit(
2188
+ "sdk.consent_denied",
2189
+ `forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
2190
+ );
2191
+ }
2192
+ this.reset();
2193
+ }
1027
2194
  /**
1028
2195
  * Read the current customer's active entitlements from the server.
1029
2196
  * Updates the local cache so subsequent isEntitled() calls answer
@@ -1101,6 +2268,17 @@ var CrossdeckClient = class {
1101
2268
  message: "track(name) requires a non-empty name."
1102
2269
  });
1103
2270
  }
2271
+ const isWebVital = name.startsWith("webvitals.");
2272
+ const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
2273
+ if (!consentGateOk) {
2274
+ if (s.debug.enabled) {
2275
+ s.debug.emit(
2276
+ "sdk.consent_denied",
2277
+ `Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
2278
+ );
2279
+ }
2280
+ return;
2281
+ }
1104
2282
  if (s.debug.enabled && properties) {
1105
2283
  const flagged = findSensitivePropertyKeys(properties);
1106
2284
  if (flagged.length > 0) {
@@ -1117,9 +2295,21 @@ var CrossdeckClient = class {
1117
2295
  "Using anonymous user until identify(userId) is called."
1118
2296
  );
1119
2297
  }
2298
+ const validation = validateEventProperties(properties);
2299
+ if (s.debug.enabled && validation.warnings.length > 0) {
2300
+ for (const w of validation.warnings) {
2301
+ s.debug.emit(
2302
+ "sdk.property_coerced",
2303
+ `Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
2304
+ { eventName: name, key: w.key, kind: w.kind }
2305
+ );
2306
+ }
2307
+ }
1120
2308
  const enriched = { ...s.deviceInfo };
1121
2309
  const sessionId = s.autoTracker?.currentSessionId;
1122
2310
  if (sessionId) enriched.sessionId = sessionId;
2311
+ const pageviewId = s.autoTracker?.currentPageviewId;
2312
+ if (pageviewId) enriched.pageviewId = pageviewId;
1123
2313
  const acquisition = s.autoTracker?.currentAcquisition;
1124
2314
  if (acquisition) {
1125
2315
  if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
@@ -1127,14 +2317,31 @@ var CrossdeckClient = class {
1127
2317
  if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
1128
2318
  if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
1129
2319
  if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
1130
- if (acquisition.referrer) enriched.referrer = acquisition.referrer;
2320
+ if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
2321
+ if (s.consent.marketing) {
2322
+ if (acquisition.gclid) enriched.gclid = acquisition.gclid;
2323
+ if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
2324
+ if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
2325
+ if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
2326
+ if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
2327
+ if (acquisition.twclid) enriched.twclid = acquisition.twclid;
2328
+ }
2329
+ }
2330
+ const supers = s.superProps.getSuperProperties();
2331
+ for (const k of Object.keys(supers)) {
2332
+ if (!(k in enriched)) enriched[k] = supers[k];
2333
+ }
2334
+ const groupIds = s.superProps.getGroupIds();
2335
+ if (Object.keys(groupIds).length > 0) {
2336
+ enriched.$groups = groupIds;
1131
2337
  }
1132
- if (properties) Object.assign(enriched, properties);
2338
+ Object.assign(enriched, validation.properties);
2339
+ const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
1133
2340
  const event = {
1134
2341
  eventId: this.mintEventId(),
1135
2342
  name,
1136
2343
  timestamp: Date.now(),
1137
- properties: enriched
2344
+ properties: finalProperties
1138
2345
  };
1139
2346
  Object.assign(event, this.identityHintForEvent());
1140
2347
  s.events.enqueue(event);
@@ -1212,7 +2419,12 @@ var CrossdeckClient = class {
1212
2419
  */
1213
2420
  async heartbeat() {
1214
2421
  const s = this.requireStarted();
1215
- return await s.http.request("GET", "/sdk/heartbeat");
2422
+ const result = await s.http.request("GET", "/sdk/heartbeat");
2423
+ if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
2424
+ s.lastServerTime = result.serverTime;
2425
+ s.lastClientTime = Date.now();
2426
+ }
2427
+ return result;
1216
2428
  }
1217
2429
  /**
1218
2430
  * Wipe persisted identity + entitlement cache. Use on logout. The
@@ -1221,10 +2433,17 @@ var CrossdeckClient = class {
1221
2433
  */
1222
2434
  reset() {
1223
2435
  if (!this.state) return;
2436
+ if (this.state.developerUserId) {
2437
+ try {
2438
+ this.track("user.signed_out", { auto: true });
2439
+ } catch {
2440
+ }
2441
+ }
1224
2442
  this.state.autoTracker?.uninstall();
1225
2443
  this.state.identity.reset();
1226
2444
  this.state.entitlements.clear();
1227
2445
  this.state.events.reset();
2446
+ this.state.superProps.clear();
1228
2447
  this.state.developerUserId = null;
1229
2448
  if (this.state.autoTracker) {
1230
2449
  const tracker = new AutoTracker(
@@ -1252,17 +2471,21 @@ var CrossdeckClient = class {
1252
2471
  developerUserId: null,
1253
2472
  sdkVersion: null,
1254
2473
  baseUrl: null,
1255
- entitlements: { count: 0, lastUpdated: 0 },
2474
+ clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
2475
+ entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
1256
2476
  events: {
1257
2477
  buffered: 0,
1258
2478
  dropped: 0,
1259
2479
  inFlight: 0,
1260
2480
  lastFlushAt: 0,
1261
- lastError: null
2481
+ lastError: null,
2482
+ consecutiveFailures: 0,
2483
+ nextRetryAt: null
1262
2484
  }
1263
2485
  };
1264
2486
  }
1265
2487
  const s = this.state;
2488
+ const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
1266
2489
  return {
1267
2490
  started: true,
1268
2491
  anonymousId: s.identity.anonymousId,
@@ -1270,9 +2493,15 @@ var CrossdeckClient = class {
1270
2493
  developerUserId: s.developerUserId,
1271
2494
  sdkVersion: s.options.sdkVersion,
1272
2495
  baseUrl: s.options.baseUrl,
2496
+ clock: {
2497
+ lastServerTime: s.lastServerTime,
2498
+ lastClientTime: s.lastClientTime,
2499
+ skewMs
2500
+ },
1273
2501
  entitlements: {
1274
2502
  count: s.entitlements.list().length,
1275
- lastUpdated: s.entitlements.freshness
2503
+ lastUpdated: s.entitlements.freshness,
2504
+ listenerErrors: s.entitlements.listenerErrors
1276
2505
  },
1277
2506
  events: s.events.getStats()
1278
2507
  };
@@ -1301,14 +2530,30 @@ var CrossdeckClient = class {
1301
2530
  if (s.developerUserId) return { userId: s.developerUserId };
1302
2531
  return { anonymousId: s.identity.anonymousId };
1303
2532
  }
1304
- /** Pick the right identity hint to embed on a queued event. */
2533
+ /**
2534
+ * Embed every known identity axis on the event. Earlier this returned
2535
+ * just the highest-priority hint (cdcust → developerUserId → anonymousId)
2536
+ * to keep payloads small, but that leaked into analytics: once a user
2537
+ * was logged in, every subsequent page.viewed shipped without
2538
+ * anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
2539
+ * counted 0 visitors for the entire authenticated app.
2540
+ *
2541
+ * Bank-grade rule: the server is the single source of truth on
2542
+ * dedup. Send everything we know; let CH count by whichever axis
2543
+ * matches the question. Each field is at most 32 bytes — sending
2544
+ * three on every event costs ~80 bytes per request, which is
2545
+ * trivial compared to the analytics correctness it buys.
2546
+ */
1305
2547
  identityHintForEvent() {
1306
2548
  const s = this.requireStarted();
2549
+ const hint = {
2550
+ anonymousId: s.identity.anonymousId
2551
+ };
2552
+ if (s.developerUserId) hint.developerUserId = s.developerUserId;
1307
2553
  if (s.identity.crossdeckCustomerId) {
1308
- return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
2554
+ hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
1309
2555
  }
1310
- if (s.developerUserId) return { developerUserId: s.developerUserId };
1311
- return { anonymousId: s.identity.anonymousId };
2556
+ return hint;
1312
2557
  }
1313
2558
  mintEventId() {
1314
2559
  const ts = Date.now().toString(36);
@@ -1321,9 +2566,28 @@ function inferEnvFromKey(publicKey) {
1321
2566
  if (publicKey.startsWith("cd_pub_live_")) return "production";
1322
2567
  return null;
1323
2568
  }
2569
+ function isLocalHostname() {
2570
+ const w = globalThis.window;
2571
+ if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
2572
+ const hostname = w?.location?.hostname;
2573
+ if (!hostname) return false;
2574
+ if (hostname === "localhost" || hostname === "127.0.0.1") return true;
2575
+ if (hostname === "::1" || hostname === "[::1]") return true;
2576
+ if (hostname.endsWith(".local")) return true;
2577
+ if (/^10\./.test(hostname)) return true;
2578
+ if (/^192\.168\./.test(hostname)) return true;
2579
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
2580
+ return false;
2581
+ }
1324
2582
  function resolveAutoTrack(input) {
1325
2583
  if (input === false) {
1326
- return { sessions: false, pageViews: false, deviceInfo: false };
2584
+ return {
2585
+ sessions: false,
2586
+ pageViews: false,
2587
+ deviceInfo: false,
2588
+ clicks: false,
2589
+ webVitals: false
2590
+ };
1327
2591
  }
1328
2592
  if (input === void 0 || input === true) {
1329
2593
  return { ...DEFAULT_AUTO_TRACK };
@@ -1331,7 +2595,9 @@ function resolveAutoTrack(input) {
1331
2595
  return {
1332
2596
  sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
1333
2597
  pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
1334
- deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
2598
+ deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
2599
+ clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
2600
+ webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
1335
2601
  };
1336
2602
  }
1337
2603
  function installUnloadFlush(onUnload) {