@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/dist/index.d.mts CHANGED
@@ -188,6 +188,48 @@ interface Diagnostics {
188
188
  };
189
189
  }
190
190
 
191
+ /**
192
+ * Local cache of active entitlements so isEntitled() can answer
193
+ * synchronously after the first read. Cache is updated:
194
+ * - On successful getEntitlements()
195
+ * - On successful purchase()
196
+ * - Manually via setFromList() (used by callers that batch updates)
197
+ *
198
+ * The cache holds only ACTIVE entitlements — inactive ones are excluded
199
+ * by the backend before they hit us. isEntitled returns false for
200
+ * anything not in the set.
201
+ *
202
+ * Reactive listener API
203
+ * ---------------------
204
+ * `subscribe(listener)` registers a callback that fires every time the
205
+ * cache mutates (setFromList or clear). This is the foundation for the
206
+ * `useEntitlement` React hook in `@cross-deck/web/react` and any other
207
+ * framework binding consumers need: SwiftUI's `@Observable`, Vue's
208
+ * `ref()`, Solid's signals, etc.
209
+ *
210
+ * Why we need it: isEntitled() is a sync cache read — but if a React
211
+ * component calls it in a render path, React has no way to know when
212
+ * the cache populates asynchronously after `getEntitlements()` lands.
213
+ * Without a subscribe API the component shows the empty-cache result
214
+ * forever (until something else triggers a re-render). With it, the
215
+ * binding can re-render when the data actually arrives.
216
+ *
217
+ * Listener semantics:
218
+ * - Fired AFTER the cache has been mutated (listener sees fresh state)
219
+ * - Fire-and-forget: thrown errors in a listener don't crash the SDK
220
+ * (they're swallowed; the next listener still runs)
221
+ * - The unsubscribe function returned from subscribe() is idempotent
222
+ * - Listeners are NOT fired on subscribe — caller is expected to
223
+ * read current state synchronously from isEntitled()/list() if it
224
+ * wants the initial render to reflect cached data
225
+ *
226
+ * Thread / re-entrancy safety: this is a synchronous in-memory Set with
227
+ * no I/O. The async paths that update it are serialised through the
228
+ * SDK's request queue — callers won't see torn reads.
229
+ */
230
+
231
+ type EntitlementsListener = (entitlements: PublicEntitlement[]) => void;
232
+
191
233
  /**
192
234
  * Public API surface for @cross-deck/web.
193
235
  *
@@ -258,6 +300,34 @@ declare class CrossdeckClient {
258
300
  isEntitled(key: string): boolean;
259
301
  /** Snapshot of the local entitlement cache. */
260
302
  listEntitlements(): PublicEntitlement[];
303
+ /**
304
+ * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
305
+ *
306
+ * The listener is invoked AFTER the cache mutates — once after a
307
+ * successful `getEntitlements()` warms it, again after `syncPurchases()`
308
+ * delivers fresh entitlements, and once on `reset()` to fire the
309
+ * empty-cache state for logout flows.
310
+ *
311
+ * It is NOT invoked synchronously on subscribe. Callers that need
312
+ * the current state should read it via `isEntitled()` / `listEntitlements()`
313
+ * inline; the listener fires only on FUTURE changes.
314
+ *
315
+ * This is the foundation of the `useEntitlement` React hook in
316
+ * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
317
+ * / Vue) would have no way to re-render when entitlements arrive
318
+ * asynchronously after init. The naive pattern of calling
319
+ * `Crossdeck.isEntitled("pro")` directly inside a render path
320
+ * shows the empty-cache result forever; binding the result to
321
+ * component state via `onEntitlementsChange` is the correct
322
+ * pattern.
323
+ *
324
+ * Idempotent unsubscribe — calling the returned function multiple
325
+ * times is safe.
326
+ *
327
+ * Listener errors are swallowed (a buggy listener can't crash the
328
+ * SDK or other listeners).
329
+ */
330
+ onEntitlementsChange(listener: EntitlementsListener): () => void;
261
331
  /**
262
332
  * Queue a telemetry event. Returns immediately — the network round-
263
333
  * trip happens in the background. To flush before the page unloads,
@@ -267,9 +337,16 @@ declare class CrossdeckClient {
267
337
  /**
268
338
  * Force-flush queued events. Useful to call from page-unload handlers.
269
339
  *
340
+ * Pass `{ keepalive: true }` from terminal handlers (pagehide /
341
+ * visibilitychange→hidden / beforeunload). The browser keeps the
342
+ * request alive after the page tears down, so the final batch
343
+ * actually lands instead of being cancelled with the unload.
344
+ *
270
345
  * NorthStar §4: standard method name across all Crossdeck SDKs.
271
346
  */
272
- flush(): Promise<void>;
347
+ flush(options?: {
348
+ keepalive?: boolean;
349
+ }): Promise<void>;
273
350
  /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
274
351
  flushEvents(): Promise<void>;
275
352
  /**
@@ -399,7 +476,7 @@ declare class MemoryStorage implements KeyValueStorage {
399
476
  * fetch shim, no transitive deps.
400
477
  */
401
478
  declare const SDK_NAME = "@cross-deck/web";
402
- declare const SDK_VERSION = "0.3.0";
479
+ declare const SDK_VERSION = "0.5.0";
403
480
  declare const DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
404
481
 
405
482
  /**
package/dist/index.d.ts CHANGED
@@ -188,6 +188,48 @@ interface Diagnostics {
188
188
  };
189
189
  }
190
190
 
191
+ /**
192
+ * Local cache of active entitlements so isEntitled() can answer
193
+ * synchronously after the first read. Cache is updated:
194
+ * - On successful getEntitlements()
195
+ * - On successful purchase()
196
+ * - Manually via setFromList() (used by callers that batch updates)
197
+ *
198
+ * The cache holds only ACTIVE entitlements — inactive ones are excluded
199
+ * by the backend before they hit us. isEntitled returns false for
200
+ * anything not in the set.
201
+ *
202
+ * Reactive listener API
203
+ * ---------------------
204
+ * `subscribe(listener)` registers a callback that fires every time the
205
+ * cache mutates (setFromList or clear). This is the foundation for the
206
+ * `useEntitlement` React hook in `@cross-deck/web/react` and any other
207
+ * framework binding consumers need: SwiftUI's `@Observable`, Vue's
208
+ * `ref()`, Solid's signals, etc.
209
+ *
210
+ * Why we need it: isEntitled() is a sync cache read — but if a React
211
+ * component calls it in a render path, React has no way to know when
212
+ * the cache populates asynchronously after `getEntitlements()` lands.
213
+ * Without a subscribe API the component shows the empty-cache result
214
+ * forever (until something else triggers a re-render). With it, the
215
+ * binding can re-render when the data actually arrives.
216
+ *
217
+ * Listener semantics:
218
+ * - Fired AFTER the cache has been mutated (listener sees fresh state)
219
+ * - Fire-and-forget: thrown errors in a listener don't crash the SDK
220
+ * (they're swallowed; the next listener still runs)
221
+ * - The unsubscribe function returned from subscribe() is idempotent
222
+ * - Listeners are NOT fired on subscribe — caller is expected to
223
+ * read current state synchronously from isEntitled()/list() if it
224
+ * wants the initial render to reflect cached data
225
+ *
226
+ * Thread / re-entrancy safety: this is a synchronous in-memory Set with
227
+ * no I/O. The async paths that update it are serialised through the
228
+ * SDK's request queue — callers won't see torn reads.
229
+ */
230
+
231
+ type EntitlementsListener = (entitlements: PublicEntitlement[]) => void;
232
+
191
233
  /**
192
234
  * Public API surface for @cross-deck/web.
193
235
  *
@@ -258,6 +300,34 @@ declare class CrossdeckClient {
258
300
  isEntitled(key: string): boolean;
259
301
  /** Snapshot of the local entitlement cache. */
260
302
  listEntitlements(): PublicEntitlement[];
303
+ /**
304
+ * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
305
+ *
306
+ * The listener is invoked AFTER the cache mutates — once after a
307
+ * successful `getEntitlements()` warms it, again after `syncPurchases()`
308
+ * delivers fresh entitlements, and once on `reset()` to fire the
309
+ * empty-cache state for logout flows.
310
+ *
311
+ * It is NOT invoked synchronously on subscribe. Callers that need
312
+ * the current state should read it via `isEntitled()` / `listEntitlements()`
313
+ * inline; the listener fires only on FUTURE changes.
314
+ *
315
+ * This is the foundation of the `useEntitlement` React hook in
316
+ * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
317
+ * / Vue) would have no way to re-render when entitlements arrive
318
+ * asynchronously after init. The naive pattern of calling
319
+ * `Crossdeck.isEntitled("pro")` directly inside a render path
320
+ * shows the empty-cache result forever; binding the result to
321
+ * component state via `onEntitlementsChange` is the correct
322
+ * pattern.
323
+ *
324
+ * Idempotent unsubscribe — calling the returned function multiple
325
+ * times is safe.
326
+ *
327
+ * Listener errors are swallowed (a buggy listener can't crash the
328
+ * SDK or other listeners).
329
+ */
330
+ onEntitlementsChange(listener: EntitlementsListener): () => void;
261
331
  /**
262
332
  * Queue a telemetry event. Returns immediately — the network round-
263
333
  * trip happens in the background. To flush before the page unloads,
@@ -267,9 +337,16 @@ declare class CrossdeckClient {
267
337
  /**
268
338
  * Force-flush queued events. Useful to call from page-unload handlers.
269
339
  *
340
+ * Pass `{ keepalive: true }` from terminal handlers (pagehide /
341
+ * visibilitychange→hidden / beforeunload). The browser keeps the
342
+ * request alive after the page tears down, so the final batch
343
+ * actually lands instead of being cancelled with the unload.
344
+ *
270
345
  * NorthStar §4: standard method name across all Crossdeck SDKs.
271
346
  */
272
- flush(): Promise<void>;
347
+ flush(options?: {
348
+ keepalive?: boolean;
349
+ }): Promise<void>;
273
350
  /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
274
351
  flushEvents(): Promise<void>;
275
352
  /**
@@ -399,7 +476,7 @@ declare class MemoryStorage implements KeyValueStorage {
399
476
  * fetch shim, no transitive deps.
400
477
  */
401
478
  declare const SDK_NAME = "@cross-deck/web";
402
- declare const SDK_VERSION = "0.3.0";
479
+ declare const SDK_VERSION = "0.5.0";
403
480
  declare const DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
404
481
 
405
482
  /**
package/dist/index.mjs CHANGED
@@ -46,7 +46,7 @@ function typeMapForStatus(status) {
46
46
 
47
47
  // src/http.ts
48
48
  var SDK_NAME = "@cross-deck/web";
49
- var SDK_VERSION = "0.3.0";
49
+ var SDK_VERSION = "0.5.0";
50
50
  var DEFAULT_BASE_URL = "https://api.cross-deck.com/v1";
51
51
  var HttpClient = class {
52
52
  constructor(config) {
@@ -78,7 +78,8 @@ var HttpClient = class {
78
78
  response = await fetch(url, {
79
79
  method,
80
80
  headers,
81
- body: bodyInit
81
+ body: bodyInit,
82
+ keepalive: options.keepalive === true
82
83
  });
83
84
  } catch (err) {
84
85
  throw new CrossdeckError({
@@ -200,6 +201,7 @@ var EntitlementCache = class {
200
201
  this.active = /* @__PURE__ */ new Set();
201
202
  this.all = [];
202
203
  this.lastUpdated = 0;
204
+ this.listeners = /* @__PURE__ */ new Set();
203
205
  }
204
206
  /** Sync read — true iff the entitlement key is currently active. */
205
207
  isEntitled(key) {
@@ -217,20 +219,57 @@ var EntitlementCache = class {
217
219
  * Replace the cache with a fresh server response. The backend already
218
220
  * filters to active + env-matching, so we don't re-filter — just trust
219
221
  * what we got.
222
+ *
223
+ * Fires listeners AFTER the mutation so each listener sees the new state.
220
224
  */
221
225
  setFromList(entitlements) {
222
226
  this.all = entitlements.slice();
223
227
  this.active = new Set(entitlements.filter((e) => e.isActive).map((e) => e.key));
224
228
  this.lastUpdated = Date.now();
229
+ this.notify();
225
230
  }
226
231
  /**
227
232
  * Wipe — used on reset() (logout). The SDK forgets everything until
228
233
  * the next identify + read.
234
+ *
235
+ * Fires listeners so React/SwiftUI/etc bindings re-render to the
236
+ * logged-out state immediately.
229
237
  */
230
238
  clear() {
231
239
  this.active.clear();
232
240
  this.all = [];
233
241
  this.lastUpdated = 0;
242
+ this.notify();
243
+ }
244
+ /**
245
+ * Subscribe to cache mutations. Returns an unsubscribe function.
246
+ *
247
+ * The listener is invoked AFTER setFromList() or clear() with the
248
+ * current snapshot. Throwing inside a listener is non-fatal — the
249
+ * error is swallowed and subsequent listeners still run.
250
+ *
251
+ * Used by `@cross-deck/web/react`'s `useEntitlement` hook to
252
+ * trigger re-renders when entitlements change.
253
+ */
254
+ subscribe(listener) {
255
+ this.listeners.add(listener);
256
+ let unsubscribed = false;
257
+ return () => {
258
+ if (unsubscribed) return;
259
+ unsubscribed = true;
260
+ this.listeners.delete(listener);
261
+ };
262
+ }
263
+ notify() {
264
+ if (this.listeners.size === 0) return;
265
+ const snapshot = this.all.slice();
266
+ const listenersSnapshot = [...this.listeners];
267
+ for (const listener of listenersSnapshot) {
268
+ try {
269
+ listener(snapshot);
270
+ } catch {
271
+ }
272
+ }
234
273
  }
235
274
  };
236
275
 
@@ -265,8 +304,12 @@ var EventQueue = class {
265
304
  * Flush the buffer to /v1/events. Resolves when the network call
266
305
  * completes (success or failure). On failure, events stay in the
267
306
  * buffer for the next flush attempt.
307
+ *
308
+ * `options.keepalive` marks the underlying fetch as keepalive so the
309
+ * browser keeps the request alive past page unload. Use this for
310
+ * terminal flushes (pagehide / visibilitychange→hidden / beforeunload).
268
311
  */
269
- async flush() {
312
+ async flush(options = {}) {
270
313
  if (this.buffer.length === 0) return null;
271
314
  this.cancelTimerIfSet();
272
315
  const batch = this.buffer.splice(0);
@@ -282,7 +325,8 @@ var EventQueue = class {
282
325
  environment: env.environment,
283
326
  sdk: env.sdk,
284
327
  events: batch
285
- }
328
+ },
329
+ keepalive: options.keepalive === true
286
330
  });
287
331
  this.lastFlushAt = Date.now();
288
332
  this.lastError = null;
@@ -712,7 +756,11 @@ var CrossdeckClient = class {
712
756
  storagePrefix: options.storagePrefix ?? "crossdeck:",
713
757
  autoHeartbeat: options.autoHeartbeat ?? true,
714
758
  eventFlushBatchSize: options.eventFlushBatchSize ?? 20,
715
- eventFlushIntervalMs: options.eventFlushIntervalMs ?? 5e3,
759
+ // 1500ms idle window. Short enough that an event queued on page
760
+ // load still flushes if the user leaves quickly (the keepalive
761
+ // pagehide handler picks up anything that doesn't); long enough
762
+ // that bursts of clicks coalesce into one network round-trip.
763
+ eventFlushIntervalMs: options.eventFlushIntervalMs ?? 1500,
716
764
  sdkVersion: options.sdkVersion ?? SDK_VERSION,
717
765
  autoTrack,
718
766
  appVersion: options.appVersion ?? null
@@ -754,7 +802,8 @@ var CrossdeckClient = class {
754
802
  deviceInfo,
755
803
  options: opts,
756
804
  debug,
757
- developerUserId: null
805
+ developerUserId: null,
806
+ uninstallUnloadFlush: null
758
807
  };
759
808
  debug.emit("sdk.configured", `Crossdeck connected to ${opts.appId} in ${opts.environment} mode.`, {
760
809
  appId: opts.appId,
@@ -769,6 +818,9 @@ var CrossdeckClient = class {
769
818
  this.state.autoTracker = tracker;
770
819
  tracker.install();
771
820
  }
821
+ this.state.uninstallUnloadFlush = installUnloadFlush(() => {
822
+ void this.flush({ keepalive: true }).catch(() => void 0);
823
+ });
772
824
  if (opts.autoHeartbeat) {
773
825
  void this.heartbeat().catch(() => void 0);
774
826
  }
@@ -838,6 +890,37 @@ var CrossdeckClient = class {
838
890
  const s = this.requireStarted();
839
891
  return s.entitlements.list();
840
892
  }
893
+ /**
894
+ * Subscribe to entitlement-cache changes. Returns an unsubscribe fn.
895
+ *
896
+ * The listener is invoked AFTER the cache mutates — once after a
897
+ * successful `getEntitlements()` warms it, again after `syncPurchases()`
898
+ * delivers fresh entitlements, and once on `reset()` to fire the
899
+ * empty-cache state for logout flows.
900
+ *
901
+ * It is NOT invoked synchronously on subscribe. Callers that need
902
+ * the current state should read it via `isEntitled()` / `listEntitlements()`
903
+ * inline; the listener fires only on FUTURE changes.
904
+ *
905
+ * This is the foundation of the `useEntitlement` React hook in
906
+ * `@cross-deck/web/react` — without it, React (or SwiftUI / Compose
907
+ * / Vue) would have no way to re-render when entitlements arrive
908
+ * asynchronously after init. The naive pattern of calling
909
+ * `Crossdeck.isEntitled("pro")` directly inside a render path
910
+ * shows the empty-cache result forever; binding the result to
911
+ * component state via `onEntitlementsChange` is the correct
912
+ * pattern.
913
+ *
914
+ * Idempotent unsubscribe — calling the returned function multiple
915
+ * times is safe.
916
+ *
917
+ * Listener errors are swallowed (a buggy listener can't crash the
918
+ * SDK or other listeners).
919
+ */
920
+ onEntitlementsChange(listener) {
921
+ const s = this.requireStarted();
922
+ return s.entitlements.subscribe(listener);
923
+ }
841
924
  /**
842
925
  * Queue a telemetry event. Returns immediately — the network round-
843
926
  * trip happens in the background. To flush before the page unloads,
@@ -884,11 +967,16 @@ var CrossdeckClient = class {
884
967
  /**
885
968
  * Force-flush queued events. Useful to call from page-unload handlers.
886
969
  *
970
+ * Pass `{ keepalive: true }` from terminal handlers (pagehide /
971
+ * visibilitychange→hidden / beforeunload). The browser keeps the
972
+ * request alive after the page tears down, so the final batch
973
+ * actually lands instead of being cancelled with the unload.
974
+ *
887
975
  * NorthStar §4: standard method name across all Crossdeck SDKs.
888
976
  */
889
- async flush() {
977
+ async flush(options = {}) {
890
978
  const s = this.requireStarted();
891
- await s.events.flush();
979
+ await s.events.flush(options);
892
980
  }
893
981
  /** @deprecated Use `flush()` instead. NorthStar §4 standardised the name. */
894
982
  async flushEvents() {
@@ -1071,6 +1159,23 @@ function resolveAutoTrack(input) {
1071
1159
  deviceInfo: input.deviceInfo ?? DEFAULT_AUTO_TRACK.deviceInfo
1072
1160
  };
1073
1161
  }
1162
+ function installUnloadFlush(onUnload) {
1163
+ const w = globalThis.window;
1164
+ const doc = globalThis.document;
1165
+ if (!w || !doc) return () => void 0;
1166
+ const onVisChange = () => {
1167
+ if (doc.visibilityState === "hidden") onUnload();
1168
+ };
1169
+ const onTerminal = () => onUnload();
1170
+ doc.addEventListener("visibilitychange", onVisChange);
1171
+ w.addEventListener("pagehide", onTerminal);
1172
+ w.addEventListener("beforeunload", onTerminal);
1173
+ return () => {
1174
+ doc.removeEventListener("visibilitychange", onVisChange);
1175
+ w.removeEventListener("pagehide", onTerminal);
1176
+ w.removeEventListener("beforeunload", onTerminal);
1177
+ };
1178
+ }
1074
1179
  export {
1075
1180
  Crossdeck,
1076
1181
  CrossdeckClient,