@use-everywhere/core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -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,167 @@ 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>;
325
+
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
+ /**
353
+ * The Web Locks manager to elect on. Defaults to `navigator.locks`.
354
+ *
355
+ * A test seam: `@use-everywhere/test-utils` passes a `FakeLockManager` here
356
+ * so several simulated tabs can queue on one seat — and so a crashed tab's
357
+ * lock is reclaimed — in a plain test process.
358
+ */
359
+ locks?: LockManagerLike;
360
+ }
361
+ /** The slice of the Web Locks API this library uses. */
362
+ interface LockManagerLike {
363
+ request(name: string, options: {
364
+ signal?: AbortSignal;
365
+ }, callback: () => Promise<void>): Promise<void>;
366
+ }
367
+ interface LeaderSnapshot {
368
+ /** The current leader's clientId, or null while the seat is empty. */
369
+ readonly leaderId: string | null;
370
+ readonly isLeader: boolean;
371
+ }
372
+ interface Leader {
373
+ readonly clientId: string;
374
+ /** Which mechanism arbitrates this seat — useful in devtools and bug reports. */
375
+ readonly strategy: Exclude<LeaderStrategy, 'auto'>;
376
+ /** Frozen; a new object only when the leader actually changes. */
377
+ getSnapshot(): LeaderSnapshot;
378
+ subscribe(fn: () => void): () => void;
379
+ /**
380
+ * Resolves the moment this client holds the seat, or immediately if it
381
+ * already does. Rejects if the leader is closed while still waiting — so an
382
+ * `await` in a torn-down tab does not hang forever.
383
+ */
384
+ waitForLeadership(): Promise<void>;
385
+ /** Give up the seat now. Peers take over immediately rather than waiting for the lease. */
386
+ resign(): void;
387
+ /** Turn candidacy on or off. Eligibility is a property of the tab, not a component. */
388
+ setEligible(eligible: boolean): void;
389
+ close(): void;
390
+ }
391
+
392
+ interface SharedReducerOptions extends CommonOptions {
393
+ /**
394
+ * Which reducer this is, when several share a bus name. Default `'default'`.
395
+ *
396
+ * The same axis as a store key: one bus, many reducers, each ordered
397
+ * independently of the others.
398
+ */
399
+ key?: string;
400
+ /**
401
+ * Reuse an existing leader rather than electing another. A reducer needs a
402
+ * sequencer, and a page that already has a `Leader` for this bus should not
403
+ * run a second election to get one.
404
+ */
405
+ leader?: Leader;
406
+ /** Election settings, when this reducer elects its own leader. Ignored if `leader` is passed. */
407
+ leaderOptions?: LeaderOptions;
408
+ }
409
+ interface SharedReducer<S, A> {
410
+ readonly clientId: string;
411
+ /** Immutable, replaced whenever the value changes. Safe for useSyncExternalStore. */
412
+ getSnapshot(): S;
413
+ /**
414
+ * Apply an action everywhere.
415
+ *
416
+ * Applied locally first so the UI does not wait for a round trip, then
417
+ * proposed to the leader for ordering. If the committed order turns out to
418
+ * differ from the optimistic one, the local value is rebuilt from the
419
+ * committed state — so a dispatch can be *seen* out of order for a moment,
420
+ * but never *settle* out of order.
421
+ */
422
+ dispatch(action: A): void;
423
+ subscribe(fn: () => void): () => void;
424
+ /**
425
+ * Number of dispatches from this client that have not yet come back
426
+ * committed. Zero means this client's view is entirely confirmed.
427
+ */
428
+ pendingCount(): number;
429
+ close(): void;
430
+ }
431
+
432
+ /**
433
+ * State that converges by *replaying actions in one order*, rather than by
434
+ * last-writer-wins on a value.
435
+ *
436
+ * ## Why this exists
437
+ *
438
+ * `useSharedState`'s convergence rule is last-writer-wins per key, and for a
439
+ * register — a theme, a selected row, a draft — that is exactly right. For an
440
+ * *accumulating* write it is exactly wrong, and the README's own counter
441
+ * example was the proof: two tabs running `set('n', n => n + 1)` at the same
442
+ * moment both read 4, both write 5, and one increment is silently gone. Nothing
443
+ * is broken, no error is raised, and the number is simply too small.
444
+ *
445
+ * A reducer fixes it by moving what travels. LWW ships the *result* of the
446
+ * increment, so concurrent results overwrite each other; this ships the
447
+ * *increment*, and results are computed by every client from the same ordered
448
+ * list. Two increments are two entries in that list.
449
+ *
450
+ * ## How the order is decided
451
+ *
452
+ * The leader is the sequencer. A dispatch is broadcast as a `propose`; the
453
+ * leader stamps it with the next number and broadcasts a `commit`; every client
454
+ * — the leader included — applies commits strictly in that order. One list, one
455
+ * order, one answer, for *any* reducer.
456
+ *
457
+ * Deliberately not "op-log CRDT for commutative operations", which is cheaper
458
+ * and needs no leader. That design converges only if the reducer happens to be
459
+ * commutative, and nothing in a function's type says whether it is. A library
460
+ * whose rule is that silent divergence is the worst failure mode cannot ship a
461
+ * primitive whose correctness depends on a property it cannot check.
462
+ *
463
+ * ## What it costs
464
+ *
465
+ * A dispatch is applied locally at once and reconciled when its commit arrives,
466
+ * so typing is never gated on the network. If the committed order differs from
467
+ * the optimistic one, the value is rebuilt from committed state plus whatever
468
+ * is still pending — visible as a brief correction, never as a wrong result.
469
+ *
470
+ * Leadership here inherits leadership's own caveat: it is advisory. In the
471
+ * moment two tabs both believe they hold the seat, two commits can carry the
472
+ * same number. The second one to arrive is dropped, and the client asks for a
473
+ * fresh snapshot rather than guessing — so the outcome is a re-sync, not a
474
+ * divergence. Anything that must happen exactly once still needs a server.
475
+ */
476
+ declare function createSharedReducer<S, A>(name: string, reducer: (state: S, action: A) => S, initial: S, options?: SharedReducerOptions): SharedReducer<S, A>;
100
477
 
101
- /** Everything on the same-origin bus, multiplexed by scope over one BroadcastChannel per name. */
478
+ /**
479
+ * Everything on the same-origin bus, multiplexed by scope over one
480
+ * BroadcastChannel per name.
481
+ *
482
+ * `v` is the wire protocol version, and changing it is a decision with rules —
483
+ * see `wire.ts` for what may be added within a version and what must bump it.
484
+ */
102
485
  type BusWire = {
103
486
  v: 1;
104
487
  scope: 'state';
@@ -128,6 +511,16 @@ type BusWire = {
128
511
  type: 'hello' | 'ping' | 'bye';
129
512
  clientId: string;
130
513
  kind: PeerKind;
514
+ /**
515
+ * Whatever this client wants peers to know about it — a display name, a
516
+ * tab title, a cursor.
517
+ *
518
+ * Carried on `hello` only, never on a ping. A ping is a heartbeat and
519
+ * arrives constantly; attaching metadata to it would re-announce
520
+ * unchanged data forever and churn every subscriber's roster. Additive
521
+ * within wire v1: a build that predates it neither sets nor reads it.
522
+ */
523
+ metadata?: unknown;
131
524
  } | {
132
525
  v: 1;
133
526
  scope: 'leader';
@@ -142,6 +535,43 @@ type BusWire = {
142
535
  term: Version;
143
536
  clientId: string;
144
537
  kind: PeerKind;
538
+ } | {
539
+ v: 1;
540
+ scope: 'op';
541
+ type: 'hello';
542
+ key: string;
543
+ clientId: string;
544
+ kind: PeerKind;
545
+ } | {
546
+ v: 1;
547
+ scope: 'op';
548
+ type: 'propose';
549
+ key: string;
550
+ action: unknown;
551
+ /** Identifies this dispatch across its proposal and its commit, so a commit can be recognised as one's own and applied once. */
552
+ opId: string;
553
+ clientId: string;
554
+ kind: PeerKind;
555
+ } | {
556
+ v: 1;
557
+ scope: 'op';
558
+ type: 'commit';
559
+ key: string;
560
+ action: unknown;
561
+ opId: string;
562
+ /** The leader's ordering decision: a gapless counter every client replays in the same order. */
563
+ seq: number;
564
+ clientId: string;
565
+ kind: PeerKind;
566
+ } | {
567
+ v: 1;
568
+ scope: 'op';
569
+ type: 'snapshot';
570
+ key: string;
571
+ state: unknown;
572
+ seq: number;
573
+ clientId: string;
574
+ kind: PeerKind;
145
575
  } | {
146
576
  v: 1;
147
577
  scope: 'event';
@@ -150,6 +580,14 @@ type BusWire = {
150
580
  clientId: string;
151
581
  kind: PeerKind;
152
582
  msgId: string;
583
+ /**
584
+ * The `msgId` this is an answer to, when it is one.
585
+ *
586
+ * Additive within wire v1: a build that predates `ask` never sets it and
587
+ * never reads it, so the field is simply absent both ways — which is the
588
+ * rule for new optional fields (see wire.ts).
589
+ */
590
+ replyTo?: string;
153
591
  };
154
592
  interface BusOptions extends CommonOptions {
155
593
  /** Presence heartbeat interval in ms. Default 2000. */
@@ -166,11 +604,31 @@ interface PresenceOptions extends BusOptions {
166
604
  * it can be short: a peer that is merely throttled still answers at once.
167
605
  */
168
606
  probeGraceMs?: number;
607
+ /**
608
+ * What to tell peers about this client — a display name, a tab title, a
609
+ * cursor. Must survive the wire (structured clone), like any payload.
610
+ */
611
+ metadata?: unknown;
612
+ /**
613
+ * Put this client in its own roster. Default false.
614
+ *
615
+ * The default answers "who *else* is here", which is what a presence strip
616
+ * asks. Turn it on for an avatar list, where leaving yourself out means
617
+ * every tab renders a different list of the same room.
618
+ */
619
+ includeSelf?: boolean;
169
620
  }
170
621
  interface Presence {
171
622
  readonly clientId: string;
172
623
  /** Stable array snapshot (replaced on change) — safe for useSyncExternalStore. */
173
624
  getPeers(): readonly Peer[];
625
+ /**
626
+ * Publish new metadata for this client, announcing it to peers.
627
+ *
628
+ * A no-op when the value has not actually changed, so calling it on every
629
+ * render — which is what a hook does — costs nothing and announces nothing.
630
+ */
631
+ setMetadata(metadata: unknown): void;
174
632
  subscribe(fn: () => void): () => void;
175
633
  close(): void;
176
634
  }
@@ -194,66 +652,6 @@ interface Presence {
194
652
  */
195
653
  declare function createPresence(name: string, options?: PresenceOptions): Presence;
196
654
 
197
- /**
198
- * Deliberately extends CommonOptions, not BusOptions: `heartbeatMs` here means
199
- * the leader's re-announce interval, which is a different thing from the bus's
200
- * presence ping. See the note in leader.ts about forwarding to getBus.
201
- */
202
- /**
203
- * How the seat is arbitrated.
204
- *
205
- * - `'web-locks'` — the browser's Web Locks API owns the queue. The lock is
206
- * released by the browser itself when a tab dies, and holding it does not
207
- * depend on a timer, so a backgrounded tab cannot be deposed for being
208
- * throttled. No heartbeat traffic at all.
209
- * - `'heartbeat'` — lease-and-claim over the bus. Works anywhere, including
210
- * plain-http origins where `navigator.locks` does not exist.
211
- * - `'auto'` (default) — Web Locks when available, heartbeat otherwise.
212
- */
213
- type LeaderStrategy = 'auto' | 'web-locks' | 'heartbeat';
214
- interface LeaderOptions extends CommonOptions {
215
- /** How often the leader re-announces itself, in ms. Default 1000. Heartbeat strategy only. */
216
- heartbeatMs?: number;
217
- /** How long a follower tolerates silence before calling the seat empty, in ms. Default 3000. Heartbeat strategy only. */
218
- leaseMs?: number;
219
- /** May this client hold the leadership? Default true. */
220
- eligible?: boolean;
221
- /** How to arbitrate the seat. Default 'auto'. */
222
- strategy?: LeaderStrategy;
223
- /** @internal Test seam for the Web Locks manager. Defaults to navigator.locks. */
224
- locks?: LockManagerLike;
225
- }
226
- /** The slice of the Web Locks API this library uses. */
227
- interface LockManagerLike {
228
- request(name: string, options: {
229
- signal?: AbortSignal;
230
- }, callback: () => Promise<void>): Promise<void>;
231
- }
232
- interface LeaderSnapshot {
233
- /** The current leader's clientId, or null while the seat is empty. */
234
- readonly leaderId: string | null;
235
- readonly isLeader: boolean;
236
- }
237
- interface Leader {
238
- readonly clientId: string;
239
- /** Which mechanism arbitrates this seat — useful in devtools and bug reports. */
240
- readonly strategy: Exclude<LeaderStrategy, 'auto'>;
241
- /** Frozen; a new object only when the leader actually changes. */
242
- getSnapshot(): LeaderSnapshot;
243
- subscribe(fn: () => void): () => void;
244
- /**
245
- * Resolves the moment this client holds the seat, or immediately if it
246
- * already does. Rejects if the leader is closed while still waiting — so an
247
- * `await` in a torn-down tab does not hang forever.
248
- */
249
- waitForLeadership(): Promise<void>;
250
- /** Give up the seat now. Peers take over immediately rather than waiting for the lease. */
251
- resign(): void;
252
- /** Turn candidacy on or off. Eligibility is a property of the tab, not a component. */
253
- setEligible(eligible: boolean): void;
254
- close(): void;
255
- }
256
-
257
655
  /**
258
656
  * Elects exactly one client on the bus to hold a seat: the tab that owns the
259
657
  * WebSocket, polls, or refreshes the token, while the others stand by.
@@ -269,6 +667,48 @@ interface Leader {
269
667
  */
270
668
  declare function createLeader(name: string, options?: LeaderOptions): Leader;
271
669
 
670
+ /**
671
+ * How a value becomes text, for the two paths that cannot use structured clone.
672
+ *
673
+ * `BroadcastChannel` carries structured clone, so a `Date` arrives a `Date` and
674
+ * a `Map` a `Map`. The storage-event transport and disk persistence carry
675
+ * *text*, and JSON is a strictly poorer format: a `Date` comes back a string, a
676
+ * `Map` comes back `{}`, an `undefined` property is simply gone. Same library,
677
+ * same call, different answer depending on which transport happened to be
678
+ * available — which is the kind of difference that is discovered in production.
679
+ *
680
+ * The seam exists so the two can be made to agree. It is deliberately *not* a
681
+ * bundled dependency: `devalue` costs 3.4 kB brotlied and `superjson` 3.6 kB,
682
+ * against a whole-library budget of 7.3 kB. Charging every user 47% for a
683
+ * fidelity most of them do not need would be the wrong default. So the default
684
+ * is JSON — free, and now loud — and anything better is one line away.
685
+ *
686
+ * ```ts
687
+ * import * as devalue from 'devalue';
688
+ *
689
+ * localStorageAdapter('settings', {
690
+ * serializer: { stringify: devalue.stringify, parse: devalue.parse },
691
+ * });
692
+ * ```
693
+ */
694
+ interface Serializer {
695
+ stringify(value: unknown): string;
696
+ parse(text: string): unknown;
697
+ }
698
+ /**
699
+ * JSON, with silent losses turned into errors.
700
+ *
701
+ * Every type below survives `BroadcastChannel` and does not survive JSON, so
702
+ * without this a value's fate depends on which transport a browser happened to
703
+ * give you. Refusing is the same call `store.set()` already makes for a value
704
+ * structured clone rejects: better one actionable error naming the key than two
705
+ * replicas that quietly disagree.
706
+ *
707
+ * `BigInt` and circular references need no check here — `JSON.stringify` throws
708
+ * on both already. Only the *silent* losses are worth code.
709
+ */
710
+ declare const jsonSerializer: Serializer;
711
+
272
712
  type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
273
713
  interface WebStorageAdapterOptions {
274
714
  /**
@@ -278,6 +718,16 @@ interface WebStorageAdapterOptions {
278
718
  * not a recovery path. Errors thrown by the callback itself are swallowed.
279
719
  */
280
720
  onError?: (error: unknown, operation: 'read' | 'write' | 'remove') => void;
721
+ /**
722
+ * How values become text. Defaults to JSON, which refuses anything it would
723
+ * silently change — a `Date`, a `Map`, an `undefined` — rather than write a
724
+ * value that reads back different.
725
+ *
726
+ * Pass devalue or superjson to carry those instead. Not bundled: they cost
727
+ * 3.4-3.6 kB brotlied against a whole-library budget of 7.3 kB, and most
728
+ * state is JSON-shaped.
729
+ */
730
+ serializer?: Serializer;
281
731
  }
282
732
  /**
283
733
  * Persist to any Storage-shaped thing.
@@ -298,6 +748,47 @@ declare function localStorageAdapter(key: string, options?: WebStorageAdapterOpt
298
748
  /** Survives reloads, but dies with the tab. */
299
749
  declare function sessionStorageAdapter(key: string, options?: WebStorageAdapterOptions): PersistAdapter;
300
750
 
751
+ interface IndexedDbAdapterOptions {
752
+ /** Database name. Default 'use-everywhere'. */
753
+ database?: string;
754
+ /**
755
+ * Called when an operation fails: blocked storage, a version conflict, a
756
+ * quota. Persistence stays best-effort either way — this is the
757
+ * observability seam, not a recovery path.
758
+ */
759
+ onError?: (error: unknown, operation: 'read' | 'write' | 'remove') => void;
760
+ }
761
+ /**
762
+ * Persist to IndexedDB.
763
+ *
764
+ * Two things this has that `localStorage` does not.
765
+ *
766
+ * **Real fidelity, with no serializer.** IndexedDB stores values with the
767
+ * structured clone algorithm — the same one `BroadcastChannel` uses — so a
768
+ * `Date` comes back a `Date` and a `Map` a `Map`, for free. The whole
769
+ * JSON-degrades-your-types problem the {@link Serializer} seam exists to solve
770
+ * simply is not present here, and passing a serializer would only reintroduce
771
+ * it. That makes this the right home for state that is not JSON-shaped.
772
+ *
773
+ * **Room.** `localStorage` is a few megabytes per origin and shared with
774
+ * everything else on it; IndexedDB is orders of magnitude larger.
775
+ *
776
+ * And one thing it does not have.
777
+ *
778
+ * **A synchronous flush.** `read` is asynchronous, so the store is handed back
779
+ * before its state arrives — which is exactly the window `store.hydrated` and
780
+ * `useHydrated` exist to close. Gate first input on one of them, or a keystroke
781
+ * landing in that window is discarded by last-writer-wins when the restore
782
+ * lands holding an older but higher-counter value.
783
+ *
784
+ * The same asymmetry applies on the way out: a `pagehide` flush cannot be
785
+ * awaited, so the last debounced write before a tab closes may not land. The
786
+ * debounce (`persist.debounceMs`, default 100) is the real protection — keep it
787
+ * short for state you would mind losing, or keep that state in
788
+ * `localStorageAdapter`, which writes synchronously, and the bulk here.
789
+ */
790
+ declare function indexedDbAdapter(key: string, options?: IndexedDbAdapterOptions): PersistAdapter;
791
+
301
792
  interface MessageEventLike {
302
793
  data: unknown;
303
794
  origin: string;
@@ -403,6 +894,55 @@ declare function newer(a: Version, b: Version | undefined): boolean;
403
894
  */
404
895
  declare const DEFAULT_NAME = "use-everywhere";
405
896
 
897
+ /**
898
+ * Everything a namespace makes, with the prefix already applied.
899
+ *
900
+ * The same signatures as the bare factories, minus nothing — a namespace is a
901
+ * naming decision, not a reduced API.
902
+ */
903
+ interface Namespace {
904
+ /** The prefix every name from this namespace carries. */
905
+ readonly name: string;
906
+ /** What a bare name becomes here. Exposed so devtools, `observeBus` and tests can name the same bus. */
907
+ busName(name?: string): string;
908
+ createSharedStore<S extends Record<string, unknown>>(name: string | undefined, initial: S, options?: SharedStoreOptions<S>): SharedStore<S>;
909
+ createChannel<M extends MessageMap>(name?: string, options?: ChannelOptions<M>): Channel<M>;
910
+ createPresence(name?: string, options?: PresenceOptions): Presence;
911
+ createLeader(name?: string, options?: LeaderOptions): Leader;
912
+ }
913
+ /**
914
+ * Namespaced factories, so two independently deployed apps on one origin cannot
915
+ * collide by both taking the defaults.
916
+ *
917
+ * Bare names are the problem this solves. A `BroadcastChannel` is global to the
918
+ * origin, so a name *is* an identity — and two micro-frontends that each call
919
+ * `createSharedStore('cart', …)`, or each omit the name and land on
920
+ * {@link DEFAULT_NAME}, are not two carts. They are one cart, with two teams
921
+ * writing to it, one leader seat contended between them, and one presence roster
922
+ * counting both. Nothing warns, because from the library's side it looks exactly
923
+ * like the intended case of two tabs sharing state.
924
+ *
925
+ * "Prefix your names" is the workaround, and it fails the way conventions fail:
926
+ * silently, once, in whichever app forgot.
927
+ *
928
+ * ```ts
929
+ * const checkout = createNamespace('checkout');
930
+ * const cart = checkout.createSharedStore('cart', { items: [] }); // bus "checkout:cart"
931
+ * const events = checkout.createChannel('events'); // bus "checkout:events"
932
+ * ```
933
+ *
934
+ * ## What it is not
935
+ *
936
+ * Not a security boundary. Everything here is same-origin and a namespace is a
937
+ * string, so anything on the page can construct the same one deliberately. It
938
+ * prevents collision, not access — see the security model docs.
939
+ *
940
+ * Not related to `wire.scope`, which says *which engine* a wire belongs to, or
941
+ * to the React package's share scope, which says *how far* a value travels.
942
+ * Three different axes; this is the one about names.
943
+ */
944
+ declare function createNamespace(namespace: string): Namespace;
945
+
406
946
  /** One wire crossing the bus, in either direction. */
407
947
  interface BusEvent {
408
948
  /** The bus name the wire crossed. */
@@ -430,6 +970,55 @@ declare function observeBus(name: string, fn: BusObserver): () => void;
430
970
  /** Log every wire on a bus to the console. Returns a function to stop. */
431
971
  declare function enableDebug(options?: DebugOptions): () => void;
432
972
 
973
+ /**
974
+ * The wire protocol this build speaks. Stamped as `v` on everything posted, and
975
+ * required to match on everything received.
976
+ *
977
+ * ## The compatibility contract
978
+ *
979
+ * Every rolling deploy produces version skew: a tab opened this morning is
980
+ * still running last week's bundle while the tab opened after lunch is running
981
+ * today's, and both are on the same origin talking over the same bus. The
982
+ * contract that makes that safe has two halves.
983
+ *
984
+ * **Across versions, partition — loudly.** A wire whose `v` is not this one is
985
+ * dropped rather than guessed at, because the only thing a build knows about
986
+ * another protocol version is that it does not know it. Dropping alone would be
987
+ * the silent-degradation failure this library exists to avoid, so a foreign
988
+ * version is also recorded on the page ({@link getWireSkew}) and warned about
989
+ * once in development. The two builds still each work, still each sync with
990
+ * their own generation, and the fact that they cannot see each other is
991
+ * *observable* rather than something to be discovered from a bug report.
992
+ *
993
+ * **Within a version, evolve additively.** A new `type` on an existing `scope`
994
+ * may be added without bumping `v`, on one condition: every engine dispatches
995
+ * on the types it knows and ignores the rest. A build that has never heard of
996
+ * `state`/`remove` must treat it as nothing, not as a malformed something —
997
+ * which is why no dispatch here ends in a bare `else`. New *fields* on an
998
+ * existing type follow the same rule: readers must tolerate their absence,
999
+ * because half the tabs on the origin were built before the field existed.
1000
+ *
1001
+ * Bump `v` only for a change that breaks those rules — a field whose meaning
1002
+ * changes, a type that stops being sent, a value that stops being comparable.
1003
+ * Bumping is not a failure; it is the honest signal, and it is cheap because
1004
+ * the generations partition cleanly instead of corrupting each other.
1005
+ */
1006
+ declare const WIRE_VERSION = 1;
1007
+ /**
1008
+ * Which foreign wire protocol versions have been heard on a bus, ascending.
1009
+ *
1010
+ * Empty means every peer seen so far speaks {@link WIRE_VERSION} — the normal
1011
+ * case, and the one a deploy should return to once the last stale tab is gone.
1012
+ * A non-empty result means this page is mid-skew and is partitioned from those
1013
+ * peers by design: gate a "reload for the latest version" prompt on it rather
1014
+ * than letting users work in a tab that silently sees half the picture.
1015
+ *
1016
+ * Page-wide and cumulative, like the skew it reports. It counts what was heard,
1017
+ * not what is still out there, so it never un-reports a version — a stale tab
1018
+ * that closes leaves its mark, because the deploy that produced it happened.
1019
+ */
1020
+ declare function getWireSkew(name: string): number[];
1021
+
433
1022
  /**
434
1023
  * Names of the buses currently alive on this page. Buses built with a custom
435
1024
  * transport (tests) bypass the table, so they are not listed.
@@ -477,10 +1066,10 @@ declare class NoopTransport implements Transport {
477
1066
  * Two differences from the real thing, both deliberate and both documented:
478
1067
  *
479
1068
  * 1. **Fidelity is JSON, not structured clone.** `localStorage` holds strings.
480
- * A `Date` arrives as an ISO string and a `Map` as `{}`. Values that cannot
481
- * be represented at all functions, symbolsare rejected rather than
482
- * silently dropped, so a write still cannot leave this tab holding something
483
- * its peers never received.
1069
+ * The default serializer therefore *rejects* every value JSON would quietly
1070
+ * change a `Date`, a `Map`, a function rather than let a write appear to
1071
+ * succeed while peers receive something else. Pass a `Serializer` (devalue,
1072
+ * superjson) to carry those instead.
484
1073
  * 2. **The entry is removed immediately after writing.** Peers have already been
485
1074
  * notified by then (the event carries the value), and leaving application
486
1075
  * state sitting in `localStorage` would be both a quota cost and a privacy
@@ -494,7 +1083,8 @@ declare class StorageTransport implements Transport {
494
1083
  private listeners;
495
1084
  private onStorage;
496
1085
  private seq;
497
- constructor(name: string, storage?: Storage);
1086
+ private serializer;
1087
+ constructor(name: string, storage?: Storage, serializer?: Serializer);
498
1088
  post(data: unknown): void;
499
1089
  subscribe(listener: (data: unknown) => void): () => void;
500
1090
  close(): void;
@@ -521,4 +1111,4 @@ declare function isStorageEventAvailable(): boolean;
521
1111
  */
522
1112
  declare function defaultTransport(name: string): Transport;
523
1113
 
524
- 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 LeaderStrategy, 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, StorageTransport, Transport, TransportKind, type Version, type WebStorageAdapterOptions, WindowClosedError, type WindowEventTarget, type WindowLike, connectToOpener, createChannel, createLeader, createPresence, createSharedStore, defaultTransport, enableDebug, getBusNames, getTransportKind, isBroadcastChannelAvailable, isStorageEventAvailable, localStorageAdapter, newer, observeBus, openWindow, sessionStorageAdapter, webStorageAdapter };
1114
+ 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 LockManagerLike, 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 };