@use-everywhere/core 0.6.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");
@@ -75,9 +77,38 @@ function newMsgId() {
75
77
  return randomHex(16);
76
78
  }
77
79
 
80
+ // src/rendezvous.ts
81
+ var PROTOCOL = 1;
82
+ var TABLE = /* @__PURE__ */ Symbol.for(`use-everywhere.rendezvous.${PROTOCOL}`);
83
+ var CENSUS = /* @__PURE__ */ Symbol.for("use-everywhere.rendezvous.census");
84
+ var SKEW = /* @__PURE__ */ Symbol.for("use-everywhere.rendezvous.skew");
85
+ function announce() {
86
+ const g = globalThis;
87
+ const census = g[CENSUS] ?? (g[CENSUS] = { protocols: [] });
88
+ if (census.protocols.includes(PROTOCOL)) return;
89
+ census.protocols.push(PROTOCOL);
90
+ if (census.protocols.length > 1) {
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
+ }
96
+ }
97
+ }
98
+ function busTable() {
99
+ announce();
100
+ const g = globalThis;
101
+ return g[TABLE] ?? (g[TABLE] = /* @__PURE__ */ new Map());
102
+ }
103
+ function skewLedger() {
104
+ const g = globalThis;
105
+ return g[SKEW] ?? (g[SKEW] = /* @__PURE__ */ new Map());
106
+ }
107
+
78
108
  // src/transport/broadcast-channel-transport.ts
79
109
  var BroadcastChannelTransport = class {
80
110
  constructor(name) {
111
+ this.kind = "broadcast-channel";
81
112
  this.listeners = /* @__PURE__ */ new Set();
82
113
  this.bc = new BroadcastChannel(name);
83
114
  this.bc.onmessage = (event) => {
@@ -99,6 +130,9 @@ var BroadcastChannelTransport = class {
99
130
 
100
131
  // src/transport/noop-transport.ts
101
132
  var NoopTransport = class {
133
+ constructor() {
134
+ this.kind = "none";
135
+ }
102
136
  post() {
103
137
  }
104
138
  subscribe() {
@@ -109,157 +143,434 @@ var NoopTransport = class {
109
143
  }
110
144
  };
111
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
+
175
+ // src/transport/storage-transport.ts
176
+ var StorageTransport = class {
177
+ constructor(name, storage = globalThis.localStorage, serializer = jsonSerializer) {
178
+ this.kind = "storage";
179
+ this.listeners = /* @__PURE__ */ new Set();
180
+ this.seq = 0;
181
+ if (!storage) {
182
+ throw new Error(
183
+ "[use-everywhere] StorageTransport needs localStorage; workers have none. Use BroadcastChannel there."
184
+ );
185
+ }
186
+ this.serializer = serializer;
187
+ this.key = `use-everywhere:bus:${name}`;
188
+ this.storage = storage;
189
+ this.onStorage = (event) => {
190
+ if (event.key !== this.key || event.newValue === null) return;
191
+ let payload;
192
+ try {
193
+ payload = this.serializer.parse(event.newValue);
194
+ } catch {
195
+ return;
196
+ }
197
+ for (const listener of this.listeners) listener(payload.data);
198
+ };
199
+ addEventListener("storage", this.onStorage);
200
+ }
201
+ post(data) {
202
+ const envelope = this.serializer.stringify({ seq: this.seq++, data });
203
+ try {
204
+ this.storage.setItem(this.key, envelope);
205
+ this.storage.removeItem(this.key);
206
+ } catch {
207
+ }
208
+ }
209
+ subscribe(listener) {
210
+ this.listeners.add(listener);
211
+ return () => this.listeners.delete(listener);
212
+ }
213
+ close() {
214
+ this.listeners.clear();
215
+ removeEventListener("storage", this.onStorage);
216
+ }
217
+ };
218
+
112
219
  // src/transport/default-transport.ts
113
220
  function isBroadcastChannelAvailable() {
114
221
  return typeof BroadcastChannel !== "undefined";
115
222
  }
223
+ function isStorageEventAvailable() {
224
+ if (typeof addEventListener !== "function") return false;
225
+ try {
226
+ const storage = globalThis.localStorage;
227
+ if (!storage) return false;
228
+ const probe = "use-everywhere:probe";
229
+ storage.setItem(probe, "1");
230
+ storage.removeItem(probe);
231
+ return true;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
116
236
  function defaultTransport(name) {
117
- return isBroadcastChannelAvailable() ? new BroadcastChannelTransport(name) : new NoopTransport();
237
+ if (isBroadcastChannelAvailable()) return new BroadcastChannelTransport(name);
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") {
247
+ devWarn(
248
+ "[use-everywhere] no BroadcastChannel and no usable localStorage: nothing is shared between tabs. Storage is probably blocked."
249
+ );
250
+ }
251
+ return new NoopTransport();
118
252
  }
119
253
 
120
- // 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
+ }
121
259
  function isBusWire(data) {
122
- return typeof data === "object" && data !== null && data.v === 1;
260
+ if (typeof data !== "object" || data === null) return false;
261
+ const wire = data;
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;
123
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);
284
+ }
285
+
286
+ // src/bus.ts
124
287
  function defaultKind() {
125
288
  return typeof document === "undefined" ? "worker" : "tab";
126
289
  }
127
- var registry = /* @__PURE__ */ new Map();
128
- function createBus(name, options, onShutdown) {
290
+ var SHARED_WITHIN_CLIENT = /* @__PURE__ */ new Set(["state", "event", "op"]);
291
+ function createBusCore(name, options, onShutdown) {
129
292
  const transport = (options.transport ?? defaultTransport)(name);
130
293
  const clientId = newClientId();
131
294
  const kind = options.kind ?? defaultKind();
132
- const listeners = /* @__PURE__ */ new Set();
295
+ const handles = /* @__PURE__ */ new Set();
133
296
  let refs = 0;
134
297
  let closed = false;
135
- const post = (wire) => {
298
+ const deliver = (wire, from) => {
299
+ for (const handle of handles) {
300
+ if (handle === from) continue;
301
+ for (const fn of handle.listeners) fn(wire);
302
+ }
303
+ };
304
+ const post = (wire, from) => {
136
305
  if (closed) return;
137
306
  emitBusEvent(name, "out", wire);
138
307
  transport.post(wire);
308
+ if (SHARED_WITHIN_CLIENT.has(wire.scope)) deliver(wire, from);
139
309
  };
140
310
  const unsubscribe = transport.subscribe((data) => {
311
+ const foreign = foreignWireVersion(data);
312
+ if (foreign !== null) {
313
+ recordSkew(name, foreign);
314
+ return;
315
+ }
141
316
  if (!isBusWire(data)) return;
142
317
  if (data.clientId === clientId) return;
143
318
  emitBusEvent(name, "in", data);
144
319
  if (data.scope === "presence" && data.type === "hello") {
145
- post({ v: 1, scope: "presence", type: "ping", clientId, kind });
320
+ post({ v: 1, scope: "presence", type: "ping", clientId, kind }, null);
146
321
  }
147
- for (const fn of listeners) fn(data);
322
+ deliver(data, null);
148
323
  });
149
- post({ v: 1, scope: "presence", type: "hello", clientId, kind });
324
+ post({ v: 1, scope: "presence", type: "hello", clientId, kind }, null);
150
325
  const heartbeat = setInterval(
151
- () => post({ v: 1, scope: "presence", type: "ping", clientId, kind }),
326
+ () => post({ v: 1, scope: "presence", type: "ping", clientId, kind }, null),
152
327
  options.heartbeatMs ?? 2e3
153
328
  );
154
- const sayBye = () => post({ v: 1, scope: "presence", type: "bye", clientId, kind });
329
+ const sayBye = () => post({ v: 1, scope: "presence", type: "bye", clientId, kind }, null);
155
330
  const onPageShow = (event) => {
156
331
  if (!event.persisted) return;
157
- post({ v: 1, scope: "presence", type: "hello", clientId, kind });
332
+ post({ v: 1, scope: "presence", type: "hello", clientId, kind }, null);
333
+ };
334
+ const onVisible = () => {
335
+ if (document.visibilityState === "visible") {
336
+ post({ v: 1, scope: "presence", type: "hello", clientId, kind }, null);
337
+ }
158
338
  };
159
339
  const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
160
340
  if (hasWindow) {
161
341
  addEventListener("pagehide", sayBye);
162
342
  addEventListener("pageshow", onPageShow);
343
+ addEventListener("visibilitychange", onVisible);
163
344
  }
345
+ const shutdown = () => {
346
+ sayBye();
347
+ closed = true;
348
+ clearInterval(heartbeat);
349
+ if (hasWindow) {
350
+ removeEventListener("pagehide", sayBye);
351
+ removeEventListener("pageshow", onPageShow);
352
+ removeEventListener("visibilitychange", onVisible);
353
+ }
354
+ unsubscribe();
355
+ transport.close();
356
+ handles.clear();
357
+ onShutdown();
358
+ };
164
359
  return {
165
360
  name,
166
361
  clientId,
167
362
  kind,
363
+ transportKind: transport.kind ?? "custom",
168
364
  heartbeatMs: options.heartbeatMs ?? 2e3,
169
- post,
170
- subscribe(fn) {
171
- listeners.add(fn);
172
- return () => listeners.delete(fn);
173
- },
174
- acquire() {
365
+ connect() {
366
+ const handle = { listeners: /* @__PURE__ */ new Set() };
367
+ handles.add(handle);
175
368
  refs++;
176
- },
177
- release() {
178
- if (closed) return;
179
- refs--;
180
- if (refs > 0) return;
181
- sayBye();
182
- closed = true;
183
- clearInterval(heartbeat);
184
- if (hasWindow) {
185
- removeEventListener("pagehide", sayBye);
186
- removeEventListener("pageshow", onPageShow);
187
- }
188
- unsubscribe();
189
- transport.close();
190
- listeners.clear();
191
- onShutdown();
369
+ let released = false;
370
+ return {
371
+ name,
372
+ clientId,
373
+ kind,
374
+ transportKind: transport.kind ?? "custom",
375
+ post: (wire) => post(wire, handle),
376
+ subscribe(fn) {
377
+ handle.listeners.add(fn);
378
+ return () => handle.listeners.delete(fn);
379
+ },
380
+ release() {
381
+ if (released || closed) return;
382
+ released = true;
383
+ handles.delete(handle);
384
+ handle.listeners.clear();
385
+ refs--;
386
+ if (refs === 0) shutdown();
387
+ }
388
+ };
192
389
  }
193
390
  };
194
391
  }
195
392
  function getBusNames() {
196
- return [...registry.keys()];
393
+ return [...busTable().keys()];
394
+ }
395
+ function getTransportKind(name) {
396
+ return busTable().get(name)?.transportKind ?? null;
197
397
  }
198
398
  function getBus(name, options = {}) {
199
- if (options.transport) {
200
- const bus2 = createBus(name, options, () => {
201
- });
202
- bus2.acquire();
203
- return bus2;
204
- }
205
- let bus = registry.get(name);
206
- if (!bus) {
207
- bus = createBus(name, options, () => registry.delete(name));
208
- registry.set(name, bus);
399
+ if (options.transport) return createBusCore(name, options, () => {
400
+ }).connect();
401
+ const table = busTable();
402
+ let core = table.get(name);
403
+ if (!core) {
404
+ core = createBusCore(name, options, () => table.delete(name));
405
+ table.set(name, core);
209
406
  } else {
210
- if (options.heartbeatMs !== void 0 && options.heartbeatMs !== bus.heartbeatMs) {
407
+ if (options.heartbeatMs !== void 0 && options.heartbeatMs !== core.heartbeatMs) {
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
+ }
413
+ }
414
+ if (options.kind !== void 0 && options.kind !== core.kind) {
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
+ }
420
+ }
421
+ }
422
+ return core.connect();
423
+ }
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") {
211
446
  devWarn(
212
- `[use-everywhere] bus "${name}": heartbeatMs ignored \u2014 the first creator fixes bus options`
447
+ `[use-everywhere] ${name}/${key}: ${direction}bound payload rejected by its schema \u2014 ${result.issues.join("; ")}. https://rxova.org/guides/validating-payloads/`
213
448
  );
214
449
  }
215
- if (options.kind !== void 0 && options.kind !== bus.kind) {
216
- devWarn(`[use-everywhere] bus "${name}": kind ignored \u2014 the first creator fixes bus options`);
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
+ }
217
461
  }
218
- }
219
- bus.acquire();
220
- return bus;
462
+ };
221
463
  }
222
464
 
223
465
  // src/channel.ts
224
466
  function createChannel(name, options = {}) {
225
467
  const bus = getBus(name, options);
226
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
+ };
227
477
  const unsubscribe = bus.subscribe((wire) => {
228
478
  if (wire.scope !== "event") return;
229
- const set = handlers.get(wire.type);
230
- if (!set) return;
231
479
  const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
232
- for (const fn of set) fn(wire.payload, meta);
233
- });
234
- let closed = false;
235
- return {
236
- name,
237
- clientId: bus.clientId,
238
- 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) {
239
489
  bus.post({
240
490
  v: 1,
241
491
  scope: "event",
242
- type,
243
- payload,
492
+ type: wire.type,
493
+ payload: responder(wire.payload, meta),
244
494
  clientId: bus.clientId,
245
495
  kind: bus.kind,
246
- msgId: newMsgId()
496
+ msgId: newMsgId(),
497
+ replyTo: wire.msgId
247
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
+ }
248
525
  },
249
- on(type, handler) {
526
+ on(type, handler, options2) {
250
527
  let set = handlers.get(type);
251
528
  if (!set) {
252
529
  set = /* @__PURE__ */ new Set();
253
530
  handlers.set(type, set);
254
531
  }
255
- set.add(handler);
256
- 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);
257
566
  },
258
567
  close() {
259
568
  if (closed) return;
260
569
  closed = true;
261
570
  unsubscribe();
262
571
  handlers.clear();
572
+ responders.clear();
573
+ waiting.clear();
263
574
  bus.release();
264
575
  }
265
576
  };
@@ -269,6 +580,9 @@ function createChannel(name, options = {}) {
269
580
  function newer(a, b) {
270
581
  return !b || a[0] > b[0] || a[0] === b[0] && a[1] > b[1];
271
582
  }
583
+ function isVersion(value) {
584
+ return Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && Number.isFinite(value[0]) && typeof value[1] === "string";
585
+ }
272
586
 
273
587
  // src/dev-freeze.ts
274
588
  var inDev2 = false;
@@ -307,15 +621,18 @@ function createSharedStore(name, initial, options = {}) {
307
621
  if (onSharedBus) {
308
622
  const live = liveStores.get(name) ?? 0;
309
623
  if (live > 0) {
310
- devWarn(
311
- `[use-everywhere] second shared store for "${name}" in this tab \u2014 stores on one page never hear each other and will diverge. Reuse one per name.`
312
- );
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
+ }
313
629
  }
314
630
  liveStores.set(name, live + 1);
315
631
  }
316
632
  const bus = getBus(name, options);
317
633
  const clientId = bus.clientId;
318
634
  const accept = options.accept;
635
+ const gate = createGate(name, options.schema, options.onInvalid);
319
636
  const state = { ...initial };
320
637
  const versions = {};
321
638
  for (const k in state) {
@@ -326,23 +643,61 @@ function createSharedStore(name, initial, options = {}) {
326
643
  let versionsSnapshot = Object.freeze({ ...versions });
327
644
  const listeners = /* @__PURE__ */ new Set();
328
645
  const keyListeners = /* @__PURE__ */ new Map();
329
- function notify(key, value, meta) {
646
+ let batchDepth = 0;
647
+ const batched = [];
648
+ const rebuild = () => {
330
649
  snapshot = Object.freeze({ ...state });
331
650
  versionsSnapshot = Object.freeze({ ...versions });
651
+ };
652
+ const emit = (key, value, meta) => {
332
653
  for (const fn of listeners) fn(key, value, meta);
333
654
  const set = keyListeners.get(key);
334
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
+ }
335
677
  }
336
678
  function applyRemote(key, value, version, meta) {
337
679
  if (!newer(version, versions[key])) return;
680
+ if (gate && !gate.accepts(key, value)) return;
338
681
  versions[key] = version;
339
682
  state[key] = freezeShared(value);
340
683
  notify(key, value, meta);
341
684
  }
342
- const unsubscribe = bus.subscribe((wire) => {
343
- if (wire.scope !== "state") return;
344
- const meta = { clientId: wire.clientId, kind: wire.kind, self: false };
345
- 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;
346
701
  bus.post({
347
702
  v: 1,
348
703
  scope: "state",
@@ -352,19 +707,36 @@ function createSharedStore(name, initial, options = {}) {
352
707
  state: { ...state },
353
708
  versions: { ...versions }
354
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();
355
717
  return;
356
718
  }
357
719
  if (accept && !accept(meta)) return;
358
720
  if (wire.type === "patch") {
721
+ if (typeof wire.key !== "string" || !isVersion(wire.version)) return;
359
722
  applyRemote(wire.key, wire.value, wire.version, meta);
360
- } else {
361
- for (const k in wire.state) {
362
- const version = wire.versions[k];
363
- if (version) applyRemote(k, wire.state[k], version, meta);
364
- }
723
+ return;
724
+ }
725
+ if (wire.type === "snapshot") {
726
+ const versions2 = wire.versions;
727
+ if (typeof versions2 !== "object" || versions2 === null) return;
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;
365
736
  }
366
737
  });
367
738
  function setKey(key, value) {
739
+ gate?.assert(key, value);
368
740
  const version = [(versions[key]?.[0] ?? 0) + 1, clientId];
369
741
  try {
370
742
  bus.post({
@@ -396,29 +768,73 @@ function createSharedStore(name, initial, options = {}) {
396
768
  });
397
769
  const persist = options.persist;
398
770
  let flushPersist;
771
+ let settle = () => {
772
+ };
773
+ const hydrated = persist ? new Promise((resolve) => {
774
+ settle = resolve;
775
+ }) : Promise.resolve();
399
776
  if (persist) {
400
- 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;
401
785
  const shouldPersist = (key) => !keys || keys.includes(key);
402
- const hydrate = (saved2) => {
403
- if (!saved2) return;
404
- for (const key in saved2.state) {
405
- 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);
406
821
  if (!version || !shouldPersist(key)) continue;
407
- applyRemote(key, saved2.state[key], version, { clientId, kind: bus.kind, self: true });
822
+ applyRemote(key, restored[key], version, { clientId, kind: bus.kind, self: true });
408
823
  bus.post({
409
824
  v: 1,
410
825
  scope: "state",
411
826
  type: "patch",
412
827
  key,
413
- value: saved2.state[key],
828
+ value: restored[key],
414
829
  version,
415
830
  clientId,
416
831
  kind: bus.kind
417
832
  });
418
833
  }
834
+ settle();
419
835
  };
420
836
  const collect = () => {
421
- const out = { v: 1, state: {}, versions: {} };
837
+ const out = { v: 1, schema: schemaVersion, state: {}, versions: {} };
422
838
  for (const key in versions) {
423
839
  const version = versions[key];
424
840
  if (!version || version[0] === 0 || !shouldPersist(key)) continue;
@@ -458,6 +874,7 @@ function createSharedStore(name, initial, options = {}) {
458
874
  let storeClosed = false;
459
875
  return {
460
876
  clientId,
877
+ hydrated,
461
878
  state: proxy,
462
879
  getSnapshot: () => snapshot,
463
880
  getVersions: () => versionsSnapshot,
@@ -465,6 +882,9 @@ function createSharedStore(name, initial, options = {}) {
465
882
  const next = typeof value === "function" ? value(state[key]) : value;
466
883
  setKey(key, next);
467
884
  },
885
+ transaction(fn) {
886
+ return batch(fn);
887
+ },
468
888
  subscribe(fn) {
469
889
  listeners.add(fn);
470
890
  return () => listeners.delete(fn);
@@ -496,6 +916,7 @@ function createSharedStore(name, initial, options = {}) {
496
916
  if (live > 0) liveStores.set(name, live);
497
917
  else liveStores.delete(name);
498
918
  }
919
+ cancelSnapshot();
499
920
  if (hasWindow) removeEventListener("pageshow", onPageShow);
500
921
  if (flushPersist) {
501
922
  flushPersist();
@@ -511,62 +932,149 @@ function createSharedStore(name, initial, options = {}) {
511
932
  };
512
933
  }
513
934
 
514
- // src/presence.ts
515
- function createPresence(name, options = {}) {
516
- const pruneAfterMs = options.pruneAfterMs ?? 5e3;
517
- const bus = getBus(name, options);
518
- const peers = /* @__PURE__ */ new Map();
935
+ // src/leader-web-locks.ts
936
+ var NO_LEADER = Object.freeze({ leaderId: null, isLeader: false });
937
+ function createWebLocksLeader(name, options, locks) {
938
+ const busOptions = {
939
+ ...options.transport ? { transport: options.transport } : {},
940
+ ...options.kind ? { kind: options.kind } : {}
941
+ };
942
+ const bus = getBus(name, busOptions);
943
+ const clientId = bus.clientId;
944
+ let eligible = options.eligible ?? true;
945
+ let leaderId = null;
946
+ let snapshot = NO_LEADER;
947
+ let closed = false;
519
948
  const listeners = /* @__PURE__ */ new Set();
520
- let snapshot = [];
521
- function notify() {
522
- snapshot = Object.freeze([...peers.values()]);
949
+ const waiters = /* @__PURE__ */ new Set();
950
+ let releaseHeld = null;
951
+ let pending = null;
952
+ function setLeader(id) {
953
+ if (id === leaderId) return;
954
+ leaderId = id;
955
+ snapshot = Object.freeze({ leaderId: id, isLeader: id === clientId });
523
956
  for (const fn of listeners) fn();
957
+ if (id === clientId) {
958
+ for (const waiter of waiters) waiter.resolve();
959
+ waiters.clear();
960
+ }
524
961
  }
962
+ const term = [1, clientId];
963
+ const announce2 = () => bus.post({ v: 1, scope: "leader", type: "heartbeat", term, clientId, kind: bus.kind });
525
964
  const unsubscribe = bus.subscribe((wire) => {
526
- if (wire.scope === "presence" && wire.type === "bye") {
527
- if (peers.delete(wire.clientId)) notify();
965
+ if (wire.scope !== "leader") return;
966
+ if (wire.type === "hello") {
967
+ if (leaderId === clientId) announce2();
528
968
  return;
529
969
  }
530
- const existing = peers.get(wire.clientId);
531
- peers.set(wire.clientId, { id: wire.clientId, kind: wire.kind, lastSeen: Date.now() });
532
- if (!existing) notify();
970
+ if (wire.type === "resign") {
971
+ if (wire.clientId === leaderId) setLeader(null);
972
+ return;
973
+ }
974
+ setLeader(wire.clientId);
533
975
  });
534
- const prune = setInterval(
535
- () => {
536
- const cutoff = Date.now() - pruneAfterMs;
537
- let changed = false;
538
- for (const [id, peer] of peers) {
539
- if (peer.lastSeen < cutoff) {
540
- peers.delete(id);
541
- changed = true;
542
- }
543
- }
544
- if (changed) notify();
545
- },
546
- Math.max(500, Math.floor(pruneAfterMs / 2))
547
- );
548
- let closed = false;
976
+ function joinQueue() {
977
+ if (closed || !eligible || releaseHeld || pending) return;
978
+ const controller = new AbortController();
979
+ pending = controller;
980
+ const held = new Promise((resolve) => {
981
+ releaseHeld = resolve;
982
+ });
983
+ void locks.request(name, { signal: controller.signal }, async () => {
984
+ pending = null;
985
+ if (closed || !eligible) return;
986
+ setLeader(clientId);
987
+ announce2();
988
+ await held;
989
+ }).catch(() => {
990
+ });
991
+ }
992
+ function letGo() {
993
+ const release = releaseHeld;
994
+ releaseHeld = null;
995
+ release?.();
996
+ }
997
+ function resign() {
998
+ if (leaderId !== clientId) return;
999
+ letGo();
1000
+ setLeader(null);
1001
+ bus.post({ v: 1, scope: "leader", type: "resign", term, clientId, kind: bus.kind });
1002
+ joinQueue();
1003
+ }
1004
+ const hasWindow = typeof document !== "undefined" && typeof addEventListener === "function";
1005
+ const onPageHide = () => resign();
1006
+ if (hasWindow) addEventListener("pagehide", onPageHide);
1007
+ bus.post({ v: 1, scope: "leader", type: "hello", clientId, kind: bus.kind });
1008
+ joinQueue();
549
1009
  return {
550
- clientId: bus.clientId,
551
- getPeers: () => snapshot,
1010
+ clientId,
1011
+ strategy: "web-locks",
1012
+ getSnapshot: () => snapshot,
552
1013
  subscribe(fn) {
553
1014
  listeners.add(fn);
554
1015
  return () => listeners.delete(fn);
555
1016
  },
1017
+ waitForLeadership() {
1018
+ if (leaderId === clientId) return Promise.resolve();
1019
+ if (closed) return Promise.reject(new Error("leader is closed"));
1020
+ return new Promise((resolve, reject) => {
1021
+ waiters.add({ resolve, reject });
1022
+ });
1023
+ },
1024
+ resign,
1025
+ setEligible(next) {
1026
+ if (next === eligible) return;
1027
+ eligible = next;
1028
+ if (next) {
1029
+ joinQueue();
1030
+ return;
1031
+ }
1032
+ if (leaderId === clientId) resignWithoutRequeue();
1033
+ pending?.abort();
1034
+ pending = null;
1035
+ },
556
1036
  close() {
557
1037
  if (closed) return;
558
1038
  closed = true;
559
- clearInterval(prune);
1039
+ if (leaderId === clientId) resignWithoutRequeue();
1040
+ pending?.abort();
1041
+ pending = null;
1042
+ if (hasWindow) removeEventListener("pagehide", onPageHide);
1043
+ for (const waiter of waiters) waiter.reject(new Error("leader is closed"));
1044
+ waiters.clear();
560
1045
  unsubscribe();
561
1046
  listeners.clear();
562
1047
  bus.release();
563
1048
  }
564
1049
  };
1050
+ function resignWithoutRequeue() {
1051
+ letGo();
1052
+ setLeader(null);
1053
+ bus.post({ v: 1, scope: "leader", type: "resign", term, clientId, kind: bus.kind });
1054
+ }
565
1055
  }
566
1056
 
567
1057
  // src/leader.ts
568
- var NO_LEADER = Object.freeze({ leaderId: null, isLeader: false });
1058
+ var NO_LEADER2 = Object.freeze({ leaderId: null, isLeader: false });
1059
+ function availableLocks(options) {
1060
+ if (options.locks) return options.locks;
1061
+ const locks = globalThis.navigator?.locks;
1062
+ return typeof locks?.request === "function" ? locks : void 0;
1063
+ }
569
1064
  function createLeader(name, options = {}) {
1065
+ const strategy = options.strategy ?? "auto";
1066
+ if (strategy !== "heartbeat") {
1067
+ const locks = availableLocks(options);
1068
+ if (locks) return createWebLocksLeader(name, options, locks);
1069
+ if (strategy === "web-locks") {
1070
+ throw new Error(
1071
+ 'strategy: "web-locks" was requested but navigator.locks is unavailable. Web Locks needs a secure context (https, or localhost) \u2014 use "auto" to fall back to the heartbeat election.'
1072
+ );
1073
+ }
1074
+ }
1075
+ return createHeartbeatLeader(name, options);
1076
+ }
1077
+ function createHeartbeatLeader(name, options) {
570
1078
  const heartbeatMs = options.heartbeatMs ?? 1e3;
571
1079
  const leaseMs = options.leaseMs ?? 3e3;
572
1080
  const busOptions = {
@@ -578,16 +1086,21 @@ function createLeader(name, options = {}) {
578
1086
  let eligible = options.eligible ?? true;
579
1087
  let leaderId = null;
580
1088
  let term = [0, clientId];
581
- let snapshot = NO_LEADER;
1089
+ let snapshot = NO_LEADER2;
582
1090
  let beat;
583
1091
  let lease;
584
1092
  let closed = false;
585
1093
  const listeners = /* @__PURE__ */ new Set();
1094
+ const waiters = /* @__PURE__ */ new Set();
586
1095
  function setLeader(id) {
587
1096
  if (id === leaderId) return;
588
1097
  leaderId = id;
589
1098
  snapshot = Object.freeze({ leaderId: id, isLeader: id === clientId });
590
1099
  for (const fn of listeners) fn();
1100
+ if (id === clientId) {
1101
+ for (const waiter of waiters) waiter.resolve();
1102
+ waiters.clear();
1103
+ }
591
1104
  }
592
1105
  function armLease(delay) {
593
1106
  clearTimeout(lease);
@@ -625,6 +1138,7 @@ function createLeader(name, options = {}) {
625
1138
  armLease(0);
626
1139
  return;
627
1140
  }
1141
+ if (!isVersion(wire.term)) return;
628
1142
  if (newer(wire.term, term)) {
629
1143
  term = wire.term;
630
1144
  stepDown(wire.clientId);
@@ -660,11 +1174,19 @@ function createLeader(name, options = {}) {
660
1174
  armLease(heartbeatMs);
661
1175
  return {
662
1176
  clientId,
1177
+ strategy: "heartbeat",
663
1178
  getSnapshot: () => snapshot,
664
1179
  subscribe(fn) {
665
1180
  listeners.add(fn);
666
1181
  return () => listeners.delete(fn);
667
1182
  },
1183
+ waitForLeadership() {
1184
+ if (leaderId === clientId) return Promise.resolve();
1185
+ if (closed) return Promise.reject(new Error("leader is closed"));
1186
+ return new Promise((resolve, reject) => {
1187
+ waiters.add({ resolve, reject });
1188
+ });
1189
+ },
668
1190
  resign,
669
1191
  setEligible(next) {
670
1192
  if (next === eligible) return;
@@ -679,6 +1201,8 @@ function createLeader(name, options = {}) {
679
1201
  if (closed) return;
680
1202
  closed = true;
681
1203
  resign();
1204
+ for (const waiter of waiters) waiter.reject(new Error("leader is closed"));
1205
+ waiters.clear();
682
1206
  clearInterval(beat);
683
1207
  clearTimeout(lease);
684
1208
  if (hasWindow) {
@@ -692,6 +1216,246 @@ function createLeader(name, options = {}) {
692
1216
  };
693
1217
  }
694
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
+
695
1459
  // src/persist-web-storage.ts
696
1460
  function isPersisted(value) {
697
1461
  if (typeof value !== "object" || value === null) return false;
@@ -699,6 +1463,7 @@ function isPersisted(value) {
699
1463
  return candidate.v === 1 && typeof candidate.state === "object" && typeof candidate.versions === "object";
700
1464
  }
701
1465
  function webStorageAdapter(storage, key, options = {}) {
1466
+ const serializer = options.serializer ?? jsonSerializer;
702
1467
  const report = (error, operation) => {
703
1468
  try {
704
1469
  options.onError?.(error, operation);
@@ -718,7 +1483,7 @@ function webStorageAdapter(storage, key, options = {}) {
718
1483
  try {
719
1484
  const raw = resolve("read")?.getItem(key);
720
1485
  if (!raw) return void 0;
721
- const parsed = JSON.parse(raw);
1486
+ const parsed = serializer.parse(raw);
722
1487
  return isPersisted(parsed) ? parsed : void 0;
723
1488
  } catch (error) {
724
1489
  report(error, "read");
@@ -727,7 +1492,7 @@ function webStorageAdapter(storage, key, options = {}) {
727
1492
  },
728
1493
  write(snapshot) {
729
1494
  try {
730
- resolve("write")?.setItem(key, JSON.stringify(snapshot));
1495
+ resolve("write")?.setItem(key, serializer.stringify(snapshot));
731
1496
  } catch (error) {
732
1497
  report(error, "write");
733
1498
  }
@@ -748,6 +1513,58 @@ function sessionStorageAdapter(key, options) {
748
1513
  return webStorageAdapter(() => globalThis.sessionStorage, key, options);
749
1514
  }
750
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
+
751
1568
  // src/errors/handshake-timeout-error.ts
752
1569
  var HandshakeTimeoutError = class extends Error {
753
1570
  constructor(message = "handshake with the other window timed out") {
@@ -982,22 +1799,50 @@ function connectToOpener(options) {
982
1799
  }
983
1800
  };
984
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
+ }
985
1821
  export {
986
1822
  BroadcastChannelTransport,
987
1823
  CID_PARAM,
988
1824
  DEFAULT_NAME,
989
1825
  HandshakeTimeoutError,
990
1826
  NoopTransport,
1827
+ StorageTransport,
1828
+ WIRE_VERSION,
991
1829
  WindowClosedError,
992
1830
  connectToOpener,
993
1831
  createChannel,
994
1832
  createLeader,
1833
+ createNamespace,
995
1834
  createPresence,
1835
+ createSharedReducer,
996
1836
  createSharedStore,
997
1837
  defaultTransport,
998
1838
  enableDebug,
999
1839
  getBusNames,
1840
+ getTransportKind,
1841
+ getWireSkew,
1842
+ indexedDbAdapter,
1000
1843
  isBroadcastChannelAvailable,
1844
+ isStorageEventAvailable,
1845
+ jsonSerializer,
1001
1846
  localStorageAdapter,
1002
1847
  newer,
1003
1848
  observeBus,