@use-everywhere/core 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +677 -142
- package/dist/index.d.cts +658 -74
- package/dist/index.d.ts +658 -74
- package/dist/index.js +671 -142
- package/package.json +29 -12
package/dist/index.d.cts
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
|
-
|
|
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.
|
|
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?:
|
|
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
|
-
/**
|
|
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. */
|
|
@@ -166,11 +598,31 @@ interface PresenceOptions extends BusOptions {
|
|
|
166
598
|
* it can be short: a peer that is merely throttled still answers at once.
|
|
167
599
|
*/
|
|
168
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;
|
|
169
614
|
}
|
|
170
615
|
interface Presence {
|
|
171
616
|
readonly clientId: string;
|
|
172
617
|
/** Stable array snapshot (replaced on change) — safe for useSyncExternalStore. */
|
|
173
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;
|
|
174
626
|
subscribe(fn: () => void): () => void;
|
|
175
627
|
close(): void;
|
|
176
628
|
}
|
|
@@ -194,66 +646,6 @@ interface Presence {
|
|
|
194
646
|
*/
|
|
195
647
|
declare function createPresence(name: string, options?: PresenceOptions): Presence;
|
|
196
648
|
|
|
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
649
|
/**
|
|
258
650
|
* Elects exactly one client on the bus to hold a seat: the tab that owns the
|
|
259
651
|
* WebSocket, polls, or refreshes the token, while the others stand by.
|
|
@@ -269,6 +661,48 @@ interface Leader {
|
|
|
269
661
|
*/
|
|
270
662
|
declare function createLeader(name: string, options?: LeaderOptions): Leader;
|
|
271
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
|
+
|
|
272
706
|
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
273
707
|
interface WebStorageAdapterOptions {
|
|
274
708
|
/**
|
|
@@ -278,6 +712,16 @@ interface WebStorageAdapterOptions {
|
|
|
278
712
|
* not a recovery path. Errors thrown by the callback itself are swallowed.
|
|
279
713
|
*/
|
|
280
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;
|
|
281
725
|
}
|
|
282
726
|
/**
|
|
283
727
|
* Persist to any Storage-shaped thing.
|
|
@@ -298,6 +742,47 @@ declare function localStorageAdapter(key: string, options?: WebStorageAdapterOpt
|
|
|
298
742
|
/** Survives reloads, but dies with the tab. */
|
|
299
743
|
declare function sessionStorageAdapter(key: string, options?: WebStorageAdapterOptions): PersistAdapter;
|
|
300
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
|
+
|
|
301
786
|
interface MessageEventLike {
|
|
302
787
|
data: unknown;
|
|
303
788
|
origin: string;
|
|
@@ -403,6 +888,55 @@ declare function newer(a: Version, b: Version | undefined): boolean;
|
|
|
403
888
|
*/
|
|
404
889
|
declare const DEFAULT_NAME = "use-everywhere";
|
|
405
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
|
+
|
|
406
940
|
/** One wire crossing the bus, in either direction. */
|
|
407
941
|
interface BusEvent {
|
|
408
942
|
/** The bus name the wire crossed. */
|
|
@@ -430,6 +964,55 @@ declare function observeBus(name: string, fn: BusObserver): () => void;
|
|
|
430
964
|
/** Log every wire on a bus to the console. Returns a function to stop. */
|
|
431
965
|
declare function enableDebug(options?: DebugOptions): () => void;
|
|
432
966
|
|
|
967
|
+
/**
|
|
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.
|
|
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
|
+
|
|
433
1016
|
/**
|
|
434
1017
|
* Names of the buses currently alive on this page. Buses built with a custom
|
|
435
1018
|
* transport (tests) bypass the table, so they are not listed.
|
|
@@ -477,10 +1060,10 @@ declare class NoopTransport implements Transport {
|
|
|
477
1060
|
* Two differences from the real thing, both deliberate and both documented:
|
|
478
1061
|
*
|
|
479
1062
|
* 1. **Fidelity is JSON, not structured clone.** `localStorage` holds strings.
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
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.
|
|
484
1067
|
* 2. **The entry is removed immediately after writing.** Peers have already been
|
|
485
1068
|
* notified by then (the event carries the value), and leaving application
|
|
486
1069
|
* state sitting in `localStorage` would be both a quota cost and a privacy
|
|
@@ -494,7 +1077,8 @@ declare class StorageTransport implements Transport {
|
|
|
494
1077
|
private listeners;
|
|
495
1078
|
private onStorage;
|
|
496
1079
|
private seq;
|
|
497
|
-
|
|
1080
|
+
private serializer;
|
|
1081
|
+
constructor(name: string, storage?: Storage, serializer?: Serializer);
|
|
498
1082
|
post(data: unknown): void;
|
|
499
1083
|
subscribe(listener: (data: unknown) => void): () => void;
|
|
500
1084
|
close(): void;
|
|
@@ -521,4 +1105,4 @@ declare function isStorageEventAvailable(): boolean;
|
|
|
521
1105
|
*/
|
|
522
1106
|
declare function defaultTransport(name: string): Transport;
|
|
523
1107
|
|
|
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 };
|
|
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 };
|