@cross-deck/web 0.7.0 → 1.0.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 +197 -0
- package/dist/crossdeck.umd.min.js +3 -0
- package/dist/crossdeck.umd.min.js.map +1 -0
- package/dist/error-codes.json +91 -0
- package/dist/index.cjs +1810 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +469 -4
- package/dist/index.d.ts +469 -4
- package/dist/index.mjs +1807 -29
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +1710 -28
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +1710 -28
- package/dist/react.mjs.map +1 -1
- package/dist/vue.cjs +3350 -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 +3324 -0
- package/dist/vue.mjs.map +1 -0
- package/package.json +25 -6
package/dist/index.cjs
CHANGED
|
@@ -20,13 +20,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
CROSSDECK_ERROR_CODES: () => CROSSDECK_ERROR_CODES,
|
|
23
24
|
Crossdeck: () => Crossdeck,
|
|
24
25
|
CrossdeckClient: () => CrossdeckClient,
|
|
25
26
|
CrossdeckError: () => CrossdeckError,
|
|
26
27
|
DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
|
|
27
28
|
MemoryStorage: () => MemoryStorage,
|
|
28
29
|
SDK_NAME: () => SDK_NAME,
|
|
29
|
-
SDK_VERSION: () => SDK_VERSION
|
|
30
|
+
SDK_VERSION: () => SDK_VERSION,
|
|
31
|
+
getErrorCode: () => getErrorCode
|
|
30
32
|
});
|
|
31
33
|
module.exports = __toCommonJS(index_exports);
|
|
32
34
|
|
|
@@ -39,11 +41,13 @@ var CrossdeckError = class _CrossdeckError extends Error {
|
|
|
39
41
|
this.code = payload.code;
|
|
40
42
|
this.requestId = payload.requestId;
|
|
41
43
|
this.status = payload.status;
|
|
44
|
+
this.retryAfterMs = payload.retryAfterMs;
|
|
42
45
|
Object.setPrototypeOf(this, _CrossdeckError.prototype);
|
|
43
46
|
}
|
|
44
47
|
};
|
|
45
48
|
async function crossdeckErrorFromResponse(res) {
|
|
46
49
|
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
50
|
+
const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
|
|
47
51
|
let body;
|
|
48
52
|
try {
|
|
49
53
|
body = await res.json();
|
|
@@ -57,7 +61,8 @@ async function crossdeckErrorFromResponse(res) {
|
|
|
57
61
|
code: envelope.code,
|
|
58
62
|
message: envelope.message ?? `HTTP ${res.status}`,
|
|
59
63
|
requestId: envelope.request_id ?? requestId,
|
|
60
|
-
status: res.status
|
|
64
|
+
status: res.status,
|
|
65
|
+
retryAfterMs
|
|
61
66
|
});
|
|
62
67
|
}
|
|
63
68
|
return new CrossdeckError({
|
|
@@ -65,9 +70,25 @@ async function crossdeckErrorFromResponse(res) {
|
|
|
65
70
|
code: `http_${res.status}`,
|
|
66
71
|
message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
|
|
67
72
|
requestId,
|
|
68
|
-
status: res.status
|
|
73
|
+
status: res.status,
|
|
74
|
+
retryAfterMs
|
|
69
75
|
});
|
|
70
76
|
}
|
|
77
|
+
function parseRetryAfterHeader(value) {
|
|
78
|
+
if (!value) return void 0;
|
|
79
|
+
const trimmed = value.trim();
|
|
80
|
+
if (!trimmed) return void 0;
|
|
81
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
82
|
+
const secs = Number(trimmed);
|
|
83
|
+
if (!Number.isFinite(secs) || secs < 0) return void 0;
|
|
84
|
+
return Math.round(secs * 1e3);
|
|
85
|
+
}
|
|
86
|
+
if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
|
|
87
|
+
const target = Date.parse(trimmed);
|
|
88
|
+
if (!Number.isFinite(target)) return void 0;
|
|
89
|
+
const delta = target - Date.now();
|
|
90
|
+
return delta > 0 ? delta : 0;
|
|
91
|
+
}
|
|
71
92
|
function typeMapForStatus(status) {
|
|
72
93
|
if (status === 401) return "authentication_error";
|
|
73
94
|
if (status === 403) return "permission_error";
|
|
@@ -78,8 +99,9 @@ function typeMapForStatus(status) {
|
|
|
78
99
|
|
|
79
100
|
// src/http.ts
|
|
80
101
|
var SDK_NAME = "@cross-deck/web";
|
|
81
|
-
var SDK_VERSION = "0.
|
|
102
|
+
var SDK_VERSION = "1.0.0";
|
|
82
103
|
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
104
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
83
105
|
var HttpClient = class {
|
|
84
106
|
constructor(config) {
|
|
85
107
|
this.config = config;
|
|
@@ -103,25 +125,38 @@ var HttpClient = class {
|
|
|
103
125
|
"Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
|
|
104
126
|
Accept: "application/json"
|
|
105
127
|
};
|
|
128
|
+
if (options.idempotencyKey) {
|
|
129
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
130
|
+
}
|
|
106
131
|
let bodyInit;
|
|
107
132
|
if (options.body !== void 0) {
|
|
108
133
|
headers["Content-Type"] = "application/json";
|
|
109
134
|
bodyInit = JSON.stringify(options.body);
|
|
110
135
|
}
|
|
136
|
+
const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
137
|
+
const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
|
|
138
|
+
let timeoutHandle = null;
|
|
139
|
+
if (controller && effectiveTimeout > 0) {
|
|
140
|
+
timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
|
|
141
|
+
}
|
|
111
142
|
let response;
|
|
112
143
|
try {
|
|
113
144
|
response = await fetch(url, {
|
|
114
145
|
method,
|
|
115
146
|
headers,
|
|
116
147
|
body: bodyInit,
|
|
117
|
-
keepalive: options.keepalive === true
|
|
148
|
+
keepalive: options.keepalive === true,
|
|
149
|
+
signal: controller?.signal
|
|
118
150
|
});
|
|
119
151
|
} catch (err) {
|
|
152
|
+
const aborted = controller?.signal?.aborted === true;
|
|
120
153
|
throw new CrossdeckError({
|
|
121
154
|
type: "network_error",
|
|
122
|
-
code: "fetch_failed",
|
|
123
|
-
message: err instanceof Error ? err.message : "fetch failed"
|
|
155
|
+
code: aborted ? "request_timeout" : "fetch_failed",
|
|
156
|
+
message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
|
|
124
157
|
});
|
|
158
|
+
} finally {
|
|
159
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
125
160
|
}
|
|
126
161
|
if (!response.ok) {
|
|
127
162
|
throw await crossdeckErrorFromResponse(response);
|
|
@@ -318,6 +353,7 @@ var EntitlementCache = class {
|
|
|
318
353
|
this.all = [];
|
|
319
354
|
this.lastUpdated = 0;
|
|
320
355
|
this.listeners = /* @__PURE__ */ new Set();
|
|
356
|
+
this.listenerErrorCount = 0;
|
|
321
357
|
}
|
|
322
358
|
/** Sync read — true iff the entitlement key is currently active. */
|
|
323
359
|
isEntitled(key) {
|
|
@@ -331,6 +367,15 @@ var EntitlementCache = class {
|
|
|
331
367
|
get freshness() {
|
|
332
368
|
return this.lastUpdated;
|
|
333
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Cumulative count of listener invocations that threw. Listener errors
|
|
372
|
+
* are swallowed (a buggy consumer must not crash the SDK) but the
|
|
373
|
+
* counter lets diagnostics() surface "you have a broken subscriber"
|
|
374
|
+
* without putting the developer in a debug session.
|
|
375
|
+
*/
|
|
376
|
+
get listenerErrors() {
|
|
377
|
+
return this.listenerErrorCount;
|
|
378
|
+
}
|
|
334
379
|
/**
|
|
335
380
|
* Replace the cache with a fresh server response. The backend already
|
|
336
381
|
* filters to active + env-matching, so we don't re-filter — just trust
|
|
@@ -384,11 +429,54 @@ var EntitlementCache = class {
|
|
|
384
429
|
try {
|
|
385
430
|
listener(snapshot);
|
|
386
431
|
} catch {
|
|
432
|
+
this.listenerErrorCount += 1;
|
|
387
433
|
}
|
|
388
434
|
}
|
|
389
435
|
}
|
|
390
436
|
};
|
|
391
437
|
|
|
438
|
+
// src/retry-policy.ts
|
|
439
|
+
var DEFAULT_BASE = 1e3;
|
|
440
|
+
var DEFAULT_MAX = 6e4;
|
|
441
|
+
var DEFAULT_FACTOR = 2;
|
|
442
|
+
var DEFAULT_WARN = 8;
|
|
443
|
+
function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
|
|
444
|
+
const base = options.baseMs ?? DEFAULT_BASE;
|
|
445
|
+
const max = options.maxMs ?? DEFAULT_MAX;
|
|
446
|
+
const factor = options.factor ?? DEFAULT_FACTOR;
|
|
447
|
+
const safeAttempts = Math.min(attempts, 30);
|
|
448
|
+
const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
|
|
449
|
+
const jittered = ceiling * random();
|
|
450
|
+
if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
|
|
451
|
+
return Math.min(max, retryAfterMs);
|
|
452
|
+
}
|
|
453
|
+
return Math.max(0, Math.round(jittered));
|
|
454
|
+
}
|
|
455
|
+
var RetryPolicy = class {
|
|
456
|
+
constructor(options = {}) {
|
|
457
|
+
this.options = options;
|
|
458
|
+
this.attempts = 0;
|
|
459
|
+
}
|
|
460
|
+
/** How many consecutive failures since the last success. */
|
|
461
|
+
get consecutiveFailures() {
|
|
462
|
+
return this.attempts;
|
|
463
|
+
}
|
|
464
|
+
/** Whether we've crossed the failuresBeforeWarn threshold. */
|
|
465
|
+
get isWarning() {
|
|
466
|
+
return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
|
|
467
|
+
}
|
|
468
|
+
/** Schedule-time delay for the NEXT retry. Increments the counter. */
|
|
469
|
+
nextDelay(retryAfterMs, random = Math.random) {
|
|
470
|
+
const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
|
|
471
|
+
this.attempts += 1;
|
|
472
|
+
return delay;
|
|
473
|
+
}
|
|
474
|
+
/** Mark a successful flush — reset the counter. */
|
|
475
|
+
recordSuccess() {
|
|
476
|
+
this.attempts = 0;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
392
480
|
// src/event-queue.ts
|
|
393
481
|
var HARD_BUFFER_CAP = 1e3;
|
|
394
482
|
var EventQueue = class {
|
|
@@ -401,6 +489,22 @@ var EventQueue = class {
|
|
|
401
489
|
this.lastError = null;
|
|
402
490
|
this.cancelTimer = null;
|
|
403
491
|
this.firstFlushFired = false;
|
|
492
|
+
this.nextRetryAt = null;
|
|
493
|
+
this.retry = new RetryPolicy(cfg.retry ?? {});
|
|
494
|
+
this.persistent = cfg.persistentStore ?? null;
|
|
495
|
+
if (this.persistent) {
|
|
496
|
+
const restored = this.persistent.load();
|
|
497
|
+
if (restored.length > 0) {
|
|
498
|
+
if (restored.length > HARD_BUFFER_CAP) {
|
|
499
|
+
this.dropped += restored.length - HARD_BUFFER_CAP;
|
|
500
|
+
this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
|
|
501
|
+
} else {
|
|
502
|
+
this.buffer = restored;
|
|
503
|
+
}
|
|
504
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
505
|
+
this.scheduleIdleFlush();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
404
508
|
}
|
|
405
509
|
enqueue(event) {
|
|
406
510
|
this.buffer.push(event);
|
|
@@ -410,6 +514,8 @@ var EventQueue = class {
|
|
|
410
514
|
this.dropped += overflow;
|
|
411
515
|
this.cfg.onDrop?.(overflow);
|
|
412
516
|
}
|
|
517
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
518
|
+
this.persistent?.save(this.buffer);
|
|
413
519
|
if (this.buffer.length >= this.cfg.batchSize) {
|
|
414
520
|
void this.flush();
|
|
415
521
|
} else {
|
|
@@ -419,7 +525,7 @@ var EventQueue = class {
|
|
|
419
525
|
/**
|
|
420
526
|
* Flush the buffer to /v1/events. Resolves when the network call
|
|
421
527
|
* completes (success or failure). On failure, events stay in the
|
|
422
|
-
* buffer for the next
|
|
528
|
+
* buffer for the next scheduled retry.
|
|
423
529
|
*
|
|
424
530
|
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
425
531
|
* browser keeps the request alive past page unload. Use this for
|
|
@@ -428,25 +534,32 @@ var EventQueue = class {
|
|
|
428
534
|
async flush(options = {}) {
|
|
429
535
|
if (this.buffer.length === 0) return null;
|
|
430
536
|
this.cancelTimerIfSet();
|
|
537
|
+
this.nextRetryAt = null;
|
|
431
538
|
const batch = this.buffer.splice(0);
|
|
539
|
+
const batchId = this.mintBatchId();
|
|
432
540
|
this.inFlight += batch.length;
|
|
541
|
+
this.persistent?.save(this.buffer);
|
|
542
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
433
543
|
try {
|
|
434
544
|
const env = this.cfg.envelope();
|
|
435
545
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
436
546
|
body: {
|
|
437
547
|
// NorthStar §13.1 batch envelope. The backend validates these
|
|
438
|
-
// against the API-key-resolved app and rejects mismatches
|
|
439
|
-
// (env_mismatch).
|
|
548
|
+
// against the API-key-resolved app and rejects mismatches
|
|
549
|
+
// loudly (env_mismatch).
|
|
440
550
|
appId: env.appId,
|
|
441
551
|
environment: env.environment,
|
|
442
552
|
sdk: env.sdk,
|
|
443
553
|
events: batch
|
|
444
554
|
},
|
|
445
|
-
keepalive: options.keepalive === true
|
|
555
|
+
keepalive: options.keepalive === true,
|
|
556
|
+
idempotencyKey: batchId
|
|
446
557
|
});
|
|
447
558
|
this.lastFlushAt = Date.now();
|
|
448
559
|
this.lastError = null;
|
|
449
560
|
this.inFlight -= batch.length;
|
|
561
|
+
this.retry.recordSuccess();
|
|
562
|
+
this.persistent?.save(this.buffer);
|
|
450
563
|
if (!this.firstFlushFired) {
|
|
451
564
|
this.firstFlushFired = true;
|
|
452
565
|
this.cfg.onFirstFlushSuccess?.();
|
|
@@ -455,18 +568,33 @@ var EventQueue = class {
|
|
|
455
568
|
} catch (err) {
|
|
456
569
|
this.buffer.unshift(...batch);
|
|
457
570
|
this.inFlight -= batch.length;
|
|
458
|
-
|
|
459
|
-
this.
|
|
571
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
572
|
+
this.lastError = message;
|
|
573
|
+
this.persistent?.save(this.buffer);
|
|
574
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
575
|
+
const retryAfterMs = extractRetryAfterMs(err);
|
|
576
|
+
const delay = this.retry.nextDelay(retryAfterMs);
|
|
577
|
+
this.scheduleRetry(delay);
|
|
578
|
+
this.cfg.onRetryScheduled?.({
|
|
579
|
+
delayMs: delay,
|
|
580
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
581
|
+
retryAfterMs,
|
|
582
|
+
lastError: message
|
|
583
|
+
});
|
|
460
584
|
return null;
|
|
461
585
|
}
|
|
462
586
|
}
|
|
463
|
-
/** Cancel any pending timer and clear in-memory state. */
|
|
587
|
+
/** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
|
|
464
588
|
reset() {
|
|
465
589
|
this.cancelTimerIfSet();
|
|
590
|
+
this.nextRetryAt = null;
|
|
466
591
|
this.buffer = [];
|
|
467
592
|
this.dropped = 0;
|
|
468
593
|
this.inFlight = 0;
|
|
469
594
|
this.lastError = null;
|
|
595
|
+
this.retry.recordSuccess();
|
|
596
|
+
this.persistent?.clear();
|
|
597
|
+
this.cfg.onBufferChange?.(0);
|
|
470
598
|
}
|
|
471
599
|
getStats() {
|
|
472
600
|
return {
|
|
@@ -474,9 +602,12 @@ var EventQueue = class {
|
|
|
474
602
|
dropped: this.dropped,
|
|
475
603
|
inFlight: this.inFlight,
|
|
476
604
|
lastFlushAt: this.lastFlushAt,
|
|
477
|
-
lastError: this.lastError
|
|
605
|
+
lastError: this.lastError,
|
|
606
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
607
|
+
nextRetryAt: this.nextRetryAt
|
|
478
608
|
};
|
|
479
609
|
}
|
|
610
|
+
// ---------- internal scheduling ----------
|
|
480
611
|
scheduleIdleFlush() {
|
|
481
612
|
this.cancelTimerIfSet();
|
|
482
613
|
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
@@ -484,13 +615,31 @@ var EventQueue = class {
|
|
|
484
615
|
void this.flush();
|
|
485
616
|
}, this.cfg.intervalMs);
|
|
486
617
|
}
|
|
618
|
+
scheduleRetry(delayMs) {
|
|
619
|
+
this.cancelTimerIfSet();
|
|
620
|
+
this.nextRetryAt = Date.now() + delayMs;
|
|
621
|
+
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
622
|
+
this.cancelTimer = sched(() => {
|
|
623
|
+
void this.flush();
|
|
624
|
+
}, delayMs);
|
|
625
|
+
}
|
|
487
626
|
cancelTimerIfSet() {
|
|
488
627
|
if (this.cancelTimer) {
|
|
489
628
|
this.cancelTimer();
|
|
490
629
|
this.cancelTimer = null;
|
|
491
630
|
}
|
|
492
631
|
}
|
|
632
|
+
mintBatchId() {
|
|
633
|
+
return `batch_${Date.now().toString(36)}${randomChars(10)}`;
|
|
634
|
+
}
|
|
493
635
|
};
|
|
636
|
+
function extractRetryAfterMs(err) {
|
|
637
|
+
if (err && typeof err === "object" && "retryAfterMs" in err) {
|
|
638
|
+
const v = err.retryAfterMs;
|
|
639
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
|
|
640
|
+
}
|
|
641
|
+
return void 0;
|
|
642
|
+
}
|
|
494
643
|
function defaultScheduler(fn, ms) {
|
|
495
644
|
const id = setTimeout(fn, ms);
|
|
496
645
|
if (typeof id.unref === "function") {
|
|
@@ -502,6 +651,87 @@ function defaultScheduler(fn, ms) {
|
|
|
502
651
|
return () => clearTimeout(id);
|
|
503
652
|
}
|
|
504
653
|
|
|
654
|
+
// src/event-storage.ts
|
|
655
|
+
var PersistentEventStore = class {
|
|
656
|
+
constructor(options) {
|
|
657
|
+
this.options = options;
|
|
658
|
+
this.writeScheduled = false;
|
|
659
|
+
// Pending events captured on the most recent write request. We keep
|
|
660
|
+
// the latest snapshot ref so a debounced write always picks up the
|
|
661
|
+
// freshest buffer state.
|
|
662
|
+
this.pendingSnapshot = null;
|
|
663
|
+
this.key = `${options.prefix}queue.v1`;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Read the persisted queue on boot. Returns an empty array (with no
|
|
667
|
+
* warning) when nothing is stored, the blob is malformed, or storage
|
|
668
|
+
* is unavailable. Caller is responsible for treating duplicates from
|
|
669
|
+
* the persisted queue as the SAME events (eventId-based dedup).
|
|
670
|
+
*/
|
|
671
|
+
load() {
|
|
672
|
+
let raw;
|
|
673
|
+
try {
|
|
674
|
+
raw = this.options.storage.getItem(this.key);
|
|
675
|
+
} catch {
|
|
676
|
+
return [];
|
|
677
|
+
}
|
|
678
|
+
if (!raw) return [];
|
|
679
|
+
try {
|
|
680
|
+
const parsed = JSON.parse(raw);
|
|
681
|
+
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
|
|
682
|
+
return [];
|
|
683
|
+
}
|
|
684
|
+
return parsed.events;
|
|
685
|
+
} catch {
|
|
686
|
+
return [];
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Schedule a write of the current buffer. Debounced via microtask so
|
|
691
|
+
* a burst of enqueue() calls coalesces into one persistence write.
|
|
692
|
+
* Writes are best-effort: if storage throws (quota, private mode),
|
|
693
|
+
* we swallow and rely on the in-memory buffer.
|
|
694
|
+
*/
|
|
695
|
+
save(snapshot) {
|
|
696
|
+
this.pendingSnapshot = snapshot.slice();
|
|
697
|
+
if (this.writeScheduled) return;
|
|
698
|
+
this.writeScheduled = true;
|
|
699
|
+
queueMicrotask(() => this.flushWrite());
|
|
700
|
+
}
|
|
701
|
+
/** Synchronous variant for terminal flushes (pagehide / beforeunload). */
|
|
702
|
+
saveSync(snapshot) {
|
|
703
|
+
this.pendingSnapshot = snapshot.slice();
|
|
704
|
+
this.flushWrite();
|
|
705
|
+
}
|
|
706
|
+
/** Wipe the persisted blob. Used by reset() (logout). */
|
|
707
|
+
clear() {
|
|
708
|
+
this.pendingSnapshot = null;
|
|
709
|
+
this.writeScheduled = false;
|
|
710
|
+
try {
|
|
711
|
+
this.options.storage.removeItem(this.key);
|
|
712
|
+
} catch {
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
flushWrite() {
|
|
716
|
+
this.writeScheduled = false;
|
|
717
|
+
const snapshot = this.pendingSnapshot;
|
|
718
|
+
this.pendingSnapshot = null;
|
|
719
|
+
if (snapshot === null) return;
|
|
720
|
+
if (snapshot.length === 0) {
|
|
721
|
+
try {
|
|
722
|
+
this.options.storage.removeItem(this.key);
|
|
723
|
+
} catch {
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const blob = { version: 1, events: snapshot };
|
|
728
|
+
try {
|
|
729
|
+
this.options.storage.setItem(this.key, JSON.stringify(blob));
|
|
730
|
+
} catch {
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
|
|
505
735
|
// src/storage.ts
|
|
506
736
|
var MemoryStorage = class {
|
|
507
737
|
constructor() {
|
|
@@ -692,7 +922,9 @@ var DEFAULT_AUTO_TRACK = {
|
|
|
692
922
|
sessions: true,
|
|
693
923
|
pageViews: true,
|
|
694
924
|
deviceInfo: true,
|
|
695
|
-
clicks: true
|
|
925
|
+
clicks: true,
|
|
926
|
+
webVitals: true,
|
|
927
|
+
errors: true
|
|
696
928
|
};
|
|
697
929
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
698
930
|
var EMPTY_ACQUISITION = {
|
|
@@ -701,7 +933,13 @@ var EMPTY_ACQUISITION = {
|
|
|
701
933
|
utm_campaign: "",
|
|
702
934
|
utm_content: "",
|
|
703
935
|
utm_term: "",
|
|
704
|
-
referrer: ""
|
|
936
|
+
referrer: "",
|
|
937
|
+
gclid: "",
|
|
938
|
+
fbclid: "",
|
|
939
|
+
msclkid: "",
|
|
940
|
+
ttclid: "",
|
|
941
|
+
li_fat_id: "",
|
|
942
|
+
twclid: ""
|
|
705
943
|
};
|
|
706
944
|
var AutoTracker = class {
|
|
707
945
|
constructor(cfg, track) {
|
|
@@ -709,6 +947,17 @@ var AutoTracker = class {
|
|
|
709
947
|
this.track = track;
|
|
710
948
|
this.session = null;
|
|
711
949
|
this.cleanups = [];
|
|
950
|
+
/**
|
|
951
|
+
* Stable per-page-view identifier. Minted at every `page.viewed`
|
|
952
|
+
* emission and attached to every subsequent event until the next
|
|
953
|
+
* `page.viewed`. Lets dashboards correlate "user clicked X" to
|
|
954
|
+
* "user viewed page Y" without timestamp arithmetic — the canonical
|
|
955
|
+
* Mixpanel `$current_url` / Segment `pageId` pattern.
|
|
956
|
+
*
|
|
957
|
+
* Null until the first `page.viewed` fires (which happens at SDK
|
|
958
|
+
* install if `autoTrack.pageViews !== false`).
|
|
959
|
+
*/
|
|
960
|
+
this.pageviewId = null;
|
|
712
961
|
}
|
|
713
962
|
install() {
|
|
714
963
|
if (!isBrowserSafe()) return;
|
|
@@ -739,6 +988,10 @@ var AutoTracker = class {
|
|
|
739
988
|
get currentSessionId() {
|
|
740
989
|
return this.session?.sessionId ?? null;
|
|
741
990
|
}
|
|
991
|
+
/** Stable per-page-view ID. Null before the first page.viewed has fired. */
|
|
992
|
+
get currentPageviewId() {
|
|
993
|
+
return this.pageviewId;
|
|
994
|
+
}
|
|
742
995
|
/**
|
|
743
996
|
* Per-session acquisition context — utm_* + referrer, captured once
|
|
744
997
|
* at session start. Returns empty strings when there's no session
|
|
@@ -819,7 +1072,9 @@ var AutoTracker = class {
|
|
|
819
1072
|
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
820
1073
|
lastFiredAt = now;
|
|
821
1074
|
lastFiredUrl = url;
|
|
1075
|
+
this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
|
|
822
1076
|
this.track("page.viewed", {
|
|
1077
|
+
pageviewId: this.pageviewId,
|
|
823
1078
|
path: loc.pathname,
|
|
824
1079
|
url,
|
|
825
1080
|
search: loc.search || void 0,
|
|
@@ -1023,6 +1278,12 @@ function captureAcquisition() {
|
|
|
1023
1278
|
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
1024
1279
|
result.utm_content = params.get("utm_content") ?? "";
|
|
1025
1280
|
result.utm_term = params.get("utm_term") ?? "";
|
|
1281
|
+
result.gclid = params.get("gclid") ?? "";
|
|
1282
|
+
result.fbclid = params.get("fbclid") ?? "";
|
|
1283
|
+
result.msclkid = params.get("msclkid") ?? "";
|
|
1284
|
+
result.ttclid = params.get("ttclid") ?? "";
|
|
1285
|
+
result.li_fat_id = params.get("li_fat_id") ?? "";
|
|
1286
|
+
result.twclid = params.get("twclid") ?? "";
|
|
1026
1287
|
} catch {
|
|
1027
1288
|
}
|
|
1028
1289
|
try {
|
|
@@ -1080,6 +1341,1025 @@ function safeJson(obj) {
|
|
|
1080
1341
|
}
|
|
1081
1342
|
}
|
|
1082
1343
|
|
|
1344
|
+
// src/event-validation.ts
|
|
1345
|
+
var DEFAULT_MAX_STRING = 1024;
|
|
1346
|
+
var DEFAULT_MAX_BYTES = 8 * 1024;
|
|
1347
|
+
var DEFAULT_MAX_DEPTH = 5;
|
|
1348
|
+
function validateEventProperties(input, options = {}) {
|
|
1349
|
+
const warnings = [];
|
|
1350
|
+
if (!input) return { properties: {}, warnings };
|
|
1351
|
+
const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
|
|
1352
|
+
const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
|
|
1353
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1354
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
1355
|
+
const visit = (value, key, depth) => {
|
|
1356
|
+
if (depth > maxDepth) {
|
|
1357
|
+
warnings.push({ kind: "depth_exceeded", key });
|
|
1358
|
+
return { keep: true, value: "[depth-exceeded]" };
|
|
1359
|
+
}
|
|
1360
|
+
if (value === null) return { keep: true, value: null };
|
|
1361
|
+
const t = typeof value;
|
|
1362
|
+
if (t === "string") {
|
|
1363
|
+
const s = value;
|
|
1364
|
+
if (s.length > maxStringLength) {
|
|
1365
|
+
warnings.push({ kind: "truncated_string", key });
|
|
1366
|
+
return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
|
|
1367
|
+
}
|
|
1368
|
+
return { keep: true, value: s };
|
|
1369
|
+
}
|
|
1370
|
+
if (t === "number") {
|
|
1371
|
+
if (!Number.isFinite(value)) {
|
|
1372
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1373
|
+
return { keep: true, value: null };
|
|
1374
|
+
}
|
|
1375
|
+
return { keep: true, value };
|
|
1376
|
+
}
|
|
1377
|
+
if (t === "boolean") return { keep: true, value };
|
|
1378
|
+
if (t === "bigint") {
|
|
1379
|
+
warnings.push({ kind: "coerced_bigint", key });
|
|
1380
|
+
return { keep: true, value: value.toString() };
|
|
1381
|
+
}
|
|
1382
|
+
if (t === "function") {
|
|
1383
|
+
warnings.push({ kind: "dropped_function", key });
|
|
1384
|
+
return { keep: false, value: void 0 };
|
|
1385
|
+
}
|
|
1386
|
+
if (t === "symbol") {
|
|
1387
|
+
warnings.push({ kind: "dropped_symbol", key });
|
|
1388
|
+
return { keep: false, value: void 0 };
|
|
1389
|
+
}
|
|
1390
|
+
if (t === "undefined") {
|
|
1391
|
+
warnings.push({ kind: "dropped_undefined", key });
|
|
1392
|
+
return { keep: false, value: void 0 };
|
|
1393
|
+
}
|
|
1394
|
+
if (value instanceof Date) {
|
|
1395
|
+
warnings.push({ kind: "coerced_date", key });
|
|
1396
|
+
const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
|
|
1397
|
+
return { keep: true, value: iso };
|
|
1398
|
+
}
|
|
1399
|
+
if (value instanceof Error) {
|
|
1400
|
+
warnings.push({ kind: "coerced_error", key });
|
|
1401
|
+
return {
|
|
1402
|
+
keep: true,
|
|
1403
|
+
value: {
|
|
1404
|
+
name: value.name,
|
|
1405
|
+
message: value.message,
|
|
1406
|
+
stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
if (value instanceof Map) {
|
|
1411
|
+
warnings.push({ kind: "coerced_map", key });
|
|
1412
|
+
const obj = {};
|
|
1413
|
+
for (const [k, v] of value.entries()) {
|
|
1414
|
+
const subKey = typeof k === "string" ? k : String(k);
|
|
1415
|
+
const result = visit(v, `${key}.${subKey}`, depth + 1);
|
|
1416
|
+
if (result.keep) obj[subKey] = result.value;
|
|
1417
|
+
}
|
|
1418
|
+
return { keep: true, value: obj };
|
|
1419
|
+
}
|
|
1420
|
+
if (value instanceof Set) {
|
|
1421
|
+
warnings.push({ kind: "coerced_set", key });
|
|
1422
|
+
const arr = [];
|
|
1423
|
+
let i = 0;
|
|
1424
|
+
for (const v of value.values()) {
|
|
1425
|
+
const result = visit(v, `${key}[${i}]`, depth + 1);
|
|
1426
|
+
if (result.keep) arr.push(result.value);
|
|
1427
|
+
i++;
|
|
1428
|
+
}
|
|
1429
|
+
return { keep: true, value: arr };
|
|
1430
|
+
}
|
|
1431
|
+
if (Array.isArray(value)) {
|
|
1432
|
+
if (seen.has(value)) {
|
|
1433
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1434
|
+
return { keep: true, value: "[circular]" };
|
|
1435
|
+
}
|
|
1436
|
+
seen.add(value);
|
|
1437
|
+
const out = [];
|
|
1438
|
+
for (let i = 0; i < value.length; i++) {
|
|
1439
|
+
const result = visit(value[i], `${key}[${i}]`, depth + 1);
|
|
1440
|
+
if (result.keep) out.push(result.value);
|
|
1441
|
+
}
|
|
1442
|
+
return { keep: true, value: out };
|
|
1443
|
+
}
|
|
1444
|
+
if (t === "object") {
|
|
1445
|
+
const obj = value;
|
|
1446
|
+
if (seen.has(obj)) {
|
|
1447
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1448
|
+
return { keep: true, value: "[circular]" };
|
|
1449
|
+
}
|
|
1450
|
+
seen.add(obj);
|
|
1451
|
+
const out = {};
|
|
1452
|
+
for (const k of Object.keys(obj)) {
|
|
1453
|
+
const result = visit(obj[k], `${key}.${k}`, depth + 1);
|
|
1454
|
+
if (result.keep) out[k] = result.value;
|
|
1455
|
+
}
|
|
1456
|
+
return { keep: true, value: out };
|
|
1457
|
+
}
|
|
1458
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1459
|
+
try {
|
|
1460
|
+
return { keep: true, value: String(value) };
|
|
1461
|
+
} catch {
|
|
1462
|
+
return { keep: false, value: void 0 };
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
const cleaned = {};
|
|
1466
|
+
for (const k of Object.keys(input)) {
|
|
1467
|
+
const result = visit(input[k], k, 0);
|
|
1468
|
+
if (result.keep) cleaned[k] = result.value;
|
|
1469
|
+
}
|
|
1470
|
+
const serialised = safeStringify(cleaned);
|
|
1471
|
+
if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
|
|
1472
|
+
warnings.push({ kind: "size_cap_exceeded", key: "*" });
|
|
1473
|
+
const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
|
|
1474
|
+
let currentSize = byteLength(serialised);
|
|
1475
|
+
for (const { k } of sizes) {
|
|
1476
|
+
if (currentSize <= maxBatchPropertyBytes) break;
|
|
1477
|
+
currentSize -= sizes.find((s) => s.k === k).size;
|
|
1478
|
+
delete cleaned[k];
|
|
1479
|
+
}
|
|
1480
|
+
cleaned.__truncated = true;
|
|
1481
|
+
}
|
|
1482
|
+
return { properties: cleaned, warnings };
|
|
1483
|
+
}
|
|
1484
|
+
function safeStringify(v) {
|
|
1485
|
+
try {
|
|
1486
|
+
return JSON.stringify(v) ?? null;
|
|
1487
|
+
} catch {
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
function byteLength(s) {
|
|
1492
|
+
if (typeof TextEncoder !== "undefined") {
|
|
1493
|
+
return new TextEncoder().encode(s).length;
|
|
1494
|
+
}
|
|
1495
|
+
return s.length * 4;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/super-properties.ts
|
|
1499
|
+
var KEY_SUPER = "super_props";
|
|
1500
|
+
var KEY_GROUPS = "groups";
|
|
1501
|
+
var SuperPropertyStore = class {
|
|
1502
|
+
constructor(storage, prefix) {
|
|
1503
|
+
this.storage = storage;
|
|
1504
|
+
this.prefix = prefix;
|
|
1505
|
+
this.superProps = {};
|
|
1506
|
+
this.groups = {};
|
|
1507
|
+
this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
|
|
1508
|
+
this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
|
|
1509
|
+
}
|
|
1510
|
+
// ---------- super properties ----------
|
|
1511
|
+
/**
|
|
1512
|
+
* Merge new keys into the super-property bag. Returns a snapshot of
|
|
1513
|
+
* the resulting bag. Values that are `null` are deleted (Mixpanel
|
|
1514
|
+
* semantics — explicit null = "stop tracking this key").
|
|
1515
|
+
*/
|
|
1516
|
+
register(props) {
|
|
1517
|
+
for (const [k, v] of Object.entries(props)) {
|
|
1518
|
+
if (v === null) {
|
|
1519
|
+
delete this.superProps[k];
|
|
1520
|
+
} else if (v !== void 0) {
|
|
1521
|
+
this.superProps[k] = v;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1525
|
+
return { ...this.superProps };
|
|
1526
|
+
}
|
|
1527
|
+
/** Remove a single super-property key. Idempotent. */
|
|
1528
|
+
unregister(key) {
|
|
1529
|
+
if (key in this.superProps) {
|
|
1530
|
+
delete this.superProps[key];
|
|
1531
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
/** Snapshot of the current super-property bag. */
|
|
1535
|
+
getSuperProperties() {
|
|
1536
|
+
return { ...this.superProps };
|
|
1537
|
+
}
|
|
1538
|
+
// ---------- groups ----------
|
|
1539
|
+
/**
|
|
1540
|
+
* Set a group membership. Passing `id: null` clears the membership
|
|
1541
|
+
* for that group type — the SDK stops attaching it to events.
|
|
1542
|
+
*/
|
|
1543
|
+
setGroup(type, id, traits) {
|
|
1544
|
+
if (id === null) {
|
|
1545
|
+
delete this.groups[type];
|
|
1546
|
+
} else {
|
|
1547
|
+
this.groups[type] = traits !== void 0 ? { id, traits } : { id };
|
|
1548
|
+
}
|
|
1549
|
+
writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Snapshot of the current groups map, keyed by group type. Returned
|
|
1553
|
+
* shape mirrors what the SDK attaches to every event as
|
|
1554
|
+
* `$groups.{type}`. The `traits` sub-object is the most-recent
|
|
1555
|
+
* traits payload passed to `setGroup` for that type; null when none.
|
|
1556
|
+
*/
|
|
1557
|
+
getGroups() {
|
|
1558
|
+
return JSON.parse(JSON.stringify(this.groups));
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* The flat `{ type: id }` projection used for event-attachment. Stable
|
|
1562
|
+
* for fast every-event merge — we don't want to JSON-clone on each
|
|
1563
|
+
* track() call.
|
|
1564
|
+
*/
|
|
1565
|
+
getGroupIds() {
|
|
1566
|
+
const out = {};
|
|
1567
|
+
for (const [type, info] of Object.entries(this.groups)) {
|
|
1568
|
+
out[type] = info.id;
|
|
1569
|
+
}
|
|
1570
|
+
return out;
|
|
1571
|
+
}
|
|
1572
|
+
/** Wipe both bags. Called by Crossdeck.reset() (logout). */
|
|
1573
|
+
clear() {
|
|
1574
|
+
this.superProps = {};
|
|
1575
|
+
this.groups = {};
|
|
1576
|
+
try {
|
|
1577
|
+
this.storage.removeItem(this.prefix + KEY_SUPER);
|
|
1578
|
+
} catch {
|
|
1579
|
+
}
|
|
1580
|
+
try {
|
|
1581
|
+
this.storage.removeItem(this.prefix + KEY_GROUPS);
|
|
1582
|
+
} catch {
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
function readJson(storage, key) {
|
|
1587
|
+
let raw;
|
|
1588
|
+
try {
|
|
1589
|
+
raw = storage.getItem(key);
|
|
1590
|
+
} catch {
|
|
1591
|
+
return null;
|
|
1592
|
+
}
|
|
1593
|
+
if (!raw) return null;
|
|
1594
|
+
try {
|
|
1595
|
+
return JSON.parse(raw);
|
|
1596
|
+
} catch {
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
function writeJson(storage, key, value) {
|
|
1601
|
+
try {
|
|
1602
|
+
storage.setItem(key, JSON.stringify(value));
|
|
1603
|
+
} catch {
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// src/web-vitals.ts
|
|
1608
|
+
var WebVitalsTracker = class {
|
|
1609
|
+
constructor(cfg, report) {
|
|
1610
|
+
this.cfg = cfg;
|
|
1611
|
+
this.report = report;
|
|
1612
|
+
this.observers = [];
|
|
1613
|
+
this.flushed = /* @__PURE__ */ new Set();
|
|
1614
|
+
this.cls = 0;
|
|
1615
|
+
this.clsEntries = [];
|
|
1616
|
+
this.inp = 0;
|
|
1617
|
+
this.cleanups = [];
|
|
1618
|
+
}
|
|
1619
|
+
install() {
|
|
1620
|
+
if (!this.cfg.enabled) return;
|
|
1621
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
1622
|
+
if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
|
|
1623
|
+
const doc = globalThis.document;
|
|
1624
|
+
try {
|
|
1625
|
+
const navObserver = new PerformanceObserver((list) => {
|
|
1626
|
+
for (const entry of list.getEntries()) {
|
|
1627
|
+
const e = entry;
|
|
1628
|
+
if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
|
|
1629
|
+
this.flushed.add("ttfb");
|
|
1630
|
+
this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
navObserver.observe({ type: "navigation", buffered: true });
|
|
1635
|
+
this.observers.push(navObserver);
|
|
1636
|
+
} catch {
|
|
1637
|
+
}
|
|
1638
|
+
try {
|
|
1639
|
+
const paintObserver = new PerformanceObserver((list) => {
|
|
1640
|
+
for (const entry of list.getEntries()) {
|
|
1641
|
+
if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
|
|
1642
|
+
this.flushed.add("fcp");
|
|
1643
|
+
this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
});
|
|
1647
|
+
paintObserver.observe({ type: "paint", buffered: true });
|
|
1648
|
+
this.observers.push(paintObserver);
|
|
1649
|
+
} catch {
|
|
1650
|
+
}
|
|
1651
|
+
let lcpValue = 0;
|
|
1652
|
+
try {
|
|
1653
|
+
const lcpObserver = new PerformanceObserver((list) => {
|
|
1654
|
+
const entries = list.getEntries();
|
|
1655
|
+
const last = entries[entries.length - 1];
|
|
1656
|
+
if (last) lcpValue = last.startTime;
|
|
1657
|
+
});
|
|
1658
|
+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
|
1659
|
+
this.observers.push(lcpObserver);
|
|
1660
|
+
} catch {
|
|
1661
|
+
}
|
|
1662
|
+
try {
|
|
1663
|
+
const clsObserver = new PerformanceObserver((list) => {
|
|
1664
|
+
for (const entry of list.getEntries()) {
|
|
1665
|
+
const e = entry;
|
|
1666
|
+
if (typeof e.value === "number" && !e.hadRecentInput) {
|
|
1667
|
+
this.cls += e.value;
|
|
1668
|
+
this.clsEntries.push(entry);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
clsObserver.observe({ type: "layout-shift", buffered: true });
|
|
1673
|
+
this.observers.push(clsObserver);
|
|
1674
|
+
} catch {
|
|
1675
|
+
}
|
|
1676
|
+
try {
|
|
1677
|
+
const eventObserver = new PerformanceObserver((list) => {
|
|
1678
|
+
for (const entry of list.getEntries()) {
|
|
1679
|
+
const e = entry;
|
|
1680
|
+
if (e.interactionId && e.duration > this.inp) {
|
|
1681
|
+
this.inp = e.duration;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
});
|
|
1685
|
+
try {
|
|
1686
|
+
eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
1687
|
+
} catch {
|
|
1688
|
+
eventObserver.observe({ type: "first-input", buffered: true });
|
|
1689
|
+
}
|
|
1690
|
+
this.observers.push(eventObserver);
|
|
1691
|
+
} catch {
|
|
1692
|
+
}
|
|
1693
|
+
const flush = () => {
|
|
1694
|
+
if (lcpValue > 0 && !this.flushed.has("lcp")) {
|
|
1695
|
+
this.flushed.add("lcp");
|
|
1696
|
+
this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
|
|
1697
|
+
}
|
|
1698
|
+
if (this.cls > 0 && !this.flushed.has("cls")) {
|
|
1699
|
+
this.flushed.add("cls");
|
|
1700
|
+
this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
|
|
1701
|
+
}
|
|
1702
|
+
if (this.inp > 0 && !this.flushed.has("inp")) {
|
|
1703
|
+
this.flushed.add("inp");
|
|
1704
|
+
this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
const onHidden = () => {
|
|
1708
|
+
if (doc.visibilityState === "hidden") flush();
|
|
1709
|
+
};
|
|
1710
|
+
doc.addEventListener("visibilitychange", onHidden);
|
|
1711
|
+
globalThis.window.addEventListener("pagehide", flush);
|
|
1712
|
+
this.cleanups.push(() => {
|
|
1713
|
+
doc.removeEventListener("visibilitychange", onHidden);
|
|
1714
|
+
globalThis.window.removeEventListener("pagehide", flush);
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
uninstall() {
|
|
1718
|
+
for (const o of this.observers) {
|
|
1719
|
+
try {
|
|
1720
|
+
o.disconnect();
|
|
1721
|
+
} catch {
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
this.observers = [];
|
|
1725
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1726
|
+
try {
|
|
1727
|
+
fn();
|
|
1728
|
+
} catch {
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
|
|
1734
|
+
// src/consent.ts
|
|
1735
|
+
var ALL_GRANTED = {
|
|
1736
|
+
analytics: true,
|
|
1737
|
+
marketing: true,
|
|
1738
|
+
errors: true
|
|
1739
|
+
};
|
|
1740
|
+
var ConsentManager = class {
|
|
1741
|
+
constructor(options) {
|
|
1742
|
+
this.state = { ...ALL_GRANTED };
|
|
1743
|
+
this.dntDenied = false;
|
|
1744
|
+
if (options?.respectDnt && this.detectDnt()) {
|
|
1745
|
+
this.dntDenied = true;
|
|
1746
|
+
this.state = { analytics: false, marketing: false, errors: false };
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Merge new dimensions onto the current state. Returns the resulting
|
|
1751
|
+
* snapshot. DNT-derived denies cannot be flipped back on by a `set`
|
|
1752
|
+
* call — once the browser says "don't track", we don't track even if
|
|
1753
|
+
* the developer code disagrees. That's the contract.
|
|
1754
|
+
*/
|
|
1755
|
+
set(partial) {
|
|
1756
|
+
if (this.dntDenied) return { ...this.state };
|
|
1757
|
+
for (const k of Object.keys(partial)) {
|
|
1758
|
+
const v = partial[k];
|
|
1759
|
+
if (typeof v === "boolean") this.state[k] = v;
|
|
1760
|
+
}
|
|
1761
|
+
return { ...this.state };
|
|
1762
|
+
}
|
|
1763
|
+
/** Snapshot of the current state. */
|
|
1764
|
+
get() {
|
|
1765
|
+
return { ...this.state };
|
|
1766
|
+
}
|
|
1767
|
+
/** Convenience getters for hot paths. */
|
|
1768
|
+
get analytics() {
|
|
1769
|
+
return this.state.analytics;
|
|
1770
|
+
}
|
|
1771
|
+
get marketing() {
|
|
1772
|
+
return this.state.marketing;
|
|
1773
|
+
}
|
|
1774
|
+
get errors() {
|
|
1775
|
+
return this.state.errors;
|
|
1776
|
+
}
|
|
1777
|
+
/** True iff the constructor detected and applied DNT. */
|
|
1778
|
+
get isDntDenied() {
|
|
1779
|
+
return this.dntDenied;
|
|
1780
|
+
}
|
|
1781
|
+
detectDnt() {
|
|
1782
|
+
try {
|
|
1783
|
+
const nav = globalThis.navigator;
|
|
1784
|
+
if (!nav) return false;
|
|
1785
|
+
const sources = [
|
|
1786
|
+
nav.doNotTrack,
|
|
1787
|
+
nav.msDoNotTrack,
|
|
1788
|
+
globalThis.doNotTrack
|
|
1789
|
+
];
|
|
1790
|
+
return sources.some((v) => v === "1" || v === "yes");
|
|
1791
|
+
} catch {
|
|
1792
|
+
return false;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
1797
|
+
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
1798
|
+
var REPLACEMENT_EMAIL = "[email]";
|
|
1799
|
+
var REPLACEMENT_CARD = "[card]";
|
|
1800
|
+
function scrubPii(value) {
|
|
1801
|
+
if (!value) return value;
|
|
1802
|
+
let out = value;
|
|
1803
|
+
if (EMAIL_PATTERN.test(out)) {
|
|
1804
|
+
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
1805
|
+
}
|
|
1806
|
+
EMAIL_PATTERN.lastIndex = 0;
|
|
1807
|
+
if (CARD_PATTERN.test(out)) {
|
|
1808
|
+
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1809
|
+
}
|
|
1810
|
+
CARD_PATTERN.lastIndex = 0;
|
|
1811
|
+
return out;
|
|
1812
|
+
}
|
|
1813
|
+
function scrubPiiFromProperties(properties) {
|
|
1814
|
+
const out = {};
|
|
1815
|
+
for (const k of Object.keys(properties)) {
|
|
1816
|
+
const v = properties[k];
|
|
1817
|
+
if (typeof v === "string") {
|
|
1818
|
+
out[k] = scrubPii(v);
|
|
1819
|
+
} else if (Array.isArray(v)) {
|
|
1820
|
+
out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
|
|
1821
|
+
} else {
|
|
1822
|
+
out[k] = v;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
return out;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// src/breadcrumbs.ts
|
|
1829
|
+
var BreadcrumbBuffer = class {
|
|
1830
|
+
constructor(maxSize = 50) {
|
|
1831
|
+
this.maxSize = maxSize;
|
|
1832
|
+
this.items = [];
|
|
1833
|
+
}
|
|
1834
|
+
add(crumb) {
|
|
1835
|
+
this.items.push(crumb);
|
|
1836
|
+
if (this.items.length > this.maxSize) {
|
|
1837
|
+
this.items.shift();
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
/** Defensive copy — caller can read freely without mutating buffer state. */
|
|
1841
|
+
snapshot() {
|
|
1842
|
+
return this.items.slice();
|
|
1843
|
+
}
|
|
1844
|
+
clear() {
|
|
1845
|
+
this.items = [];
|
|
1846
|
+
}
|
|
1847
|
+
get size() {
|
|
1848
|
+
return this.items.length;
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
// src/stack-parser.ts
|
|
1853
|
+
function parseStack(stack) {
|
|
1854
|
+
if (!stack || typeof stack !== "string") return [];
|
|
1855
|
+
const lines = stack.split("\n");
|
|
1856
|
+
const frames = [];
|
|
1857
|
+
for (const line of lines) {
|
|
1858
|
+
const trimmed = line.trim();
|
|
1859
|
+
if (!trimmed) continue;
|
|
1860
|
+
const frame = parseLine(trimmed);
|
|
1861
|
+
if (frame) frames.push(frame);
|
|
1862
|
+
}
|
|
1863
|
+
return frames;
|
|
1864
|
+
}
|
|
1865
|
+
function parseLine(line) {
|
|
1866
|
+
let m = /^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/.exec(line);
|
|
1867
|
+
if (m) {
|
|
1868
|
+
return buildFrame({
|
|
1869
|
+
function: m[1],
|
|
1870
|
+
filename: m[2],
|
|
1871
|
+
lineno: parseInt(m[3], 10),
|
|
1872
|
+
colno: parseInt(m[4], 10),
|
|
1873
|
+
raw: line
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
m = /^at\s+(.+?):(\d+):(\d+)$/.exec(line);
|
|
1877
|
+
if (m) {
|
|
1878
|
+
return buildFrame({
|
|
1879
|
+
function: "?",
|
|
1880
|
+
filename: m[1],
|
|
1881
|
+
lineno: parseInt(m[2], 10),
|
|
1882
|
+
colno: parseInt(m[3], 10),
|
|
1883
|
+
raw: line
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
m = /^(.*?)@(.+?):(\d+):(\d+)$/.exec(line);
|
|
1887
|
+
if (m) {
|
|
1888
|
+
return buildFrame({
|
|
1889
|
+
function: m[1] || "?",
|
|
1890
|
+
filename: m[2],
|
|
1891
|
+
lineno: parseInt(m[3], 10),
|
|
1892
|
+
colno: parseInt(m[4], 10),
|
|
1893
|
+
raw: line
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
if (/^\w*Error/.test(line) || !line.includes(":")) {
|
|
1897
|
+
return null;
|
|
1898
|
+
}
|
|
1899
|
+
return {
|
|
1900
|
+
function: "?",
|
|
1901
|
+
filename: "",
|
|
1902
|
+
lineno: 0,
|
|
1903
|
+
colno: 0,
|
|
1904
|
+
in_app: true,
|
|
1905
|
+
raw: line
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
function buildFrame(input) {
|
|
1909
|
+
return {
|
|
1910
|
+
function: input.function || "?",
|
|
1911
|
+
filename: input.filename,
|
|
1912
|
+
lineno: Number.isFinite(input.lineno) ? input.lineno : 0,
|
|
1913
|
+
colno: Number.isFinite(input.colno) ? input.colno : 0,
|
|
1914
|
+
in_app: isInAppFrame(input.filename),
|
|
1915
|
+
raw: input.raw
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
function isInAppFrame(filename) {
|
|
1919
|
+
if (!filename) return true;
|
|
1920
|
+
if (/^(?:chrome|moz|safari|webkit)-extension:\/\//.test(filename)) return false;
|
|
1921
|
+
if (/\bcdn\.jsdelivr\.net\b/.test(filename)) return false;
|
|
1922
|
+
if (/\bunpkg\.com\b/.test(filename)) return false;
|
|
1923
|
+
if (/\bgoogletagmanager\.com\b/.test(filename)) return false;
|
|
1924
|
+
if (/\bgoogle-analytics\.com\b/.test(filename)) return false;
|
|
1925
|
+
if (/\b@cross-deck\/web\b/.test(filename)) return false;
|
|
1926
|
+
if (/\/crossdeck\.umd\.min\.js$/.test(filename)) return false;
|
|
1927
|
+
return true;
|
|
1928
|
+
}
|
|
1929
|
+
function fingerprintError(message, frames) {
|
|
1930
|
+
const inAppFrames = frames.filter((f) => f.in_app).slice(0, 3);
|
|
1931
|
+
const key = [
|
|
1932
|
+
(message || "").slice(0, 200),
|
|
1933
|
+
...inAppFrames.map((f) => `${f.function}@${f.filename}:${f.lineno}`)
|
|
1934
|
+
].join("|");
|
|
1935
|
+
return djb2Hex(key);
|
|
1936
|
+
}
|
|
1937
|
+
function djb2Hex(input) {
|
|
1938
|
+
let h = 5381;
|
|
1939
|
+
for (let i = 0; i < input.length; i++) {
|
|
1940
|
+
h = (h << 5) + h + input.charCodeAt(i) | 0;
|
|
1941
|
+
}
|
|
1942
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// src/error-capture.ts
|
|
1946
|
+
var DEFAULT_ERROR_CAPTURE = {
|
|
1947
|
+
enabled: true,
|
|
1948
|
+
onError: true,
|
|
1949
|
+
onUnhandledRejection: true,
|
|
1950
|
+
wrapFetch: true,
|
|
1951
|
+
wrapXhr: true,
|
|
1952
|
+
captureConsole: false,
|
|
1953
|
+
ignoreErrors: [
|
|
1954
|
+
// Classic browser noise. These aren't application bugs.
|
|
1955
|
+
"ResizeObserver loop limit exceeded",
|
|
1956
|
+
"ResizeObserver loop completed with undelivered notifications",
|
|
1957
|
+
"Non-Error promise rejection captured",
|
|
1958
|
+
// Cross-origin script errors that the browser strips — no info,
|
|
1959
|
+
// no way to act on them, just noise.
|
|
1960
|
+
"Script error.",
|
|
1961
|
+
"Script error"
|
|
1962
|
+
],
|
|
1963
|
+
allowUrls: [],
|
|
1964
|
+
denyUrls: [
|
|
1965
|
+
// Common third-party extensions that pollute error streams.
|
|
1966
|
+
/^chrome-extension:\/\//,
|
|
1967
|
+
/^moz-extension:\/\//,
|
|
1968
|
+
/^safari-extension:\/\//,
|
|
1969
|
+
/^webkit-extension:\/\//,
|
|
1970
|
+
/^safari-web-extension:\/\//
|
|
1971
|
+
],
|
|
1972
|
+
sampleRate: 1,
|
|
1973
|
+
maxPerFingerprintPerMinute: 5,
|
|
1974
|
+
maxPerSession: 100
|
|
1975
|
+
};
|
|
1976
|
+
var ErrorTracker = class {
|
|
1977
|
+
constructor(opts) {
|
|
1978
|
+
this.opts = opts;
|
|
1979
|
+
this.installed = false;
|
|
1980
|
+
this.cleanups = [];
|
|
1981
|
+
this._reporting = false;
|
|
1982
|
+
this.sessionCount = 0;
|
|
1983
|
+
this.fingerprintWindow = /* @__PURE__ */ new Map();
|
|
1984
|
+
}
|
|
1985
|
+
install() {
|
|
1986
|
+
if (this.installed) return;
|
|
1987
|
+
if (!this.opts.config.enabled) return;
|
|
1988
|
+
if (typeof globalThis === "undefined" || !("window" in globalThis)) return;
|
|
1989
|
+
const w = globalThis.window;
|
|
1990
|
+
if (this.opts.config.onError) this.installOnErrorListener(w);
|
|
1991
|
+
if (this.opts.config.onUnhandledRejection) this.installRejectionListener(w);
|
|
1992
|
+
if (this.opts.config.wrapFetch) this.installFetchWrap(w);
|
|
1993
|
+
if (this.opts.config.wrapXhr) this.installXhrWrap(w);
|
|
1994
|
+
if (this.opts.config.captureConsole) this.installConsoleWrap();
|
|
1995
|
+
this.installed = true;
|
|
1996
|
+
}
|
|
1997
|
+
uninstall() {
|
|
1998
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1999
|
+
try {
|
|
2000
|
+
fn();
|
|
2001
|
+
} catch {
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
this.installed = false;
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Manual API. Either an Error instance or any unknown value (we
|
|
2008
|
+
* coerce). Returns silently — never throws.
|
|
2009
|
+
*/
|
|
2010
|
+
captureError(error, options) {
|
|
2011
|
+
if (!this.opts.isConsented()) return;
|
|
2012
|
+
try {
|
|
2013
|
+
const captured = this.buildFromUnknown(error, "error.handled", options?.level ?? "error");
|
|
2014
|
+
if (options?.context) captured.context = { ...captured.context, ...options.context };
|
|
2015
|
+
if (options?.tags) captured.tags = { ...captured.tags, ...options.tags };
|
|
2016
|
+
this.maybeReport(captured);
|
|
2017
|
+
} catch {
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Capture a non-error event as an issue. For "we hit a soft-warning
|
|
2022
|
+
* code path" / "deprecated API used" kinds of signals. Pairs with
|
|
2023
|
+
* Sentry's captureMessage().
|
|
2024
|
+
*/
|
|
2025
|
+
captureMessage(message, level = "info") {
|
|
2026
|
+
if (!this.opts.isConsented()) return;
|
|
2027
|
+
try {
|
|
2028
|
+
const captured = {
|
|
2029
|
+
timestamp: Date.now(),
|
|
2030
|
+
kind: "error.message",
|
|
2031
|
+
level,
|
|
2032
|
+
message,
|
|
2033
|
+
errorType: null,
|
|
2034
|
+
frames: [],
|
|
2035
|
+
rawStack: null,
|
|
2036
|
+
filename: null,
|
|
2037
|
+
lineno: null,
|
|
2038
|
+
colno: null,
|
|
2039
|
+
fingerprint: fingerprintError(message, []),
|
|
2040
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2041
|
+
context: this.opts.getContext(),
|
|
2042
|
+
tags: this.opts.getTags()
|
|
2043
|
+
};
|
|
2044
|
+
this.maybeReport(captured);
|
|
2045
|
+
} catch {
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
// ============================================================
|
|
2049
|
+
// Listener installation
|
|
2050
|
+
// ============================================================
|
|
2051
|
+
installOnErrorListener(w) {
|
|
2052
|
+
const handler = (event) => {
|
|
2053
|
+
if (this._reporting) return;
|
|
2054
|
+
if (!this.opts.isConsented()) return;
|
|
2055
|
+
try {
|
|
2056
|
+
this._reporting = true;
|
|
2057
|
+
const captured = this.buildFromErrorEvent(event);
|
|
2058
|
+
this.maybeReport(captured);
|
|
2059
|
+
} catch {
|
|
2060
|
+
} finally {
|
|
2061
|
+
this._reporting = false;
|
|
2062
|
+
}
|
|
2063
|
+
};
|
|
2064
|
+
w.addEventListener("error", handler, true);
|
|
2065
|
+
this.cleanups.push(() => w.removeEventListener("error", handler, true));
|
|
2066
|
+
}
|
|
2067
|
+
installRejectionListener(w) {
|
|
2068
|
+
const handler = (event) => {
|
|
2069
|
+
if (this._reporting) return;
|
|
2070
|
+
if (!this.opts.isConsented()) return;
|
|
2071
|
+
try {
|
|
2072
|
+
this._reporting = true;
|
|
2073
|
+
const captured = this.buildFromUnknown(
|
|
2074
|
+
event.reason,
|
|
2075
|
+
"error.unhandledrejection",
|
|
2076
|
+
"error"
|
|
2077
|
+
);
|
|
2078
|
+
this.maybeReport(captured);
|
|
2079
|
+
} catch {
|
|
2080
|
+
} finally {
|
|
2081
|
+
this._reporting = false;
|
|
2082
|
+
}
|
|
2083
|
+
};
|
|
2084
|
+
w.addEventListener("unhandledrejection", handler);
|
|
2085
|
+
this.cleanups.push(() => w.removeEventListener("unhandledrejection", handler));
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Wrap fetch() so failed HTTP requests get auto-captured. We do NOT
|
|
2089
|
+
* call this an "error" for 4xx (those are often expected — auth
|
|
2090
|
+
* required, validation failed). Only 5xx + network failures fire.
|
|
2091
|
+
*/
|
|
2092
|
+
installFetchWrap(w) {
|
|
2093
|
+
const origFetch = w.fetch?.bind(w);
|
|
2094
|
+
if (!origFetch) return;
|
|
2095
|
+
const wrapped = async (...args) => {
|
|
2096
|
+
const input = args[0];
|
|
2097
|
+
const init = args[1] ?? {};
|
|
2098
|
+
const url = typeof input === "string" ? input : input?.url ?? "";
|
|
2099
|
+
const method = (init.method || "GET").toUpperCase();
|
|
2100
|
+
const start = Date.now();
|
|
2101
|
+
this.opts.breadcrumbs.add({
|
|
2102
|
+
timestamp: start,
|
|
2103
|
+
category: "http",
|
|
2104
|
+
message: `${method} ${url}`,
|
|
2105
|
+
data: { url, method }
|
|
2106
|
+
});
|
|
2107
|
+
try {
|
|
2108
|
+
const response = await origFetch(...args);
|
|
2109
|
+
if (response.status >= 500 && this.opts.isConsented()) {
|
|
2110
|
+
if (!url.includes("api.cross-deck.com")) {
|
|
2111
|
+
this.captureHttp({
|
|
2112
|
+
url,
|
|
2113
|
+
method,
|
|
2114
|
+
status: response.status,
|
|
2115
|
+
statusText: response.statusText
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
return response;
|
|
2120
|
+
} catch (err) {
|
|
2121
|
+
if (this.opts.isConsented() && !url.includes("api.cross-deck.com")) {
|
|
2122
|
+
this.captureHttp({
|
|
2123
|
+
url,
|
|
2124
|
+
method,
|
|
2125
|
+
status: 0,
|
|
2126
|
+
statusText: err instanceof Error ? err.message : "network error"
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
throw err;
|
|
2130
|
+
}
|
|
2131
|
+
};
|
|
2132
|
+
w.fetch = wrapped;
|
|
2133
|
+
this.cleanups.push(() => {
|
|
2134
|
+
if (w.fetch === wrapped) w.fetch = origFetch;
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Wrap XMLHttpRequest for legacy consumers (jQuery $.ajax under the
|
|
2139
|
+
* hood, older bundlers). Same capture semantics as fetch.
|
|
2140
|
+
*/
|
|
2141
|
+
installXhrWrap(w) {
|
|
2142
|
+
const xhrCtor = w.XMLHttpRequest;
|
|
2143
|
+
const proto = xhrCtor?.prototype;
|
|
2144
|
+
if (!proto) return;
|
|
2145
|
+
const origOpen = proto.open;
|
|
2146
|
+
const origSend = proto.send;
|
|
2147
|
+
const tracker = this;
|
|
2148
|
+
proto.open = function(method, url, ...rest) {
|
|
2149
|
+
this._cdMethod = method;
|
|
2150
|
+
this._cdUrl = url;
|
|
2151
|
+
return origOpen.apply(this, [method, url, ...rest]);
|
|
2152
|
+
};
|
|
2153
|
+
proto.send = function(body) {
|
|
2154
|
+
const xhr = this;
|
|
2155
|
+
const onLoad = () => {
|
|
2156
|
+
try {
|
|
2157
|
+
if (xhr.status >= 500 && tracker.opts.isConsented()) {
|
|
2158
|
+
const url = xhr._cdUrl ?? "";
|
|
2159
|
+
if (!url.includes("api.cross-deck.com")) {
|
|
2160
|
+
tracker.captureHttp({
|
|
2161
|
+
url,
|
|
2162
|
+
method: (xhr._cdMethod ?? "GET").toUpperCase(),
|
|
2163
|
+
status: xhr.status,
|
|
2164
|
+
statusText: xhr.statusText
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
} catch {
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
xhr.addEventListener("loadend", onLoad);
|
|
2172
|
+
return origSend.apply(this, [body ?? null]);
|
|
2173
|
+
};
|
|
2174
|
+
this.cleanups.push(() => {
|
|
2175
|
+
proto.open = origOpen;
|
|
2176
|
+
proto.send = origSend;
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
installConsoleWrap() {
|
|
2180
|
+
const console2 = globalThis.console;
|
|
2181
|
+
if (!console2) return;
|
|
2182
|
+
const orig = console2.error.bind(console2);
|
|
2183
|
+
console2.error = (...args) => {
|
|
2184
|
+
try {
|
|
2185
|
+
if (this.opts.isConsented()) {
|
|
2186
|
+
this.captureMessage(args.map((a) => safeStringify2(a)).join(" "), "error");
|
|
2187
|
+
}
|
|
2188
|
+
} catch {
|
|
2189
|
+
}
|
|
2190
|
+
return orig(...args);
|
|
2191
|
+
};
|
|
2192
|
+
this.cleanups.push(() => {
|
|
2193
|
+
console2.error = orig;
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
// ============================================================
|
|
2197
|
+
// Builders
|
|
2198
|
+
// ============================================================
|
|
2199
|
+
buildFromErrorEvent(event) {
|
|
2200
|
+
const err = event.error;
|
|
2201
|
+
const message = event.message || (err instanceof Error ? err.message : "Unknown error");
|
|
2202
|
+
const stack = err instanceof Error ? err.stack ?? null : null;
|
|
2203
|
+
const frames = parseStack(stack);
|
|
2204
|
+
return {
|
|
2205
|
+
timestamp: Date.now(),
|
|
2206
|
+
kind: "error.unhandled",
|
|
2207
|
+
level: "error",
|
|
2208
|
+
message: String(message).slice(0, 1024),
|
|
2209
|
+
errorType: err instanceof Error ? err.name : null,
|
|
2210
|
+
frames,
|
|
2211
|
+
rawStack: stack,
|
|
2212
|
+
filename: event.filename || null,
|
|
2213
|
+
lineno: typeof event.lineno === "number" ? event.lineno : null,
|
|
2214
|
+
colno: typeof event.colno === "number" ? event.colno : null,
|
|
2215
|
+
fingerprint: fingerprintError(message, frames),
|
|
2216
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2217
|
+
context: this.opts.getContext(),
|
|
2218
|
+
tags: this.opts.getTags()
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
buildFromUnknown(err, kind, level) {
|
|
2222
|
+
if (err instanceof Error) {
|
|
2223
|
+
const frames = parseStack(err.stack);
|
|
2224
|
+
return {
|
|
2225
|
+
timestamp: Date.now(),
|
|
2226
|
+
kind,
|
|
2227
|
+
level,
|
|
2228
|
+
message: String(err.message).slice(0, 1024),
|
|
2229
|
+
errorType: err.name,
|
|
2230
|
+
frames,
|
|
2231
|
+
rawStack: err.stack ?? null,
|
|
2232
|
+
filename: null,
|
|
2233
|
+
lineno: null,
|
|
2234
|
+
colno: null,
|
|
2235
|
+
fingerprint: fingerprintError(err.message, frames),
|
|
2236
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2237
|
+
context: this.opts.getContext(),
|
|
2238
|
+
tags: this.opts.getTags()
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
const message = safeStringify2(err).slice(0, 1024);
|
|
2242
|
+
return {
|
|
2243
|
+
timestamp: Date.now(),
|
|
2244
|
+
kind,
|
|
2245
|
+
level,
|
|
2246
|
+
message,
|
|
2247
|
+
errorType: null,
|
|
2248
|
+
frames: [],
|
|
2249
|
+
rawStack: null,
|
|
2250
|
+
filename: null,
|
|
2251
|
+
lineno: null,
|
|
2252
|
+
colno: null,
|
|
2253
|
+
fingerprint: fingerprintError(message, []),
|
|
2254
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2255
|
+
context: this.opts.getContext(),
|
|
2256
|
+
tags: this.opts.getTags()
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
captureHttp(info) {
|
|
2260
|
+
try {
|
|
2261
|
+
const message = `HTTP ${info.status} ${info.method} ${info.url}`;
|
|
2262
|
+
const captured = {
|
|
2263
|
+
timestamp: Date.now(),
|
|
2264
|
+
kind: "error.http",
|
|
2265
|
+
level: "error",
|
|
2266
|
+
message,
|
|
2267
|
+
errorType: `HTTPError`,
|
|
2268
|
+
frames: [],
|
|
2269
|
+
rawStack: null,
|
|
2270
|
+
filename: info.url,
|
|
2271
|
+
lineno: null,
|
|
2272
|
+
colno: null,
|
|
2273
|
+
fingerprint: fingerprintError(`HTTP ${info.status} ${info.method}`, []),
|
|
2274
|
+
breadcrumbs: this.opts.breadcrumbs.snapshot(),
|
|
2275
|
+
context: this.opts.getContext(),
|
|
2276
|
+
tags: this.opts.getTags(),
|
|
2277
|
+
http: info
|
|
2278
|
+
};
|
|
2279
|
+
this.maybeReport(captured);
|
|
2280
|
+
} catch {
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
// ============================================================
|
|
2284
|
+
// Reporting pipeline — filter / sample / rate-limit / send
|
|
2285
|
+
// ============================================================
|
|
2286
|
+
maybeReport(err) {
|
|
2287
|
+
if (this.sessionCount >= this.opts.config.maxPerSession) return;
|
|
2288
|
+
if (this.shouldIgnore(err)) return;
|
|
2289
|
+
if (!this.passesUrlGate(err)) return;
|
|
2290
|
+
if (!this.passesSample(err)) return;
|
|
2291
|
+
if (!this.passesRateLimit(err)) return;
|
|
2292
|
+
let finalErr = err;
|
|
2293
|
+
if (this.opts.beforeSend) {
|
|
2294
|
+
try {
|
|
2295
|
+
finalErr = this.opts.beforeSend(err);
|
|
2296
|
+
} catch {
|
|
2297
|
+
finalErr = err;
|
|
2298
|
+
}
|
|
2299
|
+
if (!finalErr) return;
|
|
2300
|
+
}
|
|
2301
|
+
this.sessionCount += 1;
|
|
2302
|
+
try {
|
|
2303
|
+
this.opts.report(finalErr);
|
|
2304
|
+
} catch {
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
shouldIgnore(err) {
|
|
2308
|
+
for (const pat of this.opts.config.ignoreErrors) {
|
|
2309
|
+
if (typeof pat === "string" && err.message.includes(pat)) return true;
|
|
2310
|
+
if (pat instanceof RegExp && pat.test(err.message)) return true;
|
|
2311
|
+
}
|
|
2312
|
+
return false;
|
|
2313
|
+
}
|
|
2314
|
+
passesUrlGate(err) {
|
|
2315
|
+
const topFrame = err.frames.find((f) => f.filename) ?? null;
|
|
2316
|
+
const url = topFrame?.filename ?? err.filename ?? "";
|
|
2317
|
+
if (!url) return true;
|
|
2318
|
+
for (const pat of this.opts.config.denyUrls) {
|
|
2319
|
+
if (typeof pat === "string" && url.includes(pat)) return false;
|
|
2320
|
+
if (pat instanceof RegExp && pat.test(url)) return false;
|
|
2321
|
+
}
|
|
2322
|
+
if (this.opts.config.allowUrls.length > 0) {
|
|
2323
|
+
for (const pat of this.opts.config.allowUrls) {
|
|
2324
|
+
if (typeof pat === "string" && url.includes(pat)) return true;
|
|
2325
|
+
if (pat instanceof RegExp && pat.test(url)) return true;
|
|
2326
|
+
}
|
|
2327
|
+
return false;
|
|
2328
|
+
}
|
|
2329
|
+
return true;
|
|
2330
|
+
}
|
|
2331
|
+
passesSample(err) {
|
|
2332
|
+
if (this.opts.config.sampleRate >= 1) return true;
|
|
2333
|
+
if (this.opts.config.sampleRate <= 0) return false;
|
|
2334
|
+
const hashByte = parseInt(err.fingerprint.slice(0, 2), 16);
|
|
2335
|
+
return hashByte / 255 < this.opts.config.sampleRate;
|
|
2336
|
+
}
|
|
2337
|
+
passesRateLimit(err) {
|
|
2338
|
+
const windowMs = 6e4;
|
|
2339
|
+
const now = Date.now();
|
|
2340
|
+
const max = this.opts.config.maxPerFingerprintPerMinute;
|
|
2341
|
+
const arr = this.fingerprintWindow.get(err.fingerprint) ?? [];
|
|
2342
|
+
const fresh = arr.filter((t) => now - t < windowMs);
|
|
2343
|
+
if (fresh.length >= max) {
|
|
2344
|
+
this.fingerprintWindow.set(err.fingerprint, fresh);
|
|
2345
|
+
return false;
|
|
2346
|
+
}
|
|
2347
|
+
fresh.push(now);
|
|
2348
|
+
this.fingerprintWindow.set(err.fingerprint, fresh);
|
|
2349
|
+
return true;
|
|
2350
|
+
}
|
|
2351
|
+
};
|
|
2352
|
+
function safeStringify2(v) {
|
|
2353
|
+
if (v == null) return String(v);
|
|
2354
|
+
if (typeof v === "string") return v;
|
|
2355
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
2356
|
+
try {
|
|
2357
|
+
return JSON.stringify(v);
|
|
2358
|
+
} catch {
|
|
2359
|
+
return Object.prototype.toString.call(v);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
1083
2363
|
// src/crossdeck.ts
|
|
1084
2364
|
var CrossdeckClient = class {
|
|
1085
2365
|
constructor() {
|
|
@@ -1169,6 +2449,13 @@ var CrossdeckClient = class {
|
|
|
1169
2449
|
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1170
2450
|
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
1171
2451
|
const entitlements = new EntitlementCache();
|
|
2452
|
+
const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
|
|
2453
|
+
if (persistentEvents) {
|
|
2454
|
+
debug.emit(
|
|
2455
|
+
"sdk.queue_restored",
|
|
2456
|
+
"Restored persisted event queue from a prior session."
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
1172
2459
|
const events = new EventQueue({
|
|
1173
2460
|
http,
|
|
1174
2461
|
batchSize: opts.eventFlushBatchSize,
|
|
@@ -1178,26 +2465,57 @@ var CrossdeckClient = class {
|
|
|
1178
2465
|
environment: opts.environment,
|
|
1179
2466
|
sdk: { name: SDK_NAME, version: opts.sdkVersion }
|
|
1180
2467
|
}),
|
|
2468
|
+
persistentStore: persistentEvents ?? void 0,
|
|
1181
2469
|
onFirstFlushSuccess: () => {
|
|
1182
2470
|
debug.emit(
|
|
1183
2471
|
"sdk.first_event_sent",
|
|
1184
2472
|
"First telemetry event received. View it in Live Events.",
|
|
1185
2473
|
{ appId: opts.appId, environment: opts.environment }
|
|
1186
2474
|
);
|
|
2475
|
+
},
|
|
2476
|
+
onRetryScheduled: (info) => {
|
|
2477
|
+
debug.emit(
|
|
2478
|
+
"sdk.flush_retry_scheduled",
|
|
2479
|
+
`Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
|
|
2480
|
+
{ ...info }
|
|
2481
|
+
);
|
|
1187
2482
|
}
|
|
1188
2483
|
});
|
|
1189
2484
|
const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
|
|
2485
|
+
const superProps = new SuperPropertyStore(
|
|
2486
|
+
persistIdentity ? effectiveStorage : new MemoryStorage(),
|
|
2487
|
+
opts.storagePrefix
|
|
2488
|
+
);
|
|
2489
|
+
const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
|
|
2490
|
+
if (consent.isDntDenied) {
|
|
2491
|
+
debug.emit(
|
|
2492
|
+
"sdk.consent_dnt_applied",
|
|
2493
|
+
"Do Not Track detected \u2014 all tracking dimensions denied at init."
|
|
2494
|
+
);
|
|
2495
|
+
}
|
|
2496
|
+
const breadcrumbs = new BreadcrumbBuffer(50);
|
|
1190
2497
|
this.state = {
|
|
1191
2498
|
http,
|
|
1192
2499
|
identity,
|
|
1193
2500
|
entitlements,
|
|
1194
2501
|
events,
|
|
1195
2502
|
autoTracker: null,
|
|
2503
|
+
webVitals: null,
|
|
2504
|
+
errors: null,
|
|
2505
|
+
breadcrumbs,
|
|
2506
|
+
errorContext: {},
|
|
2507
|
+
errorTags: {},
|
|
2508
|
+
errorBeforeSend: null,
|
|
2509
|
+
superProps,
|
|
2510
|
+
consent,
|
|
2511
|
+
scrubPii: options.scrubPii !== false,
|
|
1196
2512
|
deviceInfo,
|
|
1197
2513
|
options: opts,
|
|
1198
2514
|
debug,
|
|
1199
2515
|
developerUserId: null,
|
|
1200
|
-
uninstallUnloadFlush: null
|
|
2516
|
+
uninstallUnloadFlush: null,
|
|
2517
|
+
lastServerTime: null,
|
|
2518
|
+
lastClientTime: null
|
|
1201
2519
|
};
|
|
1202
2520
|
debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
|
|
1203
2521
|
appId: opts.appId,
|
|
@@ -1212,6 +2530,27 @@ var CrossdeckClient = class {
|
|
|
1212
2530
|
this.state.autoTracker = tracker;
|
|
1213
2531
|
tracker.install();
|
|
1214
2532
|
}
|
|
2533
|
+
if (autoTrack.webVitals) {
|
|
2534
|
+
const vitals = new WebVitalsTracker(
|
|
2535
|
+
{ enabled: true },
|
|
2536
|
+
(name, properties) => this.track(name, properties)
|
|
2537
|
+
);
|
|
2538
|
+
this.state.webVitals = vitals;
|
|
2539
|
+
vitals.install();
|
|
2540
|
+
}
|
|
2541
|
+
if (autoTrack.errors) {
|
|
2542
|
+
const tracker = new ErrorTracker({
|
|
2543
|
+
config: { ...DEFAULT_ERROR_CAPTURE, enabled: true },
|
|
2544
|
+
breadcrumbs,
|
|
2545
|
+
report: (err) => this.reportError(err),
|
|
2546
|
+
getContext: () => ({ ...this.state.errorContext }),
|
|
2547
|
+
getTags: () => ({ ...this.state.errorTags }),
|
|
2548
|
+
beforeSend: this.state.errorBeforeSend,
|
|
2549
|
+
isConsented: () => this.state.consent.errors
|
|
2550
|
+
});
|
|
2551
|
+
this.state.errors = tracker;
|
|
2552
|
+
tracker.install();
|
|
2553
|
+
}
|
|
1215
2554
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
1216
2555
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
1217
2556
|
});
|
|
@@ -1235,8 +2574,19 @@ var CrossdeckClient = class {
|
|
|
1235
2574
|
/**
|
|
1236
2575
|
* Link the anonymous device to a developer-supplied user ID. Cache
|
|
1237
2576
|
* the resolved Crossdeck customer for follow-up calls.
|
|
2577
|
+
*
|
|
2578
|
+
* v0.9.0+ accepts an optional `traits` bag — profile data (name,
|
|
2579
|
+
* plan, signupDate, role) persisted on the Crossdeck customer record
|
|
2580
|
+
* and queryable from dashboards. Traits are sanitised through the
|
|
2581
|
+
* same validator that gates `track()` properties, so a `{ avatar:
|
|
2582
|
+
* <File>, onSave: () => {} }` payload can't corrupt the alias call.
|
|
2583
|
+
*
|
|
2584
|
+
* Crossdeck.identify("user_847", {
|
|
2585
|
+
* email: "wes@pinet.co.za",
|
|
2586
|
+
* traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
|
|
2587
|
+
* });
|
|
1238
2588
|
*/
|
|
1239
|
-
async identify(userId,
|
|
2589
|
+
async identify(userId, options) {
|
|
1240
2590
|
const s = this.requireStarted();
|
|
1241
2591
|
if (!userId) {
|
|
1242
2592
|
throw new CrossdeckError({
|
|
@@ -1245,13 +2595,265 @@ var CrossdeckClient = class {
|
|
|
1245
2595
|
message: "identify(userId) requires a non-empty userId."
|
|
1246
2596
|
});
|
|
1247
2597
|
}
|
|
2598
|
+
if (!s.consent.analytics) {
|
|
2599
|
+
s.debug.emit(
|
|
2600
|
+
"sdk.consent_denied",
|
|
2601
|
+
`identify() skipped \u2014 consent denied for analytics.`
|
|
2602
|
+
);
|
|
2603
|
+
return {
|
|
2604
|
+
object: "alias_result",
|
|
2605
|
+
crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
|
|
2606
|
+
linked: [],
|
|
2607
|
+
mergePending: false,
|
|
2608
|
+
env: s.options.environment
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
|
|
2612
|
+
const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
|
|
2613
|
+
if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
|
|
2614
|
+
for (const w of traitsValidation.warnings) {
|
|
2615
|
+
s.debug.emit(
|
|
2616
|
+
"sdk.property_coerced",
|
|
2617
|
+
`identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2618
|
+
{ key: w.key, kind: w.kind }
|
|
2619
|
+
);
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
const body = {
|
|
2623
|
+
userId,
|
|
2624
|
+
anonymousId: s.identity.anonymousId
|
|
2625
|
+
};
|
|
2626
|
+
if (options?.email) body.email = options.email;
|
|
2627
|
+
if (traits) body.traits = traits;
|
|
1248
2628
|
const result = await s.http.request("POST", "/identity/alias", {
|
|
1249
|
-
body
|
|
2629
|
+
body
|
|
1250
2630
|
});
|
|
1251
2631
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
1252
2632
|
s.developerUserId = userId;
|
|
1253
2633
|
return result;
|
|
1254
2634
|
}
|
|
2635
|
+
/**
|
|
2636
|
+
* Register super-properties — Mixpanel pattern. Once set, every
|
|
2637
|
+
* subsequent event of THIS SDK instance carries these keys on its
|
|
2638
|
+
* properties bag automatically.
|
|
2639
|
+
*
|
|
2640
|
+
* Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
|
|
2641
|
+
* Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
|
|
2642
|
+
*
|
|
2643
|
+
* Values that are `null` are deleted (the explicit "stop tracking
|
|
2644
|
+
* this key" idiom). Returns the resulting bag.
|
|
2645
|
+
*
|
|
2646
|
+
* Sanitised through `validateEventProperties` so a `{ avatar: File }`
|
|
2647
|
+
* payload can't poison the queue at flush time.
|
|
2648
|
+
*/
|
|
2649
|
+
register(properties) {
|
|
2650
|
+
const s = this.requireStarted();
|
|
2651
|
+
const validation = validateEventProperties(properties);
|
|
2652
|
+
return s.superProps.register(validation.properties);
|
|
2653
|
+
}
|
|
2654
|
+
/** Remove a single super-property key. Idempotent. */
|
|
2655
|
+
unregister(key) {
|
|
2656
|
+
const s = this.requireStarted();
|
|
2657
|
+
s.superProps.unregister(key);
|
|
2658
|
+
}
|
|
2659
|
+
/** Snapshot of the current super-property bag. */
|
|
2660
|
+
getSuperProperties() {
|
|
2661
|
+
if (!this.state) return {};
|
|
2662
|
+
return this.state.superProps.getSuperProperties();
|
|
2663
|
+
}
|
|
2664
|
+
/**
|
|
2665
|
+
* Associate the current user with a group (org, team, account, etc.).
|
|
2666
|
+
* Mixpanel / Segment "Group Analytics" pattern.
|
|
2667
|
+
*
|
|
2668
|
+
* Crossdeck.group("org", "acme_inc");
|
|
2669
|
+
* Crossdeck.group("team", "design", { headcount: 12 });
|
|
2670
|
+
*
|
|
2671
|
+
* Once set, every subsequent event carries `$groups.<type>: id` on
|
|
2672
|
+
* its properties bag, enabling B2B dashboards ("how is Acme using
|
|
2673
|
+
* the product"). Pass `id: null` to clear a group membership.
|
|
2674
|
+
*/
|
|
2675
|
+
group(type, id, traits) {
|
|
2676
|
+
const s = this.requireStarted();
|
|
2677
|
+
if (!type) {
|
|
2678
|
+
throw new CrossdeckError({
|
|
2679
|
+
type: "invalid_request_error",
|
|
2680
|
+
code: "missing_group_type",
|
|
2681
|
+
message: "group(type, id) requires a non-empty type."
|
|
2682
|
+
});
|
|
2683
|
+
}
|
|
2684
|
+
const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
|
|
2685
|
+
s.superProps.setGroup(type, id, sanitisedTraits);
|
|
2686
|
+
}
|
|
2687
|
+
/** Snapshot of the current groups map keyed by type. */
|
|
2688
|
+
getGroups() {
|
|
2689
|
+
if (!this.state) return {};
|
|
2690
|
+
return this.state.superProps.getGroups();
|
|
2691
|
+
}
|
|
2692
|
+
/**
|
|
2693
|
+
* Update consent state. Three independent dimensions:
|
|
2694
|
+
*
|
|
2695
|
+
* analytics — track() + identify() + auto-emissions
|
|
2696
|
+
* marketing — paid-traffic click IDs + referrer URL on events
|
|
2697
|
+
* errors — Web Vitals + (future) error reporting
|
|
2698
|
+
*
|
|
2699
|
+
* Each defaults to `true` (granted). Pass partial state — only the
|
|
2700
|
+
* keys you provide are changed.
|
|
2701
|
+
*
|
|
2702
|
+
* Crossdeck.consent({ analytics: false });
|
|
2703
|
+
* Crossdeck.consent({ marketing: true, errors: true });
|
|
2704
|
+
*
|
|
2705
|
+
* DNT-derived denies cannot be flipped back on; if the browser said
|
|
2706
|
+
* "don't track" we don't track even if the developer code disagrees.
|
|
2707
|
+
*/
|
|
2708
|
+
consent(state) {
|
|
2709
|
+
const s = this.requireStarted();
|
|
2710
|
+
const next = s.consent.set(state);
|
|
2711
|
+
s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
|
|
2712
|
+
return next;
|
|
2713
|
+
}
|
|
2714
|
+
/** Snapshot of the current consent state. */
|
|
2715
|
+
consentStatus() {
|
|
2716
|
+
if (!this.state) {
|
|
2717
|
+
return { analytics: true, marketing: true, errors: true };
|
|
2718
|
+
}
|
|
2719
|
+
return this.state.consent.get();
|
|
2720
|
+
}
|
|
2721
|
+
// ============================================================
|
|
2722
|
+
// Error capture surface (v1.0.0+)
|
|
2723
|
+
// ============================================================
|
|
2724
|
+
/**
|
|
2725
|
+
* Manually capture an error from a try/catch block.
|
|
2726
|
+
*
|
|
2727
|
+
* try { …risky… } catch (err) {
|
|
2728
|
+
* Crossdeck.captureError(err, { context: { plan: "pro" } });
|
|
2729
|
+
* }
|
|
2730
|
+
*
|
|
2731
|
+
* The error is shipped through the same event queue as analytics
|
|
2732
|
+
* (durable, retried, rate-limited per fingerprint). Sends are gated
|
|
2733
|
+
* by `consent.errors`. Returns silently — never throws, even if the
|
|
2734
|
+
* SDK isn't initialised yet.
|
|
2735
|
+
*/
|
|
2736
|
+
captureError(error, options) {
|
|
2737
|
+
if (!this.state?.errors) return;
|
|
2738
|
+
this.state.errors.captureError(error, options);
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Capture a non-error event you want to surface as an issue
|
|
2742
|
+
* ("deprecated path hit", "we entered the slow code path"). Sentry
|
|
2743
|
+
* captureMessage pattern. Returns silently if not initialised.
|
|
2744
|
+
*/
|
|
2745
|
+
captureMessage(message, level = "info") {
|
|
2746
|
+
if (!this.state?.errors) return;
|
|
2747
|
+
this.state.errors.captureMessage(message, level);
|
|
2748
|
+
}
|
|
2749
|
+
/**
|
|
2750
|
+
* Attach a tag to every subsequent error report. Tags are key/value
|
|
2751
|
+
* strings (Sentry pattern): `setTag("flow", "checkout")` → every
|
|
2752
|
+
* error from this point on carries `tags.flow === "checkout"`.
|
|
2753
|
+
*/
|
|
2754
|
+
setTag(key, value) {
|
|
2755
|
+
if (!this.state) return;
|
|
2756
|
+
this.state.errorTags[key] = value;
|
|
2757
|
+
}
|
|
2758
|
+
/** Bulk-set tags. Merges with existing tags. */
|
|
2759
|
+
setTags(tags) {
|
|
2760
|
+
if (!this.state) return;
|
|
2761
|
+
Object.assign(this.state.errorTags, tags);
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* Attach a structured context blob to every subsequent error report.
|
|
2765
|
+
* Unlike tags (flat key/value), context is a named bag of arbitrary
|
|
2766
|
+
* data: `setContext("cart", { items: 3, total: 42.99 })`.
|
|
2767
|
+
*/
|
|
2768
|
+
setContext(name, data) {
|
|
2769
|
+
if (!this.state) return;
|
|
2770
|
+
this.state.errorContext[name] = data;
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* Add a custom breadcrumb to the rolling buffer. Useful for marking
|
|
2774
|
+
* domain-meaningful moments ("user opened paywall") that aren't
|
|
2775
|
+
* already auto-captured. The buffer caps at 50 entries; old ones
|
|
2776
|
+
* evict.
|
|
2777
|
+
*/
|
|
2778
|
+
addBreadcrumb(crumb) {
|
|
2779
|
+
if (!this.state) return;
|
|
2780
|
+
this.state.breadcrumbs.add(crumb);
|
|
2781
|
+
}
|
|
2782
|
+
/**
|
|
2783
|
+
* Install a pre-send hook for errors. Return null to drop, or a
|
|
2784
|
+
* modified CapturedError to scrub / rewrite. Sentry's beforeSend
|
|
2785
|
+
* pattern — the only way to redact app-specific PII (auth tokens
|
|
2786
|
+
* in URLs, etc.) before the report leaves the browser.
|
|
2787
|
+
*/
|
|
2788
|
+
setErrorBeforeSend(hook) {
|
|
2789
|
+
if (!this.state) return;
|
|
2790
|
+
this.state.errorBeforeSend = hook;
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* Internal: turn a CapturedError into a Crossdeck event and enqueue
|
|
2794
|
+
* it. Goes through the same queue / persistence / consent / scrub
|
|
2795
|
+
* pipeline as analytics events.
|
|
2796
|
+
*/
|
|
2797
|
+
reportError(err) {
|
|
2798
|
+
const properties = {
|
|
2799
|
+
// Identifiers
|
|
2800
|
+
fingerprint: err.fingerprint,
|
|
2801
|
+
level: err.level,
|
|
2802
|
+
// Error shape
|
|
2803
|
+
errorType: err.errorType,
|
|
2804
|
+
message: err.message,
|
|
2805
|
+
// Stack
|
|
2806
|
+
stack: err.rawStack ?? void 0,
|
|
2807
|
+
frames: err.frames,
|
|
2808
|
+
filename: err.filename ?? void 0,
|
|
2809
|
+
lineno: err.lineno ?? void 0,
|
|
2810
|
+
colno: err.colno ?? void 0,
|
|
2811
|
+
// Context
|
|
2812
|
+
tags: err.tags,
|
|
2813
|
+
context: err.context,
|
|
2814
|
+
breadcrumbs: err.breadcrumbs,
|
|
2815
|
+
// HTTP (only when applicable)
|
|
2816
|
+
http: err.http
|
|
2817
|
+
};
|
|
2818
|
+
for (const k of Object.keys(properties)) {
|
|
2819
|
+
if (properties[k] === void 0) delete properties[k];
|
|
2820
|
+
}
|
|
2821
|
+
this.track(err.kind, properties);
|
|
2822
|
+
}
|
|
2823
|
+
/**
|
|
2824
|
+
* GDPR/CCPA "right to be forgotten" — calls the backend's
|
|
2825
|
+
* /v1/identity/forget endpoint to schedule a server-side deletion of
|
|
2826
|
+
* the customer's events and profile, then wipes all local state
|
|
2827
|
+
* (identity, entitlements, queue, super-props, persistent stores).
|
|
2828
|
+
*
|
|
2829
|
+
* Idempotent. Safe to call when no identity has been established
|
|
2830
|
+
* (it just wipes the empty local state).
|
|
2831
|
+
*
|
|
2832
|
+
* After forget() resolves, the SDK is in the same shape as if the
|
|
2833
|
+
* developer had called `Crossdeck.reset()` — a fresh anonymousId is
|
|
2834
|
+
* minted and the next session is a brand new identity-graph entry.
|
|
2835
|
+
*/
|
|
2836
|
+
async forget() {
|
|
2837
|
+
const s = this.requireStarted();
|
|
2838
|
+
const identityQuery = this.identityQueryParams();
|
|
2839
|
+
try {
|
|
2840
|
+
await s.http.request("POST", "/identity/forget", {
|
|
2841
|
+
body: {
|
|
2842
|
+
// Send every identity hint we hold; the server resolves the
|
|
2843
|
+
// canonical customer record and queues deletion. Missing
|
|
2844
|
+
// endpoint (older backend) gracefully degrades — local state
|
|
2845
|
+
// still wipes via the reset() call below.
|
|
2846
|
+
...identityQuery
|
|
2847
|
+
}
|
|
2848
|
+
});
|
|
2849
|
+
} catch (err) {
|
|
2850
|
+
s.debug.emit(
|
|
2851
|
+
"sdk.consent_denied",
|
|
2852
|
+
`forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
|
|
2853
|
+
);
|
|
2854
|
+
}
|
|
2855
|
+
this.reset();
|
|
2856
|
+
}
|
|
1255
2857
|
/**
|
|
1256
2858
|
* Read the current customer's active entitlements from the server.
|
|
1257
2859
|
* Updates the local cache so subsequent isEntitled() calls answer
|
|
@@ -1329,6 +2931,18 @@ var CrossdeckClient = class {
|
|
|
1329
2931
|
message: "track(name) requires a non-empty name."
|
|
1330
2932
|
});
|
|
1331
2933
|
}
|
|
2934
|
+
const isError = name.startsWith("error.");
|
|
2935
|
+
const isWebVital = name.startsWith("webvitals.");
|
|
2936
|
+
const consentGateOk = isError || isWebVital ? s.consent.errors : s.consent.analytics;
|
|
2937
|
+
if (!consentGateOk) {
|
|
2938
|
+
if (s.debug.enabled) {
|
|
2939
|
+
s.debug.emit(
|
|
2940
|
+
"sdk.consent_denied",
|
|
2941
|
+
`Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
1332
2946
|
if (s.debug.enabled && properties) {
|
|
1333
2947
|
const flagged = findSensitivePropertyKeys(properties);
|
|
1334
2948
|
if (flagged.length > 0) {
|
|
@@ -1345,9 +2959,21 @@ var CrossdeckClient = class {
|
|
|
1345
2959
|
"Using anonymous user until identify(userId) is called."
|
|
1346
2960
|
);
|
|
1347
2961
|
}
|
|
2962
|
+
const validation = validateEventProperties(properties);
|
|
2963
|
+
if (s.debug.enabled && validation.warnings.length > 0) {
|
|
2964
|
+
for (const w of validation.warnings) {
|
|
2965
|
+
s.debug.emit(
|
|
2966
|
+
"sdk.property_coerced",
|
|
2967
|
+
`Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2968
|
+
{ eventName: name, key: w.key, kind: w.kind }
|
|
2969
|
+
);
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
1348
2972
|
const enriched = { ...s.deviceInfo };
|
|
1349
2973
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
1350
2974
|
if (sessionId) enriched.sessionId = sessionId;
|
|
2975
|
+
const pageviewId = s.autoTracker?.currentPageviewId;
|
|
2976
|
+
if (pageviewId) enriched.pageviewId = pageviewId;
|
|
1351
2977
|
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1352
2978
|
if (acquisition) {
|
|
1353
2979
|
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
@@ -1355,17 +2981,46 @@ var CrossdeckClient = class {
|
|
|
1355
2981
|
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1356
2982
|
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1357
2983
|
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1358
|
-
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
2984
|
+
if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
|
|
2985
|
+
if (s.consent.marketing) {
|
|
2986
|
+
if (acquisition.gclid) enriched.gclid = acquisition.gclid;
|
|
2987
|
+
if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
|
|
2988
|
+
if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
|
|
2989
|
+
if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
|
|
2990
|
+
if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
|
|
2991
|
+
if (acquisition.twclid) enriched.twclid = acquisition.twclid;
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
const supers = s.superProps.getSuperProperties();
|
|
2995
|
+
for (const k of Object.keys(supers)) {
|
|
2996
|
+
if (!(k in enriched)) enriched[k] = supers[k];
|
|
1359
2997
|
}
|
|
1360
|
-
|
|
2998
|
+
const groupIds = s.superProps.getGroupIds();
|
|
2999
|
+
if (Object.keys(groupIds).length > 0) {
|
|
3000
|
+
enriched.$groups = groupIds;
|
|
3001
|
+
}
|
|
3002
|
+
Object.assign(enriched, validation.properties);
|
|
3003
|
+
const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
|
|
1361
3004
|
const event = {
|
|
1362
3005
|
eventId: this.mintEventId(),
|
|
1363
3006
|
name,
|
|
1364
3007
|
timestamp: Date.now(),
|
|
1365
|
-
properties:
|
|
3008
|
+
properties: finalProperties
|
|
1366
3009
|
};
|
|
1367
3010
|
Object.assign(event, this.identityHintForEvent());
|
|
1368
3011
|
s.events.enqueue(event);
|
|
3012
|
+
if (!isError && !isWebVital) {
|
|
3013
|
+
const category = name.startsWith("page.") ? "navigation" : name.startsWith("element.") || name === "session.started" ? "ui.click" : "custom";
|
|
3014
|
+
s.breadcrumbs.add({
|
|
3015
|
+
timestamp: event.timestamp,
|
|
3016
|
+
category,
|
|
3017
|
+
message: name,
|
|
3018
|
+
// Strip the device-info / session bloat from the breadcrumb
|
|
3019
|
+
// payload — only the caller-supplied properties belong in
|
|
3020
|
+
// the user-readable trail.
|
|
3021
|
+
data: properties ? { ...properties } : void 0
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
1369
3024
|
}
|
|
1370
3025
|
/**
|
|
1371
3026
|
* Force-flush queued events. Useful to call from page-unload handlers.
|
|
@@ -1440,7 +3095,12 @@ var CrossdeckClient = class {
|
|
|
1440
3095
|
*/
|
|
1441
3096
|
async heartbeat() {
|
|
1442
3097
|
const s = this.requireStarted();
|
|
1443
|
-
|
|
3098
|
+
const result = await s.http.request("GET", "/sdk/heartbeat");
|
|
3099
|
+
if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
|
|
3100
|
+
s.lastServerTime = result.serverTime;
|
|
3101
|
+
s.lastClientTime = Date.now();
|
|
3102
|
+
}
|
|
3103
|
+
return result;
|
|
1444
3104
|
}
|
|
1445
3105
|
/**
|
|
1446
3106
|
* Wipe persisted identity + entitlement cache. Use on logout. The
|
|
@@ -1459,6 +3119,10 @@ var CrossdeckClient = class {
|
|
|
1459
3119
|
this.state.identity.reset();
|
|
1460
3120
|
this.state.entitlements.clear();
|
|
1461
3121
|
this.state.events.reset();
|
|
3122
|
+
this.state.superProps.clear();
|
|
3123
|
+
this.state.breadcrumbs.clear();
|
|
3124
|
+
this.state.errorContext = {};
|
|
3125
|
+
this.state.errorTags = {};
|
|
1462
3126
|
this.state.developerUserId = null;
|
|
1463
3127
|
if (this.state.autoTracker) {
|
|
1464
3128
|
const tracker = new AutoTracker(
|
|
@@ -1486,17 +3150,21 @@ var CrossdeckClient = class {
|
|
|
1486
3150
|
developerUserId: null,
|
|
1487
3151
|
sdkVersion: null,
|
|
1488
3152
|
baseUrl: null,
|
|
1489
|
-
|
|
3153
|
+
clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
|
|
3154
|
+
entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
|
|
1490
3155
|
events: {
|
|
1491
3156
|
buffered: 0,
|
|
1492
3157
|
dropped: 0,
|
|
1493
3158
|
inFlight: 0,
|
|
1494
3159
|
lastFlushAt: 0,
|
|
1495
|
-
lastError: null
|
|
3160
|
+
lastError: null,
|
|
3161
|
+
consecutiveFailures: 0,
|
|
3162
|
+
nextRetryAt: null
|
|
1496
3163
|
}
|
|
1497
3164
|
};
|
|
1498
3165
|
}
|
|
1499
3166
|
const s = this.state;
|
|
3167
|
+
const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
|
|
1500
3168
|
return {
|
|
1501
3169
|
started: true,
|
|
1502
3170
|
anonymousId: s.identity.anonymousId,
|
|
@@ -1504,9 +3172,15 @@ var CrossdeckClient = class {
|
|
|
1504
3172
|
developerUserId: s.developerUserId,
|
|
1505
3173
|
sdkVersion: s.options.sdkVersion,
|
|
1506
3174
|
baseUrl: s.options.baseUrl,
|
|
3175
|
+
clock: {
|
|
3176
|
+
lastServerTime: s.lastServerTime,
|
|
3177
|
+
lastClientTime: s.lastClientTime,
|
|
3178
|
+
skewMs
|
|
3179
|
+
},
|
|
1507
3180
|
entitlements: {
|
|
1508
3181
|
count: s.entitlements.list().length,
|
|
1509
|
-
lastUpdated: s.entitlements.freshness
|
|
3182
|
+
lastUpdated: s.entitlements.freshness,
|
|
3183
|
+
listenerErrors: s.entitlements.listenerErrors
|
|
1510
3184
|
},
|
|
1511
3185
|
events: s.events.getStats()
|
|
1512
3186
|
};
|
|
@@ -1573,6 +3247,7 @@ function inferEnvFromKey(publicKey) {
|
|
|
1573
3247
|
}
|
|
1574
3248
|
function isLocalHostname() {
|
|
1575
3249
|
const w = globalThis.window;
|
|
3250
|
+
if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
|
|
1576
3251
|
const hostname = w?.location?.hostname;
|
|
1577
3252
|
if (!hostname) return false;
|
|
1578
3253
|
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
@@ -1585,7 +3260,14 @@ function isLocalHostname() {
|
|
|
1585
3260
|
}
|
|
1586
3261
|
function resolveAutoTrack(input) {
|
|
1587
3262
|
if (input === false) {
|
|
1588
|
-
return {
|
|
3263
|
+
return {
|
|
3264
|
+
sessions: false,
|
|
3265
|
+
pageViews: false,
|
|
3266
|
+
deviceInfo: false,
|
|
3267
|
+
clicks: false,
|
|
3268
|
+
webVitals: false,
|
|
3269
|
+
errors: false
|
|
3270
|
+
};
|
|
1589
3271
|
}
|
|
1590
3272
|
if (input === void 0 || input === true) {
|
|
1591
3273
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1594,7 +3276,9 @@ function resolveAutoTrack(input) {
|
|
|
1594
3276
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1595
3277
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1596
3278
|
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
1597
|
-
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks
|
|
3279
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
|
|
3280
|
+
webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals,
|
|
3281
|
+
errors: input.errors ?? DEFAULT_AUTO_TRACK.errors
|
|
1598
3282
|
};
|
|
1599
3283
|
}
|
|
1600
3284
|
function installUnloadFlush(onUnload) {
|
|
@@ -1614,14 +3298,110 @@ function installUnloadFlush(onUnload) {
|
|
|
1614
3298
|
w.removeEventListener("beforeunload", onTerminal);
|
|
1615
3299
|
};
|
|
1616
3300
|
}
|
|
3301
|
+
|
|
3302
|
+
// src/error-codes.ts
|
|
3303
|
+
var CROSSDECK_ERROR_CODES = Object.freeze([
|
|
3304
|
+
// ----- Configuration -----
|
|
3305
|
+
{
|
|
3306
|
+
code: "invalid_public_key",
|
|
3307
|
+
type: "configuration_error",
|
|
3308
|
+
description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
|
|
3309
|
+
resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
|
|
3310
|
+
retryable: false
|
|
3311
|
+
},
|
|
3312
|
+
{
|
|
3313
|
+
code: "missing_app_id",
|
|
3314
|
+
type: "configuration_error",
|
|
3315
|
+
description: "Crossdeck.init() was called without an appId.",
|
|
3316
|
+
resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
|
|
3317
|
+
retryable: false
|
|
3318
|
+
},
|
|
3319
|
+
{
|
|
3320
|
+
code: "invalid_environment",
|
|
3321
|
+
type: "configuration_error",
|
|
3322
|
+
description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
|
|
3323
|
+
resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
|
|
3324
|
+
retryable: false
|
|
3325
|
+
},
|
|
3326
|
+
{
|
|
3327
|
+
code: "environment_mismatch",
|
|
3328
|
+
type: "configuration_error",
|
|
3329
|
+
description: "The publishable key's env prefix doesn't match the declared environment option.",
|
|
3330
|
+
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.",
|
|
3331
|
+
retryable: false
|
|
3332
|
+
},
|
|
3333
|
+
{
|
|
3334
|
+
code: "not_initialized",
|
|
3335
|
+
type: "configuration_error",
|
|
3336
|
+
description: "An SDK method was called before Crossdeck.init().",
|
|
3337
|
+
resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
|
|
3338
|
+
retryable: false
|
|
3339
|
+
},
|
|
3340
|
+
// ----- Identify / track / purchase argument validation -----
|
|
3341
|
+
{
|
|
3342
|
+
code: "missing_user_id",
|
|
3343
|
+
type: "invalid_request_error",
|
|
3344
|
+
description: "identify() was called with an empty userId.",
|
|
3345
|
+
resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
|
|
3346
|
+
retryable: false
|
|
3347
|
+
},
|
|
3348
|
+
{
|
|
3349
|
+
code: "missing_event_name",
|
|
3350
|
+
type: "invalid_request_error",
|
|
3351
|
+
description: "track() was called without an event name.",
|
|
3352
|
+
resolution: "Pass a non-empty string as the first argument.",
|
|
3353
|
+
retryable: false
|
|
3354
|
+
},
|
|
3355
|
+
{
|
|
3356
|
+
code: "missing_group_type",
|
|
3357
|
+
type: "invalid_request_error",
|
|
3358
|
+
description: "group() was called without a group type.",
|
|
3359
|
+
resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
|
|
3360
|
+
retryable: false
|
|
3361
|
+
},
|
|
3362
|
+
{
|
|
3363
|
+
code: "missing_signed_transaction_info",
|
|
3364
|
+
type: "invalid_request_error",
|
|
3365
|
+
description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
|
|
3366
|
+
resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
|
|
3367
|
+
retryable: false
|
|
3368
|
+
},
|
|
3369
|
+
// ----- Network / transport -----
|
|
3370
|
+
{
|
|
3371
|
+
code: "fetch_failed",
|
|
3372
|
+
type: "network_error",
|
|
3373
|
+
description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
|
|
3374
|
+
resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
|
|
3375
|
+
retryable: true
|
|
3376
|
+
},
|
|
3377
|
+
{
|
|
3378
|
+
code: "request_timeout",
|
|
3379
|
+
type: "network_error",
|
|
3380
|
+
description: "A request was aborted after the configured timeoutMs (default 15s).",
|
|
3381
|
+
resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
|
|
3382
|
+
retryable: true
|
|
3383
|
+
},
|
|
3384
|
+
{
|
|
3385
|
+
code: "invalid_json_response",
|
|
3386
|
+
type: "internal_error",
|
|
3387
|
+
description: "The server returned a 2xx with an unparseable body.",
|
|
3388
|
+
resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
|
|
3389
|
+
retryable: true
|
|
3390
|
+
}
|
|
3391
|
+
]);
|
|
3392
|
+
function getErrorCode(code) {
|
|
3393
|
+
return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
|
|
3394
|
+
}
|
|
1617
3395
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1618
3396
|
0 && (module.exports = {
|
|
3397
|
+
CROSSDECK_ERROR_CODES,
|
|
1619
3398
|
Crossdeck,
|
|
1620
3399
|
CrossdeckClient,
|
|
1621
3400
|
CrossdeckError,
|
|
1622
3401
|
DEFAULT_BASE_URL,
|
|
1623
3402
|
MemoryStorage,
|
|
1624
3403
|
SDK_NAME,
|
|
1625
|
-
SDK_VERSION
|
|
3404
|
+
SDK_VERSION,
|
|
3405
|
+
getErrorCode
|
|
1626
3406
|
});
|
|
1627
3407
|
//# sourceMappingURL=index.cjs.map
|