@use-everywhere/core 0.4.1 → 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.cjs CHANGED
@@ -55,7 +55,13 @@ function emitBusEvent(name, direction, wire) {
55
55
  const set = observers.get(name);
56
56
  if (!set) return;
57
57
  const event = { name, direction, wire };
58
- for (const fn of set) fn(event);
58
+ for (const fn of set) {
59
+ try {
60
+ fn(event);
61
+ } catch (error) {
62
+ console.error(`[use-everywhere] a bus observer for "${name}" threw`, error);
63
+ }
64
+ }
59
65
  }
60
66
  function observeBus(name, fn) {
61
67
  let set = observers.get(name);
@@ -80,12 +86,41 @@ function enableDebug(options = {}) {
80
86
  });
81
87
  }
82
88
 
89
+ // src/dev.ts
90
+ var inDev = false;
91
+ try {
92
+ inDev = process.env.NODE_ENV !== "production";
93
+ } catch {
94
+ }
95
+ var warned = /* @__PURE__ */ new Set();
96
+ function devWarn(message) {
97
+ if (!inDev || warned.has(message)) return;
98
+ warned.add(message);
99
+ console.warn(message);
100
+ }
101
+
83
102
  // src/ids.ts
103
+ function randomHex(bytes) {
104
+ const crypto = globalThis.crypto;
105
+ if (crypto?.getRandomValues) {
106
+ const buf = crypto.getRandomValues(new Uint8Array(bytes));
107
+ let out2 = "";
108
+ for (const b of buf) out2 += b.toString(16).padStart(2, "0");
109
+ return out2;
110
+ }
111
+ devWarn(
112
+ "[use-everywhere] crypto.getRandomValues is unavailable; falling back to Math.random for ids. Cross-origin window nonces are not cryptographically strong in this environment."
113
+ );
114
+ let out = "";
115
+ while (out.length < bytes * 2)
116
+ out += Math.floor(Math.random() * 256).toString(16).padStart(2, "0");
117
+ return out;
118
+ }
84
119
  function newClientId() {
85
- return Math.random().toString(36).slice(2, 8);
120
+ return randomHex(8);
86
121
  }
87
122
  function newMsgId() {
88
- return Math.random().toString(36).slice(2, 10);
123
+ return randomHex(16);
89
124
  }
90
125
 
91
126
  // src/transport/broadcast-channel-transport.ts
@@ -165,12 +200,20 @@ function createBus(name, options, onShutdown) {
165
200
  options.heartbeatMs ?? 2e3
166
201
  );
167
202
  const sayBye = () => post({ v: 1, scope: "presence", type: "bye", clientId, kind });
203
+ const onPageShow = (event) => {
204
+ if (!event.persisted) return;
205
+ post({ v: 1, scope: "presence", type: "hello", clientId, kind });
206
+ };
168
207
  const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
169
- if (hasWindow) addEventListener("pagehide", sayBye);
208
+ if (hasWindow) {
209
+ addEventListener("pagehide", sayBye);
210
+ addEventListener("pageshow", onPageShow);
211
+ }
170
212
  return {
171
213
  name,
172
214
  clientId,
173
215
  kind,
216
+ heartbeatMs: options.heartbeatMs ?? 2e3,
174
217
  post,
175
218
  subscribe(fn) {
176
219
  listeners.add(fn);
@@ -180,12 +223,16 @@ function createBus(name, options, onShutdown) {
180
223
  refs++;
181
224
  },
182
225
  release() {
226
+ if (closed) return;
183
227
  refs--;
184
228
  if (refs > 0) return;
185
229
  sayBye();
186
230
  closed = true;
187
231
  clearInterval(heartbeat);
188
- if (hasWindow) removeEventListener("pagehide", sayBye);
232
+ if (hasWindow) {
233
+ removeEventListener("pagehide", sayBye);
234
+ removeEventListener("pageshow", onPageShow);
235
+ }
189
236
  unsubscribe();
190
237
  transport.close();
191
238
  listeners.clear();
@@ -207,6 +254,15 @@ function getBus(name, options = {}) {
207
254
  if (!bus) {
208
255
  bus = createBus(name, options, () => registry.delete(name));
209
256
  registry.set(name, bus);
257
+ } else {
258
+ if (options.heartbeatMs !== void 0 && options.heartbeatMs !== bus.heartbeatMs) {
259
+ devWarn(
260
+ `[use-everywhere] bus "${name}": heartbeatMs ignored \u2014 the first creator fixes bus options`
261
+ );
262
+ }
263
+ if (options.kind !== void 0 && options.kind !== bus.kind) {
264
+ devWarn(`[use-everywhere] bus "${name}": kind ignored \u2014 the first creator fixes bus options`);
265
+ }
210
266
  }
211
267
  bus.acquire();
212
268
  return bus;
@@ -223,6 +279,7 @@ function createChannel(name, options = {}) {
223
279
  const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
224
280
  for (const fn of set) fn(wire.payload, meta);
225
281
  });
282
+ let closed = false;
226
283
  return {
227
284
  name,
228
285
  clientId: bus.clientId,
@@ -247,6 +304,8 @@ function createChannel(name, options = {}) {
247
304
  return () => set.delete(handler);
248
305
  },
249
306
  close() {
307
+ if (closed) return;
308
+ closed = true;
250
309
  unsubscribe();
251
310
  handlers.clear();
252
311
  bus.release();
@@ -260,25 +319,48 @@ function newer(a, b) {
260
319
  }
261
320
 
262
321
  // src/dev-freeze.ts
263
- var inDev = false;
322
+ var inDev2 = false;
264
323
  try {
265
- inDev = process.env.NODE_ENV !== "production";
324
+ inDev2 = process.env.NODE_ENV !== "production";
266
325
  } catch {
267
326
  }
268
327
  function deepFreeze(value) {
269
328
  if (value === null || typeof value !== "object" || Object.isFrozen(value)) return;
329
+ if (ArrayBuffer.isView(value)) return;
270
330
  Object.freeze(value);
331
+ if (value instanceof Map) {
332
+ for (const [k, v] of value) {
333
+ deepFreeze(k);
334
+ deepFreeze(v);
335
+ }
336
+ return;
337
+ }
338
+ if (value instanceof Set) {
339
+ for (const entry of value) deepFreeze(entry);
340
+ return;
341
+ }
271
342
  for (const key of Object.keys(value)) {
272
343
  deepFreeze(value[key]);
273
344
  }
274
345
  }
275
346
  function freezeShared(value) {
276
- if (inDev) deepFreeze(value);
347
+ if (inDev2) deepFreeze(value);
277
348
  return value;
278
349
  }
279
350
 
280
351
  // src/shared-store.ts
352
+ var liveStores = /* @__PURE__ */ new Map();
281
353
  function createSharedStore(name, initial, options = {}) {
354
+ const onSharedBus = !options.transport;
355
+ if (onSharedBus) {
356
+ const live = liveStores.get(name) ?? 0;
357
+ if (live > 0) {
358
+ devWarn(
359
+ `[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.`
360
+ );
361
+ }
362
+ liveStores.set(name, live + 1);
363
+ }
282
364
  const bus = getBus(name, options);
283
365
  const clientId = bus.clientId;
284
366
  const accept = options.accept;
@@ -332,18 +414,25 @@ function createSharedStore(name, initial, options = {}) {
332
414
  });
333
415
  function setKey(key, value) {
334
416
  const version = [(versions[key]?.[0] ?? 0) + 1, clientId];
417
+ try {
418
+ bus.post({
419
+ v: 1,
420
+ scope: "state",
421
+ type: "patch",
422
+ key,
423
+ value,
424
+ version,
425
+ clientId,
426
+ kind: bus.kind
427
+ });
428
+ } catch (error) {
429
+ throw new TypeError(
430
+ `use-everywhere: the value for key "${key}" cannot cross the wire (structured clone failed); the write was not applied. ${String(error)}`,
431
+ { cause: error }
432
+ );
433
+ }
335
434
  versions[key] = version;
336
435
  state[key] = freezeShared(value);
337
- bus.post({
338
- v: 1,
339
- scope: "state",
340
- type: "patch",
341
- key,
342
- value,
343
- version,
344
- clientId,
345
- kind: bus.kind
346
- });
347
436
  notify(key, value, { clientId, kind: bus.kind, self: true });
348
437
  }
349
438
  const proxy = new Proxy(state, {
@@ -406,7 +495,15 @@ function createSharedStore(name, initial, options = {}) {
406
495
  addEventListener("pagehide", flushPersist);
407
496
  }
408
497
  }
409
- bus.post({ v: 1, scope: "state", type: "hello", clientId, kind: bus.kind });
498
+ const sayHello = () => bus.post({ v: 1, scope: "state", type: "hello", clientId, kind: bus.kind });
499
+ const onPageShow = (event) => {
500
+ if (!event.persisted) return;
501
+ sayHello();
502
+ };
503
+ const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
504
+ if (hasWindow) addEventListener("pageshow", onPageShow);
505
+ sayHello();
506
+ let storeClosed = false;
410
507
  return {
411
508
  clientId,
412
509
  state: proxy,
@@ -427,16 +524,27 @@ function createSharedStore(name, initial, options = {}) {
427
524
  keyListeners.set(key, set);
428
525
  }
429
526
  set.add(fn);
430
- return () => set.delete(fn);
527
+ return () => {
528
+ set.delete(fn);
529
+ if (set.size === 0) keyListeners.delete(key);
530
+ };
431
531
  },
432
532
  registerKey(key, initialValue) {
433
533
  if (key in versions) return;
434
534
  versions[key] = [0, clientId];
435
- state[key] = initialValue;
535
+ state[key] = freezeShared(initialValue);
436
536
  snapshot = Object.freeze({ ...state });
437
537
  versionsSnapshot = Object.freeze({ ...versions });
438
538
  },
439
539
  close() {
540
+ if (storeClosed) return;
541
+ storeClosed = true;
542
+ if (onSharedBus) {
543
+ const live = (liveStores.get(name) ?? 1) - 1;
544
+ if (live > 0) liveStores.set(name, live);
545
+ else liveStores.delete(name);
546
+ }
547
+ if (hasWindow) removeEventListener("pageshow", onPageShow);
440
548
  if (flushPersist) {
441
549
  flushPersist();
442
550
  if (typeof document !== "undefined" && typeof removeEventListener === "function") {
@@ -485,6 +593,7 @@ function createPresence(name, options = {}) {
485
593
  },
486
594
  Math.max(500, Math.floor(pruneAfterMs / 2))
487
595
  );
596
+ let closed = false;
488
597
  return {
489
598
  clientId: bus.clientId,
490
599
  getPeers: () => snapshot,
@@ -493,6 +602,8 @@ function createPresence(name, options = {}) {
493
602
  return () => listeners.delete(fn);
494
603
  },
495
604
  close() {
605
+ if (closed) return;
606
+ closed = true;
496
607
  clearInterval(prune);
497
608
  unsubscribe();
498
609
  listeners.clear();
@@ -581,10 +692,19 @@ function createLeader(name, options = {}) {
581
692
  bus.post({ v: 1, scope: "leader", type: "resign", term: resigning, clientId, kind: bus.kind });
582
693
  armLease(leaseMs);
583
694
  }
695
+ const sayHello = () => bus.post({ v: 1, scope: "leader", type: "hello", clientId, kind: bus.kind });
584
696
  const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
585
697
  const onPageHide = () => resign();
586
- if (hasWindow) addEventListener("pagehide", onPageHide);
587
- bus.post({ v: 1, scope: "leader", type: "hello", clientId, kind: bus.kind });
698
+ const onPageShow = (event) => {
699
+ if (!event.persisted) return;
700
+ sayHello();
701
+ armLease(heartbeatMs);
702
+ };
703
+ if (hasWindow) {
704
+ addEventListener("pagehide", onPageHide);
705
+ addEventListener("pageshow", onPageShow);
706
+ }
707
+ sayHello();
588
708
  armLease(heartbeatMs);
589
709
  return {
590
710
  clientId,
@@ -604,11 +724,15 @@ function createLeader(name, options = {}) {
604
724
  if (leaderId === null) armLease(0);
605
725
  },
606
726
  close() {
727
+ if (closed) return;
607
728
  closed = true;
608
729
  resign();
609
730
  clearInterval(beat);
610
731
  clearTimeout(lease);
611
- if (hasWindow) removeEventListener("pagehide", onPageHide);
732
+ if (hasWindow) {
733
+ removeEventListener("pagehide", onPageHide);
734
+ removeEventListener("pageshow", onPageShow);
735
+ }
612
736
  unsubscribe();
613
737
  listeners.clear();
614
738
  bus.release();
@@ -622,44 +746,54 @@ function isPersisted(value) {
622
746
  const candidate = value;
623
747
  return candidate.v === 1 && typeof candidate.state === "object" && typeof candidate.versions === "object";
624
748
  }
625
- function webStorageAdapter(storage, key) {
626
- const resolve = () => {
749
+ function webStorageAdapter(storage, key, options = {}) {
750
+ const report = (error, operation) => {
627
751
  try {
628
- return typeof storage === "function" ? storage() : storage;
752
+ options.onError?.(error, operation);
629
753
  } catch {
754
+ }
755
+ };
756
+ const resolve = (operation) => {
757
+ try {
758
+ return typeof storage === "function" ? storage() : storage;
759
+ } catch (error) {
760
+ report(error, operation);
630
761
  return void 0;
631
762
  }
632
763
  };
633
764
  return {
634
765
  read() {
635
766
  try {
636
- const raw = resolve()?.getItem(key);
767
+ const raw = resolve("read")?.getItem(key);
637
768
  if (!raw) return void 0;
638
769
  const parsed = JSON.parse(raw);
639
770
  return isPersisted(parsed) ? parsed : void 0;
640
- } catch {
771
+ } catch (error) {
772
+ report(error, "read");
641
773
  return void 0;
642
774
  }
643
775
  },
644
776
  write(snapshot) {
645
777
  try {
646
- resolve()?.setItem(key, JSON.stringify(snapshot));
647
- } catch {
778
+ resolve("write")?.setItem(key, JSON.stringify(snapshot));
779
+ } catch (error) {
780
+ report(error, "write");
648
781
  }
649
782
  },
650
783
  remove() {
651
784
  try {
652
- resolve()?.removeItem(key);
653
- } catch {
785
+ resolve("remove")?.removeItem(key);
786
+ } catch (error) {
787
+ report(error, "remove");
654
788
  }
655
789
  }
656
790
  };
657
791
  }
658
- function localStorageAdapter(key) {
659
- return webStorageAdapter(() => globalThis.localStorage, key);
792
+ function localStorageAdapter(key, options) {
793
+ return webStorageAdapter(() => globalThis.localStorage, key, options);
660
794
  }
661
- function sessionStorageAdapter(key) {
662
- return webStorageAdapter(() => globalThis.sessionStorage, key);
795
+ function sessionStorageAdapter(key, options) {
796
+ return webStorageAdapter(() => globalThis.sessionStorage, key, options);
663
797
  }
664
798
 
665
799
  // src/errors/handshake-timeout-error.ts
@@ -786,6 +920,7 @@ function openWindow(url, options) {
786
920
  }, 400);
787
921
  const readyTimer = setTimeout(() => {
788
922
  if (isReady || closedSettled) return;
923
+ localWindow.removeEventListener("message", onMessage);
789
924
  const err = new HandshakeTimeoutError();
790
925
  rejectReady(err);
791
926
  if (!resultSettled) {
@@ -839,6 +974,7 @@ function connectToOpener(options) {
839
974
  markHandled(ready);
840
975
  const onMessage = (event) => {
841
976
  if (!allowAnyOrigin && event.origin !== peerOrigin) return;
977
+ if (event.source !== opener) return;
842
978
  if (!isWindowWire(event.data, cid)) return;
843
979
  const wire = event.data;
844
980
  if (wire.t === "ready-ack") {
@@ -934,9 +1070,11 @@ var MemoryHub = class {
934
1070
  }
935
1071
  /** @internal */
936
1072
  broadcast(from, data) {
1073
+ structuredClone(data);
937
1074
  for (const transport of this.transports) {
938
1075
  if (transport !== from) {
939
- queueMicrotask(() => transport.deliver(data));
1076
+ const copy = structuredClone(data);
1077
+ queueMicrotask(() => transport.deliver(copy));
940
1078
  }
941
1079
  }
942
1080
  }
package/dist/index.d.cts CHANGED
@@ -230,6 +230,15 @@ interface Leader {
230
230
  declare function createLeader(name: string, options?: LeaderOptions): Leader;
231
231
 
232
232
  type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
233
+ interface WebStorageAdapterOptions {
234
+ /**
235
+ * Called when a storage operation fails: blocked storage, corrupt JSON, a
236
+ * full quota. Persistence stays best-effort either way — this is the
237
+ * observability seam (telemetry, a "your changes may not be saved" notice),
238
+ * not a recovery path. Errors thrown by the callback itself are swallowed.
239
+ */
240
+ onError?: (error: unknown, operation: 'read' | 'write' | 'remove') => void;
241
+ }
233
242
  /**
234
243
  * Persist to any Storage-shaped thing.
235
244
  *
@@ -240,13 +249,14 @@ type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
240
249
  * Behind a thunk, every access happens inside one.
241
250
  *
242
251
  * Blocked storage, corrupt JSON, a foreign schema, or a full quota all degrade
243
- * to a silent no-op. Persistence is best-effort; it must never break the store.
252
+ * to a silent no-op persistence is best-effort; it must never break the
253
+ * store. Pass `onError` to observe the failures anyway.
244
254
  */
245
- declare function webStorageAdapter(storage: StorageLike | (() => StorageLike | undefined), key: string): PersistAdapter;
255
+ declare function webStorageAdapter(storage: StorageLike | (() => StorageLike | undefined), key: string, options?: WebStorageAdapterOptions): PersistAdapter;
246
256
  /** Survives closing every tab. */
247
- declare function localStorageAdapter(key: string): PersistAdapter;
257
+ declare function localStorageAdapter(key: string, options?: WebStorageAdapterOptions): PersistAdapter;
248
258
  /** Survives reloads, but dies with the tab. */
249
- declare function sessionStorageAdapter(key: string): PersistAdapter;
259
+ declare function sessionStorageAdapter(key: string, options?: WebStorageAdapterOptions): PersistAdapter;
250
260
 
251
261
  interface MessageEventLike {
252
262
  data: unknown;
@@ -442,4 +452,4 @@ declare class MemoryHub {
442
452
  disconnect(transport: MemoryTransport): void;
443
453
  }
444
454
 
445
- export { BroadcastChannelTransport, type BusEvent, type BusObserver, type BusWire, CID_PARAM, type Channel, type CommonOptions, type ConnectToOpenerOptions, DEFAULT_NAME, type DebugOptions, HandshakeTimeoutError, type Leader, type LeaderOptions, type LeaderSnapshot, MemoryHub, MemoryTransport, type MessageEventLike, type MessageMap, type MessageMeta, NoopTransport, type OpenWindowOptions, type OpenedWindow, type OpenerConnection, type Peer, type PeerKind, type PersistAdapter, type PersistOptions, type Persisted, type Presence, type PresenceOptions, type SharedStore, type SharedStoreOptions, type StorageLike, type Transport, type Version, WindowClosedError, type WindowEventTarget, type WindowLike, connectToOpener, createChannel, createLeader, createPresence, createSharedStore, defaultTransport, enableDebug, getBusNames, isBroadcastChannelAvailable, localStorageAdapter, newer, observeBus, openWindow, sessionStorageAdapter, webStorageAdapter };
455
+ export { BroadcastChannelTransport, type BusEvent, type BusObserver, type BusWire, CID_PARAM, type Channel, type CommonOptions, type ConnectToOpenerOptions, DEFAULT_NAME, type DebugOptions, HandshakeTimeoutError, type Leader, type LeaderOptions, type LeaderSnapshot, MemoryHub, MemoryTransport, type MessageEventLike, type MessageMap, type MessageMeta, NoopTransport, type OpenWindowOptions, type OpenedWindow, type OpenerConnection, type Peer, type PeerKind, type PersistAdapter, type PersistOptions, type Persisted, type Presence, type PresenceOptions, type SharedStore, type SharedStoreOptions, type StorageLike, type Transport, type Version, type WebStorageAdapterOptions, WindowClosedError, type WindowEventTarget, type WindowLike, connectToOpener, createChannel, createLeader, createPresence, createSharedStore, defaultTransport, enableDebug, getBusNames, isBroadcastChannelAvailable, localStorageAdapter, newer, observeBus, openWindow, sessionStorageAdapter, webStorageAdapter };
package/dist/index.d.ts CHANGED
@@ -230,6 +230,15 @@ interface Leader {
230
230
  declare function createLeader(name: string, options?: LeaderOptions): Leader;
231
231
 
232
232
  type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
233
+ interface WebStorageAdapterOptions {
234
+ /**
235
+ * Called when a storage operation fails: blocked storage, corrupt JSON, a
236
+ * full quota. Persistence stays best-effort either way — this is the
237
+ * observability seam (telemetry, a "your changes may not be saved" notice),
238
+ * not a recovery path. Errors thrown by the callback itself are swallowed.
239
+ */
240
+ onError?: (error: unknown, operation: 'read' | 'write' | 'remove') => void;
241
+ }
233
242
  /**
234
243
  * Persist to any Storage-shaped thing.
235
244
  *
@@ -240,13 +249,14 @@ type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
240
249
  * Behind a thunk, every access happens inside one.
241
250
  *
242
251
  * Blocked storage, corrupt JSON, a foreign schema, or a full quota all degrade
243
- * to a silent no-op. Persistence is best-effort; it must never break the store.
252
+ * to a silent no-op persistence is best-effort; it must never break the
253
+ * store. Pass `onError` to observe the failures anyway.
244
254
  */
245
- declare function webStorageAdapter(storage: StorageLike | (() => StorageLike | undefined), key: string): PersistAdapter;
255
+ declare function webStorageAdapter(storage: StorageLike | (() => StorageLike | undefined), key: string, options?: WebStorageAdapterOptions): PersistAdapter;
246
256
  /** Survives closing every tab. */
247
- declare function localStorageAdapter(key: string): PersistAdapter;
257
+ declare function localStorageAdapter(key: string, options?: WebStorageAdapterOptions): PersistAdapter;
248
258
  /** Survives reloads, but dies with the tab. */
249
- declare function sessionStorageAdapter(key: string): PersistAdapter;
259
+ declare function sessionStorageAdapter(key: string, options?: WebStorageAdapterOptions): PersistAdapter;
250
260
 
251
261
  interface MessageEventLike {
252
262
  data: unknown;
@@ -442,4 +452,4 @@ declare class MemoryHub {
442
452
  disconnect(transport: MemoryTransport): void;
443
453
  }
444
454
 
445
- export { BroadcastChannelTransport, type BusEvent, type BusObserver, type BusWire, CID_PARAM, type Channel, type CommonOptions, type ConnectToOpenerOptions, DEFAULT_NAME, type DebugOptions, HandshakeTimeoutError, type Leader, type LeaderOptions, type LeaderSnapshot, MemoryHub, MemoryTransport, type MessageEventLike, type MessageMap, type MessageMeta, NoopTransport, type OpenWindowOptions, type OpenedWindow, type OpenerConnection, type Peer, type PeerKind, type PersistAdapter, type PersistOptions, type Persisted, type Presence, type PresenceOptions, type SharedStore, type SharedStoreOptions, type StorageLike, type Transport, type Version, WindowClosedError, type WindowEventTarget, type WindowLike, connectToOpener, createChannel, createLeader, createPresence, createSharedStore, defaultTransport, enableDebug, getBusNames, isBroadcastChannelAvailable, localStorageAdapter, newer, observeBus, openWindow, sessionStorageAdapter, webStorageAdapter };
455
+ export { BroadcastChannelTransport, type BusEvent, type BusObserver, type BusWire, CID_PARAM, type Channel, type CommonOptions, type ConnectToOpenerOptions, DEFAULT_NAME, type DebugOptions, HandshakeTimeoutError, type Leader, type LeaderOptions, type LeaderSnapshot, MemoryHub, MemoryTransport, type MessageEventLike, type MessageMap, type MessageMeta, NoopTransport, type OpenWindowOptions, type OpenedWindow, type OpenerConnection, type Peer, type PeerKind, type PersistAdapter, type PersistOptions, type Persisted, type Presence, type PresenceOptions, type SharedStore, type SharedStoreOptions, type StorageLike, type Transport, type Version, type WebStorageAdapterOptions, WindowClosedError, type WindowEventTarget, type WindowLike, connectToOpener, createChannel, createLeader, createPresence, createSharedStore, defaultTransport, enableDebug, getBusNames, isBroadcastChannelAvailable, localStorageAdapter, newer, observeBus, openWindow, sessionStorageAdapter, webStorageAdapter };
package/dist/index.js CHANGED
@@ -7,7 +7,13 @@ function emitBusEvent(name, direction, wire) {
7
7
  const set = observers.get(name);
8
8
  if (!set) return;
9
9
  const event = { name, direction, wire };
10
- for (const fn of set) fn(event);
10
+ for (const fn of set) {
11
+ try {
12
+ fn(event);
13
+ } catch (error) {
14
+ console.error(`[use-everywhere] a bus observer for "${name}" threw`, error);
15
+ }
16
+ }
11
17
  }
12
18
  function observeBus(name, fn) {
13
19
  let set = observers.get(name);
@@ -32,12 +38,41 @@ function enableDebug(options = {}) {
32
38
  });
33
39
  }
34
40
 
41
+ // src/dev.ts
42
+ var inDev = false;
43
+ try {
44
+ inDev = process.env.NODE_ENV !== "production";
45
+ } catch {
46
+ }
47
+ var warned = /* @__PURE__ */ new Set();
48
+ function devWarn(message) {
49
+ if (!inDev || warned.has(message)) return;
50
+ warned.add(message);
51
+ console.warn(message);
52
+ }
53
+
35
54
  // src/ids.ts
55
+ function randomHex(bytes) {
56
+ const crypto = globalThis.crypto;
57
+ if (crypto?.getRandomValues) {
58
+ const buf = crypto.getRandomValues(new Uint8Array(bytes));
59
+ let out2 = "";
60
+ for (const b of buf) out2 += b.toString(16).padStart(2, "0");
61
+ return out2;
62
+ }
63
+ devWarn(
64
+ "[use-everywhere] crypto.getRandomValues is unavailable; falling back to Math.random for ids. Cross-origin window nonces are not cryptographically strong in this environment."
65
+ );
66
+ let out = "";
67
+ while (out.length < bytes * 2)
68
+ out += Math.floor(Math.random() * 256).toString(16).padStart(2, "0");
69
+ return out;
70
+ }
36
71
  function newClientId() {
37
- return Math.random().toString(36).slice(2, 8);
72
+ return randomHex(8);
38
73
  }
39
74
  function newMsgId() {
40
- return Math.random().toString(36).slice(2, 10);
75
+ return randomHex(16);
41
76
  }
42
77
 
43
78
  // src/transport/broadcast-channel-transport.ts
@@ -117,12 +152,20 @@ function createBus(name, options, onShutdown) {
117
152
  options.heartbeatMs ?? 2e3
118
153
  );
119
154
  const sayBye = () => post({ v: 1, scope: "presence", type: "bye", clientId, kind });
155
+ const onPageShow = (event) => {
156
+ if (!event.persisted) return;
157
+ post({ v: 1, scope: "presence", type: "hello", clientId, kind });
158
+ };
120
159
  const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
121
- if (hasWindow) addEventListener("pagehide", sayBye);
160
+ if (hasWindow) {
161
+ addEventListener("pagehide", sayBye);
162
+ addEventListener("pageshow", onPageShow);
163
+ }
122
164
  return {
123
165
  name,
124
166
  clientId,
125
167
  kind,
168
+ heartbeatMs: options.heartbeatMs ?? 2e3,
126
169
  post,
127
170
  subscribe(fn) {
128
171
  listeners.add(fn);
@@ -132,12 +175,16 @@ function createBus(name, options, onShutdown) {
132
175
  refs++;
133
176
  },
134
177
  release() {
178
+ if (closed) return;
135
179
  refs--;
136
180
  if (refs > 0) return;
137
181
  sayBye();
138
182
  closed = true;
139
183
  clearInterval(heartbeat);
140
- if (hasWindow) removeEventListener("pagehide", sayBye);
184
+ if (hasWindow) {
185
+ removeEventListener("pagehide", sayBye);
186
+ removeEventListener("pageshow", onPageShow);
187
+ }
141
188
  unsubscribe();
142
189
  transport.close();
143
190
  listeners.clear();
@@ -159,6 +206,15 @@ function getBus(name, options = {}) {
159
206
  if (!bus) {
160
207
  bus = createBus(name, options, () => registry.delete(name));
161
208
  registry.set(name, bus);
209
+ } else {
210
+ if (options.heartbeatMs !== void 0 && options.heartbeatMs !== bus.heartbeatMs) {
211
+ devWarn(
212
+ `[use-everywhere] bus "${name}": heartbeatMs ignored \u2014 the first creator fixes bus options`
213
+ );
214
+ }
215
+ if (options.kind !== void 0 && options.kind !== bus.kind) {
216
+ devWarn(`[use-everywhere] bus "${name}": kind ignored \u2014 the first creator fixes bus options`);
217
+ }
162
218
  }
163
219
  bus.acquire();
164
220
  return bus;
@@ -175,6 +231,7 @@ function createChannel(name, options = {}) {
175
231
  const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
176
232
  for (const fn of set) fn(wire.payload, meta);
177
233
  });
234
+ let closed = false;
178
235
  return {
179
236
  name,
180
237
  clientId: bus.clientId,
@@ -199,6 +256,8 @@ function createChannel(name, options = {}) {
199
256
  return () => set.delete(handler);
200
257
  },
201
258
  close() {
259
+ if (closed) return;
260
+ closed = true;
202
261
  unsubscribe();
203
262
  handlers.clear();
204
263
  bus.release();
@@ -212,25 +271,48 @@ function newer(a, b) {
212
271
  }
213
272
 
214
273
  // src/dev-freeze.ts
215
- var inDev = false;
274
+ var inDev2 = false;
216
275
  try {
217
- inDev = process.env.NODE_ENV !== "production";
276
+ inDev2 = process.env.NODE_ENV !== "production";
218
277
  } catch {
219
278
  }
220
279
  function deepFreeze(value) {
221
280
  if (value === null || typeof value !== "object" || Object.isFrozen(value)) return;
281
+ if (ArrayBuffer.isView(value)) return;
222
282
  Object.freeze(value);
283
+ if (value instanceof Map) {
284
+ for (const [k, v] of value) {
285
+ deepFreeze(k);
286
+ deepFreeze(v);
287
+ }
288
+ return;
289
+ }
290
+ if (value instanceof Set) {
291
+ for (const entry of value) deepFreeze(entry);
292
+ return;
293
+ }
223
294
  for (const key of Object.keys(value)) {
224
295
  deepFreeze(value[key]);
225
296
  }
226
297
  }
227
298
  function freezeShared(value) {
228
- if (inDev) deepFreeze(value);
299
+ if (inDev2) deepFreeze(value);
229
300
  return value;
230
301
  }
231
302
 
232
303
  // src/shared-store.ts
304
+ var liveStores = /* @__PURE__ */ new Map();
233
305
  function createSharedStore(name, initial, options = {}) {
306
+ const onSharedBus = !options.transport;
307
+ if (onSharedBus) {
308
+ const live = liveStores.get(name) ?? 0;
309
+ if (live > 0) {
310
+ 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.`
312
+ );
313
+ }
314
+ liveStores.set(name, live + 1);
315
+ }
234
316
  const bus = getBus(name, options);
235
317
  const clientId = bus.clientId;
236
318
  const accept = options.accept;
@@ -284,18 +366,25 @@ function createSharedStore(name, initial, options = {}) {
284
366
  });
285
367
  function setKey(key, value) {
286
368
  const version = [(versions[key]?.[0] ?? 0) + 1, clientId];
369
+ try {
370
+ bus.post({
371
+ v: 1,
372
+ scope: "state",
373
+ type: "patch",
374
+ key,
375
+ value,
376
+ version,
377
+ clientId,
378
+ kind: bus.kind
379
+ });
380
+ } catch (error) {
381
+ throw new TypeError(
382
+ `use-everywhere: the value for key "${key}" cannot cross the wire (structured clone failed); the write was not applied. ${String(error)}`,
383
+ { cause: error }
384
+ );
385
+ }
287
386
  versions[key] = version;
288
387
  state[key] = freezeShared(value);
289
- bus.post({
290
- v: 1,
291
- scope: "state",
292
- type: "patch",
293
- key,
294
- value,
295
- version,
296
- clientId,
297
- kind: bus.kind
298
- });
299
388
  notify(key, value, { clientId, kind: bus.kind, self: true });
300
389
  }
301
390
  const proxy = new Proxy(state, {
@@ -358,7 +447,15 @@ function createSharedStore(name, initial, options = {}) {
358
447
  addEventListener("pagehide", flushPersist);
359
448
  }
360
449
  }
361
- bus.post({ v: 1, scope: "state", type: "hello", clientId, kind: bus.kind });
450
+ const sayHello = () => bus.post({ v: 1, scope: "state", type: "hello", clientId, kind: bus.kind });
451
+ const onPageShow = (event) => {
452
+ if (!event.persisted) return;
453
+ sayHello();
454
+ };
455
+ const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
456
+ if (hasWindow) addEventListener("pageshow", onPageShow);
457
+ sayHello();
458
+ let storeClosed = false;
362
459
  return {
363
460
  clientId,
364
461
  state: proxy,
@@ -379,16 +476,27 @@ function createSharedStore(name, initial, options = {}) {
379
476
  keyListeners.set(key, set);
380
477
  }
381
478
  set.add(fn);
382
- return () => set.delete(fn);
479
+ return () => {
480
+ set.delete(fn);
481
+ if (set.size === 0) keyListeners.delete(key);
482
+ };
383
483
  },
384
484
  registerKey(key, initialValue) {
385
485
  if (key in versions) return;
386
486
  versions[key] = [0, clientId];
387
- state[key] = initialValue;
487
+ state[key] = freezeShared(initialValue);
388
488
  snapshot = Object.freeze({ ...state });
389
489
  versionsSnapshot = Object.freeze({ ...versions });
390
490
  },
391
491
  close() {
492
+ if (storeClosed) return;
493
+ storeClosed = true;
494
+ if (onSharedBus) {
495
+ const live = (liveStores.get(name) ?? 1) - 1;
496
+ if (live > 0) liveStores.set(name, live);
497
+ else liveStores.delete(name);
498
+ }
499
+ if (hasWindow) removeEventListener("pageshow", onPageShow);
392
500
  if (flushPersist) {
393
501
  flushPersist();
394
502
  if (typeof document !== "undefined" && typeof removeEventListener === "function") {
@@ -437,6 +545,7 @@ function createPresence(name, options = {}) {
437
545
  },
438
546
  Math.max(500, Math.floor(pruneAfterMs / 2))
439
547
  );
548
+ let closed = false;
440
549
  return {
441
550
  clientId: bus.clientId,
442
551
  getPeers: () => snapshot,
@@ -445,6 +554,8 @@ function createPresence(name, options = {}) {
445
554
  return () => listeners.delete(fn);
446
555
  },
447
556
  close() {
557
+ if (closed) return;
558
+ closed = true;
448
559
  clearInterval(prune);
449
560
  unsubscribe();
450
561
  listeners.clear();
@@ -533,10 +644,19 @@ function createLeader(name, options = {}) {
533
644
  bus.post({ v: 1, scope: "leader", type: "resign", term: resigning, clientId, kind: bus.kind });
534
645
  armLease(leaseMs);
535
646
  }
647
+ const sayHello = () => bus.post({ v: 1, scope: "leader", type: "hello", clientId, kind: bus.kind });
536
648
  const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
537
649
  const onPageHide = () => resign();
538
- if (hasWindow) addEventListener("pagehide", onPageHide);
539
- bus.post({ v: 1, scope: "leader", type: "hello", clientId, kind: bus.kind });
650
+ const onPageShow = (event) => {
651
+ if (!event.persisted) return;
652
+ sayHello();
653
+ armLease(heartbeatMs);
654
+ };
655
+ if (hasWindow) {
656
+ addEventListener("pagehide", onPageHide);
657
+ addEventListener("pageshow", onPageShow);
658
+ }
659
+ sayHello();
540
660
  armLease(heartbeatMs);
541
661
  return {
542
662
  clientId,
@@ -556,11 +676,15 @@ function createLeader(name, options = {}) {
556
676
  if (leaderId === null) armLease(0);
557
677
  },
558
678
  close() {
679
+ if (closed) return;
559
680
  closed = true;
560
681
  resign();
561
682
  clearInterval(beat);
562
683
  clearTimeout(lease);
563
- if (hasWindow) removeEventListener("pagehide", onPageHide);
684
+ if (hasWindow) {
685
+ removeEventListener("pagehide", onPageHide);
686
+ removeEventListener("pageshow", onPageShow);
687
+ }
564
688
  unsubscribe();
565
689
  listeners.clear();
566
690
  bus.release();
@@ -574,44 +698,54 @@ function isPersisted(value) {
574
698
  const candidate = value;
575
699
  return candidate.v === 1 && typeof candidate.state === "object" && typeof candidate.versions === "object";
576
700
  }
577
- function webStorageAdapter(storage, key) {
578
- const resolve = () => {
701
+ function webStorageAdapter(storage, key, options = {}) {
702
+ const report = (error, operation) => {
579
703
  try {
580
- return typeof storage === "function" ? storage() : storage;
704
+ options.onError?.(error, operation);
581
705
  } catch {
706
+ }
707
+ };
708
+ const resolve = (operation) => {
709
+ try {
710
+ return typeof storage === "function" ? storage() : storage;
711
+ } catch (error) {
712
+ report(error, operation);
582
713
  return void 0;
583
714
  }
584
715
  };
585
716
  return {
586
717
  read() {
587
718
  try {
588
- const raw = resolve()?.getItem(key);
719
+ const raw = resolve("read")?.getItem(key);
589
720
  if (!raw) return void 0;
590
721
  const parsed = JSON.parse(raw);
591
722
  return isPersisted(parsed) ? parsed : void 0;
592
- } catch {
723
+ } catch (error) {
724
+ report(error, "read");
593
725
  return void 0;
594
726
  }
595
727
  },
596
728
  write(snapshot) {
597
729
  try {
598
- resolve()?.setItem(key, JSON.stringify(snapshot));
599
- } catch {
730
+ resolve("write")?.setItem(key, JSON.stringify(snapshot));
731
+ } catch (error) {
732
+ report(error, "write");
600
733
  }
601
734
  },
602
735
  remove() {
603
736
  try {
604
- resolve()?.removeItem(key);
605
- } catch {
737
+ resolve("remove")?.removeItem(key);
738
+ } catch (error) {
739
+ report(error, "remove");
606
740
  }
607
741
  }
608
742
  };
609
743
  }
610
- function localStorageAdapter(key) {
611
- return webStorageAdapter(() => globalThis.localStorage, key);
744
+ function localStorageAdapter(key, options) {
745
+ return webStorageAdapter(() => globalThis.localStorage, key, options);
612
746
  }
613
- function sessionStorageAdapter(key) {
614
- return webStorageAdapter(() => globalThis.sessionStorage, key);
747
+ function sessionStorageAdapter(key, options) {
748
+ return webStorageAdapter(() => globalThis.sessionStorage, key, options);
615
749
  }
616
750
 
617
751
  // src/errors/handshake-timeout-error.ts
@@ -738,6 +872,7 @@ function openWindow(url, options) {
738
872
  }, 400);
739
873
  const readyTimer = setTimeout(() => {
740
874
  if (isReady || closedSettled) return;
875
+ localWindow.removeEventListener("message", onMessage);
741
876
  const err = new HandshakeTimeoutError();
742
877
  rejectReady(err);
743
878
  if (!resultSettled) {
@@ -791,6 +926,7 @@ function connectToOpener(options) {
791
926
  markHandled(ready);
792
927
  const onMessage = (event) => {
793
928
  if (!allowAnyOrigin && event.origin !== peerOrigin) return;
929
+ if (event.source !== opener) return;
794
930
  if (!isWindowWire(event.data, cid)) return;
795
931
  const wire = event.data;
796
932
  if (wire.t === "ready-ack") {
@@ -886,9 +1022,11 @@ var MemoryHub = class {
886
1022
  }
887
1023
  /** @internal */
888
1024
  broadcast(from, data) {
1025
+ structuredClone(data);
889
1026
  for (const transport of this.transports) {
890
1027
  if (transport !== from) {
891
- queueMicrotask(() => transport.deliver(data));
1028
+ const copy = structuredClone(data);
1029
+ queueMicrotask(() => transport.deliver(copy));
892
1030
  }
893
1031
  }
894
1032
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@use-everywhere/core",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Cross-tab shared state, events, presence, and cross-origin window channels",
5
5
  "license": "MIT",
6
6
  "author": "Jonatan Kruszewski <jonakrusze@gmail.com>",
@@ -48,43 +48,43 @@
48
48
  "name": "everything (import *)",
49
49
  "path": "dist/index.js",
50
50
  "import": "*",
51
- "limit": "4.5 kB"
51
+ "limit": "5 kB"
52
52
  },
53
53
  {
54
54
  "name": "createLeader",
55
55
  "path": "dist/index.js",
56
56
  "import": "{ createLeader }",
57
- "limit": "1.3 kB"
57
+ "limit": "1.6 kB"
58
58
  },
59
59
  {
60
60
  "name": "createSharedStore",
61
61
  "path": "dist/index.js",
62
62
  "import": "{ createSharedStore }",
63
- "limit": "1.6 kB"
63
+ "limit": "2.25 kB"
64
64
  },
65
65
  {
66
66
  "name": "createChannel",
67
67
  "path": "dist/index.js",
68
68
  "import": "{ createChannel }",
69
- "limit": "900 B"
69
+ "limit": "1.25 kB"
70
70
  },
71
71
  {
72
72
  "name": "createPresence",
73
73
  "path": "dist/index.js",
74
74
  "import": "{ createPresence }",
75
- "limit": "1 kB"
75
+ "limit": "1.35 kB"
76
76
  },
77
77
  {
78
78
  "name": "openWindow (opener side)",
79
79
  "path": "dist/index.js",
80
80
  "import": "{ openWindow }",
81
- "limit": "1.15 kB"
81
+ "limit": "1.3 kB"
82
82
  },
83
83
  {
84
84
  "name": "connectToOpener (child side)",
85
85
  "path": "dist/index.js",
86
86
  "import": "{ connectToOpener }",
87
- "limit": "1 kB"
87
+ "limit": "1.15 kB"
88
88
  },
89
89
  {
90
90
  "name": "MemoryHub (test transport)",