@cross-deck/web 0.3.0 → 0.5.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 +27 -0
- package/README.md +57 -16
- package/dist/{index.js → index.cjs} +114 -9
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +79 -2
- package/dist/index.d.ts +79 -2
- package/dist/index.mjs +113 -8
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +1262 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.mts +68 -0
- package/dist/react.d.ts +68 -0
- package/dist/react.mjs +1236 -0
- package/dist/react.mjs.map +1 -0
- package/package.json +16 -1
- package/dist/index.js.map +0 -1
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
// src/react.ts
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
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
|
+
Object.setPrototypeOf(this, _CrossdeckError.prototype);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
async function crossdeckErrorFromResponse(res) {
|
|
17
|
+
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
18
|
+
let body;
|
|
19
|
+
try {
|
|
20
|
+
body = await res.json();
|
|
21
|
+
} catch {
|
|
22
|
+
body = null;
|
|
23
|
+
}
|
|
24
|
+
const envelope = body?.error;
|
|
25
|
+
if (envelope && typeof envelope.type === "string" && typeof envelope.code === "string") {
|
|
26
|
+
return new CrossdeckError({
|
|
27
|
+
type: envelope.type,
|
|
28
|
+
code: envelope.code,
|
|
29
|
+
message: envelope.message ?? `HTTP ${res.status}`,
|
|
30
|
+
requestId: envelope.request_id ?? requestId,
|
|
31
|
+
status: res.status
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return new CrossdeckError({
|
|
35
|
+
type: typeMapForStatus(res.status),
|
|
36
|
+
code: `http_${res.status}`,
|
|
37
|
+
message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
|
|
38
|
+
requestId,
|
|
39
|
+
status: res.status
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function typeMapForStatus(status) {
|
|
43
|
+
if (status === 401) return "authentication_error";
|
|
44
|
+
if (status === 403) return "permission_error";
|
|
45
|
+
if (status === 429) return "rate_limit_error";
|
|
46
|
+
if (status >= 400 && status < 500) return "invalid_request_error";
|
|
47
|
+
return "internal_error";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/http.ts
|
|
51
|
+
var SDK_NAME = "@cross-deck/web";
|
|
52
|
+
var SDK_VERSION = "0.5.0";
|
|
53
|
+
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
54
|
+
var HttpClient = class {
|
|
55
|
+
constructor(config) {
|
|
56
|
+
this.config = config;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Issue a request. `path` is relative to the configured baseUrl
|
|
60
|
+
* ("/entitlements", "/identity/alias", etc.).
|
|
61
|
+
*
|
|
62
|
+
* Throws CrossdeckError on:
|
|
63
|
+
* - Network failure (`type: "network_error"`)
|
|
64
|
+
* - Non-2xx response (typed from the body envelope)
|
|
65
|
+
* - JSON parse failure on a 2xx (treated as `internal_error`)
|
|
66
|
+
*/
|
|
67
|
+
async request(method, path, options = {}) {
|
|
68
|
+
const url = this.buildUrl(path, options.query);
|
|
69
|
+
const headers = {
|
|
70
|
+
Authorization: `Bearer ${this.config.publicKey}`,
|
|
71
|
+
"Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
|
|
72
|
+
Accept: "application/json"
|
|
73
|
+
};
|
|
74
|
+
let bodyInit;
|
|
75
|
+
if (options.body !== void 0) {
|
|
76
|
+
headers["Content-Type"] = "application/json";
|
|
77
|
+
bodyInit = JSON.stringify(options.body);
|
|
78
|
+
}
|
|
79
|
+
let response;
|
|
80
|
+
try {
|
|
81
|
+
response = await fetch(url, {
|
|
82
|
+
method,
|
|
83
|
+
headers,
|
|
84
|
+
body: bodyInit,
|
|
85
|
+
keepalive: options.keepalive === true
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new CrossdeckError({
|
|
89
|
+
type: "network_error",
|
|
90
|
+
code: "fetch_failed",
|
|
91
|
+
message: err instanceof Error ? err.message : "fetch failed"
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw await crossdeckErrorFromResponse(response);
|
|
96
|
+
}
|
|
97
|
+
if (response.status === 204) return void 0;
|
|
98
|
+
try {
|
|
99
|
+
return await response.json();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new CrossdeckError({
|
|
102
|
+
type: "internal_error",
|
|
103
|
+
code: "invalid_json_response",
|
|
104
|
+
message: "Server returned a 2xx with an unparseable body.",
|
|
105
|
+
requestId: response.headers.get("x-request-id") ?? void 0,
|
|
106
|
+
status: response.status
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
buildUrl(path, query) {
|
|
111
|
+
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
112
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
113
|
+
let url = base + cleanPath;
|
|
114
|
+
if (query) {
|
|
115
|
+
const params = new URLSearchParams();
|
|
116
|
+
for (const [k, v] of Object.entries(query)) {
|
|
117
|
+
if (typeof v === "string" && v.length > 0) params.append(k, v);
|
|
118
|
+
}
|
|
119
|
+
const qs = params.toString();
|
|
120
|
+
if (qs) url += (url.includes("?") ? "&" : "?") + qs;
|
|
121
|
+
}
|
|
122
|
+
return url;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// src/identity.ts
|
|
127
|
+
var KEY_ANON = "anon_id";
|
|
128
|
+
var KEY_CDCUST = "cdcust_id";
|
|
129
|
+
var IdentityStore = class {
|
|
130
|
+
constructor(storage, prefix) {
|
|
131
|
+
this.storage = storage;
|
|
132
|
+
this.prefix = prefix;
|
|
133
|
+
const stored = {
|
|
134
|
+
anon: storage.getItem(prefix + KEY_ANON),
|
|
135
|
+
cdcust: storage.getItem(prefix + KEY_CDCUST)
|
|
136
|
+
};
|
|
137
|
+
this.state = {
|
|
138
|
+
anonymousId: stored.anon ?? this.mintAnonymousId(),
|
|
139
|
+
crossdeckCustomerId: stored.cdcust
|
|
140
|
+
};
|
|
141
|
+
if (!stored.anon) {
|
|
142
|
+
storage.setItem(prefix + KEY_ANON, this.state.anonymousId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Return the persisted anonymous device ID (always set). */
|
|
146
|
+
get anonymousId() {
|
|
147
|
+
return this.state.anonymousId;
|
|
148
|
+
}
|
|
149
|
+
/** Return the resolved crossdeckCustomerId once we have one, else null. */
|
|
150
|
+
get crossdeckCustomerId() {
|
|
151
|
+
return this.state.crossdeckCustomerId;
|
|
152
|
+
}
|
|
153
|
+
/** Persist a newly-resolved Crossdeck customer ID. */
|
|
154
|
+
setCrossdeckCustomerId(value) {
|
|
155
|
+
this.state.crossdeckCustomerId = value;
|
|
156
|
+
this.storage.setItem(this.prefix + KEY_CDCUST, value);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Wipe persisted identity. Called by reset() — used when an end-user
|
|
160
|
+
* logs out. After reset the SDK mints a new anonymousId so the next
|
|
161
|
+
* pre-login session is a fresh customer in the identity graph.
|
|
162
|
+
*/
|
|
163
|
+
reset() {
|
|
164
|
+
this.storage.removeItem(this.prefix + KEY_ANON);
|
|
165
|
+
this.storage.removeItem(this.prefix + KEY_CDCUST);
|
|
166
|
+
this.state = {
|
|
167
|
+
anonymousId: this.mintAnonymousId(),
|
|
168
|
+
crossdeckCustomerId: null
|
|
169
|
+
};
|
|
170
|
+
this.storage.setItem(this.prefix + KEY_ANON, this.state.anonymousId);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Generate an anonymousId. Crockford-ish base32 timestamp + random
|
|
174
|
+
* suffix. Same shape Stripe / Segment / others use — sortable, log-
|
|
175
|
+
* friendly, no PII.
|
|
176
|
+
*/
|
|
177
|
+
mintAnonymousId() {
|
|
178
|
+
const ts = Date.now().toString(36);
|
|
179
|
+
const rand = randomChars(10);
|
|
180
|
+
return `anon_${ts}${rand}`;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
function randomChars(count) {
|
|
184
|
+
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
185
|
+
const out = [];
|
|
186
|
+
const cryptoApi = globalThis.crypto;
|
|
187
|
+
if (cryptoApi?.getRandomValues) {
|
|
188
|
+
const buf = new Uint8Array(count);
|
|
189
|
+
cryptoApi.getRandomValues(buf);
|
|
190
|
+
for (let i = 0; i < count; i++) {
|
|
191
|
+
out.push(alphabet[buf[i] % alphabet.length] ?? "0");
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
for (let i = 0; i < count; i++) {
|
|
195
|
+
out.push(alphabet[Math.floor(Math.random() * alphabet.length)] ?? "0");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out.join("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/entitlement-cache.ts
|
|
202
|
+
var EntitlementCache = class {
|
|
203
|
+
constructor() {
|
|
204
|
+
this.active = /* @__PURE__ */ new Set();
|
|
205
|
+
this.all = [];
|
|
206
|
+
this.lastUpdated = 0;
|
|
207
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
208
|
+
}
|
|
209
|
+
/** Sync read — true iff the entitlement key is currently active. */
|
|
210
|
+
isEntitled(key) {
|
|
211
|
+
return this.active.has(key);
|
|
212
|
+
}
|
|
213
|
+
/** Full snapshot for callers that need source / validUntil details. */
|
|
214
|
+
list() {
|
|
215
|
+
return this.all.slice();
|
|
216
|
+
}
|
|
217
|
+
/** When the cache was last refreshed. 0 means "never". */
|
|
218
|
+
get freshness() {
|
|
219
|
+
return this.lastUpdated;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Replace the cache with a fresh server response. The backend already
|
|
223
|
+
* filters to active + env-matching, so we don't re-filter — just trust
|
|
224
|
+
* what we got.
|
|
225
|
+
*
|
|
226
|
+
* Fires listeners AFTER the mutation so each listener sees the new state.
|
|
227
|
+
*/
|
|
228
|
+
setFromList(entitlements) {
|
|
229
|
+
this.all = entitlements.slice();
|
|
230
|
+
this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));
|
|
231
|
+
this.lastUpdated = Date.now();
|
|
232
|
+
this.notify();
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Wipe — used on reset() (logout). The SDK forgets everything until
|
|
236
|
+
* the next identify + read.
|
|
237
|
+
*
|
|
238
|
+
* Fires listeners so React/SwiftUI/etc bindings re-render to the
|
|
239
|
+
* logged-out state immediately.
|
|
240
|
+
*/
|
|
241
|
+
clear() {
|
|
242
|
+
this.active.clear();
|
|
243
|
+
this.all = [];
|
|
244
|
+
this.lastUpdated = 0;
|
|
245
|
+
this.notify();
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Subscribe to cache mutations. Returns an unsubscribe function.
|
|
249
|
+
*
|
|
250
|
+
* The listener is invoked AFTER setFromList() or clear() with the
|
|
251
|
+
* current snapshot. Throwing inside a listener is non-fatal — the
|
|
252
|
+
* error is swallowed and subsequent listeners still run.
|
|
253
|
+
*
|
|
254
|
+
* Used by `@cross-deck/web/react`'s `useEntitlement` hook to
|
|
255
|
+
* trigger re-renders when entitlements change.
|
|
256
|
+
*/
|
|
257
|
+
subscribe(listener) {
|
|
258
|
+
this.listeners.add(listener);
|
|
259
|
+
let unsubscribed = false;
|
|
260
|
+
return () => {
|
|
261
|
+
if (unsubscribed) return;
|
|
262
|
+
unsubscribed = true;
|
|
263
|
+
this.listeners.delete(listener);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
notify() {
|
|
267
|
+
if (this.listeners.size === 0) return;
|
|
268
|
+
const snapshot = this.all.slice();
|
|
269
|
+
const listenersSnapshot = [...this.listeners];
|
|
270
|
+
for (const listener of listenersSnapshot) {
|
|
271
|
+
try {
|
|
272
|
+
listener(snapshot);
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// src/event-queue.ts
|
|
280
|
+
var HARD_BUFFER_CAP = 1e3;
|
|
281
|
+
var EventQueue = class {
|
|
282
|
+
constructor(cfg) {
|
|
283
|
+
this.cfg = cfg;
|
|
284
|
+
this.buffer = [];
|
|
285
|
+
this.dropped = 0;
|
|
286
|
+
this.inFlight = 0;
|
|
287
|
+
this.lastFlushAt = 0;
|
|
288
|
+
this.lastError = null;
|
|
289
|
+
this.cancelTimer = null;
|
|
290
|
+
this.firstFlushFired = false;
|
|
291
|
+
}
|
|
292
|
+
enqueue(event) {
|
|
293
|
+
this.buffer.push(event);
|
|
294
|
+
if (this.buffer.length > HARD_BUFFER_CAP) {
|
|
295
|
+
const overflow = this.buffer.length - HARD_BUFFER_CAP;
|
|
296
|
+
this.buffer.splice(0, overflow);
|
|
297
|
+
this.dropped += overflow;
|
|
298
|
+
this.cfg.onDrop?.(overflow);
|
|
299
|
+
}
|
|
300
|
+
if (this.buffer.length >= this.cfg.batchSize) {
|
|
301
|
+
void this.flush();
|
|
302
|
+
} else {
|
|
303
|
+
this.scheduleIdleFlush();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Flush the buffer to /v1/events. Resolves when the network call
|
|
308
|
+
* completes (success or failure). On failure, events stay in the
|
|
309
|
+
* buffer for the next flush attempt.
|
|
310
|
+
*
|
|
311
|
+
* `options.keepalive` marks the underlying fetch as keepalive so the
|
|
312
|
+
* browser keeps the request alive past page unload. Use this for
|
|
313
|
+
* terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
|
|
314
|
+
*/
|
|
315
|
+
async flush(options = {}) {
|
|
316
|
+
if (this.buffer.length === 0) return null;
|
|
317
|
+
this.cancelTimerIfSet();
|
|
318
|
+
const batch = this.buffer.splice(0);
|
|
319
|
+
this.inFlight += batch.length;
|
|
320
|
+
try {
|
|
321
|
+
const env = this.cfg.envelope();
|
|
322
|
+
const result = await this.cfg.http.request("POST", "/events", {
|
|
323
|
+
body: {
|
|
324
|
+
// NorthStar §13.1 batch envelope. The backend validates these
|
|
325
|
+
// against the API-key-resolved app and rejects mismatches loudly
|
|
326
|
+
// (env_mismatch).
|
|
327
|
+
appId: env.appId,
|
|
328
|
+
environment: env.environment,
|
|
329
|
+
sdk: env.sdk,
|
|
330
|
+
events: batch
|
|
331
|
+
},
|
|
332
|
+
keepalive: options.keepalive === true
|
|
333
|
+
});
|
|
334
|
+
this.lastFlushAt = Date.now();
|
|
335
|
+
this.lastError = null;
|
|
336
|
+
this.inFlight -= batch.length;
|
|
337
|
+
if (!this.firstFlushFired) {
|
|
338
|
+
this.firstFlushFired = true;
|
|
339
|
+
this.cfg.onFirstFlushSuccess?.();
|
|
340
|
+
}
|
|
341
|
+
return result;
|
|
342
|
+
} catch (err) {
|
|
343
|
+
this.buffer.unshift(...batch);
|
|
344
|
+
this.inFlight -= batch.length;
|
|
345
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
346
|
+
this.scheduleIdleFlush();
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** Cancel any pending timer and clear in-memory state. */
|
|
351
|
+
reset() {
|
|
352
|
+
this.cancelTimerIfSet();
|
|
353
|
+
this.buffer = [];
|
|
354
|
+
this.dropped = 0;
|
|
355
|
+
this.inFlight = 0;
|
|
356
|
+
this.lastError = null;
|
|
357
|
+
}
|
|
358
|
+
getStats() {
|
|
359
|
+
return {
|
|
360
|
+
buffered: this.buffer.length,
|
|
361
|
+
dropped: this.dropped,
|
|
362
|
+
inFlight: this.inFlight,
|
|
363
|
+
lastFlushAt: this.lastFlushAt,
|
|
364
|
+
lastError: this.lastError
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
scheduleIdleFlush() {
|
|
368
|
+
this.cancelTimerIfSet();
|
|
369
|
+
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
370
|
+
this.cancelTimer = sched(() => {
|
|
371
|
+
void this.flush();
|
|
372
|
+
}, this.cfg.intervalMs);
|
|
373
|
+
}
|
|
374
|
+
cancelTimerIfSet() {
|
|
375
|
+
if (this.cancelTimer) {
|
|
376
|
+
this.cancelTimer();
|
|
377
|
+
this.cancelTimer = null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
function defaultScheduler(fn, ms) {
|
|
382
|
+
const id = setTimeout(fn, ms);
|
|
383
|
+
if (typeof id.unref === "function") {
|
|
384
|
+
try {
|
|
385
|
+
id.unref();
|
|
386
|
+
} catch {
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return () => clearTimeout(id);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/storage.ts
|
|
393
|
+
var MemoryStorage = class {
|
|
394
|
+
constructor() {
|
|
395
|
+
this.store = /* @__PURE__ */ new Map();
|
|
396
|
+
}
|
|
397
|
+
getItem(key) {
|
|
398
|
+
return this.store.get(key) ?? null;
|
|
399
|
+
}
|
|
400
|
+
setItem(key, value) {
|
|
401
|
+
this.store.set(key, value);
|
|
402
|
+
}
|
|
403
|
+
removeItem(key) {
|
|
404
|
+
this.store.delete(key);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
function detectDefaultStorage() {
|
|
408
|
+
try {
|
|
409
|
+
const ls = globalThis.localStorage;
|
|
410
|
+
if (ls) {
|
|
411
|
+
const probe = "__crossdeck_probe__";
|
|
412
|
+
ls.setItem(probe, "1");
|
|
413
|
+
ls.removeItem(probe);
|
|
414
|
+
return ls;
|
|
415
|
+
}
|
|
416
|
+
} catch {
|
|
417
|
+
}
|
|
418
|
+
return new MemoryStorage();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/device-info.ts
|
|
422
|
+
function isBrowser() {
|
|
423
|
+
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined" && typeof globalThis.navigator !== "undefined";
|
|
424
|
+
}
|
|
425
|
+
function collectDeviceInfo(extra) {
|
|
426
|
+
const info = {};
|
|
427
|
+
if (extra?.appVersion) info.appVersion = extra.appVersion;
|
|
428
|
+
if (!isBrowser()) return info;
|
|
429
|
+
const w = globalThis.window;
|
|
430
|
+
const nav = globalThis.navigator;
|
|
431
|
+
const doc = globalThis.document;
|
|
432
|
+
try {
|
|
433
|
+
if (typeof nav.language === "string") info.locale = nav.language;
|
|
434
|
+
} catch {
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
info.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
438
|
+
} catch {
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
if (w.screen) {
|
|
442
|
+
info.screenWidth = w.screen.width;
|
|
443
|
+
info.screenHeight = w.screen.height;
|
|
444
|
+
}
|
|
445
|
+
info.viewportWidth = w.innerWidth;
|
|
446
|
+
info.viewportHeight = w.innerHeight;
|
|
447
|
+
info.devicePixelRatio = w.devicePixelRatio;
|
|
448
|
+
} catch {
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
const ua = nav.userAgent ?? "";
|
|
452
|
+
const parsed = parseUserAgent(ua);
|
|
453
|
+
Object.assign(info, parsed);
|
|
454
|
+
} catch {
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const uaData = nav.userAgentData;
|
|
458
|
+
if (uaData?.platform && !info.os) info.os = uaData.platform;
|
|
459
|
+
if (uaData?.brands && !info.browser) {
|
|
460
|
+
const real = uaData.brands.find(
|
|
461
|
+
(b) => !/Not[ .;A]*Brand/i.test(b.brand) && !/Chromium/i.test(b.brand)
|
|
462
|
+
);
|
|
463
|
+
if (real) {
|
|
464
|
+
info.browser = real.brand;
|
|
465
|
+
info.browserVersion = real.version;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
}
|
|
470
|
+
void doc;
|
|
471
|
+
return info;
|
|
472
|
+
}
|
|
473
|
+
function parseUserAgent(ua) {
|
|
474
|
+
const out = {};
|
|
475
|
+
if (/iPad|iPhone|iPod/.test(ua)) {
|
|
476
|
+
out.os = "iOS";
|
|
477
|
+
const m = ua.match(/OS (\d+[._]\d+(?:[._]\d+)?)/);
|
|
478
|
+
if (m?.[1]) out.osVersion = m[1].replace(/_/g, ".");
|
|
479
|
+
} else if (/Android/.test(ua)) {
|
|
480
|
+
out.os = "Android";
|
|
481
|
+
const m = ua.match(/Android (\d+(?:\.\d+)*)/);
|
|
482
|
+
if (m?.[1]) out.osVersion = m[1];
|
|
483
|
+
} else if (/Windows/.test(ua)) {
|
|
484
|
+
out.os = "Windows";
|
|
485
|
+
const m = ua.match(/Windows NT (\d+\.\d+)/);
|
|
486
|
+
if (m?.[1]) out.osVersion = m[1];
|
|
487
|
+
} else if (/Mac OS X|Macintosh/.test(ua)) {
|
|
488
|
+
out.os = "macOS";
|
|
489
|
+
const m = ua.match(/Mac OS X (\d+[._]\d+(?:[._]\d+)?)/);
|
|
490
|
+
if (m?.[1]) out.osVersion = m[1].replace(/_/g, ".");
|
|
491
|
+
} else if (/Linux/.test(ua)) {
|
|
492
|
+
out.os = "Linux";
|
|
493
|
+
}
|
|
494
|
+
if (/Edg\/(\d+(?:\.\d+)*)/.test(ua)) {
|
|
495
|
+
out.browser = "Edge";
|
|
496
|
+
out.browserVersion = ua.match(/Edg\/(\d+(?:\.\d+)*)/)?.[1];
|
|
497
|
+
} else if (/Firefox\/(\d+(?:\.\d+)*)/.test(ua)) {
|
|
498
|
+
out.browser = "Firefox";
|
|
499
|
+
out.browserVersion = ua.match(/Firefox\/(\d+(?:\.\d+)*)/)?.[1];
|
|
500
|
+
} else if (/OPR\/(\d+(?:\.\d+)*)/.test(ua)) {
|
|
501
|
+
out.browser = "Opera";
|
|
502
|
+
out.browserVersion = ua.match(/OPR\/(\d+(?:\.\d+)*)/)?.[1];
|
|
503
|
+
} else if (/Chrome\/(\d+(?:\.\d+)*)/.test(ua)) {
|
|
504
|
+
out.browser = "Chrome";
|
|
505
|
+
out.browserVersion = ua.match(/Chrome\/(\d+(?:\.\d+)*)/)?.[1];
|
|
506
|
+
} else if (/Version\/(\d+(?:\.\d+)*).*Safari/.test(ua)) {
|
|
507
|
+
out.browser = "Safari";
|
|
508
|
+
out.browserVersion = ua.match(/Version\/(\d+(?:\.\d+)*)/)?.[1];
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// src/auto-track.ts
|
|
514
|
+
var DEFAULT_AUTO_TRACK = {
|
|
515
|
+
sessions: true,
|
|
516
|
+
pageViews: true,
|
|
517
|
+
deviceInfo: true
|
|
518
|
+
};
|
|
519
|
+
var SESSION_RESUME_THRESHOLD_MS = 30 * 60 * 1e3;
|
|
520
|
+
var AutoTracker = class {
|
|
521
|
+
constructor(cfg, track) {
|
|
522
|
+
this.cfg = cfg;
|
|
523
|
+
this.track = track;
|
|
524
|
+
this.session = null;
|
|
525
|
+
this.cleanups = [];
|
|
526
|
+
}
|
|
527
|
+
install() {
|
|
528
|
+
if (!isBrowserSafe()) return;
|
|
529
|
+
if (this.cfg.sessions) this.installSessionTracking();
|
|
530
|
+
if (this.cfg.pageViews) this.installPageViewTracking();
|
|
531
|
+
}
|
|
532
|
+
uninstall() {
|
|
533
|
+
while (this.cleanups.length) {
|
|
534
|
+
const fn = this.cleanups.pop();
|
|
535
|
+
try {
|
|
536
|
+
fn?.();
|
|
537
|
+
} catch {
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (this.session && !this.session.endedSent) {
|
|
541
|
+
this.emitSessionEnd();
|
|
542
|
+
}
|
|
543
|
+
this.session = null;
|
|
544
|
+
}
|
|
545
|
+
/** Exposed for tests + consumers that want to reset the session manually. */
|
|
546
|
+
resetSession() {
|
|
547
|
+
if (this.session && !this.session.endedSent) this.emitSessionEnd();
|
|
548
|
+
this.session = this.startNewSession();
|
|
549
|
+
this.emitSessionStart();
|
|
550
|
+
}
|
|
551
|
+
/** Exposed for inspection/tests — returns the current sessionId (or null if not in a session). */
|
|
552
|
+
get currentSessionId() {
|
|
553
|
+
return this.session?.sessionId ?? null;
|
|
554
|
+
}
|
|
555
|
+
// ---------- sessions ----------
|
|
556
|
+
installSessionTracking() {
|
|
557
|
+
this.session = this.startNewSession();
|
|
558
|
+
this.emitSessionStart();
|
|
559
|
+
const onVisChange = () => {
|
|
560
|
+
if (!this.session) return;
|
|
561
|
+
const doc2 = globalThis.document;
|
|
562
|
+
if (doc2.visibilityState === "hidden") {
|
|
563
|
+
this.session.hiddenAt = Date.now();
|
|
564
|
+
} else if (doc2.visibilityState === "visible") {
|
|
565
|
+
const hiddenFor = this.session.hiddenAt ? Date.now() - this.session.hiddenAt : 0;
|
|
566
|
+
if (hiddenFor >= SESSION_RESUME_THRESHOLD_MS) {
|
|
567
|
+
this.emitSessionEnd();
|
|
568
|
+
this.session = this.startNewSession();
|
|
569
|
+
this.emitSessionStart();
|
|
570
|
+
} else {
|
|
571
|
+
this.session.hiddenAt = null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
const onPageHide = () => this.emitSessionEnd();
|
|
576
|
+
const w = globalThis.window;
|
|
577
|
+
const doc = globalThis.document;
|
|
578
|
+
doc.addEventListener("visibilitychange", onVisChange);
|
|
579
|
+
w.addEventListener("pagehide", onPageHide);
|
|
580
|
+
w.addEventListener("beforeunload", onPageHide);
|
|
581
|
+
this.cleanups.push(() => {
|
|
582
|
+
doc.removeEventListener("visibilitychange", onVisChange);
|
|
583
|
+
w.removeEventListener("pagehide", onPageHide);
|
|
584
|
+
w.removeEventListener("beforeunload", onPageHide);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
startNewSession() {
|
|
588
|
+
return {
|
|
589
|
+
sessionId: mintSessionId(),
|
|
590
|
+
startedAt: Date.now(),
|
|
591
|
+
hiddenAt: null,
|
|
592
|
+
endedSent: false
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
emitSessionStart() {
|
|
596
|
+
if (!this.session) return;
|
|
597
|
+
this.track("session.started", { sessionId: this.session.sessionId });
|
|
598
|
+
}
|
|
599
|
+
emitSessionEnd() {
|
|
600
|
+
if (!this.session || this.session.endedSent) return;
|
|
601
|
+
const duration = Date.now() - this.session.startedAt;
|
|
602
|
+
this.track("session.ended", {
|
|
603
|
+
sessionId: this.session.sessionId,
|
|
604
|
+
durationMs: duration
|
|
605
|
+
});
|
|
606
|
+
this.session.endedSent = true;
|
|
607
|
+
}
|
|
608
|
+
// ---------- page views ----------
|
|
609
|
+
installPageViewTracking() {
|
|
610
|
+
const w = globalThis.window;
|
|
611
|
+
const doc = globalThis.document;
|
|
612
|
+
const fire = () => {
|
|
613
|
+
const loc = w.location;
|
|
614
|
+
this.track("page.viewed", {
|
|
615
|
+
path: loc.pathname,
|
|
616
|
+
url: loc.href,
|
|
617
|
+
search: loc.search || void 0,
|
|
618
|
+
hash: loc.hash || void 0,
|
|
619
|
+
title: doc.title,
|
|
620
|
+
// referrer only on the first hit of the session — afterward it's
|
|
621
|
+
// always our previous URL, which isn't useful.
|
|
622
|
+
referrer: doc.referrer || void 0
|
|
623
|
+
});
|
|
624
|
+
};
|
|
625
|
+
fire();
|
|
626
|
+
const origPush = w.history.pushState;
|
|
627
|
+
const origReplace = w.history.replaceState;
|
|
628
|
+
function patchedPush(data, unused, url) {
|
|
629
|
+
origPush.apply(this, [data, unused, url]);
|
|
630
|
+
queueMicrotask(fire);
|
|
631
|
+
}
|
|
632
|
+
function patchedReplace(data, unused, url) {
|
|
633
|
+
origReplace.apply(this, [data, unused, url]);
|
|
634
|
+
queueMicrotask(fire);
|
|
635
|
+
}
|
|
636
|
+
w.history.pushState = patchedPush;
|
|
637
|
+
w.history.replaceState = patchedReplace;
|
|
638
|
+
const onPopState = () => fire();
|
|
639
|
+
w.addEventListener("popstate", onPopState);
|
|
640
|
+
this.cleanups.push(() => {
|
|
641
|
+
if (w.history.pushState === patchedPush) {
|
|
642
|
+
w.history.pushState = origPush;
|
|
643
|
+
}
|
|
644
|
+
if (w.history.replaceState === patchedReplace) {
|
|
645
|
+
w.history.replaceState = origReplace;
|
|
646
|
+
}
|
|
647
|
+
w.removeEventListener("popstate", onPopState);
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
function isBrowserSafe() {
|
|
652
|
+
return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined";
|
|
653
|
+
}
|
|
654
|
+
function mintSessionId() {
|
|
655
|
+
const ts = Date.now().toString(36);
|
|
656
|
+
return `sess_${ts}${randomChars(10)}`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/debug.ts
|
|
660
|
+
var SENSITIVE_KEY_PATTERNS = [
|
|
661
|
+
/^email$/i,
|
|
662
|
+
/^password$/i,
|
|
663
|
+
/^token$/i,
|
|
664
|
+
/^secret$/i,
|
|
665
|
+
/^card$/i,
|
|
666
|
+
/^phone$/i,
|
|
667
|
+
/password/i,
|
|
668
|
+
/credit_?card/i
|
|
669
|
+
];
|
|
670
|
+
function findSensitivePropertyKeys(properties) {
|
|
671
|
+
if (!properties) return [];
|
|
672
|
+
const hits = [];
|
|
673
|
+
for (const k of Object.keys(properties)) {
|
|
674
|
+
if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(k))) hits.push(k);
|
|
675
|
+
}
|
|
676
|
+
return hits;
|
|
677
|
+
}
|
|
678
|
+
var ConsoleDebugLogger = class {
|
|
679
|
+
constructor() {
|
|
680
|
+
this.enabled = false;
|
|
681
|
+
this.seen = /* @__PURE__ */ new Set();
|
|
682
|
+
}
|
|
683
|
+
emit(signal, message, context) {
|
|
684
|
+
if (!this.enabled) return;
|
|
685
|
+
if (ONCE_SIGNALS.has(signal)) {
|
|
686
|
+
if (this.seen.has(signal)) return;
|
|
687
|
+
this.seen.add(signal);
|
|
688
|
+
}
|
|
689
|
+
const ctx = context ? ` ${safeJson(context)}` : "";
|
|
690
|
+
console.info(`[crossdeck:${signal}] ${message}${ctx}`);
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
var ONCE_SIGNALS = /* @__PURE__ */ new Set([
|
|
694
|
+
"sdk.configured",
|
|
695
|
+
"sdk.first_event_sent",
|
|
696
|
+
"sdk.environment_mismatch"
|
|
697
|
+
]);
|
|
698
|
+
function safeJson(obj) {
|
|
699
|
+
try {
|
|
700
|
+
return JSON.stringify(obj);
|
|
701
|
+
} catch {
|
|
702
|
+
return "[unserialisable context]";
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/crossdeck.ts
|
|
707
|
+
var CrossdeckClient = class {
|
|
708
|
+
constructor() {
|
|
709
|
+
this.state = null;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Boot the SDK. Idempotent — calling init twice with the same options
|
|
713
|
+
* is a no-op; calling with different options replaces the previous
|
|
714
|
+
* configuration.
|
|
715
|
+
*
|
|
716
|
+
* NorthStar §11.1: signature is `Crossdeck.init({ appId, publicKey,
|
|
717
|
+
* environment })`. The trio is validated up-front so a typo'd key or a
|
|
718
|
+
* mismatched env fails fast at boot rather than at first event-flush.
|
|
719
|
+
*/
|
|
720
|
+
init(options) {
|
|
721
|
+
if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
|
|
722
|
+
throw new CrossdeckError({
|
|
723
|
+
type: "configuration_error",
|
|
724
|
+
code: "invalid_public_key",
|
|
725
|
+
message: "Crossdeck.init requires a publishable key starting with cd_pub_."
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
if (!options.appId) {
|
|
729
|
+
throw new CrossdeckError({
|
|
730
|
+
type: "configuration_error",
|
|
731
|
+
code: "missing_app_id",
|
|
732
|
+
message: "Crossdeck.init requires an appId. Find yours in the Crossdeck dashboard."
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (options.environment !== "production" && options.environment !== "sandbox") {
|
|
736
|
+
throw new CrossdeckError({
|
|
737
|
+
type: "configuration_error",
|
|
738
|
+
code: "invalid_environment",
|
|
739
|
+
message: 'Crossdeck.init requires environment: "production" | "sandbox".'
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
const keyEnv = inferEnvFromKey(options.publicKey);
|
|
743
|
+
if (keyEnv && keyEnv !== options.environment) {
|
|
744
|
+
throw new CrossdeckError({
|
|
745
|
+
type: "configuration_error",
|
|
746
|
+
code: "environment_mismatch",
|
|
747
|
+
message: `Crossdeck.init: environment "${options.environment}" disagrees with key prefix (${keyEnv}). Reconcile the publishable key with the environment declaration.`
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
const storage = options.storage ?? detectDefaultStorage();
|
|
751
|
+
const persistIdentity = options.persistIdentity ?? true;
|
|
752
|
+
const autoTrack = resolveAutoTrack(options.autoTrack);
|
|
753
|
+
const opts = {
|
|
754
|
+
appId: options.appId,
|
|
755
|
+
publicKey: options.publicKey,
|
|
756
|
+
environment: options.environment,
|
|
757
|
+
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
|
|
758
|
+
persistIdentity,
|
|
759
|
+
storagePrefix: options.storagePrefix ?? "crossdeck:",
|
|
760
|
+
autoHeartbeat: options.autoHeartbeat ?? true,
|
|
761
|
+
eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
|
|
762
|
+
// 1500ms idle window. Short enough that an event queued on page
|
|
763
|
+
// load still flushes if the user leaves quickly (the keepalive
|
|
764
|
+
// pagehide handler picks up anything that doesn't); long enough
|
|
765
|
+
// that bursts of clicks coalesce into one network round-trip.
|
|
766
|
+
eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
|
|
767
|
+
sdkVersion: options.sdkVersion ?? SDK_VERSION,
|
|
768
|
+
autoTrack,
|
|
769
|
+
appVersion: options.appVersion ?? null
|
|
770
|
+
};
|
|
771
|
+
const debug = new ConsoleDebugLogger();
|
|
772
|
+
debug.enabled = options.debug === true;
|
|
773
|
+
const http = new HttpClient({
|
|
774
|
+
publicKey: opts.publicKey,
|
|
775
|
+
baseUrl: opts.baseUrl,
|
|
776
|
+
sdkVersion: opts.sdkVersion
|
|
777
|
+
});
|
|
778
|
+
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
779
|
+
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
|
|
780
|
+
const entitlements = new EntitlementCache();
|
|
781
|
+
const events = new EventQueue({
|
|
782
|
+
http,
|
|
783
|
+
batchSize: opts.eventFlushBatchSize,
|
|
784
|
+
intervalMs: opts.eventFlushIntervalMs,
|
|
785
|
+
envelope: () => ({
|
|
786
|
+
appId: opts.appId,
|
|
787
|
+
environment: opts.environment,
|
|
788
|
+
sdk: { name: SDK_NAME, version: opts.sdkVersion }
|
|
789
|
+
}),
|
|
790
|
+
onFirstFlushSuccess: () => {
|
|
791
|
+
debug.emit(
|
|
792
|
+
"sdk.first_event_sent",
|
|
793
|
+
"First telemetry event received. View it in Live Events.",
|
|
794
|
+
{ appId: opts.appId, environment: opts.environment }
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
const deviceInfo = autoTrack.deviceInfo ? collectDeviceInfo({ appVersion: opts.appVersion ?? void 0 }) : opts.appVersion ? { appVersion: opts.appVersion } : {};
|
|
799
|
+
this.state = {
|
|
800
|
+
http,
|
|
801
|
+
identity,
|
|
802
|
+
entitlements,
|
|
803
|
+
events,
|
|
804
|
+
autoTracker: null,
|
|
805
|
+
deviceInfo,
|
|
806
|
+
options: opts,
|
|
807
|
+
debug,
|
|
808
|
+
developerUserId: null,
|
|
809
|
+
uninstallUnloadFlush: null
|
|
810
|
+
};
|
|
811
|
+
debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
|
|
812
|
+
appId: opts.appId,
|
|
813
|
+
environment: opts.environment,
|
|
814
|
+
sdkVersion: opts.sdkVersion
|
|
815
|
+
});
|
|
816
|
+
if (autoTrack.sessions || autoTrack.pageViews) {
|
|
817
|
+
const tracker = new AutoTracker(
|
|
818
|
+
autoTrack,
|
|
819
|
+
(name, properties) => this.track(name, properties)
|
|
820
|
+
);
|
|
821
|
+
this.state.autoTracker = tracker;
|
|
822
|
+
tracker.install();
|
|
823
|
+
}
|
|
824
|
+
this.state.uninstallUnloadFlush = installUnloadFlush(() => {
|
|
825
|
+
void this.flush({ keepalive: true }).catch(() => void 0);
|
|
826
|
+
});
|
|
827
|
+
if (opts.autoHeartbeat) {
|
|
828
|
+
void this.heartbeat().catch(() => void 0);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* @deprecated Use `init()` instead. NorthStar §4 standardised the
|
|
833
|
+
* lifecycle method name across SDKs as `init` (formerly `start` /
|
|
834
|
+
* `configure`). `start` will be removed in a future major version.
|
|
835
|
+
*/
|
|
836
|
+
start(options) {
|
|
837
|
+
if (typeof console !== "undefined") {
|
|
838
|
+
console.warn(
|
|
839
|
+
"[crossdeck] Crossdeck.start() is deprecated \u2014 use Crossdeck.init() instead. The signature is the same."
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
this.init(options);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Link the anonymous device to a developer-supplied user ID. Cache
|
|
846
|
+
* the resolved Crossdeck customer for follow-up calls.
|
|
847
|
+
*/
|
|
848
|
+
async identify(userId, _options) {
|
|
849
|
+
const s = this.requireStarted();
|
|
850
|
+
if (!userId) {
|
|
851
|
+
throw new CrossdeckError({
|
|
852
|
+
type: "invalid_request_error",
|
|
853
|
+
code: "missing_user_id",
|
|
854
|
+
message: "identify(userId) requires a non-empty userId."
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
const result = await s.http.request("POST", "/identity/alias", {
|
|
858
|
+
body: { userId, anonymousId: s.identity.anonymousId }
|
|
859
|
+
});
|
|
860
|
+
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
861
|
+
s.developerUserId = userId;
|
|
862
|
+
return result;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Read the current customer's active entitlements from the server.
|
|
866
|
+
* Updates the local cache so subsequent isEntitled() calls answer
|
|
867
|
+
* synchronously.
|
|
868
|
+
*/
|
|
869
|
+
async getEntitlements() {
|
|
870
|
+
const s = this.requireStarted();
|
|
871
|
+
const query = this.identityQueryParams();
|
|
872
|
+
const result = await s.http.request(
|
|
873
|
+
"GET",
|
|
874
|
+
"/entitlements",
|
|
875
|
+
{ query }
|
|
876
|
+
);
|
|
877
|
+
if (result.crossdeckCustomerId) {
|
|
878
|
+
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
879
|
+
}
|
|
880
|
+
s.entitlements.setFromList(result.data);
|
|
881
|
+
return result.data;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Synchronous read from the local cache. Returns false if the cache
|
|
885
|
+
* has never been populated (call getEntitlements first to warm it).
|
|
886
|
+
*/
|
|
887
|
+
isEntitled(key) {
|
|
888
|
+
const s = this.requireStarted();
|
|
889
|
+
return s.entitlements.isEntitled(key);
|
|
890
|
+
}
|
|
891
|
+
/** Snapshot of the local entitlement cache. */
|
|
892
|
+
listEntitlements() {
|
|
893
|
+
const s = this.requireStarted();
|
|
894
|
+
return s.entitlements.list();
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
|
|
898
|
+
*
|
|
899
|
+
* The listener is invoked AFTER the cache mutates — once after a
|
|
900
|
+
* successful `getEntitlements()` warms it, again after `syncPurchases()`
|
|
901
|
+
* delivers fresh entitlements, and once on `reset()` to fire the
|
|
902
|
+
* empty-cache state for logout flows.
|
|
903
|
+
*
|
|
904
|
+
* It is NOT invoked synchronously on subscribe. Callers that need
|
|
905
|
+
* the current state should read it via `isEntitled()` / `listEntitlements()`
|
|
906
|
+
* inline; the listener fires only on FUTURE changes.
|
|
907
|
+
*
|
|
908
|
+
* This is the foundation of the `useEntitlement` React hook in
|
|
909
|
+
* `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
|
|
910
|
+
* / Vue) would have no way to re-render when entitlements arrive
|
|
911
|
+
* asynchronously after init. The naive pattern of calling
|
|
912
|
+
* `Crossdeck.isEntitled("pro")` directly inside a render path
|
|
913
|
+
* shows the empty-cache result forever; binding the result to
|
|
914
|
+
* component state via `onEntitlementsChange` is the correct
|
|
915
|
+
* pattern.
|
|
916
|
+
*
|
|
917
|
+
* Idempotent unsubscribe — calling the returned function multiple
|
|
918
|
+
* times is safe.
|
|
919
|
+
*
|
|
920
|
+
* Listener errors are swallowed (a buggy listener can't crash the
|
|
921
|
+
* SDK or other listeners).
|
|
922
|
+
*/
|
|
923
|
+
onEntitlementsChange(listener) {
|
|
924
|
+
const s = this.requireStarted();
|
|
925
|
+
return s.entitlements.subscribe(listener);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Queue a telemetry event. Returns immediately — the network round-
|
|
929
|
+
* trip happens in the background. To flush before the page unloads,
|
|
930
|
+
* call flush().
|
|
931
|
+
*/
|
|
932
|
+
track(name, properties) {
|
|
933
|
+
const s = this.requireStarted();
|
|
934
|
+
if (!name) {
|
|
935
|
+
throw new CrossdeckError({
|
|
936
|
+
type: "invalid_request_error",
|
|
937
|
+
code: "missing_event_name",
|
|
938
|
+
message: "track(name) requires a non-empty name."
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
if (s.debug.enabled && properties) {
|
|
942
|
+
const flagged = findSensitivePropertyKeys(properties);
|
|
943
|
+
if (flagged.length > 0) {
|
|
944
|
+
s.debug.emit(
|
|
945
|
+
"sdk.sensitive_property_warning",
|
|
946
|
+
`Event "${name}" has potentially sensitive property names: ${flagged.join(", ")}. Crossdeck is privacy-first \u2014 avoid sending PII unless intentional.`,
|
|
947
|
+
{ eventName: name, flagged }
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (s.debug.enabled && !s.developerUserId && !s.identity.crossdeckCustomerId) {
|
|
952
|
+
s.debug.emit(
|
|
953
|
+
"sdk.no_identity",
|
|
954
|
+
"Using anonymous user until identify(userId) is called."
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
const enriched = { ...s.deviceInfo };
|
|
958
|
+
const sessionId = s.autoTracker?.currentSessionId;
|
|
959
|
+
if (sessionId) enriched.sessionId = sessionId;
|
|
960
|
+
if (properties) Object.assign(enriched, properties);
|
|
961
|
+
const event = {
|
|
962
|
+
eventId: this.mintEventId(),
|
|
963
|
+
name,
|
|
964
|
+
timestamp: Date.now(),
|
|
965
|
+
properties: enriched
|
|
966
|
+
};
|
|
967
|
+
Object.assign(event, this.identityHintForEvent());
|
|
968
|
+
s.events.enqueue(event);
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Force-flush queued events. Useful to call from page-unload handlers.
|
|
972
|
+
*
|
|
973
|
+
* Pass `{ keepalive: true }` from terminal handlers (pagehide /
|
|
974
|
+
* visibilitychange→hidden / beforeunload). The browser keeps the
|
|
975
|
+
* request alive after the page tears down, so the final batch
|
|
976
|
+
* actually lands instead of being cancelled with the unload.
|
|
977
|
+
*
|
|
978
|
+
* NorthStar §4: standard method name across all Crossdeck SDKs.
|
|
979
|
+
*/
|
|
980
|
+
async flush(options = {}) {
|
|
981
|
+
const s = this.requireStarted();
|
|
982
|
+
await s.events.flush(options);
|
|
983
|
+
}
|
|
984
|
+
/** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
|
|
985
|
+
async flushEvents() {
|
|
986
|
+
return this.flush();
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Forward purchase evidence to the backend for verification + entitlement
|
|
990
|
+
* projection. NorthStar §4 + §13 canonical name.
|
|
991
|
+
*
|
|
992
|
+
* Today the web SDK only supports Apple StoreKit 2 forwarding (web apps
|
|
993
|
+
* that sit alongside an iOS app). Stripe doesn't need this method —
|
|
994
|
+
* Stripe webhooks deliver evidence server-side without a client round-trip.
|
|
995
|
+
*/
|
|
996
|
+
async syncPurchases(input) {
|
|
997
|
+
const s = this.requireStarted();
|
|
998
|
+
if (!input.signedTransactionInfo) {
|
|
999
|
+
throw new CrossdeckError({
|
|
1000
|
+
type: "invalid_request_error",
|
|
1001
|
+
code: "missing_signed_transaction_info",
|
|
1002
|
+
message: "syncPurchases requires a signedTransactionInfo string from StoreKit 2."
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
const result = await s.http.request("POST", "/purchases/sync", {
|
|
1006
|
+
body: { rail: input.rail ?? "apple", ...input }
|
|
1007
|
+
});
|
|
1008
|
+
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
1009
|
+
s.entitlements.setFromList(result.entitlements);
|
|
1010
|
+
s.debug.emit(
|
|
1011
|
+
"sdk.purchase_evidence_sent",
|
|
1012
|
+
"StoreKit transaction forwarded. Waiting for backend verification.",
|
|
1013
|
+
{ rail: input.rail ?? "apple" }
|
|
1014
|
+
);
|
|
1015
|
+
return result;
|
|
1016
|
+
}
|
|
1017
|
+
/** @deprecated Use `syncPurchases()` instead. NorthStar §4 standardised the name. */
|
|
1018
|
+
async purchaseApple(input) {
|
|
1019
|
+
return this.syncPurchases({ rail: "apple", ...input });
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Toggle verbose diagnostic logging — NorthStar §16. When enabled, the
|
|
1023
|
+
* SDK emits a fixed vocabulary of debug signals to console.info that the
|
|
1024
|
+
* dashboard's onboarding checklist can also surface as live events.
|
|
1025
|
+
*/
|
|
1026
|
+
setDebugMode(enabled) {
|
|
1027
|
+
const s = this.requireStarted();
|
|
1028
|
+
s.debug.enabled = enabled;
|
|
1029
|
+
if (enabled) {
|
|
1030
|
+
s.debug.emit(
|
|
1031
|
+
"sdk.configured",
|
|
1032
|
+
`Debug mode enabled for ${s.options.appId} in ${s.options.environment} mode.`,
|
|
1033
|
+
{ appId: s.options.appId, environment: s.options.environment }
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Send the boot heartbeat. Called automatically by start() unless
|
|
1039
|
+
* autoHeartbeat:false. Safe to call manually as a "we're still here" ping.
|
|
1040
|
+
*/
|
|
1041
|
+
async heartbeat() {
|
|
1042
|
+
const s = this.requireStarted();
|
|
1043
|
+
return await s.http.request("GET", "/sdk/heartbeat");
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Wipe persisted identity + entitlement cache. Use on logout. The
|
|
1047
|
+
* next pre-login session generates a fresh anonymousId and starts a
|
|
1048
|
+
* new identity-graph entry.
|
|
1049
|
+
*/
|
|
1050
|
+
reset() {
|
|
1051
|
+
if (!this.state) return;
|
|
1052
|
+
this.state.autoTracker?.uninstall();
|
|
1053
|
+
this.state.identity.reset();
|
|
1054
|
+
this.state.entitlements.clear();
|
|
1055
|
+
this.state.events.reset();
|
|
1056
|
+
this.state.developerUserId = null;
|
|
1057
|
+
if (this.state.autoTracker) {
|
|
1058
|
+
const tracker = new AutoTracker(
|
|
1059
|
+
this.state.options.autoTrack,
|
|
1060
|
+
(name, props) => this.track(name, props)
|
|
1061
|
+
);
|
|
1062
|
+
this.state.autoTracker = tracker;
|
|
1063
|
+
tracker.install();
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Diagnostic: current state + queue stats. Useful for the dashboard's
|
|
1068
|
+
* heartbeat row and debugging in dev.
|
|
1069
|
+
*
|
|
1070
|
+
* Returns a stable shape regardless of whether start() has been called —
|
|
1071
|
+
* callers don't need to narrow on `started` to access `events` or
|
|
1072
|
+
* `entitlements`. Pre-start values are sensible empties.
|
|
1073
|
+
*/
|
|
1074
|
+
diagnostics() {
|
|
1075
|
+
if (!this.state) {
|
|
1076
|
+
return {
|
|
1077
|
+
started: false,
|
|
1078
|
+
anonymousId: null,
|
|
1079
|
+
crossdeckCustomerId: null,
|
|
1080
|
+
developerUserId: null,
|
|
1081
|
+
sdkVersion: null,
|
|
1082
|
+
baseUrl: null,
|
|
1083
|
+
entitlements: { count: 0, lastUpdated: 0 },
|
|
1084
|
+
events: {
|
|
1085
|
+
buffered: 0,
|
|
1086
|
+
dropped: 0,
|
|
1087
|
+
inFlight: 0,
|
|
1088
|
+
lastFlushAt: 0,
|
|
1089
|
+
lastError: null
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
const s = this.state;
|
|
1094
|
+
return {
|
|
1095
|
+
started: true,
|
|
1096
|
+
anonymousId: s.identity.anonymousId,
|
|
1097
|
+
crossdeckCustomerId: s.identity.crossdeckCustomerId,
|
|
1098
|
+
developerUserId: s.developerUserId,
|
|
1099
|
+
sdkVersion: s.options.sdkVersion,
|
|
1100
|
+
baseUrl: s.options.baseUrl,
|
|
1101
|
+
entitlements: {
|
|
1102
|
+
count: s.entitlements.list().length,
|
|
1103
|
+
lastUpdated: s.entitlements.freshness
|
|
1104
|
+
},
|
|
1105
|
+
events: s.events.getStats()
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
// ---------- private helpers ----------
|
|
1109
|
+
requireStarted() {
|
|
1110
|
+
if (!this.state) {
|
|
1111
|
+
throw new CrossdeckError({
|
|
1112
|
+
type: "configuration_error",
|
|
1113
|
+
code: "not_initialized",
|
|
1114
|
+
message: "Call Crossdeck.init({ appId, publicKey, environment }) before any other method."
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
return this.state;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Build the identity query for /v1/entitlements. Priority:
|
|
1121
|
+
* crossdeckCustomerId > developerUserId > anonymousId
|
|
1122
|
+
* — matches the resolveCrossdeckCustomerId precedence on the server.
|
|
1123
|
+
*/
|
|
1124
|
+
identityQueryParams() {
|
|
1125
|
+
const s = this.requireStarted();
|
|
1126
|
+
if (s.identity.crossdeckCustomerId) {
|
|
1127
|
+
return { customerId: s.identity.crossdeckCustomerId };
|
|
1128
|
+
}
|
|
1129
|
+
if (s.developerUserId) return { userId: s.developerUserId };
|
|
1130
|
+
return { anonymousId: s.identity.anonymousId };
|
|
1131
|
+
}
|
|
1132
|
+
/** Pick the right identity hint to embed on a queued event. */
|
|
1133
|
+
identityHintForEvent() {
|
|
1134
|
+
const s = this.requireStarted();
|
|
1135
|
+
if (s.identity.crossdeckCustomerId) {
|
|
1136
|
+
return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
|
|
1137
|
+
}
|
|
1138
|
+
if (s.developerUserId) return { developerUserId: s.developerUserId };
|
|
1139
|
+
return { anonymousId: s.identity.anonymousId };
|
|
1140
|
+
}
|
|
1141
|
+
mintEventId() {
|
|
1142
|
+
const ts = Date.now().toString(36);
|
|
1143
|
+
return `evt_${ts}${randomChars(8)}`;
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
var Crossdeck = new CrossdeckClient();
|
|
1147
|
+
function inferEnvFromKey(publicKey) {
|
|
1148
|
+
if (publicKey.startsWith("cd_pub_test_")) return "sandbox";
|
|
1149
|
+
if (publicKey.startsWith("cd_pub_live_")) return "production";
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
function resolveAutoTrack(input) {
|
|
1153
|
+
if (input === false) {
|
|
1154
|
+
return { sessions: false, pageViews: false, deviceInfo: false };
|
|
1155
|
+
}
|
|
1156
|
+
if (input === void 0 || input === true) {
|
|
1157
|
+
return { ...DEFAULT_AUTO_TRACK };
|
|
1158
|
+
}
|
|
1159
|
+
return {
|
|
1160
|
+
sessions: input.sessions ?? DEFAULT_AUTO_TRACK.sessions,
|
|
1161
|
+
pageViews: input.pageViews ?? DEFAULT_AUTO_TRACK.pageViews,
|
|
1162
|
+
deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
function installUnloadFlush(onUnload) {
|
|
1166
|
+
const w = globalThis.window;
|
|
1167
|
+
const doc = globalThis.document;
|
|
1168
|
+
if (!w || !doc) return () => void 0;
|
|
1169
|
+
const onVisChange = () => {
|
|
1170
|
+
if (doc.visibilityState === "hidden") onUnload();
|
|
1171
|
+
};
|
|
1172
|
+
const onTerminal = () => onUnload();
|
|
1173
|
+
doc.addEventListener("visibilitychange", onVisChange);
|
|
1174
|
+
w.addEventListener("pagehide", onTerminal);
|
|
1175
|
+
w.addEventListener("beforeunload", onTerminal);
|
|
1176
|
+
return () => {
|
|
1177
|
+
doc.removeEventListener("visibilitychange", onVisChange);
|
|
1178
|
+
w.removeEventListener("pagehide", onTerminal);
|
|
1179
|
+
w.removeEventListener("beforeunload", onTerminal);
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// src/react.ts
|
|
1184
|
+
function useEntitlement(key) {
|
|
1185
|
+
const [isEntitled, setIsEntitled] = useState(() => safeIsEntitled(key));
|
|
1186
|
+
useEffect(() => {
|
|
1187
|
+
setIsEntitled(safeIsEntitled(key));
|
|
1188
|
+
let unsubscribe = null;
|
|
1189
|
+
try {
|
|
1190
|
+
unsubscribe = Crossdeck.onEntitlementsChange(() => {
|
|
1191
|
+
setIsEntitled(safeIsEntitled(key));
|
|
1192
|
+
});
|
|
1193
|
+
} catch {
|
|
1194
|
+
}
|
|
1195
|
+
return () => {
|
|
1196
|
+
if (unsubscribe) unsubscribe();
|
|
1197
|
+
};
|
|
1198
|
+
}, [key]);
|
|
1199
|
+
return isEntitled;
|
|
1200
|
+
}
|
|
1201
|
+
function useEntitlements() {
|
|
1202
|
+
const [keys, setKeys] = useState(() => safeListKeys());
|
|
1203
|
+
useEffect(() => {
|
|
1204
|
+
setKeys(safeListKeys());
|
|
1205
|
+
let unsubscribe = null;
|
|
1206
|
+
try {
|
|
1207
|
+
unsubscribe = Crossdeck.onEntitlementsChange((entitlements) => {
|
|
1208
|
+
setKeys(entitlements.filter((e) => e.isActive).map((e) => e.key));
|
|
1209
|
+
});
|
|
1210
|
+
} catch {
|
|
1211
|
+
}
|
|
1212
|
+
return () => {
|
|
1213
|
+
if (unsubscribe) unsubscribe();
|
|
1214
|
+
};
|
|
1215
|
+
}, []);
|
|
1216
|
+
return keys;
|
|
1217
|
+
}
|
|
1218
|
+
function safeIsEntitled(key) {
|
|
1219
|
+
try {
|
|
1220
|
+
return Crossdeck.isEntitled(key);
|
|
1221
|
+
} catch {
|
|
1222
|
+
return false;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
function safeListKeys() {
|
|
1226
|
+
try {
|
|
1227
|
+
return Crossdeck.listEntitlements().filter((e) => e.isActive).map((e) => e.key);
|
|
1228
|
+
} catch {
|
|
1229
|
+
return [];
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
export {
|
|
1233
|
+
useEntitlement,
|
|
1234
|
+
useEntitlements
|
|
1235
|
+
};
|
|
1236
|
+
//# sourceMappingURL=react.mjs.map
|