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