@cross-deck/web 0.7.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/dist/crossdeck.umd.min.js +2 -0
- package/dist/crossdeck.umd.min.js.map +1 -0
- package/dist/error-codes.json +91 -0
- package/dist/index.cjs +1135 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +258 -4
- package/dist/index.d.ts +258 -4
- package/dist/index.mjs +1132 -29
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +1035 -28
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +1035 -28
- package/dist/react.mjs.map +1 -1
- package/dist/vue.cjs +2675 -0
- package/dist/vue.cjs.map +1 -0
- package/dist/vue.d.mts +37 -0
- package/dist/vue.d.ts +37 -0
- package/dist/vue.mjs +2649 -0
- package/dist/vue.mjs.map +1 -0
- package/package.json +25 -6
package/dist/vue.mjs
ADDED
|
@@ -0,0 +1,2649 @@
|
|
|
1
|
+
// src/vue.ts
|
|
2
|
+
import { ref, onMounted, onScopeDispose } from "vue";
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
var CrossdeckError = class _CrossdeckError extends Error {
|
|
6
|
+
constructor(payload) {
|
|
7
|
+
super(payload.message);
|
|
8
|
+
this.name = "CrossdeckError";
|
|
9
|
+
this.type = payload.type;
|
|
10
|
+
this.code = payload.code;
|
|
11
|
+
this.requestId = payload.requestId;
|
|
12
|
+
this.status = payload.status;
|
|
13
|
+
this.retryAfterMs = payload.retryAfterMs;
|
|
14
|
+
Object.setPrototypeOf(this, _CrossdeckError.prototype);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
async function crossdeckErrorFromResponse(res) {
|
|
18
|
+
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
19
|
+
const retryAfterMs = parseRetryAfterHeader(res.headers.get("retry-after"));
|
|
20
|
+
let body;
|
|
21
|
+
try {
|
|
22
|
+
body = await res.json();
|
|
23
|
+
} catch {
|
|
24
|
+
body = null;
|
|
25
|
+
}
|
|
26
|
+
const envelope = body?.error;
|
|
27
|
+
if (envelope && typeof envelope.type === "string" && typeof envelope.code === "string") {
|
|
28
|
+
return new CrossdeckError({
|
|
29
|
+
type: envelope.type,
|
|
30
|
+
code: envelope.code,
|
|
31
|
+
message: envelope.message ?? `HTTP ${res.status}`,
|
|
32
|
+
requestId: envelope.request_id ?? requestId,
|
|
33
|
+
status: res.status,
|
|
34
|
+
retryAfterMs
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return new CrossdeckError({
|
|
38
|
+
type: typeMapForStatus(res.status),
|
|
39
|
+
code: `http_${res.status}`,
|
|
40
|
+
message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
|
|
41
|
+
requestId,
|
|
42
|
+
status: res.status,
|
|
43
|
+
retryAfterMs
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function parseRetryAfterHeader(value) {
|
|
47
|
+
if (!value) return void 0;
|
|
48
|
+
const trimmed = value.trim();
|
|
49
|
+
if (!trimmed) return void 0;
|
|
50
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
51
|
+
const secs = Number(trimmed);
|
|
52
|
+
if (!Number.isFinite(secs) || secs < 0) return void 0;
|
|
53
|
+
return Math.round(secs * 1e3);
|
|
54
|
+
}
|
|
55
|
+
if (!/[a-zA-Z,/:]/.test(trimmed)) return void 0;
|
|
56
|
+
const target = Date.parse(trimmed);
|
|
57
|
+
if (!Number.isFinite(target)) return void 0;
|
|
58
|
+
const delta = target - Date.now();
|
|
59
|
+
return delta > 0 ? delta : 0;
|
|
60
|
+
}
|
|
61
|
+
function typeMapForStatus(status) {
|
|
62
|
+
if (status === 401) return "authentication_error";
|
|
63
|
+
if (status === 403) return "permission_error";
|
|
64
|
+
if (status === 429) return "rate_limit_error";
|
|
65
|
+
if (status >= 400 && status < 500) return "invalid_request_error";
|
|
66
|
+
return "internal_error";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/http.ts
|
|
70
|
+
var SDK_NAME = "@cross-deck/web";
|
|
71
|
+
var SDK_VERSION = "0.10.0";
|
|
72
|
+
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
73
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
74
|
+
var HttpClient = class {
|
|
75
|
+
constructor(config) {
|
|
76
|
+
this.config = config;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Issue a request. `path` is relative to the configured baseUrl
|
|
80
|
+
* ("/entitlements", "/identity/alias", etc.).
|
|
81
|
+
*
|
|
82
|
+
* Throws CrossdeckError on:
|
|
83
|
+
* - Network failure (`type: "network_error"`)
|
|
84
|
+
* - Non-2xx response (typed from the body envelope)
|
|
85
|
+
* - JSON parse failure on a 2xx (treated as `internal_error`)
|
|
86
|
+
*/
|
|
87
|
+
async request(method, path, options = {}) {
|
|
88
|
+
if (this.config.localDevMode) {
|
|
89
|
+
return synthesizeLocalDevResponse(path);
|
|
90
|
+
}
|
|
91
|
+
const url = this.buildUrl(path, options.query);
|
|
92
|
+
const headers = {
|
|
93
|
+
Authorization: `Bearer ${this.config.publicKey}`,
|
|
94
|
+
"Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
|
|
95
|
+
Accept: "application/json"
|
|
96
|
+
};
|
|
97
|
+
if (options.idempotencyKey) {
|
|
98
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
99
|
+
}
|
|
100
|
+
let bodyInit;
|
|
101
|
+
if (options.body !== void 0) {
|
|
102
|
+
headers["Content-Type"] = "application/json";
|
|
103
|
+
bodyInit = JSON.stringify(options.body);
|
|
104
|
+
}
|
|
105
|
+
const effectiveTimeout = options.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
106
|
+
const controller = typeof AbortController !== "undefined" && effectiveTimeout > 0 ? new AbortController() : null;
|
|
107
|
+
let timeoutHandle = null;
|
|
108
|
+
if (controller && effectiveTimeout > 0) {
|
|
109
|
+
timeoutHandle = setTimeout(() => controller.abort(), effectiveTimeout);
|
|
110
|
+
}
|
|
111
|
+
let response;
|
|
112
|
+
try {
|
|
113
|
+
response = await fetch(url, {
|
|
114
|
+
method,
|
|
115
|
+
headers,
|
|
116
|
+
body: bodyInit,
|
|
117
|
+
keepalive: options.keepalive === true,
|
|
118
|
+
signal: controller?.signal
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const aborted = controller?.signal?.aborted === true;
|
|
122
|
+
throw new CrossdeckError({
|
|
123
|
+
type: "network_error",
|
|
124
|
+
code: aborted ? "request_timeout" : "fetch_failed",
|
|
125
|
+
message: aborted ? `Request to ${path} aborted after ${effectiveTimeout}ms` : err instanceof Error ? err.message : "fetch failed"
|
|
126
|
+
});
|
|
127
|
+
} finally {
|
|
128
|
+
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
|
|
129
|
+
}
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
throw await crossdeckErrorFromResponse(response);
|
|
132
|
+
}
|
|
133
|
+
if (response.status === 204) return void 0;
|
|
134
|
+
try {
|
|
135
|
+
return await response.json();
|
|
136
|
+
} catch (err) {
|
|
137
|
+
throw new CrossdeckError({
|
|
138
|
+
type: "internal_error",
|
|
139
|
+
code: "invalid_json_response",
|
|
140
|
+
message: "Server returned a 2xx with an unparseable body.",
|
|
141
|
+
requestId: response.headers.get("x-request-id") ?? void 0,
|
|
142
|
+
status: response.status
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Whether this client is in localhost dev-mode short-circuit. Used
|
|
148
|
+
* by other SDK pieces (event-queue) to skip network-bound work
|
|
149
|
+
* entirely rather than going through synthesizeLocalDevResponse.
|
|
150
|
+
*/
|
|
151
|
+
get isLocalDevMode() {
|
|
152
|
+
return this.config.localDevMode === true;
|
|
153
|
+
}
|
|
154
|
+
buildUrl(path, query) {
|
|
155
|
+
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
156
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
157
|
+
let url = base + cleanPath;
|
|
158
|
+
if (query) {
|
|
159
|
+
const params = new URLSearchParams();
|
|
160
|
+
for (const [k, v] of Object.entries(query)) {
|
|
161
|
+
if (typeof v === "string" && v.length > 0) params.append(k, v);
|
|
162
|
+
}
|
|
163
|
+
const qs = params.toString();
|
|
164
|
+
if (qs) url += (url.includes("?") ? "&" : "?") + qs;
|
|
165
|
+
}
|
|
166
|
+
return url;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
var cachedLocalCdcust = null;
|
|
170
|
+
function synthesizeLocalDevResponse(path) {
|
|
171
|
+
if (path.startsWith("/sdk/heartbeat")) {
|
|
172
|
+
return {
|
|
173
|
+
object: "heartbeat",
|
|
174
|
+
ok: true,
|
|
175
|
+
projectId: "proj_local_dev",
|
|
176
|
+
appId: "app_local_dev",
|
|
177
|
+
platform: "web",
|
|
178
|
+
env: "sandbox",
|
|
179
|
+
serverTime: Date.now()
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (path.startsWith("/identity/alias")) {
|
|
183
|
+
if (!cachedLocalCdcust) {
|
|
184
|
+
const tail = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().replace(/-/g, "").slice(0, 16) : Math.random().toString(36).slice(2, 18);
|
|
185
|
+
cachedLocalCdcust = `cdcust_local_${tail}`;
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
object: "alias_result",
|
|
189
|
+
crossdeckCustomerId: cachedLocalCdcust,
|
|
190
|
+
linked: [],
|
|
191
|
+
mergePending: false,
|
|
192
|
+
env: "sandbox"
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (path.startsWith("/entitlements")) {
|
|
196
|
+
return {
|
|
197
|
+
object: "list",
|
|
198
|
+
data: [],
|
|
199
|
+
crossdeckCustomerId: cachedLocalCdcust ?? "",
|
|
200
|
+
env: "sandbox"
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (path.startsWith("/events")) {
|
|
204
|
+
return {
|
|
205
|
+
object: "list",
|
|
206
|
+
received: 0,
|
|
207
|
+
env: "sandbox"
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/identity.ts
|
|
214
|
+
var KEY_ANON = "anon_id";
|
|
215
|
+
var KEY_CDCUST = "cdcust_id";
|
|
216
|
+
var IdentityStore = class {
|
|
217
|
+
constructor(primary, prefix, secondary) {
|
|
218
|
+
this.primary = primary;
|
|
219
|
+
this.prefix = prefix;
|
|
220
|
+
this.secondary = secondary ?? null;
|
|
221
|
+
const anonFromPrimary = primary.getItem(prefix + KEY_ANON);
|
|
222
|
+
const cdcustFromPrimary = primary.getItem(prefix + KEY_CDCUST);
|
|
223
|
+
const anonFromSecondary = this.secondary?.getItem(prefix + KEY_ANON) ?? null;
|
|
224
|
+
const cdcustFromSecondary = this.secondary?.getItem(prefix + KEY_CDCUST) ?? null;
|
|
225
|
+
const anon = anonFromPrimary ?? anonFromSecondary;
|
|
226
|
+
const cdcust = cdcustFromPrimary ?? cdcustFromSecondary;
|
|
227
|
+
this.state = {
|
|
228
|
+
anonymousId: anon ?? this.mintAnonymousId(),
|
|
229
|
+
crossdeckCustomerId: cdcust
|
|
230
|
+
};
|
|
231
|
+
if (!anonFromPrimary || !anonFromSecondary) {
|
|
232
|
+
this.writeBoth(prefix + KEY_ANON, this.state.anonymousId);
|
|
233
|
+
}
|
|
234
|
+
if (cdcust && (!cdcustFromPrimary || !cdcustFromSecondary)) {
|
|
235
|
+
this.writeBoth(prefix + KEY_CDCUST, cdcust);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Return the persisted anonymous device ID (always set). */
|
|
239
|
+
get anonymousId() {
|
|
240
|
+
return this.state.anonymousId;
|
|
241
|
+
}
|
|
242
|
+
/** Return the resolved crossdeckCustomerId once we have one, else null. */
|
|
243
|
+
get crossdeckCustomerId() {
|
|
244
|
+
return this.state.crossdeckCustomerId;
|
|
245
|
+
}
|
|
246
|
+
/** Persist a newly-resolved Crossdeck customer ID. */
|
|
247
|
+
setCrossdeckCustomerId(value) {
|
|
248
|
+
this.state.crossdeckCustomerId = value;
|
|
249
|
+
this.writeBoth(this.prefix + KEY_CDCUST, value);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Wipe persisted identity. Called by reset() — used when an end-user
|
|
253
|
+
* logs out. After reset the SDK mints a new anonymousId so the next
|
|
254
|
+
* pre-login session is a fresh customer in the identity graph.
|
|
255
|
+
*/
|
|
256
|
+
reset() {
|
|
257
|
+
this.deleteBoth(this.prefix + KEY_ANON);
|
|
258
|
+
this.deleteBoth(this.prefix + KEY_CDCUST);
|
|
259
|
+
this.state = {
|
|
260
|
+
anonymousId: this.mintAnonymousId(),
|
|
261
|
+
crossdeckCustomerId: null
|
|
262
|
+
};
|
|
263
|
+
this.writeBoth(this.prefix + KEY_ANON, this.state.anonymousId);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Generate an anonymousId. Crockford-ish base32 timestamp + random
|
|
267
|
+
* suffix. Same shape Stripe / Segment / others use — sortable, log-
|
|
268
|
+
* friendly, no PII.
|
|
269
|
+
*/
|
|
270
|
+
mintAnonymousId() {
|
|
271
|
+
const ts = Date.now().toString(36);
|
|
272
|
+
const rand = randomChars(10);
|
|
273
|
+
return `anon_${ts}${rand}`;
|
|
274
|
+
}
|
|
275
|
+
writeBoth(key, value) {
|
|
276
|
+
try {
|
|
277
|
+
this.primary.setItem(key, value);
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
if (this.secondary) {
|
|
281
|
+
try {
|
|
282
|
+
this.secondary.setItem(key, value);
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
deleteBoth(key) {
|
|
288
|
+
try {
|
|
289
|
+
this.primary.removeItem(key);
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
if (this.secondary) {
|
|
293
|
+
try {
|
|
294
|
+
this.secondary.removeItem(key);
|
|
295
|
+
} catch {
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
function randomChars(count) {
|
|
301
|
+
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
302
|
+
const out = [];
|
|
303
|
+
const cryptoApi = globalThis.crypto;
|
|
304
|
+
if (cryptoApi?.getRandomValues) {
|
|
305
|
+
const buf = new Uint8Array(count);
|
|
306
|
+
cryptoApi.getRandomValues(buf);
|
|
307
|
+
for (let i = 0; i < count; i++) {
|
|
308
|
+
out.push(alphabet[buf[i] % alphabet.length] ?? "0");
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
for (let i = 0; i < count; i++) {
|
|
312
|
+
out.push(alphabet[Math.floor(Math.random() * alphabet.length)] ?? "0");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return out.join("");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/entitlement-cache.ts
|
|
319
|
+
var EntitlementCache = class {
|
|
320
|
+
constructor() {
|
|
321
|
+
this.active = /* @__PURE__ */ new Set();
|
|
322
|
+
this.all = [];
|
|
323
|
+
this.lastUpdated = 0;
|
|
324
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
325
|
+
this.listenerErrorCount = 0;
|
|
326
|
+
}
|
|
327
|
+
/** Sync read — true iff the entitlement key is currently active. */
|
|
328
|
+
isEntitled(key) {
|
|
329
|
+
return this.active.has(key);
|
|
330
|
+
}
|
|
331
|
+
/** Full snapshot for callers that need source / validUntil details. */
|
|
332
|
+
list() {
|
|
333
|
+
return this.all.slice();
|
|
334
|
+
}
|
|
335
|
+
/** When the cache was last refreshed. 0 means "never". */
|
|
336
|
+
get freshness() {
|
|
337
|
+
return this.lastUpdated;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Cumulative count of listener invocations that threw. Listener errors
|
|
341
|
+
* are swallowed (a buggy consumer must not crash the SDK) but the
|
|
342
|
+
* counter lets diagnostics() surface "you have a broken subscriber"
|
|
343
|
+
* without putting the developer in a debug session.
|
|
344
|
+
*/
|
|
345
|
+
get listenerErrors() {
|
|
346
|
+
return this.listenerErrorCount;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Replace the cache with a fresh server response. The backend already
|
|
350
|
+
* filters to active + env-matching, so we don't re-filter — just trust
|
|
351
|
+
* what we got.
|
|
352
|
+
*
|
|
353
|
+
* Fires listeners AFTER the mutation so each listener sees the new state.
|
|
354
|
+
*/
|
|
355
|
+
setFromList(entitlements) {
|
|
356
|
+
this.all = entitlements.slice();
|
|
357
|
+
this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));
|
|
358
|
+
this.lastUpdated = Date.now();
|
|
359
|
+
this.notify();
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Wipe — used on reset() (logout). The SDK forgets everything until
|
|
363
|
+
* the next identify + read.
|
|
364
|
+
*
|
|
365
|
+
* Fires listeners so React/SwiftUI/etc bindings re-render to the
|
|
366
|
+
* logged-out state immediately.
|
|
367
|
+
*/
|
|
368
|
+
clear() {
|
|
369
|
+
this.active.clear();
|
|
370
|
+
this.all = [];
|
|
371
|
+
this.lastUpdated = 0;
|
|
372
|
+
this.notify();
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Subscribe to cache mutations. Returns an unsubscribe function.
|
|
376
|
+
*
|
|
377
|
+
* The listener is invoked AFTER setFromList() or clear() with the
|
|
378
|
+
* current snapshot. Throwing inside a listener is non-fatal — the
|
|
379
|
+
* error is swallowed and subsequent listeners still run.
|
|
380
|
+
*
|
|
381
|
+
* Used by `@cross-deck/web/react`'s `useEntitlement` hook to
|
|
382
|
+
* trigger re-renders when entitlements change.
|
|
383
|
+
*/
|
|
384
|
+
subscribe(listener) {
|
|
385
|
+
this.listeners.add(listener);
|
|
386
|
+
let unsubscribed = false;
|
|
387
|
+
return () => {
|
|
388
|
+
if (unsubscribed) return;
|
|
389
|
+
unsubscribed = true;
|
|
390
|
+
this.listeners.delete(listener);
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
notify() {
|
|
394
|
+
if (this.listeners.size === 0) return;
|
|
395
|
+
const snapshot = this.all.slice();
|
|
396
|
+
const listenersSnapshot = [...this.listeners];
|
|
397
|
+
for (const listener of listenersSnapshot) {
|
|
398
|
+
try {
|
|
399
|
+
listener(snapshot);
|
|
400
|
+
} catch {
|
|
401
|
+
this.listenerErrorCount += 1;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// src/retry-policy.ts
|
|
408
|
+
var DEFAULT_BASE = 1e3;
|
|
409
|
+
var DEFAULT_MAX = 6e4;
|
|
410
|
+
var DEFAULT_FACTOR = 2;
|
|
411
|
+
var DEFAULT_WARN = 8;
|
|
412
|
+
function computeNextDelay(attempts, retryAfterMs, options = {}, random = Math.random) {
|
|
413
|
+
const base = options.baseMs ?? DEFAULT_BASE;
|
|
414
|
+
const max = options.maxMs ?? DEFAULT_MAX;
|
|
415
|
+
const factor = options.factor ?? DEFAULT_FACTOR;
|
|
416
|
+
const safeAttempts = Math.min(attempts, 30);
|
|
417
|
+
const ceiling = Math.min(max, base * Math.pow(factor, safeAttempts));
|
|
418
|
+
const jittered = ceiling * random();
|
|
419
|
+
if (retryAfterMs !== void 0 && retryAfterMs > jittered) {
|
|
420
|
+
return Math.min(max, retryAfterMs);
|
|
421
|
+
}
|
|
422
|
+
return Math.max(0, Math.round(jittered));
|
|
423
|
+
}
|
|
424
|
+
var RetryPolicy = class {
|
|
425
|
+
constructor(options = {}) {
|
|
426
|
+
this.options = options;
|
|
427
|
+
this.attempts = 0;
|
|
428
|
+
}
|
|
429
|
+
/** How many consecutive failures since the last success. */
|
|
430
|
+
get consecutiveFailures() {
|
|
431
|
+
return this.attempts;
|
|
432
|
+
}
|
|
433
|
+
/** Whether we've crossed the failuresBeforeWarn threshold. */
|
|
434
|
+
get isWarning() {
|
|
435
|
+
return this.attempts >= (this.options.failuresBeforeWarn ?? DEFAULT_WARN);
|
|
436
|
+
}
|
|
437
|
+
/** Schedule-time delay for the NEXT retry. Increments the counter. */
|
|
438
|
+
nextDelay(retryAfterMs, random = Math.random) {
|
|
439
|
+
const delay = computeNextDelay(this.attempts, retryAfterMs, this.options, random);
|
|
440
|
+
this.attempts += 1;
|
|
441
|
+
return delay;
|
|
442
|
+
}
|
|
443
|
+
/** Mark a successful flush — reset the counter. */
|
|
444
|
+
recordSuccess() {
|
|
445
|
+
this.attempts = 0;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// src/event-queue.ts
|
|
450
|
+
var HARD_BUFFER_CAP = 1e3;
|
|
451
|
+
var EventQueue = class {
|
|
452
|
+
constructor(cfg) {
|
|
453
|
+
this.cfg = cfg;
|
|
454
|
+
this.buffer = [];
|
|
455
|
+
this.dropped = 0;
|
|
456
|
+
this.inFlight = 0;
|
|
457
|
+
this.lastFlushAt = 0;
|
|
458
|
+
this.lastError = null;
|
|
459
|
+
this.cancelTimer = null;
|
|
460
|
+
this.firstFlushFired = false;
|
|
461
|
+
this.nextRetryAt = null;
|
|
462
|
+
this.retry = new RetryPolicy(cfg.retry ?? {});
|
|
463
|
+
this.persistent = cfg.persistentStore ?? null;
|
|
464
|
+
if (this.persistent) {
|
|
465
|
+
const restored = this.persistent.load();
|
|
466
|
+
if (restored.length > 0) {
|
|
467
|
+
if (restored.length > HARD_BUFFER_CAP) {
|
|
468
|
+
this.dropped += restored.length - HARD_BUFFER_CAP;
|
|
469
|
+
this.buffer = restored.slice(restored.length - HARD_BUFFER_CAP);
|
|
470
|
+
} else {
|
|
471
|
+
this.buffer = restored;
|
|
472
|
+
}
|
|
473
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
474
|
+
this.scheduleIdleFlush();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
enqueue(event) {
|
|
479
|
+
this.buffer.push(event);
|
|
480
|
+
if (this.buffer.length > HARD_BUFFER_CAP) {
|
|
481
|
+
const overflow = this.buffer.length - HARD_BUFFER_CAP;
|
|
482
|
+
this.buffer.splice(0, overflow);
|
|
483
|
+
this.dropped += overflow;
|
|
484
|
+
this.cfg.onDrop?.(overflow);
|
|
485
|
+
}
|
|
486
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
487
|
+
this.persistent?.save(this.buffer);
|
|
488
|
+
if (this.buffer.length >= this.cfg.batchSize) {
|
|
489
|
+
void this.flush();
|
|
490
|
+
} else {
|
|
491
|
+
this.scheduleIdleFlush();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Flush the buffer to /v1/events. Resolves when the network call
|
|
496
|
+
* completes (success or failure). On failure, events stay in the
|
|
497
|
+
* buffer for the next scheduled retry.
|
|
498
|
+
*
|
|
499
|
+
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
500
|
+
* browser keeps the request alive past page unload. Use this for
|
|
501
|
+
* terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
|
|
502
|
+
*/
|
|
503
|
+
async flush(options = {}) {
|
|
504
|
+
if (this.buffer.length === 0) return null;
|
|
505
|
+
this.cancelTimerIfSet();
|
|
506
|
+
this.nextRetryAt = null;
|
|
507
|
+
const batch = this.buffer.splice(0);
|
|
508
|
+
const batchId = this.mintBatchId();
|
|
509
|
+
this.inFlight += batch.length;
|
|
510
|
+
this.persistent?.save(this.buffer);
|
|
511
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
512
|
+
try {
|
|
513
|
+
const env = this.cfg.envelope();
|
|
514
|
+
const result = await this.cfg.http.request("POST", "/events", {
|
|
515
|
+
body: {
|
|
516
|
+
// NorthStar §13.1 batch envelope. The backend validates these
|
|
517
|
+
// against the API-key-resolved app and rejects mismatches
|
|
518
|
+
// loudly (env_mismatch).
|
|
519
|
+
appId: env.appId,
|
|
520
|
+
environment: env.environment,
|
|
521
|
+
sdk: env.sdk,
|
|
522
|
+
events: batch
|
|
523
|
+
},
|
|
524
|
+
keepalive: options.keepalive === true,
|
|
525
|
+
idempotencyKey: batchId
|
|
526
|
+
});
|
|
527
|
+
this.lastFlushAt = Date.now();
|
|
528
|
+
this.lastError = null;
|
|
529
|
+
this.inFlight -= batch.length;
|
|
530
|
+
this.retry.recordSuccess();
|
|
531
|
+
this.persistent?.save(this.buffer);
|
|
532
|
+
if (!this.firstFlushFired) {
|
|
533
|
+
this.firstFlushFired = true;
|
|
534
|
+
this.cfg.onFirstFlushSuccess?.();
|
|
535
|
+
}
|
|
536
|
+
return result;
|
|
537
|
+
} catch (err) {
|
|
538
|
+
this.buffer.unshift(...batch);
|
|
539
|
+
this.inFlight -= batch.length;
|
|
540
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
541
|
+
this.lastError = message;
|
|
542
|
+
this.persistent?.save(this.buffer);
|
|
543
|
+
this.cfg.onBufferChange?.(this.buffer.length);
|
|
544
|
+
const retryAfterMs = extractRetryAfterMs(err);
|
|
545
|
+
const delay = this.retry.nextDelay(retryAfterMs);
|
|
546
|
+
this.scheduleRetry(delay);
|
|
547
|
+
this.cfg.onRetryScheduled?.({
|
|
548
|
+
delayMs: delay,
|
|
549
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
550
|
+
retryAfterMs,
|
|
551
|
+
lastError: message
|
|
552
|
+
});
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/** Cancel any pending timer and clear in-memory state. Wipes durable store too. */
|
|
557
|
+
reset() {
|
|
558
|
+
this.cancelTimerIfSet();
|
|
559
|
+
this.nextRetryAt = null;
|
|
560
|
+
this.buffer = [];
|
|
561
|
+
this.dropped = 0;
|
|
562
|
+
this.inFlight = 0;
|
|
563
|
+
this.lastError = null;
|
|
564
|
+
this.retry.recordSuccess();
|
|
565
|
+
this.persistent?.clear();
|
|
566
|
+
this.cfg.onBufferChange?.(0);
|
|
567
|
+
}
|
|
568
|
+
getStats() {
|
|
569
|
+
return {
|
|
570
|
+
buffered: this.buffer.length,
|
|
571
|
+
dropped: this.dropped,
|
|
572
|
+
inFlight: this.inFlight,
|
|
573
|
+
lastFlushAt: this.lastFlushAt,
|
|
574
|
+
lastError: this.lastError,
|
|
575
|
+
consecutiveFailures: this.retry.consecutiveFailures,
|
|
576
|
+
nextRetryAt: this.nextRetryAt
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
// ---------- internal scheduling ----------
|
|
580
|
+
scheduleIdleFlush() {
|
|
581
|
+
this.cancelTimerIfSet();
|
|
582
|
+
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
583
|
+
this.cancelTimer = sched(() => {
|
|
584
|
+
void this.flush();
|
|
585
|
+
}, this.cfg.intervalMs);
|
|
586
|
+
}
|
|
587
|
+
scheduleRetry(delayMs) {
|
|
588
|
+
this.cancelTimerIfSet();
|
|
589
|
+
this.nextRetryAt = Date.now() + delayMs;
|
|
590
|
+
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
591
|
+
this.cancelTimer = sched(() => {
|
|
592
|
+
void this.flush();
|
|
593
|
+
}, delayMs);
|
|
594
|
+
}
|
|
595
|
+
cancelTimerIfSet() {
|
|
596
|
+
if (this.cancelTimer) {
|
|
597
|
+
this.cancelTimer();
|
|
598
|
+
this.cancelTimer = null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
mintBatchId() {
|
|
602
|
+
return `batch_${Date.now().toString(36)}${randomChars(10)}`;
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
function extractRetryAfterMs(err) {
|
|
606
|
+
if (err && typeof err === "object" && "retryAfterMs" in err) {
|
|
607
|
+
const v = err.retryAfterMs;
|
|
608
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
|
|
609
|
+
}
|
|
610
|
+
return void 0;
|
|
611
|
+
}
|
|
612
|
+
function defaultScheduler(fn, ms) {
|
|
613
|
+
const id = setTimeout(fn, ms);
|
|
614
|
+
if (typeof id.unref === "function") {
|
|
615
|
+
try {
|
|
616
|
+
id.unref();
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return () => clearTimeout(id);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/event-storage.ts
|
|
624
|
+
var PersistentEventStore = class {
|
|
625
|
+
constructor(options) {
|
|
626
|
+
this.options = options;
|
|
627
|
+
this.writeScheduled = false;
|
|
628
|
+
// Pending events captured on the most recent write request. We keep
|
|
629
|
+
// the latest snapshot ref so a debounced write always picks up the
|
|
630
|
+
// freshest buffer state.
|
|
631
|
+
this.pendingSnapshot = null;
|
|
632
|
+
this.key = `${options.prefix}queue.v1`;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Read the persisted queue on boot. Returns an empty array (with no
|
|
636
|
+
* warning) when nothing is stored, the blob is malformed, or storage
|
|
637
|
+
* is unavailable. Caller is responsible for treating duplicates from
|
|
638
|
+
* the persisted queue as the SAME events (eventId-based dedup).
|
|
639
|
+
*/
|
|
640
|
+
load() {
|
|
641
|
+
let raw;
|
|
642
|
+
try {
|
|
643
|
+
raw = this.options.storage.getItem(this.key);
|
|
644
|
+
} catch {
|
|
645
|
+
return [];
|
|
646
|
+
}
|
|
647
|
+
if (!raw) return [];
|
|
648
|
+
try {
|
|
649
|
+
const parsed = JSON.parse(raw);
|
|
650
|
+
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.events)) {
|
|
651
|
+
return [];
|
|
652
|
+
}
|
|
653
|
+
return parsed.events;
|
|
654
|
+
} catch {
|
|
655
|
+
return [];
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Schedule a write of the current buffer. Debounced via microtask so
|
|
660
|
+
* a burst of enqueue() calls coalesces into one persistence write.
|
|
661
|
+
* Writes are best-effort: if storage throws (quota, private mode),
|
|
662
|
+
* we swallow and rely on the in-memory buffer.
|
|
663
|
+
*/
|
|
664
|
+
save(snapshot) {
|
|
665
|
+
this.pendingSnapshot = snapshot.slice();
|
|
666
|
+
if (this.writeScheduled) return;
|
|
667
|
+
this.writeScheduled = true;
|
|
668
|
+
queueMicrotask(() => this.flushWrite());
|
|
669
|
+
}
|
|
670
|
+
/** Synchronous variant for terminal flushes (pagehide / beforeunload). */
|
|
671
|
+
saveSync(snapshot) {
|
|
672
|
+
this.pendingSnapshot = snapshot.slice();
|
|
673
|
+
this.flushWrite();
|
|
674
|
+
}
|
|
675
|
+
/** Wipe the persisted blob. Used by reset() (logout). */
|
|
676
|
+
clear() {
|
|
677
|
+
this.pendingSnapshot = null;
|
|
678
|
+
this.writeScheduled = false;
|
|
679
|
+
try {
|
|
680
|
+
this.options.storage.removeItem(this.key);
|
|
681
|
+
} catch {
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
flushWrite() {
|
|
685
|
+
this.writeScheduled = false;
|
|
686
|
+
const snapshot = this.pendingSnapshot;
|
|
687
|
+
this.pendingSnapshot = null;
|
|
688
|
+
if (snapshot === null) return;
|
|
689
|
+
if (snapshot.length === 0) {
|
|
690
|
+
try {
|
|
691
|
+
this.options.storage.removeItem(this.key);
|
|
692
|
+
} catch {
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const blob = { version: 1, events: snapshot };
|
|
697
|
+
try {
|
|
698
|
+
this.options.storage.setItem(this.key, JSON.stringify(blob));
|
|
699
|
+
} catch {
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
// src/storage.ts
|
|
705
|
+
var MemoryStorage = class {
|
|
706
|
+
constructor() {
|
|
707
|
+
this.store = /* @__PURE__ */ new Map();
|
|
708
|
+
}
|
|
709
|
+
getItem(key) {
|
|
710
|
+
return this.store.get(key) ?? null;
|
|
711
|
+
}
|
|
712
|
+
setItem(key, value) {
|
|
713
|
+
this.store.set(key, value);
|
|
714
|
+
}
|
|
715
|
+
removeItem(key) {
|
|
716
|
+
this.store.delete(key);
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
var CookieStorage = class {
|
|
720
|
+
constructor(options) {
|
|
721
|
+
this.maxAgeSec = options?.maxAgeSec ?? 63072e3;
|
|
722
|
+
this.secure = options?.secure ?? defaultSecure();
|
|
723
|
+
this.sameSite = options?.sameSite ?? "Lax";
|
|
724
|
+
}
|
|
725
|
+
getItem(key) {
|
|
726
|
+
if (!hasDocument()) return null;
|
|
727
|
+
const doc = globalThis.document;
|
|
728
|
+
const cookies = doc.cookie ? doc.cookie.split(/;\s*/) : [];
|
|
729
|
+
const prefix = encodeURIComponent(key) + "=";
|
|
730
|
+
for (const c of cookies) {
|
|
731
|
+
if (c.startsWith(prefix)) {
|
|
732
|
+
try {
|
|
733
|
+
return decodeURIComponent(c.slice(prefix.length));
|
|
734
|
+
} catch {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
setItem(key, value) {
|
|
742
|
+
if (!hasDocument()) return;
|
|
743
|
+
const doc = globalThis.document;
|
|
744
|
+
const parts = [
|
|
745
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
|
|
746
|
+
"Path=/",
|
|
747
|
+
`Max-Age=${this.maxAgeSec}`,
|
|
748
|
+
`SameSite=${this.sameSite}`
|
|
749
|
+
];
|
|
750
|
+
if (this.secure) parts.push("Secure");
|
|
751
|
+
try {
|
|
752
|
+
doc.cookie = parts.join("; ");
|
|
753
|
+
} catch {
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
removeItem(key) {
|
|
757
|
+
if (!hasDocument()) return;
|
|
758
|
+
const doc = globalThis.document;
|
|
759
|
+
const parts = [
|
|
760
|
+
`${encodeURIComponent(key)}=`,
|
|
761
|
+
"Path=/",
|
|
762
|
+
"Max-Age=0",
|
|
763
|
+
`SameSite=${this.sameSite}`
|
|
764
|
+
];
|
|
765
|
+
if (this.secure) parts.push("Secure");
|
|
766
|
+
try {
|
|
767
|
+
doc.cookie = parts.join("; ");
|
|
768
|
+
} catch {
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
function detectDefaultStorage() {
|
|
773
|
+
try {
|
|
774
|
+
const ls = globalThis.localStorage;
|
|
775
|
+
if (ls) {
|
|
776
|
+
const probe = "__crossdeck_probe__";
|
|
777
|
+
ls.setItem(probe, "1");
|
|
778
|
+
ls.removeItem(probe);
|
|
779
|
+
return ls;
|
|
780
|
+
}
|
|
781
|
+
} catch {
|
|
782
|
+
}
|
|
783
|
+
return new MemoryStorage();
|
|
784
|
+
}
|
|
785
|
+
function defaultSecure() {
|
|
786
|
+
try {
|
|
787
|
+
const loc = globalThis.location;
|
|
788
|
+
return loc?.protocol === "https:";
|
|
789
|
+
} catch {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function hasDocument() {
|
|
794
|
+
return typeof globalThis.document !== "undefined";
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// src/device-info.ts
|
|
798
|
+
function isBrowser() {
|
|
799
|
+
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined" && typeof globalThis.navigator !== "undefined";
|
|
800
|
+
}
|
|
801
|
+
function collectDeviceInfo(extra) {
|
|
802
|
+
const info = {};
|
|
803
|
+
if (extra?.appVersion) info.appVersion = extra.appVersion;
|
|
804
|
+
if (!isBrowser()) return info;
|
|
805
|
+
const w = globalThis.window;
|
|
806
|
+
const nav = globalThis.navigator;
|
|
807
|
+
const doc = globalThis.document;
|
|
808
|
+
try {
|
|
809
|
+
if (typeof nav.language === "string") info.locale = nav.language;
|
|
810
|
+
} catch {
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
info.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
814
|
+
} catch {
|
|
815
|
+
}
|
|
816
|
+
try {
|
|
817
|
+
if (w.screen) {
|
|
818
|
+
info.screenWidth = w.screen.width;
|
|
819
|
+
info.screenHeight = w.screen.height;
|
|
820
|
+
}
|
|
821
|
+
info.viewportWidth = w.innerWidth;
|
|
822
|
+
info.viewportHeight = w.innerHeight;
|
|
823
|
+
info.devicePixelRatio = w.devicePixelRatio;
|
|
824
|
+
} catch {
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
const ua = nav.userAgent ?? "";
|
|
828
|
+
const parsed = parseUserAgent(ua);
|
|
829
|
+
Object.assign(info, parsed);
|
|
830
|
+
} catch {
|
|
831
|
+
}
|
|
832
|
+
try {
|
|
833
|
+
const uaData = nav.userAgentData;
|
|
834
|
+
if (uaData?.platform && !info.os) info.os = uaData.platform;
|
|
835
|
+
if (uaData?.brands && !info.browser) {
|
|
836
|
+
const real = uaData.brands.find(
|
|
837
|
+
(b) => !/Not[ .;A]*Brand/i.test(b.brand) && !/Chromium/i.test(b.brand)
|
|
838
|
+
);
|
|
839
|
+
if (real) {
|
|
840
|
+
info.browser = real.brand;
|
|
841
|
+
info.browserVersion = real.version;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
} catch {
|
|
845
|
+
}
|
|
846
|
+
void doc;
|
|
847
|
+
return info;
|
|
848
|
+
}
|
|
849
|
+
function parseUserAgent(ua) {
|
|
850
|
+
const out = {};
|
|
851
|
+
if (/iPad|iPhone|iPod/.test(ua)) {
|
|
852
|
+
out.os = "iOS";
|
|
853
|
+
const m = ua.match(/OS (\d+[._]\d+(?:[._]\d+)?)/);
|
|
854
|
+
if (m?.[1]) out.osVersion = m[1].replace(/_/g, ".");
|
|
855
|
+
} else if (/Android/.test(ua)) {
|
|
856
|
+
out.os = "Android";
|
|
857
|
+
const m = ua.match(/Android (\d+(?:\.\d+)*)/);
|
|
858
|
+
if (m?.[1]) out.osVersion = m[1];
|
|
859
|
+
} else if (/Windows/.test(ua)) {
|
|
860
|
+
out.os = "Windows";
|
|
861
|
+
const m = ua.match(/Windows NT (\d+\.\d+)/);
|
|
862
|
+
if (m?.[1]) out.osVersion = m[1];
|
|
863
|
+
} else if (/Mac OS X|Macintosh/.test(ua)) {
|
|
864
|
+
out.os = "macOS";
|
|
865
|
+
const m = ua.match(/Mac OS X (\d+[._]\d+(?:[._]\d+)?)/);
|
|
866
|
+
if (m?.[1]) out.osVersion = m[1].replace(/_/g, ".");
|
|
867
|
+
} else if (/Linux/.test(ua)) {
|
|
868
|
+
out.os = "Linux";
|
|
869
|
+
}
|
|
870
|
+
if (/Edg\/(\d+(?:\.\d+)*)/.test(ua)) {
|
|
871
|
+
out.browser = "Edge";
|
|
872
|
+
out.browserVersion = ua.match(/Edg\/(\d+(?:\.\d+)*)/)?.[1];
|
|
873
|
+
} else if (/Firefox\/(\d+(?:\.\d+)*)/.test(ua)) {
|
|
874
|
+
out.browser = "Firefox";
|
|
875
|
+
out.browserVersion = ua.match(/Firefox\/(\d+(?:\.\d+)*)/)?.[1];
|
|
876
|
+
} else if (/OPR\/(\d+(?:\.\d+)*)/.test(ua)) {
|
|
877
|
+
out.browser = "Opera";
|
|
878
|
+
out.browserVersion = ua.match(/OPR\/(\d+(?:\.\d+)*)/)?.[1];
|
|
879
|
+
} else if (/Chrome\/(\d+(?:\.\d+)*)/.test(ua)) {
|
|
880
|
+
out.browser = "Chrome";
|
|
881
|
+
out.browserVersion = ua.match(/Chrome\/(\d+(?:\.\d+)*)/)?.[1];
|
|
882
|
+
} else if (/Version\/(\d+(?:\.\d+)*).*Safari/.test(ua)) {
|
|
883
|
+
out.browser = "Safari";
|
|
884
|
+
out.browserVersion = ua.match(/Version\/(\d+(?:\.\d+)*)/)?.[1];
|
|
885
|
+
}
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// src/auto-track.ts
|
|
890
|
+
var DEFAULT_AUTO_TRACK = {
|
|
891
|
+
sessions: true,
|
|
892
|
+
pageViews: true,
|
|
893
|
+
deviceInfo: true,
|
|
894
|
+
clicks: true,
|
|
895
|
+
webVitals: true
|
|
896
|
+
};
|
|
897
|
+
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
898
|
+
var EMPTY_ACQUISITION = {
|
|
899
|
+
utm_source: "",
|
|
900
|
+
utm_medium: "",
|
|
901
|
+
utm_campaign: "",
|
|
902
|
+
utm_content: "",
|
|
903
|
+
utm_term: "",
|
|
904
|
+
referrer: "",
|
|
905
|
+
gclid: "",
|
|
906
|
+
fbclid: "",
|
|
907
|
+
msclkid: "",
|
|
908
|
+
ttclid: "",
|
|
909
|
+
li_fat_id: "",
|
|
910
|
+
twclid: ""
|
|
911
|
+
};
|
|
912
|
+
var AutoTracker = class {
|
|
913
|
+
constructor(cfg, track) {
|
|
914
|
+
this.cfg = cfg;
|
|
915
|
+
this.track = track;
|
|
916
|
+
this.session = null;
|
|
917
|
+
this.cleanups = [];
|
|
918
|
+
/**
|
|
919
|
+
* Stable per-page-view identifier. Minted at every `page.viewed`
|
|
920
|
+
* emission and attached to every subsequent event until the next
|
|
921
|
+
* `page.viewed`. Lets dashboards correlate "user clicked X" to
|
|
922
|
+
* "user viewed page Y" without timestamp arithmetic — the canonical
|
|
923
|
+
* Mixpanel `$current_url` / Segment `pageId` pattern.
|
|
924
|
+
*
|
|
925
|
+
* Null until the first `page.viewed` fires (which happens at SDK
|
|
926
|
+
* install if `autoTrack.pageViews !== false`).
|
|
927
|
+
*/
|
|
928
|
+
this.pageviewId = null;
|
|
929
|
+
}
|
|
930
|
+
install() {
|
|
931
|
+
if (!isBrowserSafe()) return;
|
|
932
|
+
if (this.cfg.sessions) this.installSessionTracking();
|
|
933
|
+
if (this.cfg.pageViews) this.installPageViewTracking();
|
|
934
|
+
if (this.cfg.clicks) this.installClickTracking();
|
|
935
|
+
}
|
|
936
|
+
uninstall() {
|
|
937
|
+
while (this.cleanups.length) {
|
|
938
|
+
const fn = this.cleanups.pop();
|
|
939
|
+
try {
|
|
940
|
+
fn?.();
|
|
941
|
+
} catch {
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (this.session && !this.session.endedSent) {
|
|
945
|
+
this.emitSessionEnd();
|
|
946
|
+
}
|
|
947
|
+
this.session = null;
|
|
948
|
+
}
|
|
949
|
+
/** Exposed for tests + consumers that want to reset the session manually. */
|
|
950
|
+
resetSession() {
|
|
951
|
+
if (this.session && !this.session.endedSent) this.emitSessionEnd();
|
|
952
|
+
this.session = this.startNewSession();
|
|
953
|
+
this.emitSessionStart();
|
|
954
|
+
}
|
|
955
|
+
/** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
|
|
956
|
+
get currentSessionId() {
|
|
957
|
+
return this.session?.sessionId ?? null;
|
|
958
|
+
}
|
|
959
|
+
/** Stable per-page-view ID. Null before the first page.viewed has fired. */
|
|
960
|
+
get currentPageviewId() {
|
|
961
|
+
return this.pageviewId;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Per-session acquisition context — utm_* + referrer, captured once
|
|
965
|
+
* at session start. Returns empty strings when there's no session
|
|
966
|
+
* (Node, before init, after uninstall) so callers can spread without
|
|
967
|
+
* conditional logic. Bank-grade rule: capture once, attach to every
|
|
968
|
+
* event of the session, don't re-read on every track() (the URL
|
|
969
|
+
* changes via SPA pushState; the source-of-record is the URL we
|
|
970
|
+
* landed on).
|
|
971
|
+
*/
|
|
972
|
+
get currentAcquisition() {
|
|
973
|
+
return this.session?.acquisition ?? EMPTY_ACQUISITION;
|
|
974
|
+
}
|
|
975
|
+
// ---------- sessions ----------
|
|
976
|
+
installSessionTracking() {
|
|
977
|
+
this.session = this.startNewSession();
|
|
978
|
+
this.emitSessionStart();
|
|
979
|
+
const onVisChange = () => {
|
|
980
|
+
if (!this.session) return;
|
|
981
|
+
const doc2 = globalThis.document;
|
|
982
|
+
if (doc2.visibilityState === "hidden") {
|
|
983
|
+
this.session.hiddenAt = Date.now();
|
|
984
|
+
} else if (doc2.visibilityState === "visible") {
|
|
985
|
+
const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
|
|
986
|
+
if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
|
|
987
|
+
this.emitSessionEnd();
|
|
988
|
+
this.session = this.startNewSession();
|
|
989
|
+
this.emitSessionStart();
|
|
990
|
+
} else {
|
|
991
|
+
this.session.hiddenAt = null;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
const onPageHide = () => this.emitSessionEnd();
|
|
996
|
+
const w = globalThis.window;
|
|
997
|
+
const doc = globalThis.document;
|
|
998
|
+
doc.addEventListener("visibilitychange", onVisChange);
|
|
999
|
+
w.addEventListener("pagehide", onPageHide);
|
|
1000
|
+
w.addEventListener("beforeunload", onPageHide);
|
|
1001
|
+
this.cleanups.push(() => {
|
|
1002
|
+
doc.removeEventListener("visibilitychange", onVisChange);
|
|
1003
|
+
w.removeEventListener("pagehide", onPageHide);
|
|
1004
|
+
w.removeEventListener("beforeunload", onPageHide);
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
startNewSession() {
|
|
1008
|
+
return {
|
|
1009
|
+
sessionId: mintSessionId(),
|
|
1010
|
+
startedAt: Date.now(),
|
|
1011
|
+
hiddenAt: null,
|
|
1012
|
+
endedSent: false,
|
|
1013
|
+
acquisition: captureAcquisition()
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
emitSessionStart() {
|
|
1017
|
+
if (!this.session) return;
|
|
1018
|
+
this.track("session.started", { sessionId: this.session.sessionId });
|
|
1019
|
+
}
|
|
1020
|
+
emitSessionEnd() {
|
|
1021
|
+
if (!this.session || this.session.endedSent) return;
|
|
1022
|
+
const duration = Date.now() - this.session.startedAt;
|
|
1023
|
+
this.track("session.ended", {
|
|
1024
|
+
sessionId: this.session.sessionId,
|
|
1025
|
+
durationMs: duration
|
|
1026
|
+
});
|
|
1027
|
+
this.session.endedSent = true;
|
|
1028
|
+
}
|
|
1029
|
+
// ---------- page views ----------
|
|
1030
|
+
installPageViewTracking() {
|
|
1031
|
+
const w = globalThis.window;
|
|
1032
|
+
const doc = globalThis.document;
|
|
1033
|
+
let lastFiredAt = 0;
|
|
1034
|
+
let lastFiredUrl = "";
|
|
1035
|
+
const DEDUP_WINDOW_MS = 250;
|
|
1036
|
+
const fire = (force = false) => {
|
|
1037
|
+
const loc = w.location;
|
|
1038
|
+
const url = loc.href;
|
|
1039
|
+
const now = Date.now();
|
|
1040
|
+
if (!force && url === lastFiredUrl && now - lastFiredAt < DEDUP_WINDOW_MS) return;
|
|
1041
|
+
lastFiredAt = now;
|
|
1042
|
+
lastFiredUrl = url;
|
|
1043
|
+
this.pageviewId = `pv_${Date.now().toString(36)}${randomChars(10)}`;
|
|
1044
|
+
this.track("page.viewed", {
|
|
1045
|
+
pageviewId: this.pageviewId,
|
|
1046
|
+
path: loc.pathname,
|
|
1047
|
+
url,
|
|
1048
|
+
search: loc.search || void 0,
|
|
1049
|
+
hash: loc.hash || void 0,
|
|
1050
|
+
title: doc.title,
|
|
1051
|
+
// referrer only on the first hit of the session — afterward it's
|
|
1052
|
+
// always our previous URL, which isn't useful.
|
|
1053
|
+
referrer: doc.referrer || void 0
|
|
1054
|
+
});
|
|
1055
|
+
};
|
|
1056
|
+
fire();
|
|
1057
|
+
const origPush = w.history.pushState;
|
|
1058
|
+
const origReplace = w.history.replaceState;
|
|
1059
|
+
function patchedPush(data, unused, url) {
|
|
1060
|
+
origPush.apply(this, [data, unused, url]);
|
|
1061
|
+
queueMicrotask(fire);
|
|
1062
|
+
}
|
|
1063
|
+
function patchedReplace(data, unused, url) {
|
|
1064
|
+
origReplace.apply(this, [data, unused, url]);
|
|
1065
|
+
queueMicrotask(fire);
|
|
1066
|
+
}
|
|
1067
|
+
w.history.pushState = patchedPush;
|
|
1068
|
+
w.history.replaceState = patchedReplace;
|
|
1069
|
+
const onPopState = () => fire(true);
|
|
1070
|
+
w.addEventListener("popstate", onPopState);
|
|
1071
|
+
this.cleanups.push(() => {
|
|
1072
|
+
if (w.history.pushState === patchedPush) {
|
|
1073
|
+
w.history.pushState = origPush;
|
|
1074
|
+
}
|
|
1075
|
+
if (w.history.replaceState === patchedReplace) {
|
|
1076
|
+
w.history.replaceState = origReplace;
|
|
1077
|
+
}
|
|
1078
|
+
w.removeEventListener("popstate", onPopState);
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
// ---------- click autocapture ----------
|
|
1082
|
+
/**
|
|
1083
|
+
* Global click tracking — Mixpanel / Amplitude style autocapture.
|
|
1084
|
+
* Fires `element.clicked` for every interactive click with the
|
|
1085
|
+
* target element's selector path, text content, tag, href, data-*
|
|
1086
|
+
* attributes, and viewport coordinates. Powers the funnel /
|
|
1087
|
+
* attribution USP: "users who clicked X then converted within
|
|
1088
|
+
* 7 days." Default ON because behavioural attribution is the
|
|
1089
|
+
* core product promise.
|
|
1090
|
+
*
|
|
1091
|
+
* Privacy guardrails:
|
|
1092
|
+
* - Skip clicks ON inputs / textareas / selects (form interaction
|
|
1093
|
+
* isn't button telemetry; the dev should track form submits
|
|
1094
|
+
* deliberately via track('form_submitted'))
|
|
1095
|
+
* - Skip clicks INSIDE [type="password"] and password-class
|
|
1096
|
+
* elements
|
|
1097
|
+
* - Skip clicks inside elements opted out via class="cd-noTrack"
|
|
1098
|
+
* or data-cd-noTrack attribute (Mixpanel's exact opt-out
|
|
1099
|
+
* idiom — most devs already know it)
|
|
1100
|
+
* - Capture text content but cap at 64 chars and trim — never
|
|
1101
|
+
* more than what you'd see on a button label
|
|
1102
|
+
*
|
|
1103
|
+
* Volume guardrails:
|
|
1104
|
+
* - Coalesce double-clicks within 100ms (React's synthetic click
|
|
1105
|
+
* pattern + browser's native dblclick can fire twice)
|
|
1106
|
+
* - Listen on document at capture phase so we see the click
|
|
1107
|
+
* before any framework's own handlers stop propagation
|
|
1108
|
+
*/
|
|
1109
|
+
installClickTracking() {
|
|
1110
|
+
const w = globalThis.window;
|
|
1111
|
+
const doc = globalThis.document;
|
|
1112
|
+
let lastFiredAt = 0;
|
|
1113
|
+
let lastFiredTarget = null;
|
|
1114
|
+
const COALESCE_MS = 100;
|
|
1115
|
+
const TEXT_CAP = 64;
|
|
1116
|
+
const onClick = (ev) => {
|
|
1117
|
+
const target = ev.target;
|
|
1118
|
+
if (!target || !(target instanceof Element)) return;
|
|
1119
|
+
const now = Date.now();
|
|
1120
|
+
if (target === lastFiredTarget && now - lastFiredAt < COALESCE_MS) return;
|
|
1121
|
+
lastFiredAt = now;
|
|
1122
|
+
lastFiredTarget = target;
|
|
1123
|
+
const actionable = closestActionable(target);
|
|
1124
|
+
const clicked = actionable || target;
|
|
1125
|
+
if (isFormInput(clicked)) return;
|
|
1126
|
+
if (isInOptedOut(clicked)) return;
|
|
1127
|
+
if (isInsidePasswordField(clicked)) return;
|
|
1128
|
+
const tag = clicked.tagName.toLowerCase();
|
|
1129
|
+
const text = trimText(extractText(clicked), TEXT_CAP);
|
|
1130
|
+
const href = clicked.href || void 0;
|
|
1131
|
+
const linkTarget = clicked.target || void 0;
|
|
1132
|
+
const elementId = clicked.id || void 0;
|
|
1133
|
+
const role = clicked.getAttribute("role") || void 0;
|
|
1134
|
+
const ariaLabel = clicked.getAttribute("aria-label") || void 0;
|
|
1135
|
+
const selector = buildSelector(clicked);
|
|
1136
|
+
const dataAttrs = collectDataAttrs(clicked);
|
|
1137
|
+
const isLink = tag === "a" && !!href;
|
|
1138
|
+
const explicitName = clicked.getAttribute("data-cd-event");
|
|
1139
|
+
const props = {
|
|
1140
|
+
selector,
|
|
1141
|
+
tag,
|
|
1142
|
+
text,
|
|
1143
|
+
elementId,
|
|
1144
|
+
role,
|
|
1145
|
+
ariaLabel,
|
|
1146
|
+
href,
|
|
1147
|
+
isLink,
|
|
1148
|
+
linkTarget,
|
|
1149
|
+
viewportX: ev.clientX,
|
|
1150
|
+
viewportY: ev.clientY,
|
|
1151
|
+
pageX: ev.pageX,
|
|
1152
|
+
pageY: ev.pageY,
|
|
1153
|
+
...dataAttrs
|
|
1154
|
+
};
|
|
1155
|
+
for (const k of Object.keys(props)) {
|
|
1156
|
+
if (props[k] === void 0 || props[k] === null || props[k] === "") delete props[k];
|
|
1157
|
+
}
|
|
1158
|
+
this.track(explicitName || "element.clicked", props);
|
|
1159
|
+
};
|
|
1160
|
+
doc.addEventListener("click", onClick, { capture: true, passive: true });
|
|
1161
|
+
this.cleanups.push(() => {
|
|
1162
|
+
doc.removeEventListener("click", onClick, { capture: true });
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
function closestActionable(el) {
|
|
1167
|
+
return el.closest("[data-cd-event]") || el.closest("[data-cd-noTrack]") || el.closest("button, a, [role='button'], [role='link'], input[type='button'], input[type='submit']") || null;
|
|
1168
|
+
}
|
|
1169
|
+
function isFormInput(el) {
|
|
1170
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
1171
|
+
const tag = el.tagName.toLowerCase();
|
|
1172
|
+
if (tag === "textarea" || tag === "select") return true;
|
|
1173
|
+
if (tag === "input") {
|
|
1174
|
+
const type = (el.type || "").toLowerCase();
|
|
1175
|
+
return type !== "button" && type !== "submit" && type !== "image" && type !== "reset";
|
|
1176
|
+
}
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
function isInOptedOut(el) {
|
|
1180
|
+
if (el.closest("[data-cd-noTrack], [data-cd-no-track], .cd-noTrack, .cd-no-track")) return true;
|
|
1181
|
+
return false;
|
|
1182
|
+
}
|
|
1183
|
+
function isInsidePasswordField(el) {
|
|
1184
|
+
if (el.closest('input[type="password"]')) return true;
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
function extractText(el) {
|
|
1188
|
+
const aria = el.getAttribute("aria-label");
|
|
1189
|
+
if (aria) return aria.replace(/\s+/g, " ").trim();
|
|
1190
|
+
if (el instanceof HTMLInputElement && el.value) return el.value;
|
|
1191
|
+
const text = (el.textContent || "").replace(/\s+/g, " ").trim();
|
|
1192
|
+
return text;
|
|
1193
|
+
}
|
|
1194
|
+
function trimText(s, cap) {
|
|
1195
|
+
if (s.length <= cap) return s;
|
|
1196
|
+
return s.slice(0, cap - 1) + "\u2026";
|
|
1197
|
+
}
|
|
1198
|
+
function buildSelector(el) {
|
|
1199
|
+
const parts = [];
|
|
1200
|
+
let cur = el;
|
|
1201
|
+
let depth = 0;
|
|
1202
|
+
while (cur && cur.nodeName.toLowerCase() !== "body" && depth < 5) {
|
|
1203
|
+
let part = cur.nodeName.toLowerCase();
|
|
1204
|
+
if (cur.id) {
|
|
1205
|
+
parts.unshift(`${part}#${cur.id}`);
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
if (cur.classList.length > 0) {
|
|
1209
|
+
const cls = Array.from(cur.classList).filter((c) => !c.startsWith("cd-")).slice(0, 2).join(".");
|
|
1210
|
+
if (cls) part += `.${cls}`;
|
|
1211
|
+
}
|
|
1212
|
+
parts.unshift(part);
|
|
1213
|
+
cur = cur.parentElement;
|
|
1214
|
+
depth++;
|
|
1215
|
+
}
|
|
1216
|
+
return parts.join(" > ");
|
|
1217
|
+
}
|
|
1218
|
+
function collectDataAttrs(el) {
|
|
1219
|
+
const out = {};
|
|
1220
|
+
if (!(el instanceof HTMLElement)) return out;
|
|
1221
|
+
for (const name of el.getAttributeNames()) {
|
|
1222
|
+
if (!name.startsWith("data-")) continue;
|
|
1223
|
+
if (name === "data-cd-noTrack" || name === "data-cd-no-track") continue;
|
|
1224
|
+
if (name === "data-cd-event") continue;
|
|
1225
|
+
const value = el.getAttribute(name) || "";
|
|
1226
|
+
const key = name.replace(/^data-cd-prop-/, "").replace(/^data-/, "");
|
|
1227
|
+
out[key] = value;
|
|
1228
|
+
}
|
|
1229
|
+
return out;
|
|
1230
|
+
}
|
|
1231
|
+
function isBrowserSafe() {
|
|
1232
|
+
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
|
|
1233
|
+
}
|
|
1234
|
+
function mintSessionId() {
|
|
1235
|
+
const ts = Date.now().toString(36);
|
|
1236
|
+
return `sess_${ts}${randomChars(10)}`;
|
|
1237
|
+
}
|
|
1238
|
+
function captureAcquisition() {
|
|
1239
|
+
if (!isBrowserSafe()) return { ...EMPTY_ACQUISITION };
|
|
1240
|
+
const result = { ...EMPTY_ACQUISITION };
|
|
1241
|
+
try {
|
|
1242
|
+
const w = globalThis.window;
|
|
1243
|
+
const params = new URLSearchParams(w.location.search ?? "");
|
|
1244
|
+
result.utm_source = params.get("utm_source") ?? "";
|
|
1245
|
+
result.utm_medium = params.get("utm_medium") ?? "";
|
|
1246
|
+
result.utm_campaign = params.get("utm_campaign") ?? "";
|
|
1247
|
+
result.utm_content = params.get("utm_content") ?? "";
|
|
1248
|
+
result.utm_term = params.get("utm_term") ?? "";
|
|
1249
|
+
result.gclid = params.get("gclid") ?? "";
|
|
1250
|
+
result.fbclid = params.get("fbclid") ?? "";
|
|
1251
|
+
result.msclkid = params.get("msclkid") ?? "";
|
|
1252
|
+
result.ttclid = params.get("ttclid") ?? "";
|
|
1253
|
+
result.li_fat_id = params.get("li_fat_id") ?? "";
|
|
1254
|
+
result.twclid = params.get("twclid") ?? "";
|
|
1255
|
+
} catch {
|
|
1256
|
+
}
|
|
1257
|
+
try {
|
|
1258
|
+
const doc = globalThis.document;
|
|
1259
|
+
if (typeof doc.referrer === "string") result.referrer = doc.referrer;
|
|
1260
|
+
} catch {
|
|
1261
|
+
}
|
|
1262
|
+
return result;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/debug.ts
|
|
1266
|
+
var SENSITIVE_KEY_PATTERNS = [
|
|
1267
|
+
/^email$/i,
|
|
1268
|
+
/^password$/i,
|
|
1269
|
+
/^token$/i,
|
|
1270
|
+
/^secret$/i,
|
|
1271
|
+
/^card$/i,
|
|
1272
|
+
/^phone$/i,
|
|
1273
|
+
/password/i,
|
|
1274
|
+
/credit_?card/i
|
|
1275
|
+
];
|
|
1276
|
+
function findSensitivePropertyKeys(properties) {
|
|
1277
|
+
if (!properties) return [];
|
|
1278
|
+
const hits = [];
|
|
1279
|
+
for (const k of Object.keys(properties)) {
|
|
1280
|
+
if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(k))) hits.push(k);
|
|
1281
|
+
}
|
|
1282
|
+
return hits;
|
|
1283
|
+
}
|
|
1284
|
+
var ConsoleDebugLogger = class {
|
|
1285
|
+
constructor() {
|
|
1286
|
+
this.enabled = false;
|
|
1287
|
+
this.seen = /* @__PURE__ */ new Set();
|
|
1288
|
+
}
|
|
1289
|
+
emit(signal, message, context) {
|
|
1290
|
+
if (!this.enabled) return;
|
|
1291
|
+
if (ONCE_SIGNALS.has(signal)) {
|
|
1292
|
+
if (this.seen.has(signal)) return;
|
|
1293
|
+
this.seen.add(signal);
|
|
1294
|
+
}
|
|
1295
|
+
const ctx = context ? ` ${safeJson(context)}` : "";
|
|
1296
|
+
console.info(`[crossdeck:${signal}] ${message}${ctx}`);
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
var ONCE_SIGNALS = /* @__PURE__ */ new Set([
|
|
1300
|
+
"sdk.configured",
|
|
1301
|
+
"sdk.first_event_sent",
|
|
1302
|
+
"sdk.environment_mismatch"
|
|
1303
|
+
]);
|
|
1304
|
+
function safeJson(obj) {
|
|
1305
|
+
try {
|
|
1306
|
+
return JSON.stringify(obj);
|
|
1307
|
+
} catch {
|
|
1308
|
+
return "[unserialisable context]";
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/event-validation.ts
|
|
1313
|
+
var DEFAULT_MAX_STRING = 1024;
|
|
1314
|
+
var DEFAULT_MAX_BYTES = 8 * 1024;
|
|
1315
|
+
var DEFAULT_MAX_DEPTH = 5;
|
|
1316
|
+
function validateEventProperties(input, options = {}) {
|
|
1317
|
+
const warnings = [];
|
|
1318
|
+
if (!input) return { properties: {}, warnings };
|
|
1319
|
+
const maxStringLength = options.maxStringLength ?? DEFAULT_MAX_STRING;
|
|
1320
|
+
const maxBatchPropertyBytes = options.maxBatchPropertyBytes ?? DEFAULT_MAX_BYTES;
|
|
1321
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1322
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
1323
|
+
const visit = (value, key, depth) => {
|
|
1324
|
+
if (depth > maxDepth) {
|
|
1325
|
+
warnings.push({ kind: "depth_exceeded", key });
|
|
1326
|
+
return { keep: true, value: "[depth-exceeded]" };
|
|
1327
|
+
}
|
|
1328
|
+
if (value === null) return { keep: true, value: null };
|
|
1329
|
+
const t = typeof value;
|
|
1330
|
+
if (t === "string") {
|
|
1331
|
+
const s = value;
|
|
1332
|
+
if (s.length > maxStringLength) {
|
|
1333
|
+
warnings.push({ kind: "truncated_string", key });
|
|
1334
|
+
return { keep: true, value: s.slice(0, maxStringLength - 1) + "\u2026" };
|
|
1335
|
+
}
|
|
1336
|
+
return { keep: true, value: s };
|
|
1337
|
+
}
|
|
1338
|
+
if (t === "number") {
|
|
1339
|
+
if (!Number.isFinite(value)) {
|
|
1340
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1341
|
+
return { keep: true, value: null };
|
|
1342
|
+
}
|
|
1343
|
+
return { keep: true, value };
|
|
1344
|
+
}
|
|
1345
|
+
if (t === "boolean") return { keep: true, value };
|
|
1346
|
+
if (t === "bigint") {
|
|
1347
|
+
warnings.push({ kind: "coerced_bigint", key });
|
|
1348
|
+
return { keep: true, value: value.toString() };
|
|
1349
|
+
}
|
|
1350
|
+
if (t === "function") {
|
|
1351
|
+
warnings.push({ kind: "dropped_function", key });
|
|
1352
|
+
return { keep: false, value: void 0 };
|
|
1353
|
+
}
|
|
1354
|
+
if (t === "symbol") {
|
|
1355
|
+
warnings.push({ kind: "dropped_symbol", key });
|
|
1356
|
+
return { keep: false, value: void 0 };
|
|
1357
|
+
}
|
|
1358
|
+
if (t === "undefined") {
|
|
1359
|
+
warnings.push({ kind: "dropped_undefined", key });
|
|
1360
|
+
return { keep: false, value: void 0 };
|
|
1361
|
+
}
|
|
1362
|
+
if (value instanceof Date) {
|
|
1363
|
+
warnings.push({ kind: "coerced_date", key });
|
|
1364
|
+
const iso = Number.isFinite(value.getTime()) ? value.toISOString() : null;
|
|
1365
|
+
return { keep: true, value: iso };
|
|
1366
|
+
}
|
|
1367
|
+
if (value instanceof Error) {
|
|
1368
|
+
warnings.push({ kind: "coerced_error", key });
|
|
1369
|
+
return {
|
|
1370
|
+
keep: true,
|
|
1371
|
+
value: {
|
|
1372
|
+
name: value.name,
|
|
1373
|
+
message: value.message,
|
|
1374
|
+
stack: typeof value.stack === "string" ? value.stack.slice(0, maxStringLength) : void 0
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
if (value instanceof Map) {
|
|
1379
|
+
warnings.push({ kind: "coerced_map", key });
|
|
1380
|
+
const obj = {};
|
|
1381
|
+
for (const [k, v] of value.entries()) {
|
|
1382
|
+
const subKey = typeof k === "string" ? k : String(k);
|
|
1383
|
+
const result = visit(v, `${key}.${subKey}`, depth + 1);
|
|
1384
|
+
if (result.keep) obj[subKey] = result.value;
|
|
1385
|
+
}
|
|
1386
|
+
return { keep: true, value: obj };
|
|
1387
|
+
}
|
|
1388
|
+
if (value instanceof Set) {
|
|
1389
|
+
warnings.push({ kind: "coerced_set", key });
|
|
1390
|
+
const arr = [];
|
|
1391
|
+
let i = 0;
|
|
1392
|
+
for (const v of value.values()) {
|
|
1393
|
+
const result = visit(v, `${key}[${i}]`, depth + 1);
|
|
1394
|
+
if (result.keep) arr.push(result.value);
|
|
1395
|
+
i++;
|
|
1396
|
+
}
|
|
1397
|
+
return { keep: true, value: arr };
|
|
1398
|
+
}
|
|
1399
|
+
if (Array.isArray(value)) {
|
|
1400
|
+
if (seen.has(value)) {
|
|
1401
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1402
|
+
return { keep: true, value: "[circular]" };
|
|
1403
|
+
}
|
|
1404
|
+
seen.add(value);
|
|
1405
|
+
const out = [];
|
|
1406
|
+
for (let i = 0; i < value.length; i++) {
|
|
1407
|
+
const result = visit(value[i], `${key}[${i}]`, depth + 1);
|
|
1408
|
+
if (result.keep) out.push(result.value);
|
|
1409
|
+
}
|
|
1410
|
+
return { keep: true, value: out };
|
|
1411
|
+
}
|
|
1412
|
+
if (t === "object") {
|
|
1413
|
+
const obj = value;
|
|
1414
|
+
if (seen.has(obj)) {
|
|
1415
|
+
warnings.push({ kind: "circular_reference", key });
|
|
1416
|
+
return { keep: true, value: "[circular]" };
|
|
1417
|
+
}
|
|
1418
|
+
seen.add(obj);
|
|
1419
|
+
const out = {};
|
|
1420
|
+
for (const k of Object.keys(obj)) {
|
|
1421
|
+
const result = visit(obj[k], `${key}.${k}`, depth + 1);
|
|
1422
|
+
if (result.keep) out[k] = result.value;
|
|
1423
|
+
}
|
|
1424
|
+
return { keep: true, value: out };
|
|
1425
|
+
}
|
|
1426
|
+
warnings.push({ kind: "non_serialisable", key });
|
|
1427
|
+
try {
|
|
1428
|
+
return { keep: true, value: String(value) };
|
|
1429
|
+
} catch {
|
|
1430
|
+
return { keep: false, value: void 0 };
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
const cleaned = {};
|
|
1434
|
+
for (const k of Object.keys(input)) {
|
|
1435
|
+
const result = visit(input[k], k, 0);
|
|
1436
|
+
if (result.keep) cleaned[k] = result.value;
|
|
1437
|
+
}
|
|
1438
|
+
const serialised = safeStringify(cleaned);
|
|
1439
|
+
if (serialised && byteLength(serialised) > maxBatchPropertyBytes) {
|
|
1440
|
+
warnings.push({ kind: "size_cap_exceeded", key: "*" });
|
|
1441
|
+
const sizes = Object.keys(cleaned).map((k) => ({ k, size: byteLength(safeStringify(cleaned[k]) ?? "") })).sort((a, b) => b.size - a.size);
|
|
1442
|
+
let currentSize = byteLength(serialised);
|
|
1443
|
+
for (const { k } of sizes) {
|
|
1444
|
+
if (currentSize <= maxBatchPropertyBytes) break;
|
|
1445
|
+
currentSize -= sizes.find((s) => s.k === k).size;
|
|
1446
|
+
delete cleaned[k];
|
|
1447
|
+
}
|
|
1448
|
+
cleaned.__truncated = true;
|
|
1449
|
+
}
|
|
1450
|
+
return { properties: cleaned, warnings };
|
|
1451
|
+
}
|
|
1452
|
+
function safeStringify(v) {
|
|
1453
|
+
try {
|
|
1454
|
+
return JSON.stringify(v) ?? null;
|
|
1455
|
+
} catch {
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
function byteLength(s) {
|
|
1460
|
+
if (typeof TextEncoder !== "undefined") {
|
|
1461
|
+
return new TextEncoder().encode(s).length;
|
|
1462
|
+
}
|
|
1463
|
+
return s.length * 4;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// src/super-properties.ts
|
|
1467
|
+
var KEY_SUPER = "super_props";
|
|
1468
|
+
var KEY_GROUPS = "groups";
|
|
1469
|
+
var SuperPropertyStore = class {
|
|
1470
|
+
constructor(storage, prefix) {
|
|
1471
|
+
this.storage = storage;
|
|
1472
|
+
this.prefix = prefix;
|
|
1473
|
+
this.superProps = {};
|
|
1474
|
+
this.groups = {};
|
|
1475
|
+
this.superProps = readJson(storage, prefix + KEY_SUPER) ?? {};
|
|
1476
|
+
this.groups = readJson(storage, prefix + KEY_GROUPS) ?? {};
|
|
1477
|
+
}
|
|
1478
|
+
// ---------- super properties ----------
|
|
1479
|
+
/**
|
|
1480
|
+
* Merge new keys into the super-property bag. Returns a snapshot of
|
|
1481
|
+
* the resulting bag. Values that are `null` are deleted (Mixpanel
|
|
1482
|
+
* semantics — explicit null = "stop tracking this key").
|
|
1483
|
+
*/
|
|
1484
|
+
register(props) {
|
|
1485
|
+
for (const [k, v] of Object.entries(props)) {
|
|
1486
|
+
if (v === null) {
|
|
1487
|
+
delete this.superProps[k];
|
|
1488
|
+
} else if (v !== void 0) {
|
|
1489
|
+
this.superProps[k] = v;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1493
|
+
return { ...this.superProps };
|
|
1494
|
+
}
|
|
1495
|
+
/** Remove a single super-property key. Idempotent. */
|
|
1496
|
+
unregister(key) {
|
|
1497
|
+
if (key in this.superProps) {
|
|
1498
|
+
delete this.superProps[key];
|
|
1499
|
+
writeJson(this.storage, this.prefix + KEY_SUPER, this.superProps);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
/** Snapshot of the current super-property bag. */
|
|
1503
|
+
getSuperProperties() {
|
|
1504
|
+
return { ...this.superProps };
|
|
1505
|
+
}
|
|
1506
|
+
// ---------- groups ----------
|
|
1507
|
+
/**
|
|
1508
|
+
* Set a group membership. Passing `id: null` clears the membership
|
|
1509
|
+
* for that group type — the SDK stops attaching it to events.
|
|
1510
|
+
*/
|
|
1511
|
+
setGroup(type, id, traits) {
|
|
1512
|
+
if (id === null) {
|
|
1513
|
+
delete this.groups[type];
|
|
1514
|
+
} else {
|
|
1515
|
+
this.groups[type] = traits !== void 0 ? { id, traits } : { id };
|
|
1516
|
+
}
|
|
1517
|
+
writeJson(this.storage, this.prefix + KEY_GROUPS, this.groups);
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Snapshot of the current groups map, keyed by group type. Returned
|
|
1521
|
+
* shape mirrors what the SDK attaches to every event as
|
|
1522
|
+
* `$groups.{type}`. The `traits` sub-object is the most-recent
|
|
1523
|
+
* traits payload passed to `setGroup` for that type; null when none.
|
|
1524
|
+
*/
|
|
1525
|
+
getGroups() {
|
|
1526
|
+
return JSON.parse(JSON.stringify(this.groups));
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* The flat `{ type: id }` projection used for event-attachment. Stable
|
|
1530
|
+
* for fast every-event merge — we don't want to JSON-clone on each
|
|
1531
|
+
* track() call.
|
|
1532
|
+
*/
|
|
1533
|
+
getGroupIds() {
|
|
1534
|
+
const out = {};
|
|
1535
|
+
for (const [type, info] of Object.entries(this.groups)) {
|
|
1536
|
+
out[type] = info.id;
|
|
1537
|
+
}
|
|
1538
|
+
return out;
|
|
1539
|
+
}
|
|
1540
|
+
/** Wipe both bags. Called by Crossdeck.reset() (logout). */
|
|
1541
|
+
clear() {
|
|
1542
|
+
this.superProps = {};
|
|
1543
|
+
this.groups = {};
|
|
1544
|
+
try {
|
|
1545
|
+
this.storage.removeItem(this.prefix + KEY_SUPER);
|
|
1546
|
+
} catch {
|
|
1547
|
+
}
|
|
1548
|
+
try {
|
|
1549
|
+
this.storage.removeItem(this.prefix + KEY_GROUPS);
|
|
1550
|
+
} catch {
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
function readJson(storage, key) {
|
|
1555
|
+
let raw;
|
|
1556
|
+
try {
|
|
1557
|
+
raw = storage.getItem(key);
|
|
1558
|
+
} catch {
|
|
1559
|
+
return null;
|
|
1560
|
+
}
|
|
1561
|
+
if (!raw) return null;
|
|
1562
|
+
try {
|
|
1563
|
+
return JSON.parse(raw);
|
|
1564
|
+
} catch {
|
|
1565
|
+
return null;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
function writeJson(storage, key, value) {
|
|
1569
|
+
try {
|
|
1570
|
+
storage.setItem(key, JSON.stringify(value));
|
|
1571
|
+
} catch {
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/web-vitals.ts
|
|
1576
|
+
var WebVitalsTracker = class {
|
|
1577
|
+
constructor(cfg, report) {
|
|
1578
|
+
this.cfg = cfg;
|
|
1579
|
+
this.report = report;
|
|
1580
|
+
this.observers = [];
|
|
1581
|
+
this.flushed = /* @__PURE__ */ new Set();
|
|
1582
|
+
this.cls = 0;
|
|
1583
|
+
this.clsEntries = [];
|
|
1584
|
+
this.inp = 0;
|
|
1585
|
+
this.cleanups = [];
|
|
1586
|
+
}
|
|
1587
|
+
install() {
|
|
1588
|
+
if (!this.cfg.enabled) return;
|
|
1589
|
+
if (typeof PerformanceObserver === "undefined") return;
|
|
1590
|
+
if (typeof globalThis === "undefined" || !("document" in globalThis)) return;
|
|
1591
|
+
const doc = globalThis.document;
|
|
1592
|
+
try {
|
|
1593
|
+
const navObserver = new PerformanceObserver((list) => {
|
|
1594
|
+
for (const entry of list.getEntries()) {
|
|
1595
|
+
const e = entry;
|
|
1596
|
+
if (e.responseStart > 0 && !this.flushed.has("ttfb")) {
|
|
1597
|
+
this.flushed.add("ttfb");
|
|
1598
|
+
this.report("webvitals.ttfb", { valueMs: Math.round(e.responseStart - e.startTime) });
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1602
|
+
navObserver.observe({ type: "navigation", buffered: true });
|
|
1603
|
+
this.observers.push(navObserver);
|
|
1604
|
+
} catch {
|
|
1605
|
+
}
|
|
1606
|
+
try {
|
|
1607
|
+
const paintObserver = new PerformanceObserver((list) => {
|
|
1608
|
+
for (const entry of list.getEntries()) {
|
|
1609
|
+
if (entry.name === "first-contentful-paint" && !this.flushed.has("fcp")) {
|
|
1610
|
+
this.flushed.add("fcp");
|
|
1611
|
+
this.report("webvitals.fcp", { valueMs: Math.round(entry.startTime) });
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
paintObserver.observe({ type: "paint", buffered: true });
|
|
1616
|
+
this.observers.push(paintObserver);
|
|
1617
|
+
} catch {
|
|
1618
|
+
}
|
|
1619
|
+
let lcpValue = 0;
|
|
1620
|
+
try {
|
|
1621
|
+
const lcpObserver = new PerformanceObserver((list) => {
|
|
1622
|
+
const entries = list.getEntries();
|
|
1623
|
+
const last = entries[entries.length - 1];
|
|
1624
|
+
if (last) lcpValue = last.startTime;
|
|
1625
|
+
});
|
|
1626
|
+
lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
|
|
1627
|
+
this.observers.push(lcpObserver);
|
|
1628
|
+
} catch {
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
const clsObserver = new PerformanceObserver((list) => {
|
|
1632
|
+
for (const entry of list.getEntries()) {
|
|
1633
|
+
const e = entry;
|
|
1634
|
+
if (typeof e.value === "number" && !e.hadRecentInput) {
|
|
1635
|
+
this.cls += e.value;
|
|
1636
|
+
this.clsEntries.push(entry);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
clsObserver.observe({ type: "layout-shift", buffered: true });
|
|
1641
|
+
this.observers.push(clsObserver);
|
|
1642
|
+
} catch {
|
|
1643
|
+
}
|
|
1644
|
+
try {
|
|
1645
|
+
const eventObserver = new PerformanceObserver((list) => {
|
|
1646
|
+
for (const entry of list.getEntries()) {
|
|
1647
|
+
const e = entry;
|
|
1648
|
+
if (e.interactionId && e.duration > this.inp) {
|
|
1649
|
+
this.inp = e.duration;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
try {
|
|
1654
|
+
eventObserver.observe({ type: "event", buffered: true, durationThreshold: 16 });
|
|
1655
|
+
} catch {
|
|
1656
|
+
eventObserver.observe({ type: "first-input", buffered: true });
|
|
1657
|
+
}
|
|
1658
|
+
this.observers.push(eventObserver);
|
|
1659
|
+
} catch {
|
|
1660
|
+
}
|
|
1661
|
+
const flush = () => {
|
|
1662
|
+
if (lcpValue > 0 && !this.flushed.has("lcp")) {
|
|
1663
|
+
this.flushed.add("lcp");
|
|
1664
|
+
this.report("webvitals.lcp", { valueMs: Math.round(lcpValue) });
|
|
1665
|
+
}
|
|
1666
|
+
if (this.cls > 0 && !this.flushed.has("cls")) {
|
|
1667
|
+
this.flushed.add("cls");
|
|
1668
|
+
this.report("webvitals.cls", { value: Math.round(this.cls * 1e3) / 1e3 });
|
|
1669
|
+
}
|
|
1670
|
+
if (this.inp > 0 && !this.flushed.has("inp")) {
|
|
1671
|
+
this.flushed.add("inp");
|
|
1672
|
+
this.report("webvitals.inp", { valueMs: Math.round(this.inp) });
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
const onHidden = () => {
|
|
1676
|
+
if (doc.visibilityState === "hidden") flush();
|
|
1677
|
+
};
|
|
1678
|
+
doc.addEventListener("visibilitychange", onHidden);
|
|
1679
|
+
globalThis.window.addEventListener("pagehide", flush);
|
|
1680
|
+
this.cleanups.push(() => {
|
|
1681
|
+
doc.removeEventListener("visibilitychange", onHidden);
|
|
1682
|
+
globalThis.window.removeEventListener("pagehide", flush);
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
uninstall() {
|
|
1686
|
+
for (const o of this.observers) {
|
|
1687
|
+
try {
|
|
1688
|
+
o.disconnect();
|
|
1689
|
+
} catch {
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
this.observers = [];
|
|
1693
|
+
for (const fn of this.cleanups.splice(0)) {
|
|
1694
|
+
try {
|
|
1695
|
+
fn();
|
|
1696
|
+
} catch {
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
// src/consent.ts
|
|
1703
|
+
var ALL_GRANTED = {
|
|
1704
|
+
analytics: true,
|
|
1705
|
+
marketing: true,
|
|
1706
|
+
errors: true
|
|
1707
|
+
};
|
|
1708
|
+
var ConsentManager = class {
|
|
1709
|
+
constructor(options) {
|
|
1710
|
+
this.state = { ...ALL_GRANTED };
|
|
1711
|
+
this.dntDenied = false;
|
|
1712
|
+
if (options?.respectDnt && this.detectDnt()) {
|
|
1713
|
+
this.dntDenied = true;
|
|
1714
|
+
this.state = { analytics: false, marketing: false, errors: false };
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* Merge new dimensions onto the current state. Returns the resulting
|
|
1719
|
+
* snapshot. DNT-derived denies cannot be flipped back on by a `set`
|
|
1720
|
+
* call — once the browser says "don't track", we don't track even if
|
|
1721
|
+
* the developer code disagrees. That's the contract.
|
|
1722
|
+
*/
|
|
1723
|
+
set(partial) {
|
|
1724
|
+
if (this.dntDenied) return { ...this.state };
|
|
1725
|
+
for (const k of Object.keys(partial)) {
|
|
1726
|
+
const v = partial[k];
|
|
1727
|
+
if (typeof v === "boolean") this.state[k] = v;
|
|
1728
|
+
}
|
|
1729
|
+
return { ...this.state };
|
|
1730
|
+
}
|
|
1731
|
+
/** Snapshot of the current state. */
|
|
1732
|
+
get() {
|
|
1733
|
+
return { ...this.state };
|
|
1734
|
+
}
|
|
1735
|
+
/** Convenience getters for hot paths. */
|
|
1736
|
+
get analytics() {
|
|
1737
|
+
return this.state.analytics;
|
|
1738
|
+
}
|
|
1739
|
+
get marketing() {
|
|
1740
|
+
return this.state.marketing;
|
|
1741
|
+
}
|
|
1742
|
+
get errors() {
|
|
1743
|
+
return this.state.errors;
|
|
1744
|
+
}
|
|
1745
|
+
/** True iff the constructor detected and applied DNT. */
|
|
1746
|
+
get isDntDenied() {
|
|
1747
|
+
return this.dntDenied;
|
|
1748
|
+
}
|
|
1749
|
+
detectDnt() {
|
|
1750
|
+
try {
|
|
1751
|
+
const nav = globalThis.navigator;
|
|
1752
|
+
if (!nav) return false;
|
|
1753
|
+
const sources = [
|
|
1754
|
+
nav.doNotTrack,
|
|
1755
|
+
nav.msDoNotTrack,
|
|
1756
|
+
globalThis.doNotTrack
|
|
1757
|
+
];
|
|
1758
|
+
return sources.some((v) => v === "1" || v === "yes");
|
|
1759
|
+
} catch {
|
|
1760
|
+
return false;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
var EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
|
|
1765
|
+
var CARD_PATTERN = /\b\d(?:[ -]?\d){12,18}\b/g;
|
|
1766
|
+
var REPLACEMENT_EMAIL = "[email]";
|
|
1767
|
+
var REPLACEMENT_CARD = "[card]";
|
|
1768
|
+
function scrubPii(value) {
|
|
1769
|
+
if (!value) return value;
|
|
1770
|
+
let out = value;
|
|
1771
|
+
if (EMAIL_PATTERN.test(out)) {
|
|
1772
|
+
out = out.replace(EMAIL_PATTERN, REPLACEMENT_EMAIL);
|
|
1773
|
+
}
|
|
1774
|
+
EMAIL_PATTERN.lastIndex = 0;
|
|
1775
|
+
if (CARD_PATTERN.test(out)) {
|
|
1776
|
+
out = out.replace(CARD_PATTERN, REPLACEMENT_CARD);
|
|
1777
|
+
}
|
|
1778
|
+
CARD_PATTERN.lastIndex = 0;
|
|
1779
|
+
return out;
|
|
1780
|
+
}
|
|
1781
|
+
function scrubPiiFromProperties(properties) {
|
|
1782
|
+
const out = {};
|
|
1783
|
+
for (const k of Object.keys(properties)) {
|
|
1784
|
+
const v = properties[k];
|
|
1785
|
+
if (typeof v === "string") {
|
|
1786
|
+
out[k] = scrubPii(v);
|
|
1787
|
+
} else if (Array.isArray(v)) {
|
|
1788
|
+
out[k] = v.map((item) => typeof item === "string" ? scrubPii(item) : item);
|
|
1789
|
+
} else {
|
|
1790
|
+
out[k] = v;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return out;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// src/crossdeck.ts
|
|
1797
|
+
var CrossdeckClient = class {
|
|
1798
|
+
constructor() {
|
|
1799
|
+
this.state = null;
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Boot the SDK. Idempotent — calling init twice with the same options
|
|
1803
|
+
* is a no-op; calling with different options replaces the previous
|
|
1804
|
+
* configuration.
|
|
1805
|
+
*
|
|
1806
|
+
* NorthStar §11.1: signature is `Crossdeck.init({ appId, publicKey,
|
|
1807
|
+
* environment })`. The trio is validated up-front so a typo'd key or a
|
|
1808
|
+
* mismatched env fails fast at boot rather than at first event-flush.
|
|
1809
|
+
*/
|
|
1810
|
+
init(options) {
|
|
1811
|
+
if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
|
|
1812
|
+
throw new CrossdeckError({
|
|
1813
|
+
type: "configuration_error",
|
|
1814
|
+
code: "invalid_public_key",
|
|
1815
|
+
message: "Crossdeck.init requires a publishable key starting with cd_pub_."
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
if (!options.appId) {
|
|
1819
|
+
throw new CrossdeckError({
|
|
1820
|
+
type: "configuration_error",
|
|
1821
|
+
code: "missing_app_id",
|
|
1822
|
+
message: "Crossdeck.init requires an appId. Find yours in the Crossdeck dashboard."
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
if (options.environment !== "production" && options.environment !== "sandbox") {
|
|
1826
|
+
throw new CrossdeckError({
|
|
1827
|
+
type: "configuration_error",
|
|
1828
|
+
code: "invalid_environment",
|
|
1829
|
+
message: 'Crossdeck.init requires environment: "production" | "sandbox".'
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
const keyEnv = inferEnvFromKey(options.publicKey);
|
|
1833
|
+
if (keyEnv && keyEnv !== options.environment) {
|
|
1834
|
+
throw new CrossdeckError({
|
|
1835
|
+
type: "configuration_error",
|
|
1836
|
+
code: "environment_mismatch",
|
|
1837
|
+
message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
const localDevMode = isLocalHostname();
|
|
1841
|
+
const storage = options.storage ?? detectDefaultStorage();
|
|
1842
|
+
const persistIdentity = options.persistIdentity ?? true;
|
|
1843
|
+
const autoTrack = resolveAutoTrack(options.autoTrack);
|
|
1844
|
+
const opts = {
|
|
1845
|
+
appId: options.appId,
|
|
1846
|
+
publicKey: options.publicKey,
|
|
1847
|
+
environment: options.environment,
|
|
1848
|
+
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
|
|
1849
|
+
persistIdentity,
|
|
1850
|
+
storagePrefix: options.storagePrefix ?? "crossdeck:",
|
|
1851
|
+
autoHeartbeat: options.autoHeartbeat ?? true,
|
|
1852
|
+
eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
|
|
1853
|
+
// 1500ms idle window. Short enough that an event queued on page
|
|
1854
|
+
// load still flushes if the user leaves quickly (the keepalive
|
|
1855
|
+
// pagehide handler picks up anything that doesn't); long enough
|
|
1856
|
+
// that bursts of clicks coalesce into one network round-trip.
|
|
1857
|
+
eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
|
|
1858
|
+
sdkVersion: options.sdkVersion ?? SDK_VERSION,
|
|
1859
|
+
autoTrack,
|
|
1860
|
+
appVersion: options.appVersion ?? null
|
|
1861
|
+
};
|
|
1862
|
+
const debug = new ConsoleDebugLogger();
|
|
1863
|
+
debug.enabled = options.debug === true;
|
|
1864
|
+
const http = new HttpClient({
|
|
1865
|
+
publicKey: opts.publicKey,
|
|
1866
|
+
baseUrl: opts.baseUrl,
|
|
1867
|
+
sdkVersion: opts.sdkVersion,
|
|
1868
|
+
// Localhost auto-route: HttpClient short-circuits every request
|
|
1869
|
+
// to a successful no-op response when localDevMode is set.
|
|
1870
|
+
// SDK methods continue to work locally; nothing reaches the
|
|
1871
|
+
// server.
|
|
1872
|
+
localDevMode
|
|
1873
|
+
});
|
|
1874
|
+
if (localDevMode) {
|
|
1875
|
+
console.log(
|
|
1876
|
+
"[crossdeck] Localhost detected \u2014 running in dev mode (no network calls). Set publicKey: 'cd_pub_test_\u2026' and deploy to a real domain to test against the Crossdeck Sandbox."
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
1880
|
+
const useCookieRedundancy = persistIdentity && !options.storage && // honour caller's adapter choice
|
|
1881
|
+
typeof globalThis.document !== "undefined";
|
|
1882
|
+
const cookieStore = useCookieRedundancy ? new CookieStorage() : void 0;
|
|
1883
|
+
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix, cookieStore);
|
|
1884
|
+
const entitlements = new EntitlementCache();
|
|
1885
|
+
const persistentEvents = persistIdentity ? new PersistentEventStore({ storage: effectiveStorage, prefix: opts.storagePrefix }) : null;
|
|
1886
|
+
if (persistentEvents) {
|
|
1887
|
+
debug.emit(
|
|
1888
|
+
"sdk.queue_restored",
|
|
1889
|
+
"Restored persisted event queue from a prior session."
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
const events = new EventQueue({
|
|
1893
|
+
http,
|
|
1894
|
+
batchSize: opts.eventFlushBatchSize,
|
|
1895
|
+
intervalMs: opts.eventFlushIntervalMs,
|
|
1896
|
+
envelope: () => ({
|
|
1897
|
+
appId: opts.appId,
|
|
1898
|
+
environment: opts.environment,
|
|
1899
|
+
sdk: { name: SDK_NAME, version: opts.sdkVersion }
|
|
1900
|
+
}),
|
|
1901
|
+
persistentStore: persistentEvents ?? void 0,
|
|
1902
|
+
onFirstFlushSuccess: () => {
|
|
1903
|
+
debug.emit(
|
|
1904
|
+
"sdk.first_event_sent",
|
|
1905
|
+
"First telemetry event received. View it in Live Events.",
|
|
1906
|
+
{ appId: opts.appId, environment: opts.environment }
|
|
1907
|
+
);
|
|
1908
|
+
},
|
|
1909
|
+
onRetryScheduled: (info) => {
|
|
1910
|
+
debug.emit(
|
|
1911
|
+
"sdk.flush_retry_scheduled",
|
|
1912
|
+
`Event flush failed (${info.lastError}). Retrying in ${info.delayMs}ms (attempt ${info.consecutiveFailures}).`,
|
|
1913
|
+
{ ...info }
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
|
|
1918
|
+
const superProps = new SuperPropertyStore(
|
|
1919
|
+
persistIdentity ? effectiveStorage : new MemoryStorage(),
|
|
1920
|
+
opts.storagePrefix
|
|
1921
|
+
);
|
|
1922
|
+
const consent = new ConsentManager({ respectDnt: options.respectDnt === true });
|
|
1923
|
+
if (consent.isDntDenied) {
|
|
1924
|
+
debug.emit(
|
|
1925
|
+
"sdk.consent_dnt_applied",
|
|
1926
|
+
"Do Not Track detected \u2014 all tracking dimensions denied at init."
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
this.state = {
|
|
1930
|
+
http,
|
|
1931
|
+
identity,
|
|
1932
|
+
entitlements,
|
|
1933
|
+
events,
|
|
1934
|
+
autoTracker: null,
|
|
1935
|
+
webVitals: null,
|
|
1936
|
+
superProps,
|
|
1937
|
+
consent,
|
|
1938
|
+
scrubPii: options.scrubPii !== false,
|
|
1939
|
+
deviceInfo,
|
|
1940
|
+
options: opts,
|
|
1941
|
+
debug,
|
|
1942
|
+
developerUserId: null,
|
|
1943
|
+
uninstallUnloadFlush: null,
|
|
1944
|
+
lastServerTime: null,
|
|
1945
|
+
lastClientTime: null
|
|
1946
|
+
};
|
|
1947
|
+
debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
|
|
1948
|
+
appId: opts.appId,
|
|
1949
|
+
environment: opts.environment,
|
|
1950
|
+
sdkVersion: opts.sdkVersion
|
|
1951
|
+
});
|
|
1952
|
+
if (autoTrack.sessions || autoTrack.pageViews) {
|
|
1953
|
+
const tracker = new AutoTracker(
|
|
1954
|
+
autoTrack,
|
|
1955
|
+
(name, properties) => this.track(name, properties)
|
|
1956
|
+
);
|
|
1957
|
+
this.state.autoTracker = tracker;
|
|
1958
|
+
tracker.install();
|
|
1959
|
+
}
|
|
1960
|
+
if (autoTrack.webVitals) {
|
|
1961
|
+
const vitals = new WebVitalsTracker(
|
|
1962
|
+
{ enabled: true },
|
|
1963
|
+
(name, properties) => this.track(name, properties)
|
|
1964
|
+
);
|
|
1965
|
+
this.state.webVitals = vitals;
|
|
1966
|
+
vitals.install();
|
|
1967
|
+
}
|
|
1968
|
+
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
1969
|
+
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
1970
|
+
});
|
|
1971
|
+
if (opts.autoHeartbeat && !localDevMode) {
|
|
1972
|
+
void this.heartbeat().catch(() => void 0);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* @deprecated Use `init()` instead. NorthStar §4 standardised the
|
|
1977
|
+
* lifecycle method name across SDKs as `init` (formerly `start` /
|
|
1978
|
+
* `configure`). `start` will be removed in a future major version.
|
|
1979
|
+
*/
|
|
1980
|
+
start(options) {
|
|
1981
|
+
if (typeof console !== "undefined") {
|
|
1982
|
+
console.warn(
|
|
1983
|
+
"[crossdeck] Crossdeck.start() is deprecated \u2014 use Crossdeck.init() instead. The signature is the same."
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
this.init(options);
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Link the anonymous device to a developer-supplied user ID. Cache
|
|
1990
|
+
* the resolved Crossdeck customer for follow-up calls.
|
|
1991
|
+
*
|
|
1992
|
+
* v0.9.0+ accepts an optional `traits` bag — profile data (name,
|
|
1993
|
+
* plan, signupDate, role) persisted on the Crossdeck customer record
|
|
1994
|
+
* and queryable from dashboards. Traits are sanitised through the
|
|
1995
|
+
* same validator that gates `track()` properties, so a `{ avatar:
|
|
1996
|
+
* <File>, onSave: () => {} }` payload can't corrupt the alias call.
|
|
1997
|
+
*
|
|
1998
|
+
* Crossdeck.identify("user_847", {
|
|
1999
|
+
* email: "wes@pinet.co.za",
|
|
2000
|
+
* traits: { name: "Wes", plan: "pro", signedUpAt: "2026-05-11" },
|
|
2001
|
+
* });
|
|
2002
|
+
*/
|
|
2003
|
+
async identify(userId, options) {
|
|
2004
|
+
const s = this.requireStarted();
|
|
2005
|
+
if (!userId) {
|
|
2006
|
+
throw new CrossdeckError({
|
|
2007
|
+
type: "invalid_request_error",
|
|
2008
|
+
code: "missing_user_id",
|
|
2009
|
+
message: "identify(userId) requires a non-empty userId."
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
if (!s.consent.analytics) {
|
|
2013
|
+
s.debug.emit(
|
|
2014
|
+
"sdk.consent_denied",
|
|
2015
|
+
`identify() skipped \u2014 consent denied for analytics.`
|
|
2016
|
+
);
|
|
2017
|
+
return {
|
|
2018
|
+
object: "alias_result",
|
|
2019
|
+
crossdeckCustomerId: s.identity.crossdeckCustomerId ?? "",
|
|
2020
|
+
linked: [],
|
|
2021
|
+
mergePending: false,
|
|
2022
|
+
env: s.options.environment
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
const traitsValidation = options?.traits !== void 0 ? validateEventProperties(options.traits) : null;
|
|
2026
|
+
const traits = traitsValidation && Object.keys(traitsValidation.properties).length > 0 ? traitsValidation.properties : void 0;
|
|
2027
|
+
if (s.debug.enabled && traitsValidation && traitsValidation.warnings.length > 0) {
|
|
2028
|
+
for (const w of traitsValidation.warnings) {
|
|
2029
|
+
s.debug.emit(
|
|
2030
|
+
"sdk.property_coerced",
|
|
2031
|
+
`identify() traits key ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2032
|
+
{ key: w.key, kind: w.kind }
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
const body = {
|
|
2037
|
+
userId,
|
|
2038
|
+
anonymousId: s.identity.anonymousId
|
|
2039
|
+
};
|
|
2040
|
+
if (options?.email) body.email = options.email;
|
|
2041
|
+
if (traits) body.traits = traits;
|
|
2042
|
+
const result = await s.http.request("POST", "/identity/alias", {
|
|
2043
|
+
body
|
|
2044
|
+
});
|
|
2045
|
+
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
2046
|
+
s.developerUserId = userId;
|
|
2047
|
+
return result;
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Register super-properties — Mixpanel pattern. Once set, every
|
|
2051
|
+
* subsequent event of THIS SDK instance carries these keys on its
|
|
2052
|
+
* properties bag automatically.
|
|
2053
|
+
*
|
|
2054
|
+
* Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
|
|
2055
|
+
* Crossdeck.track("paywall_shown"); // includes plan + releaseChannel
|
|
2056
|
+
*
|
|
2057
|
+
* Values that are `null` are deleted (the explicit "stop tracking
|
|
2058
|
+
* this key" idiom). Returns the resulting bag.
|
|
2059
|
+
*
|
|
2060
|
+
* Sanitised through `validateEventProperties` so a `{ avatar: File }`
|
|
2061
|
+
* payload can't poison the queue at flush time.
|
|
2062
|
+
*/
|
|
2063
|
+
register(properties) {
|
|
2064
|
+
const s = this.requireStarted();
|
|
2065
|
+
const validation = validateEventProperties(properties);
|
|
2066
|
+
return s.superProps.register(validation.properties);
|
|
2067
|
+
}
|
|
2068
|
+
/** Remove a single super-property key. Idempotent. */
|
|
2069
|
+
unregister(key) {
|
|
2070
|
+
const s = this.requireStarted();
|
|
2071
|
+
s.superProps.unregister(key);
|
|
2072
|
+
}
|
|
2073
|
+
/** Snapshot of the current super-property bag. */
|
|
2074
|
+
getSuperProperties() {
|
|
2075
|
+
if (!this.state) return {};
|
|
2076
|
+
return this.state.superProps.getSuperProperties();
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Associate the current user with a group (org, team, account, etc.).
|
|
2080
|
+
* Mixpanel / Segment "Group Analytics" pattern.
|
|
2081
|
+
*
|
|
2082
|
+
* Crossdeck.group("org", "acme_inc");
|
|
2083
|
+
* Crossdeck.group("team", "design", { headcount: 12 });
|
|
2084
|
+
*
|
|
2085
|
+
* Once set, every subsequent event carries `$groups.<type>: id` on
|
|
2086
|
+
* its properties bag, enabling B2B dashboards ("how is Acme using
|
|
2087
|
+
* the product"). Pass `id: null` to clear a group membership.
|
|
2088
|
+
*/
|
|
2089
|
+
group(type, id, traits) {
|
|
2090
|
+
const s = this.requireStarted();
|
|
2091
|
+
if (!type) {
|
|
2092
|
+
throw new CrossdeckError({
|
|
2093
|
+
type: "invalid_request_error",
|
|
2094
|
+
code: "missing_group_type",
|
|
2095
|
+
message: "group(type, id) requires a non-empty type."
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
const sanitisedTraits = traits ? validateEventProperties(traits).properties : void 0;
|
|
2099
|
+
s.superProps.setGroup(type, id, sanitisedTraits);
|
|
2100
|
+
}
|
|
2101
|
+
/** Snapshot of the current groups map keyed by type. */
|
|
2102
|
+
getGroups() {
|
|
2103
|
+
if (!this.state) return {};
|
|
2104
|
+
return this.state.superProps.getGroups();
|
|
2105
|
+
}
|
|
2106
|
+
/**
|
|
2107
|
+
* Update consent state. Three independent dimensions:
|
|
2108
|
+
*
|
|
2109
|
+
* analytics — track() + identify() + auto-emissions
|
|
2110
|
+
* marketing — paid-traffic click IDs + referrer URL on events
|
|
2111
|
+
* errors — Web Vitals + (future) error reporting
|
|
2112
|
+
*
|
|
2113
|
+
* Each defaults to `true` (granted). Pass partial state — only the
|
|
2114
|
+
* keys you provide are changed.
|
|
2115
|
+
*
|
|
2116
|
+
* Crossdeck.consent({ analytics: false });
|
|
2117
|
+
* Crossdeck.consent({ marketing: true, errors: true });
|
|
2118
|
+
*
|
|
2119
|
+
* DNT-derived denies cannot be flipped back on; if the browser said
|
|
2120
|
+
* "don't track" we don't track even if the developer code disagrees.
|
|
2121
|
+
*/
|
|
2122
|
+
consent(state) {
|
|
2123
|
+
const s = this.requireStarted();
|
|
2124
|
+
const next = s.consent.set(state);
|
|
2125
|
+
s.debug.emit("sdk.consent_changed", "Consent state updated.", { ...next });
|
|
2126
|
+
return next;
|
|
2127
|
+
}
|
|
2128
|
+
/** Snapshot of the current consent state. */
|
|
2129
|
+
consentStatus() {
|
|
2130
|
+
if (!this.state) {
|
|
2131
|
+
return { analytics: true, marketing: true, errors: true };
|
|
2132
|
+
}
|
|
2133
|
+
return this.state.consent.get();
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* GDPR/CCPA "right to be forgotten" — calls the backend's
|
|
2137
|
+
* /v1/identity/forget endpoint to schedule a server-side deletion of
|
|
2138
|
+
* the customer's events and profile, then wipes all local state
|
|
2139
|
+
* (identity, entitlements, queue, super-props, persistent stores).
|
|
2140
|
+
*
|
|
2141
|
+
* Idempotent. Safe to call when no identity has been established
|
|
2142
|
+
* (it just wipes the empty local state).
|
|
2143
|
+
*
|
|
2144
|
+
* After forget() resolves, the SDK is in the same shape as if the
|
|
2145
|
+
* developer had called `Crossdeck.reset()` — a fresh anonymousId is
|
|
2146
|
+
* minted and the next session is a brand new identity-graph entry.
|
|
2147
|
+
*/
|
|
2148
|
+
async forget() {
|
|
2149
|
+
const s = this.requireStarted();
|
|
2150
|
+
const identityQuery = this.identityQueryParams();
|
|
2151
|
+
try {
|
|
2152
|
+
await s.http.request("POST", "/identity/forget", {
|
|
2153
|
+
body: {
|
|
2154
|
+
// Send every identity hint we hold; the server resolves the
|
|
2155
|
+
// canonical customer record and queues deletion. Missing
|
|
2156
|
+
// endpoint (older backend) gracefully degrades — local state
|
|
2157
|
+
// still wipes via the reset() call below.
|
|
2158
|
+
...identityQuery
|
|
2159
|
+
}
|
|
2160
|
+
});
|
|
2161
|
+
} catch (err) {
|
|
2162
|
+
s.debug.emit(
|
|
2163
|
+
"sdk.consent_denied",
|
|
2164
|
+
`forget() server call failed (${err instanceof Error ? err.message : String(err)}). Local state wiped anyway.`
|
|
2165
|
+
);
|
|
2166
|
+
}
|
|
2167
|
+
this.reset();
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Read the current customer's active entitlements from the server.
|
|
2171
|
+
* Updates the local cache so subsequent isEntitled() calls answer
|
|
2172
|
+
* synchronously.
|
|
2173
|
+
*/
|
|
2174
|
+
async getEntitlements() {
|
|
2175
|
+
const s = this.requireStarted();
|
|
2176
|
+
const query = this.identityQueryParams();
|
|
2177
|
+
const result = await s.http.request(
|
|
2178
|
+
"GET",
|
|
2179
|
+
"/entitlements",
|
|
2180
|
+
{ query }
|
|
2181
|
+
);
|
|
2182
|
+
if (result.crossdeckCustomerId) {
|
|
2183
|
+
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
2184
|
+
}
|
|
2185
|
+
s.entitlements.setFromList(result.data);
|
|
2186
|
+
return result.data;
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Synchronous read from the local cache. Returns false if the cache
|
|
2190
|
+
* has never been populated (call getEntitlements first to warm it).
|
|
2191
|
+
*/
|
|
2192
|
+
isEntitled(key) {
|
|
2193
|
+
const s = this.requireStarted();
|
|
2194
|
+
return s.entitlements.isEntitled(key);
|
|
2195
|
+
}
|
|
2196
|
+
/** Snapshot of the local entitlement cache. */
|
|
2197
|
+
listEntitlements() {
|
|
2198
|
+
const s = this.requireStarted();
|
|
2199
|
+
return s.entitlements.list();
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
|
|
2203
|
+
*
|
|
2204
|
+
* The listener is invoked AFTER the cache mutates — once after a
|
|
2205
|
+
* successful `getEntitlements()` warms it, again after `syncPurchases()`
|
|
2206
|
+
* delivers fresh entitlements, and once on `reset()` to fire the
|
|
2207
|
+
* empty-cache state for logout flows.
|
|
2208
|
+
*
|
|
2209
|
+
* It is NOT invoked synchronously on subscribe. Callers that need
|
|
2210
|
+
* the current state should read it via `isEntitled()` / `listEntitlements()`
|
|
2211
|
+
* inline; the listener fires only on FUTURE changes.
|
|
2212
|
+
*
|
|
2213
|
+
* This is the foundation of the `useEntitlement` React hook in
|
|
2214
|
+
* `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
|
|
2215
|
+
* / Vue) would have no way to re-render when entitlements arrive
|
|
2216
|
+
* asynchronously after init. The naive pattern of calling
|
|
2217
|
+
* `Crossdeck.isEntitled("pro")` directly inside a render path
|
|
2218
|
+
* shows the empty-cache result forever; binding the result to
|
|
2219
|
+
* component state via `onEntitlementsChange` is the correct
|
|
2220
|
+
* pattern.
|
|
2221
|
+
*
|
|
2222
|
+
* Idempotent unsubscribe — calling the returned function multiple
|
|
2223
|
+
* times is safe.
|
|
2224
|
+
*
|
|
2225
|
+
* Listener errors are swallowed (a buggy listener can't crash the
|
|
2226
|
+
* SDK or other listeners).
|
|
2227
|
+
*/
|
|
2228
|
+
onEntitlementsChange(listener) {
|
|
2229
|
+
const s = this.requireStarted();
|
|
2230
|
+
return s.entitlements.subscribe(listener);
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Queue a telemetry event. Returns immediately — the network round-
|
|
2234
|
+
* trip happens in the background. To flush before the page unloads,
|
|
2235
|
+
* call flush().
|
|
2236
|
+
*/
|
|
2237
|
+
track(name, properties) {
|
|
2238
|
+
const s = this.requireStarted();
|
|
2239
|
+
if (!name) {
|
|
2240
|
+
throw new CrossdeckError({
|
|
2241
|
+
type: "invalid_request_error",
|
|
2242
|
+
code: "missing_event_name",
|
|
2243
|
+
message: "track(name) requires a non-empty name."
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
const isWebVital = name.startsWith("webvitals.");
|
|
2247
|
+
const consentGateOk = isWebVital ? s.consent.errors : s.consent.analytics;
|
|
2248
|
+
if (!consentGateOk) {
|
|
2249
|
+
if (s.debug.enabled) {
|
|
2250
|
+
s.debug.emit(
|
|
2251
|
+
"sdk.consent_denied",
|
|
2252
|
+
`Dropped event "${name}" \u2014 consent denied for ${isWebVital ? "errors" : "analytics"}.`
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
if (s.debug.enabled && properties) {
|
|
2258
|
+
const flagged = findSensitivePropertyKeys(properties);
|
|
2259
|
+
if (flagged.length > 0) {
|
|
2260
|
+
s.debug.emit(
|
|
2261
|
+
"sdk.sensitive_property_warning",
|
|
2262
|
+
`Event "${name}" has potentially sensitive property names: ${flagged.join(", ")}. Crossdeck is privacy-first \u2014 avoid sending PII unless intentional.`,
|
|
2263
|
+
{ eventName: name, flagged }
|
|
2264
|
+
);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
if (s.debug.enabled && !s.developerUserId && !s.identity.crossdeckCustomerId) {
|
|
2268
|
+
s.debug.emit(
|
|
2269
|
+
"sdk.no_identity",
|
|
2270
|
+
"Using anonymous user until identify(userId) is called."
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
const validation = validateEventProperties(properties);
|
|
2274
|
+
if (s.debug.enabled && validation.warnings.length > 0) {
|
|
2275
|
+
for (const w of validation.warnings) {
|
|
2276
|
+
s.debug.emit(
|
|
2277
|
+
"sdk.property_coerced",
|
|
2278
|
+
`Event "${name}" property ${JSON.stringify(w.key)} was ${w.kind.replace(/_/g, " ")} during validation.`,
|
|
2279
|
+
{ eventName: name, key: w.key, kind: w.kind }
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
const enriched = { ...s.deviceInfo };
|
|
2284
|
+
const sessionId = s.autoTracker?.currentSessionId;
|
|
2285
|
+
if (sessionId) enriched.sessionId = sessionId;
|
|
2286
|
+
const pageviewId = s.autoTracker?.currentPageviewId;
|
|
2287
|
+
if (pageviewId) enriched.pageviewId = pageviewId;
|
|
2288
|
+
const acquisition = s.autoTracker?.currentAcquisition;
|
|
2289
|
+
if (acquisition) {
|
|
2290
|
+
if (acquisition.utm_source) enriched.utm_source = acquisition.utm_source;
|
|
2291
|
+
if (acquisition.utm_medium) enriched.utm_medium = acquisition.utm_medium;
|
|
2292
|
+
if (acquisition.utm_campaign) enriched.utm_campaign = acquisition.utm_campaign;
|
|
2293
|
+
if (acquisition.utm_content) enriched.utm_content = acquisition.utm_content;
|
|
2294
|
+
if (acquisition.utm_term) enriched.utm_term = acquisition.utm_term;
|
|
2295
|
+
if (acquisition.referrer && s.consent.marketing) enriched.referrer = acquisition.referrer;
|
|
2296
|
+
if (s.consent.marketing) {
|
|
2297
|
+
if (acquisition.gclid) enriched.gclid = acquisition.gclid;
|
|
2298
|
+
if (acquisition.fbclid) enriched.fbclid = acquisition.fbclid;
|
|
2299
|
+
if (acquisition.msclkid) enriched.msclkid = acquisition.msclkid;
|
|
2300
|
+
if (acquisition.ttclid) enriched.ttclid = acquisition.ttclid;
|
|
2301
|
+
if (acquisition.li_fat_id) enriched.li_fat_id = acquisition.li_fat_id;
|
|
2302
|
+
if (acquisition.twclid) enriched.twclid = acquisition.twclid;
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
const supers = s.superProps.getSuperProperties();
|
|
2306
|
+
for (const k of Object.keys(supers)) {
|
|
2307
|
+
if (!(k in enriched)) enriched[k] = supers[k];
|
|
2308
|
+
}
|
|
2309
|
+
const groupIds = s.superProps.getGroupIds();
|
|
2310
|
+
if (Object.keys(groupIds).length > 0) {
|
|
2311
|
+
enriched.$groups = groupIds;
|
|
2312
|
+
}
|
|
2313
|
+
Object.assign(enriched, validation.properties);
|
|
2314
|
+
const finalProperties = s.scrubPii ? scrubPiiFromProperties(enriched) : enriched;
|
|
2315
|
+
const event = {
|
|
2316
|
+
eventId: this.mintEventId(),
|
|
2317
|
+
name,
|
|
2318
|
+
timestamp: Date.now(),
|
|
2319
|
+
properties: finalProperties
|
|
2320
|
+
};
|
|
2321
|
+
Object.assign(event, this.identityHintForEvent());
|
|
2322
|
+
s.events.enqueue(event);
|
|
2323
|
+
}
|
|
2324
|
+
/**
|
|
2325
|
+
* Force-flush queued events. Useful to call from page-unload handlers.
|
|
2326
|
+
*
|
|
2327
|
+
* Pass `{ keepalive: true }` from terminal handlers (pagehide /
|
|
2328
|
+
* visibilitychange→hidden / beforeunload). The browser keeps the
|
|
2329
|
+
* request alive after the page tears down, so the final batch
|
|
2330
|
+
* actually lands instead of being cancelled with the unload.
|
|
2331
|
+
*
|
|
2332
|
+
* NorthStar §4: standard method name across all Crossdeck SDKs.
|
|
2333
|
+
*/
|
|
2334
|
+
async flush(options = {}) {
|
|
2335
|
+
const s = this.requireStarted();
|
|
2336
|
+
await s.events.flush(options);
|
|
2337
|
+
}
|
|
2338
|
+
/** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
|
|
2339
|
+
async flushEvents() {
|
|
2340
|
+
return this.flush();
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Forward purchase evidence to the backend for verification + entitlement
|
|
2344
|
+
* projection. NorthStar §4 + §13 canonical name.
|
|
2345
|
+
*
|
|
2346
|
+
* Today the web SDK only supports Apple StoreKit 2 forwarding (web apps
|
|
2347
|
+
* that sit alongside an iOS app). Stripe doesn't need this method —
|
|
2348
|
+
* Stripe webhooks deliver evidence server-side without a client round-trip.
|
|
2349
|
+
*/
|
|
2350
|
+
async syncPurchases(input) {
|
|
2351
|
+
const s = this.requireStarted();
|
|
2352
|
+
if (!input.signedTransactionInfo) {
|
|
2353
|
+
throw new CrossdeckError({
|
|
2354
|
+
type: "invalid_request_error",
|
|
2355
|
+
code: "missing_signed_transaction_info",
|
|
2356
|
+
message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
const result = await s.http.request("POST", "/purchases/sync", {
|
|
2360
|
+
body: { rail: input.rail ?? "apple", ...input }
|
|
2361
|
+
});
|
|
2362
|
+
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
2363
|
+
s.entitlements.setFromList(result.entitlements);
|
|
2364
|
+
s.debug.emit(
|
|
2365
|
+
"sdk.purchase_evidence_sent",
|
|
2366
|
+
"StoreKit transaction forwarded. Waiting for backend verification.",
|
|
2367
|
+
{ rail: input.rail ?? "apple" }
|
|
2368
|
+
);
|
|
2369
|
+
return result;
|
|
2370
|
+
}
|
|
2371
|
+
/** @deprecated Use `syncPurchases()` instead. NorthStar §4 standardised the name. */
|
|
2372
|
+
async purchaseApple(input) {
|
|
2373
|
+
return this.syncPurchases({ rail: "apple", ...input });
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* Toggle verbose diagnostic logging — NorthStar §16. When enabled, the
|
|
2377
|
+
* SDK emits a fixed vocabulary of debug signals to console.info that the
|
|
2378
|
+
* dashboard's onboarding checklist can also surface as live events.
|
|
2379
|
+
*/
|
|
2380
|
+
setDebugMode(enabled) {
|
|
2381
|
+
const s = this.requireStarted();
|
|
2382
|
+
s.debug.enabled = enabled;
|
|
2383
|
+
if (enabled) {
|
|
2384
|
+
s.debug.emit(
|
|
2385
|
+
"sdk.configured",
|
|
2386
|
+
`Debug mode enabled for ${s.options.appId} in ${s.options.environment} mode.`,
|
|
2387
|
+
{ appId: s.options.appId, environment: s.options.environment }
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Send the boot heartbeat. Called automatically by start() unless
|
|
2393
|
+
* autoHeartbeat:false. Safe to call manually as a "we're still here" ping.
|
|
2394
|
+
*/
|
|
2395
|
+
async heartbeat() {
|
|
2396
|
+
const s = this.requireStarted();
|
|
2397
|
+
const result = await s.http.request("GET", "/sdk/heartbeat");
|
|
2398
|
+
if (typeof result?.serverTime === "number" && Number.isFinite(result.serverTime)) {
|
|
2399
|
+
s.lastServerTime = result.serverTime;
|
|
2400
|
+
s.lastClientTime = Date.now();
|
|
2401
|
+
}
|
|
2402
|
+
return result;
|
|
2403
|
+
}
|
|
2404
|
+
/**
|
|
2405
|
+
* Wipe persisted identity + entitlement cache. Use on logout. The
|
|
2406
|
+
* next pre-login session generates a fresh anonymousId and starts a
|
|
2407
|
+
* new identity-graph entry.
|
|
2408
|
+
*/
|
|
2409
|
+
reset() {
|
|
2410
|
+
if (!this.state) return;
|
|
2411
|
+
if (this.state.developerUserId) {
|
|
2412
|
+
try {
|
|
2413
|
+
this.track("user.signed_out", { auto: true });
|
|
2414
|
+
} catch {
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
this.state.autoTracker?.uninstall();
|
|
2418
|
+
this.state.identity.reset();
|
|
2419
|
+
this.state.entitlements.clear();
|
|
2420
|
+
this.state.events.reset();
|
|
2421
|
+
this.state.superProps.clear();
|
|
2422
|
+
this.state.developerUserId = null;
|
|
2423
|
+
if (this.state.autoTracker) {
|
|
2424
|
+
const tracker = new AutoTracker(
|
|
2425
|
+
this.state.options.autoTrack,
|
|
2426
|
+
(name, props) => this.track(name, props)
|
|
2427
|
+
);
|
|
2428
|
+
this.state.autoTracker = tracker;
|
|
2429
|
+
tracker.install();
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
/**
|
|
2433
|
+
* Diagnostic: current state + queue stats. Useful for the dashboard's
|
|
2434
|
+
* heartbeat row and debugging in dev.
|
|
2435
|
+
*
|
|
2436
|
+
* Returns a stable shape regardless of whether start() has been called —
|
|
2437
|
+
* callers don't need to narrow on `started` to access `events` or
|
|
2438
|
+
* `entitlements`. Pre-start values are sensible empties.
|
|
2439
|
+
*/
|
|
2440
|
+
diagnostics() {
|
|
2441
|
+
if (!this.state) {
|
|
2442
|
+
return {
|
|
2443
|
+
started: false,
|
|
2444
|
+
anonymousId: null,
|
|
2445
|
+
crossdeckCustomerId: null,
|
|
2446
|
+
developerUserId: null,
|
|
2447
|
+
sdkVersion: null,
|
|
2448
|
+
baseUrl: null,
|
|
2449
|
+
clock: { lastServerTime: null, lastClientTime: null, skewMs: null },
|
|
2450
|
+
entitlements: { count: 0, lastUpdated: 0, listenerErrors: 0 },
|
|
2451
|
+
events: {
|
|
2452
|
+
buffered: 0,
|
|
2453
|
+
dropped: 0,
|
|
2454
|
+
inFlight: 0,
|
|
2455
|
+
lastFlushAt: 0,
|
|
2456
|
+
lastError: null,
|
|
2457
|
+
consecutiveFailures: 0,
|
|
2458
|
+
nextRetryAt: null
|
|
2459
|
+
}
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
const s = this.state;
|
|
2463
|
+
const skewMs = s.lastServerTime !== null && s.lastClientTime !== null ? s.lastClientTime - s.lastServerTime : null;
|
|
2464
|
+
return {
|
|
2465
|
+
started: true,
|
|
2466
|
+
anonymousId: s.identity.anonymousId,
|
|
2467
|
+
crossdeckCustomerId: s.identity.crossdeckCustomerId,
|
|
2468
|
+
developerUserId: s.developerUserId,
|
|
2469
|
+
sdkVersion: s.options.sdkVersion,
|
|
2470
|
+
baseUrl: s.options.baseUrl,
|
|
2471
|
+
clock: {
|
|
2472
|
+
lastServerTime: s.lastServerTime,
|
|
2473
|
+
lastClientTime: s.lastClientTime,
|
|
2474
|
+
skewMs
|
|
2475
|
+
},
|
|
2476
|
+
entitlements: {
|
|
2477
|
+
count: s.entitlements.list().length,
|
|
2478
|
+
lastUpdated: s.entitlements.freshness,
|
|
2479
|
+
listenerErrors: s.entitlements.listenerErrors
|
|
2480
|
+
},
|
|
2481
|
+
events: s.events.getStats()
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
// ---------- private helpers ----------
|
|
2485
|
+
requireStarted() {
|
|
2486
|
+
if (!this.state) {
|
|
2487
|
+
throw new CrossdeckError({
|
|
2488
|
+
type: "configuration_error",
|
|
2489
|
+
code: "not_initialized",
|
|
2490
|
+
message: "Call Crossdeck.init({ appId, publicKey, environment }) before any other method."
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
return this.state;
|
|
2494
|
+
}
|
|
2495
|
+
/**
|
|
2496
|
+
* Build the identity query for /v1/entitlements. Priority:
|
|
2497
|
+
* crossdeckCustomerId > developerUserId > anonymousId
|
|
2498
|
+
* — matches the resolveCrossdeckCustomerId precedence on the server.
|
|
2499
|
+
*/
|
|
2500
|
+
identityQueryParams() {
|
|
2501
|
+
const s = this.requireStarted();
|
|
2502
|
+
if (s.identity.crossdeckCustomerId) {
|
|
2503
|
+
return { customerId: s.identity.crossdeckCustomerId };
|
|
2504
|
+
}
|
|
2505
|
+
if (s.developerUserId) return { userId: s.developerUserId };
|
|
2506
|
+
return { anonymousId: s.identity.anonymousId };
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* Embed every known identity axis on the event. Earlier this returned
|
|
2510
|
+
* just the highest-priority hint (cdcust → developerUserId → anonymousId)
|
|
2511
|
+
* to keep payloads small, but that leaked into analytics: once a user
|
|
2512
|
+
* was logged in, every subsequent page.viewed shipped without
|
|
2513
|
+
* anonymousId, and `uniqExact(anonymous_id)` on the warehouse side
|
|
2514
|
+
* counted 0 visitors for the entire authenticated app.
|
|
2515
|
+
*
|
|
2516
|
+
* Bank-grade rule: the server is the single source of truth on
|
|
2517
|
+
* dedup. Send everything we know; let CH count by whichever axis
|
|
2518
|
+
* matches the question. Each field is at most 32 bytes — sending
|
|
2519
|
+
* three on every event costs ~80 bytes per request, which is
|
|
2520
|
+
* trivial compared to the analytics correctness it buys.
|
|
2521
|
+
*/
|
|
2522
|
+
identityHintForEvent() {
|
|
2523
|
+
const s = this.requireStarted();
|
|
2524
|
+
const hint = {
|
|
2525
|
+
anonymousId: s.identity.anonymousId
|
|
2526
|
+
};
|
|
2527
|
+
if (s.developerUserId) hint.developerUserId = s.developerUserId;
|
|
2528
|
+
if (s.identity.crossdeckCustomerId) {
|
|
2529
|
+
hint.crossdeckCustomerId = s.identity.crossdeckCustomerId;
|
|
2530
|
+
}
|
|
2531
|
+
return hint;
|
|
2532
|
+
}
|
|
2533
|
+
mintEventId() {
|
|
2534
|
+
const ts = Date.now().toString(36);
|
|
2535
|
+
return `evt_${ts}${randomChars(8)}`;
|
|
2536
|
+
}
|
|
2537
|
+
};
|
|
2538
|
+
var Crossdeck = new CrossdeckClient();
|
|
2539
|
+
function inferEnvFromKey(publicKey) {
|
|
2540
|
+
if (publicKey.startsWith("cd_pub_test_")) return "sandbox";
|
|
2541
|
+
if (publicKey.startsWith("cd_pub_live_")) return "production";
|
|
2542
|
+
return null;
|
|
2543
|
+
}
|
|
2544
|
+
function isLocalHostname() {
|
|
2545
|
+
const w = globalThis.window;
|
|
2546
|
+
if (w?.__CROSSDECK_FORCE_LIVE__ === true) return false;
|
|
2547
|
+
const hostname = w?.location?.hostname;
|
|
2548
|
+
if (!hostname) return false;
|
|
2549
|
+
if (hostname === "localhost" || hostname === "127.0.0.1") return true;
|
|
2550
|
+
if (hostname === "::1" || hostname === "[::1]") return true;
|
|
2551
|
+
if (hostname.endsWith(".local")) return true;
|
|
2552
|
+
if (/^10\./.test(hostname)) return true;
|
|
2553
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
2554
|
+
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) return true;
|
|
2555
|
+
return false;
|
|
2556
|
+
}
|
|
2557
|
+
function resolveAutoTrack(input) {
|
|
2558
|
+
if (input === false) {
|
|
2559
|
+
return {
|
|
2560
|
+
sessions: false,
|
|
2561
|
+
pageViews: false,
|
|
2562
|
+
deviceInfo: false,
|
|
2563
|
+
clicks: false,
|
|
2564
|
+
webVitals: false
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
if (input === void 0 || input === true) {
|
|
2568
|
+
return { ...DEFAULT_AUTO_TRACK };
|
|
2569
|
+
}
|
|
2570
|
+
return {
|
|
2571
|
+
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
2572
|
+
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
2573
|
+
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo,
|
|
2574
|
+
clicks: input.clicks ?? DEFAULT_AUTO_TRACK.clicks,
|
|
2575
|
+
webVitals: input.webVitals ?? DEFAULT_AUTO_TRACK.webVitals
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
function installUnloadFlush(onUnload) {
|
|
2579
|
+
const w = globalThis.window;
|
|
2580
|
+
const doc = globalThis.document;
|
|
2581
|
+
if (!w || !doc) return () => void 0;
|
|
2582
|
+
const onVisChange = () => {
|
|
2583
|
+
if (doc.visibilityState === "hidden") onUnload();
|
|
2584
|
+
};
|
|
2585
|
+
const onTerminal = () => onUnload();
|
|
2586
|
+
doc.addEventListener("visibilitychange", onVisChange);
|
|
2587
|
+
w.addEventListener("pagehide", onTerminal);
|
|
2588
|
+
w.addEventListener("beforeunload", onTerminal);
|
|
2589
|
+
return () => {
|
|
2590
|
+
doc.removeEventListener("visibilitychange", onVisChange);
|
|
2591
|
+
w.removeEventListener("pagehide", onTerminal);
|
|
2592
|
+
w.removeEventListener("beforeunload", onTerminal);
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// src/vue.ts
|
|
2597
|
+
function useEntitlement(key) {
|
|
2598
|
+
const r = ref(safeIsEntitled(key));
|
|
2599
|
+
onMounted(() => {
|
|
2600
|
+
r.value = safeIsEntitled(key);
|
|
2601
|
+
let unsubscribe = null;
|
|
2602
|
+
try {
|
|
2603
|
+
unsubscribe = Crossdeck.onEntitlementsChange(() => {
|
|
2604
|
+
r.value = safeIsEntitled(key);
|
|
2605
|
+
});
|
|
2606
|
+
} catch {
|
|
2607
|
+
}
|
|
2608
|
+
onScopeDispose(() => {
|
|
2609
|
+
if (unsubscribe) unsubscribe();
|
|
2610
|
+
});
|
|
2611
|
+
});
|
|
2612
|
+
return r;
|
|
2613
|
+
}
|
|
2614
|
+
function useEntitlements() {
|
|
2615
|
+
const r = ref(safeListKeys());
|
|
2616
|
+
onMounted(() => {
|
|
2617
|
+
r.value = safeListKeys();
|
|
2618
|
+
let unsubscribe = null;
|
|
2619
|
+
try {
|
|
2620
|
+
unsubscribe = Crossdeck.onEntitlementsChange((entitlements) => {
|
|
2621
|
+
r.value = entitlements.filter((e) => e.isActive).map((e) => e.key);
|
|
2622
|
+
});
|
|
2623
|
+
} catch {
|
|
2624
|
+
}
|
|
2625
|
+
onScopeDispose(() => {
|
|
2626
|
+
if (unsubscribe) unsubscribe();
|
|
2627
|
+
});
|
|
2628
|
+
});
|
|
2629
|
+
return r;
|
|
2630
|
+
}
|
|
2631
|
+
function safeIsEntitled(key) {
|
|
2632
|
+
try {
|
|
2633
|
+
return Crossdeck.isEntitled(key);
|
|
2634
|
+
} catch {
|
|
2635
|
+
return false;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
function safeListKeys() {
|
|
2639
|
+
try {
|
|
2640
|
+
return Crossdeck.listEntitlements().filter((e) => e.isActive).map((e) => e.key);
|
|
2641
|
+
} catch {
|
|
2642
|
+
return [];
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
export {
|
|
2646
|
+
useEntitlement,
|
|
2647
|
+
useEntitlements
|
|
2648
|
+
};
|
|
2649
|
+
//# sourceMappingURL=vue.mjs.map
|