@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.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { T as Transport } from './transport.types-tQ1cu6Xm.js';
1
+ import { T as Transport, a as TransportKind } from './transport.types-CV1WZOhy.js';
2
2
 
3
3
  type MessageMap = Record<string, unknown>;
4
4
  type PeerKind = 'tab' | 'worker' | (string & {});
@@ -8,6 +8,8 @@ interface Peer {
8
8
  id: string;
9
9
  kind: PeerKind;
10
10
  lastSeen: number;
11
+ /** What that client published about itself, if anything. */
12
+ metadata?: unknown;
11
13
  }
12
14
  interface MessageMeta {
13
15
  clientId: string;
@@ -21,17 +23,142 @@ interface CommonOptions {
21
23
  kind?: PeerKind;
22
24
  }
23
25
 
24
- interface Channel<M extends MessageMap> {
26
+ /**
27
+ * The [Standard Schema](https://standardschema.dev) v1 interface, inlined.
28
+ *
29
+ * Inlined rather than depended on, which is the whole point of the spec: it is
30
+ * a shape, not a package. Zod, Valibot and ArkType all expose `~standard`, so
31
+ * accepting this type means accepting any of them — and any hand-written
32
+ * validator — without this library taking a dependency on, or a position about,
33
+ * which one you use.
34
+ *
35
+ * Only the parts that are read here are declared. The full spec has more.
36
+ */
37
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
38
+ readonly '~standard': {
39
+ readonly version: 1;
40
+ readonly vendor: string;
41
+ readonly validate: (value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
42
+ readonly types?: {
43
+ readonly input: Input;
44
+ readonly output: Output;
45
+ } | undefined;
46
+ };
47
+ }
48
+ type StandardSchemaResult<Output> = {
49
+ readonly value: Output;
50
+ readonly issues?: undefined;
51
+ } | {
52
+ readonly issues: ReadonlyArray<{
53
+ readonly message: string;
54
+ }>;
55
+ };
56
+ /** Why a payload was refused, for {@link OnInvalid}. */
57
+ interface InvalidPayload {
58
+ /** The bus this happened on. */
59
+ readonly name: string;
60
+ /** Message type for a channel, key for a store. */
61
+ readonly key: string;
62
+ /** `'in'` — a peer sent it; `'out'` — this client tried to send it. */
63
+ readonly direction: 'in' | 'out';
64
+ /** The value as it arrived. Not validated, so genuinely `unknown`. */
65
+ readonly payload: unknown;
66
+ /** One line per issue the schema reported, or a single line explaining a schema that could not be used. */
67
+ readonly issues: readonly string[];
68
+ }
69
+ /**
70
+ * Called when a payload fails its schema, instead of the default development
71
+ * warning. Report it, count it, sample it — but it does not change the outcome:
72
+ * an inbound payload is dropped and an outbound one throws either way.
73
+ */
74
+ type OnInvalid = (info: InvalidPayload) => void;
75
+ /**
76
+ * Per-key validators. A key with no entry is not validated, so adopting this
77
+ * one message or one store key at a time is the expected way to use it.
78
+ */
79
+ type SchemaMap<M> = {
80
+ readonly [K in keyof M & string]?: StandardSchemaV1<unknown, M[K]>;
81
+ };
82
+ interface SchemaOptions<M> {
83
+ /**
84
+ * Validate payloads against a [Standard Schema](https://standardschema.dev)
85
+ * before trusting them — any Zod, Valibot or ArkType schema, or anything else
86
+ * exposing `~standard`.
87
+ *
88
+ * Without this, an inbound payload is **cast, not checked**: a peer running
89
+ * last week's deploy sends whatever that build thought the shape was, and the
90
+ * receiving code reads it as whatever this build's types say. That is the one
91
+ * place in this library where a type is a hope rather than a guarantee, and
92
+ * a rolling deploy is what turns it into a bug.
93
+ *
94
+ * Validation runs in both directions. Inbound, a failure drops the payload —
95
+ * the same choice the envelope makes for a wire it cannot read. Outbound, it
96
+ * **throws**, because a value your own code just produced and cannot describe
97
+ * is a bug in this tab, and finding it here beats finding it in someone
98
+ * else's.
99
+ */
100
+ readonly schema?: SchemaMap<M>;
101
+ /** Observe validation failures instead of the default development warning. */
102
+ readonly onInvalid?: OnInvalid;
103
+ }
104
+
105
+ interface ChannelOptions<M extends MessageMap> extends CommonOptions, SchemaOptions<M> {
106
+ }
107
+ /**
108
+ * What each message type answers with, for `ask`/`answer`.
109
+ *
110
+ * A separate map from the request types, and empty by default, so
111
+ * request/response is opt-in and typed rather than `unknown` everywhere.
112
+ *
113
+ * ```ts
114
+ * type Requests = { 'config:get': void };
115
+ * type Replies = { 'config:get': { theme: string } };
116
+ * const channel = createChannel<Requests, Replies>('app');
117
+ * ```
118
+ */
119
+ type ReplyMap<M extends MessageMap> = Partial<Record<keyof M, unknown>>;
120
+ interface PostOptions {
121
+ /**
122
+ * Also deliver to this client's own handlers.
123
+ *
124
+ * A post is not echoed by default, which matches `BroadcastChannel` and is
125
+ * usually right. It is wrong for the case the README kept demonstrating: a
126
+ * component that has to update local state *and* tell everyone else ends up
127
+ * writing the same effect twice, in two places, which then drift.
128
+ */
129
+ echo?: boolean;
130
+ }
131
+ interface OnOptions {
132
+ /** Unsubscribe after the first message. The returned function is still safe to call. */
133
+ once?: boolean;
134
+ }
135
+ interface AskOptions {
136
+ /** Give up after this long. Default 5000. */
137
+ timeoutMs?: number;
138
+ }
139
+ interface Channel<M extends MessageMap, R extends ReplyMap<M> = Record<never, never>> {
25
140
  readonly name: string;
26
141
  readonly clientId: string;
27
- /** Fire-and-forget to every other tab/window/worker on this origin. Not echoed to self. */
28
- post<K extends keyof M & string>(type: K, payload: M[K]): void;
29
- on<K extends keyof M & string>(type: K, handler: (payload: M[K], meta: MessageMeta) => void): () => void;
142
+ /** Fire-and-forget to every other tab/window/worker on this origin. */
143
+ post<K extends keyof M & string>(type: K, payload: M[K], options?: PostOptions): void;
144
+ on<K extends keyof M & string>(type: K, handler: (payload: M[K], meta: MessageMeta) => void, options?: OnOptions): () => void;
145
+ /**
146
+ * Ask the origin a question and wait for the first answer.
147
+ *
148
+ * Rejects if nobody answers before the timeout — an unanswered question is a
149
+ * fact worth having rather than a promise that hangs. If several clients
150
+ * registered an `answer` for this type, the first reply to arrive wins and
151
+ * the rest are dropped; gate the responder on leadership when you need the
152
+ * answer to come from a particular tab.
153
+ */
154
+ ask<K extends keyof M & keyof R & string>(type: K, payload: M[K], options?: AskOptions): Promise<R[K]>;
155
+ /** Answer `ask`s of this type. Returns an unsubscribe. */
156
+ answer<K extends keyof M & keyof R & string>(type: K, responder: (payload: M[K], meta: MessageMeta) => R[K]): () => void;
30
157
  close(): void;
31
158
  }
32
159
 
33
160
  /** Typed pub/sub over the same-origin bus. */
34
- declare function createChannel<M extends MessageMap>(name: string, options?: CommonOptions): Channel<M>;
161
+ declare function createChannel<M extends MessageMap, R extends ReplyMap<M> = Record<never, never>>(name: string, options?: ChannelOptions<M>): Channel<M, R>;
35
162
 
36
163
  /**
37
164
  * What goes to disk. The version clocks travel *with* the values — that is the
@@ -40,7 +167,18 @@ declare function createChannel<M extends MessageMap>(name: string, options?: Com
40
167
  * legitimately lose to, whatever the live tabs are holding.
41
168
  */
42
169
  interface Persisted {
170
+ /**
171
+ * The *envelope* version — the shape of this record, owned by the library.
172
+ * Not to be confused with `schema`, which is the shape of your state and is
173
+ * owned by you.
174
+ */
43
175
  v: 1;
176
+ /**
177
+ * The app's state-shape version at the time of writing, from
178
+ * {@link PersistOptions.version}. Absent on anything written before
179
+ * versioning existed, which reads as 0.
180
+ */
181
+ schema?: number;
44
182
  state: Record<string, unknown>;
45
183
  versions: Record<string, Version>;
46
184
  }
@@ -53,15 +191,56 @@ interface PersistAdapter {
53
191
  write(snapshot: Persisted): void | Promise<void>;
54
192
  remove?(): void | Promise<void>;
55
193
  }
194
+ /** Why a restore was refused, for {@link PersistOptions.onRestoreError}. */
195
+ interface RestoreError {
196
+ /** `'ahead'` — written by a newer build; `'no-migrate'` — older, with no way forward; `'migrate-threw'`. */
197
+ readonly reason: 'ahead' | 'no-migrate' | 'migrate-threw';
198
+ /** The schema version on disk. */
199
+ readonly found: number;
200
+ /** The schema version this build expects. */
201
+ readonly expected: number;
202
+ /** Present only for `'migrate-threw'`. */
203
+ readonly cause?: unknown;
204
+ }
56
205
  interface PersistOptions {
57
206
  adapter: PersistAdapter;
58
207
  /** Persist only these keys. Default: every key that has been written. */
59
208
  keys?: string[];
60
209
  /** Coalesce writes for this long. Default 100. */
61
210
  debounceMs?: number;
211
+ /**
212
+ * The version of *your* state's shape. Bump it whenever a key changes meaning
213
+ * or type, and supply {@link migrate} to carry old data forward.
214
+ *
215
+ * Disk is where version skew has its longest fuse. A wire from another deploy
216
+ * is gone in a second; a value written by last month's build sits there until
217
+ * someone reopens the tab, and then restores with a clock that beats every
218
+ * live tab. Without a version there is no way to even notice.
219
+ *
220
+ * Default 0, which is also what anything written before this existed reads as
221
+ * — so adding `version: 1` and a `migrate` is enough to adopt it.
222
+ */
223
+ version?: number;
224
+ /**
225
+ * Bring persisted state written at an older `version` up to the current one.
226
+ * Return the migrated state; the version clocks are carried over untouched,
227
+ * so a migrated value keeps its place in the last-writer-wins order.
228
+ *
229
+ * Only called when `from` is *older*. Newer data — an older build reading what
230
+ * a newer one wrote — is refused instead, because a build cannot be asked to
231
+ * understand a shape that postdates it. That is the same call the wire makes
232
+ * for a protocol version it does not know.
233
+ */
234
+ migrate?: (state: Record<string, unknown>, from: number) => Record<string, unknown>;
235
+ /**
236
+ * Called when persisted state is refused instead of restored. The store keeps
237
+ * its initial values and carries on either way — this is how you find out,
238
+ * and the default is a development warning.
239
+ */
240
+ onRestoreError?: (error: RestoreError) => void;
62
241
  }
63
242
 
64
- interface SharedStoreOptions extends CommonOptions {
243
+ interface SharedStoreOptions<S = Record<string, unknown>> extends CommonOptions, SchemaOptions<S> {
65
244
  /**
66
245
  * Gatekeeper for incoming remote writes (patches and snapshot merges):
67
246
  * return false to ignore them. Lets callers delimit how much is shared —
@@ -70,9 +249,35 @@ interface SharedStoreOptions extends CommonOptions {
70
249
  accept?: (meta: MessageMeta) => boolean;
71
250
  /** Restore this store on creation and write it back as it changes. */
72
251
  persist?: PersistOptions;
252
+ /**
253
+ * Longest pause before answering a late joiner's request for state, in ms.
254
+ * Default 40. The actual wait is random within it, so peers do not all
255
+ * answer at once and the first reply cancels the rest.
256
+ */
257
+ snapshotDelayMs?: number;
73
258
  }
74
259
  interface SharedStore<S extends Record<string, unknown>> {
75
260
  readonly clientId: string;
261
+ /**
262
+ * Resolves once persisted state has been restored — or refused, or found
263
+ * absent. Already resolved when there is no `persist` option at all.
264
+ *
265
+ * Exists because an async adapter cannot hydrate before the store is handed
266
+ * back, and until now that gap was *unobservable*: a keystroke landing in it
267
+ * writes at counter 1, the restore arrives holding counter 5, and
268
+ * last-writer-wins correctly discards the newer keystroke. The behaviour is
269
+ * right and the surprise is total. Gate first paint or first input on this
270
+ * and the gap closes:
271
+ *
272
+ * ```ts
273
+ * await store.hydrated;
274
+ * ```
275
+ *
276
+ * Never rejects. A refused restore is reported through
277
+ * `persist.onRestoreError` and still settles, because a store that kept its
278
+ * initial values is usable and a promise nobody can await is not.
279
+ */
280
+ readonly hydrated: Promise<void>;
76
281
  /** Live proxy for imperative use: `store.state.count++` syncs everywhere. */
77
282
  readonly state: S;
78
283
  /** Immutable snapshot, replaced whenever a change is applied. Safe for useSyncExternalStore. */
@@ -80,6 +285,26 @@ interface SharedStore<S extends Record<string, unknown>> {
80
285
  /** The per-key version clocks behind the snapshot. Referentially stable, like getSnapshot. */
81
286
  getVersions(): Readonly<Record<string, Version>>;
82
287
  set<K extends keyof S & string>(key: K, value: S[K] | ((prev: S[K]) => S[K])): void;
288
+ /**
289
+ * Apply several writes, then notify subscribers once with the settled state.
290
+ *
291
+ * ```ts
292
+ * store.transaction(() => {
293
+ * store.set('firstName', 'Ada');
294
+ * store.set('lastName', 'Lovelace');
295
+ * });
296
+ * ```
297
+ *
298
+ * **Local batching, not a distributed transaction.** Each write is still its
299
+ * own patch on the wire, so a peer may see them arrive separately — making
300
+ * them atomic across tabs would need a wire type older builds would silently
301
+ * ignore, which is worse than the problem. What this buys is one re-render
302
+ * instead of N, and subscribers that never observe a half-applied group.
303
+ *
304
+ * Nests: only the outermost call flushes. Returns whatever `fn` returns, and
305
+ * flushes even if `fn` throws — the writes that did land are already real.
306
+ */
307
+ transaction<T>(fn: () => T): T;
83
308
  subscribe(fn: (key: keyof S & string, value: unknown, meta: MessageMeta) => void): () => void;
84
309
  subscribeKey(key: keyof S & string, fn: () => void): () => void;
85
310
  /**
@@ -96,9 +321,161 @@ interface SharedStore<S extends Record<string, unknown>> {
96
321
  * last-writer-wins version clocks and a hello/snapshot late-joiner handshake.
97
322
  * Create at most one store per name per tab (the React package memoizes).
98
323
  */
99
- declare function createSharedStore<S extends Record<string, unknown>>(name: string, initial: S, options?: SharedStoreOptions): SharedStore<S>;
324
+ declare function createSharedStore<S extends Record<string, unknown>>(name: string, initial: S, options?: SharedStoreOptions<S>): SharedStore<S>;
100
325
 
101
- /** Everything on the same-origin bus, multiplexed by scope over one BroadcastChannel per name. */
326
+ /**
327
+ * Deliberately extends CommonOptions, not BusOptions: `heartbeatMs` here means
328
+ * the leader's re-announce interval, which is a different thing from the bus's
329
+ * presence ping. See the note in leader.ts about forwarding to getBus.
330
+ */
331
+ /**
332
+ * How the seat is arbitrated.
333
+ *
334
+ * - `'web-locks'` — the browser's Web Locks API owns the queue. The lock is
335
+ * released by the browser itself when a tab dies, and holding it does not
336
+ * depend on a timer, so a backgrounded tab cannot be deposed for being
337
+ * throttled. No heartbeat traffic at all.
338
+ * - `'heartbeat'` — lease-and-claim over the bus. Works anywhere, including
339
+ * plain-http origins where `navigator.locks` does not exist.
340
+ * - `'auto'` (default) — Web Locks when available, heartbeat otherwise.
341
+ */
342
+ type LeaderStrategy = 'auto' | 'web-locks' | 'heartbeat';
343
+ interface LeaderOptions extends CommonOptions {
344
+ /** How often the leader re-announces itself, in ms. Default 1000. Heartbeat strategy only. */
345
+ heartbeatMs?: number;
346
+ /** How long a follower tolerates silence before calling the seat empty, in ms. Default 3000. Heartbeat strategy only. */
347
+ leaseMs?: number;
348
+ /** May this client hold the leadership? Default true. */
349
+ eligible?: boolean;
350
+ /** How to arbitrate the seat. Default 'auto'. */
351
+ strategy?: LeaderStrategy;
352
+ /** @internal Test seam for the Web Locks manager. Defaults to navigator.locks. */
353
+ locks?: LockManagerLike;
354
+ }
355
+ /** The slice of the Web Locks API this library uses. */
356
+ interface LockManagerLike {
357
+ request(name: string, options: {
358
+ signal?: AbortSignal;
359
+ }, callback: () => Promise<void>): Promise<void>;
360
+ }
361
+ interface LeaderSnapshot {
362
+ /** The current leader's clientId, or null while the seat is empty. */
363
+ readonly leaderId: string | null;
364
+ readonly isLeader: boolean;
365
+ }
366
+ interface Leader {
367
+ readonly clientId: string;
368
+ /** Which mechanism arbitrates this seat — useful in devtools and bug reports. */
369
+ readonly strategy: Exclude<LeaderStrategy, 'auto'>;
370
+ /** Frozen; a new object only when the leader actually changes. */
371
+ getSnapshot(): LeaderSnapshot;
372
+ subscribe(fn: () => void): () => void;
373
+ /**
374
+ * Resolves the moment this client holds the seat, or immediately if it
375
+ * already does. Rejects if the leader is closed while still waiting — so an
376
+ * `await` in a torn-down tab does not hang forever.
377
+ */
378
+ waitForLeadership(): Promise<void>;
379
+ /** Give up the seat now. Peers take over immediately rather than waiting for the lease. */
380
+ resign(): void;
381
+ /** Turn candidacy on or off. Eligibility is a property of the tab, not a component. */
382
+ setEligible(eligible: boolean): void;
383
+ close(): void;
384
+ }
385
+
386
+ interface SharedReducerOptions extends CommonOptions {
387
+ /**
388
+ * Which reducer this is, when several share a bus name. Default `'default'`.
389
+ *
390
+ * The same axis as a store key: one bus, many reducers, each ordered
391
+ * independently of the others.
392
+ */
393
+ key?: string;
394
+ /**
395
+ * Reuse an existing leader rather than electing another. A reducer needs a
396
+ * sequencer, and a page that already has a `Leader` for this bus should not
397
+ * run a second election to get one.
398
+ */
399
+ leader?: Leader;
400
+ /** Election settings, when this reducer elects its own leader. Ignored if `leader` is passed. */
401
+ leaderOptions?: LeaderOptions;
402
+ }
403
+ interface SharedReducer<S, A> {
404
+ readonly clientId: string;
405
+ /** Immutable, replaced whenever the value changes. Safe for useSyncExternalStore. */
406
+ getSnapshot(): S;
407
+ /**
408
+ * Apply an action everywhere.
409
+ *
410
+ * Applied locally first so the UI does not wait for a round trip, then
411
+ * proposed to the leader for ordering. If the committed order turns out to
412
+ * differ from the optimistic one, the local value is rebuilt from the
413
+ * committed state — so a dispatch can be *seen* out of order for a moment,
414
+ * but never *settle* out of order.
415
+ */
416
+ dispatch(action: A): void;
417
+ subscribe(fn: () => void): () => void;
418
+ /**
419
+ * Number of dispatches from this client that have not yet come back
420
+ * committed. Zero means this client's view is entirely confirmed.
421
+ */
422
+ pendingCount(): number;
423
+ close(): void;
424
+ }
425
+
426
+ /**
427
+ * State that converges by *replaying actions in one order*, rather than by
428
+ * last-writer-wins on a value.
429
+ *
430
+ * ## Why this exists
431
+ *
432
+ * `useSharedState`'s convergence rule is last-writer-wins per key, and for a
433
+ * register — a theme, a selected row, a draft — that is exactly right. For an
434
+ * *accumulating* write it is exactly wrong, and the README's own counter
435
+ * example was the proof: two tabs running `set('n', n => n + 1)` at the same
436
+ * moment both read 4, both write 5, and one increment is silently gone. Nothing
437
+ * is broken, no error is raised, and the number is simply too small.
438
+ *
439
+ * A reducer fixes it by moving what travels. LWW ships the *result* of the
440
+ * increment, so concurrent results overwrite each other; this ships the
441
+ * *increment*, and results are computed by every client from the same ordered
442
+ * list. Two increments are two entries in that list.
443
+ *
444
+ * ## How the order is decided
445
+ *
446
+ * The leader is the sequencer. A dispatch is broadcast as a `propose`; the
447
+ * leader stamps it with the next number and broadcasts a `commit`; every client
448
+ * — the leader included — applies commits strictly in that order. One list, one
449
+ * order, one answer, for *any* reducer.
450
+ *
451
+ * Deliberately not "op-log CRDT for commutative operations", which is cheaper
452
+ * and needs no leader. That design converges only if the reducer happens to be
453
+ * commutative, and nothing in a function's type says whether it is. A library
454
+ * whose rule is that silent divergence is the worst failure mode cannot ship a
455
+ * primitive whose correctness depends on a property it cannot check.
456
+ *
457
+ * ## What it costs
458
+ *
459
+ * A dispatch is applied locally at once and reconciled when its commit arrives,
460
+ * so typing is never gated on the network. If the committed order differs from
461
+ * the optimistic one, the value is rebuilt from committed state plus whatever
462
+ * is still pending — visible as a brief correction, never as a wrong result.
463
+ *
464
+ * Leadership here inherits leadership's own caveat: it is advisory. In the
465
+ * moment two tabs both believe they hold the seat, two commits can carry the
466
+ * same number. The second one to arrive is dropped, and the client asks for a
467
+ * fresh snapshot rather than guessing — so the outcome is a re-sync, not a
468
+ * divergence. Anything that must happen exactly once still needs a server.
469
+ */
470
+ declare function createSharedReducer<S, A>(name: string, reducer: (state: S, action: A) => S, initial: S, options?: SharedReducerOptions): SharedReducer<S, A>;
471
+
472
+ /**
473
+ * Everything on the same-origin bus, multiplexed by scope over one
474
+ * BroadcastChannel per name.
475
+ *
476
+ * `v` is the wire protocol version, and changing it is a decision with rules —
477
+ * see `wire.ts` for what may be added within a version and what must bump it.
478
+ */
102
479
  type BusWire = {
103
480
  v: 1;
104
481
  scope: 'state';
@@ -128,6 +505,16 @@ type BusWire = {
128
505
  type: 'hello' | 'ping' | 'bye';
129
506
  clientId: string;
130
507
  kind: PeerKind;
508
+ /**
509
+ * Whatever this client wants peers to know about it — a display name, a
510
+ * tab title, a cursor.
511
+ *
512
+ * Carried on `hello` only, never on a ping. A ping is a heartbeat and
513
+ * arrives constantly; attaching metadata to it would re-announce
514
+ * unchanged data forever and churn every subscriber's roster. Additive
515
+ * within wire v1: a build that predates it neither sets nor reads it.
516
+ */
517
+ metadata?: unknown;
131
518
  } | {
132
519
  v: 1;
133
520
  scope: 'leader';
@@ -142,6 +529,43 @@ type BusWire = {
142
529
  term: Version;
143
530
  clientId: string;
144
531
  kind: PeerKind;
532
+ } | {
533
+ v: 1;
534
+ scope: 'op';
535
+ type: 'hello';
536
+ key: string;
537
+ clientId: string;
538
+ kind: PeerKind;
539
+ } | {
540
+ v: 1;
541
+ scope: 'op';
542
+ type: 'propose';
543
+ key: string;
544
+ action: unknown;
545
+ /** Identifies this dispatch across its proposal and its commit, so a commit can be recognised as one's own and applied once. */
546
+ opId: string;
547
+ clientId: string;
548
+ kind: PeerKind;
549
+ } | {
550
+ v: 1;
551
+ scope: 'op';
552
+ type: 'commit';
553
+ key: string;
554
+ action: unknown;
555
+ opId: string;
556
+ /** The leader's ordering decision: a gapless counter every client replays in the same order. */
557
+ seq: number;
558
+ clientId: string;
559
+ kind: PeerKind;
560
+ } | {
561
+ v: 1;
562
+ scope: 'op';
563
+ type: 'snapshot';
564
+ key: string;
565
+ state: unknown;
566
+ seq: number;
567
+ clientId: string;
568
+ kind: PeerKind;
145
569
  } | {
146
570
  v: 1;
147
571
  scope: 'event';
@@ -150,6 +574,14 @@ type BusWire = {
150
574
  clientId: string;
151
575
  kind: PeerKind;
152
576
  msgId: string;
577
+ /**
578
+ * The `msgId` this is an answer to, when it is one.
579
+ *
580
+ * Additive within wire v1: a build that predates `ask` never sets it and
581
+ * never reads it, so the field is simply absent both ways — which is the
582
+ * rule for new optional fields (see wire.ts).
583
+ */
584
+ replyTo?: string;
153
585
  };
154
586
  interface BusOptions extends CommonOptions {
155
587
  /** Presence heartbeat interval in ms. Default 2000. */
@@ -157,13 +589,40 @@ interface BusOptions extends CommonOptions {
157
589
  }
158
590
 
159
591
  interface PresenceOptions extends BusOptions {
160
- /** Peers silent for longer than this are dropped. Default 5000ms. */
592
+ /** How much silence makes a peer suspect. Default 5000ms. It is then probed, not dropped. */
161
593
  pruneAfterMs?: number;
594
+ /**
595
+ * How long a probed peer has to answer before it is dropped. Default 1000ms.
596
+ *
597
+ * This is a round trip on a same-origin channel, not a heartbeat interval, so
598
+ * it can be short: a peer that is merely throttled still answers at once.
599
+ */
600
+ probeGraceMs?: number;
601
+ /**
602
+ * What to tell peers about this client — a display name, a tab title, a
603
+ * cursor. Must survive the wire (structured clone), like any payload.
604
+ */
605
+ metadata?: unknown;
606
+ /**
607
+ * Put this client in its own roster. Default false.
608
+ *
609
+ * The default answers "who *else* is here", which is what a presence strip
610
+ * asks. Turn it on for an avatar list, where leaving yourself out means
611
+ * every tab renders a different list of the same room.
612
+ */
613
+ includeSelf?: boolean;
162
614
  }
163
615
  interface Presence {
164
616
  readonly clientId: string;
165
617
  /** Stable array snapshot (replaced on change) — safe for useSyncExternalStore. */
166
618
  getPeers(): readonly Peer[];
619
+ /**
620
+ * Publish new metadata for this client, announcing it to peers.
621
+ *
622
+ * A no-op when the value has not actually changed, so calling it on every
623
+ * render — which is what a hook does — costs nothing and announces nothing.
624
+ */
625
+ setMetadata(metadata: unknown): void;
167
626
  subscribe(fn: () => void): () => void;
168
627
  close(): void;
169
628
  }
@@ -171,40 +630,22 @@ interface Presence {
171
630
  /**
172
631
  * Tracks the other tabs/windows/workers on this bus. Any message from a peer
173
632
  * counts as a liveness signal (state patches, events, and presence pings all
174
- * piggyback); explicit 'bye' or silence past pruneAfterMs removes them.
633
+ * piggyback); an explicit 'bye' removes them at once.
634
+ *
635
+ * Silence, though, is not proof of death — and treating it that way is what
636
+ * made the roster flap. Browsers clamp a hidden tab's timers to roughly one
637
+ * tick a minute, so a perfectly healthy backgrounded peer stops heartbeating,
638
+ * gets pruned, pings once, is re-added, and disappears again: a peer count
639
+ * oscillating once a minute for no reason. Waking a laptop does it to every
640
+ * peer at once.
641
+ *
642
+ * What saves it is that *message handlers are not throttled* — only timers are.
643
+ * A hidden tab still answers a hello the instant it arrives. So a peer that
644
+ * goes quiet is probed rather than dropped, and only silence that survives the
645
+ * probe counts as gone.
175
646
  */
176
647
  declare function createPresence(name: string, options?: PresenceOptions): Presence;
177
648
 
178
- /**
179
- * Deliberately extends CommonOptions, not BusOptions: `heartbeatMs` here means
180
- * the leader's re-announce interval, which is a different thing from the bus's
181
- * presence ping. See the note in leader.ts about forwarding to getBus.
182
- */
183
- interface LeaderOptions extends CommonOptions {
184
- /** How often the leader re-announces itself, in ms. Default 1000. */
185
- heartbeatMs?: number;
186
- /** How long a follower tolerates silence before calling the seat empty, in ms. Default 3000. */
187
- leaseMs?: number;
188
- /** May this client hold the leadership? Default true. */
189
- eligible?: boolean;
190
- }
191
- interface LeaderSnapshot {
192
- /** The current leader's clientId, or null while the seat is empty. */
193
- readonly leaderId: string | null;
194
- readonly isLeader: boolean;
195
- }
196
- interface Leader {
197
- readonly clientId: string;
198
- /** Frozen; a new object only when the leader actually changes. */
199
- getSnapshot(): LeaderSnapshot;
200
- subscribe(fn: () => void): () => void;
201
- /** Give up the seat now. Peers take over immediately rather than waiting for the lease. */
202
- resign(): void;
203
- /** Turn candidacy on or off. Eligibility is a property of the tab, not a component. */
204
- setEligible(eligible: boolean): void;
205
- close(): void;
206
- }
207
-
208
649
  /**
209
650
  * Elects exactly one client on the bus to hold a seat: the tab that owns the
210
651
  * WebSocket, polls, or refreshes the token, while the others stand by.
@@ -220,6 +661,48 @@ interface Leader {
220
661
  */
221
662
  declare function createLeader(name: string, options?: LeaderOptions): Leader;
222
663
 
664
+ /**
665
+ * How a value becomes text, for the two paths that cannot use structured clone.
666
+ *
667
+ * `BroadcastChannel` carries structured clone, so a `Date` arrives a `Date` and
668
+ * a `Map` a `Map`. The storage-event transport and disk persistence carry
669
+ * *text*, and JSON is a strictly poorer format: a `Date` comes back a string, a
670
+ * `Map` comes back `{}`, an `undefined` property is simply gone. Same library,
671
+ * same call, different answer depending on which transport happened to be
672
+ * available — which is the kind of difference that is discovered in production.
673
+ *
674
+ * The seam exists so the two can be made to agree. It is deliberately *not* a
675
+ * bundled dependency: `devalue` costs 3.4 kB brotlied and `superjson` 3.6 kB,
676
+ * against a whole-library budget of 7.3 kB. Charging every user 47% for a
677
+ * fidelity most of them do not need would be the wrong default. So the default
678
+ * is JSON — free, and now loud — and anything better is one line away.
679
+ *
680
+ * ```ts
681
+ * import * as devalue from 'devalue';
682
+ *
683
+ * localStorageAdapter('settings', {
684
+ * serializer: { stringify: devalue.stringify, parse: devalue.parse },
685
+ * });
686
+ * ```
687
+ */
688
+ interface Serializer {
689
+ stringify(value: unknown): string;
690
+ parse(text: string): unknown;
691
+ }
692
+ /**
693
+ * JSON, with silent losses turned into errors.
694
+ *
695
+ * Every type below survives `BroadcastChannel` and does not survive JSON, so
696
+ * without this a value's fate depends on which transport a browser happened to
697
+ * give you. Refusing is the same call `store.set()` already makes for a value
698
+ * structured clone rejects: better one actionable error naming the key than two
699
+ * replicas that quietly disagree.
700
+ *
701
+ * `BigInt` and circular references need no check here — `JSON.stringify` throws
702
+ * on both already. Only the *silent* losses are worth code.
703
+ */
704
+ declare const jsonSerializer: Serializer;
705
+
223
706
  type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
224
707
  interface WebStorageAdapterOptions {
225
708
  /**
@@ -229,6 +712,16 @@ interface WebStorageAdapterOptions {
229
712
  * not a recovery path. Errors thrown by the callback itself are swallowed.
230
713
  */
231
714
  onError?: (error: unknown, operation: 'read' | 'write' | 'remove') => void;
715
+ /**
716
+ * How values become text. Defaults to JSON, which refuses anything it would
717
+ * silently change — a `Date`, a `Map`, an `undefined` — rather than write a
718
+ * value that reads back different.
719
+ *
720
+ * Pass devalue or superjson to carry those instead. Not bundled: they cost
721
+ * 3.4-3.6 kB brotlied against a whole-library budget of 7.3 kB, and most
722
+ * state is JSON-shaped.
723
+ */
724
+ serializer?: Serializer;
232
725
  }
233
726
  /**
234
727
  * Persist to any Storage-shaped thing.
@@ -249,6 +742,47 @@ declare function localStorageAdapter(key: string, options?: WebStorageAdapterOpt
249
742
  /** Survives reloads, but dies with the tab. */
250
743
  declare function sessionStorageAdapter(key: string, options?: WebStorageAdapterOptions): PersistAdapter;
251
744
 
745
+ interface IndexedDbAdapterOptions {
746
+ /** Database name. Default 'use-everywhere'. */
747
+ database?: string;
748
+ /**
749
+ * Called when an operation fails: blocked storage, a version conflict, a
750
+ * quota. Persistence stays best-effort either way — this is the
751
+ * observability seam, not a recovery path.
752
+ */
753
+ onError?: (error: unknown, operation: 'read' | 'write' | 'remove') => void;
754
+ }
755
+ /**
756
+ * Persist to IndexedDB.
757
+ *
758
+ * Two things this has that `localStorage` does not.
759
+ *
760
+ * **Real fidelity, with no serializer.** IndexedDB stores values with the
761
+ * structured clone algorithm — the same one `BroadcastChannel` uses — so a
762
+ * `Date` comes back a `Date` and a `Map` a `Map`, for free. The whole
763
+ * JSON-degrades-your-types problem the {@link Serializer} seam exists to solve
764
+ * simply is not present here, and passing a serializer would only reintroduce
765
+ * it. That makes this the right home for state that is not JSON-shaped.
766
+ *
767
+ * **Room.** `localStorage` is a few megabytes per origin and shared with
768
+ * everything else on it; IndexedDB is orders of magnitude larger.
769
+ *
770
+ * And one thing it does not have.
771
+ *
772
+ * **A synchronous flush.** `read` is asynchronous, so the store is handed back
773
+ * before its state arrives — which is exactly the window `store.hydrated` and
774
+ * `useHydrated` exist to close. Gate first input on one of them, or a keystroke
775
+ * landing in that window is discarded by last-writer-wins when the restore
776
+ * lands holding an older but higher-counter value.
777
+ *
778
+ * The same asymmetry applies on the way out: a `pagehide` flush cannot be
779
+ * awaited, so the last debounced write before a tab closes may not land. The
780
+ * debounce (`persist.debounceMs`, default 100) is the real protection — keep it
781
+ * short for state you would mind losing, or keep that state in
782
+ * `localStorageAdapter`, which writes synchronously, and the bulk here.
783
+ */
784
+ declare function indexedDbAdapter(key: string, options?: IndexedDbAdapterOptions): PersistAdapter;
785
+
252
786
  interface MessageEventLike {
253
787
  data: unknown;
254
788
  origin: string;
@@ -354,6 +888,55 @@ declare function newer(a: Version, b: Version | undefined): boolean;
354
888
  */
355
889
  declare const DEFAULT_NAME = "use-everywhere";
356
890
 
891
+ /**
892
+ * Everything a namespace makes, with the prefix already applied.
893
+ *
894
+ * The same signatures as the bare factories, minus nothing — a namespace is a
895
+ * naming decision, not a reduced API.
896
+ */
897
+ interface Namespace {
898
+ /** The prefix every name from this namespace carries. */
899
+ readonly name: string;
900
+ /** What a bare name becomes here. Exposed so devtools, `observeBus` and tests can name the same bus. */
901
+ busName(name?: string): string;
902
+ createSharedStore<S extends Record<string, unknown>>(name: string | undefined, initial: S, options?: SharedStoreOptions<S>): SharedStore<S>;
903
+ createChannel<M extends MessageMap>(name?: string, options?: ChannelOptions<M>): Channel<M>;
904
+ createPresence(name?: string, options?: PresenceOptions): Presence;
905
+ createLeader(name?: string, options?: LeaderOptions): Leader;
906
+ }
907
+ /**
908
+ * Namespaced factories, so two independently deployed apps on one origin cannot
909
+ * collide by both taking the defaults.
910
+ *
911
+ * Bare names are the problem this solves. A `BroadcastChannel` is global to the
912
+ * origin, so a name *is* an identity — and two micro-frontends that each call
913
+ * `createSharedStore('cart', …)`, or each omit the name and land on
914
+ * {@link DEFAULT_NAME}, are not two carts. They are one cart, with two teams
915
+ * writing to it, one leader seat contended between them, and one presence roster
916
+ * counting both. Nothing warns, because from the library's side it looks exactly
917
+ * like the intended case of two tabs sharing state.
918
+ *
919
+ * "Prefix your names" is the workaround, and it fails the way conventions fail:
920
+ * silently, once, in whichever app forgot.
921
+ *
922
+ * ```ts
923
+ * const checkout = createNamespace('checkout');
924
+ * const cart = checkout.createSharedStore('cart', { items: [] }); // bus "checkout:cart"
925
+ * const events = checkout.createChannel('events'); // bus "checkout:events"
926
+ * ```
927
+ *
928
+ * ## What it is not
929
+ *
930
+ * Not a security boundary. Everything here is same-origin and a namespace is a
931
+ * string, so anything on the page can construct the same one deliberately. It
932
+ * prevents collision, not access — see the security model docs.
933
+ *
934
+ * Not related to `wire.scope`, which says *which engine* a wire belongs to, or
935
+ * to the React package's share scope, which says *how far* a value travels.
936
+ * Three different axes; this is the one about names.
937
+ */
938
+ declare function createNamespace(namespace: string): Namespace;
939
+
357
940
  /** One wire crossing the bus, in either direction. */
358
941
  interface BusEvent {
359
942
  /** The bus name the wire crossed. */
@@ -382,18 +965,71 @@ declare function observeBus(name: string, fn: BusObserver): () => void;
382
965
  declare function enableDebug(options?: DebugOptions): () => void;
383
966
 
384
967
  /**
385
- * Get the shared bus for `name`, creating it on first use. Callers must call
386
- * bus.release() exactly once when done. When a custom transport factory is
387
- * given (tests), every call creates an isolated bus — one call = one simulated client.
968
+ * The wire protocol this build speaks. Stamped as `v` on everything posted, and
969
+ * required to match on everything received.
970
+ *
971
+ * ## The compatibility contract
972
+ *
973
+ * Every rolling deploy produces version skew: a tab opened this morning is
974
+ * still running last week's bundle while the tab opened after lunch is running
975
+ * today's, and both are on the same origin talking over the same bus. The
976
+ * contract that makes that safe has two halves.
977
+ *
978
+ * **Across versions, partition — loudly.** A wire whose `v` is not this one is
979
+ * dropped rather than guessed at, because the only thing a build knows about
980
+ * another protocol version is that it does not know it. Dropping alone would be
981
+ * the silent-degradation failure this library exists to avoid, so a foreign
982
+ * version is also recorded on the page ({@link getWireSkew}) and warned about
983
+ * once in development. The two builds still each work, still each sync with
984
+ * their own generation, and the fact that they cannot see each other is
985
+ * *observable* rather than something to be discovered from a bug report.
986
+ *
987
+ * **Within a version, evolve additively.** A new `type` on an existing `scope`
988
+ * may be added without bumping `v`, on one condition: every engine dispatches
989
+ * on the types it knows and ignores the rest. A build that has never heard of
990
+ * `state`/`remove` must treat it as nothing, not as a malformed something —
991
+ * which is why no dispatch here ends in a bare `else`. New *fields* on an
992
+ * existing type follow the same rule: readers must tolerate their absence,
993
+ * because half the tabs on the origin were built before the field existed.
994
+ *
995
+ * Bump `v` only for a change that breaks those rules — a field whose meaning
996
+ * changes, a type that stops being sent, a value that stops being comparable.
997
+ * Bumping is not a failure; it is the honest signal, and it is cheap because
998
+ * the generations partition cleanly instead of corrupting each other.
388
999
  */
1000
+ declare const WIRE_VERSION = 1;
1001
+ /**
1002
+ * Which foreign wire protocol versions have been heard on a bus, ascending.
1003
+ *
1004
+ * Empty means every peer seen so far speaks {@link WIRE_VERSION} — the normal
1005
+ * case, and the one a deploy should return to once the last stale tab is gone.
1006
+ * A non-empty result means this page is mid-skew and is partitioned from those
1007
+ * peers by design: gate a "reload for the latest version" prompt on it rather
1008
+ * than letting users work in a tab that silently sees half the picture.
1009
+ *
1010
+ * Page-wide and cumulative, like the skew it reports. It counts what was heard,
1011
+ * not what is still out there, so it never un-reports a version — a stale tab
1012
+ * that closes leaves its mark, because the deploy that produced it happened.
1013
+ */
1014
+ declare function getWireSkew(name: string): number[];
1015
+
389
1016
  /**
390
1017
  * Names of the buses currently alive on this page. Buses built with a custom
391
- * transport (tests) bypass the registry, so they are not listed.
1018
+ * transport (tests) bypass the table, so they are not listed.
392
1019
  */
393
1020
  declare function getBusNames(): string[];
1021
+ /**
1022
+ * What is actually carrying this bus's traffic, or null if it has no bus yet.
1023
+ *
1024
+ * Answers the question a developer asks when nothing is syncing and the code
1025
+ * looks right: *is anything even connected?* `'none'` means no — writes are
1026
+ * local and no peer will ever see them.
1027
+ */
1028
+ declare function getTransportKind(name: string): TransportKind | null;
394
1029
 
395
1030
  /** Same-origin transport over a real BroadcastChannel. */
396
1031
  declare class BroadcastChannelTransport implements Transport {
1032
+ readonly kind: TransportKind;
397
1033
  private bc;
398
1034
  private listeners;
399
1035
  constructor(name: string);
@@ -407,13 +1043,66 @@ declare class BroadcastChannelTransport implements Transport {
407
1043
  * Used for SSR and for state scoped to a single tab.
408
1044
  */
409
1045
  declare class NoopTransport implements Transport {
1046
+ readonly kind: TransportKind;
410
1047
  post(): void;
411
1048
  subscribe(): () => void;
412
1049
  close(): void;
413
1050
  }
414
1051
 
1052
+ /**
1053
+ * Cross-tab delivery over the `storage` event, for browsers with no
1054
+ * `BroadcastChannel`.
1055
+ *
1056
+ * The mechanism is a quirk turned to advantage: writing to `localStorage` fires
1057
+ * a `storage` event in *every other* same-origin tab and never in the writer —
1058
+ * exactly BroadcastChannel's no-self-echo semantics, for free.
1059
+ *
1060
+ * Two differences from the real thing, both deliberate and both documented:
1061
+ *
1062
+ * 1. **Fidelity is JSON, not structured clone.** `localStorage` holds strings.
1063
+ * The default serializer therefore *rejects* every value JSON would quietly
1064
+ * change — a `Date`, a `Map`, a function — rather than let a write appear to
1065
+ * succeed while peers receive something else. Pass a `Serializer` (devalue,
1066
+ * superjson) to carry those instead.
1067
+ * 2. **The entry is removed immediately after writing.** Peers have already been
1068
+ * notified by then (the event carries the value), and leaving application
1069
+ * state sitting in `localStorage` would be both a quota cost and a privacy
1070
+ * one. The removal fires a second event with a null `newValue`, which
1071
+ * receivers ignore.
1072
+ */
1073
+ declare class StorageTransport implements Transport {
1074
+ readonly kind: TransportKind;
1075
+ private key;
1076
+ private storage;
1077
+ private listeners;
1078
+ private onStorage;
1079
+ private seq;
1080
+ private serializer;
1081
+ constructor(name: string, storage?: Storage, serializer?: Serializer);
1082
+ post(data: unknown): void;
1083
+ subscribe(listener: (data: unknown) => void): () => void;
1084
+ close(): void;
1085
+ }
1086
+
415
1087
  declare function isBroadcastChannelAvailable(): boolean;
416
- /** Default factory: real BroadcastChannel when available, otherwise a local no-op. */
1088
+ /**
1089
+ * Can we hear other tabs through the `storage` event?
1090
+ *
1091
+ * Reading `localStorage` is itself what throws when storage is blocked — a
1092
+ * sandboxed iframe, third-party cookies off — so the check has to happen inside
1093
+ * a try, not around a property test. Availability is also not writability:
1094
+ * Safari's old private mode exposed the object and threw on every setItem.
1095
+ */
1096
+ declare function isStorageEventAvailable(): boolean;
1097
+ /**
1098
+ * Pick the best wire this browser can offer, and say so when it is not the
1099
+ * good one.
1100
+ *
1101
+ * The chain matters more than it looks. Before it existed, a context without
1102
+ * `BroadcastChannel` got a silent no-op: every hook kept working, every write
1103
+ * appeared to succeed, and nothing was ever shared with anybody. That is the
1104
+ * worst failure this library can have, because it looks exactly like success.
1105
+ */
417
1106
  declare function defaultTransport(name: string): Transport;
418
1107
 
419
- export { BroadcastChannelTransport, type BusEvent, type BusObserver, type BusWire, CID_PARAM, type Channel, type CommonOptions, type ConnectToOpenerOptions, DEFAULT_NAME, type DebugOptions, HandshakeTimeoutError, type Leader, type LeaderOptions, type LeaderSnapshot, type MessageEventLike, type MessageMap, type MessageMeta, NoopTransport, type OpenWindowOptions, type OpenedWindow, type OpenerConnection, type Peer, type PeerKind, type PersistAdapter, type PersistOptions, type Persisted, type Presence, type PresenceOptions, type SharedStore, type SharedStoreOptions, type StorageLike, Transport, type Version, type WebStorageAdapterOptions, WindowClosedError, type WindowEventTarget, type WindowLike, connectToOpener, createChannel, createLeader, createPresence, createSharedStore, defaultTransport, enableDebug, getBusNames, isBroadcastChannelAvailable, localStorageAdapter, newer, observeBus, openWindow, sessionStorageAdapter, webStorageAdapter };
1108
+ export { type AskOptions, BroadcastChannelTransport, type BusEvent, type BusObserver, type BusWire, CID_PARAM, type Channel, type ChannelOptions, type CommonOptions, type ConnectToOpenerOptions, DEFAULT_NAME, type DebugOptions, HandshakeTimeoutError, type IndexedDbAdapterOptions, type InvalidPayload, type Leader, type LeaderOptions, type LeaderSnapshot, type LeaderStrategy, type MessageEventLike, type MessageMap, type MessageMeta, type Namespace, NoopTransport, type OnInvalid, type OnOptions, type OpenWindowOptions, type OpenedWindow, type OpenerConnection, type Peer, type PeerKind, type PersistAdapter, type PersistOptions, type Persisted, type PostOptions, type Presence, type PresenceOptions, type ReplyMap, type RestoreError, type SchemaMap, type SchemaOptions, type Serializer, type SharedReducer, type SharedReducerOptions, type SharedStore, type SharedStoreOptions, type StandardSchemaV1, type StorageLike, StorageTransport, Transport, TransportKind, type Version, WIRE_VERSION, type WebStorageAdapterOptions, WindowClosedError, type WindowEventTarget, type WindowLike, connectToOpener, createChannel, createLeader, createNamespace, createPresence, createSharedReducer, createSharedStore, defaultTransport, enableDebug, getBusNames, getTransportKind, getWireSkew, indexedDbAdapter, isBroadcastChannelAvailable, isStorageEventAvailable, jsonSerializer, localStorageAdapter, newer, observeBus, openWindow, sessionStorageAdapter, webStorageAdapter };