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