@lunora/client 1.0.0-alpha.21 → 1.0.0-alpha.22

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.
Files changed (30) hide show
  1. package/dist/auth/index.d.mts +1 -1
  2. package/dist/auth/index.d.ts +1 -1
  3. package/dist/index.d.mts +212 -4
  4. package/dist/index.d.ts +212 -4
  5. package/dist/index.mjs +9 -4
  6. package/dist/packem_shared/ClientServiceWorker-C3PAFwy0.mjs +100 -0
  7. package/dist/packem_shared/{LunoraClient-kXpHNyaE.mjs → LunoraClient-Clb118SU.mjs} +279 -22
  8. package/dist/packem_shared/{OfflineQueue-GGYJRmhF.mjs → OfflineQueue-B4HUF7rt.mjs} +1 -1
  9. package/dist/packem_shared/SubscriptionRegistry-D4jfIzZu.mjs +31 -0
  10. package/dist/packem_shared/TabCoordinator-BwRR8H06.mjs +222 -0
  11. package/dist/packem_shared/createClientQuery-CQ51bWAE.mjs +71 -0
  12. package/dist/packem_shared/createLocalStore-BDbbkoXw.mjs +2 -0
  13. package/dist/packem_shared/createReply-lI4tVS2w.mjs +36 -0
  14. package/dist/packem_shared/{createServerClient-DF-3mLmb.mjs → createServerClient-Dxemst5C.mjs} +1 -1
  15. package/dist/packem_shared/createSnapshotPrecondition-CBwnVz6r.mjs +18 -0
  16. package/dist/packem_shared/{local-store-BveBeFEo.mjs → local-store-DtcIW4c0.mjs} +1 -1
  17. package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.mts → lunora-client.d-pw-9sLl0.d.mts} +183 -1
  18. package/dist/packem_shared/{lunora-client.d-BYkEjCEJ.d.ts → lunora-client.d-pw-9sLl0.d.ts} +183 -1
  19. package/dist/packem_shared/{offline-queue-B9vfdSqp.mjs → offline-queue-CF4_Co5k.mjs} +29 -0
  20. package/dist/packem_shared/{preload.d-DrfuisCE.d.mts → preload.d-6ME5ubgq.d.mts} +1 -1
  21. package/dist/packem_shared/{preload.d-B-vyHnml.d.ts → preload.d-BkQr-3Vh.d.ts} +1 -1
  22. package/dist/packem_shared/{subscription-BjynOXCU.mjs → stable-key-wv6eP48B.mjs} +1 -29
  23. package/dist/query/index.d.mts +2 -2
  24. package/dist/query/index.d.ts +2 -2
  25. package/dist/ssr/index.d.mts +3 -3
  26. package/dist/ssr/index.d.ts +3 -3
  27. package/dist/ssr/index.mjs +1 -1
  28. package/package.json +1 -1
  29. package/dist/packem_shared/SubscriptionRegistry-DjGKZsqq.mjs +0 -1
  30. package/dist/packem_shared/createLocalStore-jRoqmazl.mjs +0 -2
@@ -1,13 +1,16 @@
1
1
  import { LunoraError } from '@lunora/errors';
2
- import { S as SubscriptionRegistry, s as stableStringify } from './subscription-BjynOXCU.mjs';
2
+ import { s as stableStringify } from './stable-key-wv6eP48B.mjs';
3
3
  import createInMemoryBookmarkStorage from './createInMemoryBookmarkStorage-BoN7a7TH.mjs';
4
+ import { ClientQueryStore } from './createClientQuery-CQ51bWAE.mjs';
5
+ import { TabCoordinator } from './TabCoordinator-BwRR8H06.mjs';
4
6
  import { isMutationDelta, applyDelta } from './applyDelta-CRKZ1PBt.mjs';
5
- import { a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, f as foldOptimistic, c as createLocalStore } from './local-store-BveBeFEo.mjs';
6
- import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-B9vfdSqp.mjs';
7
+ import { f as foldOptimistic, a as applyOptimisticLayer, d as dropConfirmedLayers, n as notifySubscription, c as createLocalStore } from './local-store-DtcIW4c0.mjs';
8
+ import { O as OfflineQueue, n as nextId, i as isStaleVersion, r as reportPersistenceError } from './offline-queue-CF4_Co5k.mjs';
7
9
  import { resolvePersistenceAdapter } from './createInMemoryPersistence-DZ2VHWgm.mjs';
8
10
  import { resolveQueryCacheAdapter, queryCacheKey } from './createInMemoryQueryCache-DiaGZkA2.mjs';
9
11
  import { createReconnect } from './createReconnect-Di_-oHH7.mjs';
10
12
  import { createStream } from './DEFAULT_MAX_BUFFER-7hFnzNk9.mjs';
13
+ import { SubscriptionRegistry } from './SubscriptionRegistry-D4jfIzZu.mjs';
11
14
 
12
15
  const MAX_BATCH_ENTRIES = 500;
13
16
 
@@ -21,6 +24,23 @@ const evictOldestEntry = (map, capacity) => {
21
24
  }
22
25
  };
23
26
 
27
+ const toBase64 = (bytes) => {
28
+ let binary = "";
29
+ const chunk = 32768;
30
+ for (let index = 0; index < bytes.length; index += chunk) {
31
+ binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
32
+ }
33
+ return btoa(binary);
34
+ };
35
+ const fromBase64 = (base64) => {
36
+ const binary = atob(base64);
37
+ const bytes = new Uint8Array(binary.length);
38
+ for (let index = 0; index < binary.length; index += 1) {
39
+ bytes[index] = binary.codePointAt(index) ?? 0;
40
+ }
41
+ return bytes;
42
+ };
43
+
24
44
  const TAG = "$lunora.wire$";
25
45
  const MAX_DEPTH = 64;
26
46
  const MAX_BIGINT_DIGITS = 1024;
@@ -47,22 +67,6 @@ const ERROR_CTORS = {
47
67
  TypeError,
48
68
  URIError
49
69
  };
50
- const toBase64 = (bytes) => {
51
- let binary = "";
52
- const chunk = 32768;
53
- for (let index = 0; index < bytes.length; index += chunk) {
54
- binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
55
- }
56
- return btoa(binary);
57
- };
58
- const fromBase64 = (base64) => {
59
- const binary = atob(base64);
60
- const bytes = new Uint8Array(binary.length);
61
- for (let index = 0; index < binary.length; index += 1) {
62
- bytes[index] = binary.codePointAt(index) ?? 0;
63
- }
64
- return bytes;
65
- };
66
70
  const encodeWire = (value, depth = 0) => {
67
71
  if (depth > MAX_DEPTH) {
68
72
  throw new RangeError(`wire-codec: value nesting exceeds the ${MAX_DEPTH}-level limit`);
@@ -463,11 +467,25 @@ const demuxBatchResults = (rawResults, count) => {
463
467
  return slots.map((slot) => slot ?? { error: new Error("batch call returned no result"), ok: false });
464
468
  };
465
469
  const TRANSIENT_BATCH_ERROR_CODES = /* @__PURE__ */ new Set(["SHARD_ERROR", "SHARD_UNAVAILABLE"]);
470
+ const RESOLVED_PROMISE = Promise.resolve();
466
471
  class LunoraClient {
467
472
  /** Hard cap on concurrently-buffered pokes — a backstop that reclaims buffers abandoned by a mid-poke disconnect (no `pokeEnd`). Far above any real concurrent-in-flight count. */
468
473
  static MAX_POKE_BUFFERS = 256;
474
+ /**
475
+ * Create a typed {@link ClientQueryRef}. Convenience wrapper around
476
+ * {@link createClientQuery} so you don't need a separate import.
477
+ * @example
478
+ * ```ts
479
+ * const sidebarOpen = LunoraClient.createClientQuery("sidebarOpen", true);
480
+ * ```
481
+ */
482
+ static createClientQuery(key, defaultValue) {
483
+ return { defaultValue, key };
484
+ }
469
485
  url;
470
486
  wsUrl;
487
+ /** Local reactive store for {@link ClientQueryRef} values — no server round-trip. Private; reach it via `getClientQuery` / `setClientQuery` / `subscribeClientQuery`. */
488
+ clientQueryStore;
471
489
  wsToken;
472
490
  /** Better-auth base path (trailing slash stripped) for the `get-session` lookup. */
473
491
  authBasePath;
@@ -488,6 +506,20 @@ class LunoraClient {
488
506
  outbox;
489
507
  /** Stable per-client id stamped onto every `OutboxMutation` (custom-mutator watermark). */
490
508
  clientId;
509
+ /**
510
+ * `true` when the constructor's hydration microtask has finished loading the
511
+ * durable read cache (Pillar 2) into `hydratedQueryCache`. Signals that
512
+ * the cache is ready for synchronous `peekHydratedQuery` reads.
513
+ */
514
+ readyResolved = false;
515
+ /** Resolvers for `whenReady()` — called once hydration completes. */
516
+ readyResolve;
517
+ /**
518
+ * Promise that resolves once the durable read cache has been loaded. When
519
+ * `hydrateOnStart` is not set or no query cache is configured, resolves
520
+ * immediately (the constructor creates an already-resolved promise).
521
+ */
522
+ readyPromise;
491
523
  /**
492
524
  * Highest custom-mutator watermark the server has echoed for this client,
493
525
  * keyed by shard bucket (`shardKey ?? ""`) since the DO tracks one
@@ -521,6 +553,12 @@ class LunoraClient {
521
553
  pendingCacheWrites = /* @__PURE__ */ new Map();
522
554
  cacheFlushTimer;
523
555
  subscriptions = new SubscriptionRegistry();
556
+ /**
557
+ * Cross-tab coordinator; created only when `crossTabSync: true`. When the
558
+ * client is not the elected leader, all WebSocket operations are skipped.
559
+ * Not `readonly` — `close()` clears it (mirrors `outboxLeaderRelease`).
560
+ */
561
+ tabCoordinator;
524
562
  /** One {@link ShardConnection} per shard key (keyed by `shardKey ?? ""`). */
525
563
  connections = /* @__PURE__ */ new Map();
526
564
  /** Default `connect`-envelope context applied to a shard with no explicit override. */
@@ -616,6 +654,59 @@ class LunoraClient {
616
654
  this.persistenceVersion = options.persistenceVersion;
617
655
  this.queryCache = resolveQueryCacheAdapter(options.queryCache);
618
656
  this.onPersistenceError = options.offlineQueue?.onPersistenceError;
657
+ this.clientQueryStore = new ClientQueryStore();
658
+ if (options.crossTabSync) {
659
+ this.tabCoordinator = new TabCoordinator({
660
+ onBecomeLeader: () => {
661
+ for (const state of this.subscriptions.all()) {
662
+ this.ensureSocket(state.shardKey);
663
+ this.sendSubscribeIfOpen(state);
664
+ }
665
+ },
666
+ onStopBeingLeader: () => {
667
+ for (const [key, conn] of this.connections) {
668
+ conn.socket?.close();
669
+ this.connections.delete(key);
670
+ }
671
+ },
672
+ onSubscriptionData: (key, data) => {
673
+ const state = this.subscriptions.get(key);
674
+ if (state) {
675
+ state.serverBase = data;
676
+ const folded = state.optimisticLayers.length === 0 ? data : foldOptimistic(data, state.optimisticLayers);
677
+ state.lastValue = folded;
678
+ for (const callback of state.callbacks) {
679
+ try {
680
+ callback(folded);
681
+ } catch {
682
+ }
683
+ }
684
+ }
685
+ },
686
+ onSubscriptionError: (key, error) => {
687
+ const state = this.subscriptions.get(key);
688
+ if (state) {
689
+ for (const callback of state.errorCallbacks) {
690
+ try {
691
+ callback(error);
692
+ } catch {
693
+ }
694
+ }
695
+ }
696
+ }
697
+ });
698
+ this.tabCoordinator.start();
699
+ } else {
700
+ this.tabCoordinator = void 0;
701
+ }
702
+ if (options.hydrateOnStart && this.queryCache) {
703
+ this.readyPromise = new Promise((resolve) => {
704
+ this.readyResolve = resolve;
705
+ });
706
+ } else {
707
+ this.readyResolved = true;
708
+ this.readyPromise = RESOLVED_PROMISE;
709
+ }
619
710
  this.offlineQueue = new OfflineQueue(options.offlineQueue, {
620
711
  onEvict: (entry, error) => {
621
712
  this.emitItemSettled(entry, "rejected", error);
@@ -635,7 +726,17 @@ class LunoraClient {
635
726
  }
636
727
  if (this.queryCache) {
637
728
  queueMicrotask(() => {
638
- this.hydrateQueryCache().catch(() => void 0);
729
+ const hydration = async () => {
730
+ try {
731
+ await this.hydrateQueryCache();
732
+ } catch {
733
+ } finally {
734
+ this.readyResolved = true;
735
+ this.readyResolve?.();
736
+ }
737
+ };
738
+ hydration().catch(() => {
739
+ });
639
740
  });
640
741
  }
641
742
  }
@@ -1008,6 +1109,135 @@ class LunoraClient {
1008
1109
  onMutationSettled(listener) {
1009
1110
  return this.mutationSettledListeners.add(listener);
1010
1111
  }
1112
+ // --- Client Query (local state) ----------------------------------------
1113
+ /**
1114
+ * Read the current value for a {@link ClientQueryRef}. Returns
1115
+ * `ref.defaultValue` when no value has been explicitly set.
1116
+ */
1117
+ getClientQuery(ref) {
1118
+ return this.clientQueryStore.get(ref);
1119
+ }
1120
+ /**
1121
+ * Set a new value for `ref` and notify every subscriber. Pass `undefined`
1122
+ * to reset the slot to `ref.defaultValue`.
1123
+ */
1124
+ setClientQuery(ref, value) {
1125
+ this.clientQueryStore.set(ref, value);
1126
+ }
1127
+ /**
1128
+ * Subscribe to changes for `ref`. The callback is NOT invoked on
1129
+ * registration — call {@link getClientQuery} for the current value.
1130
+ * Returns an unsubscribe function.
1131
+ */
1132
+ subscribeClientQuery(ref, callback) {
1133
+ return this.clientQueryStore.subscribe(ref, callback);
1134
+ }
1135
+ /**
1136
+ * Reset a {@link ClientQueryRef} to its default value, notifying every
1137
+ * subscriber. Equivalent to `setClientQuery(ref, ref.defaultValue)` but
1138
+ * removes the stored entry so a future {@link getClientQuery} returns
1139
+ * the default rather than an explicitly-set value.
1140
+ */
1141
+ resetClientQuery(ref) {
1142
+ this.clientQueryStore.reset(ref);
1143
+ }
1144
+ /**
1145
+ * Capture a snapshot of the current live query value at call time and
1146
+ * produce a `() => boolean` precondition that compares it against the
1147
+ * value at replay time (on queue drain / reconnect).
1148
+ *
1149
+ * When the precondition is checked it re-reads the query's current value
1150
+ * via `peekActiveQueryValue`. If the value differs from what was
1151
+ * captured at call time the precondition returns `false` and the offline
1152
+ * mutation is dropped as stale.
1153
+ *
1154
+ * This is a method wrapper around `createSnapshotPrecondition` that
1155
+ * binds the client instance for you — no need to pass `client` explicitly.
1156
+ * @example
1157
+ * ```ts
1158
+ * client.mutation(api.todos.update, { id, text }, {
1159
+ * precondition: client.snapshotPrecondition(api.todos.list, { userId }),
1160
+ * });
1161
+ * ```
1162
+ */
1163
+ snapshotPrecondition(functionRef, args, shardKey) {
1164
+ const snapshot = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
1165
+ const snapshotKey = snapshot === void 0 ? void 0 : stableStringify(snapshot);
1166
+ return () => {
1167
+ const current = this.peekActiveQueryValue(functionRef.__lunoraRef, args, shardKey);
1168
+ if (snapshotKey === void 0 && current === void 0) {
1169
+ return true;
1170
+ }
1171
+ if (snapshotKey === void 0 || current === void 0) {
1172
+ return false;
1173
+ }
1174
+ return stableStringify(current) === snapshotKey;
1175
+ };
1176
+ }
1177
+ // --- Hydration helpers --------------------------------------------------
1178
+ /**
1179
+ * Resolves once the durable read cache has been loaded into memory. When
1180
+ * `hydrateOnStart` is not configured or no query cache adapter is active,
1181
+ * returns an already-resolved promise so callers can always await it
1182
+ * unconditionally.
1183
+ *
1184
+ * Framework adapters (React, Vue, etc.) use this to gate the first
1185
+ * (enabled) render of a live query behind hydration, so the user sees
1186
+ * cached data instead of an undefined flash before the socket round-trip.
1187
+ */
1188
+ whenReady() {
1189
+ return this.readyPromise;
1190
+ }
1191
+ /**
1192
+ * Synchronously reports whether {@link whenReady} has already resolved (the
1193
+ * durable read cache is loaded, or none is configured). Framework adapters
1194
+ * read this to seed the hydration-gate state on the first render without
1195
+ * awaiting, then subscribe via {@link whenReady} for the pending case.
1196
+ */
1197
+ get isReady() {
1198
+ return this.readyResolved;
1199
+ }
1200
+ /**
1201
+ * Synchronously peek at a value the durable read cache loaded for the given
1202
+ * function path + args + shard key. Returns `undefined` when:
1203
+ *
1204
+ * - No query cache adapter is configured.
1205
+ * - Hydration hasn't completed yet (race — await {@link whenReady} first).
1206
+ * - The cached value's identity fingerprint doesn't match the current auth.
1207
+ *
1208
+ * Unlike the internal {@link takeHydratedCache}, this is a READ-ONLY peek:
1209
+ * the cached entry stays in `hydratedQueryCache` so the subscription created
1210
+ * later by {@link subscribe} consumes it normally.
1211
+ */
1212
+ peekHydratedQuery(functionPath, args, shardKey) {
1213
+ if (!this.readyResolved) {
1214
+ return void 0;
1215
+ }
1216
+ const argsKey = stableStringify(args);
1217
+ const key = queryCacheKey(functionPath, argsKey, shardKey);
1218
+ const entry = this.hydratedQueryCache.get(key);
1219
+ if (entry === void 0) {
1220
+ return void 0;
1221
+ }
1222
+ return entry.identity === this.identityFingerprint() ? entry.value : void 0;
1223
+ }
1224
+ /**
1225
+ * Peek at the **current live value** of an active subscription, if one
1226
+ * exists. Returns the subscription's `lastValue` (which includes any
1227
+ * optimistic overlay) or `undefined` if no subscription is active for the
1228
+ * given `(functionPath, args, shardKey)`.
1229
+ *
1230
+ * Unlike {@link peekHydratedQuery} (which reads from the durable read cache
1231
+ * and is independent of active subscriptions), this method reflects the
1232
+ * current in-memory state of an already-opened subscription — useful for
1233
+ * offline mutation preconditions that need to snapshot the value at call time
1234
+ * and compare it at replay time.
1235
+ */
1236
+ peekActiveQueryValue(functionPath, args, shardKey) {
1237
+ const key = SubscriptionRegistry.key(functionPath, args, shardKey);
1238
+ const state = this.subscriptions.get(key);
1239
+ return state?.lastValue;
1240
+ }
1011
1241
  // --- RPC ---------------------------------------------------------------
1012
1242
  async query(function_, args, options = {}) {
1013
1243
  if (this.closed) {
@@ -1105,7 +1335,15 @@ class LunoraClient {
1105
1335
  const shouldQueueOffline = this.WebSocketImpl !== void 0 && connectedGate;
1106
1336
  const midReconnect = wsState === "connecting" && connectedGate;
1107
1337
  if (wsState !== "open" && !hasSocket && shouldQueueOffline || midReconnect) {
1108
- return this.enqueueOfflineMutation(function_, argsRecord, options.shardKey, mutationId, optimisticRollbacks, optimisticConfirms);
1338
+ return this.enqueueOfflineMutation(
1339
+ function_,
1340
+ argsRecord,
1341
+ options.shardKey,
1342
+ mutationId,
1343
+ optimisticRollbacks,
1344
+ optimisticConfirms,
1345
+ options.precondition
1346
+ );
1109
1347
  }
1110
1348
  try {
1111
1349
  let commitCursor;
@@ -2113,6 +2351,8 @@ class LunoraClient {
2113
2351
  this.whisperHandlers.clear();
2114
2352
  this.shapeSubscriptions.clear();
2115
2353
  this.pokeBuffers.clear();
2354
+ this.tabCoordinator?.stop();
2355
+ this.tabCoordinator = void 0;
2116
2356
  }
2117
2357
  // --- Internals ----------------------------------------------------------
2118
2358
  /**
@@ -2126,7 +2366,7 @@ class LunoraClient {
2126
2366
  * optimistically (confirmation rides the synced view). Otherwise the
2127
2367
  * built-in `OfflineQueue` resolves/rejects the returned promise on replay.
2128
2368
  */
2129
- async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms) {
2369
+ async enqueueOfflineMutation(function_, argsRecord, shardKey, mutationId, optimisticRollbacks, optimisticConfirms, precondition) {
2130
2370
  const issuingIdentity = this.identityFingerprint();
2131
2371
  if (this.outbox) {
2132
2372
  this.outboxMutationCounter += 1;
@@ -2164,6 +2404,8 @@ class LunoraClient {
2164
2404
  // Persist the stamp alongside the record so a hydrated write can
2165
2405
  // only replay under the identity that queued it.
2166
2406
  identity: issuingIdentity,
2407
+ // Optional precondition checked before replay — see drainConflict.
2408
+ precondition,
2167
2409
  // Confirm the per-call optimistic layer(s) against the commit cursor
2168
2410
  // the flush replay echoes (see flushOfflineQueue).
2169
2411
  onCommit: (commitCursor) => {
@@ -2618,6 +2860,9 @@ class LunoraClient {
2618
2860
  if (this.closed || this.WebSocketImpl === void 0) {
2619
2861
  return;
2620
2862
  }
2863
+ if (this.tabCoordinator && !this.tabCoordinator.isLeader()) {
2864
+ return;
2865
+ }
2621
2866
  const conn = this.getOrCreateConnection(shardKey);
2622
2867
  if (conn.wsState === "open" || conn.wsState === "connecting") {
2623
2868
  return;
@@ -2979,6 +3224,10 @@ class LunoraClient {
2979
3224
  this.persistQueryValue(state);
2980
3225
  dropConfirmedLayers(state, state.serverCursor);
2981
3226
  notifySubscription(state, state.optimisticLayers.length === 0 ? payload : foldOptimistic(payload, state.optimisticLayers));
3227
+ if (this.tabCoordinator?.isLeader()) {
3228
+ const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
3229
+ this.tabCoordinator.broadcastSubscriptionData(key, payload);
3230
+ }
2982
3231
  }
2983
3232
  /**
2984
3233
  * Handle a `resume` frame (Pillar 1b): the server proved nothing the
@@ -3217,6 +3466,14 @@ class LunoraClient {
3217
3466
  this.queryCache?.clear().catch(() => void 0);
3218
3467
  }
3219
3468
  async flushOfflineQueue(shardKey) {
3469
+ const conflicted = this.offlineQueue.drainConflict();
3470
+ for (const item of conflicted) {
3471
+ this.unpersist(item.id);
3472
+ this.queuedIdentities.delete(item.id ?? "");
3473
+ const error = new Error("offline mutation skipped: precondition failed before replay");
3474
+ error.code = "OFFLINE_PRECONDITION_FAILED";
3475
+ this.emitItemSettled(item, "rejected", error);
3476
+ }
3220
3477
  const key = connectionKey(shardKey);
3221
3478
  const drained = this.offlineQueue.drain((item) => connectionKey(item.shardKey) === key);
3222
3479
  if (drained.length === 0) {
@@ -1 +1 @@
1
- export { O as OfflineQueue, n as nextId, r as reportPersistenceError } from './offline-queue-B9vfdSqp.mjs';
1
+ export { O as OfflineQueue, n as nextId, r as reportPersistenceError } from './offline-queue-CF4_Co5k.mjs';
@@ -0,0 +1,31 @@
1
+ import { s as stableStringify } from './stable-key-wv6eP48B.mjs';
2
+
3
+ class SubscriptionRegistry {
4
+ static key(functionPath, args, shardKey) {
5
+ return `${functionPath}::${stableStringify(args)}::${shardKey ?? ""}`;
6
+ }
7
+ byKey = /* @__PURE__ */ new Map();
8
+ byId = /* @__PURE__ */ new Map();
9
+ get(key) {
10
+ return this.byKey.get(key);
11
+ }
12
+ getById(id) {
13
+ return this.byId.get(id);
14
+ }
15
+ add(state) {
16
+ this.byKey.set(SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey), state);
17
+ this.byId.set(state.id, state);
18
+ }
19
+ remove(state) {
20
+ const key = SubscriptionRegistry.key(state.fn.__lunoraRef, state.args, state.shardKey);
21
+ if (this.byKey.get(key) === state) {
22
+ this.byKey.delete(key);
23
+ }
24
+ this.byId.delete(state.id);
25
+ }
26
+ all() {
27
+ return [...this.byKey.values()];
28
+ }
29
+ }
30
+
31
+ export { SubscriptionRegistry };
@@ -0,0 +1,222 @@
1
+ let nextTabId = 0;
2
+ const DEFAULT_CHANNEL = "lunora-bridge";
3
+ const DEFAULT_HEARTBEAT_MS = 1e3;
4
+ const DEFAULT_LEADER_TIMEOUT_MS = 3e3;
5
+ class TabCoordinator {
6
+ bc;
7
+ tabId;
8
+ heartbeatInterval;
9
+ leaderTimeout;
10
+ /** The tab id of the current known leader, or `undefined` if no leader. */
11
+ knownLeader = void 0;
12
+ /** `true` when this tab believes it is the leader. */
13
+ leader = false;
14
+ /** `true` once `start()` has been called. */
15
+ running = false;
16
+ /** Timestamp of the most recent leader heartbeat. */
17
+ lastHeartbeat = 0;
18
+ heartbeatTimer = void 0;
19
+ leaderCheckTimer = void 0;
20
+ /** Callbacks set via constructor options. */
21
+ onBecomeLeader;
22
+ onStopBeingLeader;
23
+ onSubscriptionData;
24
+ onSubscriptionError;
25
+ constructor(options = {}) {
26
+ nextTabId += 1;
27
+ this.tabId = `tab_${String(nextTabId)}_${crypto.randomUUID()}`;
28
+ this.heartbeatInterval = options.heartbeatInterval ?? DEFAULT_HEARTBEAT_MS;
29
+ this.leaderTimeout = options.leaderTimeout ?? DEFAULT_LEADER_TIMEOUT_MS;
30
+ this.onBecomeLeader = options.onBecomeLeader;
31
+ this.onStopBeingLeader = options.onStopBeingLeader;
32
+ this.onSubscriptionData = options.onSubscriptionData;
33
+ this.onSubscriptionError = options.onSubscriptionError;
34
+ if (typeof BroadcastChannel === "undefined") {
35
+ this.bc = void 0;
36
+ return;
37
+ }
38
+ this.bc = new BroadcastChannel(options.channelName ?? DEFAULT_CHANNEL);
39
+ this.bc.addEventListener("message", (event) => {
40
+ this.handleMessage(event.data);
41
+ });
42
+ }
43
+ // -----------------------------------------------------------------------
44
+ // Public API
45
+ // -----------------------------------------------------------------------
46
+ /**
47
+ * Start the coordinator: attempt to claim leadership and begin the
48
+ * heartbeat/leader-check cycle. Safe to call multiple times.
49
+ */
50
+ start() {
51
+ if (this.running) {
52
+ return;
53
+ }
54
+ this.running = true;
55
+ if (this.bc === void 0) {
56
+ this.becomeLeader();
57
+ return;
58
+ }
59
+ this.broadcast({ type: "claim-leadership", tabId: this.tabId, ts: Date.now() });
60
+ setTimeout(() => {
61
+ if (!this.running) {
62
+ return;
63
+ }
64
+ if (this.knownLeader === void 0) {
65
+ this.becomeLeader();
66
+ }
67
+ }, this.leaderTimeout);
68
+ this.leaderCheckTimer = setInterval(() => {
69
+ this.checkLeaderHealth();
70
+ }, this.leaderTimeout);
71
+ this.heartbeatTimer = setInterval(() => {
72
+ this.sendHeartbeat();
73
+ }, this.heartbeatInterval);
74
+ }
75
+ /**
76
+ * Stop the coordinator: yield leadership (if held), close the channel, and
77
+ * clear all timers. Safe to call multiple times.
78
+ */
79
+ stop() {
80
+ this.running = false;
81
+ if (this.leader) {
82
+ this.broadcast({ type: "yield-leadership", tabId: this.tabId });
83
+ this.leader = false;
84
+ this.knownLeader = void 0;
85
+ this.onStopBeingLeader?.();
86
+ }
87
+ if (this.heartbeatTimer !== void 0) {
88
+ clearInterval(this.heartbeatTimer);
89
+ this.heartbeatTimer = void 0;
90
+ }
91
+ if (this.leaderCheckTimer !== void 0) {
92
+ clearInterval(this.leaderCheckTimer);
93
+ this.leaderCheckTimer = void 0;
94
+ }
95
+ this.bc?.close();
96
+ }
97
+ /** `true` when this tab is the current WebSocket leader. */
98
+ isLeader() {
99
+ return this.leader;
100
+ }
101
+ /** The tab id of the current leader, or `undefined` if unknown / no leader. */
102
+ get leaderTabId() {
103
+ return this.knownLeader;
104
+ }
105
+ /** The id of this tab. */
106
+ get id() {
107
+ return this.tabId;
108
+ }
109
+ /** `true` when the coordinator has been started and is not yet stopped. */
110
+ get isRunning() {
111
+ return this.running;
112
+ }
113
+ // -----------------------------------------------------------------------
114
+ // Broadcasting
115
+ // -----------------------------------------------------------------------
116
+ /**
117
+ * Broadcast subscription data to all follower tabs. Only the leader should
118
+ * call this.
119
+ */
120
+ broadcastSubscriptionData(key, data) {
121
+ if (!this.leader) {
122
+ return;
123
+ }
124
+ this.broadcast({ type: "subscription-data", tabId: this.tabId, key, data });
125
+ }
126
+ /**
127
+ * Broadcast a subscription error to all follower tabs. Only the leader
128
+ * should call this.
129
+ */
130
+ broadcastSubscriptionError(key, error) {
131
+ if (!this.leader) {
132
+ return;
133
+ }
134
+ this.broadcast({ type: "subscription-error", tabId: this.tabId, key, error });
135
+ }
136
+ // -----------------------------------------------------------------------
137
+ // Internal
138
+ // -----------------------------------------------------------------------
139
+ broadcast(message) {
140
+ this.bc?.postMessage(message);
141
+ }
142
+ handleMessage(message) {
143
+ switch (message.type) {
144
+ case "claim-leadership": {
145
+ if (message.tabId === this.tabId) {
146
+ break;
147
+ }
148
+ if (this.leader) {
149
+ this.broadcast({ type: "heartbeat", tabId: this.tabId, ts: Date.now() });
150
+ break;
151
+ }
152
+ if (this.knownLeader === void 0 && message.tabId < this.tabId) {
153
+ this.knownLeader = message.tabId;
154
+ }
155
+ break;
156
+ }
157
+ case "heartbeat": {
158
+ this.lastHeartbeat = message.ts;
159
+ this.knownLeader = message.tabId;
160
+ break;
161
+ }
162
+ case "subscription-data": {
163
+ if (this.leader || message.tabId === this.tabId) {
164
+ break;
165
+ }
166
+ this.onSubscriptionData?.(message.key, message.data);
167
+ break;
168
+ }
169
+ case "subscription-error": {
170
+ if (this.leader || message.tabId === this.tabId) {
171
+ break;
172
+ }
173
+ this.onSubscriptionError?.(message.key, message.error);
174
+ break;
175
+ }
176
+ case "yield-leadership": {
177
+ if (this.knownLeader === message.tabId) {
178
+ this.knownLeader = void 0;
179
+ }
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ becomeLeader() {
185
+ if (this.leader) {
186
+ return;
187
+ }
188
+ this.leader = true;
189
+ this.knownLeader = this.tabId;
190
+ this.lastHeartbeat = Date.now();
191
+ this.onBecomeLeader?.();
192
+ this.broadcast({ type: "heartbeat", tabId: this.tabId, ts: Date.now() });
193
+ }
194
+ sendHeartbeat() {
195
+ if (!this.leader) {
196
+ return;
197
+ }
198
+ this.broadcast({ type: "heartbeat", tabId: this.tabId, ts: Date.now() });
199
+ }
200
+ checkLeaderHealth() {
201
+ if (!this.running) {
202
+ return;
203
+ }
204
+ if (this.leader) {
205
+ return;
206
+ }
207
+ if (this.knownLeader !== void 0) {
208
+ const elapsed = Date.now() - this.lastHeartbeat;
209
+ if (elapsed > this.leaderTimeout) {
210
+ this.knownLeader = void 0;
211
+ this.broadcast({ type: "claim-leadership", tabId: this.tabId, ts: Date.now() });
212
+ setTimeout(() => {
213
+ if (this.running && this.knownLeader === void 0) {
214
+ this.becomeLeader();
215
+ }
216
+ }, this.leaderTimeout);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ export { TabCoordinator };