@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/CHANGELOG.md +93 -0
- package/dist/crossdeck.umd.min.js +2 -0
- package/dist/crossdeck.umd.min.js.map +1 -0
- package/dist/error-codes.json +91 -0
- package/dist/index.cjs +1403 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +281 -5
- package/dist/index.d.ts +281 -5
- package/dist/index.mjs +1400 -38
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +1303 -37
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +1303 -37
- package/dist/react.mjs.map +1 -1
- package/dist/vue.cjs +2675 -0
- package/dist/vue.cjs.map +1 -0
- package/dist/vue.d.mts +37 -0
- package/dist/vue.d.ts +37 -0
- package/dist/vue.mjs +2649 -0
- package/dist/vue.mjs.map +1 -0
- package/package.json +25 -6
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
401
|
-
this.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
2554
|
+
hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
|
|
1309
2555
|
}
|
|
1310
|
-
|
|
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 {
|
|
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) {
|