@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/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 cross­deckCustomerId 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