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