@cross-deck/web 0.1.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/LICENSE +21 -0
- package/README.md +210 -0
- package/dist/index.d.mts +306 -0
- package/dist/index.d.ts +306 -0
- package/dist/index.js +655 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +622 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +57 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var CrossdeckError = class _CrossdeckError extends Error {
|
|
3
|
+
constructor(payload) {
|
|
4
|
+
super(payload.message);
|
|
5
|
+
this.name = "CrossdeckError";
|
|
6
|
+
this.type = payload.type;
|
|
7
|
+
this.code = payload.code;
|
|
8
|
+
this.requestId = payload.requestId;
|
|
9
|
+
this.status = payload.status;
|
|
10
|
+
Object.setPrototypeOf(this, _CrossdeckError.prototype);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
async function crossdeckErrorFromResponse(res) {
|
|
14
|
+
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
15
|
+
let body;
|
|
16
|
+
try {
|
|
17
|
+
body = await res.json();
|
|
18
|
+
} catch {
|
|
19
|
+
body = null;
|
|
20
|
+
}
|
|
21
|
+
const envelope = body?.error;
|
|
22
|
+
if (envelope && typeof envelope.type === "string" && typeof envelope.code === "string") {
|
|
23
|
+
return new CrossdeckError({
|
|
24
|
+
type: envelope.type,
|
|
25
|
+
code: envelope.code,
|
|
26
|
+
message: envelope.message ?? `HTTP ${res.status}`,
|
|
27
|
+
requestId: envelope.request_id ?? requestId,
|
|
28
|
+
status: res.status
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return new CrossdeckError({
|
|
32
|
+
type: typeMapForStatus(res.status),
|
|
33
|
+
code: `http_${res.status}`,
|
|
34
|
+
message: `HTTP ${res.status} ${res.statusText || ""}`.trim(),
|
|
35
|
+
requestId,
|
|
36
|
+
status: res.status
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function typeMapForStatus(status) {
|
|
40
|
+
if (status === 401) return "authentication_error";
|
|
41
|
+
if (status === 403) return "permission_error";
|
|
42
|
+
if (status === 429) return "rate_limit_error";
|
|
43
|
+
if (status >= 400 && status < 500) return "invalid_request_error";
|
|
44
|
+
return "internal_error";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/http.ts
|
|
48
|
+
var SDK_NAME = "@cross-deck/web";
|
|
49
|
+
var SDK_VERSION = "0.1.0";
|
|
50
|
+
var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
|
|
51
|
+
var HttpClient = class {
|
|
52
|
+
constructor(config) {
|
|
53
|
+
this.config = config;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Issue a request. `path` is relative to the configured baseUrl
|
|
57
|
+
* ("/entitlements", "/identity/alias", etc.).
|
|
58
|
+
*
|
|
59
|
+
* Throws CrossdeckError on:
|
|
60
|
+
* - Network failure (`type: "network_error"`)
|
|
61
|
+
* - Non-2xx response (typed from the body envelope)
|
|
62
|
+
* - JSON parse failure on a 2xx (treated as `internal_error`)
|
|
63
|
+
*/
|
|
64
|
+
async request(method, path, options = {}) {
|
|
65
|
+
const url = this.buildUrl(path, options.query);
|
|
66
|
+
const headers = {
|
|
67
|
+
Authorization: `Bearer ${this.config.publicKey}`,
|
|
68
|
+
"Crossdeck-Sdk-Version": `${SDK_NAME}@${this.config.sdkVersion}`,
|
|
69
|
+
Accept: "application/json"
|
|
70
|
+
};
|
|
71
|
+
let bodyInit;
|
|
72
|
+
if (options.body !== void 0) {
|
|
73
|
+
headers["Content-Type"] = "application/json";
|
|
74
|
+
bodyInit = JSON.stringify(options.body);
|
|
75
|
+
}
|
|
76
|
+
let response;
|
|
77
|
+
try {
|
|
78
|
+
response = await fetch(url, {
|
|
79
|
+
method,
|
|
80
|
+
headers,
|
|
81
|
+
body: bodyInit
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw new CrossdeckError({
|
|
85
|
+
type: "network_error",
|
|
86
|
+
code: "fetch_failed",
|
|
87
|
+
message: err instanceof Error ? err.message : "fetch failed"
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
throw await crossdeckErrorFromResponse(response);
|
|
92
|
+
}
|
|
93
|
+
if (response.status === 204) return void 0;
|
|
94
|
+
try {
|
|
95
|
+
return await response.json();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw new CrossdeckError({
|
|
98
|
+
type: "internal_error",
|
|
99
|
+
code: "invalid_json_response",
|
|
100
|
+
message: "Server returned a 2xx with an unparseable body.",
|
|
101
|
+
requestId: response.headers.get("x-request-id") ?? void 0,
|
|
102
|
+
status: response.status
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
buildUrl(path, query) {
|
|
107
|
+
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
108
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
109
|
+
let url = base + cleanPath;
|
|
110
|
+
if (query) {
|
|
111
|
+
const params = new URLSearchParams();
|
|
112
|
+
for (const [k, v] of Object.entries(query)) {
|
|
113
|
+
if (typeof v === "string" && v.length > 0) params.append(k, v);
|
|
114
|
+
}
|
|
115
|
+
const qs = params.toString();
|
|
116
|
+
if (qs) url += (url.includes("?") ? "&" : "?") + qs;
|
|
117
|
+
}
|
|
118
|
+
return url;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// src/identity.ts
|
|
123
|
+
var KEY_ANON = "anon_id";
|
|
124
|
+
var KEY_CDCUST = "cdcust_id";
|
|
125
|
+
var IdentityStore = class {
|
|
126
|
+
constructor(storage, prefix) {
|
|
127
|
+
this.storage = storage;
|
|
128
|
+
this.prefix = prefix;
|
|
129
|
+
const stored = {
|
|
130
|
+
anon: storage.getItem(prefix + KEY_ANON),
|
|
131
|
+
cdcust: storage.getItem(prefix + KEY_CDCUST)
|
|
132
|
+
};
|
|
133
|
+
this.state = {
|
|
134
|
+
anonymousId: stored.anon ?? this.mintAnonymousId(),
|
|
135
|
+
crossdeckCustomerId: stored.cdcust
|
|
136
|
+
};
|
|
137
|
+
if (!stored.anon) {
|
|
138
|
+
storage.setItem(prefix + KEY_ANON, this.state.anonymousId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Return the persisted anonymous device ID (always set). */
|
|
142
|
+
get anonymousId() {
|
|
143
|
+
return this.state.anonymousId;
|
|
144
|
+
}
|
|
145
|
+
/** Return the resolved crossdeckCustomerId once we have one, else null. */
|
|
146
|
+
get crossdeckCustomerId() {
|
|
147
|
+
return this.state.crossdeckCustomerId;
|
|
148
|
+
}
|
|
149
|
+
/** Persist a newly-resolved Crossdeck customer ID. */
|
|
150
|
+
setCrossdeckCustomerId(value) {
|
|
151
|
+
this.state.crossdeckCustomerId = value;
|
|
152
|
+
this.storage.setItem(this.prefix + KEY_CDCUST, value);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Wipe persisted identity. Called by reset() — used when an end-user
|
|
156
|
+
* logs out. After reset the SDK mints a new anonymousId so the next
|
|
157
|
+
* pre-login session is a fresh customer in the identity graph.
|
|
158
|
+
*/
|
|
159
|
+
reset() {
|
|
160
|
+
this.storage.removeItem(this.prefix + KEY_ANON);
|
|
161
|
+
this.storage.removeItem(this.prefix + KEY_CDCUST);
|
|
162
|
+
this.state = {
|
|
163
|
+
anonymousId: this.mintAnonymousId(),
|
|
164
|
+
crossdeckCustomerId: null
|
|
165
|
+
};
|
|
166
|
+
this.storage.setItem(this.prefix + KEY_ANON, this.state.anonymousId);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Generate an anonymousId. Crockford-ish base32 timestamp + random
|
|
170
|
+
* suffix. Same shape Stripe / Segment / others use — sortable, log-
|
|
171
|
+
* friendly, no PII.
|
|
172
|
+
*/
|
|
173
|
+
mintAnonymousId() {
|
|
174
|
+
const ts = Date.now().toString(36);
|
|
175
|
+
const rand = randomChars(10);
|
|
176
|
+
return `anon_${ts}${rand}`;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
function randomChars(count) {
|
|
180
|
+
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
181
|
+
const out = [];
|
|
182
|
+
const cryptoApi = globalThis.crypto;
|
|
183
|
+
if (cryptoApi?.getRandomValues) {
|
|
184
|
+
const buf = new Uint8Array(count);
|
|
185
|
+
cryptoApi.getRandomValues(buf);
|
|
186
|
+
for (let i = 0; i < count; i++) {
|
|
187
|
+
out.push(alphabet[buf[i] % alphabet.length] ?? "0");
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
for (let i = 0; i < count; i++) {
|
|
191
|
+
out.push(alphabet[Math.floor(Math.random() * alphabet.length)] ?? "0");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out.join("");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/entitlement-cache.ts
|
|
198
|
+
var EntitlementCache = class {
|
|
199
|
+
constructor() {
|
|
200
|
+
this.active = /* @__PURE__ */ new Set();
|
|
201
|
+
this.all = [];
|
|
202
|
+
this.lastUpdated = 0;
|
|
203
|
+
}
|
|
204
|
+
/** Sync read — true iff the entitlement key is currently active. */
|
|
205
|
+
isEntitled(key) {
|
|
206
|
+
return this.active.has(key);
|
|
207
|
+
}
|
|
208
|
+
/** Full snapshot for callers that need source / validUntil details. */
|
|
209
|
+
list() {
|
|
210
|
+
return this.all.slice();
|
|
211
|
+
}
|
|
212
|
+
/** When the cache was last refreshed. 0 means "never". */
|
|
213
|
+
get freshness() {
|
|
214
|
+
return this.lastUpdated;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Replace the cache with a fresh server response. The backend already
|
|
218
|
+
* filters to active + env-matching, so we don't re-filter — just trust
|
|
219
|
+
* what we got.
|
|
220
|
+
*/
|
|
221
|
+
setFromList(entitlements) {
|
|
222
|
+
this.all = entitlements.slice();
|
|
223
|
+
this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));
|
|
224
|
+
this.lastUpdated = Date.now();
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Wipe — used on reset() (logout). The SDK forgets everything until
|
|
228
|
+
* the next identify + read.
|
|
229
|
+
*/
|
|
230
|
+
clear() {
|
|
231
|
+
this.active.clear();
|
|
232
|
+
this.all = [];
|
|
233
|
+
this.lastUpdated = 0;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// src/event-queue.ts
|
|
238
|
+
var HARD_BUFFER_CAP = 1e3;
|
|
239
|
+
var EventQueue = class {
|
|
240
|
+
constructor(cfg) {
|
|
241
|
+
this.cfg = cfg;
|
|
242
|
+
this.buffer = [];
|
|
243
|
+
this.dropped = 0;
|
|
244
|
+
this.inFlight = 0;
|
|
245
|
+
this.lastFlushAt = 0;
|
|
246
|
+
this.lastError = null;
|
|
247
|
+
this.cancelTimer = null;
|
|
248
|
+
}
|
|
249
|
+
enqueue(event) {
|
|
250
|
+
this.buffer.push(event);
|
|
251
|
+
if (this.buffer.length > HARD_BUFFER_CAP) {
|
|
252
|
+
const overflow = this.buffer.length - HARD_BUFFER_CAP;
|
|
253
|
+
this.buffer.splice(0, overflow);
|
|
254
|
+
this.dropped += overflow;
|
|
255
|
+
this.cfg.onDrop?.(overflow);
|
|
256
|
+
}
|
|
257
|
+
if (this.buffer.length >= this.cfg.batchSize) {
|
|
258
|
+
void this.flush();
|
|
259
|
+
} else {
|
|
260
|
+
this.scheduleIdleFlush();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Flush the buffer to /v1/events. Resolves when the network call
|
|
265
|
+
* completes (success or failure). On failure, events stay in the
|
|
266
|
+
* buffer for the next flush attempt.
|
|
267
|
+
*/
|
|
268
|
+
async flush() {
|
|
269
|
+
if (this.buffer.length === 0) return null;
|
|
270
|
+
this.cancelTimerIfSet();
|
|
271
|
+
const batch = this.buffer.splice(0);
|
|
272
|
+
this.inFlight += batch.length;
|
|
273
|
+
try {
|
|
274
|
+
const result = await this.cfg.http.request("POST", "/events", {
|
|
275
|
+
body: { events: batch }
|
|
276
|
+
});
|
|
277
|
+
this.lastFlushAt = Date.now();
|
|
278
|
+
this.lastError = null;
|
|
279
|
+
this.inFlight -= batch.length;
|
|
280
|
+
return result;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
this.buffer.unshift(...batch);
|
|
283
|
+
this.inFlight -= batch.length;
|
|
284
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
285
|
+
this.scheduleIdleFlush();
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** Cancel any pending timer and clear in-memory state. */
|
|
290
|
+
reset() {
|
|
291
|
+
this.cancelTimerIfSet();
|
|
292
|
+
this.buffer = [];
|
|
293
|
+
this.dropped = 0;
|
|
294
|
+
this.inFlight = 0;
|
|
295
|
+
this.lastError = null;
|
|
296
|
+
}
|
|
297
|
+
getStats() {
|
|
298
|
+
return {
|
|
299
|
+
buffered: this.buffer.length,
|
|
300
|
+
dropped: this.dropped,
|
|
301
|
+
inFlight: this.inFlight,
|
|
302
|
+
lastFlushAt: this.lastFlushAt,
|
|
303
|
+
lastError: this.lastError
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
scheduleIdleFlush() {
|
|
307
|
+
this.cancelTimerIfSet();
|
|
308
|
+
const sched = this.cfg.scheduler ?? defaultScheduler;
|
|
309
|
+
this.cancelTimer = sched(() => {
|
|
310
|
+
void this.flush();
|
|
311
|
+
}, this.cfg.intervalMs);
|
|
312
|
+
}
|
|
313
|
+
cancelTimerIfSet() {
|
|
314
|
+
if (this.cancelTimer) {
|
|
315
|
+
this.cancelTimer();
|
|
316
|
+
this.cancelTimer = null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
function defaultScheduler(fn, ms) {
|
|
321
|
+
const id = setTimeout(fn, ms);
|
|
322
|
+
if (typeof id.unref === "function") {
|
|
323
|
+
try {
|
|
324
|
+
id.unref();
|
|
325
|
+
} catch {
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return () => clearTimeout(id);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/storage.ts
|
|
332
|
+
var MemoryStorage = class {
|
|
333
|
+
constructor() {
|
|
334
|
+
this.store = /* @__PURE__ */ new Map();
|
|
335
|
+
}
|
|
336
|
+
getItem(key) {
|
|
337
|
+
return this.store.get(key) ?? null;
|
|
338
|
+
}
|
|
339
|
+
setItem(key, value) {
|
|
340
|
+
this.store.set(key, value);
|
|
341
|
+
}
|
|
342
|
+
removeItem(key) {
|
|
343
|
+
this.store.delete(key);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
function detectDefaultStorage() {
|
|
347
|
+
try {
|
|
348
|
+
const ls = globalThis.localStorage;
|
|
349
|
+
if (ls) {
|
|
350
|
+
const probe = "__crossdeck_probe__";
|
|
351
|
+
ls.setItem(probe, "1");
|
|
352
|
+
ls.removeItem(probe);
|
|
353
|
+
return ls;
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
return new MemoryStorage();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/crossdeck.ts
|
|
361
|
+
var CrossdeckClient = class {
|
|
362
|
+
constructor() {
|
|
363
|
+
this.state = null;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Boot the SDK. Idempotent — calling start twice with the same options
|
|
367
|
+
* is a no-op; calling with different options replaces the previous
|
|
368
|
+
* configuration.
|
|
369
|
+
*/
|
|
370
|
+
start(options) {
|
|
371
|
+
if (!options.publicKey || !options.publicKey.startsWith("cd_pub_")) {
|
|
372
|
+
throw new CrossdeckError({
|
|
373
|
+
type: "configuration_error",
|
|
374
|
+
code: "invalid_public_key",
|
|
375
|
+
message: "Crossdeck.start requires a publishable key starting with cd_pub_."
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
const storage = options.storage ?? detectDefaultStorage();
|
|
379
|
+
const persistIdentity = options.persistIdentity ?? true;
|
|
380
|
+
const opts = {
|
|
381
|
+
publicKey: options.publicKey,
|
|
382
|
+
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
|
|
383
|
+
persistIdentity,
|
|
384
|
+
storagePrefix: options.storagePrefix ?? "crossdeck:",
|
|
385
|
+
autoHeartbeat: options.autoHeartbeat ?? true,
|
|
386
|
+
eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
|
|
387
|
+
eventFlushIntervalMs: options.eventFlushIntervalMs ?? 5e3,
|
|
388
|
+
sdkVersion: options.sdkVersion ?? SDK_VERSION
|
|
389
|
+
};
|
|
390
|
+
const http = new HttpClient({
|
|
391
|
+
publicKey: opts.publicKey,
|
|
392
|
+
baseUrl: opts.baseUrl,
|
|
393
|
+
sdkVersion: opts.sdkVersion
|
|
394
|
+
});
|
|
395
|
+
const effectiveStorage = persistIdentity ? storage : new MemoryStorage();
|
|
396
|
+
const identity = new IdentityStore(effectiveStorage, opts.storagePrefix);
|
|
397
|
+
const entitlements = new EntitlementCache();
|
|
398
|
+
const events = new EventQueue({
|
|
399
|
+
http,
|
|
400
|
+
batchSize: opts.eventFlushBatchSize,
|
|
401
|
+
intervalMs: opts.eventFlushIntervalMs
|
|
402
|
+
});
|
|
403
|
+
this.state = {
|
|
404
|
+
http,
|
|
405
|
+
identity,
|
|
406
|
+
entitlements,
|
|
407
|
+
events,
|
|
408
|
+
options: opts,
|
|
409
|
+
developerUserId: null
|
|
410
|
+
};
|
|
411
|
+
if (opts.autoHeartbeat) {
|
|
412
|
+
void this.heartbeat().catch(() => void 0);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Link the anonymous device to a developer-supplied user ID. Cache
|
|
417
|
+
* the resolved Crossdeck customer for follow-up calls.
|
|
418
|
+
*/
|
|
419
|
+
async identify(userId, _options) {
|
|
420
|
+
const s = this.requireStarted();
|
|
421
|
+
if (!userId) {
|
|
422
|
+
throw new CrossdeckError({
|
|
423
|
+
type: "invalid_request_error",
|
|
424
|
+
code: "missing_user_id",
|
|
425
|
+
message: "identify(userId) requires a non-empty userId."
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
const result = await s.http.request("POST", "/identity/alias", {
|
|
429
|
+
body: { userId, anonymousId: s.identity.anonymousId }
|
|
430
|
+
});
|
|
431
|
+
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
432
|
+
s.developerUserId = userId;
|
|
433
|
+
return result;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Read the current customer's active entitlements from the server.
|
|
437
|
+
* Updates the local cache so subsequent isEntitled() calls answer
|
|
438
|
+
* synchronously.
|
|
439
|
+
*/
|
|
440
|
+
async getEntitlements() {
|
|
441
|
+
const s = this.requireStarted();
|
|
442
|
+
const query = this.identityQueryParams();
|
|
443
|
+
const result = await s.http.request(
|
|
444
|
+
"GET",
|
|
445
|
+
"/entitlements",
|
|
446
|
+
{ query }
|
|
447
|
+
);
|
|
448
|
+
if (result.crossdeckCustomerId) {
|
|
449
|
+
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
450
|
+
}
|
|
451
|
+
s.entitlements.setFromList(result.data);
|
|
452
|
+
return result.data;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Synchronous read from the local cache. Returns false if the cache
|
|
456
|
+
* has never been populated (call getEntitlements first to warm it).
|
|
457
|
+
*/
|
|
458
|
+
isEntitled(key) {
|
|
459
|
+
const s = this.requireStarted();
|
|
460
|
+
return s.entitlements.isEntitled(key);
|
|
461
|
+
}
|
|
462
|
+
/** Snapshot of the local entitlement cache. */
|
|
463
|
+
listEntitlements() {
|
|
464
|
+
const s = this.requireStarted();
|
|
465
|
+
return s.entitlements.list();
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Queue a telemetry event. Returns immediately — the network round-
|
|
469
|
+
* trip happens in the background. To flush before the page unloads,
|
|
470
|
+
* call flushEvents().
|
|
471
|
+
*/
|
|
472
|
+
track(name, properties) {
|
|
473
|
+
const s = this.requireStarted();
|
|
474
|
+
if (!name) {
|
|
475
|
+
throw new CrossdeckError({
|
|
476
|
+
type: "invalid_request_error",
|
|
477
|
+
code: "missing_event_name",
|
|
478
|
+
message: "track(name) requires a non-empty name."
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
const event = {
|
|
482
|
+
eventId: this.mintEventId(),
|
|
483
|
+
name,
|
|
484
|
+
timestamp: Date.now(),
|
|
485
|
+
properties: properties ?? {}
|
|
486
|
+
};
|
|
487
|
+
Object.assign(event, this.identityHintForEvent());
|
|
488
|
+
s.events.enqueue(event);
|
|
489
|
+
}
|
|
490
|
+
/** Force-flush queued events. Useful to call from page-unload handlers. */
|
|
491
|
+
async flushEvents() {
|
|
492
|
+
const s = this.requireStarted();
|
|
493
|
+
await s.events.flush();
|
|
494
|
+
}
|
|
495
|
+
/** Forward an Apple StoreKit 2 transaction for verification + projection. */
|
|
496
|
+
async purchaseApple(input) {
|
|
497
|
+
const s = this.requireStarted();
|
|
498
|
+
if (!input.signedTransactionInfo) {
|
|
499
|
+
throw new CrossdeckError({
|
|
500
|
+
type: "invalid_request_error",
|
|
501
|
+
code: "missing_signed_transaction_info",
|
|
502
|
+
message: "purchaseApple requires a signedTransactionInfo string from StoreKit 2."
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
const result = await s.http.request("POST", "/purchases", {
|
|
506
|
+
body: { rail: "apple", ...input }
|
|
507
|
+
});
|
|
508
|
+
s.identity.setCrossdeckCustomerId(result.crossdeckCustomerId);
|
|
509
|
+
s.entitlements.setFromList(result.entitlements);
|
|
510
|
+
return result;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Send the boot heartbeat. Called automatically by start() unless
|
|
514
|
+
* autoHeartbeat:false. Safe to call manually as a "we're still here" ping.
|
|
515
|
+
*/
|
|
516
|
+
async heartbeat() {
|
|
517
|
+
const s = this.requireStarted();
|
|
518
|
+
return await s.http.request("GET", "/sdk/heartbeat");
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Wipe persisted identity + entitlement cache. Use on logout. The
|
|
522
|
+
* next pre-login session generates a fresh anonymousId and starts a
|
|
523
|
+
* new identity-graph entry.
|
|
524
|
+
*/
|
|
525
|
+
reset() {
|
|
526
|
+
if (!this.state) return;
|
|
527
|
+
this.state.identity.reset();
|
|
528
|
+
this.state.entitlements.clear();
|
|
529
|
+
this.state.events.reset();
|
|
530
|
+
this.state.developerUserId = null;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Diagnostic: current state + queue stats. Useful for the dashboard's
|
|
534
|
+
* heartbeat row and debugging in dev.
|
|
535
|
+
*
|
|
536
|
+
* Returns a stable shape regardless of whether start() has been called —
|
|
537
|
+
* callers don't need to narrow on `started` to access `events` or
|
|
538
|
+
* `entitlements`. Pre-start values are sensible empties.
|
|
539
|
+
*/
|
|
540
|
+
diagnostics() {
|
|
541
|
+
if (!this.state) {
|
|
542
|
+
return {
|
|
543
|
+
started: false,
|
|
544
|
+
anonymousId: null,
|
|
545
|
+
crossdeckCustomerId: null,
|
|
546
|
+
developerUserId: null,
|
|
547
|
+
sdkVersion: null,
|
|
548
|
+
baseUrl: null,
|
|
549
|
+
entitlements: { count: 0, lastUpdated: 0 },
|
|
550
|
+
events: {
|
|
551
|
+
buffered: 0,
|
|
552
|
+
dropped: 0,
|
|
553
|
+
inFlight: 0,
|
|
554
|
+
lastFlushAt: 0,
|
|
555
|
+
lastError: null
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const s = this.state;
|
|
560
|
+
return {
|
|
561
|
+
started: true,
|
|
562
|
+
anonymousId: s.identity.anonymousId,
|
|
563
|
+
crossdeckCustomerId: s.identity.crossdeckCustomerId,
|
|
564
|
+
developerUserId: s.developerUserId,
|
|
565
|
+
sdkVersion: s.options.sdkVersion,
|
|
566
|
+
baseUrl: s.options.baseUrl,
|
|
567
|
+
entitlements: {
|
|
568
|
+
count: s.entitlements.list().length,
|
|
569
|
+
lastUpdated: s.entitlements.freshness
|
|
570
|
+
},
|
|
571
|
+
events: s.events.getStats()
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
// ---------- private helpers ----------
|
|
575
|
+
requireStarted() {
|
|
576
|
+
if (!this.state) {
|
|
577
|
+
throw new CrossdeckError({
|
|
578
|
+
type: "configuration_error",
|
|
579
|
+
code: "not_started",
|
|
580
|
+
message: "Call Crossdeck.start({ publicKey }) before any other method."
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
return this.state;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Build the identity query for /v1/entitlements. Priority:
|
|
587
|
+
* crossdeckCustomerId > developerUserId > anonymousId
|
|
588
|
+
* — matches the resolveCrossdeckCustomerId precedence on the server.
|
|
589
|
+
*/
|
|
590
|
+
identityQueryParams() {
|
|
591
|
+
const s = this.requireStarted();
|
|
592
|
+
if (s.identity.crossdeckCustomerId) {
|
|
593
|
+
return { customerId: s.identity.crossdeckCustomerId };
|
|
594
|
+
}
|
|
595
|
+
if (s.developerUserId) return { userId: s.developerUserId };
|
|
596
|
+
return { anonymousId: s.identity.anonymousId };
|
|
597
|
+
}
|
|
598
|
+
/** Pick the right identity hint to embed on a queued event. */
|
|
599
|
+
identityHintForEvent() {
|
|
600
|
+
const s = this.requireStarted();
|
|
601
|
+
if (s.identity.crossdeckCustomerId) {
|
|
602
|
+
return { crossdeckCustomerId: s.identity.crossdeckCustomerId };
|
|
603
|
+
}
|
|
604
|
+
if (s.developerUserId) return { developerUserId: s.developerUserId };
|
|
605
|
+
return { anonymousId: s.identity.anonymousId };
|
|
606
|
+
}
|
|
607
|
+
mintEventId() {
|
|
608
|
+
const ts = Date.now().toString(36);
|
|
609
|
+
return `evt_${ts}${randomChars(8)}`;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
var Crossdeck = new CrossdeckClient();
|
|
613
|
+
export {
|
|
614
|
+
Crossdeck,
|
|
615
|
+
CrossdeckClient,
|
|
616
|
+
CrossdeckError,
|
|
617
|
+
DEFAULT_BASE_URL,
|
|
618
|
+
MemoryStorage,
|
|
619
|
+
SDK_NAME,
|
|
620
|
+
SDK_VERSION
|
|
621
|
+
};
|
|
622
|
+
//# sourceMappingURL=index.mjs.map
|