@use-everywhere/core 0.7.0 → 0.9.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
@@ -1,6 +1,26 @@
1
1
  // src/defaults.ts
2
2
  var DEFAULT_NAME = "use-everywhere";
3
3
 
4
+ // src/dev.ts
5
+ var inDev = false;
6
+ try {
7
+ inDev = process.env.NODE_ENV !== "production";
8
+ } catch {
9
+ }
10
+ var warned = /* @__PURE__ */ new Set();
11
+ var DOCS = "https://rxova.org/packages/use-everywhere/errors";
12
+ function diagnostic(code, message) {
13
+ return `[use-everywhere] ${code}: ${message}
14
+ \u2192 ${DOCS}/#${code.toLowerCase()}`;
15
+ }
16
+ function devWarn(code, message) {
17
+ if (!inDev) return;
18
+ const line = diagnostic(code, message);
19
+ if (warned.has(line)) return;
20
+ warned.add(line);
21
+ console.warn(line);
22
+ }
23
+
4
24
  // src/debug.ts
5
25
  var observers = /* @__PURE__ */ new Map();
6
26
  function emitBusEvent(name, direction, wire) {
@@ -11,7 +31,7 @@ function emitBusEvent(name, direction, wire) {
11
31
  try {
12
32
  fn(event);
13
33
  } catch (error) {
14
- console.error(`[use-everywhere] a bus observer for "${name}" threw`, error);
34
+ console.error(diagnostic("UE1012", `a bus observer for "${name}" threw`), error);
15
35
  }
16
36
  }
17
37
  }
@@ -38,19 +58,6 @@ function enableDebug(options = {}) {
38
58
  });
39
59
  }
40
60
 
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
-
54
61
  // src/ids.ts
55
62
  function randomHex(bytes) {
56
63
  const crypto = globalThis.crypto;
@@ -60,9 +67,12 @@ function randomHex(bytes) {
60
67
  for (const b of buf) out2 += b.toString(16).padStart(2, "0");
61
68
  return out2;
62
69
  }
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
- );
70
+ if (process.env.NODE_ENV !== "production") {
71
+ devWarn(
72
+ "UE1006",
73
+ "crypto.getRandomValues is unavailable; falling back to Math.random for ids. Cross-origin window nonces are not cryptographically strong in this environment."
74
+ );
75
+ }
66
76
  let out = "";
67
77
  while (out.length < bytes * 2)
68
78
  out += Math.floor(Math.random() * 256).toString(16).padStart(2, "0");
@@ -79,15 +89,19 @@ function newMsgId() {
79
89
  var PROTOCOL = 1;
80
90
  var TABLE = /* @__PURE__ */ Symbol.for(`use-everywhere.rendezvous.${PROTOCOL}`);
81
91
  var CENSUS = /* @__PURE__ */ Symbol.for("use-everywhere.rendezvous.census");
92
+ var SKEW = /* @__PURE__ */ Symbol.for("use-everywhere.rendezvous.skew");
82
93
  function announce() {
83
94
  const g = globalThis;
84
95
  const census = g[CENSUS] ?? (g[CENSUS] = { protocols: [] });
85
96
  if (census.protocols.includes(PROTOCOL)) return;
86
97
  census.protocols.push(PROTOCOL);
87
98
  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
- );
99
+ if (process.env.NODE_ENV !== "production") {
100
+ devWarn(
101
+ "UE1008",
102
+ `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.`
103
+ );
104
+ }
91
105
  }
92
106
  }
93
107
  function busTable() {
@@ -95,6 +109,10 @@ function busTable() {
95
109
  const g = globalThis;
96
110
  return g[TABLE] ?? (g[TABLE] = /* @__PURE__ */ new Map());
97
111
  }
112
+ function skewLedger() {
113
+ const g = globalThis;
114
+ return g[SKEW] ?? (g[SKEW] = /* @__PURE__ */ new Map());
115
+ }
98
116
 
99
117
  // src/transport/broadcast-channel-transport.ts
100
118
  var BroadcastChannelTransport = class {
@@ -134,24 +152,57 @@ var NoopTransport = class {
134
152
  }
135
153
  };
136
154
 
155
+ // src/serializer.ts
156
+ function lossyType(raw, value) {
157
+ if (value === void 0) return raw === void 0 ? "undefined" : "undefined after toJSON";
158
+ if (raw instanceof Date) return "Date";
159
+ if (raw instanceof Map) return "Map";
160
+ if (raw instanceof Set) return "Set";
161
+ if (raw instanceof RegExp) return "RegExp";
162
+ if (ArrayBuffer.isView(raw)) return "TypedArray";
163
+ const type = typeof raw;
164
+ return type === "function" || type === "symbol" ? type : void 0;
165
+ }
166
+ var jsonSerializer = {
167
+ stringify(value) {
168
+ if (value === void 0) {
169
+ throw new TypeError("use-everywhere: cannot serialize undefined");
170
+ }
171
+ return JSON.stringify(value, function replacer(key, forJson) {
172
+ const type = lossyType(this[key], forJson);
173
+ if (type) {
174
+ throw new TypeError(
175
+ `use-everywhere: ${key ? `"${key}" is ` : ""}${type}, which JSON cannot round-trip. https://rxova.org/packages/use-everywhere/guides/serialization/`
176
+ );
177
+ }
178
+ return forJson;
179
+ });
180
+ },
181
+ parse: (text) => JSON.parse(text)
182
+ };
183
+
137
184
  // src/transport/storage-transport.ts
138
185
  var StorageTransport = class {
139
- constructor(name, storage = globalThis.localStorage) {
186
+ constructor(name, storage = globalThis.localStorage, serializer = jsonSerializer) {
140
187
  this.kind = "storage";
141
188
  this.listeners = /* @__PURE__ */ new Set();
142
189
  this.seq = 0;
143
190
  if (!storage) {
144
191
  throw new Error(
145
- "[use-everywhere] StorageTransport needs localStorage; workers have none. Use BroadcastChannel there."
192
+ diagnostic(
193
+ "UE1011",
194
+ "StorageTransport needs localStorage; workers have none. Use BroadcastChannel there."
195
+ )
146
196
  );
147
197
  }
198
+ this.serializer = serializer;
148
199
  this.key = `use-everywhere:bus:${name}`;
149
200
  this.storage = storage;
150
201
  this.onStorage = (event) => {
151
202
  if (event.key !== this.key || event.newValue === null) return;
152
203
  let payload;
153
204
  try {
154
- payload = JSON.parse(event.newValue);
205
+ payload = this.serializer.parse(event.newValue);
155
206
  } catch {
156
207
  return;
157
208
  }
@@ -160,7 +211,7 @@ var StorageTransport = class {
160
211
  addEventListener("storage", this.onStorage);
161
212
  }
162
213
  post(data) {
163
- const envelope = JSON.stringify({ seq: this.seq++, data }, rejectUnrepresentable);
214
+ const envelope = this.serializer.stringify({ seq: this.seq++, data });
164
215
  try {
165
216
  this.storage.setItem(this.key, envelope);
166
217
  this.storage.removeItem(this.key);
@@ -176,12 +227,6 @@ var StorageTransport = class {
176
227
  removeEventListener("storage", this.onStorage);
177
228
  }
178
229
  };
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
230
 
186
231
  // src/transport/default-transport.ts
187
232
  function isBroadcastChannelAvailable() {
@@ -203,27 +248,61 @@ function isStorageEventAvailable() {
203
248
  function defaultTransport(name) {
204
249
  if (isBroadcastChannelAvailable()) return new BroadcastChannelTransport(name);
205
250
  if (isStorageEventAvailable()) {
251
+ if (process.env.NODE_ENV !== "production") {
252
+ devWarn(
253
+ "UE1009",
254
+ "no BroadcastChannel; using the storage-event fallback. Values serialise as JSON, not structured clone \u2014 keep them JSON-shaped."
255
+ );
256
+ }
257
+ return new StorageTransport(name);
258
+ }
259
+ if (process.env.NODE_ENV !== "production") {
206
260
  devWarn(
207
- "[use-everywhere] no BroadcastChannel; using the storage-event fallback. Values serialise as JSON, not structured clone \u2014 keep them JSON-shaped."
261
+ "UE1010",
262
+ "no BroadcastChannel and no usable localStorage: nothing is shared between tabs. Storage is probably blocked."
208
263
  );
209
- return new StorageTransport(name);
210
264
  }
211
- devWarn(
212
- "[use-everywhere] no BroadcastChannel and no usable localStorage: nothing is shared between tabs. Storage is probably blocked."
213
- );
214
265
  return new NoopTransport();
215
266
  }
216
267
 
217
- // src/bus.ts
268
+ // src/wire.ts
269
+ var WIRE_VERSION = 1;
270
+ function hasEnvelopeShape(wire) {
271
+ return typeof wire.scope === "string" && typeof wire.type === "string" && typeof wire.clientId === "string";
272
+ }
218
273
  function isBusWire(data) {
219
274
  if (typeof data !== "object" || data === null) return false;
220
275
  const wire = data;
221
- return wire.v === 1 && typeof wire.scope === "string" && typeof wire.type === "string" && typeof wire.clientId === "string";
276
+ return wire.v === WIRE_VERSION && hasEnvelopeShape(wire);
222
277
  }
278
+ function foreignWireVersion(data) {
279
+ if (typeof data !== "object" || data === null) return null;
280
+ const wire = data;
281
+ if (typeof wire.v !== "number" || wire.v === WIRE_VERSION) return null;
282
+ return hasEnvelopeShape(wire) ? wire.v : null;
283
+ }
284
+ function recordSkew(name, version) {
285
+ const seen = skewLedger();
286
+ const versions = seen.get(name) ?? /* @__PURE__ */ new Set();
287
+ seen.set(name, versions);
288
+ if (versions.has(version)) return;
289
+ versions.add(version);
290
+ if (process.env.NODE_ENV !== "production") {
291
+ devWarn(
292
+ "UE1007",
293
+ `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/packages/use-everywhere/under-the-hood/version-skew/`
294
+ );
295
+ }
296
+ }
297
+ function getWireSkew(name) {
298
+ return [...skewLedger().get(name) ?? []].sort((a, b) => a - b);
299
+ }
300
+
301
+ // src/bus.ts
223
302
  function defaultKind() {
224
303
  return typeof document === "undefined" ? "worker" : "tab";
225
304
  }
226
- var SHARED_WITHIN_CLIENT = /* @__PURE__ */ new Set(["state", "event"]);
305
+ var SHARED_WITHIN_CLIENT = /* @__PURE__ */ new Set(["state", "event", "op"]);
227
306
  function createBusCore(name, options, onShutdown) {
228
307
  const transport = (options.transport ?? defaultTransport)(name);
229
308
  const clientId = newClientId();
@@ -244,6 +323,11 @@ function createBusCore(name, options, onShutdown) {
244
323
  if (SHARED_WITHIN_CLIENT.has(wire.scope)) deliver(wire, from);
245
324
  };
246
325
  const unsubscribe = transport.subscribe((data) => {
326
+ const foreign = foreignWireVersion(data);
327
+ if (foreign !== null) {
328
+ recordSkew(name, foreign);
329
+ return;
330
+ }
247
331
  if (!isBusWire(data)) return;
248
332
  if (data.clientId === clientId) return;
249
333
  emitBusEvent(name, "in", data);
@@ -336,57 +420,172 @@ function getBus(name, options = {}) {
336
420
  table.set(name, core);
337
421
  } else {
338
422
  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
- );
423
+ if (process.env.NODE_ENV !== "production") {
424
+ devWarn(
425
+ "UE1004",
426
+ `bus "${name}": heartbeatMs ignored \u2014 the first creator fixes bus options`
427
+ );
428
+ }
342
429
  }
343
430
  if (options.kind !== void 0 && options.kind !== core.kind) {
344
- devWarn(`[use-everywhere] bus "${name}": kind ignored \u2014 the first creator fixes bus options`);
431
+ if (process.env.NODE_ENV !== "production") {
432
+ devWarn("UE1005", `bus "${name}": kind ignored \u2014 the first creator fixes bus options`);
433
+ }
345
434
  }
346
435
  }
347
436
  return core.connect();
348
437
  }
349
438
 
439
+ // src/schema.ts
440
+ function validate(schema, payload) {
441
+ const props = schema["~standard"];
442
+ const result = props.validate(payload);
443
+ if (typeof result.then === "function") {
444
+ return { ok: false, issues: [`the "${props.vendor}" schema validates asynchronously`] };
445
+ }
446
+ const sync = result;
447
+ if (sync.issues) return { ok: false, issues: sync.issues.map((issue) => issue.message) };
448
+ return { ok: true, value: sync.value };
449
+ }
450
+ function createGate(name, schemas, onInvalid) {
451
+ if (!schemas) return void 0;
452
+ const check = (key, payload, direction) => {
453
+ const schema = schemas[key];
454
+ if (!schema) return void 0;
455
+ const result = validate(schema, payload);
456
+ if (result.ok) return void 0;
457
+ const info = { name, key, direction, payload, issues: result.issues };
458
+ if (onInvalid) onInvalid(info);
459
+ else if (process.env.NODE_ENV !== "production") {
460
+ devWarn(
461
+ "UE1003",
462
+ `${name}/${key}: ${direction}bound payload rejected by its schema \u2014 ${result.issues.join("; ")}. https://rxova.org/packages/use-everywhere/guides/validating-payloads/`
463
+ );
464
+ }
465
+ return result.issues;
466
+ };
467
+ return {
468
+ accepts: (key, payload) => !check(key, payload, "in"),
469
+ assert(key, payload) {
470
+ const issues = check(key, payload, "out");
471
+ if (issues) {
472
+ throw new TypeError(
473
+ `use-everywhere: ${name}/${key} \u2014 the value does not match its schema, so nothing was sent or applied. ${issues.join("; ")}`
474
+ );
475
+ }
476
+ }
477
+ };
478
+ }
479
+
350
480
  // src/channel.ts
351
481
  function createChannel(name, options = {}) {
352
482
  const bus = getBus(name, options);
353
483
  const handlers = /* @__PURE__ */ new Map();
484
+ const responders = /* @__PURE__ */ new Map();
485
+ const waiting = /* @__PURE__ */ new Map();
486
+ const gate = createGate(name, options.schema, options.onInvalid);
487
+ const deliver = (type, payload, meta) => {
488
+ const set = handlers.get(type);
489
+ if (!set) return;
490
+ for (const fn of [...set]) fn(payload, meta);
491
+ };
354
492
  const unsubscribe = bus.subscribe((wire) => {
355
493
  if (wire.scope !== "event") return;
356
- const set = handlers.get(wire.type);
357
- if (!set) return;
358
494
  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) {
495
+ if (wire.replyTo !== void 0) {
496
+ const settle = waiting.get(wire.replyTo);
497
+ if (!settle) return;
498
+ waiting.delete(wire.replyTo);
499
+ settle(wire.payload);
500
+ return;
501
+ }
502
+ const responder = responders.get(wire.type);
503
+ if (responder) {
366
504
  bus.post({
367
505
  v: 1,
368
506
  scope: "event",
369
- type,
370
- payload,
507
+ type: wire.type,
508
+ payload: responder(wire.payload, meta),
371
509
  clientId: bus.clientId,
372
510
  kind: bus.kind,
373
- msgId: newMsgId()
511
+ msgId: newMsgId(),
512
+ replyTo: wire.msgId
374
513
  });
514
+ }
515
+ if (!handlers.has(wire.type)) return;
516
+ if (gate && !gate.accepts(wire.type, wire.payload)) return;
517
+ deliver(wire.type, wire.payload, meta);
518
+ });
519
+ let closed = false;
520
+ const send = (type, payload, msgId) => {
521
+ gate?.assert(type, payload);
522
+ bus.post({
523
+ v: 1,
524
+ scope: "event",
525
+ type,
526
+ payload,
527
+ clientId: bus.clientId,
528
+ kind: bus.kind,
529
+ msgId
530
+ });
531
+ };
532
+ return {
533
+ name,
534
+ clientId: bus.clientId,
535
+ post(type, payload, options2) {
536
+ send(type, payload, newMsgId());
537
+ if (options2?.echo) {
538
+ deliver(type, payload, { clientId: bus.clientId, kind: bus.kind, self: true });
539
+ }
375
540
  },
376
- on(type, handler) {
541
+ on(type, handler, options2) {
377
542
  let set = handlers.get(type);
378
543
  if (!set) {
379
544
  set = /* @__PURE__ */ new Set();
380
545
  handlers.set(type, set);
381
546
  }
382
- set.add(handler);
383
- return () => set.delete(handler);
547
+ const entry = options2?.once ? (payload, meta) => {
548
+ set.delete(entry);
549
+ handler(payload, meta);
550
+ } : handler;
551
+ set.add(entry);
552
+ return () => set.delete(entry);
553
+ },
554
+ ask(type, payload, options2) {
555
+ const msgId = newMsgId();
556
+ return new Promise((resolve, reject) => {
557
+ const timer = setTimeout(() => {
558
+ waiting.delete(msgId);
559
+ reject(
560
+ new Error(
561
+ `use-everywhere: nobody answered "${type}" on "${name}" within ${String(options2?.timeoutMs ?? 5e3)}ms`
562
+ )
563
+ );
564
+ }, options2?.timeoutMs ?? 5e3);
565
+ waiting.set(msgId, (value) => {
566
+ clearTimeout(timer);
567
+ resolve(value);
568
+ });
569
+ try {
570
+ send(type, payload, msgId);
571
+ } catch (error) {
572
+ clearTimeout(timer);
573
+ waiting.delete(msgId);
574
+ throw error;
575
+ }
576
+ });
577
+ },
578
+ answer(type, responder) {
579
+ responders.set(type, responder);
580
+ return () => responders.delete(type);
384
581
  },
385
582
  close() {
386
583
  if (closed) return;
387
584
  closed = true;
388
585
  unsubscribe();
389
586
  handlers.clear();
587
+ responders.clear();
588
+ waiting.clear();
390
589
  bus.release();
391
590
  }
392
591
  };
@@ -437,15 +636,19 @@ function createSharedStore(name, initial, options = {}) {
437
636
  if (onSharedBus) {
438
637
  const live = liveStores.get(name) ?? 0;
439
638
  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
- );
639
+ if (process.env.NODE_ENV !== "production") {
640
+ devWarn(
641
+ "UE1001",
642
+ `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.`
643
+ );
644
+ }
443
645
  }
444
646
  liveStores.set(name, live + 1);
445
647
  }
446
648
  const bus = getBus(name, options);
447
649
  const clientId = bus.clientId;
448
650
  const accept = options.accept;
651
+ const gate = createGate(name, options.schema, options.onInvalid);
449
652
  const state = { ...initial };
450
653
  const versions = {};
451
654
  for (const k in state) {
@@ -456,23 +659,61 @@ function createSharedStore(name, initial, options = {}) {
456
659
  let versionsSnapshot = Object.freeze({ ...versions });
457
660
  const listeners = /* @__PURE__ */ new Set();
458
661
  const keyListeners = /* @__PURE__ */ new Map();
459
- function notify(key, value, meta) {
662
+ let batchDepth = 0;
663
+ const batched = [];
664
+ const rebuild = () => {
460
665
  snapshot = Object.freeze({ ...state });
461
666
  versionsSnapshot = Object.freeze({ ...versions });
667
+ };
668
+ const emit = (key, value, meta) => {
462
669
  for (const fn of listeners) fn(key, value, meta);
463
670
  const set = keyListeners.get(key);
464
671
  if (set) for (const fn of set) fn();
672
+ };
673
+ function notify(key, value, meta) {
674
+ if (batchDepth > 0) {
675
+ batched.push([key, value, meta]);
676
+ return;
677
+ }
678
+ rebuild();
679
+ emit(key, value, meta);
680
+ }
681
+ function batch(fn) {
682
+ batchDepth++;
683
+ try {
684
+ return fn();
685
+ } finally {
686
+ batchDepth--;
687
+ if (batchDepth === 0 && batched.length > 0) {
688
+ const changes = batched.splice(0, batched.length);
689
+ rebuild();
690
+ for (const [key, value, meta] of changes) emit(key, value, meta);
691
+ }
692
+ }
465
693
  }
466
694
  function applyRemote(key, value, version, meta) {
467
695
  if (!newer(version, versions[key])) return;
696
+ if (gate && !gate.accepts(key, value)) return;
468
697
  versions[key] = version;
469
698
  state[key] = freezeShared(value);
470
699
  notify(key, value, meta);
471
700
  }
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") {
701
+ const snapshotDelayMs = options.snapshotDelayMs ?? 40;
702
+ let snapshotTimer;
703
+ const written = () => Object.keys(versions).filter((k) => (versions[k]?.[0] ?? 0) > 0);
704
+ const coveredBy = (theirs) => written().every((key) => {
705
+ const mine = versions[key];
706
+ return mine !== void 0 && !newer(mine, theirs[key]);
707
+ });
708
+ const cancelSnapshot = () => {
709
+ clearTimeout(snapshotTimer);
710
+ snapshotTimer = void 0;
711
+ };
712
+ const scheduleSnapshot = () => {
713
+ if (written().length === 0) return;
714
+ if (snapshotTimer !== void 0) return;
715
+ snapshotTimer = setTimeout(() => {
716
+ snapshotTimer = void 0;
476
717
  bus.post({
477
718
  v: 1,
478
719
  scope: "state",
@@ -482,22 +723,36 @@ function createSharedStore(name, initial, options = {}) {
482
723
  state: { ...state },
483
724
  versions: { ...versions }
484
725
  });
726
+ }, Math.random() * snapshotDelayMs);
727
+ };
728
+ const unsubscribe = bus.subscribe((wire) => {
729
+ if (wire.scope !== "state") return;
730
+ const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
731
+ if (wire.type === "hello") {
732
+ scheduleSnapshot();
485
733
  return;
486
734
  }
487
735
  if (accept && !accept(meta)) return;
488
736
  if (wire.type === "patch") {
489
737
  if (typeof wire.key !== "string" || !isVersion(wire.version)) return;
490
738
  applyRemote(wire.key, wire.value, wire.version, meta);
491
- } else {
739
+ return;
740
+ }
741
+ if (wire.type === "snapshot") {
492
742
  const versions2 = wire.versions;
493
743
  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
- }
744
+ if (coveredBy(versions2)) cancelSnapshot();
745
+ batch(() => {
746
+ for (const k in wire.state) {
747
+ const version = versions2[k];
748
+ if (isVersion(version)) applyRemote(k, wire.state[k], version, meta);
749
+ }
750
+ });
751
+ return;
498
752
  }
499
753
  });
500
754
  function setKey(key, value) {
755
+ gate?.assert(key, value);
501
756
  const version = [(versions[key]?.[0] ?? 0) + 1, clientId];
502
757
  try {
503
758
  bus.post({
@@ -529,29 +784,74 @@ function createSharedStore(name, initial, options = {}) {
529
784
  });
530
785
  const persist = options.persist;
531
786
  let flushPersist;
787
+ let settle = () => {
788
+ };
789
+ const hydrated = persist ? new Promise((resolve) => {
790
+ settle = resolve;
791
+ }) : Promise.resolve();
532
792
  if (persist) {
533
- const { adapter, keys, debounceMs = 100 } = persist;
793
+ const {
794
+ adapter,
795
+ keys,
796
+ debounceMs = 100,
797
+ version: schemaVersion = 0,
798
+ migrate,
799
+ onRestoreError
800
+ } = persist;
534
801
  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];
802
+ const refuse = (error) => {
803
+ if (onRestoreError) onRestoreError(error);
804
+ else if (process.env.NODE_ENV !== "production") {
805
+ devWarn(
806
+ "UE1002",
807
+ `${name}: persisted schema v${error.found} not restored, expected v${error.expected} (${error.reason}). https://rxova.org/packages/use-everywhere/guides/persistence/`
808
+ );
809
+ }
810
+ };
811
+ const reconcile = (saved2) => {
812
+ const found = saved2.schema ?? 0;
813
+ if (found === schemaVersion) return { state: saved2.state, migrated: false };
814
+ if (found > schemaVersion) {
815
+ refuse({ reason: "ahead", found, expected: schemaVersion });
816
+ return void 0;
817
+ }
818
+ if (!migrate) {
819
+ refuse({ reason: "no-migrate", found, expected: schemaVersion });
820
+ return void 0;
821
+ }
822
+ try {
823
+ return { state: migrate(saved2.state, found), migrated: true };
824
+ } catch (cause) {
825
+ refuse({ reason: "migrate-threw", found, expected: schemaVersion, cause });
826
+ return void 0;
827
+ }
828
+ };
829
+ const hydrate = (raw) => {
830
+ const reconciled = raw && reconcile(raw);
831
+ if (!reconciled || !raw) {
832
+ settle();
833
+ return;
834
+ }
835
+ const { state: restored, migrated } = reconciled;
836
+ for (const key in restored) {
837
+ const version = raw.versions[key] ?? (migrated ? [1, clientId] : void 0);
539
838
  if (!version || !shouldPersist(key)) continue;
540
- applyRemote(key, saved2.state[key], version, { clientId, kind: bus.kind, self: true });
839
+ applyRemote(key, restored[key], version, { clientId, kind: bus.kind, self: true });
541
840
  bus.post({
542
841
  v: 1,
543
842
  scope: "state",
544
843
  type: "patch",
545
844
  key,
546
- value: saved2.state[key],
845
+ value: restored[key],
547
846
  version,
548
847
  clientId,
549
848
  kind: bus.kind
550
849
  });
551
850
  }
851
+ settle();
552
852
  };
553
853
  const collect = () => {
554
- const out = { v: 1, state: {}, versions: {} };
854
+ const out = { v: 1, schema: schemaVersion, state: {}, versions: {} };
555
855
  for (const key in versions) {
556
856
  const version = versions[key];
557
857
  if (!version || version[0] === 0 || !shouldPersist(key)) continue;
@@ -591,6 +891,7 @@ function createSharedStore(name, initial, options = {}) {
591
891
  let storeClosed = false;
592
892
  return {
593
893
  clientId,
894
+ hydrated,
594
895
  state: proxy,
595
896
  getSnapshot: () => snapshot,
596
897
  getVersions: () => versionsSnapshot,
@@ -598,6 +899,9 @@ function createSharedStore(name, initial, options = {}) {
598
899
  const next = typeof value === "function" ? value(state[key]) : value;
599
900
  setKey(key, next);
600
901
  },
902
+ transaction(fn) {
903
+ return batch(fn);
904
+ },
601
905
  subscribe(fn) {
602
906
  listeners.add(fn);
603
907
  return () => listeners.delete(fn);
@@ -629,6 +933,7 @@ function createSharedStore(name, initial, options = {}) {
629
933
  if (live > 0) liveStores.set(name, live);
630
934
  else liveStores.delete(name);
631
935
  }
936
+ cancelSnapshot();
632
937
  if (hasWindow) removeEventListener("pageshow", onPageShow);
633
938
  if (flushPersist) {
634
939
  flushPersist();
@@ -644,83 +949,6 @@ function createSharedStore(name, initial, options = {}) {
644
949
  };
645
950
  }
646
951
 
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
952
  // src/leader-web-locks.ts
725
953
  var NO_LEADER = Object.freeze({ leaderId: null, isLeader: false });
726
954
  function createWebLocksLeader(name, options, locks) {
@@ -1005,6 +1233,246 @@ function createHeartbeatLeader(name, options) {
1005
1233
  };
1006
1234
  }
1007
1235
 
1236
+ // src/shared-reducer.ts
1237
+ function createSharedReducer(name, reducer, initial, options = {}) {
1238
+ const key = options.key ?? "default";
1239
+ const bus = getBus(name, options);
1240
+ const clientId = bus.clientId;
1241
+ const leader = options.leader ?? createLeader(name, {
1242
+ ...options.leaderOptions,
1243
+ // Spread conditionally: `exactOptionalPropertyTypes` distinguishes an
1244
+ // absent transport from one explicitly set to undefined, and the leader
1245
+ // must default its own rather than be handed a hole.
1246
+ ...options.transport ? { transport: options.transport } : {}
1247
+ });
1248
+ const ownsLeader = !options.leader;
1249
+ let committed = initial;
1250
+ let seq = 0;
1251
+ let pending = [];
1252
+ const early = /* @__PURE__ */ new Map();
1253
+ let lastIssued = 0;
1254
+ let view = committed;
1255
+ const listeners = /* @__PURE__ */ new Set();
1256
+ let closed = false;
1257
+ const notify = () => {
1258
+ const next = pending.reduce((state, op) => reducer(state, op.action), committed);
1259
+ if (Object.is(next, view)) return;
1260
+ view = next;
1261
+ for (const fn of listeners) fn();
1262
+ };
1263
+ const askForSnapshot = () => bus.post({ v: 1, scope: "op", type: "hello", key, clientId, kind: bus.kind });
1264
+ const receiveCommit = (at, action, opId) => {
1265
+ if (at <= seq) return;
1266
+ if (at > seq + 1) {
1267
+ early.set(at, { action, opId });
1268
+ askForSnapshot();
1269
+ return;
1270
+ }
1271
+ applyCommit(at, action, opId);
1272
+ notify();
1273
+ };
1274
+ const sequence = (action, opId) => {
1275
+ lastIssued = Math.max(lastIssued, seq) + 1;
1276
+ const at = lastIssued;
1277
+ bus.post({
1278
+ v: 1,
1279
+ scope: "op",
1280
+ type: "commit",
1281
+ key,
1282
+ action,
1283
+ opId,
1284
+ seq: at,
1285
+ clientId,
1286
+ kind: bus.kind
1287
+ });
1288
+ receiveCommit(at, action, opId);
1289
+ };
1290
+ const applyCommit = (at, action, opId) => {
1291
+ committed = reducer(committed, action);
1292
+ seq = at;
1293
+ pending = pending.filter((op) => op.opId !== opId);
1294
+ const next = early.get(seq + 1);
1295
+ if (next) {
1296
+ early.delete(seq + 1);
1297
+ applyCommit(seq + 1, next.action, next.opId);
1298
+ }
1299
+ };
1300
+ const unsubscribe = bus.subscribe((wire) => {
1301
+ if (wire.scope !== "op" || wire.key !== key) return;
1302
+ if (wire.type === "propose") {
1303
+ if (!leader.getSnapshot().isLeader) return;
1304
+ sequence(wire.action, wire.opId);
1305
+ return;
1306
+ }
1307
+ if (wire.type === "commit") {
1308
+ receiveCommit(wire.seq, wire.action, wire.opId);
1309
+ return;
1310
+ }
1311
+ if (wire.type === "hello") {
1312
+ if (seq === 0) return;
1313
+ bus.post({
1314
+ v: 1,
1315
+ scope: "op",
1316
+ type: "snapshot",
1317
+ key,
1318
+ state: committed,
1319
+ seq,
1320
+ clientId,
1321
+ kind: bus.kind
1322
+ });
1323
+ return;
1324
+ }
1325
+ if (wire.type === "snapshot") {
1326
+ if (wire.seq <= seq) return;
1327
+ committed = wire.state;
1328
+ seq = wire.seq;
1329
+ for (const at of [...early.keys()]) if (at <= seq) early.delete(at);
1330
+ const next = early.get(seq + 1);
1331
+ if (next) {
1332
+ early.delete(seq + 1);
1333
+ applyCommit(seq + 1, next.action, next.opId);
1334
+ }
1335
+ notify();
1336
+ return;
1337
+ }
1338
+ });
1339
+ const dispatch = (action) => {
1340
+ if (closed) return;
1341
+ const opId = newMsgId();
1342
+ pending.push({ opId, action });
1343
+ notify();
1344
+ if (leader.getSnapshot().isLeader) sequence(action, opId);
1345
+ else
1346
+ bus.post({ v: 1, scope: "op", type: "propose", key, action, opId, clientId, kind: bus.kind });
1347
+ };
1348
+ askForSnapshot();
1349
+ return {
1350
+ clientId,
1351
+ getSnapshot: () => view,
1352
+ dispatch,
1353
+ subscribe(fn) {
1354
+ listeners.add(fn);
1355
+ return () => listeners.delete(fn);
1356
+ },
1357
+ pendingCount: () => pending.length,
1358
+ close() {
1359
+ if (closed) return;
1360
+ closed = true;
1361
+ unsubscribe();
1362
+ listeners.clear();
1363
+ if (ownsLeader) leader.close();
1364
+ bus.release();
1365
+ }
1366
+ };
1367
+ }
1368
+
1369
+ // src/presence.ts
1370
+ function createPresence(name, options = {}) {
1371
+ const pruneAfterMs = options.pruneAfterMs ?? 5e3;
1372
+ const probeGraceMs = options.probeGraceMs ?? 1e3;
1373
+ const bus = getBus(name, options);
1374
+ const peers = /* @__PURE__ */ new Map();
1375
+ const listeners = /* @__PURE__ */ new Set();
1376
+ let snapshot = [];
1377
+ let metadata = options.metadata;
1378
+ const same = (a, b) => {
1379
+ if (Object.is(a, b)) return true;
1380
+ try {
1381
+ return JSON.stringify(a) === JSON.stringify(b);
1382
+ } catch {
1383
+ return false;
1384
+ }
1385
+ };
1386
+ const self = () => ({
1387
+ id: bus.clientId,
1388
+ kind: bus.kind,
1389
+ lastSeen: Date.now(),
1390
+ ...metadata === void 0 ? {} : { metadata }
1391
+ });
1392
+ function notify() {
1393
+ const roster = [...peers.values()];
1394
+ snapshot = Object.freeze(options.includeSelf ? [self(), ...roster] : roster);
1395
+ for (const fn of listeners) fn();
1396
+ }
1397
+ const announce2 = () => bus.post({
1398
+ v: 1,
1399
+ scope: "presence",
1400
+ type: "hello",
1401
+ clientId: bus.clientId,
1402
+ kind: bus.kind,
1403
+ ...metadata === void 0 ? {} : { metadata }
1404
+ });
1405
+ const unsubscribe = bus.subscribe((wire) => {
1406
+ if (wire.clientId === bus.clientId) return;
1407
+ if (wire.scope === "presence" && wire.type === "bye") {
1408
+ if (peers.delete(wire.clientId)) notify();
1409
+ return;
1410
+ }
1411
+ const existing = peers.get(wire.clientId);
1412
+ const announced = wire.scope === "presence" ? wire.metadata : void 0;
1413
+ const next = announced === void 0 ? existing?.metadata : announced;
1414
+ peers.set(wire.clientId, {
1415
+ id: wire.clientId,
1416
+ kind: wire.kind,
1417
+ lastSeen: Date.now(),
1418
+ ...next === void 0 ? {} : { metadata: next }
1419
+ });
1420
+ if (!existing || !same(existing.metadata, next)) notify();
1421
+ });
1422
+ announce2();
1423
+ if (options.includeSelf) notify();
1424
+ const probed = /* @__PURE__ */ new Map();
1425
+ const prune = setInterval(
1426
+ () => {
1427
+ const now = Date.now();
1428
+ const silentSince = now - pruneAfterMs;
1429
+ let changed = false;
1430
+ let needProbe = false;
1431
+ for (const [id, peer] of peers) {
1432
+ if (peer.lastSeen >= silentSince) {
1433
+ probed.delete(id);
1434
+ continue;
1435
+ }
1436
+ const askedAt = probed.get(id);
1437
+ if (askedAt === void 0) {
1438
+ probed.set(id, now);
1439
+ needProbe = true;
1440
+ } else if (now - askedAt >= probeGraceMs) {
1441
+ peers.delete(id);
1442
+ probed.delete(id);
1443
+ changed = true;
1444
+ }
1445
+ }
1446
+ if (needProbe) announce2();
1447
+ if (changed) notify();
1448
+ },
1449
+ Math.max(250, Math.floor(Math.min(pruneAfterMs, probeGraceMs) / 2))
1450
+ );
1451
+ let closed = false;
1452
+ return {
1453
+ clientId: bus.clientId,
1454
+ getPeers: () => snapshot,
1455
+ setMetadata(next) {
1456
+ if (same(metadata, next)) return;
1457
+ metadata = next;
1458
+ announce2();
1459
+ notify();
1460
+ },
1461
+ subscribe(fn) {
1462
+ listeners.add(fn);
1463
+ return () => listeners.delete(fn);
1464
+ },
1465
+ close() {
1466
+ if (closed) return;
1467
+ closed = true;
1468
+ clearInterval(prune);
1469
+ unsubscribe();
1470
+ listeners.clear();
1471
+ bus.release();
1472
+ }
1473
+ };
1474
+ }
1475
+
1008
1476
  // src/persist-web-storage.ts
1009
1477
  function isPersisted(value) {
1010
1478
  if (typeof value !== "object" || value === null) return false;
@@ -1012,6 +1480,7 @@ function isPersisted(value) {
1012
1480
  return candidate.v === 1 && typeof candidate.state === "object" && typeof candidate.versions === "object";
1013
1481
  }
1014
1482
  function webStorageAdapter(storage, key, options = {}) {
1483
+ const serializer = options.serializer ?? jsonSerializer;
1015
1484
  const report = (error, operation) => {
1016
1485
  try {
1017
1486
  options.onError?.(error, operation);
@@ -1031,7 +1500,7 @@ function webStorageAdapter(storage, key, options = {}) {
1031
1500
  try {
1032
1501
  const raw = resolve("read")?.getItem(key);
1033
1502
  if (!raw) return void 0;
1034
- const parsed = JSON.parse(raw);
1503
+ const parsed = serializer.parse(raw);
1035
1504
  return isPersisted(parsed) ? parsed : void 0;
1036
1505
  } catch (error) {
1037
1506
  report(error, "read");
@@ -1040,7 +1509,7 @@ function webStorageAdapter(storage, key, options = {}) {
1040
1509
  },
1041
1510
  write(snapshot) {
1042
1511
  try {
1043
- resolve("write")?.setItem(key, JSON.stringify(snapshot));
1512
+ resolve("write")?.setItem(key, serializer.stringify(snapshot));
1044
1513
  } catch (error) {
1045
1514
  report(error, "write");
1046
1515
  }
@@ -1061,6 +1530,58 @@ function sessionStorageAdapter(key, options) {
1061
1530
  return webStorageAdapter(() => globalThis.sessionStorage, key, options);
1062
1531
  }
1063
1532
 
1533
+ // src/persist-indexeddb.ts
1534
+ var STORE = "state";
1535
+ var request = (req) => new Promise((resolve, reject) => {
1536
+ req.onsuccess = () => resolve(req.result);
1537
+ req.onerror = () => reject(req.error);
1538
+ });
1539
+ function indexedDbAdapter(key, options = {}) {
1540
+ const database = options.database ?? "use-everywhere";
1541
+ let open;
1542
+ const report = (error, operation) => {
1543
+ try {
1544
+ options.onError?.(error, operation);
1545
+ } catch {
1546
+ }
1547
+ };
1548
+ const db = () => open ?? (open = new Promise((resolve, reject) => {
1549
+ const req = indexedDB.open(database, 1);
1550
+ req.onupgradeneeded = () => req.result.createObjectStore(STORE);
1551
+ req.onsuccess = () => resolve(req.result);
1552
+ req.onerror = () => reject(req.error);
1553
+ req.onblocked = () => reject(new Error("use-everywhere: IndexedDB upgrade blocked"));
1554
+ }));
1555
+ const transact = async (mode, run) => {
1556
+ const connection = await db();
1557
+ return request(run(connection.transaction(STORE, mode).objectStore(STORE)));
1558
+ };
1559
+ return {
1560
+ async read() {
1561
+ try {
1562
+ return await transact("readonly", (store) => store.get(key));
1563
+ } catch (error) {
1564
+ report(error, "read");
1565
+ return void 0;
1566
+ }
1567
+ },
1568
+ async write(snapshot) {
1569
+ try {
1570
+ await transact("readwrite", (store) => store.put(snapshot, key));
1571
+ } catch (error) {
1572
+ report(error, "write");
1573
+ }
1574
+ },
1575
+ async remove() {
1576
+ try {
1577
+ await transact("readwrite", (store) => store.delete(key));
1578
+ } catch (error) {
1579
+ report(error, "remove");
1580
+ }
1581
+ }
1582
+ };
1583
+ }
1584
+
1064
1585
  // src/errors/handshake-timeout-error.ts
1065
1586
  var HandshakeTimeoutError = class extends Error {
1066
1587
  constructor(message = "handshake with the other window timed out") {
@@ -1295,6 +1816,25 @@ function connectToOpener(options) {
1295
1816
  }
1296
1817
  };
1297
1818
  }
1819
+
1820
+ // src/namespace.ts
1821
+ var SEPARATOR = ":";
1822
+ function createNamespace(namespace) {
1823
+ if (!namespace) {
1824
+ throw new TypeError(
1825
+ "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."
1826
+ );
1827
+ }
1828
+ const busName = (name = DEFAULT_NAME) => `${namespace}${SEPARATOR}${name}`;
1829
+ return {
1830
+ name: namespace,
1831
+ busName,
1832
+ createSharedStore: (name, initial, options) => createSharedStore(busName(name), initial, options),
1833
+ createChannel: (name, options) => createChannel(busName(name), options),
1834
+ createPresence: (name, options) => createPresence(busName(name), options),
1835
+ createLeader: (name, options) => createLeader(busName(name), options)
1836
+ };
1837
+ }
1298
1838
  export {
1299
1839
  BroadcastChannelTransport,
1300
1840
  CID_PARAM,
@@ -1302,18 +1842,24 @@ export {
1302
1842
  HandshakeTimeoutError,
1303
1843
  NoopTransport,
1304
1844
  StorageTransport,
1845
+ WIRE_VERSION,
1305
1846
  WindowClosedError,
1306
1847
  connectToOpener,
1307
1848
  createChannel,
1308
1849
  createLeader,
1850
+ createNamespace,
1309
1851
  createPresence,
1852
+ createSharedReducer,
1310
1853
  createSharedStore,
1311
1854
  defaultTransport,
1312
1855
  enableDebug,
1313
1856
  getBusNames,
1314
1857
  getTransportKind,
1858
+ getWireSkew,
1859
+ indexedDbAdapter,
1315
1860
  isBroadcastChannelAvailable,
1316
1861
  isStorageEventAvailable,
1862
+ jsonSerializer,
1317
1863
  localStorageAdapter,
1318
1864
  newer,
1319
1865
  observeBus,