@use-everywhere/core 0.6.0 → 0.7.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.js CHANGED
@@ -75,9 +75,31 @@ function newMsgId() {
75
75
  return randomHex(16);
76
76
  }
77
77
 
78
+ // src/rendezvous.ts
79
+ var PROTOCOL = 1;
80
+ var TABLE = /* @__PURE__ */ Symbol.for(`use-everywhere.rendezvous.${PROTOCOL}`);
81
+ var CENSUS = /* @__PURE__ */ Symbol.for("use-everywhere.rendezvous.census");
82
+ function announce() {
83
+ const g = globalThis;
84
+ const census = g[CENSUS] ?? (g[CENSUS] = { protocols: [] });
85
+ if (census.protocols.includes(PROTOCOL)) return;
86
+ census.protocols.push(PROTOCOL);
87
+ if (census.protocols.length > 1) {
88
+ devWarn(
89
+ `[use-everywhere] two incompatible versions of this library are loaded on one page (rendezvous protocols ${census.protocols.join(", ")}). They will not share a client identity: expect one presence entry per version and no synchronous delivery between them. They still sync over the bus. Align the versions to fix it.`
90
+ );
91
+ }
92
+ }
93
+ function busTable() {
94
+ announce();
95
+ const g = globalThis;
96
+ return g[TABLE] ?? (g[TABLE] = /* @__PURE__ */ new Map());
97
+ }
98
+
78
99
  // src/transport/broadcast-channel-transport.ts
79
100
  var BroadcastChannelTransport = class {
80
101
  constructor(name) {
102
+ this.kind = "broadcast-channel";
81
103
  this.listeners = /* @__PURE__ */ new Set();
82
104
  this.bc = new BroadcastChannel(name);
83
105
  this.bc.onmessage = (event) => {
@@ -99,6 +121,9 @@ var BroadcastChannelTransport = class {
99
121
 
100
122
  // src/transport/noop-transport.ts
101
123
  var NoopTransport = class {
124
+ constructor() {
125
+ this.kind = "none";
126
+ }
102
127
  post() {
103
128
  }
104
129
  subscribe() {
@@ -109,115 +134,217 @@ var NoopTransport = class {
109
134
  }
110
135
  };
111
136
 
137
+ // src/transport/storage-transport.ts
138
+ var StorageTransport = class {
139
+ constructor(name, storage = globalThis.localStorage) {
140
+ this.kind = "storage";
141
+ this.listeners = /* @__PURE__ */ new Set();
142
+ this.seq = 0;
143
+ if (!storage) {
144
+ throw new Error(
145
+ "[use-everywhere] StorageTransport needs localStorage; workers have none. Use BroadcastChannel there."
146
+ );
147
+ }
148
+ this.key = `use-everywhere:bus:${name}`;
149
+ this.storage = storage;
150
+ this.onStorage = (event) => {
151
+ if (event.key !== this.key || event.newValue === null) return;
152
+ let payload;
153
+ try {
154
+ payload = JSON.parse(event.newValue);
155
+ } catch {
156
+ return;
157
+ }
158
+ for (const listener of this.listeners) listener(payload.data);
159
+ };
160
+ addEventListener("storage", this.onStorage);
161
+ }
162
+ post(data) {
163
+ const envelope = JSON.stringify({ seq: this.seq++, data }, rejectUnrepresentable);
164
+ try {
165
+ this.storage.setItem(this.key, envelope);
166
+ this.storage.removeItem(this.key);
167
+ } catch {
168
+ }
169
+ }
170
+ subscribe(listener) {
171
+ this.listeners.add(listener);
172
+ return () => this.listeners.delete(listener);
173
+ }
174
+ close() {
175
+ this.listeners.clear();
176
+ removeEventListener("storage", this.onStorage);
177
+ }
178
+ };
179
+ function rejectUnrepresentable(_key, value) {
180
+ if (typeof value === "function" || typeof value === "symbol") {
181
+ throw new TypeError(`${typeof value} values cannot be shared`);
182
+ }
183
+ return value;
184
+ }
185
+
112
186
  // src/transport/default-transport.ts
113
187
  function isBroadcastChannelAvailable() {
114
188
  return typeof BroadcastChannel !== "undefined";
115
189
  }
190
+ function isStorageEventAvailable() {
191
+ if (typeof addEventListener !== "function") return false;
192
+ try {
193
+ const storage = globalThis.localStorage;
194
+ if (!storage) return false;
195
+ const probe = "use-everywhere:probe";
196
+ storage.setItem(probe, "1");
197
+ storage.removeItem(probe);
198
+ return true;
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
116
203
  function defaultTransport(name) {
117
- return isBroadcastChannelAvailable() ? new BroadcastChannelTransport(name) : new NoopTransport();
204
+ if (isBroadcastChannelAvailable()) return new BroadcastChannelTransport(name);
205
+ if (isStorageEventAvailable()) {
206
+ devWarn(
207
+ "[use-everywhere] no BroadcastChannel; using the storage-event fallback. Values serialise as JSON, not structured clone \u2014 keep them JSON-shaped."
208
+ );
209
+ return new StorageTransport(name);
210
+ }
211
+ devWarn(
212
+ "[use-everywhere] no BroadcastChannel and no usable localStorage: nothing is shared between tabs. Storage is probably blocked."
213
+ );
214
+ return new NoopTransport();
118
215
  }
119
216
 
120
217
  // src/bus.ts
121
218
  function isBusWire(data) {
122
- return typeof data === "object" && data !== null && data.v === 1;
219
+ if (typeof data !== "object" || data === null) return false;
220
+ const wire = data;
221
+ return wire.v === 1 && typeof wire.scope === "string" && typeof wire.type === "string" && typeof wire.clientId === "string";
123
222
  }
124
223
  function defaultKind() {
125
224
  return typeof document === "undefined" ? "worker" : "tab";
126
225
  }
127
- var registry = /* @__PURE__ */ new Map();
128
- function createBus(name, options, onShutdown) {
226
+ var SHARED_WITHIN_CLIENT = /* @__PURE__ */ new Set(["state", "event"]);
227
+ function createBusCore(name, options, onShutdown) {
129
228
  const transport = (options.transport ?? defaultTransport)(name);
130
229
  const clientId = newClientId();
131
230
  const kind = options.kind ?? defaultKind();
132
- const listeners = /* @__PURE__ */ new Set();
231
+ const handles = /* @__PURE__ */ new Set();
133
232
  let refs = 0;
134
233
  let closed = false;
135
- const post = (wire) => {
234
+ const deliver = (wire, from) => {
235
+ for (const handle of handles) {
236
+ if (handle === from) continue;
237
+ for (const fn of handle.listeners) fn(wire);
238
+ }
239
+ };
240
+ const post = (wire, from) => {
136
241
  if (closed) return;
137
242
  emitBusEvent(name, "out", wire);
138
243
  transport.post(wire);
244
+ if (SHARED_WITHIN_CLIENT.has(wire.scope)) deliver(wire, from);
139
245
  };
140
246
  const unsubscribe = transport.subscribe((data) => {
141
247
  if (!isBusWire(data)) return;
142
248
  if (data.clientId === clientId) return;
143
249
  emitBusEvent(name, "in", data);
144
250
  if (data.scope === "presence" && data.type === "hello") {
145
- post({ v: 1, scope: "presence", type: "ping", clientId, kind });
251
+ post({ v: 1, scope: "presence", type: "ping", clientId, kind }, null);
146
252
  }
147
- for (const fn of listeners) fn(data);
253
+ deliver(data, null);
148
254
  });
149
- post({ v: 1, scope: "presence", type: "hello", clientId, kind });
255
+ post({ v: 1, scope: "presence", type: "hello", clientId, kind }, null);
150
256
  const heartbeat = setInterval(
151
- () => post({ v: 1, scope: "presence", type: "ping", clientId, kind }),
257
+ () => post({ v: 1, scope: "presence", type: "ping", clientId, kind }, null),
152
258
  options.heartbeatMs ?? 2e3
153
259
  );
154
- const sayBye = () => post({ v: 1, scope: "presence", type: "bye", clientId, kind });
260
+ const sayBye = () => post({ v: 1, scope: "presence", type: "bye", clientId, kind }, null);
155
261
  const onPageShow = (event) => {
156
262
  if (!event.persisted) return;
157
- post({ v: 1, scope: "presence", type: "hello", clientId, kind });
263
+ post({ v: 1, scope: "presence", type: "hello", clientId, kind }, null);
264
+ };
265
+ const onVisible = () => {
266
+ if (document.visibilityState === "visible") {
267
+ post({ v: 1, scope: "presence", type: "hello", clientId, kind }, null);
268
+ }
158
269
  };
159
270
  const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
160
271
  if (hasWindow) {
161
272
  addEventListener("pagehide", sayBye);
162
273
  addEventListener("pageshow", onPageShow);
274
+ addEventListener("visibilitychange", onVisible);
163
275
  }
276
+ const shutdown = () => {
277
+ sayBye();
278
+ closed = true;
279
+ clearInterval(heartbeat);
280
+ if (hasWindow) {
281
+ removeEventListener("pagehide", sayBye);
282
+ removeEventListener("pageshow", onPageShow);
283
+ removeEventListener("visibilitychange", onVisible);
284
+ }
285
+ unsubscribe();
286
+ transport.close();
287
+ handles.clear();
288
+ onShutdown();
289
+ };
164
290
  return {
165
291
  name,
166
292
  clientId,
167
293
  kind,
294
+ transportKind: transport.kind ?? "custom",
168
295
  heartbeatMs: options.heartbeatMs ?? 2e3,
169
- post,
170
- subscribe(fn) {
171
- listeners.add(fn);
172
- return () => listeners.delete(fn);
173
- },
174
- acquire() {
296
+ connect() {
297
+ const handle = { listeners: /* @__PURE__ */ new Set() };
298
+ handles.add(handle);
175
299
  refs++;
176
- },
177
- release() {
178
- if (closed) return;
179
- refs--;
180
- if (refs > 0) return;
181
- sayBye();
182
- closed = true;
183
- clearInterval(heartbeat);
184
- if (hasWindow) {
185
- removeEventListener("pagehide", sayBye);
186
- removeEventListener("pageshow", onPageShow);
187
- }
188
- unsubscribe();
189
- transport.close();
190
- listeners.clear();
191
- onShutdown();
300
+ let released = false;
301
+ return {
302
+ name,
303
+ clientId,
304
+ kind,
305
+ transportKind: transport.kind ?? "custom",
306
+ post: (wire) => post(wire, handle),
307
+ subscribe(fn) {
308
+ handle.listeners.add(fn);
309
+ return () => handle.listeners.delete(fn);
310
+ },
311
+ release() {
312
+ if (released || closed) return;
313
+ released = true;
314
+ handles.delete(handle);
315
+ handle.listeners.clear();
316
+ refs--;
317
+ if (refs === 0) shutdown();
318
+ }
319
+ };
192
320
  }
193
321
  };
194
322
  }
195
323
  function getBusNames() {
196
- return [...registry.keys()];
324
+ return [...busTable().keys()];
325
+ }
326
+ function getTransportKind(name) {
327
+ return busTable().get(name)?.transportKind ?? null;
197
328
  }
198
329
  function getBus(name, options = {}) {
199
- if (options.transport) {
200
- const bus2 = createBus(name, options, () => {
201
- });
202
- bus2.acquire();
203
- return bus2;
204
- }
205
- let bus = registry.get(name);
206
- if (!bus) {
207
- bus = createBus(name, options, () => registry.delete(name));
208
- registry.set(name, bus);
330
+ if (options.transport) return createBusCore(name, options, () => {
331
+ }).connect();
332
+ const table = busTable();
333
+ let core = table.get(name);
334
+ if (!core) {
335
+ core = createBusCore(name, options, () => table.delete(name));
336
+ table.set(name, core);
209
337
  } else {
210
- if (options.heartbeatMs !== void 0 && options.heartbeatMs !== bus.heartbeatMs) {
338
+ if (options.heartbeatMs !== void 0 && options.heartbeatMs !== core.heartbeatMs) {
211
339
  devWarn(
212
340
  `[use-everywhere] bus "${name}": heartbeatMs ignored \u2014 the first creator fixes bus options`
213
341
  );
214
342
  }
215
- if (options.kind !== void 0 && options.kind !== bus.kind) {
343
+ if (options.kind !== void 0 && options.kind !== core.kind) {
216
344
  devWarn(`[use-everywhere] bus "${name}": kind ignored \u2014 the first creator fixes bus options`);
217
345
  }
218
346
  }
219
- bus.acquire();
220
- return bus;
347
+ return core.connect();
221
348
  }
222
349
 
223
350
  // src/channel.ts
@@ -269,6 +396,9 @@ function createChannel(name, options = {}) {
269
396
  function newer(a, b) {
270
397
  return !b || a[0] > b[0] || a[0] === b[0] && a[1] > b[1];
271
398
  }
399
+ function isVersion(value) {
400
+ return Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && Number.isFinite(value[0]) && typeof value[1] === "string";
401
+ }
272
402
 
273
403
  // src/dev-freeze.ts
274
404
  var inDev2 = false;
@@ -308,7 +438,7 @@ function createSharedStore(name, initial, options = {}) {
308
438
  const live = liveStores.get(name) ?? 0;
309
439
  if (live > 0) {
310
440
  devWarn(
311
- `[use-everywhere] second shared store for "${name}" in this tab \u2014 stores on one page never hear each other and will diverge. Reuse one per name.`
441
+ `[use-everywhere] second shared store for "${name}" in this tab \u2014 they stay in sync, but you are paying twice for one store's state, subscriptions, and persistence writes. Reuse one per name.`
312
442
  );
313
443
  }
314
444
  liveStores.set(name, live + 1);
@@ -356,11 +486,14 @@ function createSharedStore(name, initial, options = {}) {
356
486
  }
357
487
  if (accept && !accept(meta)) return;
358
488
  if (wire.type === "patch") {
489
+ if (typeof wire.key !== "string" || !isVersion(wire.version)) return;
359
490
  applyRemote(wire.key, wire.value, wire.version, meta);
360
491
  } else {
492
+ const versions2 = wire.versions;
493
+ if (typeof versions2 !== "object" || versions2 === null) return;
361
494
  for (const k in wire.state) {
362
- const version = wire.versions[k];
363
- if (version) applyRemote(k, wire.state[k], version, meta);
495
+ const version = versions2[k];
496
+ if (isVersion(version)) applyRemote(k, wire.state[k], version, meta);
364
497
  }
365
498
  }
366
499
  });
@@ -514,6 +647,7 @@ function createSharedStore(name, initial, options = {}) {
514
647
  // src/presence.ts
515
648
  function createPresence(name, options = {}) {
516
649
  const pruneAfterMs = options.pruneAfterMs ?? 5e3;
650
+ const probeGraceMs = options.probeGraceMs ?? 1e3;
517
651
  const bus = getBus(name, options);
518
652
  const peers = /* @__PURE__ */ new Map();
519
653
  const listeners = /* @__PURE__ */ new Set();
@@ -523,6 +657,7 @@ function createPresence(name, options = {}) {
523
657
  for (const fn of listeners) fn();
524
658
  }
525
659
  const unsubscribe = bus.subscribe((wire) => {
660
+ if (wire.clientId === bus.clientId) return;
526
661
  if (wire.scope === "presence" && wire.type === "bye") {
527
662
  if (peers.delete(wire.clientId)) notify();
528
663
  return;
@@ -531,19 +666,41 @@ function createPresence(name, options = {}) {
531
666
  peers.set(wire.clientId, { id: wire.clientId, kind: wire.kind, lastSeen: Date.now() });
532
667
  if (!existing) notify();
533
668
  });
669
+ bus.post({ v: 1, scope: "presence", type: "hello", clientId: bus.clientId, kind: bus.kind });
670
+ const probed = /* @__PURE__ */ new Map();
534
671
  const prune = setInterval(
535
672
  () => {
536
- const cutoff = Date.now() - pruneAfterMs;
673
+ const now = Date.now();
674
+ const silentSince = now - pruneAfterMs;
537
675
  let changed = false;
676
+ let needProbe = false;
538
677
  for (const [id, peer] of peers) {
539
- if (peer.lastSeen < cutoff) {
678
+ if (peer.lastSeen >= silentSince) {
679
+ probed.delete(id);
680
+ continue;
681
+ }
682
+ const askedAt = probed.get(id);
683
+ if (askedAt === void 0) {
684
+ probed.set(id, now);
685
+ needProbe = true;
686
+ } else if (now - askedAt >= probeGraceMs) {
540
687
  peers.delete(id);
688
+ probed.delete(id);
541
689
  changed = true;
542
690
  }
543
691
  }
692
+ if (needProbe) {
693
+ bus.post({
694
+ v: 1,
695
+ scope: "presence",
696
+ type: "hello",
697
+ clientId: bus.clientId,
698
+ kind: bus.kind
699
+ });
700
+ }
544
701
  if (changed) notify();
545
702
  },
546
- Math.max(500, Math.floor(pruneAfterMs / 2))
703
+ Math.max(250, Math.floor(Math.min(pruneAfterMs, probeGraceMs) / 2))
547
704
  );
548
705
  let closed = false;
549
706
  return {
@@ -564,9 +721,149 @@ function createPresence(name, options = {}) {
564
721
  };
565
722
  }
566
723
 
567
- // src/leader.ts
724
+ // src/leader-web-locks.ts
568
725
  var NO_LEADER = Object.freeze({ leaderId: null, isLeader: false });
726
+ function createWebLocksLeader(name, options, locks) {
727
+ const busOptions = {
728
+ ...options.transport ? { transport: options.transport } : {},
729
+ ...options.kind ? { kind: options.kind } : {}
730
+ };
731
+ const bus = getBus(name, busOptions);
732
+ const clientId = bus.clientId;
733
+ let eligible = options.eligible ?? true;
734
+ let leaderId = null;
735
+ let snapshot = NO_LEADER;
736
+ let closed = false;
737
+ const listeners = /* @__PURE__ */ new Set();
738
+ const waiters = /* @__PURE__ */ new Set();
739
+ let releaseHeld = null;
740
+ let pending = null;
741
+ function setLeader(id) {
742
+ if (id === leaderId) return;
743
+ leaderId = id;
744
+ snapshot = Object.freeze({ leaderId: id, isLeader: id === clientId });
745
+ for (const fn of listeners) fn();
746
+ if (id === clientId) {
747
+ for (const waiter of waiters) waiter.resolve();
748
+ waiters.clear();
749
+ }
750
+ }
751
+ const term = [1, clientId];
752
+ const announce2 = () => bus.post({ v: 1, scope: "leader", type: "heartbeat", term, clientId, kind: bus.kind });
753
+ const unsubscribe = bus.subscribe((wire) => {
754
+ if (wire.scope !== "leader") return;
755
+ if (wire.type === "hello") {
756
+ if (leaderId === clientId) announce2();
757
+ return;
758
+ }
759
+ if (wire.type === "resign") {
760
+ if (wire.clientId === leaderId) setLeader(null);
761
+ return;
762
+ }
763
+ setLeader(wire.clientId);
764
+ });
765
+ function joinQueue() {
766
+ if (closed || !eligible || releaseHeld || pending) return;
767
+ const controller = new AbortController();
768
+ pending = controller;
769
+ const held = new Promise((resolve) => {
770
+ releaseHeld = resolve;
771
+ });
772
+ void locks.request(name, { signal: controller.signal }, async () => {
773
+ pending = null;
774
+ if (closed || !eligible) return;
775
+ setLeader(clientId);
776
+ announce2();
777
+ await held;
778
+ }).catch(() => {
779
+ });
780
+ }
781
+ function letGo() {
782
+ const release = releaseHeld;
783
+ releaseHeld = null;
784
+ release?.();
785
+ }
786
+ function resign() {
787
+ if (leaderId !== clientId) return;
788
+ letGo();
789
+ setLeader(null);
790
+ bus.post({ v: 1, scope: "leader", type: "resign", term, clientId, kind: bus.kind });
791
+ joinQueue();
792
+ }
793
+ const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
794
+ const onPageHide = () => resign();
795
+ if (hasWindow) addEventListener("pagehide", onPageHide);
796
+ bus.post({ v: 1, scope: "leader", type: "hello", clientId, kind: bus.kind });
797
+ joinQueue();
798
+ return {
799
+ clientId,
800
+ strategy: "web-locks",
801
+ getSnapshot: () => snapshot,
802
+ subscribe(fn) {
803
+ listeners.add(fn);
804
+ return () => listeners.delete(fn);
805
+ },
806
+ waitForLeadership() {
807
+ if (leaderId === clientId) return Promise.resolve();
808
+ if (closed) return Promise.reject(new Error("leader is closed"));
809
+ return new Promise((resolve, reject) => {
810
+ waiters.add({ resolve, reject });
811
+ });
812
+ },
813
+ resign,
814
+ setEligible(next) {
815
+ if (next === eligible) return;
816
+ eligible = next;
817
+ if (next) {
818
+ joinQueue();
819
+ return;
820
+ }
821
+ if (leaderId === clientId) resignWithoutRequeue();
822
+ pending?.abort();
823
+ pending = null;
824
+ },
825
+ close() {
826
+ if (closed) return;
827
+ closed = true;
828
+ if (leaderId === clientId) resignWithoutRequeue();
829
+ pending?.abort();
830
+ pending = null;
831
+ if (hasWindow) removeEventListener("pagehide", onPageHide);
832
+ for (const waiter of waiters) waiter.reject(new Error("leader is closed"));
833
+ waiters.clear();
834
+ unsubscribe();
835
+ listeners.clear();
836
+ bus.release();
837
+ }
838
+ };
839
+ function resignWithoutRequeue() {
840
+ letGo();
841
+ setLeader(null);
842
+ bus.post({ v: 1, scope: "leader", type: "resign", term, clientId, kind: bus.kind });
843
+ }
844
+ }
845
+
846
+ // src/leader.ts
847
+ var NO_LEADER2 = Object.freeze({ leaderId: null, isLeader: false });
848
+ function availableLocks(options) {
849
+ if (options.locks) return options.locks;
850
+ const locks = globalThis.navigator?.locks;
851
+ return typeof locks?.request === "function" ? locks : void 0;
852
+ }
569
853
  function createLeader(name, options = {}) {
854
+ const strategy = options.strategy ?? "auto";
855
+ if (strategy !== "heartbeat") {
856
+ const locks = availableLocks(options);
857
+ if (locks) return createWebLocksLeader(name, options, locks);
858
+ if (strategy === "web-locks") {
859
+ throw new Error(
860
+ 'strategy: "web-locks" was requested but navigator.locks is unavailable. Web Locks needs a secure context (https, or localhost) \u2014 use "auto" to fall back to the heartbeat election.'
861
+ );
862
+ }
863
+ }
864
+ return createHeartbeatLeader(name, options);
865
+ }
866
+ function createHeartbeatLeader(name, options) {
570
867
  const heartbeatMs = options.heartbeatMs ?? 1e3;
571
868
  const leaseMs = options.leaseMs ?? 3e3;
572
869
  const busOptions = {
@@ -578,16 +875,21 @@ function createLeader(name, options = {}) {
578
875
  let eligible = options.eligible ?? true;
579
876
  let leaderId = null;
580
877
  let term = [0, clientId];
581
- let snapshot = NO_LEADER;
878
+ let snapshot = NO_LEADER2;
582
879
  let beat;
583
880
  let lease;
584
881
  let closed = false;
585
882
  const listeners = /* @__PURE__ */ new Set();
883
+ const waiters = /* @__PURE__ */ new Set();
586
884
  function setLeader(id) {
587
885
  if (id === leaderId) return;
588
886
  leaderId = id;
589
887
  snapshot = Object.freeze({ leaderId: id, isLeader: id === clientId });
590
888
  for (const fn of listeners) fn();
889
+ if (id === clientId) {
890
+ for (const waiter of waiters) waiter.resolve();
891
+ waiters.clear();
892
+ }
591
893
  }
592
894
  function armLease(delay) {
593
895
  clearTimeout(lease);
@@ -625,6 +927,7 @@ function createLeader(name, options = {}) {
625
927
  armLease(0);
626
928
  return;
627
929
  }
930
+ if (!isVersion(wire.term)) return;
628
931
  if (newer(wire.term, term)) {
629
932
  term = wire.term;
630
933
  stepDown(wire.clientId);
@@ -660,11 +963,19 @@ function createLeader(name, options = {}) {
660
963
  armLease(heartbeatMs);
661
964
  return {
662
965
  clientId,
966
+ strategy: "heartbeat",
663
967
  getSnapshot: () => snapshot,
664
968
  subscribe(fn) {
665
969
  listeners.add(fn);
666
970
  return () => listeners.delete(fn);
667
971
  },
972
+ waitForLeadership() {
973
+ if (leaderId === clientId) return Promise.resolve();
974
+ if (closed) return Promise.reject(new Error("leader is closed"));
975
+ return new Promise((resolve, reject) => {
976
+ waiters.add({ resolve, reject });
977
+ });
978
+ },
668
979
  resign,
669
980
  setEligible(next) {
670
981
  if (next === eligible) return;
@@ -679,6 +990,8 @@ function createLeader(name, options = {}) {
679
990
  if (closed) return;
680
991
  closed = true;
681
992
  resign();
993
+ for (const waiter of waiters) waiter.reject(new Error("leader is closed"));
994
+ waiters.clear();
682
995
  clearInterval(beat);
683
996
  clearTimeout(lease);
684
997
  if (hasWindow) {
@@ -988,6 +1301,7 @@ export {
988
1301
  DEFAULT_NAME,
989
1302
  HandshakeTimeoutError,
990
1303
  NoopTransport,
1304
+ StorageTransport,
991
1305
  WindowClosedError,
992
1306
  connectToOpener,
993
1307
  createChannel,
@@ -997,7 +1311,9 @@ export {
997
1311
  defaultTransport,
998
1312
  enableDebug,
999
1313
  getBusNames,
1314
+ getTransportKind,
1000
1315
  isBroadcastChannelAvailable,
1316
+ isStorageEventAvailable,
1001
1317
  localStorageAdapter,
1002
1318
  newer,
1003
1319
  observeBus,
package/dist/testing.cjs CHANGED
@@ -29,6 +29,7 @@ module.exports = __toCommonJS(testing_exports);
29
29
  var MemoryTransport = class {
30
30
  constructor(hub) {
31
31
  this.hub = hub;
32
+ this.kind = "memory";
32
33
  this.listeners = /* @__PURE__ */ new Set();
33
34
  this.closed = false;
34
35
  }
@@ -1,8 +1,9 @@
1
- import { T as Transport } from './transport.types-tQ1cu6Xm.cjs';
1
+ import { T as Transport, a as TransportKind } from './transport.types-CV1WZOhy.cjs';
2
2
 
3
3
  /** One simulated client on a MemoryHub. Create via hub.connect(). */
4
4
  declare class MemoryTransport implements Transport {
5
5
  private hub;
6
+ readonly kind: TransportKind;
6
7
  private listeners;
7
8
  private closed;
8
9
  constructor(hub: MemoryHub);
package/dist/testing.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { T as Transport } from './transport.types-tQ1cu6Xm.js';
1
+ import { T as Transport, a as TransportKind } from './transport.types-CV1WZOhy.js';
2
2
 
3
3
  /** One simulated client on a MemoryHub. Create via hub.connect(). */
4
4
  declare class MemoryTransport implements Transport {
5
5
  private hub;
6
+ readonly kind: TransportKind;
6
7
  private listeners;
7
8
  private closed;
8
9
  constructor(hub: MemoryHub);
package/dist/testing.js CHANGED
@@ -2,6 +2,7 @@
2
2
  var MemoryTransport = class {
3
3
  constructor(hub) {
4
4
  this.hub = hub;
5
+ this.kind = "memory";
5
6
  this.listeners = /* @__PURE__ */ new Set();
6
7
  this.closed = false;
7
8
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Which mechanism a transport actually uses. Worth surfacing, because they are
3
+ * not equivalent: `storage` is a fallback with lower fidelity than
4
+ * `broadcast-channel`, and `none` means nothing is being shared at all.
5
+ */
6
+ type TransportKind = 'broadcast-channel' | 'storage' | 'memory' | 'none' | (string & {});
7
+ /**
8
+ * Minimal message bus. Implementations: BroadcastChannelTransport (same-origin),
9
+ * StorageTransport (fallback), NoopTransport (SSR / local-only), MemoryTransport
10
+ * (tests). A transport never echoes a client's own posts back to it.
11
+ */
12
+ interface Transport {
13
+ /** What this transport is. Optional so a custom transport need not declare one. */
14
+ readonly kind?: TransportKind;
15
+ post(data: unknown): void;
16
+ subscribe(listener: (data: unknown) => void): () => void;
17
+ close(): void;
18
+ }
19
+
20
+ export type { Transport as T, TransportKind as a };