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