@use-everywhere/core 0.7.0 → 0.8.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
@@ -26,18 +26,24 @@ __export(src_exports, {
26
26
  HandshakeTimeoutError: () => HandshakeTimeoutError,
27
27
  NoopTransport: () => NoopTransport,
28
28
  StorageTransport: () => StorageTransport,
29
+ WIRE_VERSION: () => WIRE_VERSION,
29
30
  WindowClosedError: () => WindowClosedError,
30
31
  connectToOpener: () => connectToOpener,
31
32
  createChannel: () => createChannel,
32
33
  createLeader: () => createLeader,
34
+ createNamespace: () => createNamespace,
33
35
  createPresence: () => createPresence,
36
+ createSharedReducer: () => createSharedReducer,
34
37
  createSharedStore: () => createSharedStore,
35
38
  defaultTransport: () => defaultTransport,
36
39
  enableDebug: () => enableDebug,
37
40
  getBusNames: () => getBusNames,
38
41
  getTransportKind: () => getTransportKind,
42
+ getWireSkew: () => getWireSkew,
43
+ indexedDbAdapter: () => indexedDbAdapter,
39
44
  isBroadcastChannelAvailable: () => isBroadcastChannelAvailable,
40
45
  isStorageEventAvailable: () => isStorageEventAvailable,
46
+ jsonSerializer: () => jsonSerializer,
41
47
  localStorageAdapter: () => localStorageAdapter,
42
48
  newer: () => newer,
43
49
  observeBus: () => observeBus,
@@ -109,9 +115,11 @@ function randomHex(bytes) {
109
115
  for (const b of buf) out2 += b.toString(16).padStart(2, "0");
110
116
  return out2;
111
117
  }
112
- devWarn(
113
- "[use-everywhere] crypto.getRandomValues is unavailable; falling back to Math.random for ids. Cross-origin window nonces are not cryptographically strong in this environment."
114
- );
118
+ if (process.env.NODE_ENV !== "production") {
119
+ devWarn(
120
+ "[use-everywhere] crypto.getRandomValues is unavailable; falling back to Math.random for ids. Cross-origin window nonces are not cryptographically strong in this environment."
121
+ );
122
+ }
115
123
  let out = "";
116
124
  while (out.length < bytes * 2)
117
125
  out += Math.floor(Math.random() * 256).toString(16).padStart(2, "0");
@@ -128,15 +136,18 @@ function newMsgId() {
128
136
  var PROTOCOL = 1;
129
137
  var TABLE = /* @__PURE__ */ Symbol.for(`use-everywhere.rendezvous.${PROTOCOL}`);
130
138
  var CENSUS = /* @__PURE__ */ Symbol.for("use-everywhere.rendezvous.census");
139
+ var SKEW = /* @__PURE__ */ Symbol.for("use-everywhere.rendezvous.skew");
131
140
  function announce() {
132
141
  const g = globalThis;
133
142
  const census = g[CENSUS] ?? (g[CENSUS] = { protocols: [] });
134
143
  if (census.protocols.includes(PROTOCOL)) return;
135
144
  census.protocols.push(PROTOCOL);
136
145
  if (census.protocols.length > 1) {
137
- devWarn(
138
- `[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.`
139
- );
146
+ if (process.env.NODE_ENV !== "production") {
147
+ devWarn(
148
+ `[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.`
149
+ );
150
+ }
140
151
  }
141
152
  }
142
153
  function busTable() {
@@ -144,6 +155,10 @@ function busTable() {
144
155
  const g = globalThis;
145
156
  return g[TABLE] ?? (g[TABLE] = /* @__PURE__ */ new Map());
146
157
  }
158
+ function skewLedger() {
159
+ const g = globalThis;
160
+ return g[SKEW] ?? (g[SKEW] = /* @__PURE__ */ new Map());
161
+ }
147
162
 
148
163
  // src/transport/broadcast-channel-transport.ts
149
164
  var BroadcastChannelTransport = class {
@@ -183,9 +198,38 @@ var NoopTransport = class {
183
198
  }
184
199
  };
185
200
 
201
+ // src/serializer.ts
202
+ function lossyType(raw, value) {
203
+ if (value === void 0) return raw === void 0 ? "undefined" : "undefined after toJSON";
204
+ if (raw instanceof Date) return "Date";
205
+ if (raw instanceof Map) return "Map";
206
+ if (raw instanceof Set) return "Set";
207
+ if (raw instanceof RegExp) return "RegExp";
208
+ if (ArrayBuffer.isView(raw)) return "TypedArray";
209
+ const type = typeof raw;
210
+ return type === "function" || type === "symbol" ? type : void 0;
211
+ }
212
+ var jsonSerializer = {
213
+ stringify(value) {
214
+ if (value === void 0) {
215
+ throw new TypeError("use-everywhere: cannot serialize undefined");
216
+ }
217
+ return JSON.stringify(value, function replacer(key, forJson) {
218
+ const type = lossyType(this[key], forJson);
219
+ if (type) {
220
+ throw new TypeError(
221
+ `use-everywhere: ${key ? `"${key}" is ` : ""}${type}, which JSON cannot round-trip. https://rxova.org/guides/serialization/`
222
+ );
223
+ }
224
+ return forJson;
225
+ });
226
+ },
227
+ parse: (text) => JSON.parse(text)
228
+ };
229
+
186
230
  // src/transport/storage-transport.ts
187
231
  var StorageTransport = class {
188
- constructor(name, storage = globalThis.localStorage) {
232
+ constructor(name, storage = globalThis.localStorage, serializer = jsonSerializer) {
189
233
  this.kind = "storage";
190
234
  this.listeners = /* @__PURE__ */ new Set();
191
235
  this.seq = 0;
@@ -194,13 +238,14 @@ var StorageTransport = class {
194
238
  "[use-everywhere] StorageTransport needs localStorage; workers have none. Use BroadcastChannel there."
195
239
  );
196
240
  }
241
+ this.serializer = serializer;
197
242
  this.key = `use-everywhere:bus:${name}`;
198
243
  this.storage = storage;
199
244
  this.onStorage = (event) => {
200
245
  if (event.key !== this.key || event.newValue === null) return;
201
246
  let payload;
202
247
  try {
203
- payload = JSON.parse(event.newValue);
248
+ payload = this.serializer.parse(event.newValue);
204
249
  } catch {
205
250
  return;
206
251
  }
@@ -209,7 +254,7 @@ var StorageTransport = class {
209
254
  addEventListener("storage", this.onStorage);
210
255
  }
211
256
  post(data) {
212
- const envelope = JSON.stringify({ seq: this.seq++, data }, rejectUnrepresentable);
257
+ const envelope = this.serializer.stringify({ seq: this.seq++, data });
213
258
  try {
214
259
  this.storage.setItem(this.key, envelope);
215
260
  this.storage.removeItem(this.key);
@@ -225,12 +270,6 @@ var StorageTransport = class {
225
270
  removeEventListener("storage", this.onStorage);
226
271
  }
227
272
  };
228
- function rejectUnrepresentable(_key, value) {
229
- if (typeof value === "function" || typeof value === "symbol") {
230
- throw new TypeError(`${typeof value} values cannot be shared`);
231
- }
232
- return value;
233
- }
234
273
 
235
274
  // src/transport/default-transport.ts
236
275
  function isBroadcastChannelAvailable() {
@@ -252,27 +291,58 @@ function isStorageEventAvailable() {
252
291
  function defaultTransport(name) {
253
292
  if (isBroadcastChannelAvailable()) return new BroadcastChannelTransport(name);
254
293
  if (isStorageEventAvailable()) {
294
+ if (process.env.NODE_ENV !== "production") {
295
+ devWarn(
296
+ "[use-everywhere] no BroadcastChannel; using the storage-event fallback. Values serialise as JSON, not structured clone \u2014 keep them JSON-shaped."
297
+ );
298
+ }
299
+ return new StorageTransport(name);
300
+ }
301
+ if (process.env.NODE_ENV !== "production") {
255
302
  devWarn(
256
- "[use-everywhere] no BroadcastChannel; using the storage-event fallback. Values serialise as JSON, not structured clone \u2014 keep them JSON-shaped."
303
+ "[use-everywhere] no BroadcastChannel and no usable localStorage: nothing is shared between tabs. Storage is probably blocked."
257
304
  );
258
- return new StorageTransport(name);
259
305
  }
260
- devWarn(
261
- "[use-everywhere] no BroadcastChannel and no usable localStorage: nothing is shared between tabs. Storage is probably blocked."
262
- );
263
306
  return new NoopTransport();
264
307
  }
265
308
 
266
- // src/bus.ts
309
+ // src/wire.ts
310
+ var WIRE_VERSION = 1;
311
+ function hasEnvelopeShape(wire) {
312
+ return typeof wire.scope === "string" && typeof wire.type === "string" && typeof wire.clientId === "string";
313
+ }
267
314
  function isBusWire(data) {
268
315
  if (typeof data !== "object" || data === null) return false;
269
316
  const wire = data;
270
- return wire.v === 1 && typeof wire.scope === "string" && typeof wire.type === "string" && typeof wire.clientId === "string";
317
+ return wire.v === WIRE_VERSION && hasEnvelopeShape(wire);
318
+ }
319
+ function foreignWireVersion(data) {
320
+ if (typeof data !== "object" || data === null) return null;
321
+ const wire = data;
322
+ if (typeof wire.v !== "number" || wire.v === WIRE_VERSION) return null;
323
+ return hasEnvelopeShape(wire) ? wire.v : null;
324
+ }
325
+ function recordSkew(name, version) {
326
+ const seen = skewLedger();
327
+ const versions = seen.get(name) ?? /* @__PURE__ */ new Set();
328
+ seen.set(name, versions);
329
+ if (versions.has(version)) return;
330
+ versions.add(version);
331
+ if (process.env.NODE_ENV !== "production") {
332
+ devWarn(
333
+ `[use-everywhere] bus "${name}": a peer speaks wire protocol v${version}, this build speaks v${WIRE_VERSION} \u2014 a ${version > WIRE_VERSION ? "newer" : "older"} deploy. They cannot share state, presence, or a leader seat. https://rxova.org/under-the-hood/version-skew/`
334
+ );
335
+ }
336
+ }
337
+ function getWireSkew(name) {
338
+ return [...skewLedger().get(name) ?? []].sort((a, b) => a - b);
271
339
  }
340
+
341
+ // src/bus.ts
272
342
  function defaultKind() {
273
343
  return typeof document === "undefined" ? "worker" : "tab";
274
344
  }
275
- var SHARED_WITHIN_CLIENT = /* @__PURE__ */ new Set(["state", "event"]);
345
+ var SHARED_WITHIN_CLIENT = /* @__PURE__ */ new Set(["state", "event", "op"]);
276
346
  function createBusCore(name, options, onShutdown) {
277
347
  const transport = (options.transport ?? defaultTransport)(name);
278
348
  const clientId = newClientId();
@@ -293,6 +363,11 @@ function createBusCore(name, options, onShutdown) {
293
363
  if (SHARED_WITHIN_CLIENT.has(wire.scope)) deliver(wire, from);
294
364
  };
295
365
  const unsubscribe = transport.subscribe((data) => {
366
+ const foreign = foreignWireVersion(data);
367
+ if (foreign !== null) {
368
+ recordSkew(name, foreign);
369
+ return;
370
+ }
296
371
  if (!isBusWire(data)) return;
297
372
  if (data.clientId === clientId) return;
298
373
  emitBusEvent(name, "in", data);
@@ -385,57 +460,172 @@ function getBus(name, options = {}) {
385
460
  table.set(name, core);
386
461
  } else {
387
462
  if (options.heartbeatMs !== void 0 && options.heartbeatMs !== core.heartbeatMs) {
388
- devWarn(
389
- `[use-everywhere] bus "${name}": heartbeatMs ignored \u2014 the first creator fixes bus options`
390
- );
463
+ if (process.env.NODE_ENV !== "production") {
464
+ devWarn(
465
+ `[use-everywhere] bus "${name}": heartbeatMs ignored \u2014 the first creator fixes bus options`
466
+ );
467
+ }
391
468
  }
392
469
  if (options.kind !== void 0 && options.kind !== core.kind) {
393
- devWarn(`[use-everywhere] bus "${name}": kind ignored \u2014 the first creator fixes bus options`);
470
+ if (process.env.NODE_ENV !== "production") {
471
+ devWarn(
472
+ `[use-everywhere] bus "${name}": kind ignored \u2014 the first creator fixes bus options`
473
+ );
474
+ }
394
475
  }
395
476
  }
396
477
  return core.connect();
397
478
  }
398
479
 
480
+ // src/schema.ts
481
+ function validate(schema, payload) {
482
+ const props = schema["~standard"];
483
+ const result = props.validate(payload);
484
+ if (typeof result.then === "function") {
485
+ return { ok: false, issues: [`the "${props.vendor}" schema validates asynchronously`] };
486
+ }
487
+ const sync = result;
488
+ if (sync.issues) return { ok: false, issues: sync.issues.map((issue) => issue.message) };
489
+ return { ok: true, value: sync.value };
490
+ }
491
+ function createGate(name, schemas, onInvalid) {
492
+ if (!schemas) return void 0;
493
+ const check = (key, payload, direction) => {
494
+ const schema = schemas[key];
495
+ if (!schema) return void 0;
496
+ const result = validate(schema, payload);
497
+ if (result.ok) return void 0;
498
+ const info = { name, key, direction, payload, issues: result.issues };
499
+ if (onInvalid) onInvalid(info);
500
+ else if (process.env.NODE_ENV !== "production") {
501
+ devWarn(
502
+ `[use-everywhere] ${name}/${key}: ${direction}bound payload rejected by its schema \u2014 ${result.issues.join("; ")}. https://rxova.org/guides/validating-payloads/`
503
+ );
504
+ }
505
+ return result.issues;
506
+ };
507
+ return {
508
+ accepts: (key, payload) => !check(key, payload, "in"),
509
+ assert(key, payload) {
510
+ const issues = check(key, payload, "out");
511
+ if (issues) {
512
+ throw new TypeError(
513
+ `use-everywhere: ${name}/${key} \u2014 the value does not match its schema, so nothing was sent or applied. ${issues.join("; ")}`
514
+ );
515
+ }
516
+ }
517
+ };
518
+ }
519
+
399
520
  // src/channel.ts
400
521
  function createChannel(name, options = {}) {
401
522
  const bus = getBus(name, options);
402
523
  const handlers = /* @__PURE__ */ new Map();
524
+ const responders = /* @__PURE__ */ new Map();
525
+ const waiting = /* @__PURE__ */ new Map();
526
+ const gate = createGate(name, options.schema, options.onInvalid);
527
+ const deliver = (type, payload, meta) => {
528
+ const set = handlers.get(type);
529
+ if (!set) return;
530
+ for (const fn of [...set]) fn(payload, meta);
531
+ };
403
532
  const unsubscribe = bus.subscribe((wire) => {
404
533
  if (wire.scope !== "event") return;
405
- const set = handlers.get(wire.type);
406
- if (!set) return;
407
534
  const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
408
- for (const fn of set) fn(wire.payload, meta);
409
- });
410
- let closed = false;
411
- return {
412
- name,
413
- clientId: bus.clientId,
414
- post(type, payload) {
535
+ if (wire.replyTo !== void 0) {
536
+ const settle = waiting.get(wire.replyTo);
537
+ if (!settle) return;
538
+ waiting.delete(wire.replyTo);
539
+ settle(wire.payload);
540
+ return;
541
+ }
542
+ const responder = responders.get(wire.type);
543
+ if (responder) {
415
544
  bus.post({
416
545
  v: 1,
417
546
  scope: "event",
418
- type,
419
- payload,
547
+ type: wire.type,
548
+ payload: responder(wire.payload, meta),
420
549
  clientId: bus.clientId,
421
550
  kind: bus.kind,
422
- msgId: newMsgId()
551
+ msgId: newMsgId(),
552
+ replyTo: wire.msgId
423
553
  });
554
+ }
555
+ if (!handlers.has(wire.type)) return;
556
+ if (gate && !gate.accepts(wire.type, wire.payload)) return;
557
+ deliver(wire.type, wire.payload, meta);
558
+ });
559
+ let closed = false;
560
+ const send = (type, payload, msgId) => {
561
+ gate?.assert(type, payload);
562
+ bus.post({
563
+ v: 1,
564
+ scope: "event",
565
+ type,
566
+ payload,
567
+ clientId: bus.clientId,
568
+ kind: bus.kind,
569
+ msgId
570
+ });
571
+ };
572
+ return {
573
+ name,
574
+ clientId: bus.clientId,
575
+ post(type, payload, options2) {
576
+ send(type, payload, newMsgId());
577
+ if (options2?.echo) {
578
+ deliver(type, payload, { clientId: bus.clientId, kind: bus.kind, self: true });
579
+ }
424
580
  },
425
- on(type, handler) {
581
+ on(type, handler, options2) {
426
582
  let set = handlers.get(type);
427
583
  if (!set) {
428
584
  set = /* @__PURE__ */ new Set();
429
585
  handlers.set(type, set);
430
586
  }
431
- set.add(handler);
432
- return () => set.delete(handler);
587
+ const entry = options2?.once ? (payload, meta) => {
588
+ set.delete(entry);
589
+ handler(payload, meta);
590
+ } : handler;
591
+ set.add(entry);
592
+ return () => set.delete(entry);
593
+ },
594
+ ask(type, payload, options2) {
595
+ const msgId = newMsgId();
596
+ return new Promise((resolve, reject) => {
597
+ const timer = setTimeout(() => {
598
+ waiting.delete(msgId);
599
+ reject(
600
+ new Error(
601
+ `use-everywhere: nobody answered "${type}" on "${name}" within ${String(options2?.timeoutMs ?? 5e3)}ms`
602
+ )
603
+ );
604
+ }, options2?.timeoutMs ?? 5e3);
605
+ waiting.set(msgId, (value) => {
606
+ clearTimeout(timer);
607
+ resolve(value);
608
+ });
609
+ try {
610
+ send(type, payload, msgId);
611
+ } catch (error) {
612
+ clearTimeout(timer);
613
+ waiting.delete(msgId);
614
+ throw error;
615
+ }
616
+ });
617
+ },
618
+ answer(type, responder) {
619
+ responders.set(type, responder);
620
+ return () => responders.delete(type);
433
621
  },
434
622
  close() {
435
623
  if (closed) return;
436
624
  closed = true;
437
625
  unsubscribe();
438
626
  handlers.clear();
627
+ responders.clear();
628
+ waiting.clear();
439
629
  bus.release();
440
630
  }
441
631
  };
@@ -486,15 +676,18 @@ function createSharedStore(name, initial, options = {}) {
486
676
  if (onSharedBus) {
487
677
  const live = liveStores.get(name) ?? 0;
488
678
  if (live > 0) {
489
- devWarn(
490
- `[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.`
491
- );
679
+ if (process.env.NODE_ENV !== "production") {
680
+ devWarn(
681
+ `[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.`
682
+ );
683
+ }
492
684
  }
493
685
  liveStores.set(name, live + 1);
494
686
  }
495
687
  const bus = getBus(name, options);
496
688
  const clientId = bus.clientId;
497
689
  const accept = options.accept;
690
+ const gate = createGate(name, options.schema, options.onInvalid);
498
691
  const state = { ...initial };
499
692
  const versions = {};
500
693
  for (const k in state) {
@@ -505,23 +698,61 @@ function createSharedStore(name, initial, options = {}) {
505
698
  let versionsSnapshot = Object.freeze({ ...versions });
506
699
  const listeners = /* @__PURE__ */ new Set();
507
700
  const keyListeners = /* @__PURE__ */ new Map();
508
- function notify(key, value, meta) {
701
+ let batchDepth = 0;
702
+ const batched = [];
703
+ const rebuild = () => {
509
704
  snapshot = Object.freeze({ ...state });
510
705
  versionsSnapshot = Object.freeze({ ...versions });
706
+ };
707
+ const emit = (key, value, meta) => {
511
708
  for (const fn of listeners) fn(key, value, meta);
512
709
  const set = keyListeners.get(key);
513
710
  if (set) for (const fn of set) fn();
711
+ };
712
+ function notify(key, value, meta) {
713
+ if (batchDepth > 0) {
714
+ batched.push([key, value, meta]);
715
+ return;
716
+ }
717
+ rebuild();
718
+ emit(key, value, meta);
719
+ }
720
+ function batch(fn) {
721
+ batchDepth++;
722
+ try {
723
+ return fn();
724
+ } finally {
725
+ batchDepth--;
726
+ if (batchDepth === 0 && batched.length > 0) {
727
+ const changes = batched.splice(0, batched.length);
728
+ rebuild();
729
+ for (const [key, value, meta] of changes) emit(key, value, meta);
730
+ }
731
+ }
514
732
  }
515
733
  function applyRemote(key, value, version, meta) {
516
734
  if (!newer(version, versions[key])) return;
735
+ if (gate && !gate.accepts(key, value)) return;
517
736
  versions[key] = version;
518
737
  state[key] = freezeShared(value);
519
738
  notify(key, value, meta);
520
739
  }
521
- const unsubscribe = bus.subscribe((wire) => {
522
- if (wire.scope !== "state") return;
523
- const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
524
- if (wire.type === "hello") {
740
+ const snapshotDelayMs = options.snapshotDelayMs ?? 40;
741
+ let snapshotTimer;
742
+ const written = () => Object.keys(versions).filter((k) => (versions[k]?.[0] ?? 0) > 0);
743
+ const coveredBy = (theirs) => written().every((key) => {
744
+ const mine = versions[key];
745
+ return mine !== void 0 && !newer(mine, theirs[key]);
746
+ });
747
+ const cancelSnapshot = () => {
748
+ clearTimeout(snapshotTimer);
749
+ snapshotTimer = void 0;
750
+ };
751
+ const scheduleSnapshot = () => {
752
+ if (written().length === 0) return;
753
+ if (snapshotTimer !== void 0) return;
754
+ snapshotTimer = setTimeout(() => {
755
+ snapshotTimer = void 0;
525
756
  bus.post({
526
757
  v: 1,
527
758
  scope: "state",
@@ -531,22 +762,36 @@ function createSharedStore(name, initial, options = {}) {
531
762
  state: { ...state },
532
763
  versions: { ...versions }
533
764
  });
765
+ }, Math.random() * snapshotDelayMs);
766
+ };
767
+ const unsubscribe = bus.subscribe((wire) => {
768
+ if (wire.scope !== "state") return;
769
+ const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
770
+ if (wire.type === "hello") {
771
+ scheduleSnapshot();
534
772
  return;
535
773
  }
536
774
  if (accept && !accept(meta)) return;
537
775
  if (wire.type === "patch") {
538
776
  if (typeof wire.key !== "string" || !isVersion(wire.version)) return;
539
777
  applyRemote(wire.key, wire.value, wire.version, meta);
540
- } else {
778
+ return;
779
+ }
780
+ if (wire.type === "snapshot") {
541
781
  const versions2 = wire.versions;
542
782
  if (typeof versions2 !== "object" || versions2 === null) return;
543
- for (const k in wire.state) {
544
- const version = versions2[k];
545
- if (isVersion(version)) applyRemote(k, wire.state[k], version, meta);
546
- }
783
+ if (coveredBy(versions2)) cancelSnapshot();
784
+ batch(() => {
785
+ for (const k in wire.state) {
786
+ const version = versions2[k];
787
+ if (isVersion(version)) applyRemote(k, wire.state[k], version, meta);
788
+ }
789
+ });
790
+ return;
547
791
  }
548
792
  });
549
793
  function setKey(key, value) {
794
+ gate?.assert(key, value);
550
795
  const version = [(versions[key]?.[0] ?? 0) + 1, clientId];
551
796
  try {
552
797
  bus.post({
@@ -578,29 +823,73 @@ function createSharedStore(name, initial, options = {}) {
578
823
  });
579
824
  const persist = options.persist;
580
825
  let flushPersist;
826
+ let settle = () => {
827
+ };
828
+ const hydrated = persist ? new Promise((resolve) => {
829
+ settle = resolve;
830
+ }) : Promise.resolve();
581
831
  if (persist) {
582
- const { adapter, keys, debounceMs = 100 } = persist;
832
+ const {
833
+ adapter,
834
+ keys,
835
+ debounceMs = 100,
836
+ version: schemaVersion = 0,
837
+ migrate,
838
+ onRestoreError
839
+ } = persist;
583
840
  const shouldPersist = (key) => !keys || keys.includes(key);
584
- const hydrate = (saved2) => {
585
- if (!saved2) return;
586
- for (const key in saved2.state) {
587
- const version = saved2.versions[key];
841
+ const refuse = (error) => {
842
+ if (onRestoreError) onRestoreError(error);
843
+ else if (process.env.NODE_ENV !== "production") {
844
+ devWarn(
845
+ `[use-everywhere] ${name}: persisted schema v${error.found} not restored, expected v${error.expected} (${error.reason}). https://rxova.org/guides/persistence/`
846
+ );
847
+ }
848
+ };
849
+ const reconcile = (saved2) => {
850
+ const found = saved2.schema ?? 0;
851
+ if (found === schemaVersion) return { state: saved2.state, migrated: false };
852
+ if (found > schemaVersion) {
853
+ refuse({ reason: "ahead", found, expected: schemaVersion });
854
+ return void 0;
855
+ }
856
+ if (!migrate) {
857
+ refuse({ reason: "no-migrate", found, expected: schemaVersion });
858
+ return void 0;
859
+ }
860
+ try {
861
+ return { state: migrate(saved2.state, found), migrated: true };
862
+ } catch (cause) {
863
+ refuse({ reason: "migrate-threw", found, expected: schemaVersion, cause });
864
+ return void 0;
865
+ }
866
+ };
867
+ const hydrate = (raw) => {
868
+ const reconciled = raw && reconcile(raw);
869
+ if (!reconciled || !raw) {
870
+ settle();
871
+ return;
872
+ }
873
+ const { state: restored, migrated } = reconciled;
874
+ for (const key in restored) {
875
+ const version = raw.versions[key] ?? (migrated ? [1, clientId] : void 0);
588
876
  if (!version || !shouldPersist(key)) continue;
589
- applyRemote(key, saved2.state[key], version, { clientId, kind: bus.kind, self: true });
877
+ applyRemote(key, restored[key], version, { clientId, kind: bus.kind, self: true });
590
878
  bus.post({
591
879
  v: 1,
592
880
  scope: "state",
593
881
  type: "patch",
594
882
  key,
595
- value: saved2.state[key],
883
+ value: restored[key],
596
884
  version,
597
885
  clientId,
598
886
  kind: bus.kind
599
887
  });
600
888
  }
889
+ settle();
601
890
  };
602
891
  const collect = () => {
603
- const out = { v: 1, state: {}, versions: {} };
892
+ const out = { v: 1, schema: schemaVersion, state: {}, versions: {} };
604
893
  for (const key in versions) {
605
894
  const version = versions[key];
606
895
  if (!version || version[0] === 0 || !shouldPersist(key)) continue;
@@ -640,6 +929,7 @@ function createSharedStore(name, initial, options = {}) {
640
929
  let storeClosed = false;
641
930
  return {
642
931
  clientId,
932
+ hydrated,
643
933
  state: proxy,
644
934
  getSnapshot: () => snapshot,
645
935
  getVersions: () => versionsSnapshot,
@@ -647,6 +937,9 @@ function createSharedStore(name, initial, options = {}) {
647
937
  const next = typeof value === "function" ? value(state[key]) : value;
648
938
  setKey(key, next);
649
939
  },
940
+ transaction(fn) {
941
+ return batch(fn);
942
+ },
650
943
  subscribe(fn) {
651
944
  listeners.add(fn);
652
945
  return () => listeners.delete(fn);
@@ -678,6 +971,7 @@ function createSharedStore(name, initial, options = {}) {
678
971
  if (live > 0) liveStores.set(name, live);
679
972
  else liveStores.delete(name);
680
973
  }
974
+ cancelSnapshot();
681
975
  if (hasWindow) removeEventListener("pageshow", onPageShow);
682
976
  if (flushPersist) {
683
977
  flushPersist();
@@ -693,83 +987,6 @@ function createSharedStore(name, initial, options = {}) {
693
987
  };
694
988
  }
695
989
 
696
- // src/presence.ts
697
- function createPresence(name, options = {}) {
698
- const pruneAfterMs = options.pruneAfterMs ?? 5e3;
699
- const probeGraceMs = options.probeGraceMs ?? 1e3;
700
- const bus = getBus(name, options);
701
- const peers = /* @__PURE__ */ new Map();
702
- const listeners = /* @__PURE__ */ new Set();
703
- let snapshot = [];
704
- function notify() {
705
- snapshot = Object.freeze([...peers.values()]);
706
- for (const fn of listeners) fn();
707
- }
708
- const unsubscribe = bus.subscribe((wire) => {
709
- if (wire.clientId === bus.clientId) return;
710
- if (wire.scope === "presence" && wire.type === "bye") {
711
- if (peers.delete(wire.clientId)) notify();
712
- return;
713
- }
714
- const existing = peers.get(wire.clientId);
715
- peers.set(wire.clientId, { id: wire.clientId, kind: wire.kind, lastSeen: Date.now() });
716
- if (!existing) notify();
717
- });
718
- bus.post({ v: 1, scope: "presence", type: "hello", clientId: bus.clientId, kind: bus.kind });
719
- const probed = /* @__PURE__ */ new Map();
720
- const prune = setInterval(
721
- () => {
722
- const now = Date.now();
723
- const silentSince = now - pruneAfterMs;
724
- let changed = false;
725
- let needProbe = false;
726
- for (const [id, peer] of peers) {
727
- if (peer.lastSeen >= silentSince) {
728
- probed.delete(id);
729
- continue;
730
- }
731
- const askedAt = probed.get(id);
732
- if (askedAt === void 0) {
733
- probed.set(id, now);
734
- needProbe = true;
735
- } else if (now - askedAt >= probeGraceMs) {
736
- peers.delete(id);
737
- probed.delete(id);
738
- changed = true;
739
- }
740
- }
741
- if (needProbe) {
742
- bus.post({
743
- v: 1,
744
- scope: "presence",
745
- type: "hello",
746
- clientId: bus.clientId,
747
- kind: bus.kind
748
- });
749
- }
750
- if (changed) notify();
751
- },
752
- Math.max(250, Math.floor(Math.min(pruneAfterMs, probeGraceMs) / 2))
753
- );
754
- let closed = false;
755
- return {
756
- clientId: bus.clientId,
757
- getPeers: () => snapshot,
758
- subscribe(fn) {
759
- listeners.add(fn);
760
- return () => listeners.delete(fn);
761
- },
762
- close() {
763
- if (closed) return;
764
- closed = true;
765
- clearInterval(prune);
766
- unsubscribe();
767
- listeners.clear();
768
- bus.release();
769
- }
770
- };
771
- }
772
-
773
990
  // src/leader-web-locks.ts
774
991
  var NO_LEADER = Object.freeze({ leaderId: null, isLeader: false });
775
992
  function createWebLocksLeader(name, options, locks) {
@@ -1054,6 +1271,246 @@ function createHeartbeatLeader(name, options) {
1054
1271
  };
1055
1272
  }
1056
1273
 
1274
+ // src/shared-reducer.ts
1275
+ function createSharedReducer(name, reducer, initial, options = {}) {
1276
+ const key = options.key ?? "default";
1277
+ const bus = getBus(name, options);
1278
+ const clientId = bus.clientId;
1279
+ const leader = options.leader ?? createLeader(name, {
1280
+ ...options.leaderOptions,
1281
+ // Spread conditionally: `exactOptionalPropertyTypes` distinguishes an
1282
+ // absent transport from one explicitly set to undefined, and the leader
1283
+ // must default its own rather than be handed a hole.
1284
+ ...options.transport ? { transport: options.transport } : {}
1285
+ });
1286
+ const ownsLeader = !options.leader;
1287
+ let committed = initial;
1288
+ let seq = 0;
1289
+ let pending = [];
1290
+ const early = /* @__PURE__ */ new Map();
1291
+ let lastIssued = 0;
1292
+ let view = committed;
1293
+ const listeners = /* @__PURE__ */ new Set();
1294
+ let closed = false;
1295
+ const notify = () => {
1296
+ const next = pending.reduce((state, op) => reducer(state, op.action), committed);
1297
+ if (Object.is(next, view)) return;
1298
+ view = next;
1299
+ for (const fn of listeners) fn();
1300
+ };
1301
+ const askForSnapshot = () => bus.post({ v: 1, scope: "op", type: "hello", key, clientId, kind: bus.kind });
1302
+ const receiveCommit = (at, action, opId) => {
1303
+ if (at <= seq) return;
1304
+ if (at > seq + 1) {
1305
+ early.set(at, { action, opId });
1306
+ askForSnapshot();
1307
+ return;
1308
+ }
1309
+ applyCommit(at, action, opId);
1310
+ notify();
1311
+ };
1312
+ const sequence = (action, opId) => {
1313
+ lastIssued = Math.max(lastIssued, seq) + 1;
1314
+ const at = lastIssued;
1315
+ bus.post({
1316
+ v: 1,
1317
+ scope: "op",
1318
+ type: "commit",
1319
+ key,
1320
+ action,
1321
+ opId,
1322
+ seq: at,
1323
+ clientId,
1324
+ kind: bus.kind
1325
+ });
1326
+ receiveCommit(at, action, opId);
1327
+ };
1328
+ const applyCommit = (at, action, opId) => {
1329
+ committed = reducer(committed, action);
1330
+ seq = at;
1331
+ pending = pending.filter((op) => op.opId !== opId);
1332
+ const next = early.get(seq + 1);
1333
+ if (next) {
1334
+ early.delete(seq + 1);
1335
+ applyCommit(seq + 1, next.action, next.opId);
1336
+ }
1337
+ };
1338
+ const unsubscribe = bus.subscribe((wire) => {
1339
+ if (wire.scope !== "op" || wire.key !== key) return;
1340
+ if (wire.type === "propose") {
1341
+ if (!leader.getSnapshot().isLeader) return;
1342
+ sequence(wire.action, wire.opId);
1343
+ return;
1344
+ }
1345
+ if (wire.type === "commit") {
1346
+ receiveCommit(wire.seq, wire.action, wire.opId);
1347
+ return;
1348
+ }
1349
+ if (wire.type === "hello") {
1350
+ if (seq === 0) return;
1351
+ bus.post({
1352
+ v: 1,
1353
+ scope: "op",
1354
+ type: "snapshot",
1355
+ key,
1356
+ state: committed,
1357
+ seq,
1358
+ clientId,
1359
+ kind: bus.kind
1360
+ });
1361
+ return;
1362
+ }
1363
+ if (wire.type === "snapshot") {
1364
+ if (wire.seq <= seq) return;
1365
+ committed = wire.state;
1366
+ seq = wire.seq;
1367
+ for (const at of [...early.keys()]) if (at <= seq) early.delete(at);
1368
+ const next = early.get(seq + 1);
1369
+ if (next) {
1370
+ early.delete(seq + 1);
1371
+ applyCommit(seq + 1, next.action, next.opId);
1372
+ }
1373
+ notify();
1374
+ return;
1375
+ }
1376
+ });
1377
+ const dispatch = (action) => {
1378
+ if (closed) return;
1379
+ const opId = newMsgId();
1380
+ pending.push({ opId, action });
1381
+ notify();
1382
+ if (leader.getSnapshot().isLeader) sequence(action, opId);
1383
+ else
1384
+ bus.post({ v: 1, scope: "op", type: "propose", key, action, opId, clientId, kind: bus.kind });
1385
+ };
1386
+ askForSnapshot();
1387
+ return {
1388
+ clientId,
1389
+ getSnapshot: () => view,
1390
+ dispatch,
1391
+ subscribe(fn) {
1392
+ listeners.add(fn);
1393
+ return () => listeners.delete(fn);
1394
+ },
1395
+ pendingCount: () => pending.length,
1396
+ close() {
1397
+ if (closed) return;
1398
+ closed = true;
1399
+ unsubscribe();
1400
+ listeners.clear();
1401
+ if (ownsLeader) leader.close();
1402
+ bus.release();
1403
+ }
1404
+ };
1405
+ }
1406
+
1407
+ // src/presence.ts
1408
+ function createPresence(name, options = {}) {
1409
+ const pruneAfterMs = options.pruneAfterMs ?? 5e3;
1410
+ const probeGraceMs = options.probeGraceMs ?? 1e3;
1411
+ const bus = getBus(name, options);
1412
+ const peers = /* @__PURE__ */ new Map();
1413
+ const listeners = /* @__PURE__ */ new Set();
1414
+ let snapshot = [];
1415
+ let metadata = options.metadata;
1416
+ const same = (a, b) => {
1417
+ if (Object.is(a, b)) return true;
1418
+ try {
1419
+ return JSON.stringify(a) === JSON.stringify(b);
1420
+ } catch {
1421
+ return false;
1422
+ }
1423
+ };
1424
+ const self = () => ({
1425
+ id: bus.clientId,
1426
+ kind: bus.kind,
1427
+ lastSeen: Date.now(),
1428
+ ...metadata === void 0 ? {} : { metadata }
1429
+ });
1430
+ function notify() {
1431
+ const roster = [...peers.values()];
1432
+ snapshot = Object.freeze(options.includeSelf ? [self(), ...roster] : roster);
1433
+ for (const fn of listeners) fn();
1434
+ }
1435
+ const announce2 = () => bus.post({
1436
+ v: 1,
1437
+ scope: "presence",
1438
+ type: "hello",
1439
+ clientId: bus.clientId,
1440
+ kind: bus.kind,
1441
+ ...metadata === void 0 ? {} : { metadata }
1442
+ });
1443
+ const unsubscribe = bus.subscribe((wire) => {
1444
+ if (wire.clientId === bus.clientId) return;
1445
+ if (wire.scope === "presence" && wire.type === "bye") {
1446
+ if (peers.delete(wire.clientId)) notify();
1447
+ return;
1448
+ }
1449
+ const existing = peers.get(wire.clientId);
1450
+ const announced = wire.scope === "presence" ? wire.metadata : void 0;
1451
+ const next = announced === void 0 ? existing?.metadata : announced;
1452
+ peers.set(wire.clientId, {
1453
+ id: wire.clientId,
1454
+ kind: wire.kind,
1455
+ lastSeen: Date.now(),
1456
+ ...next === void 0 ? {} : { metadata: next }
1457
+ });
1458
+ if (!existing || !same(existing.metadata, next)) notify();
1459
+ });
1460
+ announce2();
1461
+ if (options.includeSelf) notify();
1462
+ const probed = /* @__PURE__ */ new Map();
1463
+ const prune = setInterval(
1464
+ () => {
1465
+ const now = Date.now();
1466
+ const silentSince = now - pruneAfterMs;
1467
+ let changed = false;
1468
+ let needProbe = false;
1469
+ for (const [id, peer] of peers) {
1470
+ if (peer.lastSeen >= silentSince) {
1471
+ probed.delete(id);
1472
+ continue;
1473
+ }
1474
+ const askedAt = probed.get(id);
1475
+ if (askedAt === void 0) {
1476
+ probed.set(id, now);
1477
+ needProbe = true;
1478
+ } else if (now - askedAt >= probeGraceMs) {
1479
+ peers.delete(id);
1480
+ probed.delete(id);
1481
+ changed = true;
1482
+ }
1483
+ }
1484
+ if (needProbe) announce2();
1485
+ if (changed) notify();
1486
+ },
1487
+ Math.max(250, Math.floor(Math.min(pruneAfterMs, probeGraceMs) / 2))
1488
+ );
1489
+ let closed = false;
1490
+ return {
1491
+ clientId: bus.clientId,
1492
+ getPeers: () => snapshot,
1493
+ setMetadata(next) {
1494
+ if (same(metadata, next)) return;
1495
+ metadata = next;
1496
+ announce2();
1497
+ notify();
1498
+ },
1499
+ subscribe(fn) {
1500
+ listeners.add(fn);
1501
+ return () => listeners.delete(fn);
1502
+ },
1503
+ close() {
1504
+ if (closed) return;
1505
+ closed = true;
1506
+ clearInterval(prune);
1507
+ unsubscribe();
1508
+ listeners.clear();
1509
+ bus.release();
1510
+ }
1511
+ };
1512
+ }
1513
+
1057
1514
  // src/persist-web-storage.ts
1058
1515
  function isPersisted(value) {
1059
1516
  if (typeof value !== "object" || value === null) return false;
@@ -1061,6 +1518,7 @@ function isPersisted(value) {
1061
1518
  return candidate.v === 1 && typeof candidate.state === "object" && typeof candidate.versions === "object";
1062
1519
  }
1063
1520
  function webStorageAdapter(storage, key, options = {}) {
1521
+ const serializer = options.serializer ?? jsonSerializer;
1064
1522
  const report = (error, operation) => {
1065
1523
  try {
1066
1524
  options.onError?.(error, operation);
@@ -1080,7 +1538,7 @@ function webStorageAdapter(storage, key, options = {}) {
1080
1538
  try {
1081
1539
  const raw = resolve("read")?.getItem(key);
1082
1540
  if (!raw) return void 0;
1083
- const parsed = JSON.parse(raw);
1541
+ const parsed = serializer.parse(raw);
1084
1542
  return isPersisted(parsed) ? parsed : void 0;
1085
1543
  } catch (error) {
1086
1544
  report(error, "read");
@@ -1089,7 +1547,7 @@ function webStorageAdapter(storage, key, options = {}) {
1089
1547
  },
1090
1548
  write(snapshot) {
1091
1549
  try {
1092
- resolve("write")?.setItem(key, JSON.stringify(snapshot));
1550
+ resolve("write")?.setItem(key, serializer.stringify(snapshot));
1093
1551
  } catch (error) {
1094
1552
  report(error, "write");
1095
1553
  }
@@ -1110,6 +1568,58 @@ function sessionStorageAdapter(key, options) {
1110
1568
  return webStorageAdapter(() => globalThis.sessionStorage, key, options);
1111
1569
  }
1112
1570
 
1571
+ // src/persist-indexeddb.ts
1572
+ var STORE = "state";
1573
+ var request = (req) => new Promise((resolve, reject) => {
1574
+ req.onsuccess = () => resolve(req.result);
1575
+ req.onerror = () => reject(req.error);
1576
+ });
1577
+ function indexedDbAdapter(key, options = {}) {
1578
+ const database = options.database ?? "use-everywhere";
1579
+ let open;
1580
+ const report = (error, operation) => {
1581
+ try {
1582
+ options.onError?.(error, operation);
1583
+ } catch {
1584
+ }
1585
+ };
1586
+ const db = () => open ?? (open = new Promise((resolve, reject) => {
1587
+ const req = indexedDB.open(database, 1);
1588
+ req.onupgradeneeded = () => req.result.createObjectStore(STORE);
1589
+ req.onsuccess = () => resolve(req.result);
1590
+ req.onerror = () => reject(req.error);
1591
+ req.onblocked = () => reject(new Error("use-everywhere: IndexedDB upgrade blocked"));
1592
+ }));
1593
+ const transact = async (mode, run) => {
1594
+ const connection = await db();
1595
+ return request(run(connection.transaction(STORE, mode).objectStore(STORE)));
1596
+ };
1597
+ return {
1598
+ async read() {
1599
+ try {
1600
+ return await transact("readonly", (store) => store.get(key));
1601
+ } catch (error) {
1602
+ report(error, "read");
1603
+ return void 0;
1604
+ }
1605
+ },
1606
+ async write(snapshot) {
1607
+ try {
1608
+ await transact("readwrite", (store) => store.put(snapshot, key));
1609
+ } catch (error) {
1610
+ report(error, "write");
1611
+ }
1612
+ },
1613
+ async remove() {
1614
+ try {
1615
+ await transact("readwrite", (store) => store.delete(key));
1616
+ } catch (error) {
1617
+ report(error, "remove");
1618
+ }
1619
+ }
1620
+ };
1621
+ }
1622
+
1113
1623
  // src/errors/handshake-timeout-error.ts
1114
1624
  var HandshakeTimeoutError = class extends Error {
1115
1625
  constructor(message = "handshake with the other window timed out") {
@@ -1344,6 +1854,25 @@ function connectToOpener(options) {
1344
1854
  }
1345
1855
  };
1346
1856
  }
1857
+
1858
+ // src/namespace.ts
1859
+ var SEPARATOR = ":";
1860
+ function createNamespace(namespace) {
1861
+ if (!namespace) {
1862
+ throw new TypeError(
1863
+ "use-everywhere: createNamespace() needs a non-empty name \u2014 an empty one would put every bus back on the shared defaults, which is what it exists to prevent."
1864
+ );
1865
+ }
1866
+ const busName = (name = DEFAULT_NAME) => `${namespace}${SEPARATOR}${name}`;
1867
+ return {
1868
+ name: namespace,
1869
+ busName,
1870
+ createSharedStore: (name, initial, options) => createSharedStore(busName(name), initial, options),
1871
+ createChannel: (name, options) => createChannel(busName(name), options),
1872
+ createPresence: (name, options) => createPresence(busName(name), options),
1873
+ createLeader: (name, options) => createLeader(busName(name), options)
1874
+ };
1875
+ }
1347
1876
  // Annotate the CommonJS export names for ESM import in node:
1348
1877
  0 && (module.exports = {
1349
1878
  BroadcastChannelTransport,
@@ -1352,18 +1881,24 @@ function connectToOpener(options) {
1352
1881
  HandshakeTimeoutError,
1353
1882
  NoopTransport,
1354
1883
  StorageTransport,
1884
+ WIRE_VERSION,
1355
1885
  WindowClosedError,
1356
1886
  connectToOpener,
1357
1887
  createChannel,
1358
1888
  createLeader,
1889
+ createNamespace,
1359
1890
  createPresence,
1891
+ createSharedReducer,
1360
1892
  createSharedStore,
1361
1893
  defaultTransport,
1362
1894
  enableDebug,
1363
1895
  getBusNames,
1364
1896
  getTransportKind,
1897
+ getWireSkew,
1898
+ indexedDbAdapter,
1365
1899
  isBroadcastChannelAvailable,
1366
1900
  isStorageEventAvailable,
1901
+ jsonSerializer,
1367
1902
  localStorageAdapter,
1368
1903
  newer,
1369
1904
  observeBus,