@cross-deck/web 0.6.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/dist/crossdeck.umd.min.js +2 -0
- package/dist/crossdeck.umd.min.js.map +1 -0
- package/dist/error-codes.json +91 -0
- package/dist/index.cjs +1403 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +281 -5
- package/dist/index.d.ts +281 -5
- package/dist/index.mjs +1400 -38
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +1303 -37
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +1303 -37
- package/dist/react.mjs.map +1 -1
- package/dist/vue.cjs +2675 -0
- package/dist/vue.cjs.map +1 -0
- package/dist/vue.d.mts +37 -0
- package/dist/vue.d.ts +37 -0
- package/dist/vue.mjs +2649 -0
- package/dist/vue.mjs.map +1 -0
- package/package.json +25 -6
package/dist/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 = "0.10.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;
|
|
@@ -94,31 +116,47 @@ var HttpClient = class {
|
|
|
94
116
|
* - JSON parse failure on a 2xx (treated as `internal_error`)
|
|
95
117
|
*/
|
|
96
118
|
async request(method, path, options = {}) {
|
|
119
|
+
if (this.config.localDevMode) {
|
|
120
|
+
return synthesizeLocalDevResponse(path);
|
|
121
|
+
}
|
|
97
122
|
const url = this.buildUrl(path, options.query);
|
|
98
123
|
const headers = {
|
|
99
124
|
Authorization: `Bearer ${this.config.publicKey}`,
|
|
100
125
|
"Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
|
|
101
126
|
Accept: "application/json"
|
|
102
127
|
};
|
|
128
|
+
if (options.idempotencyKey) {
|
|
129
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
130
|
+
}
|
|
103
131
|
let bodyInit;
|
|
104
132
|
if (options.body !== void 0) {
|
|
105
133
|
headers["Content-Type"] = "application/json";
|
|
106
134
|
bodyInit = JSON.stringify(options.body);
|
|
107
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
|
+
}
|
|
108
142
|
let response;
|
|
109
143
|
try {
|
|
110
144
|
response = await fetch(url, {
|
|
111
145
|
method,
|
|
112
146
|
headers,
|
|
113
147
|
body: bodyInit,
|
|
114
|
-
keepalive: options.keepalive === true
|
|
148
|
+
keepalive: options.keepalive === true,
|
|
149
|
+
signal: controller?.signal
|
|
115
150
|
});
|
|
116
151
|
} catch (err) {
|
|
152
|
+
const aborted = controller?.signal?.aborted === true;
|
|
117
153
|
throw new CrossdeckError({
|
|
118
154
|
type: "network_error",
|
|
119
|
-
code: "fetch_failed",
|
|
120
|
-
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"
|
|
121
157
|
});
|
|
158
|
+
} finally {
|
|
159
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
122
160
|
}
|
|
123
161
|
if (!response.ok) {
|
|
124
162
|
throw await crossdeckErrorFromResponse(response);
|
|
@@ -136,6 +174,14 @@ var HttpClient = class {
|
|
|
136
174
|
});
|
|
137
175
|
}
|
|
138
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Whether this client is in localhost dev-mode short-circuit. Used
|
|
179
|
+
* by other SDK pieces (event-queue) to skip network-bound work
|
|
180
|
+
* entirely rather than going through synthesizeLocalDevResponse.
|
|
181
|
+
*/
|
|
182
|
+
get isLocalDevMode() {
|
|
183
|
+
return this.config.localDevMode === true;
|
|
184
|
+
}
|
|
139
185
|
buildUrl(path, query) {
|
|
140
186
|
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
141
187
|
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
@@ -151,6 +197,49 @@ var HttpClient = class {
|
|
|
151
197
|
return url;
|
|
152
198
|
}
|
|
153
199
|
};
|
|
200
|
+
var cachedLocalCdcust = null;
|
|
201
|
+
function synthesizeLocalDevResponse(path) {
|
|
202
|
+
if (path.startsWith("/sdk/heartbeat")) {
|
|
203
|
+
return {
|
|
204
|
+
object: "heartbeat",
|
|
205
|
+
ok: true,
|
|
206
|
+
projectId: "proj_local_dev",
|
|
207
|
+
appId: "app_local_dev",
|
|
208
|
+
platform: "web",
|
|
209
|
+
env: "sandbox",
|
|
210
|
+
serverTime: Date.now()
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (path.startsWith("/identity/alias")) {
|
|
214
|
+
if (!cachedLocalCdcust) {
|
|
215
|
+
const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
|
|
216
|
+
cachedLocalCdcust = `cdcust_local_${tail}`;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
object: "alias_result",
|
|
220
|
+
crossdeckCustomerId: cachedLocalCdcust,
|
|
221
|
+
linked: [],
|
|
222
|
+
mergePending: false,
|
|
223
|
+
env: "sandbox"
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (path.startsWith("/entitlements")) {
|
|
227
|
+
return {
|
|
228
|
+
object: "list",
|
|
229
|
+
data: [],
|
|
230
|
+
crossdeckCustomerId: cachedLocalCdcust ?? "",
|
|
231
|
+
env: "sandbox"
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (path.startsWith("/events")) {
|
|
235
|
+
return {
|
|
236
|
+
object: "list",
|
|
237
|
+
received: 0,
|
|
238
|
+
env: "sandbox"
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {};
|
|
242
|
+
}
|
|
154
243
|
|
|
155
244
|
// src/identity.ts
|
|
156
245
|
var KEY_ANON = "anon_id";
|
|
@@ -264,6 +353,7 @@ var EntitlementCache = class {
|
|
|
264
353
|
this.all = [];
|
|
265
354
|
this.lastUpdated = 0;
|
|
266
355
|
this.listeners = /* @__PURE__ */ new Set();
|
|
356
|
+
this.listenerErrorCount = 0;
|
|
267
357
|
}
|
|
268
358
|
/** Sync read — true iff the entitlement key is currently active. */
|
|
269
359
|
isEntitled(key) {
|
|
@@ -277,6 +367,15 @@ var EntitlementCache = class {
|
|
|
277
367
|
get freshness() {
|
|
278
368
|
return this.lastUpdated;
|
|
279
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
|
+
}
|
|
280
379
|
/**
|
|
281
380
|
* Replace the cache with a fresh server response. The backend already
|
|
282
381
|
* filters to active + env-matching, so we don't re-filter — just trust
|
|
@@ -330,11 +429,54 @@ var EntitlementCache = class {
|
|
|
330
429
|
try {
|
|
331
430
|
listener(snapshot);
|
|
332
431
|
} catch {
|
|
432
|
+
this.listenerErrorCount += 1;
|
|
333
433
|
}
|
|
334
434
|
}
|
|
335
435
|
}
|
|
336
436
|
};
|
|
337
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
|
+
|
|
338
480
|
// src/event-queue.ts
|
|
339
481
|
var HARD_BUFFER_CAP = 1e3;
|
|
340
482
|
var EventQueue = class {
|
|
@@ -347,6 +489,22 @@ var EventQueue = class {
|
|
|
347
489
|
this.lastError = null;
|
|
348
490
|
this.cancelTimer = null;
|
|
349
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
|
+
}
|
|
350
508
|
}
|
|
351
509
|
enqueue(event) {
|
|
352
510
|
this.buffer.push(event);
|
|
@@ -356,6 +514,8 @@ var EventQueue = class {
|
|
|
356
514
|
this.dropped += overflow;
|
|
357
515
|
this.cfg.onDrop?.(overflow);
|
|
358
516
|
}
|
|
517
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
518
|
+
this.persistent?.save(this.buffer);
|
|
359
519
|
if (this.buffer.length >= this.cfg.batchSize) {
|
|
360
520
|
void this.flush();
|
|
361
521
|
} else {
|
|
@@ -365,7 +525,7 @@ var EventQueue = class {
|
|
|
365
525
|
/**
|
|
366
526
|
* Flush the buffer to /v1/events. Resolves when the network call
|
|
367
527
|
* completes (success or failure). On failure, events stay in the
|
|
368
|
-
* buffer for the next
|
|
528
|
+
* buffer for the next scheduled retry.
|
|
369
529
|
*
|
|
370
530
|
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
371
531
|
* browser keeps the request alive past page unload. Use this for
|
|
@@ -374,25 +534,32 @@ var EventQueue = class {
|
|
|
374
534
|
async flush(options = {}) {
|
|
375
535
|
if (this.buffer.length === 0) return null;
|
|
376
536
|
this.cancelTimerIfSet();
|
|
537
|
+
this.nextRetryAt = null;
|
|
377
538
|
const batch = this.buffer.splice(0);
|
|
539
|
+
const batchId = this.mintBatchId();
|
|
378
540
|
this.inFlight += batch.length;
|
|
541
|
+
this.persistent?.save(this.buffer);
|
|
542
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
379
543
|
try {
|
|
380
544
|
const env = this.cfg.envelope();
|
|
381
545
|
const result = await this.cfg.http.request("POST", "/events", {
|
|
382
546
|
body: {
|
|
383
547
|
// NorthStar §13.1 batch envelope. The backend validates these
|
|
384
|
-
// against the API-key-resolved app and rejects mismatches
|
|
385
|
-
// (env_mismatch).
|
|
548
|
+
// against the API-key-resolved app and rejects mismatches
|
|
549
|
+
// loudly (env_mismatch).
|
|
386
550
|
appId: env.appId,
|
|
387
551
|
environment: env.environment,
|
|
388
552
|
sdk: env.sdk,
|
|
389
553
|
events: batch
|
|
390
554
|
},
|
|
391
|
-
keepalive: options.keepalive === true
|
|
555
|
+
keepalive: options.keepalive === true,
|
|
556
|
+
idempotencyKey: batchId
|
|
392
557
|
});
|
|
393
558
|
this.lastFlushAt = Date.now();
|
|
394
559
|
this.lastError = null;
|
|
395
560
|
this.inFlight -= batch.length;
|
|
561
|
+
this.retry.recordSuccess();
|
|
562
|
+
this.persistent?.save(this.buffer);
|
|
396
563
|
if (!this.firstFlushFired) {
|
|
397
564
|
this.firstFlushFired = true;
|
|
398
565
|
this.cfg.onFirstFlushSuccess?.();
|
|
@@ -401,18 +568,33 @@ var EventQueue = class {
|
|
|
401
568
|
} catch (err) {
|
|
402
569
|
this.buffer.unshift(...batch);
|
|
403
570
|
this.inFlight -= batch.length;
|
|
404
|
-
|
|
405
|
-
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
|
+
});
|
|
406
584
|
return null;
|
|
407
585
|
}
|
|
408
586
|
}
|
|
409
|
-
/** Cancel any pending timer and clear in-memory state. */
|
|
587
|
+
/** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
|
|
410
588
|
reset() {
|
|
411
589
|
this.cancelTimerIfSet();
|
|
590
|
+
this.nextRetryAt = null;
|
|
412
591
|
this.buffer = [];
|
|
413
592
|
this.dropped = 0;
|
|
414
593
|
this.inFlight = 0;
|
|
415
594
|
this.lastError = null;
|
|
595
|
+
this.retry.recordSuccess();
|
|
596
|
+
this.persistent?.clear();
|
|
597
|
+
this.cfg.onBufferChange?.(0);
|
|
416
598
|
}
|
|
417
599
|
getStats() {
|
|
418
600
|
return {
|
|
@@ -420,9 +602,12 @@ var EventQueue = class {
|
|
|
420
602
|
dropped: this.dropped,
|
|
421
603
|
inFlight: this.inFlight,
|
|
422
604
|
lastFlushAt: this.lastFlushAt,
|
|
423
|
-
lastError: this.lastError
|
|
605
|
+
lastError: this.lastError,
|
|
606
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
607
|
+
nextRetryAt: this.nextRetryAt
|
|
424
608
|
};
|
|
425
609
|
}
|
|
610
|
+
// ---------- internal scheduling ----------
|
|
426
611
|
scheduleIdleFlush() {
|
|
427
612
|
this.cancelTimerIfSet();
|
|
428
613
|
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
@@ -430,13 +615,31 @@ var EventQueue = class {
|
|
|
430
615
|
void this.flush();
|
|
431
616
|
}, this.cfg.intervalMs);
|
|
432
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
|
+
}
|
|
433
626
|
cancelTimerIfSet() {
|
|
434
627
|
if (this.cancelTimer) {
|
|
435
628
|
this.cancelTimer();
|
|
436
629
|
this.cancelTimer = null;
|
|
437
630
|
}
|
|
438
631
|
}
|
|
632
|
+
mintBatchId() {
|
|
633
|
+
return `batch_${Date.now().toString(36)}${randomChars(10)}`;
|
|
634
|
+
}
|
|
439
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
|
+
}
|
|
440
643
|
function defaultScheduler(fn, ms) {
|
|
441
644
|
const id = setTimeout(fn, ms);
|
|
442
645
|
if (typeof id.unref === "function") {
|
|
@@ -448,6 +651,87 @@ function defaultScheduler(fn, ms) {
|
|
|
448
651
|
return () => clearTimeout(id);
|
|
449
652
|
}
|
|
450
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
|
+
|
|
451
735
|
// src/storage.ts
|
|
452
736
|
var MemoryStorage = class {
|
|
453
737
|
constructor() {
|
|
@@ -637,7 +921,9 @@ function parseUserAgent(ua) {
|
|
|
637
921
|
var DEFAULT_AUTO_TRACK = {
|
|
638
922
|
sessions: true,
|
|
639
923
|
pageViews: true,
|
|
640
|
-
deviceInfo: true
|
|
924
|
+
deviceInfo: true,
|
|
925
|
+
clicks: true,
|
|
926
|
+
webVitals: true
|
|
641
927
|
};
|
|
642
928
|
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
643
929
|
var EMPTY_ACQUISITION = {
|
|
@@ -646,7 +932,13 @@ var EMPTY_ACQUISITION = {
|
|
|
646
932
|
utm_campaign: "",
|
|
647
933
|
utm_content: "",
|
|
648
934
|
utm_term: "",
|
|
649
|
-
referrer: ""
|
|
935
|
+
referrer: "",
|
|
936
|
+
gclid: "",
|
|
937
|
+
fbclid: "",
|
|
938
|
+
msclkid: "",
|
|
939
|
+
ttclid: "",
|
|
940
|
+
li_fat_id: "",
|
|
941
|
+
twclid: ""
|
|
650
942
|
};
|
|
651
943
|
var AutoTracker = class {
|
|
652
944
|
constructor(cfg, track) {
|
|
@@ -654,11 +946,23 @@ var AutoTracker = class {
|
|
|
654
946
|
this.track = track;
|
|
655
947
|
this.session = null;
|
|
656
948
|
this.cleanups = [];
|
|
949
|
+
/**
|
|
950
|
+
* Stable per-page-view identifier. Minted at every `page.viewed`
|
|
951
|
+
* emission and attached to every subsequent event until the next
|
|
952
|
+
* `page.viewed`. Lets dashboards correlate "user clicked X" to
|
|
953
|
+
* "user viewed page Y" without timestamp arithmetic — the canonical
|
|
954
|
+
* Mixpanel `$current_url` / Segment `pageId` pattern.
|
|
955
|
+
*
|
|
956
|
+
* Null until the first `page.viewed` fires (which happens at SDK
|
|
957
|
+
* install if `autoTrack.pageViews !== false`).
|
|
958
|
+
*/
|
|
959
|
+
this.pageviewId = null;
|
|
657
960
|
}
|
|
658
961
|
install() {
|
|
659
962
|
if (!isBrowserSafe()) return;
|
|
660
963
|
if (this.cfg.sessions) this.installSessionTracking();
|
|
661
964
|
if (this.cfg.pageViews) this.installPageViewTracking();
|
|
965
|
+
if (this.cfg.clicks) this.installClickTracking();
|
|
662
966
|
}
|
|
663
967
|
uninstall() {
|
|
664
968
|
while (this.cleanups.length) {
|
|
@@ -683,6 +987,10 @@ var AutoTracker = class {
|
|
|
683
987
|
get currentSessionId() {
|
|
684
988
|
return this.session?.sessionId ?? null;
|
|
685
989
|
}
|
|
990
|
+
/** Stable per-page-view ID. Null before the first page.viewed has fired. */
|
|
991
|
+
get currentPageviewId() {
|
|
992
|
+
return this.pageviewId;
|
|
993
|
+
}
|
|
686
994
|
/**
|
|
687
995
|
* Per-session acquisition context — utm_* + referrer, captured once
|
|
688
996
|
* at session start. Returns empty strings when there's no session
|
|
@@ -753,11 +1061,21 @@ var AutoTracker = class {
|
|
|
753
1061
|
installPageViewTracking() {
|
|
754
1062
|
const w = globalThis.window;
|
|
755
1063
|
const doc = globalThis.document;
|
|
756
|
-
|
|
1064
|
+
let lastFiredAt = 0;
|
|
1065
|
+
let lastFiredUrl = "";
|
|
1066
|
+
const DEDUP_WINDOW_MS = 250;
|
|
1067
|
+
const fire = (force = false) => {
|
|
757
1068
|
const loc = w.location;
|
|
1069
|
+
const url = loc.href;
|
|
1070
|
+
const now = Date.now();
|
|
1071
|
+
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
1072
|
+
lastFiredAt = now;
|
|
1073
|
+
lastFiredUrl = url;
|
|
1074
|
+
this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
|
|
758
1075
|
this.track("page.viewed", {
|
|
1076
|
+
pageviewId: this.pageviewId,
|
|
759
1077
|
path: loc.pathname,
|
|
760
|
-
url
|
|
1078
|
+
url,
|
|
761
1079
|
search: loc.search || void 0,
|
|
762
1080
|
hash: loc.hash || void 0,
|
|
763
1081
|
title: doc.title,
|
|
@@ -779,7 +1097,7 @@ var AutoTracker = class {
|
|
|
779
1097
|
}
|
|
780
1098
|
w.history.pushState = patchedPush;
|
|
781
1099
|
w.history.replaceState = patchedReplace;
|
|
782
|
-
const onPopState = () => fire();
|
|
1100
|
+
const onPopState = () => fire(true);
|
|
783
1101
|
w.addEventListener("popstate", onPopState);
|
|
784
1102
|
this.cleanups.push(() => {
|
|
785
1103
|
if (w.history.pushState === patchedPush) {
|
|
@@ -791,7 +1109,156 @@ var AutoTracker = class {
|
|
|
791
1109
|
w.removeEventListener("popstate", onPopState);
|
|
792
1110
|
});
|
|
793
1111
|
}
|
|
1112
|
+
// ---------- click autocapture ----------
|
|
1113
|
+
/**
|
|
1114
|
+
* Global click tracking — Mixpanel / Amplitude style autocapture.
|
|
1115
|
+
* Fires `element.clicked` for every interactive click with the
|
|
1116
|
+
* target element's selector path, text content, tag, href, data-*
|
|
1117
|
+
* attributes, and viewport coordinates. Powers the funnel /
|
|
1118
|
+
* attribution USP: "users who clicked X then converted within
|
|
1119
|
+
* 7 days." Default ON because behavioural attribution is the
|
|
1120
|
+
* core product promise.
|
|
1121
|
+
*
|
|
1122
|
+
* Privacy guardrails:
|
|
1123
|
+
* - Skip clicks ON inputs / textareas / selects (form interaction
|
|
1124
|
+
* isn't button telemetry; the dev should track form submits
|
|
1125
|
+
* deliberately via track('form_submitted'))
|
|
1126
|
+
* - Skip clicks INSIDE [type="password"] and password-class
|
|
1127
|
+
* elements
|
|
1128
|
+
* - Skip clicks inside elements opted out via class="cd-noTrack"
|
|
1129
|
+
* or data-cd-noTrack attribute (Mixpanel's exact opt-out
|
|
1130
|
+
* idiom — most devs already know it)
|
|
1131
|
+
* - Capture text content but cap at 64 chars and trim — never
|
|
1132
|
+
* more than what you'd see on a button label
|
|
1133
|
+
*
|
|
1134
|
+
* Volume guardrails:
|
|
1135
|
+
* - Coalesce double-clicks within 100ms (React's synthetic click
|
|
1136
|
+
* pattern + browser's native dblclick can fire twice)
|
|
1137
|
+
* - Listen on document at capture phase so we see the click
|
|
1138
|
+
* before any framework's own handlers stop propagation
|
|
1139
|
+
*/
|
|
1140
|
+
installClickTracking() {
|
|
1141
|
+
const w = globalThis.window;
|
|
1142
|
+
const doc = globalThis.document;
|
|
1143
|
+
let lastFiredAt = 0;
|
|
1144
|
+
let lastFiredTarget = null;
|
|
1145
|
+
const COALESCE_MS = 100;
|
|
1146
|
+
const TEXT_CAP = 64;
|
|
1147
|
+
const onClick = (ev) => {
|
|
1148
|
+
const target = ev.target;
|
|
1149
|
+
if (!target || !(target instanceof Element)) return;
|
|
1150
|
+
const now = Date.now();
|
|
1151
|
+
if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
|
|
1152
|
+
lastFiredAt = now;
|
|
1153
|
+
lastFiredTarget = target;
|
|
1154
|
+
const actionable = closestActionable(target);
|
|
1155
|
+
const clicked = actionable || target;
|
|
1156
|
+
if (isFormInput(clicked)) return;
|
|
1157
|
+
if (isInOptedOut(clicked)) return;
|
|
1158
|
+
if (isInsidePasswordField(clicked)) return;
|
|
1159
|
+
const tag = clicked.tagName.toLowerCase();
|
|
1160
|
+
const text = trimText(extractText(clicked), TEXT_CAP);
|
|
1161
|
+
const href = clicked.href || void 0;
|
|
1162
|
+
const linkTarget = clicked.target || void 0;
|
|
1163
|
+
const elementId = clicked.id || void 0;
|
|
1164
|
+
const role = clicked.getAttribute("role") || void 0;
|
|
1165
|
+
const ariaLabel = clicked.getAttribute("aria-label") || void 0;
|
|
1166
|
+
const selector = buildSelector(clicked);
|
|
1167
|
+
const dataAttrs = collectDataAttrs(clicked);
|
|
1168
|
+
const isLink = tag === "a" && !!href;
|
|
1169
|
+
const explicitName = clicked.getAttribute("data-cd-event");
|
|
1170
|
+
const props = {
|
|
1171
|
+
selector,
|
|
1172
|
+
tag,
|
|
1173
|
+
text,
|
|
1174
|
+
elementId,
|
|
1175
|
+
role,
|
|
1176
|
+
ariaLabel,
|
|
1177
|
+
href,
|
|
1178
|
+
isLink,
|
|
1179
|
+
linkTarget,
|
|
1180
|
+
viewportX: ev.clientX,
|
|
1181
|
+
viewportY: ev.clientY,
|
|
1182
|
+
pageX: ev.pageX,
|
|
1183
|
+
pageY: ev.pageY,
|
|
1184
|
+
...dataAttrs
|
|
1185
|
+
};
|
|
1186
|
+
for (const k of Object.keys(props)) {
|
|
1187
|
+
if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
|
|
1188
|
+
}
|
|
1189
|
+
this.track(explicitName || "element.clicked", props);
|
|
1190
|
+
};
|
|
1191
|
+
doc.addEventListener("click", onClick, { capture: true, passive: true });
|
|
1192
|
+
this.cleanups.push(() => {
|
|
1193
|
+
doc.removeEventListener("click", onClick, { capture: true });
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
794
1196
|
};
|
|
1197
|
+
function closestActionable(el) {
|
|
1198
|
+
return el.closest("[data-cd-event]") || el.closest("[data-cd-noTrack]") || el.closest("button, a, [role='button'], [role='link'], input[type='button'], input[type='submit']") || null;
|
|
1199
|
+
}
|
|
1200
|
+
function isFormInput(el) {
|
|
1201
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
1202
|
+
const tag = el.tagName.toLowerCase();
|
|
1203
|
+
if (tag === "textarea" || tag === "select") return true;
|
|
1204
|
+
if (tag === "input") {
|
|
1205
|
+
const type = (el.type || "").toLowerCase();
|
|
1206
|
+
return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
|
|
1207
|
+
}
|
|
1208
|
+
return false;
|
|
1209
|
+
}
|
|
1210
|
+
function isInOptedOut(el) {
|
|
1211
|
+
if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
function isInsidePasswordField(el) {
|
|
1215
|
+
if (el.closest('input[type="password"]')) return true;
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
function extractText(el) {
|
|
1219
|
+
const aria = el.getAttribute("aria-label");
|
|
1220
|
+
if (aria) return aria.replace(/\s+/g, " ").trim();
|
|
1221
|
+
if (el instanceof HTMLInputElement && el.value) return el.value;
|
|
1222
|
+
const text = (el.textContent || "").replace(/\s+/g, " ").trim();
|
|
1223
|
+
return text;
|
|
1224
|
+
}
|
|
1225
|
+
function trimText(s, cap) {
|
|
1226
|
+
if (s.length <= cap) return s;
|
|
1227
|
+
return s.slice(0, cap - 1) + "\u2026";
|
|
1228
|
+
}
|
|
1229
|
+
function buildSelector(el) {
|
|
1230
|
+
const parts = [];
|
|
1231
|
+
let cur = el;
|
|
1232
|
+
let depth = 0;
|
|
1233
|
+
while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
|
|
1234
|
+
let part = cur.nodeName.toLowerCase();
|
|
1235
|
+
if (cur.id) {
|
|
1236
|
+
parts.unshift(`${part}#${cur.id}`);
|
|
1237
|
+
break;
|
|
1238
|
+
}
|
|
1239
|
+
if (cur.classList.length > 0) {
|
|
1240
|
+
const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
|
|
1241
|
+
if (cls) part += `.${cls}`;
|
|
1242
|
+
}
|
|
1243
|
+
parts.unshift(part);
|
|
1244
|
+
cur = cur.parentElement;
|
|
1245
|
+
depth++;
|
|
1246
|
+
}
|
|
1247
|
+
return parts.join(" > ");
|
|
1248
|
+
}
|
|
1249
|
+
function collectDataAttrs(el) {
|
|
1250
|
+
const out = {};
|
|
1251
|
+
if (!(el instanceof HTMLElement)) return out;
|
|
1252
|
+
for (const name of el.getAttributeNames()) {
|
|
1253
|
+
if (!name.startsWith("data-")) continue;
|
|
1254
|
+
if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
|
|
1255
|
+
if (name === "data-cd-event") continue;
|
|
1256
|
+
const value = el.getAttribute(name) || "";
|
|
1257
|
+
const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
|
|
1258
|
+
out[key] = value;
|
|
1259
|
+
}
|
|
1260
|
+
return out;
|
|
1261
|
+
}
|
|
795
1262
|
function isBrowserSafe() {
|
|
796
1263
|
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
|
|
797
1264
|
}
|
|
@@ -810,6 +1277,12 @@ function captureAcquisition() {
|
|
|
810
1277
|
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
811
1278
|
result.utm_content = params.get("utm_content") ?? "";
|
|
812
1279
|
result.utm_term = params.get("utm_term") ?? "";
|
|
1280
|
+
result.gclid = params.get("gclid") ?? "";
|
|
1281
|
+
result.fbclid = params.get("fbclid") ?? "";
|
|
1282
|
+
result.msclkid = params.get("msclkid") ?? "";
|
|
1283
|
+
result.ttclid = params.get("ttclid") ?? "";
|
|
1284
|
+
result.li_fat_id = params.get("li_fat_id") ?? "";
|
|
1285
|
+
result.twclid = params.get("twclid") ?? "";
|
|
813
1286
|
} catch {
|
|
814
1287
|
}
|
|
815
1288
|
try {
|
|
@@ -867,6 +1340,490 @@ function safeJson(obj) {
|
|
|
867
1340
|
}
|
|
868
1341
|
}
|
|
869
1342
|
|
|
1343
|
+
// src/event-validation.ts
|
|
1344
|
+
var DEFAULT_MAX_STRING = 1024;
|
|
1345
|
+
var DEFAULT_MAX_BYTES = 8 * 1024;
|
|
1346
|
+
var DEFAULT_MAX_DEPTH = 5;
|
|
1347
|
+
function validateEventProperties(input, options = {}) {
|
|
1348
|
+
const warnings = [];
|
|
1349
|
+
if (!input) return { properties: {}, warnings };
|
|
1350
|
+
const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
|
|
1351
|
+
const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
|
|
1352
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1353
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
1354
|
+
const visit = (value, key, depth) => {
|
|
1355
|
+
if (depth > maxDepth) {
|
|
1356
|
+
warnings.push({ kind: "depth_exceeded", key });
|
|
1357
|
+
return { keep: true, value: "[depth-exceeded]" };
|
|
1358
|
+
}
|
|
1359
|
+
if (value === null) return { keep: true, value: null };
|
|
1360
|
+
const t = typeof value;
|
|
1361
|
+
if (t === "string") {
|
|
1362
|
+
const s = value;
|
|
1363
|
+
if (s.length > maxStringLength) {
|
|
1364
|
+
warnings.push({ kind: "truncated_string", key });
|
|
1365
|
+
return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
|
|
1366
|
+
}
|
|
1367
|
+
return { keep: true, value: s };
|
|
1368
|
+
}
|
|
1369
|
+
if (t === "number") {
|
|
1370
|
+
if (!Number.isFinite(value)) {
|
|
1371
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1372
|
+
return { keep: true, value: null };
|
|
1373
|
+
}
|
|
1374
|
+
return { keep: true, value };
|
|
1375
|
+
}
|
|
1376
|
+
if (t === "boolean") return { keep: true, value };
|
|
1377
|
+
if (t === "bigint") {
|
|
1378
|
+
warnings.push({ kind: "coerced_bigint", key });
|
|
1379
|
+
return { keep: true, value: value.toString() };
|
|
1380
|
+
}
|
|
1381
|
+
if (t === "function") {
|
|
1382
|
+
warnings.push({ kind: "dropped_function", key });
|
|
1383
|
+
return { keep: false, value: void 0 };
|
|
1384
|
+
}
|
|
1385
|
+
if (t === "symbol") {
|
|
1386
|
+
warnings.push({ kind: "dropped_symbol", key });
|
|
1387
|
+
return { keep: false, value: void 0 };
|
|
1388
|
+
}
|
|
1389
|
+
if (t === "undefined") {
|
|
1390
|
+
warnings.push({ kind: "dropped_undefined", key });
|
|
1391
|
+
return { keep: false, value: void 0 };
|
|
1392
|
+
}
|
|
1393
|
+
if (value instanceof Date) {
|
|
1394
|
+
warnings.push({ kind: "coerced_date", key });
|
|
1395
|
+
const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
|
|
1396
|
+
return { keep: true, value: iso };
|
|
1397
|
+
}
|
|
1398
|
+
if (value instanceof Error) {
|
|
1399
|
+
warnings.push({ kind: "coerced_error", key });
|
|
1400
|
+
return {
|
|
1401
|
+
keep: true,
|
|
1402
|
+
value: {
|
|
1403
|
+
name: value.name,
|
|
1404
|
+
message: value.message,
|
|
1405
|
+
stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
if (value instanceof Map) {
|
|
1410
|
+
warnings.push({ kind: "coerced_map", key });
|
|
1411
|
+
const obj = {};
|
|
1412
|
+
for (const [k, v] of value.entries()) {
|
|
1413
|
+
const subKey = typeof k === "string" ? k : String(k);
|
|
1414
|
+
const result = visit(v, `${key}.${subKey}`, depth + 1);
|
|
1415
|
+
if (result.keep) obj[subKey] = result.value;
|
|
1416
|
+
}
|
|
1417
|
+
return { keep: true, value: obj };
|
|
1418
|
+
}
|
|
1419
|
+
if (value instanceof Set) {
|
|
1420
|
+
warnings.push({ kind: "coerced_set", key });
|
|
1421
|
+
const arr = [];
|
|
1422
|
+
let i = 0;
|
|
1423
|
+
for (const v of value.values()) {
|
|
1424
|
+
const result = visit(v, `${key}[${i}]`, depth + 1);
|
|
1425
|
+
if (result.keep) arr.push(result.value);
|
|
1426
|
+
i++;
|
|
1427
|
+
}
|
|
1428
|
+
return { keep: true, value: arr };
|
|
1429
|
+
}
|
|
1430
|
+
if (Array.isArray(value)) {
|
|
1431
|
+
if (seen.has(value)) {
|
|
1432
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1433
|
+
return { keep: true, value: "[circular]" };
|
|
1434
|
+
}
|
|
1435
|
+
seen.add(value);
|
|
1436
|
+
const out = [];
|
|
1437
|
+
for (let i = 0; i < value.length; i++) {
|
|
1438
|
+
const result = visit(value[i], `${key}[${i}]`, depth + 1);
|
|
1439
|
+
if (result.keep) out.push(result.value);
|
|
1440
|
+
}
|
|
1441
|
+
return { keep: true, value: out };
|
|
1442
|
+
}
|
|
1443
|
+
if (t === "object") {
|
|
1444
|
+
const obj = value;
|
|
1445
|
+
if (seen.has(obj)) {
|
|
1446
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1447
|
+
return { keep: true, value: "[circular]" };
|
|
1448
|
+
}
|
|
1449
|
+
seen.add(obj);
|
|
1450
|
+
const out = {};
|
|
1451
|
+
for (const k of Object.keys(obj)) {
|
|
1452
|
+
const result = visit(obj[k], `${key}.${k}`, depth + 1);
|
|
1453
|
+
if (result.keep) out[k] = result.value;
|
|
1454
|
+
}
|
|
1455
|
+
return { keep: true, value: out };
|
|
1456
|
+
}
|
|
1457
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1458
|
+
try {
|
|
1459
|
+
return { keep: true, value: String(value) };
|
|
1460
|
+
} catch {
|
|
1461
|
+
return { keep: false, value: void 0 };
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
const cleaned = {};
|
|
1465
|
+
for (const k of Object.keys(input)) {
|
|
1466
|
+
const result = visit(input[k], k, 0);
|
|
1467
|
+
if (result.keep) cleaned[k] = result.value;
|
|
1468
|
+
}
|
|
1469
|
+
const serialised = safeStringify(cleaned);
|
|
1470
|
+
if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
|
|
1471
|
+
warnings.push({ kind: "size_cap_exceeded", key: "*" });
|
|
1472
|
+
const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
|
|
1473
|
+
let currentSize = byteLength(serialised);
|
|
1474
|
+
for (const { k } of sizes) {
|
|
1475
|
+
if (currentSize <= maxBatchPropertyBytes) break;
|
|
1476
|
+
currentSize -= sizes.find((s) => s.k === k).size;
|
|
1477
|
+
delete cleaned[k];
|
|
1478
|
+
}
|
|
1479
|
+
cleaned.__truncated = true;
|
|
1480
|
+
}
|
|
1481
|
+
return { properties: cleaned, warnings };
|
|
1482
|
+
}
|
|
1483
|
+
function safeStringify(v) {
|
|
1484
|
+
try {
|
|
1485
|
+
return JSON.stringify(v) ?? null;
|
|
1486
|
+
} catch {
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
function byteLength(s) {
|
|
1491
|
+
if (typeof TextEncoder !== "undefined") {
|
|
1492
|
+
return new TextEncoder().encode(s).length;
|
|
1493
|
+
}
|
|
1494
|
+
return s.length * 4;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// src/super-properties.ts
|
|
1498
|
+
var KEY_SUPER = "super_props";
|
|
1499
|
+
var KEY_GROUPS = "groups";
|
|
1500
|
+
var SuperPropertyStore = class {
|
|
1501
|
+
constructor(storage, prefix) {
|
|
1502
|
+
this.storage = storage;
|
|
1503
|
+
this.prefix = prefix;
|
|
1504
|
+
this.superProps = {};
|
|
1505
|
+
this.groups = {};
|
|
1506
|
+
this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
|
|
1507
|
+
this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
|
|
1508
|
+
}
|
|
1509
|
+
// ---------- super properties ----------
|
|
1510
|
+
/**
|
|
1511
|
+
* Merge new keys into the super-property bag. Returns a snapshot of
|
|
1512
|
+
* the resulting bag. Values that are `null` are deleted (Mixpanel
|
|
1513
|
+
* semantics — explicit null = "stop tracking this key").
|
|
1514
|
+
*/
|
|
1515
|
+
register(props) {
|
|
1516
|
+
for (const [k, v] of Object.entries(props)) {
|
|
1517
|
+
if (v === null) {
|
|
1518
|
+
delete this.superProps[k];
|
|
1519
|
+
} else if (v !== void 0) {
|
|
1520
|
+
this.superProps[k] = v;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1524
|
+
return { ...this.superProps };
|
|
1525
|
+
}
|
|
1526
|
+
/** Remove a single super-property key. Idempotent. */
|
|
1527
|
+
unregister(key) {
|
|
1528
|
+
if (key in this.superProps) {
|
|
1529
|
+
delete this.superProps[key];
|
|
1530
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
/** Snapshot of the current super-property bag. */
|
|
1534
|
+
getSuperProperties() {
|
|
1535
|
+
return { ...this.superProps };
|
|
1536
|
+
}
|
|
1537
|
+
// ---------- groups ----------
|
|
1538
|
+
/**
|
|
1539
|
+
* Set a group membership. Passing `id: null` clears the membership
|
|
1540
|
+
* for that group type — the SDK stops attaching it to events.
|
|
1541
|
+
*/
|
|
1542
|
+
setGroup(type, id, traits) {
|
|
1543
|
+
if (id === null) {
|
|
1544
|
+
delete this.groups[type];
|
|
1545
|
+
} else {
|
|
1546
|
+
this.groups[type] = traits !== void 0 ? { id, traits } : { id };
|
|
1547
|
+
}
|
|
1548
|
+
writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Snapshot of the current groups map, keyed by group type. Returned
|
|
1552
|
+
* shape mirrors what the SDK attaches to every event as
|
|
1553
|
+
* `$groups.{type}`. The `traits` sub-object is the most-recent
|
|
1554
|
+
* traits payload passed to `setGroup` for that type; null when none.
|
|
1555
|
+
*/
|
|
1556
|
+
getGroups() {
|
|
1557
|
+
return JSON.parse(JSON.stringify(this.groups));
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* The flat `{ type: id }` projection used for event-attachment. Stable
|
|
1561
|
+
* for fast every-event merge — we don't want to JSON-clone on each
|
|
1562
|
+
* track() call.
|
|
1563
|
+
*/
|
|
1564
|
+
getGroupIds() {
|
|
1565
|
+
const out = {};
|
|
1566
|
+
for (const [type, info] of Object.entries(this.groups)) {
|
|
1567
|
+
out[type] = info.id;
|
|
1568
|
+
}
|
|
1569
|
+
return out;
|
|
1570
|
+
}
|
|
1571
|
+
/** Wipe both bags. Called by Crossdeck.reset() (logout). */
|
|
1572
|
+
clear() {
|
|
1573
|
+
this.superProps = {};
|
|
1574
|
+
this.groups = {};
|
|
1575
|
+
try {
|
|
1576
|
+
this.storage.removeItem(this.prefix + KEY_SUPER);
|
|
1577
|
+
} catch {
|
|
1578
|
+
}
|
|
1579
|
+
try {
|
|
1580
|
+
this.storage.removeItem(this.prefix + KEY_GROUPS);
|
|
1581
|
+
} catch {
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
};
|
|
1585
|
+
function readJson(storage, key) {
|
|
1586
|
+
let raw;
|
|
1587
|
+
try {
|
|
1588
|
+
raw = storage.getItem(key);
|
|
1589
|
+
} catch {
|
|
1590
|
+
return null;
|
|
1591
|
+
}
|
|
1592
|
+
if (!raw) return null;
|
|
1593
|
+
try {
|
|
1594
|
+
return JSON.parse(raw);
|
|
1595
|
+
} catch {
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
function writeJson(storage, key, value) {
|
|
1600
|
+
try {
|
|
1601
|
+
storage.setItem(key, JSON.stringify(value));
|
|
1602
|
+
} catch {
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// src/web-vitals.ts
|
|
1607
|
+
var WebVitalsTracker = class {
|
|
1608
|
+
constructor(cfg, report) {
|
|
1609
|
+
this.cfg = cfg;
|
|
1610
|
+
this.report = report;
|
|
1611
|
+
this.observers = [];
|
|
1612
|
+
this.flushed = /* @__PURE__ */ new Set();
|
|
1613
|
+
this.cls = 0;
|
|
1614
|
+
this.clsEntries = [];
|
|
1615
|
+
this.inp = 0;
|
|
1616
|
+
this.cleanups = [];
|
|
1617
|
+
}
|
|
1618
|
+
install() {
|
|
1619
|
+
if (!this.cfg.enabled) return;
|
|
1620
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
1621
|
+
if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
|
|
1622
|
+
const doc = globalThis.document;
|
|
1623
|
+
try {
|
|
1624
|
+
const navObserver = new PerformanceObserver((list) => {
|
|
1625
|
+
for (const entry of list.getEntries()) {
|
|
1626
|
+
const e = entry;
|
|
1627
|
+
if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
|
|
1628
|
+
this.flushed.add("ttfb");
|
|
1629
|
+
this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
navObserver.observe({ type: "navigation", buffered: true });
|
|
1634
|
+
this.observers.push(navObserver);
|
|
1635
|
+
} catch {
|
|
1636
|
+
}
|
|
1637
|
+
try {
|
|
1638
|
+
const paintObserver = new PerformanceObserver((list) => {
|
|
1639
|
+
for (const entry of list.getEntries()) {
|
|
1640
|
+
if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
|
|
1641
|
+
this.flushed.add("fcp");
|
|
1642
|
+
this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
});
|
|
1646
|
+
paintObserver.observe({ type: "paint", buffered: true });
|
|
1647
|
+
this.observers.push(paintObserver);
|
|
1648
|
+
} catch {
|
|
1649
|
+
}
|
|
1650
|
+
let lcpValue = 0;
|
|
1651
|
+
try {
|
|
1652
|
+
const lcpObserver = new PerformanceObserver((list) => {
|
|
1653
|
+
const entries = list.getEntries();
|
|
1654
|
+
const last = entries[entries.length - 1];
|
|
1655
|
+
if (last) lcpValue = last.startTime;
|
|
1656
|
+
});
|
|
1657
|
+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
|
1658
|
+
this.observers.push(lcpObserver);
|
|
1659
|
+
} catch {
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
const clsObserver = new PerformanceObserver((list) => {
|
|
1663
|
+
for (const entry of list.getEntries()) {
|
|
1664
|
+
const e = entry;
|
|
1665
|
+
if (typeof e.value === "number" && !e.hadRecentInput) {
|
|
1666
|
+
this.cls += e.value;
|
|
1667
|
+
this.clsEntries.push(entry);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
clsObserver.observe({ type: "layout-shift", buffered: true });
|
|
1672
|
+
this.observers.push(clsObserver);
|
|
1673
|
+
} catch {
|
|
1674
|
+
}
|
|
1675
|
+
try {
|
|
1676
|
+
const eventObserver = new PerformanceObserver((list) => {
|
|
1677
|
+
for (const entry of list.getEntries()) {
|
|
1678
|
+
const e = entry;
|
|
1679
|
+
if (e.interactionId && e.duration > this.inp) {
|
|
1680
|
+
this.inp = e.duration;
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
try {
|
|
1685
|
+
eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
1686
|
+
} catch {
|
|
1687
|
+
eventObserver.observe({ type: "first-input", buffered: true });
|
|
1688
|
+
}
|
|
1689
|
+
this.observers.push(eventObserver);
|
|
1690
|
+
} catch {
|
|
1691
|
+
}
|
|
1692
|
+
const flush = () => {
|
|
1693
|
+
if (lcpValue > 0 && !this.flushed.has("lcp")) {
|
|
1694
|
+
this.flushed.add("lcp");
|
|
1695
|
+
this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
|
|
1696
|
+
}
|
|
1697
|
+
if (this.cls > 0 && !this.flushed.has("cls")) {
|
|
1698
|
+
this.flushed.add("cls");
|
|
1699
|
+
this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
|
|
1700
|
+
}
|
|
1701
|
+
if (this.inp > 0 && !this.flushed.has("inp")) {
|
|
1702
|
+
this.flushed.add("inp");
|
|
1703
|
+
this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
|
|
1704
|
+
}
|
|
1705
|
+
};
|
|
1706
|
+
const onHidden = () => {
|
|
1707
|
+
if (doc.visibilityState === "hidden") flush();
|
|
1708
|
+
};
|
|
1709
|
+
doc.addEventListener("visibilitychange", onHidden);
|
|
1710
|
+
globalThis.window.addEventListener("pagehide", flush);
|
|
1711
|
+
this.cleanups.push(() => {
|
|
1712
|
+
doc.removeEventListener("visibilitychange", onHidden);
|
|
1713
|
+
globalThis.window.removeEventListener("pagehide", flush);
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
uninstall() {
|
|
1717
|
+
for (const o of this.observers) {
|
|
1718
|
+
try {
|
|
1719
|
+
o.disconnect();
|
|
1720
|
+
} catch {
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
this.observers = [];
|
|
1724
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1725
|
+
try {
|
|
1726
|
+
fn();
|
|
1727
|
+
} catch {
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
// src/consent.ts
|
|
1734
|
+
var ALL_GRANTED = {
|
|
1735
|
+
analytics: true,
|
|
1736
|
+
marketing: true,
|
|
1737
|
+
errors: true
|
|
1738
|
+
};
|
|
1739
|
+
var ConsentManager = class {
|
|
1740
|
+
constructor(options) {
|
|
1741
|
+
this.state = { ...ALL_GRANTED };
|
|
1742
|
+
this.dntDenied = false;
|
|
1743
|
+
if (options?.respectDnt && this.detectDnt()) {
|
|
1744
|
+
this.dntDenied = true;
|
|
1745
|
+
this.state = { analytics: false, marketing: false, errors: false };
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Merge new dimensions onto the current state. Returns the resulting
|
|
1750
|
+
* snapshot. DNT-derived denies cannot be flipped back on by a `set`
|
|
1751
|
+
* call — once the browser says "don't track", we don't track even if
|
|
1752
|
+
* the developer code disagrees. That's the contract.
|
|
1753
|
+
*/
|
|
1754
|
+
set(partial) {
|
|
1755
|
+
if (this.dntDenied) return { ...this.state };
|
|
1756
|
+
for (const k of Object.keys(partial)) {
|
|
1757
|
+
const v = partial[k];
|
|
1758
|
+
if (typeof v === "boolean") this.state[k] = v;
|
|
1759
|
+
}
|
|
1760
|
+
return { ...this.state };
|
|
1761
|
+
}
|
|
1762
|
+
/** Snapshot of the current state. */
|
|
1763
|
+
get() {
|
|
1764
|
+
return { ...this.state };
|
|
1765
|
+
}
|
|
1766
|
+
/** Convenience getters for hot paths. */
|
|
1767
|
+
get analytics() {
|
|
1768
|
+
return this.state.analytics;
|
|
1769
|
+
}
|
|
1770
|
+
get marketing() {
|
|
1771
|
+
return this.state.marketing;
|
|
1772
|
+
}
|
|
1773
|
+
get errors() {
|
|
1774
|
+
return this.state.errors;
|
|
1775
|
+
}
|
|
1776
|
+
/** True iff the constructor detected and applied DNT. */
|
|
1777
|
+
get isDntDenied() {
|
|
1778
|
+
return this.dntDenied;
|
|
1779
|
+
}
|
|
1780
|
+
detectDnt() {
|
|
1781
|
+
try {
|
|
1782
|
+
const nav = globalThis.navigator;
|
|
1783
|
+
if (!nav) return false;
|
|
1784
|
+
const sources = [
|
|
1785
|
+
nav.doNotTrack,
|
|
1786
|
+
nav.msDoNotTrack,
|
|
1787
|
+
globalThis.doNotTrack
|
|
1788
|
+
];
|
|
1789
|
+
return sources.some((v) => v === "1" || v === "yes");
|
|
1790
|
+
} catch {
|
|
1791
|
+
return false;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
1796
|
+
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
1797
|
+
var REPLACEMENT_EMAIL = "[email]";
|
|
1798
|
+
var REPLACEMENT_CARD = "[card]";
|
|
1799
|
+
function scrubPii(value) {
|
|
1800
|
+
if (!value) return value;
|
|
1801
|
+
let out = value;
|
|
1802
|
+
if (EMAIL_PATTERN.test(out)) {
|
|
1803
|
+
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
1804
|
+
}
|
|
1805
|
+
EMAIL_PATTERN.lastIndex = 0;
|
|
1806
|
+
if (CARD_PATTERN.test(out)) {
|
|
1807
|
+
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1808
|
+
}
|
|
1809
|
+
CARD_PATTERN.lastIndex = 0;
|
|
1810
|
+
return out;
|
|
1811
|
+
}
|
|
1812
|
+
function scrubPiiFromProperties(properties) {
|
|
1813
|
+
const out = {};
|
|
1814
|
+
for (const k of Object.keys(properties)) {
|
|
1815
|
+
const v = properties[k];
|
|
1816
|
+
if (typeof v === "string") {
|
|
1817
|
+
out[k] = scrubPii(v);
|
|
1818
|
+
} else if (Array.isArray(v)) {
|
|
1819
|
+
out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
|
|
1820
|
+
} else {
|
|
1821
|
+
out[k] = v;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return out;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
870
1827
|
// src/crossdeck.ts
|
|
871
1828
|
var CrossdeckClient = class {
|
|
872
1829
|
constructor() {
|
|
@@ -911,6 +1868,7 @@ var CrossdeckClient = class {
|
|
|
911
1868
|
message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
|
|
912
1869
|
});
|
|
913
1870
|
}
|
|
1871
|
+
const localDevMode = isLocalHostname();
|
|
914
1872
|
const storage = options.storage ?? detectDefaultStorage();
|
|
915
1873
|
const persistIdentity = options.persistIdentity ?? true;
|
|
916
1874
|
const autoTrack = resolveAutoTrack(options.autoTrack);
|
|
@@ -937,14 +1895,31 @@ var CrossdeckClient = class {
|
|
|
937
1895
|
const http = new HttpClient({
|
|
938
1896
|
publicKey: opts.publicKey,
|
|
939
1897
|
baseUrl: opts.baseUrl,
|
|
940
|
-
sdkVersion: opts.sdkVersion
|
|
1898
|
+
sdkVersion: opts.sdkVersion,
|
|
1899
|
+
// Localhost auto-route: HttpClient short-circuits every request
|
|
1900
|
+
// to a successful no-op response when localDevMode is set.
|
|
1901
|
+
// SDK methods continue to work locally; nothing reaches the
|
|
1902
|
+
// server.
|
|
1903
|
+
localDevMode
|
|
941
1904
|
});
|
|
1905
|
+
if (localDevMode) {
|
|
1906
|
+
console.log(
|
|
1907
|
+
"[crossdeck] Localhost detected \u2014 running in dev mode (no network calls). Set publicKey: 'cd_pub_test_\u2026' and deploy to a real domain to test against the Crossdeck Sandbox."
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
942
1910
|
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
943
1911
|
const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
|
|
944
1912
|
typeof globalThis.document !== "undefined";
|
|
945
1913
|
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
946
1914
|
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
947
1915
|
const entitlements = new EntitlementCache();
|
|
1916
|
+
const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
|
|
1917
|
+
if (persistentEvents) {
|
|
1918
|
+
debug.emit(
|
|
1919
|
+
"sdk.queue_restored",
|
|
1920
|
+
"Restored persisted event queue from a prior session."
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
948
1923
|
const events = new EventQueue({
|
|
949
1924
|
http,
|
|
950
1925
|
batchSize: opts.eventFlushBatchSize,
|
|
@@ -954,26 +1929,51 @@ var CrossdeckClient = class {
|
|
|
954
1929
|
environment: opts.environment,
|
|
955
1930
|
sdk: { name: SDK_NAME, version: opts.sdkVersion }
|
|
956
1931
|
}),
|
|
1932
|
+
persistentStore: persistentEvents ?? void 0,
|
|
957
1933
|
onFirstFlushSuccess: () => {
|
|
958
1934
|
debug.emit(
|
|
959
1935
|
"sdk.first_event_sent",
|
|
960
1936
|
"First telemetry event received. View it in Live Events.",
|
|
961
1937
|
{ appId: opts.appId, environment: opts.environment }
|
|
962
1938
|
);
|
|
1939
|
+
},
|
|
1940
|
+
onRetryScheduled: (info) => {
|
|
1941
|
+
debug.emit(
|
|
1942
|
+
"sdk.flush_retry_scheduled",
|
|
1943
|
+
`Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
|
|
1944
|
+
{ ...info }
|
|
1945
|
+
);
|
|
963
1946
|
}
|
|
964
1947
|
});
|
|
965
1948
|
const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
|
|
1949
|
+
const superProps = new SuperPropertyStore(
|
|
1950
|
+
persistIdentity ? effectiveStorage : new MemoryStorage(),
|
|
1951
|
+
opts.storagePrefix
|
|
1952
|
+
);
|
|
1953
|
+
const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
|
|
1954
|
+
if (consent.isDntDenied) {
|
|
1955
|
+
debug.emit(
|
|
1956
|
+
"sdk.consent_dnt_applied",
|
|
1957
|
+
"Do Not Track detected \u2014 all tracking dimensions denied at init."
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
966
1960
|
this.state = {
|
|
967
1961
|
http,
|
|
968
1962
|
identity,
|
|
969
1963
|
entitlements,
|
|
970
1964
|
events,
|
|
971
1965
|
autoTracker: null,
|
|
1966
|
+
webVitals: null,
|
|
1967
|
+
superProps,
|
|
1968
|
+
consent,
|
|
1969
|
+
scrubPii: options.scrubPii !== false,
|
|
972
1970
|
deviceInfo,
|
|
973
1971
|
options: opts,
|
|
974
1972
|
debug,
|
|
975
1973
|
developerUserId: null,
|
|
976
|
-
uninstallUnloadFlush: null
|
|
1974
|
+
uninstallUnloadFlush: null,
|
|
1975
|
+
lastServerTime: null,
|
|
1976
|
+
lastClientTime: null
|
|
977
1977
|
};
|
|
978
1978
|
debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
|
|
979
1979
|
appId: opts.appId,
|
|
@@ -988,10 +1988,18 @@ var CrossdeckClient = class {
|
|
|
988
1988
|
this.state.autoTracker = tracker;
|
|
989
1989
|
tracker.install();
|
|
990
1990
|
}
|
|
1991
|
+
if (autoTrack.webVitals) {
|
|
1992
|
+
const vitals = new WebVitalsTracker(
|
|
1993
|
+
{ enabled: true },
|
|
1994
|
+
(name, properties) => this.track(name, properties)
|
|
1995
|
+
);
|
|
1996
|
+
this.state.webVitals = vitals;
|
|
1997
|
+
vitals.install();
|
|
1998
|
+
}
|
|
991
1999
|
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
992
2000
|
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
993
2001
|
});
|
|
994
|
-
if (opts.autoHeartbeat) {
|
|
2002
|
+
if (opts.autoHeartbeat && !localDevMode) {
|
|
995
2003
|
void this.heartbeat().catch(() => void 0);
|
|
996
2004
|
}
|
|
997
2005
|
}
|
|
@@ -1011,8 +2019,19 @@ var CrossdeckClient = class {
|
|
|
1011
2019
|
/**
|
|
1012
2020
|
* Link the anonymous device to a developer-supplied user ID. Cache
|
|
1013
2021
|
* the resolved Crossdeck customer for follow-up calls.
|
|
2022
|
+
*
|
|
2023
|
+
* v0.9.0+ accepts an optional `traits` bag — profile data (name,
|
|
2024
|
+
* plan, signupDate, role) persisted on the Crossdeck customer record
|
|
2025
|
+
* and queryable from dashboards. Traits are sanitised through the
|
|
2026
|
+
* same validator that gates `track()` properties, so a `{ avatar:
|
|
2027
|
+
* <File>, onSave: () => {} }` payload can't corrupt the alias call.
|
|
2028
|
+
*
|
|
2029
|
+
* Crossdeck.identify("user_847", {
|
|
2030
|
+
* email: "wes@pinet.co.za",
|
|
2031
|
+
* traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
|
|
2032
|
+
* });
|
|
1014
2033
|
*/
|
|
1015
|
-
async identify(userId,
|
|
2034
|
+
async identify(userId, options) {
|
|
1016
2035
|
const s = this.requireStarted();
|
|
1017
2036
|
if (!userId) {
|
|
1018
2037
|
throw new CrossdeckError({
|
|
@@ -1021,13 +2040,163 @@ var CrossdeckClient = class {
|
|
|
1021
2040
|
message: "identify(userId) requires a non-empty userId."
|
|
1022
2041
|
});
|
|
1023
2042
|
}
|
|
2043
|
+
if (!s.consent.analytics) {
|
|
2044
|
+
s.debug.emit(
|
|
2045
|
+
"sdk.consent_denied",
|
|
2046
|
+
`identify() skipped \u2014 consent denied for analytics.`
|
|
2047
|
+
);
|
|
2048
|
+
return {
|
|
2049
|
+
object: "alias_result",
|
|
2050
|
+
crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
|
|
2051
|
+
linked: [],
|
|
2052
|
+
mergePending: false,
|
|
2053
|
+
env: s.options.environment
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
|
|
2057
|
+
const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
|
|
2058
|
+
if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
|
|
2059
|
+
for (const w of traitsValidation.warnings) {
|
|
2060
|
+
s.debug.emit(
|
|
2061
|
+
"sdk.property_coerced",
|
|
2062
|
+
`identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2063
|
+
{ key: w.key, kind: w.kind }
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
const body = {
|
|
2068
|
+
userId,
|
|
2069
|
+
anonymousId: s.identity.anonymousId
|
|
2070
|
+
};
|
|
2071
|
+
if (options?.email) body.email = options.email;
|
|
2072
|
+
if (traits) body.traits = traits;
|
|
1024
2073
|
const result = await s.http.request("POST", "/identity/alias", {
|
|
1025
|
-
body
|
|
2074
|
+
body
|
|
1026
2075
|
});
|
|
1027
2076
|
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
1028
2077
|
s.developerUserId = userId;
|
|
1029
2078
|
return result;
|
|
1030
2079
|
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Register super-properties — Mixpanel pattern. Once set, every
|
|
2082
|
+
* subsequent event of THIS SDK instance carries these keys on its
|
|
2083
|
+
* properties bag automatically.
|
|
2084
|
+
*
|
|
2085
|
+
* Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
|
|
2086
|
+
* Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
|
|
2087
|
+
*
|
|
2088
|
+
* Values that are `null` are deleted (the explicit "stop tracking
|
|
2089
|
+
* this key" idiom). Returns the resulting bag.
|
|
2090
|
+
*
|
|
2091
|
+
* Sanitised through `validateEventProperties` so a `{ avatar: File }`
|
|
2092
|
+
* payload can't poison the queue at flush time.
|
|
2093
|
+
*/
|
|
2094
|
+
register(properties) {
|
|
2095
|
+
const s = this.requireStarted();
|
|
2096
|
+
const validation = validateEventProperties(properties);
|
|
2097
|
+
return s.superProps.register(validation.properties);
|
|
2098
|
+
}
|
|
2099
|
+
/** Remove a single super-property key. Idempotent. */
|
|
2100
|
+
unregister(key) {
|
|
2101
|
+
const s = this.requireStarted();
|
|
2102
|
+
s.superProps.unregister(key);
|
|
2103
|
+
}
|
|
2104
|
+
/** Snapshot of the current super-property bag. */
|
|
2105
|
+
getSuperProperties() {
|
|
2106
|
+
if (!this.state) return {};
|
|
2107
|
+
return this.state.superProps.getSuperProperties();
|
|
2108
|
+
}
|
|
2109
|
+
/**
|
|
2110
|
+
* Associate the current user with a group (org, team, account, etc.).
|
|
2111
|
+
* Mixpanel / Segment "Group Analytics" pattern.
|
|
2112
|
+
*
|
|
2113
|
+
* Crossdeck.group("org", "acme_inc");
|
|
2114
|
+
* Crossdeck.group("team", "design", { headcount: 12 });
|
|
2115
|
+
*
|
|
2116
|
+
* Once set, every subsequent event carries `$groups.<type>: id` on
|
|
2117
|
+
* its properties bag, enabling B2B dashboards ("how is Acme using
|
|
2118
|
+
* the product"). Pass `id: null` to clear a group membership.
|
|
2119
|
+
*/
|
|
2120
|
+
group(type, id, traits) {
|
|
2121
|
+
const s = this.requireStarted();
|
|
2122
|
+
if (!type) {
|
|
2123
|
+
throw new CrossdeckError({
|
|
2124
|
+
type: "invalid_request_error",
|
|
2125
|
+
code: "missing_group_type",
|
|
2126
|
+
message: "group(type, id) requires a non-empty type."
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
|
|
2130
|
+
s.superProps.setGroup(type, id, sanitisedTraits);
|
|
2131
|
+
}
|
|
2132
|
+
/** Snapshot of the current groups map keyed by type. */
|
|
2133
|
+
getGroups() {
|
|
2134
|
+
if (!this.state) return {};
|
|
2135
|
+
return this.state.superProps.getGroups();
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Update consent state. Three independent dimensions:
|
|
2139
|
+
*
|
|
2140
|
+
* analytics — track() + identify() + auto-emissions
|
|
2141
|
+
* marketing — paid-traffic click IDs + referrer URL on events
|
|
2142
|
+
* errors — Web Vitals + (future) error reporting
|
|
2143
|
+
*
|
|
2144
|
+
* Each defaults to `true` (granted). Pass partial state — only the
|
|
2145
|
+
* keys you provide are changed.
|
|
2146
|
+
*
|
|
2147
|
+
* Crossdeck.consent({ analytics: false });
|
|
2148
|
+
* Crossdeck.consent({ marketing: true, errors: true });
|
|
2149
|
+
*
|
|
2150
|
+
* DNT-derived denies cannot be flipped back on; if the browser said
|
|
2151
|
+
* "don't track" we don't track even if the developer code disagrees.
|
|
2152
|
+
*/
|
|
2153
|
+
consent(state) {
|
|
2154
|
+
const s = this.requireStarted();
|
|
2155
|
+
const next = s.consent.set(state);
|
|
2156
|
+
s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
|
|
2157
|
+
return next;
|
|
2158
|
+
}
|
|
2159
|
+
/** Snapshot of the current consent state. */
|
|
2160
|
+
consentStatus() {
|
|
2161
|
+
if (!this.state) {
|
|
2162
|
+
return { analytics: true, marketing: true, errors: true };
|
|
2163
|
+
}
|
|
2164
|
+
return this.state.consent.get();
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* GDPR/CCPA "right to be forgotten" — calls the backend's
|
|
2168
|
+
* /v1/identity/forget endpoint to schedule a server-side deletion of
|
|
2169
|
+
* the customer's events and profile, then wipes all local state
|
|
2170
|
+
* (identity, entitlements, queue, super-props, persistent stores).
|
|
2171
|
+
*
|
|
2172
|
+
* Idempotent. Safe to call when no identity has been established
|
|
2173
|
+
* (it just wipes the empty local state).
|
|
2174
|
+
*
|
|
2175
|
+
* After forget() resolves, the SDK is in the same shape as if the
|
|
2176
|
+
* developer had called `Crossdeck.reset()` — a fresh anonymousId is
|
|
2177
|
+
* minted and the next session is a brand new identity-graph entry.
|
|
2178
|
+
*/
|
|
2179
|
+
async forget() {
|
|
2180
|
+
const s = this.requireStarted();
|
|
2181
|
+
const identityQuery = this.identityQueryParams();
|
|
2182
|
+
try {
|
|
2183
|
+
await s.http.request("POST", "/identity/forget", {
|
|
2184
|
+
body: {
|
|
2185
|
+
// Send every identity hint we hold; the server resolves the
|
|
2186
|
+
// canonical customer record and queues deletion. Missing
|
|
2187
|
+
// endpoint (older backend) gracefully degrades — local state
|
|
2188
|
+
// still wipes via the reset() call below.
|
|
2189
|
+
...identityQuery
|
|
2190
|
+
}
|
|
2191
|
+
});
|
|
2192
|
+
} catch (err) {
|
|
2193
|
+
s.debug.emit(
|
|
2194
|
+
"sdk.consent_denied",
|
|
2195
|
+
`forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
this.reset();
|
|
2199
|
+
}
|
|
1031
2200
|
/**
|
|
1032
2201
|
* Read the current customer's active entitlements from the server.
|
|
1033
2202
|
* Updates the local cache so subsequent isEntitled() calls answer
|
|
@@ -1105,6 +2274,17 @@ var CrossdeckClient = class {
|
|
|
1105
2274
|
message: "track(name) requires a non-empty name."
|
|
1106
2275
|
});
|
|
1107
2276
|
}
|
|
2277
|
+
const isWebVital = name.startsWith("webvitals.");
|
|
2278
|
+
const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
|
|
2279
|
+
if (!consentGateOk) {
|
|
2280
|
+
if (s.debug.enabled) {
|
|
2281
|
+
s.debug.emit(
|
|
2282
|
+
"sdk.consent_denied",
|
|
2283
|
+
`Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
|
|
2284
|
+
);
|
|
2285
|
+
}
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
1108
2288
|
if (s.debug.enabled && properties) {
|
|
1109
2289
|
const flagged = findSensitivePropertyKeys(properties);
|
|
1110
2290
|
if (flagged.length > 0) {
|
|
@@ -1121,9 +2301,21 @@ var CrossdeckClient = class {
|
|
|
1121
2301
|
"Using anonymous user until identify(userId) is called."
|
|
1122
2302
|
);
|
|
1123
2303
|
}
|
|
2304
|
+
const validation = validateEventProperties(properties);
|
|
2305
|
+
if (s.debug.enabled && validation.warnings.length > 0) {
|
|
2306
|
+
for (const w of validation.warnings) {
|
|
2307
|
+
s.debug.emit(
|
|
2308
|
+
"sdk.property_coerced",
|
|
2309
|
+
`Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2310
|
+
{ eventName: name, key: w.key, kind: w.kind }
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
1124
2314
|
const enriched = { ...s.deviceInfo };
|
|
1125
2315
|
const sessionId = s.autoTracker?.currentSessionId;
|
|
1126
2316
|
if (sessionId) enriched.sessionId = sessionId;
|
|
2317
|
+
const pageviewId = s.autoTracker?.currentPageviewId;
|
|
2318
|
+
if (pageviewId) enriched.pageviewId = pageviewId;
|
|
1127
2319
|
const acquisition = s.autoTracker?.currentAcquisition;
|
|
1128
2320
|
if (acquisition) {
|
|
1129
2321
|
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
@@ -1131,14 +2323,31 @@ var CrossdeckClient = class {
|
|
|
1131
2323
|
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
1132
2324
|
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
1133
2325
|
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
1134
|
-
if (acquisition.referrer) enriched.referrer = acquisition.referrer;
|
|
2326
|
+
if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
|
|
2327
|
+
if (s.consent.marketing) {
|
|
2328
|
+
if (acquisition.gclid) enriched.gclid = acquisition.gclid;
|
|
2329
|
+
if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
|
|
2330
|
+
if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
|
|
2331
|
+
if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
|
|
2332
|
+
if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
|
|
2333
|
+
if (acquisition.twclid) enriched.twclid = acquisition.twclid;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
const supers = s.superProps.getSuperProperties();
|
|
2337
|
+
for (const k of Object.keys(supers)) {
|
|
2338
|
+
if (!(k in enriched)) enriched[k] = supers[k];
|
|
2339
|
+
}
|
|
2340
|
+
const groupIds = s.superProps.getGroupIds();
|
|
2341
|
+
if (Object.keys(groupIds).length > 0) {
|
|
2342
|
+
enriched.$groups = groupIds;
|
|
1135
2343
|
}
|
|
1136
|
-
|
|
2344
|
+
Object.assign(enriched, validation.properties);
|
|
2345
|
+
const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
|
|
1137
2346
|
const event = {
|
|
1138
2347
|
eventId: this.mintEventId(),
|
|
1139
2348
|
name,
|
|
1140
2349
|
timestamp: Date.now(),
|
|
1141
|
-
properties:
|
|
2350
|
+
properties: finalProperties
|
|
1142
2351
|
};
|
|
1143
2352
|
Object.assign(event, this.identityHintForEvent());
|
|
1144
2353
|
s.events.enqueue(event);
|
|
@@ -1216,7 +2425,12 @@ var CrossdeckClient = class {
|
|
|
1216
2425
|
*/
|
|
1217
2426
|
async heartbeat() {
|
|
1218
2427
|
const s = this.requireStarted();
|
|
1219
|
-
|
|
2428
|
+
const result = await s.http.request("GET", "/sdk/heartbeat");
|
|
2429
|
+
if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
|
|
2430
|
+
s.lastServerTime = result.serverTime;
|
|
2431
|
+
s.lastClientTime = Date.now();
|
|
2432
|
+
}
|
|
2433
|
+
return result;
|
|
1220
2434
|
}
|
|
1221
2435
|
/**
|
|
1222
2436
|
* Wipe persisted identity + entitlement cache. Use on logout. The
|
|
@@ -1225,10 +2439,17 @@ var CrossdeckClient = class {
|
|
|
1225
2439
|
*/
|
|
1226
2440
|
reset() {
|
|
1227
2441
|
if (!this.state) return;
|
|
2442
|
+
if (this.state.developerUserId) {
|
|
2443
|
+
try {
|
|
2444
|
+
this.track("user.signed_out", { auto: true });
|
|
2445
|
+
} catch {
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
1228
2448
|
this.state.autoTracker?.uninstall();
|
|
1229
2449
|
this.state.identity.reset();
|
|
1230
2450
|
this.state.entitlements.clear();
|
|
1231
2451
|
this.state.events.reset();
|
|
2452
|
+
this.state.superProps.clear();
|
|
1232
2453
|
this.state.developerUserId = null;
|
|
1233
2454
|
if (this.state.autoTracker) {
|
|
1234
2455
|
const tracker = new AutoTracker(
|
|
@@ -1256,17 +2477,21 @@ var CrossdeckClient = class {
|
|
|
1256
2477
|
developerUserId: null,
|
|
1257
2478
|
sdkVersion: null,
|
|
1258
2479
|
baseUrl: null,
|
|
1259
|
-
|
|
2480
|
+
clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
|
|
2481
|
+
entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
|
|
1260
2482
|
events: {
|
|
1261
2483
|
buffered: 0,
|
|
1262
2484
|
dropped: 0,
|
|
1263
2485
|
inFlight: 0,
|
|
1264
2486
|
lastFlushAt: 0,
|
|
1265
|
-
lastError: null
|
|
2487
|
+
lastError: null,
|
|
2488
|
+
consecutiveFailures: 0,
|
|
2489
|
+
nextRetryAt: null
|
|
1266
2490
|
}
|
|
1267
2491
|
};
|
|
1268
2492
|
}
|
|
1269
2493
|
const s = this.state;
|
|
2494
|
+
const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
|
|
1270
2495
|
return {
|
|
1271
2496
|
started: true,
|
|
1272
2497
|
anonymousId: s.identity.anonymousId,
|
|
@@ -1274,9 +2499,15 @@ var CrossdeckClient = class {
|
|
|
1274
2499
|
developerUserId: s.developerUserId,
|
|
1275
2500
|
sdkVersion: s.options.sdkVersion,
|
|
1276
2501
|
baseUrl: s.options.baseUrl,
|
|
2502
|
+
clock: {
|
|
2503
|
+
lastServerTime: s.lastServerTime,
|
|
2504
|
+
lastClientTime: s.lastClientTime,
|
|
2505
|
+
skewMs
|
|
2506
|
+
},
|
|
1277
2507
|
entitlements: {
|
|
1278
2508
|
count: s.entitlements.list().length,
|
|
1279
|
-
lastUpdated: s.entitlements.freshness
|
|
2509
|
+
lastUpdated: s.entitlements.freshness,
|
|
2510
|
+
listenerErrors: s.entitlements.listenerErrors
|
|
1280
2511
|
},
|
|
1281
2512
|
events: s.events.getStats()
|
|
1282
2513
|
};
|
|
@@ -1305,14 +2536,30 @@ var CrossdeckClient = class {
|
|
|
1305
2536
|
if (s.developerUserId) return { userId: s.developerUserId };
|
|
1306
2537
|
return { anonymousId: s.identity.anonymousId };
|
|
1307
2538
|
}
|
|
1308
|
-
/**
|
|
2539
|
+
/**
|
|
2540
|
+
* Embed every known identity axis on the event. Earlier this returned
|
|
2541
|
+
* just the highest-priority hint (cdcust → developerUserId → anonymousId)
|
|
2542
|
+
* to keep payloads small, but that leaked into analytics: once a user
|
|
2543
|
+
* was logged in, every subsequent page.viewed shipped without
|
|
2544
|
+
* anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
|
|
2545
|
+
* counted 0 visitors for the entire authenticated app.
|
|
2546
|
+
*
|
|
2547
|
+
* Bank-grade rule: the server is the single source of truth on
|
|
2548
|
+
* dedup. Send everything we know; let CH count by whichever axis
|
|
2549
|
+
* matches the question. Each field is at most 32 bytes — sending
|
|
2550
|
+
* three on every event costs ~80 bytes per request, which is
|
|
2551
|
+
* trivial compared to the analytics correctness it buys.
|
|
2552
|
+
*/
|
|
1309
2553
|
identityHintForEvent() {
|
|
1310
2554
|
const s = this.requireStarted();
|
|
2555
|
+
const hint = {
|
|
2556
|
+
anonymousId: s.identity.anonymousId
|
|
2557
|
+
};
|
|
2558
|
+
if (s.developerUserId) hint.developerUserId = s.developerUserId;
|
|
1311
2559
|
if (s.identity.crossdeckCustomerId) {
|
|
1312
|
-
|
|
2560
|
+
hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
|
|
1313
2561
|
}
|
|
1314
|
-
|
|
1315
|
-
return { anonymousId: s.identity.anonymousId };
|
|
2562
|
+
return hint;
|
|
1316
2563
|
}
|
|
1317
2564
|
mintEventId() {
|
|
1318
2565
|
const ts = Date.now().toString(36);
|
|
@@ -1325,9 +2572,28 @@ function inferEnvFromKey(publicKey) {
|
|
|
1325
2572
|
if (publicKey.startsWith("cd_pub_live_")) return "production";
|
|
1326
2573
|
return null;
|
|
1327
2574
|
}
|
|
2575
|
+
function isLocalHostname() {
|
|
2576
|
+
const w = globalThis.window;
|
|
2577
|
+
if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
|
|
2578
|
+
const hostname = w?.location?.hostname;
|
|
2579
|
+
if (!hostname) return false;
|
|
2580
|
+
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
2581
|
+
if (hostname === "::1" || hostname === "[::1]") return true;
|
|
2582
|
+
if (hostname.endsWith(".local")) return true;
|
|
2583
|
+
if (/^10\./.test(hostname)) return true;
|
|
2584
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
2585
|
+
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
|
|
2586
|
+
return false;
|
|
2587
|
+
}
|
|
1328
2588
|
function resolveAutoTrack(input) {
|
|
1329
2589
|
if (input === false) {
|
|
1330
|
-
return {
|
|
2590
|
+
return {
|
|
2591
|
+
sessions: false,
|
|
2592
|
+
pageViews: false,
|
|
2593
|
+
deviceInfo: false,
|
|
2594
|
+
clicks: false,
|
|
2595
|
+
webVitals: false
|
|
2596
|
+
};
|
|
1331
2597
|
}
|
|
1332
2598
|
if (input === void 0 || input === true) {
|
|
1333
2599
|
return { ...DEFAULT_AUTO_TRACK };
|
|
@@ -1335,7 +2601,9 @@ function resolveAutoTrack(input) {
|
|
|
1335
2601
|
return {
|
|
1336
2602
|
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1337
2603
|
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1338
|
-
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
|
|
2604
|
+
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
2605
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
|
|
2606
|
+
webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
|
|
1339
2607
|
};
|
|
1340
2608
|
}
|
|
1341
2609
|
function installUnloadFlush(onUnload) {
|
|
@@ -1355,14 +2623,110 @@ function installUnloadFlush(onUnload) {
|
|
|
1355
2623
|
w.removeEventListener("beforeunload", onTerminal);
|
|
1356
2624
|
};
|
|
1357
2625
|
}
|
|
2626
|
+
|
|
2627
|
+
// src/error-codes.ts
|
|
2628
|
+
var CROSSDECK_ERROR_CODES = Object.freeze([
|
|
2629
|
+
// ----- Configuration -----
|
|
2630
|
+
{
|
|
2631
|
+
code: "invalid_public_key",
|
|
2632
|
+
type: "configuration_error",
|
|
2633
|
+
description: "The publishable key passed to Crossdeck.init() doesn't start with cd_pub_.",
|
|
2634
|
+
resolution: "Copy the key from your Crossdeck dashboard \u2192 API keys page.",
|
|
2635
|
+
retryable: false
|
|
2636
|
+
},
|
|
2637
|
+
{
|
|
2638
|
+
code: "missing_app_id",
|
|
2639
|
+
type: "configuration_error",
|
|
2640
|
+
description: "Crossdeck.init() was called without an appId.",
|
|
2641
|
+
resolution: "Add appId to your init options \u2014 find it in the dashboard's Apps page.",
|
|
2642
|
+
retryable: false
|
|
2643
|
+
},
|
|
2644
|
+
{
|
|
2645
|
+
code: "invalid_environment",
|
|
2646
|
+
type: "configuration_error",
|
|
2647
|
+
description: "Crossdeck.init() requires environment: 'production' | 'sandbox'.",
|
|
2648
|
+
resolution: 'Pass the literal string "production" or "sandbox" \u2014 no other values are accepted.',
|
|
2649
|
+
retryable: false
|
|
2650
|
+
},
|
|
2651
|
+
{
|
|
2652
|
+
code: "environment_mismatch",
|
|
2653
|
+
type: "configuration_error",
|
|
2654
|
+
description: "The publishable key's env prefix doesn't match the declared environment option.",
|
|
2655
|
+
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.",
|
|
2656
|
+
retryable: false
|
|
2657
|
+
},
|
|
2658
|
+
{
|
|
2659
|
+
code: "not_initialized",
|
|
2660
|
+
type: "configuration_error",
|
|
2661
|
+
description: "An SDK method was called before Crossdeck.init().",
|
|
2662
|
+
resolution: "Call Crossdeck.init({ appId, publicKey, environment }) once at app startup before any other method.",
|
|
2663
|
+
retryable: false
|
|
2664
|
+
},
|
|
2665
|
+
// ----- Identify / track / purchase argument validation -----
|
|
2666
|
+
{
|
|
2667
|
+
code: "missing_user_id",
|
|
2668
|
+
type: "invalid_request_error",
|
|
2669
|
+
description: "identify() was called with an empty userId.",
|
|
2670
|
+
resolution: "Pass a stable, non-empty user identifier from your auth layer \u2014 never a hardcoded placeholder.",
|
|
2671
|
+
retryable: false
|
|
2672
|
+
},
|
|
2673
|
+
{
|
|
2674
|
+
code: "missing_event_name",
|
|
2675
|
+
type: "invalid_request_error",
|
|
2676
|
+
description: "track() was called without an event name.",
|
|
2677
|
+
resolution: "Pass a non-empty string as the first argument.",
|
|
2678
|
+
retryable: false
|
|
2679
|
+
},
|
|
2680
|
+
{
|
|
2681
|
+
code: "missing_group_type",
|
|
2682
|
+
type: "invalid_request_error",
|
|
2683
|
+
description: "group() was called without a group type.",
|
|
2684
|
+
resolution: 'Pass a non-empty type (e.g. "org", "team") as the first argument.',
|
|
2685
|
+
retryable: false
|
|
2686
|
+
},
|
|
2687
|
+
{
|
|
2688
|
+
code: "missing_signed_transaction_info",
|
|
2689
|
+
type: "invalid_request_error",
|
|
2690
|
+
description: "syncPurchases() was called without StoreKit 2 signed transaction info.",
|
|
2691
|
+
resolution: "Pass the JWS string from Transaction.currentEntitlements / Transaction.updates.",
|
|
2692
|
+
retryable: false
|
|
2693
|
+
},
|
|
2694
|
+
// ----- Network / transport -----
|
|
2695
|
+
{
|
|
2696
|
+
code: "fetch_failed",
|
|
2697
|
+
type: "network_error",
|
|
2698
|
+
description: "The underlying fetch() call failed (typically a network outage or DNS issue).",
|
|
2699
|
+
resolution: "Check the user's network. The SDK will retry automatically with exponential backoff.",
|
|
2700
|
+
retryable: true
|
|
2701
|
+
},
|
|
2702
|
+
{
|
|
2703
|
+
code: "request_timeout",
|
|
2704
|
+
type: "network_error",
|
|
2705
|
+
description: "A request was aborted after the configured timeoutMs (default 15s).",
|
|
2706
|
+
resolution: "Check the user's connection. Increase timeoutMs in init options if the user is on a known-slow network.",
|
|
2707
|
+
retryable: true
|
|
2708
|
+
},
|
|
2709
|
+
{
|
|
2710
|
+
code: "invalid_json_response",
|
|
2711
|
+
type: "internal_error",
|
|
2712
|
+
description: "The server returned a 2xx with an unparseable body.",
|
|
2713
|
+
resolution: "Likely a transient backend bug. Retry; if it persists, contact support with the requestId.",
|
|
2714
|
+
retryable: true
|
|
2715
|
+
}
|
|
2716
|
+
]);
|
|
2717
|
+
function getErrorCode(code) {
|
|
2718
|
+
return CROSSDECK_ERROR_CODES.find((e) => e.code === code);
|
|
2719
|
+
}
|
|
1358
2720
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1359
2721
|
0 && (module.exports = {
|
|
2722
|
+
CROSSDECK_ERROR_CODES,
|
|
1360
2723
|
Crossdeck,
|
|
1361
2724
|
CrossdeckClient,
|
|
1362
2725
|
CrossdeckError,
|
|
1363
2726
|
DEFAULT_BASE_URL,
|
|
1364
2727
|
MemoryStorage,
|
|
1365
2728
|
SDK_NAME,
|
|
1366
|
-
SDK_VERSION
|
|
2729
|
+
SDK_VERSION,
|
|
2730
|
+
getErrorCode
|
|
1367
2731
|
});
|
|
1368
2732
|
//# sourceMappingURL=index.cjs.map
|