@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.js CHANGED
@@ -60,9 +60,11 @@ function randomHex(bytes) {
60
60
  for (const b of buf) out2 += b.toString(16).padStart(2, "0");
61
61
  return out2;
62
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
- );
63
+ if (process.env.NODE_ENV !== "production") {
64
+ devWarn(
65
+ "[use-everywhere] crypto.getRandomValues is unavailable; falling back to Math.random for ids. Cross-origin window nonces are not cryptographically strong in this environment."
66
+ );
67
+ }
66
68
  let out = "";
67
69
  while (out.length < bytes * 2)
68
70
  out += Math.floor(Math.random() * 256).toString(16).padStart(2, "0");
@@ -79,15 +81,18 @@ function newMsgId() {
79
81
  var PROTOCOL = 1;
80
82
  var TABLE = /* @__PURE__ */ Symbol.for(`use-everywhere.rendezvous.${PROTOCOL}`);
81
83
  var CENSUS = /* @__PURE__ */ Symbol.for("use-everywhere.rendezvous.census");
84
+ var SKEW = /* @__PURE__ */ Symbol.for("use-everywhere.rendezvous.skew");
82
85
  function announce() {
83
86
  const g = globalThis;
84
87
  const census = g[CENSUS] ?? (g[CENSUS] = { protocols: [] });
85
88
  if (census.protocols.includes(PROTOCOL)) return;
86
89
  census.protocols.push(PROTOCOL);
87
90
  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
+ if (process.env.NODE_ENV !== "production") {
92
+ devWarn(
93
+ `[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.`
94
+ );
95
+ }
91
96
  }
92
97
  }
93
98
  function busTable() {
@@ -95,6 +100,10 @@ function busTable() {
95
100
  const g = globalThis;
96
101
  return g[TABLE] ?? (g[TABLE] = /* @__PURE__ */ new Map());
97
102
  }
103
+ function skewLedger() {
104
+ const g = globalThis;
105
+ return g[SKEW] ?? (g[SKEW] = /* @__PURE__ */ new Map());
106
+ }
98
107
 
99
108
  // src/transport/broadcast-channel-transport.ts
100
109
  var BroadcastChannelTransport = class {
@@ -134,9 +143,38 @@ var NoopTransport = class {
134
143
  }
135
144
  };
136
145
 
146
+ // src/serializer.ts
147
+ function lossyType(raw, value) {
148
+ if (value === void 0) return raw === void 0 ? "undefined" : "undefined after toJSON";
149
+ if (raw instanceof Date) return "Date";
150
+ if (raw instanceof Map) return "Map";
151
+ if (raw instanceof Set) return "Set";
152
+ if (raw instanceof RegExp) return "RegExp";
153
+ if (ArrayBuffer.isView(raw)) return "TypedArray";
154
+ const type = typeof raw;
155
+ return type === "function" || type === "symbol" ? type : void 0;
156
+ }
157
+ var jsonSerializer = {
158
+ stringify(value) {
159
+ if (value === void 0) {
160
+ throw new TypeError("use-everywhere: cannot serialize undefined");
161
+ }
162
+ return JSON.stringify(value, function replacer(key, forJson) {
163
+ const type = lossyType(this[key], forJson);
164
+ if (type) {
165
+ throw new TypeError(
166
+ `use-everywhere: ${key ? `"${key}" is ` : ""}${type}, which JSON cannot round-trip. https://rxova.org/guides/serialization/`
167
+ );
168
+ }
169
+ return forJson;
170
+ });
171
+ },
172
+ parse: (text) => JSON.parse(text)
173
+ };
174
+
137
175
  // src/transport/storage-transport.ts
138
176
  var StorageTransport = class {
139
- constructor(name, storage = globalThis.localStorage) {
177
+ constructor(name, storage = globalThis.localStorage, serializer = jsonSerializer) {
140
178
  this.kind = "storage";
141
179
  this.listeners = /* @__PURE__ */ new Set();
142
180
  this.seq = 0;
@@ -145,13 +183,14 @@ var StorageTransport = class {
145
183
  "[use-everywhere] StorageTransport needs localStorage; workers have none. Use BroadcastChannel there."
146
184
  );
147
185
  }
186
+ this.serializer = serializer;
148
187
  this.key = `use-everywhere:bus:${name}`;
149
188
  this.storage = storage;
150
189
  this.onStorage = (event) => {
151
190
  if (event.key !== this.key || event.newValue === null) return;
152
191
  let payload;
153
192
  try {
154
- payload = JSON.parse(event.newValue);
193
+ payload = this.serializer.parse(event.newValue);
155
194
  } catch {
156
195
  return;
157
196
  }
@@ -160,7 +199,7 @@ var StorageTransport = class {
160
199
  addEventListener("storage", this.onStorage);
161
200
  }
162
201
  post(data) {
163
- const envelope = JSON.stringify({ seq: this.seq++, data }, rejectUnrepresentable);
202
+ const envelope = this.serializer.stringify({ seq: this.seq++, data });
164
203
  try {
165
204
  this.storage.setItem(this.key, envelope);
166
205
  this.storage.removeItem(this.key);
@@ -176,12 +215,6 @@ var StorageTransport = class {
176
215
  removeEventListener("storage", this.onStorage);
177
216
  }
178
217
  };
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
218
 
186
219
  // src/transport/default-transport.ts
187
220
  function isBroadcastChannelAvailable() {
@@ -203,27 +236,58 @@ function isStorageEventAvailable() {
203
236
  function defaultTransport(name) {
204
237
  if (isBroadcastChannelAvailable()) return new BroadcastChannelTransport(name);
205
238
  if (isStorageEventAvailable()) {
239
+ if (process.env.NODE_ENV !== "production") {
240
+ devWarn(
241
+ "[use-everywhere] no BroadcastChannel; using the storage-event fallback. Values serialise as JSON, not structured clone \u2014 keep them JSON-shaped."
242
+ );
243
+ }
244
+ return new StorageTransport(name);
245
+ }
246
+ if (process.env.NODE_ENV !== "production") {
206
247
  devWarn(
207
- "[use-everywhere] no BroadcastChannel; using the storage-event fallback. Values serialise as JSON, not structured clone \u2014 keep them JSON-shaped."
248
+ "[use-everywhere] no BroadcastChannel and no usable localStorage: nothing is shared between tabs. Storage is probably blocked."
208
249
  );
209
- return new StorageTransport(name);
210
250
  }
211
- devWarn(
212
- "[use-everywhere] no BroadcastChannel and no usable localStorage: nothing is shared between tabs. Storage is probably blocked."
213
- );
214
251
  return new NoopTransport();
215
252
  }
216
253
 
217
- // src/bus.ts
254
+ // src/wire.ts
255
+ var WIRE_VERSION = 1;
256
+ function hasEnvelopeShape(wire) {
257
+ return typeof wire.scope === "string" && typeof wire.type === "string" && typeof wire.clientId === "string";
258
+ }
218
259
  function isBusWire(data) {
219
260
  if (typeof data !== "object" || data === null) return false;
220
261
  const wire = data;
221
- return wire.v === 1 && typeof wire.scope === "string" && typeof wire.type === "string" && typeof wire.clientId === "string";
262
+ return wire.v === WIRE_VERSION && hasEnvelopeShape(wire);
263
+ }
264
+ function foreignWireVersion(data) {
265
+ if (typeof data !== "object" || data === null) return null;
266
+ const wire = data;
267
+ if (typeof wire.v !== "number" || wire.v === WIRE_VERSION) return null;
268
+ return hasEnvelopeShape(wire) ? wire.v : null;
269
+ }
270
+ function recordSkew(name, version) {
271
+ const seen = skewLedger();
272
+ const versions = seen.get(name) ?? /* @__PURE__ */ new Set();
273
+ seen.set(name, versions);
274
+ if (versions.has(version)) return;
275
+ versions.add(version);
276
+ if (process.env.NODE_ENV !== "production") {
277
+ devWarn(
278
+ `[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/`
279
+ );
280
+ }
281
+ }
282
+ function getWireSkew(name) {
283
+ return [...skewLedger().get(name) ?? []].sort((a, b) => a - b);
222
284
  }
285
+
286
+ // src/bus.ts
223
287
  function defaultKind() {
224
288
  return typeof document === "undefined" ? "worker" : "tab";
225
289
  }
226
- var SHARED_WITHIN_CLIENT = /* @__PURE__ */ new Set(["state", "event"]);
290
+ var SHARED_WITHIN_CLIENT = /* @__PURE__ */ new Set(["state", "event", "op"]);
227
291
  function createBusCore(name, options, onShutdown) {
228
292
  const transport = (options.transport ?? defaultTransport)(name);
229
293
  const clientId = newClientId();
@@ -244,6 +308,11 @@ function createBusCore(name, options, onShutdown) {
244
308
  if (SHARED_WITHIN_CLIENT.has(wire.scope)) deliver(wire, from);
245
309
  };
246
310
  const unsubscribe = transport.subscribe((data) => {
311
+ const foreign = foreignWireVersion(data);
312
+ if (foreign !== null) {
313
+ recordSkew(name, foreign);
314
+ return;
315
+ }
247
316
  if (!isBusWire(data)) return;
248
317
  if (data.clientId === clientId) return;
249
318
  emitBusEvent(name, "in", data);
@@ -336,57 +405,172 @@ function getBus(name, options = {}) {
336
405
  table.set(name, core);
337
406
  } else {
338
407
  if (options.heartbeatMs !== void 0 && options.heartbeatMs !== core.heartbeatMs) {
339
- devWarn(
340
- `[use-everywhere] bus "${name}": heartbeatMs ignored \u2014 the first creator fixes bus options`
341
- );
408
+ if (process.env.NODE_ENV !== "production") {
409
+ devWarn(
410
+ `[use-everywhere] bus "${name}": heartbeatMs ignored \u2014 the first creator fixes bus options`
411
+ );
412
+ }
342
413
  }
343
414
  if (options.kind !== void 0 && options.kind !== core.kind) {
344
- devWarn(`[use-everywhere] bus "${name}": kind ignored \u2014 the first creator fixes bus options`);
415
+ if (process.env.NODE_ENV !== "production") {
416
+ devWarn(
417
+ `[use-everywhere] bus "${name}": kind ignored \u2014 the first creator fixes bus options`
418
+ );
419
+ }
345
420
  }
346
421
  }
347
422
  return core.connect();
348
423
  }
349
424
 
425
+ // src/schema.ts
426
+ function validate(schema, payload) {
427
+ const props = schema["~standard"];
428
+ const result = props.validate(payload);
429
+ if (typeof result.then === "function") {
430
+ return { ok: false, issues: [`the "${props.vendor}" schema validates asynchronously`] };
431
+ }
432
+ const sync = result;
433
+ if (sync.issues) return { ok: false, issues: sync.issues.map((issue) => issue.message) };
434
+ return { ok: true, value: sync.value };
435
+ }
436
+ function createGate(name, schemas, onInvalid) {
437
+ if (!schemas) return void 0;
438
+ const check = (key, payload, direction) => {
439
+ const schema = schemas[key];
440
+ if (!schema) return void 0;
441
+ const result = validate(schema, payload);
442
+ if (result.ok) return void 0;
443
+ const info = { name, key, direction, payload, issues: result.issues };
444
+ if (onInvalid) onInvalid(info);
445
+ else if (process.env.NODE_ENV !== "production") {
446
+ devWarn(
447
+ `[use-everywhere] ${name}/${key}: ${direction}bound payload rejected by its schema \u2014 ${result.issues.join("; ")}. https://rxova.org/guides/validating-payloads/`
448
+ );
449
+ }
450
+ return result.issues;
451
+ };
452
+ return {
453
+ accepts: (key, payload) => !check(key, payload, "in"),
454
+ assert(key, payload) {
455
+ const issues = check(key, payload, "out");
456
+ if (issues) {
457
+ throw new TypeError(
458
+ `use-everywhere: ${name}/${key} \u2014 the value does not match its schema, so nothing was sent or applied. ${issues.join("; ")}`
459
+ );
460
+ }
461
+ }
462
+ };
463
+ }
464
+
350
465
  // src/channel.ts
351
466
  function createChannel(name, options = {}) {
352
467
  const bus = getBus(name, options);
353
468
  const handlers = /* @__PURE__ */ new Map();
469
+ const responders = /* @__PURE__ */ new Map();
470
+ const waiting = /* @__PURE__ */ new Map();
471
+ const gate = createGate(name, options.schema, options.onInvalid);
472
+ const deliver = (type, payload, meta) => {
473
+ const set = handlers.get(type);
474
+ if (!set) return;
475
+ for (const fn of [...set]) fn(payload, meta);
476
+ };
354
477
  const unsubscribe = bus.subscribe((wire) => {
355
478
  if (wire.scope !== "event") return;
356
- const set = handlers.get(wire.type);
357
- if (!set) return;
358
479
  const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
359
- for (const fn of set) fn(wire.payload, meta);
360
- });
361
- let closed = false;
362
- return {
363
- name,
364
- clientId: bus.clientId,
365
- post(type, payload) {
480
+ if (wire.replyTo !== void 0) {
481
+ const settle = waiting.get(wire.replyTo);
482
+ if (!settle) return;
483
+ waiting.delete(wire.replyTo);
484
+ settle(wire.payload);
485
+ return;
486
+ }
487
+ const responder = responders.get(wire.type);
488
+ if (responder) {
366
489
  bus.post({
367
490
  v: 1,
368
491
  scope: "event",
369
- type,
370
- payload,
492
+ type: wire.type,
493
+ payload: responder(wire.payload, meta),
371
494
  clientId: bus.clientId,
372
495
  kind: bus.kind,
373
- msgId: newMsgId()
496
+ msgId: newMsgId(),
497
+ replyTo: wire.msgId
374
498
  });
499
+ }
500
+ if (!handlers.has(wire.type)) return;
501
+ if (gate && !gate.accepts(wire.type, wire.payload)) return;
502
+ deliver(wire.type, wire.payload, meta);
503
+ });
504
+ let closed = false;
505
+ const send = (type, payload, msgId) => {
506
+ gate?.assert(type, payload);
507
+ bus.post({
508
+ v: 1,
509
+ scope: "event",
510
+ type,
511
+ payload,
512
+ clientId: bus.clientId,
513
+ kind: bus.kind,
514
+ msgId
515
+ });
516
+ };
517
+ return {
518
+ name,
519
+ clientId: bus.clientId,
520
+ post(type, payload, options2) {
521
+ send(type, payload, newMsgId());
522
+ if (options2?.echo) {
523
+ deliver(type, payload, { clientId: bus.clientId, kind: bus.kind, self: true });
524
+ }
375
525
  },
376
- on(type, handler) {
526
+ on(type, handler, options2) {
377
527
  let set = handlers.get(type);
378
528
  if (!set) {
379
529
  set = /* @__PURE__ */ new Set();
380
530
  handlers.set(type, set);
381
531
  }
382
- set.add(handler);
383
- return () => set.delete(handler);
532
+ const entry = options2?.once ? (payload, meta) => {
533
+ set.delete(entry);
534
+ handler(payload, meta);
535
+ } : handler;
536
+ set.add(entry);
537
+ return () => set.delete(entry);
538
+ },
539
+ ask(type, payload, options2) {
540
+ const msgId = newMsgId();
541
+ return new Promise((resolve, reject) => {
542
+ const timer = setTimeout(() => {
543
+ waiting.delete(msgId);
544
+ reject(
545
+ new Error(
546
+ `use-everywhere: nobody answered "${type}" on "${name}" within ${String(options2?.timeoutMs ?? 5e3)}ms`
547
+ )
548
+ );
549
+ }, options2?.timeoutMs ?? 5e3);
550
+ waiting.set(msgId, (value) => {
551
+ clearTimeout(timer);
552
+ resolve(value);
553
+ });
554
+ try {
555
+ send(type, payload, msgId);
556
+ } catch (error) {
557
+ clearTimeout(timer);
558
+ waiting.delete(msgId);
559
+ throw error;
560
+ }
561
+ });
562
+ },
563
+ answer(type, responder) {
564
+ responders.set(type, responder);
565
+ return () => responders.delete(type);
384
566
  },
385
567
  close() {
386
568
  if (closed) return;
387
569
  closed = true;
388
570
  unsubscribe();
389
571
  handlers.clear();
572
+ responders.clear();
573
+ waiting.clear();
390
574
  bus.release();
391
575
  }
392
576
  };
@@ -437,15 +621,18 @@ function createSharedStore(name, initial, options = {}) {
437
621
  if (onSharedBus) {
438
622
  const live = liveStores.get(name) ?? 0;
439
623
  if (live > 0) {
440
- devWarn(
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.`
442
- );
624
+ if (process.env.NODE_ENV !== "production") {
625
+ devWarn(
626
+ `[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.`
627
+ );
628
+ }
443
629
  }
444
630
  liveStores.set(name, live + 1);
445
631
  }
446
632
  const bus = getBus(name, options);
447
633
  const clientId = bus.clientId;
448
634
  const accept = options.accept;
635
+ const gate = createGate(name, options.schema, options.onInvalid);
449
636
  const state = { ...initial };
450
637
  const versions = {};
451
638
  for (const k in state) {
@@ -456,23 +643,61 @@ function createSharedStore(name, initial, options = {}) {
456
643
  let versionsSnapshot = Object.freeze({ ...versions });
457
644
  const listeners = /* @__PURE__ */ new Set();
458
645
  const keyListeners = /* @__PURE__ */ new Map();
459
- function notify(key, value, meta) {
646
+ let batchDepth = 0;
647
+ const batched = [];
648
+ const rebuild = () => {
460
649
  snapshot = Object.freeze({ ...state });
461
650
  versionsSnapshot = Object.freeze({ ...versions });
651
+ };
652
+ const emit = (key, value, meta) => {
462
653
  for (const fn of listeners) fn(key, value, meta);
463
654
  const set = keyListeners.get(key);
464
655
  if (set) for (const fn of set) fn();
656
+ };
657
+ function notify(key, value, meta) {
658
+ if (batchDepth > 0) {
659
+ batched.push([key, value, meta]);
660
+ return;
661
+ }
662
+ rebuild();
663
+ emit(key, value, meta);
664
+ }
665
+ function batch(fn) {
666
+ batchDepth++;
667
+ try {
668
+ return fn();
669
+ } finally {
670
+ batchDepth--;
671
+ if (batchDepth === 0 && batched.length > 0) {
672
+ const changes = batched.splice(0, batched.length);
673
+ rebuild();
674
+ for (const [key, value, meta] of changes) emit(key, value, meta);
675
+ }
676
+ }
465
677
  }
466
678
  function applyRemote(key, value, version, meta) {
467
679
  if (!newer(version, versions[key])) return;
680
+ if (gate && !gate.accepts(key, value)) return;
468
681
  versions[key] = version;
469
682
  state[key] = freezeShared(value);
470
683
  notify(key, value, meta);
471
684
  }
472
- const unsubscribe = bus.subscribe((wire) => {
473
- if (wire.scope !== "state") return;
474
- const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
475
- if (wire.type === "hello") {
685
+ const snapshotDelayMs = options.snapshotDelayMs ?? 40;
686
+ let snapshotTimer;
687
+ const written = () => Object.keys(versions).filter((k) => (versions[k]?.[0] ?? 0) > 0);
688
+ const coveredBy = (theirs) => written().every((key) => {
689
+ const mine = versions[key];
690
+ return mine !== void 0 && !newer(mine, theirs[key]);
691
+ });
692
+ const cancelSnapshot = () => {
693
+ clearTimeout(snapshotTimer);
694
+ snapshotTimer = void 0;
695
+ };
696
+ const scheduleSnapshot = () => {
697
+ if (written().length === 0) return;
698
+ if (snapshotTimer !== void 0) return;
699
+ snapshotTimer = setTimeout(() => {
700
+ snapshotTimer = void 0;
476
701
  bus.post({
477
702
  v: 1,
478
703
  scope: "state",
@@ -482,22 +707,36 @@ function createSharedStore(name, initial, options = {}) {
482
707
  state: { ...state },
483
708
  versions: { ...versions }
484
709
  });
710
+ }, Math.random() * snapshotDelayMs);
711
+ };
712
+ const unsubscribe = bus.subscribe((wire) => {
713
+ if (wire.scope !== "state") return;
714
+ const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
715
+ if (wire.type === "hello") {
716
+ scheduleSnapshot();
485
717
  return;
486
718
  }
487
719
  if (accept && !accept(meta)) return;
488
720
  if (wire.type === "patch") {
489
721
  if (typeof wire.key !== "string" || !isVersion(wire.version)) return;
490
722
  applyRemote(wire.key, wire.value, wire.version, meta);
491
- } else {
723
+ return;
724
+ }
725
+ if (wire.type === "snapshot") {
492
726
  const versions2 = wire.versions;
493
727
  if (typeof versions2 !== "object" || versions2 === null) return;
494
- for (const k in wire.state) {
495
- const version = versions2[k];
496
- if (isVersion(version)) applyRemote(k, wire.state[k], version, meta);
497
- }
728
+ if (coveredBy(versions2)) cancelSnapshot();
729
+ batch(() => {
730
+ for (const k in wire.state) {
731
+ const version = versions2[k];
732
+ if (isVersion(version)) applyRemote(k, wire.state[k], version, meta);
733
+ }
734
+ });
735
+ return;
498
736
  }
499
737
  });
500
738
  function setKey(key, value) {
739
+ gate?.assert(key, value);
501
740
  const version = [(versions[key]?.[0] ?? 0) + 1, clientId];
502
741
  try {
503
742
  bus.post({
@@ -529,29 +768,73 @@ function createSharedStore(name, initial, options = {}) {
529
768
  });
530
769
  const persist = options.persist;
531
770
  let flushPersist;
771
+ let settle = () => {
772
+ };
773
+ const hydrated = persist ? new Promise((resolve) => {
774
+ settle = resolve;
775
+ }) : Promise.resolve();
532
776
  if (persist) {
533
- const { adapter, keys, debounceMs = 100 } = persist;
777
+ const {
778
+ adapter,
779
+ keys,
780
+ debounceMs = 100,
781
+ version: schemaVersion = 0,
782
+ migrate,
783
+ onRestoreError
784
+ } = persist;
534
785
  const shouldPersist = (key) => !keys || keys.includes(key);
535
- const hydrate = (saved2) => {
536
- if (!saved2) return;
537
- for (const key in saved2.state) {
538
- const version = saved2.versions[key];
786
+ const refuse = (error) => {
787
+ if (onRestoreError) onRestoreError(error);
788
+ else if (process.env.NODE_ENV !== "production") {
789
+ devWarn(
790
+ `[use-everywhere] ${name}: persisted schema v${error.found} not restored, expected v${error.expected} (${error.reason}). https://rxova.org/guides/persistence/`
791
+ );
792
+ }
793
+ };
794
+ const reconcile = (saved2) => {
795
+ const found = saved2.schema ?? 0;
796
+ if (found === schemaVersion) return { state: saved2.state, migrated: false };
797
+ if (found > schemaVersion) {
798
+ refuse({ reason: "ahead", found, expected: schemaVersion });
799
+ return void 0;
800
+ }
801
+ if (!migrate) {
802
+ refuse({ reason: "no-migrate", found, expected: schemaVersion });
803
+ return void 0;
804
+ }
805
+ try {
806
+ return { state: migrate(saved2.state, found), migrated: true };
807
+ } catch (cause) {
808
+ refuse({ reason: "migrate-threw", found, expected: schemaVersion, cause });
809
+ return void 0;
810
+ }
811
+ };
812
+ const hydrate = (raw) => {
813
+ const reconciled = raw && reconcile(raw);
814
+ if (!reconciled || !raw) {
815
+ settle();
816
+ return;
817
+ }
818
+ const { state: restored, migrated } = reconciled;
819
+ for (const key in restored) {
820
+ const version = raw.versions[key] ?? (migrated ? [1, clientId] : void 0);
539
821
  if (!version || !shouldPersist(key)) continue;
540
- applyRemote(key, saved2.state[key], version, { clientId, kind: bus.kind, self: true });
822
+ applyRemote(key, restored[key], version, { clientId, kind: bus.kind, self: true });
541
823
  bus.post({
542
824
  v: 1,
543
825
  scope: "state",
544
826
  type: "patch",
545
827
  key,
546
- value: saved2.state[key],
828
+ value: restored[key],
547
829
  version,
548
830
  clientId,
549
831
  kind: bus.kind
550
832
  });
551
833
  }
834
+ settle();
552
835
  };
553
836
  const collect = () => {
554
- const out = { v: 1, state: {}, versions: {} };
837
+ const out = { v: 1, schema: schemaVersion, state: {}, versions: {} };
555
838
  for (const key in versions) {
556
839
  const version = versions[key];
557
840
  if (!version || version[0] === 0 || !shouldPersist(key)) continue;
@@ -591,6 +874,7 @@ function createSharedStore(name, initial, options = {}) {
591
874
  let storeClosed = false;
592
875
  return {
593
876
  clientId,
877
+ hydrated,
594
878
  state: proxy,
595
879
  getSnapshot: () => snapshot,
596
880
  getVersions: () => versionsSnapshot,
@@ -598,6 +882,9 @@ function createSharedStore(name, initial, options = {}) {
598
882
  const next = typeof value === "function" ? value(state[key]) : value;
599
883
  setKey(key, next);
600
884
  },
885
+ transaction(fn) {
886
+ return batch(fn);
887
+ },
601
888
  subscribe(fn) {
602
889
  listeners.add(fn);
603
890
  return () => listeners.delete(fn);
@@ -629,6 +916,7 @@ function createSharedStore(name, initial, options = {}) {
629
916
  if (live > 0) liveStores.set(name, live);
630
917
  else liveStores.delete(name);
631
918
  }
919
+ cancelSnapshot();
632
920
  if (hasWindow) removeEventListener("pageshow", onPageShow);
633
921
  if (flushPersist) {
634
922
  flushPersist();
@@ -644,83 +932,6 @@ function createSharedStore(name, initial, options = {}) {
644
932
  };
645
933
  }
646
934
 
647
- // src/presence.ts
648
- function createPresence(name, options = {}) {
649
- const pruneAfterMs = options.pruneAfterMs ?? 5e3;
650
- const probeGraceMs = options.probeGraceMs ?? 1e3;
651
- const bus = getBus(name, options);
652
- const peers = /* @__PURE__ */ new Map();
653
- const listeners = /* @__PURE__ */ new Set();
654
- let snapshot = [];
655
- function notify() {
656
- snapshot = Object.freeze([...peers.values()]);
657
- for (const fn of listeners) fn();
658
- }
659
- const unsubscribe = bus.subscribe((wire) => {
660
- if (wire.clientId === bus.clientId) return;
661
- if (wire.scope === "presence" && wire.type === "bye") {
662
- if (peers.delete(wire.clientId)) notify();
663
- return;
664
- }
665
- const existing = peers.get(wire.clientId);
666
- peers.set(wire.clientId, { id: wire.clientId, kind: wire.kind, lastSeen: Date.now() });
667
- if (!existing) notify();
668
- });
669
- bus.post({ v: 1, scope: "presence", type: "hello", clientId: bus.clientId, kind: bus.kind });
670
- const probed = /* @__PURE__ */ new Map();
671
- const prune = setInterval(
672
- () => {
673
- const now = Date.now();
674
- const silentSince = now - pruneAfterMs;
675
- let changed = false;
676
- let needProbe = false;
677
- for (const [id, peer] of peers) {
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) {
687
- peers.delete(id);
688
- probed.delete(id);
689
- changed = true;
690
- }
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
- }
701
- if (changed) notify();
702
- },
703
- Math.max(250, Math.floor(Math.min(pruneAfterMs, probeGraceMs) / 2))
704
- );
705
- let closed = false;
706
- return {
707
- clientId: bus.clientId,
708
- getPeers: () => snapshot,
709
- subscribe(fn) {
710
- listeners.add(fn);
711
- return () => listeners.delete(fn);
712
- },
713
- close() {
714
- if (closed) return;
715
- closed = true;
716
- clearInterval(prune);
717
- unsubscribe();
718
- listeners.clear();
719
- bus.release();
720
- }
721
- };
722
- }
723
-
724
935
  // src/leader-web-locks.ts
725
936
  var NO_LEADER = Object.freeze({ leaderId: null, isLeader: false });
726
937
  function createWebLocksLeader(name, options, locks) {
@@ -1005,6 +1216,246 @@ function createHeartbeatLeader(name, options) {
1005
1216
  };
1006
1217
  }
1007
1218
 
1219
+ // src/shared-reducer.ts
1220
+ function createSharedReducer(name, reducer, initial, options = {}) {
1221
+ const key = options.key ?? "default";
1222
+ const bus = getBus(name, options);
1223
+ const clientId = bus.clientId;
1224
+ const leader = options.leader ?? createLeader(name, {
1225
+ ...options.leaderOptions,
1226
+ // Spread conditionally: `exactOptionalPropertyTypes` distinguishes an
1227
+ // absent transport from one explicitly set to undefined, and the leader
1228
+ // must default its own rather than be handed a hole.
1229
+ ...options.transport ? { transport: options.transport } : {}
1230
+ });
1231
+ const ownsLeader = !options.leader;
1232
+ let committed = initial;
1233
+ let seq = 0;
1234
+ let pending = [];
1235
+ const early = /* @__PURE__ */ new Map();
1236
+ let lastIssued = 0;
1237
+ let view = committed;
1238
+ const listeners = /* @__PURE__ */ new Set();
1239
+ let closed = false;
1240
+ const notify = () => {
1241
+ const next = pending.reduce((state, op) => reducer(state, op.action), committed);
1242
+ if (Object.is(next, view)) return;
1243
+ view = next;
1244
+ for (const fn of listeners) fn();
1245
+ };
1246
+ const askForSnapshot = () => bus.post({ v: 1, scope: "op", type: "hello", key, clientId, kind: bus.kind });
1247
+ const receiveCommit = (at, action, opId) => {
1248
+ if (at <= seq) return;
1249
+ if (at > seq + 1) {
1250
+ early.set(at, { action, opId });
1251
+ askForSnapshot();
1252
+ return;
1253
+ }
1254
+ applyCommit(at, action, opId);
1255
+ notify();
1256
+ };
1257
+ const sequence = (action, opId) => {
1258
+ lastIssued = Math.max(lastIssued, seq) + 1;
1259
+ const at = lastIssued;
1260
+ bus.post({
1261
+ v: 1,
1262
+ scope: "op",
1263
+ type: "commit",
1264
+ key,
1265
+ action,
1266
+ opId,
1267
+ seq: at,
1268
+ clientId,
1269
+ kind: bus.kind
1270
+ });
1271
+ receiveCommit(at, action, opId);
1272
+ };
1273
+ const applyCommit = (at, action, opId) => {
1274
+ committed = reducer(committed, action);
1275
+ seq = at;
1276
+ pending = pending.filter((op) => op.opId !== opId);
1277
+ const next = early.get(seq + 1);
1278
+ if (next) {
1279
+ early.delete(seq + 1);
1280
+ applyCommit(seq + 1, next.action, next.opId);
1281
+ }
1282
+ };
1283
+ const unsubscribe = bus.subscribe((wire) => {
1284
+ if (wire.scope !== "op" || wire.key !== key) return;
1285
+ if (wire.type === "propose") {
1286
+ if (!leader.getSnapshot().isLeader) return;
1287
+ sequence(wire.action, wire.opId);
1288
+ return;
1289
+ }
1290
+ if (wire.type === "commit") {
1291
+ receiveCommit(wire.seq, wire.action, wire.opId);
1292
+ return;
1293
+ }
1294
+ if (wire.type === "hello") {
1295
+ if (seq === 0) return;
1296
+ bus.post({
1297
+ v: 1,
1298
+ scope: "op",
1299
+ type: "snapshot",
1300
+ key,
1301
+ state: committed,
1302
+ seq,
1303
+ clientId,
1304
+ kind: bus.kind
1305
+ });
1306
+ return;
1307
+ }
1308
+ if (wire.type === "snapshot") {
1309
+ if (wire.seq <= seq) return;
1310
+ committed = wire.state;
1311
+ seq = wire.seq;
1312
+ for (const at of [...early.keys()]) if (at <= seq) early.delete(at);
1313
+ const next = early.get(seq + 1);
1314
+ if (next) {
1315
+ early.delete(seq + 1);
1316
+ applyCommit(seq + 1, next.action, next.opId);
1317
+ }
1318
+ notify();
1319
+ return;
1320
+ }
1321
+ });
1322
+ const dispatch = (action) => {
1323
+ if (closed) return;
1324
+ const opId = newMsgId();
1325
+ pending.push({ opId, action });
1326
+ notify();
1327
+ if (leader.getSnapshot().isLeader) sequence(action, opId);
1328
+ else
1329
+ bus.post({ v: 1, scope: "op", type: "propose", key, action, opId, clientId, kind: bus.kind });
1330
+ };
1331
+ askForSnapshot();
1332
+ return {
1333
+ clientId,
1334
+ getSnapshot: () => view,
1335
+ dispatch,
1336
+ subscribe(fn) {
1337
+ listeners.add(fn);
1338
+ return () => listeners.delete(fn);
1339
+ },
1340
+ pendingCount: () => pending.length,
1341
+ close() {
1342
+ if (closed) return;
1343
+ closed = true;
1344
+ unsubscribe();
1345
+ listeners.clear();
1346
+ if (ownsLeader) leader.close();
1347
+ bus.release();
1348
+ }
1349
+ };
1350
+ }
1351
+
1352
+ // src/presence.ts
1353
+ function createPresence(name, options = {}) {
1354
+ const pruneAfterMs = options.pruneAfterMs ?? 5e3;
1355
+ const probeGraceMs = options.probeGraceMs ?? 1e3;
1356
+ const bus = getBus(name, options);
1357
+ const peers = /* @__PURE__ */ new Map();
1358
+ const listeners = /* @__PURE__ */ new Set();
1359
+ let snapshot = [];
1360
+ let metadata = options.metadata;
1361
+ const same = (a, b) => {
1362
+ if (Object.is(a, b)) return true;
1363
+ try {
1364
+ return JSON.stringify(a) === JSON.stringify(b);
1365
+ } catch {
1366
+ return false;
1367
+ }
1368
+ };
1369
+ const self = () => ({
1370
+ id: bus.clientId,
1371
+ kind: bus.kind,
1372
+ lastSeen: Date.now(),
1373
+ ...metadata === void 0 ? {} : { metadata }
1374
+ });
1375
+ function notify() {
1376
+ const roster = [...peers.values()];
1377
+ snapshot = Object.freeze(options.includeSelf ? [self(), ...roster] : roster);
1378
+ for (const fn of listeners) fn();
1379
+ }
1380
+ const announce2 = () => bus.post({
1381
+ v: 1,
1382
+ scope: "presence",
1383
+ type: "hello",
1384
+ clientId: bus.clientId,
1385
+ kind: bus.kind,
1386
+ ...metadata === void 0 ? {} : { metadata }
1387
+ });
1388
+ const unsubscribe = bus.subscribe((wire) => {
1389
+ if (wire.clientId === bus.clientId) return;
1390
+ if (wire.scope === "presence" && wire.type === "bye") {
1391
+ if (peers.delete(wire.clientId)) notify();
1392
+ return;
1393
+ }
1394
+ const existing = peers.get(wire.clientId);
1395
+ const announced = wire.scope === "presence" ? wire.metadata : void 0;
1396
+ const next = announced === void 0 ? existing?.metadata : announced;
1397
+ peers.set(wire.clientId, {
1398
+ id: wire.clientId,
1399
+ kind: wire.kind,
1400
+ lastSeen: Date.now(),
1401
+ ...next === void 0 ? {} : { metadata: next }
1402
+ });
1403
+ if (!existing || !same(existing.metadata, next)) notify();
1404
+ });
1405
+ announce2();
1406
+ if (options.includeSelf) notify();
1407
+ const probed = /* @__PURE__ */ new Map();
1408
+ const prune = setInterval(
1409
+ () => {
1410
+ const now = Date.now();
1411
+ const silentSince = now - pruneAfterMs;
1412
+ let changed = false;
1413
+ let needProbe = false;
1414
+ for (const [id, peer] of peers) {
1415
+ if (peer.lastSeen >= silentSince) {
1416
+ probed.delete(id);
1417
+ continue;
1418
+ }
1419
+ const askedAt = probed.get(id);
1420
+ if (askedAt === void 0) {
1421
+ probed.set(id, now);
1422
+ needProbe = true;
1423
+ } else if (now - askedAt >= probeGraceMs) {
1424
+ peers.delete(id);
1425
+ probed.delete(id);
1426
+ changed = true;
1427
+ }
1428
+ }
1429
+ if (needProbe) announce2();
1430
+ if (changed) notify();
1431
+ },
1432
+ Math.max(250, Math.floor(Math.min(pruneAfterMs, probeGraceMs) / 2))
1433
+ );
1434
+ let closed = false;
1435
+ return {
1436
+ clientId: bus.clientId,
1437
+ getPeers: () => snapshot,
1438
+ setMetadata(next) {
1439
+ if (same(metadata, next)) return;
1440
+ metadata = next;
1441
+ announce2();
1442
+ notify();
1443
+ },
1444
+ subscribe(fn) {
1445
+ listeners.add(fn);
1446
+ return () => listeners.delete(fn);
1447
+ },
1448
+ close() {
1449
+ if (closed) return;
1450
+ closed = true;
1451
+ clearInterval(prune);
1452
+ unsubscribe();
1453
+ listeners.clear();
1454
+ bus.release();
1455
+ }
1456
+ };
1457
+ }
1458
+
1008
1459
  // src/persist-web-storage.ts
1009
1460
  function isPersisted(value) {
1010
1461
  if (typeof value !== "object" || value === null) return false;
@@ -1012,6 +1463,7 @@ function isPersisted(value) {
1012
1463
  return candidate.v === 1 && typeof candidate.state === "object" && typeof candidate.versions === "object";
1013
1464
  }
1014
1465
  function webStorageAdapter(storage, key, options = {}) {
1466
+ const serializer = options.serializer ?? jsonSerializer;
1015
1467
  const report = (error, operation) => {
1016
1468
  try {
1017
1469
  options.onError?.(error, operation);
@@ -1031,7 +1483,7 @@ function webStorageAdapter(storage, key, options = {}) {
1031
1483
  try {
1032
1484
  const raw = resolve("read")?.getItem(key);
1033
1485
  if (!raw) return void 0;
1034
- const parsed = JSON.parse(raw);
1486
+ const parsed = serializer.parse(raw);
1035
1487
  return isPersisted(parsed) ? parsed : void 0;
1036
1488
  } catch (error) {
1037
1489
  report(error, "read");
@@ -1040,7 +1492,7 @@ function webStorageAdapter(storage, key, options = {}) {
1040
1492
  },
1041
1493
  write(snapshot) {
1042
1494
  try {
1043
- resolve("write")?.setItem(key, JSON.stringify(snapshot));
1495
+ resolve("write")?.setItem(key, serializer.stringify(snapshot));
1044
1496
  } catch (error) {
1045
1497
  report(error, "write");
1046
1498
  }
@@ -1061,6 +1513,58 @@ function sessionStorageAdapter(key, options) {
1061
1513
  return webStorageAdapter(() => globalThis.sessionStorage, key, options);
1062
1514
  }
1063
1515
 
1516
+ // src/persist-indexeddb.ts
1517
+ var STORE = "state";
1518
+ var request = (req) => new Promise((resolve, reject) => {
1519
+ req.onsuccess = () => resolve(req.result);
1520
+ req.onerror = () => reject(req.error);
1521
+ });
1522
+ function indexedDbAdapter(key, options = {}) {
1523
+ const database = options.database ?? "use-everywhere";
1524
+ let open;
1525
+ const report = (error, operation) => {
1526
+ try {
1527
+ options.onError?.(error, operation);
1528
+ } catch {
1529
+ }
1530
+ };
1531
+ const db = () => open ?? (open = new Promise((resolve, reject) => {
1532
+ const req = indexedDB.open(database, 1);
1533
+ req.onupgradeneeded = () => req.result.createObjectStore(STORE);
1534
+ req.onsuccess = () => resolve(req.result);
1535
+ req.onerror = () => reject(req.error);
1536
+ req.onblocked = () => reject(new Error("use-everywhere: IndexedDB upgrade blocked"));
1537
+ }));
1538
+ const transact = async (mode, run) => {
1539
+ const connection = await db();
1540
+ return request(run(connection.transaction(STORE, mode).objectStore(STORE)));
1541
+ };
1542
+ return {
1543
+ async read() {
1544
+ try {
1545
+ return await transact("readonly", (store) => store.get(key));
1546
+ } catch (error) {
1547
+ report(error, "read");
1548
+ return void 0;
1549
+ }
1550
+ },
1551
+ async write(snapshot) {
1552
+ try {
1553
+ await transact("readwrite", (store) => store.put(snapshot, key));
1554
+ } catch (error) {
1555
+ report(error, "write");
1556
+ }
1557
+ },
1558
+ async remove() {
1559
+ try {
1560
+ await transact("readwrite", (store) => store.delete(key));
1561
+ } catch (error) {
1562
+ report(error, "remove");
1563
+ }
1564
+ }
1565
+ };
1566
+ }
1567
+
1064
1568
  // src/errors/handshake-timeout-error.ts
1065
1569
  var HandshakeTimeoutError = class extends Error {
1066
1570
  constructor(message = "handshake with the other window timed out") {
@@ -1295,6 +1799,25 @@ function connectToOpener(options) {
1295
1799
  }
1296
1800
  };
1297
1801
  }
1802
+
1803
+ // src/namespace.ts
1804
+ var SEPARATOR = ":";
1805
+ function createNamespace(namespace) {
1806
+ if (!namespace) {
1807
+ throw new TypeError(
1808
+ "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."
1809
+ );
1810
+ }
1811
+ const busName = (name = DEFAULT_NAME) => `${namespace}${SEPARATOR}${name}`;
1812
+ return {
1813
+ name: namespace,
1814
+ busName,
1815
+ createSharedStore: (name, initial, options) => createSharedStore(busName(name), initial, options),
1816
+ createChannel: (name, options) => createChannel(busName(name), options),
1817
+ createPresence: (name, options) => createPresence(busName(name), options),
1818
+ createLeader: (name, options) => createLeader(busName(name), options)
1819
+ };
1820
+ }
1298
1821
  export {
1299
1822
  BroadcastChannelTransport,
1300
1823
  CID_PARAM,
@@ -1302,18 +1825,24 @@ export {
1302
1825
  HandshakeTimeoutError,
1303
1826
  NoopTransport,
1304
1827
  StorageTransport,
1828
+ WIRE_VERSION,
1305
1829
  WindowClosedError,
1306
1830
  connectToOpener,
1307
1831
  createChannel,
1308
1832
  createLeader,
1833
+ createNamespace,
1309
1834
  createPresence,
1835
+ createSharedReducer,
1310
1836
  createSharedStore,
1311
1837
  defaultTransport,
1312
1838
  enableDebug,
1313
1839
  getBusNames,
1314
1840
  getTransportKind,
1841
+ getWireSkew,
1842
+ indexedDbAdapter,
1315
1843
  isBroadcastChannelAvailable,
1316
1844
  isStorageEventAvailable,
1845
+ jsonSerializer,
1317
1846
  localStorageAdapter,
1318
1847
  newer,
1319
1848
  observeBus,