statewire 0.16.0 → 0.17.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/README.md +12 -4
- package/dist/client/connection.d.ts +5 -1
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +19 -4
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +2 -0
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +15 -4
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +232 -50
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +5 -1
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +3 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +27 -9
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +10 -3
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +103 -39
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +9 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host-internal.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/statewire.d.ts +33 -2
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +34 -3
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fixtures.js +1 -0
- package/dist/transports/fixtures.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +15 -8
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +8 -8
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +1 -1
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +12 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.js +2 -2
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/documents.d.ts +25 -2
- package/dist/wire/documents.d.ts.map +1 -1
- package/dist/wire/documents.js +27 -7
- package/dist/wire/documents.js.map +1 -1
- package/dist/wire/protocol.d.ts +55 -0
- package/dist/wire/protocol.d.ts.map +1 -0
- package/dist/wire/protocol.js +221 -0
- package/dist/wire/protocol.js.map +1 -0
- package/dist/wire/version.d.ts +1 -3
- package/dist/wire/version.d.ts.map +1 -1
- package/dist/wire/version.js +1 -3
- package/dist/wire/version.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +43 -13
- package/src/client/core.ts +6 -0
- package/src/client/session.ts +338 -39
- package/src/client/sessionStore.ts +8 -0
- package/src/host/host.ts +1 -0
- package/src/host/http.ts +36 -7
- package/src/host/socket.ts +141 -50
- package/src/host-internal.ts +12 -1
- package/src/index.ts +3 -0
- package/src/statewire.ts +109 -6
- package/src/transports/fixtures.ts +1 -1
- package/src/transports/remote.ts +16 -7
- package/src/transports/sse.ts +18 -8
- package/src/transports/testing.ts +10 -2
- package/src/transports/ws.ts +1 -1
- package/src/types.ts +11 -0
- package/src/wire/documents.ts +126 -8
- package/src/wire/protocol.ts +327 -0
- package/src/wire/version.ts +0 -2
package/src/statewire.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { StatewireDocuments } from "./wire/documents";
|
|
2
|
+
import type { StatewireProtocol } from "./wire/protocol";
|
|
1
3
|
import type { StatewireHost } from "./host";
|
|
2
4
|
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
5
|
import {
|
|
@@ -13,18 +15,32 @@ import type { ResourceElement } from "@assistant-ui/tap";
|
|
|
13
15
|
import { CONNECTED_CONNECTION, useStatewireCore } from "./client/core";
|
|
14
16
|
import type { Statewire } from "./types";
|
|
15
17
|
import type { TERMINAL } from "./client/connection";
|
|
16
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
DocumentStorageContext,
|
|
20
|
+
ProtocolContext,
|
|
21
|
+
SessionStorageContext,
|
|
22
|
+
} from "./client/sessionStore";
|
|
17
23
|
|
|
18
24
|
const useSessionTransport = ({
|
|
19
25
|
transport,
|
|
20
26
|
session,
|
|
27
|
+
documents,
|
|
28
|
+
protocol,
|
|
21
29
|
}: {
|
|
22
30
|
transport: ResourceElement<Statewire<any, any>>;
|
|
23
31
|
session: StatewireClient.SessionStorage | undefined;
|
|
32
|
+
documents: StatewireDocuments.Storage | undefined;
|
|
33
|
+
protocol: StatewireProtocol.Options;
|
|
24
34
|
}): Statewire<unknown> =>
|
|
25
35
|
useContextProvider(SessionStorageContext, session, () =>
|
|
26
36
|
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
27
|
-
|
|
37
|
+
useContextProvider(DocumentStorageContext, documents, () =>
|
|
38
|
+
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
39
|
+
useContextProvider(ProtocolContext, protocol, () =>
|
|
40
|
+
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
41
|
+
useResource(transport),
|
|
42
|
+
),
|
|
43
|
+
),
|
|
28
44
|
);
|
|
29
45
|
|
|
30
46
|
// The transport owns the session; it reads the storage off the context this
|
|
@@ -45,8 +61,30 @@ export const useStatewire = <
|
|
|
45
61
|
? [withKey("session", options.storage.session)]
|
|
46
62
|
: [],
|
|
47
63
|
)[0];
|
|
64
|
+
const documents = useResources(
|
|
65
|
+
options.storage?.documents === undefined
|
|
66
|
+
? []
|
|
67
|
+
: [withKey("documents", options.storage.documents)],
|
|
68
|
+
)[0];
|
|
69
|
+
const protocol: StatewireProtocol.Options = {
|
|
70
|
+
...(options.protocol !== undefined && { protocol: options.protocol }),
|
|
71
|
+
...(options.wireVersion !== undefined && {
|
|
72
|
+
wireVersion: options.wireVersion,
|
|
73
|
+
}),
|
|
74
|
+
...(options.applicationProtocols !== undefined && {
|
|
75
|
+
applicationProtocols: options.applicationProtocols,
|
|
76
|
+
}),
|
|
77
|
+
...(options.keyPrefixes !== undefined && {
|
|
78
|
+
keyPrefixes: options.keyPrefixes,
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
48
81
|
const value = useResource(
|
|
49
|
-
SessionTransport({
|
|
82
|
+
SessionTransport({
|
|
83
|
+
transport: options.transport,
|
|
84
|
+
session,
|
|
85
|
+
documents,
|
|
86
|
+
protocol,
|
|
87
|
+
}),
|
|
50
88
|
);
|
|
51
89
|
const core = useStatewireCore(() => latest.current, value);
|
|
52
90
|
const [subscribe] = useState(
|
|
@@ -76,6 +114,26 @@ export const useStatewire = <
|
|
|
76
114
|
};
|
|
77
115
|
return {
|
|
78
116
|
state,
|
|
117
|
+
documents: value.documents,
|
|
118
|
+
selection: value.selection,
|
|
119
|
+
invoke: (
|
|
120
|
+
protocol: string,
|
|
121
|
+
method: string,
|
|
122
|
+
params: readonly unknown[],
|
|
123
|
+
) => {
|
|
124
|
+
if (!value.invoke)
|
|
125
|
+
throw new Error(
|
|
126
|
+
"statewire: transport does not support protocol commands",
|
|
127
|
+
);
|
|
128
|
+
return value.invoke(protocol, method, params);
|
|
129
|
+
},
|
|
130
|
+
updateContext: (ops: readonly unknown[]) => {
|
|
131
|
+
if (!value.updateContext)
|
|
132
|
+
throw new Error(
|
|
133
|
+
"statewire: transport does not support context documents",
|
|
134
|
+
);
|
|
135
|
+
value.updateContext(ops);
|
|
136
|
+
},
|
|
79
137
|
getState: core.getState as () => TState,
|
|
80
138
|
connection,
|
|
81
139
|
commands: core.commands as Statewire.CommandsProxy<TCommands>,
|
|
@@ -129,6 +187,23 @@ export class StatewireClient<
|
|
|
129
187
|
return this.root.getValue().commands;
|
|
130
188
|
}
|
|
131
189
|
|
|
190
|
+
get documents(): StatewireDocuments.Source | undefined {
|
|
191
|
+
return this.root.getValue().documents;
|
|
192
|
+
}
|
|
193
|
+
get selection(): StatewireProtocol.Selection | undefined {
|
|
194
|
+
return this.root.getValue().selection;
|
|
195
|
+
}
|
|
196
|
+
invoke(
|
|
197
|
+
protocol: string,
|
|
198
|
+
method: string,
|
|
199
|
+
params: readonly unknown[],
|
|
200
|
+
): Promise<unknown> {
|
|
201
|
+
return this.root.getValue().invoke(protocol, method, params);
|
|
202
|
+
}
|
|
203
|
+
updateContext(ops: readonly unknown[]): void {
|
|
204
|
+
this.root.getValue().updateContext(ops);
|
|
205
|
+
}
|
|
206
|
+
|
|
132
207
|
get isBusy(): boolean {
|
|
133
208
|
return this.root.getValue().isBusy;
|
|
134
209
|
}
|
|
@@ -165,10 +240,17 @@ export namespace StatewireClient {
|
|
|
165
240
|
TState,
|
|
166
241
|
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
167
242
|
> = {
|
|
243
|
+
protocol?: string;
|
|
244
|
+
keyPrefixes?: Readonly<Record<string, readonly string[]>>;
|
|
245
|
+
wireVersion?: StatewireProtocol.Range;
|
|
246
|
+
applicationProtocols?: readonly StatewireProtocol.Offer[] | "*";
|
|
168
247
|
transport: ResourceElement<Statewire<TState, TCommands>>;
|
|
169
248
|
/** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */
|
|
170
249
|
storage?:
|
|
171
|
-
| {
|
|
250
|
+
| {
|
|
251
|
+
session?: ResourceElement<SessionStorage> | undefined;
|
|
252
|
+
documents?: ResourceElement<StatewireDocuments.Storage> | undefined;
|
|
253
|
+
}
|
|
172
254
|
| undefined;
|
|
173
255
|
/** Served as the canonical state until the attach snapshot; own commands fold over it. */
|
|
174
256
|
initialState?: TState;
|
|
@@ -197,6 +279,14 @@ export namespace StatewireClient {
|
|
|
197
279
|
> = {
|
|
198
280
|
/** The statewire's state type — remote statewires include `undefined` until the first attach snapshot applies. */
|
|
199
281
|
state: TState;
|
|
282
|
+
documents: StatewireDocuments.Source | undefined;
|
|
283
|
+
selection: StatewireProtocol.Selection | undefined;
|
|
284
|
+
invoke: (
|
|
285
|
+
protocol: string,
|
|
286
|
+
method: string,
|
|
287
|
+
params: readonly unknown[],
|
|
288
|
+
) => Promise<unknown>;
|
|
289
|
+
updateContext: (ops: readonly unknown[]) => void;
|
|
200
290
|
/** Identity-stable across renders; reads the current canonical state without subscribing the caller. */
|
|
201
291
|
getState: () => TState;
|
|
202
292
|
connection: Connection;
|
|
@@ -318,6 +408,8 @@ export namespace StatewireClient {
|
|
|
318
408
|
|
|
319
409
|
/** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */
|
|
320
410
|
export type FailedCommand = {
|
|
411
|
+
readonly protocol: string;
|
|
412
|
+
readonly produced?: SessionCommand["produced"];
|
|
321
413
|
readonly command: string;
|
|
322
414
|
readonly params: readonly unknown[];
|
|
323
415
|
readonly meta?: unknown;
|
|
@@ -329,6 +421,11 @@ export namespace StatewireClient {
|
|
|
329
421
|
/** A command as persisted while un-durable: its lane seq plus the wire statement. */
|
|
330
422
|
export type SessionCommand = {
|
|
331
423
|
seq: number;
|
|
424
|
+
protocol: string;
|
|
425
|
+
produced?: {
|
|
426
|
+
wire: StatewireProtocol.Range;
|
|
427
|
+
protocols: readonly StatewireProtocol.Offer[] | "*";
|
|
428
|
+
};
|
|
332
429
|
method: string;
|
|
333
430
|
params: unknown[];
|
|
334
431
|
meta?: unknown;
|
|
@@ -339,6 +436,7 @@ export namespace StatewireClient {
|
|
|
339
436
|
clientId: string;
|
|
340
437
|
nextSeq: number;
|
|
341
438
|
ctxNextSeq: number;
|
|
439
|
+
context?: { ops: unknown[]; pending: { seq: number; ops: unknown[] }[] };
|
|
342
440
|
commands: readonly SessionCommand[];
|
|
343
441
|
/** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
|
|
344
442
|
verdicts?: readonly SessionVerdict[];
|
|
@@ -360,6 +458,11 @@ export namespace StatewireClient {
|
|
|
360
458
|
export type SessionStorage = {
|
|
361
459
|
load: () => SessionRecord | null;
|
|
362
460
|
save: (record: SessionRecord) => void;
|
|
461
|
+
/** Upgrade incompatible stored commands; null discards the old record and starts a new client identity. */
|
|
462
|
+
migrate?: (
|
|
463
|
+
record: SessionRecord,
|
|
464
|
+
current: NonNullable<SessionCommand["produced"]>,
|
|
465
|
+
) => SessionRecord | null;
|
|
363
466
|
/** Serializes record mutations across sharers; presence marks the storage shared. */
|
|
364
467
|
lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;
|
|
365
468
|
/** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */
|
|
@@ -373,7 +476,7 @@ export namespace StatewireClient {
|
|
|
373
476
|
| HeadersInit
|
|
374
477
|
| ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);
|
|
375
478
|
|
|
376
|
-
export type HttpOptions = {
|
|
479
|
+
export type HttpOptions = StatewireProtocol.Options & {
|
|
377
480
|
sessionStore?: SessionStorage | undefined;
|
|
378
481
|
url: string;
|
|
379
482
|
/** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
|
|
@@ -387,7 +490,7 @@ export namespace StatewireClient {
|
|
|
387
490
|
onDone?: () => void;
|
|
388
491
|
};
|
|
389
492
|
|
|
390
|
-
export type WebsocketOptions = {
|
|
493
|
+
export type WebsocketOptions = StatewireProtocol.Options & {
|
|
391
494
|
sessionStore?: SessionStorage | undefined;
|
|
392
495
|
url: string;
|
|
393
496
|
/** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
|
|
@@ -24,7 +24,7 @@ export const commandFrame = (
|
|
|
24
24
|
method: string,
|
|
25
25
|
seq: number,
|
|
26
26
|
params: unknown[] = [],
|
|
27
|
-
) => JSON.stringify({ cmd: [{ method, params, seq }] });
|
|
27
|
+
) => JSON.stringify({ cmd: [{ protocol: "default", method, params, seq }] });
|
|
28
28
|
|
|
29
29
|
export type SocketRecorder = {
|
|
30
30
|
sent: string[];
|
package/src/transports/remote.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useContext, useEffect } from "react";
|
|
2
|
+
import { ProtocolContext } from "../client/sessionStore";
|
|
2
3
|
import type { Statewire } from "../types";
|
|
3
4
|
import {
|
|
5
|
+
documentSource,
|
|
6
|
+
invokeProtocol,
|
|
7
|
+
updateContext,
|
|
4
8
|
discardFailed,
|
|
5
9
|
failedCommands,
|
|
6
10
|
retryFailed,
|
|
@@ -22,16 +26,21 @@ export const useStatewireRemote = <
|
|
|
22
26
|
options: TOptions,
|
|
23
27
|
useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,
|
|
24
28
|
): Statewire<any, any> => {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
});
|
|
30
|
-
const conn = useLink(options, s.ctx);
|
|
29
|
+
const inherited = useContext(ProtocolContext);
|
|
30
|
+
const configured = { ...inherited, ...options };
|
|
31
|
+
const s = useStatewireSession(configured);
|
|
32
|
+
const conn = useLink(configured, s.ctx);
|
|
31
33
|
useEffect(() => setTransport(s, conn), [s, conn]);
|
|
32
34
|
|
|
33
35
|
return {
|
|
34
36
|
state: s.canonical,
|
|
37
|
+
documents: documentSource(s),
|
|
38
|
+
get selection() {
|
|
39
|
+
return s.selection;
|
|
40
|
+
},
|
|
41
|
+
invoke: (protocol, method, params) =>
|
|
42
|
+
invokeProtocol(s, protocol, method, params),
|
|
43
|
+
updateContext: (ops) => updateContext(s, ops),
|
|
35
44
|
commands: s.commands,
|
|
36
45
|
connection: conn.connection,
|
|
37
46
|
source: s.source,
|
package/src/transports/sse.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
classify,
|
|
8
8
|
handleEnvelope,
|
|
9
9
|
resolveHeaders,
|
|
10
|
-
unsupportedVersion,
|
|
11
10
|
useLink,
|
|
12
11
|
validateRefusalSyn,
|
|
13
12
|
} from "../client/connection";
|
|
@@ -22,7 +21,7 @@ import type { Statewire } from "../types";
|
|
|
22
21
|
import type { StatewireClient } from "../statewire";
|
|
23
22
|
import type { StatewireHost } from "../host";
|
|
24
23
|
import { useStatewireRemote } from "./remote";
|
|
25
|
-
import {
|
|
24
|
+
import { negotiationHeaders, parseNegotiation } from "../wire/protocol";
|
|
26
25
|
|
|
27
26
|
/** A client frame's lanes: stamped statements, strictly consecutive per lane. */
|
|
28
27
|
export type StatewireFrame = {
|
|
@@ -121,6 +120,10 @@ const attemptStream = async (
|
|
|
121
120
|
): Promise<AttemptOutcome> => {
|
|
122
121
|
c.lease = null;
|
|
123
122
|
const headers = await resolveHeaders(options.headers, c.authStale);
|
|
123
|
+
for (const [name, value] of Object.entries(negotiationHeaders(options))) {
|
|
124
|
+
if (!headers.has(name)) headers.set(name, value);
|
|
125
|
+
}
|
|
126
|
+
c.negotiation = parseNegotiation(headers);
|
|
124
127
|
c.authStale = false;
|
|
125
128
|
const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
|
|
126
129
|
const body = lanes === null ? undefined : JSON.stringify(lanes);
|
|
@@ -150,14 +153,21 @@ const attemptStream = async (
|
|
|
150
153
|
} finally {
|
|
151
154
|
clearTimeout(headerTimer);
|
|
152
155
|
}
|
|
153
|
-
if (!res.ok)
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
const body = await res
|
|
158
|
+
.clone()
|
|
159
|
+
.json()
|
|
160
|
+
.catch(() => null);
|
|
161
|
+
return (
|
|
162
|
+
flushTapSync(() =>
|
|
163
|
+
body?.fin === undefined
|
|
164
|
+
? settleFin(c, httpFin(res))
|
|
165
|
+
: handleEnvelope(c, body),
|
|
166
|
+
) ?? "stop"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
154
169
|
if (!res.body)
|
|
155
170
|
throw new Error("statewire: protocol error: stream response has no body");
|
|
156
|
-
const version = res.headers.get(VERSION_HEADER)?.trim();
|
|
157
|
-
if (version !== undefined && version !== PROTOCOL_VERSION) {
|
|
158
|
-
res.body.cancel().catch(() => {});
|
|
159
|
-
return unsupportedVersion(c, version);
|
|
160
|
-
}
|
|
161
171
|
c.lease = res.headers.get(LEASE_HEADER);
|
|
162
172
|
const reader = res.body.getReader();
|
|
163
173
|
try {
|
|
@@ -41,6 +41,7 @@ let mockCount = 0;
|
|
|
41
41
|
|
|
42
42
|
export const createMockTransport = <TState>(
|
|
43
43
|
initialState: TState,
|
|
44
|
+
protocol = "default",
|
|
44
45
|
): MockTransport<TState> => {
|
|
45
46
|
let state: unknown = initialState;
|
|
46
47
|
let clientId = "";
|
|
@@ -57,11 +58,13 @@ export const createMockTransport = <TState>(
|
|
|
57
58
|
const admit = (id: string, frame: StatewireFrame) => {
|
|
58
59
|
const rec = clients.get(id)!;
|
|
59
60
|
for (const statement of frame.ctx ?? []) {
|
|
61
|
+
if (statement.seq <= rec.ctxSeq) continue;
|
|
60
62
|
rec.ctxSeq = Math.max(rec.ctxSeq, statement.seq);
|
|
61
63
|
const root = statement.ops[0] as { value?: unknown } | undefined;
|
|
62
64
|
commands.push({ context: root?.value });
|
|
63
65
|
}
|
|
64
66
|
for (const statement of frame.cmd ?? []) {
|
|
67
|
+
if (statement.seq <= rec.seq) continue;
|
|
65
68
|
rec.seq = Math.max(rec.seq, statement.seq);
|
|
66
69
|
commands.push({ method: statement.method, params: statement.params });
|
|
67
70
|
}
|
|
@@ -87,8 +90,13 @@ export const createMockTransport = <TState>(
|
|
|
87
90
|
(handle) => {
|
|
88
91
|
handle.send(
|
|
89
92
|
sseFrame({
|
|
90
|
-
ops: [{ op: "
|
|
91
|
-
syn: {
|
|
93
|
+
ops: [{ op: "mount", protocol, main: true, value: state }],
|
|
94
|
+
syn: {
|
|
95
|
+
seq: rec.seq,
|
|
96
|
+
ctxSeq: rec.ctxSeq,
|
|
97
|
+
version: "2026-09-07",
|
|
98
|
+
protocols: [{ name: protocol, version: "2026-09-07" }],
|
|
99
|
+
},
|
|
92
100
|
}),
|
|
93
101
|
);
|
|
94
102
|
},
|
package/src/transports/ws.ts
CHANGED
|
@@ -72,7 +72,7 @@ const runSocket = (
|
|
|
72
72
|
ws.onerror = () => {};
|
|
73
73
|
ws.onopen = () => {
|
|
74
74
|
const negotiated = ws.protocol.trim();
|
|
75
|
-
if (negotiated !==
|
|
75
|
+
if (negotiated !== WS_SUBPROTOCOL) {
|
|
76
76
|
outcome = unsupportedVersion(c, negotiated);
|
|
77
77
|
ws.close();
|
|
78
78
|
return;
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { StatewireDocuments } from "./wire/documents";
|
|
2
|
+
import type { StatewireProtocol } from "./wire/protocol";
|
|
1
3
|
import type { StatewireClient } from "./statewire";
|
|
2
4
|
import type { StatewirePath } from "./wire/ops";
|
|
3
5
|
|
|
@@ -16,6 +18,7 @@ export namespace Statewire {
|
|
|
16
18
|
|
|
17
19
|
export type CommandUpdate<TCommands extends Commands = Commands> = {
|
|
18
20
|
[K in keyof TCommands]: {
|
|
21
|
+
readonly protocol?: string;
|
|
19
22
|
method: K;
|
|
20
23
|
params: Parameters<TCommands[K]>;
|
|
21
24
|
seq: number;
|
|
@@ -50,6 +53,14 @@ export interface Statewire<
|
|
|
50
53
|
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
51
54
|
> {
|
|
52
55
|
state: TState;
|
|
56
|
+
documents?: StatewireDocuments.Source;
|
|
57
|
+
selection?: StatewireProtocol.Selection | undefined;
|
|
58
|
+
invoke?: (
|
|
59
|
+
protocol: string,
|
|
60
|
+
method: string,
|
|
61
|
+
params: readonly unknown[],
|
|
62
|
+
) => Promise<unknown>;
|
|
63
|
+
updateContext?: (ops: readonly unknown[]) => void;
|
|
53
64
|
commands: Statewire.CommandsProxy<TCommands>;
|
|
54
65
|
connection?: StatewireClient.Connection;
|
|
55
66
|
source?: Statewire.StateSource;
|
package/src/wire/documents.ts
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { applyOps } from "./ops";
|
|
2
2
|
import { reviveValue } from "./meta";
|
|
3
|
+
import { protocolToken } from "./protocol";
|
|
3
4
|
import type { StatewirePath } from "./ops";
|
|
4
5
|
|
|
5
6
|
export namespace StatewireDocuments {
|
|
6
|
-
export type
|
|
7
|
+
export type CacheEntry = { version: string; value: unknown };
|
|
8
|
+
export type Storage = {
|
|
9
|
+
namespace?: string;
|
|
10
|
+
load: (identity: readonly string[]) => CacheEntry | undefined;
|
|
11
|
+
save: (identity: readonly string[], entry: CacheEntry) => void;
|
|
12
|
+
subscribe?: (listener: () => void) => () => void;
|
|
13
|
+
};
|
|
14
|
+
export type Source = {
|
|
15
|
+
get: (
|
|
16
|
+
protocol: string,
|
|
17
|
+
key?: readonly string[],
|
|
18
|
+
prefix?: readonly string[],
|
|
19
|
+
) => unknown;
|
|
20
|
+
list: () => ReadonlyMap<number, Document>;
|
|
21
|
+
subscribe: (listener: () => void) => () => void;
|
|
22
|
+
};
|
|
23
|
+
export type Document = {
|
|
24
|
+
key: readonly string[] | null;
|
|
25
|
+
protocol?: string;
|
|
26
|
+
main?: boolean;
|
|
27
|
+
value: unknown;
|
|
28
|
+
};
|
|
7
29
|
export type State = {
|
|
8
30
|
mode: "mounted" | "keyless" | null;
|
|
9
31
|
documents: ReadonlyMap<number, Document>;
|
|
@@ -17,8 +39,12 @@ const invalid: (message: string) => never = (message) => {
|
|
|
17
39
|
export const applyDocumentOps = (
|
|
18
40
|
previous: StatewireDocuments.State,
|
|
19
41
|
ops: readonly unknown[],
|
|
42
|
+
protocols?: readonly { name: string; version: string }[],
|
|
43
|
+
strict = false,
|
|
20
44
|
) => {
|
|
21
|
-
let mode =
|
|
45
|
+
let mode =
|
|
46
|
+
previous.mode ??
|
|
47
|
+
(protocols !== undefined && ops.length === 0 ? "mounted" : null);
|
|
22
48
|
const documents = new Map(previous.documents);
|
|
23
49
|
const changedPaths: StatewirePath[] = [];
|
|
24
50
|
for (const raw of ops) {
|
|
@@ -35,10 +61,13 @@ export const applyDocumentOps = (
|
|
|
35
61
|
op.op as string,
|
|
36
62
|
)
|
|
37
63
|
) {
|
|
64
|
+
if (strict) invalid("unknown context operation");
|
|
38
65
|
const { doc: _doc, ...unknown } = op;
|
|
39
66
|
applyOps(undefined, [unknown]);
|
|
40
67
|
continue;
|
|
41
68
|
}
|
|
69
|
+
if (protocols !== undefined && mode === null && op.op !== "mount")
|
|
70
|
+
invalid("negotiated state must start with mounts");
|
|
42
71
|
if (mode === null) {
|
|
43
72
|
if (op.op === "mount") mode = "mounted";
|
|
44
73
|
else if (
|
|
@@ -64,23 +93,73 @@ export const applyDocumentOps = (
|
|
|
64
93
|
if (op.op === "mount") {
|
|
65
94
|
if (documents.has(doc)) invalid("document is already mounted");
|
|
66
95
|
if (
|
|
67
|
-
|
|
68
|
-
|
|
96
|
+
(op.key !== undefined &&
|
|
97
|
+
(!Array.isArray(op.key) ||
|
|
98
|
+
!op.key.every((key) => typeof key === "string"))) ||
|
|
69
99
|
!("value" in op)
|
|
70
100
|
) {
|
|
71
|
-
invalid("mount requires
|
|
101
|
+
invalid("mount requires value and an optional key array");
|
|
72
102
|
}
|
|
103
|
+
if (
|
|
104
|
+
op.protocol !== undefined &&
|
|
105
|
+
(typeof op.protocol !== "string" ||
|
|
106
|
+
!protocolToken.test(op.protocol) ||
|
|
107
|
+
op.protocol === "*")
|
|
108
|
+
)
|
|
109
|
+
invalid("invalid mount protocol");
|
|
110
|
+
if (
|
|
111
|
+
protocols !== undefined &&
|
|
112
|
+
!protocols.some((p) => p.name === op.protocol)
|
|
113
|
+
)
|
|
114
|
+
invalid("mount protocol was not negotiated");
|
|
115
|
+
if (op.main !== undefined && typeof op.main !== "boolean")
|
|
116
|
+
invalid("main must be boolean");
|
|
117
|
+
if (
|
|
118
|
+
op.main === true &&
|
|
119
|
+
[...documents.values()].some(
|
|
120
|
+
(d) => d.protocol === op.protocol && d.main,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
invalid("protocol already has a main document");
|
|
124
|
+
if (
|
|
125
|
+
op.key !== undefined &&
|
|
126
|
+
[...documents.values()].some(
|
|
127
|
+
(d) =>
|
|
128
|
+
d.protocol === op.protocol &&
|
|
129
|
+
JSON.stringify(d.key) === JSON.stringify(op.key),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
invalid("protocol document key is already mounted");
|
|
73
133
|
documents.set(doc, {
|
|
74
|
-
key: op.key,
|
|
134
|
+
key: op.key === undefined ? null : (op.key as string[]),
|
|
135
|
+
...(op.protocol === undefined
|
|
136
|
+
? {}
|
|
137
|
+
: { protocol: op.protocol as string }),
|
|
138
|
+
...(op.main === undefined ? {} : { main: op.main as boolean }),
|
|
75
139
|
value:
|
|
76
|
-
op.meta === undefined
|
|
140
|
+
op.meta === undefined
|
|
141
|
+
? op.value
|
|
142
|
+
: reviveValue(op.value, op.meta, { rejectUnknown: strict }),
|
|
77
143
|
});
|
|
78
144
|
if (doc === 0) changedPaths.push([]);
|
|
79
145
|
} else if (op.op === "unmount") {
|
|
146
|
+
const document = documents.get(doc);
|
|
147
|
+
if (
|
|
148
|
+
protocols !== undefined &&
|
|
149
|
+
document !== undefined &&
|
|
150
|
+
!protocols.some((p) => p.name === document.protocol)
|
|
151
|
+
)
|
|
152
|
+
invalid("document protocol was not negotiated");
|
|
80
153
|
if (!documents.delete(doc)) invalid("document is not mounted");
|
|
81
154
|
if (doc === 0) changedPaths.push([]);
|
|
82
155
|
} else if (op.op === "add" || op.op === "replace" || op.op === "remove") {
|
|
83
156
|
const document = documents.get(doc);
|
|
157
|
+
if (
|
|
158
|
+
protocols !== undefined &&
|
|
159
|
+
document !== undefined &&
|
|
160
|
+
!protocols.some((p) => p.name === document.protocol)
|
|
161
|
+
)
|
|
162
|
+
invalid("document protocol was not negotiated");
|
|
84
163
|
if (!document) invalid("document is not mounted");
|
|
85
164
|
if (
|
|
86
165
|
!Array.isArray(op.path) ||
|
|
@@ -98,7 +177,9 @@ export const applyDocumentOps = (
|
|
|
98
177
|
invalid("operation requires a value");
|
|
99
178
|
const { doc: _doc, meta, ...mutation } = op;
|
|
100
179
|
if (meta !== undefined)
|
|
101
|
-
mutation.value = reviveValue(mutation.value, meta
|
|
180
|
+
mutation.value = reviveValue(mutation.value, meta, {
|
|
181
|
+
rejectUnknown: strict,
|
|
182
|
+
});
|
|
102
183
|
const applied = applyOps(document.value, [mutation]);
|
|
103
184
|
documents.set(doc, { ...document, value: applied.state });
|
|
104
185
|
if (doc === 0) changedPaths.push(...applied.changedPaths);
|
|
@@ -107,3 +188,40 @@ export const applyDocumentOps = (
|
|
|
107
188
|
if (mode === null) invalid("hello missing snapshot");
|
|
108
189
|
return { mode, documents, changedPaths };
|
|
109
190
|
};
|
|
191
|
+
|
|
192
|
+
export const mainDocument = (
|
|
193
|
+
state: StatewireDocuments.State,
|
|
194
|
+
protocol: string,
|
|
195
|
+
) =>
|
|
196
|
+
[...state.documents.values()].find(
|
|
197
|
+
(document) => document.protocol === protocol && document.main,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
export const resolveDocumentKey = (
|
|
201
|
+
key: readonly string[],
|
|
202
|
+
prefix?: readonly string[],
|
|
203
|
+
) =>
|
|
204
|
+
key[0] === "."
|
|
205
|
+
? prefix === undefined
|
|
206
|
+
? undefined
|
|
207
|
+
: [...prefix, ...key.slice(1)]
|
|
208
|
+
: key;
|
|
209
|
+
|
|
210
|
+
export const findDocument = (
|
|
211
|
+
state: StatewireDocuments.State,
|
|
212
|
+
protocol: string,
|
|
213
|
+
key?: readonly string[],
|
|
214
|
+
prefix?: readonly string[],
|
|
215
|
+
) => {
|
|
216
|
+
if (key === undefined) return mainDocument(state, protocol);
|
|
217
|
+
const resolved = resolveDocumentKey(key, prefix);
|
|
218
|
+
return [...state.documents.values()].find(
|
|
219
|
+
(document) =>
|
|
220
|
+
document.protocol === protocol &&
|
|
221
|
+
document.key !== null &&
|
|
222
|
+
(JSON.stringify(document.key) === JSON.stringify(key) ||
|
|
223
|
+
(resolved !== undefined &&
|
|
224
|
+
JSON.stringify(resolveDocumentKey(document.key, prefix)) ===
|
|
225
|
+
JSON.stringify(resolved))),
|
|
226
|
+
);
|
|
227
|
+
};
|