statewire 0.19.3 → 0.20.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 +15 -1
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +5 -4
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +8 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +52 -12
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +2 -0
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/client/timing.d.ts +2 -0
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +5 -4
- package/dist/client/timing.js.map +1 -1
- package/dist/host/host.d.ts +2 -1
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +6 -1
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.js +1 -1
- package/dist/host/socket.d.ts +6 -1
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +79 -9
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +1 -1
- package/dist/host.d.ts +6 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/statewire.d.ts +3 -0
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +22 -5
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +3 -2
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +4 -0
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +5 -0
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +2 -0
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +11 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/context.d.ts +14 -0
- package/dist/wire/context.d.ts.map +1 -0
- package/dist/wire/context.js +31 -0
- package/dist/wire/context.js.map +1 -0
- package/dist/wire/limits.d.ts +2 -1
- package/dist/wire/limits.d.ts.map +1 -1
- package/dist/wire/limits.js +2 -1
- package/dist/wire/limits.js.map +1 -1
- package/dist/wire/protocol.d.ts +1 -1
- package/dist/wire/protocol.js +4 -4
- package/dist/wire/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +6 -3
- package/src/client/session.ts +74 -4
- package/src/client/sessionStore.ts +5 -1
- package/src/client/timing.ts +12 -7
- package/src/host/host.ts +9 -0
- package/src/host/socket.ts +121 -7
- package/src/host.ts +5 -0
- package/src/statewire.ts +29 -3
- package/src/transports/remote.ts +3 -0
- package/src/transports/sse.ts +7 -0
- package/src/transports/testing.ts +14 -3
- package/src/types.ts +12 -0
- package/src/wire/context.ts +40 -0
- package/src/wire/limits.ts +2 -0
- package/src/wire/protocol.ts +1 -1
package/src/host/socket.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { contextValue, captureContext } from "../wire/context";
|
|
2
|
+
import { MAX_CONTEXT_BYTES } from "../wire/limits";
|
|
1
3
|
import { encodeDocument } from "../document";
|
|
2
4
|
import {
|
|
3
5
|
DEFAULT_PROTOCOL,
|
|
@@ -127,6 +129,7 @@ export type Conn = {
|
|
|
127
129
|
/** The awaited `authorize` return captured at this attach's authorization. */
|
|
128
130
|
auth: unknown;
|
|
129
131
|
lanes: Map<string, Lane>;
|
|
132
|
+
contexts: Map<string, Statewire.Context>;
|
|
130
133
|
documents: Map<string, StatewireHost.Document>;
|
|
131
134
|
included: Set<string>;
|
|
132
135
|
view: Map<string, { doc: number; value: unknown }>;
|
|
@@ -164,7 +167,10 @@ type CmdStatement = {
|
|
|
164
167
|
meta?: unknown;
|
|
165
168
|
};
|
|
166
169
|
|
|
167
|
-
export type ParsedFrame = {
|
|
170
|
+
export type ParsedFrame = {
|
|
171
|
+
cmd: CmdStatement[];
|
|
172
|
+
ctx?: { protocol: string; value: Statewire.Context }[];
|
|
173
|
+
};
|
|
168
174
|
|
|
169
175
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
170
176
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -175,12 +181,13 @@ export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
|
175
181
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
176
182
|
const keys = Object.keys(frame);
|
|
177
183
|
if (keys.length === 0) return violation("malformed frame");
|
|
178
|
-
const unknown = keys.filter((key) => key !== "cmd").sort();
|
|
184
|
+
const unknown = keys.filter((key) => key !== "cmd" && key !== "ctx").sort();
|
|
179
185
|
if (unknown.length > 0)
|
|
180
186
|
return violation(`unknown frame key: '${unknown[0]}'`);
|
|
181
|
-
const lane = frame["cmd"];
|
|
187
|
+
const lane = frame["cmd"] ?? [];
|
|
182
188
|
if (!Array.isArray(lane)) return violation("malformed frame");
|
|
183
|
-
if (lane.length === 0)
|
|
189
|
+
if (Object.hasOwn(frame, "cmd") && lane.length === 0)
|
|
190
|
+
return violation("empty lane");
|
|
184
191
|
if (lane.length > MAX_COMMANDS_PER_FRAME)
|
|
185
192
|
return violation("too many commands");
|
|
186
193
|
const prev = new Map<string, number>();
|
|
@@ -212,7 +219,47 @@ export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
|
212
219
|
...(statement["meta"] !== undefined && { meta: statement["meta"] }),
|
|
213
220
|
});
|
|
214
221
|
}
|
|
215
|
-
|
|
222
|
+
const contexts = frame["ctx"];
|
|
223
|
+
const ctx: NonNullable<ParsedFrame["ctx"]> = [];
|
|
224
|
+
if (contexts !== undefined) {
|
|
225
|
+
if (!Array.isArray(contexts) || contexts.length === 0)
|
|
226
|
+
return violation("empty context lane");
|
|
227
|
+
const seen = new Set<string>();
|
|
228
|
+
for (const entry of contexts) {
|
|
229
|
+
if (
|
|
230
|
+
!isPlainObject(entry) ||
|
|
231
|
+
typeof entry["protocol"] !== "string" ||
|
|
232
|
+
!Object.hasOwn(entry, "value") ||
|
|
233
|
+
Object.keys(entry).some(
|
|
234
|
+
(key) => !["protocol", "value", "meta"].includes(key),
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return violation("malformed context");
|
|
238
|
+
const protocol = entry["protocol"];
|
|
239
|
+
if (seen.has(protocol)) return violation("duplicate context protocol");
|
|
240
|
+
seen.add(protocol);
|
|
241
|
+
if (
|
|
242
|
+
frameEncoder.encode(JSON.stringify(entry)).byteLength >
|
|
243
|
+
MAX_CONTEXT_BYTES
|
|
244
|
+
)
|
|
245
|
+
return violation("context entry too large");
|
|
246
|
+
try {
|
|
247
|
+
ctx.push({
|
|
248
|
+
protocol,
|
|
249
|
+
value: contextValue({
|
|
250
|
+
value: entry["value"],
|
|
251
|
+
...(entry["meta"] !== undefined && { meta: entry["meta"] }),
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return violation(
|
|
256
|
+
error instanceof Error ? error.message : "invalid context",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (cmd.length === 0 && ctx.length === 0) return violation("empty frame");
|
|
262
|
+
return { cmd, ...(ctx.length > 0 && { ctx }) };
|
|
216
263
|
};
|
|
217
264
|
|
|
218
265
|
export const parseFrame = (raw: string): ParsedFrame => {
|
|
@@ -554,6 +601,13 @@ const attachOf = (h: SocketHostState, conn: Conn): StatewireHost.Attach => {
|
|
|
554
601
|
return entry;
|
|
555
602
|
};
|
|
556
603
|
return {
|
|
604
|
+
get caller() {
|
|
605
|
+
return callerOf(h, h.clients.get(conn.clientId)!, conn);
|
|
606
|
+
},
|
|
607
|
+
context: (protocol) => {
|
|
608
|
+
requireSelected(protocol);
|
|
609
|
+
return captureContext(conn.contexts.get(protocol) ?? {});
|
|
610
|
+
},
|
|
557
611
|
document: (protocol, key) => {
|
|
558
612
|
if (key === undefined && protocol === h.protocol.name)
|
|
559
613
|
return h.stateHost!.snapshot();
|
|
@@ -916,6 +970,7 @@ const admitCmd = (
|
|
|
916
970
|
clientId: rec.id,
|
|
917
971
|
caller: callerOf(h, rec, conn),
|
|
918
972
|
attach: conn.attach,
|
|
973
|
+
context: captureContext(conn.contexts.get(statement.protocol) ?? {}),
|
|
919
974
|
onApplied: () => {
|
|
920
975
|
if (adm.settled || adm.applied) return;
|
|
921
976
|
adm.applied = true;
|
|
@@ -959,11 +1014,65 @@ const refusal = (
|
|
|
959
1014
|
},
|
|
960
1015
|
});
|
|
961
1016
|
|
|
1017
|
+
const validateContexts = (
|
|
1018
|
+
h: SocketHostState,
|
|
1019
|
+
conn: Conn,
|
|
1020
|
+
frame: ParsedFrame,
|
|
1021
|
+
) => {
|
|
1022
|
+
for (const statement of frame.cmd) {
|
|
1023
|
+
if (!selects(conn, statement.protocol))
|
|
1024
|
+
return violation("unsupported protocol");
|
|
1025
|
+
const selected = conn.selection.protocols.find(
|
|
1026
|
+
(p) => p.name === statement.protocol,
|
|
1027
|
+
)!;
|
|
1028
|
+
if (selected.record === "ephemeral") {
|
|
1029
|
+
const through = watermark(h, conn, statement.protocol);
|
|
1030
|
+
const first = frame.cmd.find(
|
|
1031
|
+
(s) => s.protocol === statement.protocol && s.seq > through,
|
|
1032
|
+
);
|
|
1033
|
+
if (first && first.seq !== through + 1)
|
|
1034
|
+
return violation("ephemeral lane gap");
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
for (const entry of frame.ctx ?? []) {
|
|
1038
|
+
if (!selects(conn, entry.protocol))
|
|
1039
|
+
return violation("unsupported context protocol");
|
|
1040
|
+
try {
|
|
1041
|
+
h.current?.validateContext?.(
|
|
1042
|
+
entry.protocol,
|
|
1043
|
+
captureContext(entry.value),
|
|
1044
|
+
conn.attach,
|
|
1045
|
+
);
|
|
1046
|
+
} catch (error) {
|
|
1047
|
+
return violation(
|
|
1048
|
+
error instanceof Error ? error.message : "invalid context",
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
const installContexts = (
|
|
1055
|
+
h: SocketHostState,
|
|
1056
|
+
conn: Conn,
|
|
1057
|
+
frame: ParsedFrame,
|
|
1058
|
+
) => {
|
|
1059
|
+
for (const entry of frame.ctx ?? [])
|
|
1060
|
+
conn.contexts.set(entry.protocol, captureContext(entry.value));
|
|
1061
|
+
for (const entry of frame.ctx ?? [])
|
|
1062
|
+
h.current?.contextChanged?.(
|
|
1063
|
+
entry.protocol,
|
|
1064
|
+
captureContext(entry.value),
|
|
1065
|
+
conn.attach,
|
|
1066
|
+
);
|
|
1067
|
+
};
|
|
1068
|
+
|
|
962
1069
|
export const admitFrame = (
|
|
963
1070
|
h: SocketHostState,
|
|
964
1071
|
conn: Conn,
|
|
965
1072
|
frame: ParsedFrame,
|
|
966
1073
|
): FrameAdmission => {
|
|
1074
|
+
validateContexts(h, conn, frame);
|
|
1075
|
+
installContexts(h, conn, frame);
|
|
967
1076
|
const rec = h.clients.get(conn.clientId)!;
|
|
968
1077
|
rec.touched = Date.now();
|
|
969
1078
|
const lanes = new Map(
|
|
@@ -1048,7 +1157,7 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
1048
1157
|
let admission: ReturnType<typeof admitFrame>;
|
|
1049
1158
|
try {
|
|
1050
1159
|
const frame = first ? parseInitialFrame(data).frame : parseFrame(data);
|
|
1051
|
-
if (frame.cmd.length === 0) return;
|
|
1160
|
+
if (frame.cmd.length === 0 && !frame.ctx?.length) return;
|
|
1052
1161
|
admission = admitFrame(h, conn, frame);
|
|
1053
1162
|
} catch (error) {
|
|
1054
1163
|
const reason = violationReason(error);
|
|
@@ -1445,6 +1554,7 @@ export const register = (
|
|
|
1445
1554
|
lease: null,
|
|
1446
1555
|
auth,
|
|
1447
1556
|
lanes: new Map(),
|
|
1557
|
+
contexts: new Map(),
|
|
1448
1558
|
documents: new Map(),
|
|
1449
1559
|
included: new Set(),
|
|
1450
1560
|
view: new Map(),
|
|
@@ -1456,9 +1566,13 @@ export const register = (
|
|
|
1456
1566
|
conn.attach = attachOf(h, conn);
|
|
1457
1567
|
h.conns.add(conn);
|
|
1458
1568
|
try {
|
|
1569
|
+
validateContexts(h, conn, frame);
|
|
1570
|
+
installContexts(h, conn, frame);
|
|
1459
1571
|
h.current?.attaching?.(conn.attach);
|
|
1460
1572
|
} catch (error) {
|
|
1461
|
-
|
|
1573
|
+
const reason = violationReason(error);
|
|
1574
|
+
if (reason !== null) violate(h, conn, reason);
|
|
1575
|
+
else closeConn(h, conn, 1011, "attach failed");
|
|
1462
1576
|
throw error;
|
|
1463
1577
|
}
|
|
1464
1578
|
gcClients(h);
|
package/src/host.ts
CHANGED
|
@@ -53,8 +53,11 @@ export namespace StatewireHost {
|
|
|
53
53
|
* optimistic overlay — before the handler settles (idempotent, a no-op after
|
|
54
54
|
* settle). The command settles when the handler's returned promise does.
|
|
55
55
|
*/
|
|
56
|
+
export type CommandsOptions = { context?: () => Statewire.Context };
|
|
57
|
+
|
|
56
58
|
export type Ctx = {
|
|
57
59
|
readonly caller: Caller;
|
|
60
|
+
readonly context: Statewire.Context;
|
|
58
61
|
readonly attach: Attach;
|
|
59
62
|
readonly signal: AbortSignal;
|
|
60
63
|
readonly applied: () => void;
|
|
@@ -76,6 +79,8 @@ export namespace StatewireHost {
|
|
|
76
79
|
|
|
77
80
|
/** The admitting attach's view: its ephemeral documents plus the shared documents included for it. */
|
|
78
81
|
export type Attach = {
|
|
82
|
+
readonly caller: Caller;
|
|
83
|
+
context: (protocol: string) => Statewire.Context;
|
|
79
84
|
document: (protocol: string, key?: readonly string[]) => unknown;
|
|
80
85
|
/** `protocol` must be ephemeral; the encoded value is bounded by `MAX_EPHEMERAL_DOCUMENT_BYTES`. */
|
|
81
86
|
mount: (
|
package/src/statewire.ts
CHANGED
|
@@ -40,7 +40,10 @@ const useSessionTransport = ({
|
|
|
40
40
|
session: StatewireClient.SessionStorage | undefined;
|
|
41
41
|
documents: StatewireDocuments.Storage | undefined;
|
|
42
42
|
protocol: StatewireProtocol.Options;
|
|
43
|
-
link: {
|
|
43
|
+
link: {
|
|
44
|
+
isNew?: boolean | undefined;
|
|
45
|
+
context?: Readonly<Record<string, Statewire.Context>> | undefined;
|
|
46
|
+
};
|
|
44
47
|
}): Statewire<unknown> =>
|
|
45
48
|
useContextProvider(SessionStorageContext, session, () =>
|
|
46
49
|
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
@@ -92,8 +95,11 @@ export const useStatewire = <
|
|
|
92
95
|
}),
|
|
93
96
|
};
|
|
94
97
|
const link = useMemo(
|
|
95
|
-
() => (
|
|
96
|
-
|
|
98
|
+
() => ({
|
|
99
|
+
...(options.isNew === undefined ? {} : { isNew: options.isNew }),
|
|
100
|
+
...(options.context === undefined ? {} : { context: options.context }),
|
|
101
|
+
}),
|
|
102
|
+
[options.isNew, options.context],
|
|
97
103
|
);
|
|
98
104
|
const value = useResource(
|
|
99
105
|
SessionTransport({
|
|
@@ -104,6 +110,14 @@ export const useStatewire = <
|
|
|
104
110
|
link,
|
|
105
111
|
}),
|
|
106
112
|
);
|
|
113
|
+
const localContext =
|
|
114
|
+
value.connection === undefined ? value.setContext : undefined;
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const current = latest.current;
|
|
117
|
+
if (localContext === undefined || current.context === undefined) return;
|
|
118
|
+
for (const [protocol, context] of Object.entries(current.context))
|
|
119
|
+
localContext(protocol, context);
|
|
120
|
+
}, [options.context, localContext]);
|
|
107
121
|
const core = useStatewireCore(() => latest.current, value);
|
|
108
122
|
useEffect(() => core.rematerialize(), [core, options.materializeKey]);
|
|
109
123
|
const [subscribe] = useState(
|
|
@@ -127,6 +141,7 @@ export const useStatewire = <
|
|
|
127
141
|
documents: source,
|
|
128
142
|
selection,
|
|
129
143
|
invoke: transportInvoke,
|
|
144
|
+
setContext: transportSetContext,
|
|
130
145
|
failures: transportFailures,
|
|
131
146
|
} = value;
|
|
132
147
|
|
|
@@ -153,6 +168,11 @@ export const useStatewire = <
|
|
|
153
168
|
documents: source,
|
|
154
169
|
selection,
|
|
155
170
|
invoke,
|
|
171
|
+
setContext: (protocol: string, value: Statewire.Context) => {
|
|
172
|
+
if (!transportSetContext)
|
|
173
|
+
throw new Error("statewire: transport does not support context");
|
|
174
|
+
transportSetContext(protocol, value);
|
|
175
|
+
},
|
|
156
176
|
getState: core.getState as () => TState,
|
|
157
177
|
connection,
|
|
158
178
|
commands: core.commands as Statewire.CommandsProxy<TCommands>,
|
|
@@ -173,6 +193,7 @@ export const useStatewire = <
|
|
|
173
193
|
selection,
|
|
174
194
|
invoke,
|
|
175
195
|
transportFailures,
|
|
196
|
+
transportSetContext,
|
|
176
197
|
]);
|
|
177
198
|
};
|
|
178
199
|
|
|
@@ -221,6 +242,9 @@ export class StatewireClient<
|
|
|
221
242
|
): Promise<unknown> {
|
|
222
243
|
return this.root.getValue().invoke(protocol, method, params);
|
|
223
244
|
}
|
|
245
|
+
setContext(protocol: string, value: Statewire.Context): void {
|
|
246
|
+
this.root.getValue().setContext(protocol, value);
|
|
247
|
+
}
|
|
224
248
|
get isBusy(): boolean {
|
|
225
249
|
return this.root.getValue().isBusy;
|
|
226
250
|
}
|
|
@@ -254,6 +278,7 @@ export namespace StatewireClient {
|
|
|
254
278
|
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
255
279
|
> = {
|
|
256
280
|
protocol?: string;
|
|
281
|
+
context?: Readonly<Record<string, Statewire.Context>>;
|
|
257
282
|
keyPrefixes?: Readonly<Record<string, readonly string[]>>;
|
|
258
283
|
wireVersion?: StatewireProtocol.Range;
|
|
259
284
|
applicationProtocols?: readonly StatewireProtocol.Offer[] | "*";
|
|
@@ -298,6 +323,7 @@ export namespace StatewireClient {
|
|
|
298
323
|
state: TState;
|
|
299
324
|
documents: StatewireDocuments.Source | undefined;
|
|
300
325
|
selection: StatewireProtocol.Selection | undefined;
|
|
326
|
+
setContext: (protocol: string, value: Statewire.Context) => void;
|
|
301
327
|
invoke: (
|
|
302
328
|
protocol: string,
|
|
303
329
|
method: string,
|
package/src/transports/remote.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
retryFailed,
|
|
10
10
|
setTransport,
|
|
11
11
|
useStatewireSession,
|
|
12
|
+
setContext,
|
|
12
13
|
} from "../client/session";
|
|
13
14
|
import type { StatewireSessionOptions } from "../client/session";
|
|
14
15
|
import type { StatewireClient } from "../statewire";
|
|
@@ -35,6 +36,8 @@ export const useStatewireRemote = <
|
|
|
35
36
|
const lanes = useMemo(
|
|
36
37
|
() => ({
|
|
37
38
|
documents: documentSource(s),
|
|
39
|
+
setContext: (protocol: string, value: Statewire.Context) =>
|
|
40
|
+
setContext(s, protocol, value),
|
|
38
41
|
invoke: (protocol: string, method: string, params: readonly unknown[]) =>
|
|
39
42
|
invokeProtocol(s, protocol, method, params),
|
|
40
43
|
failures: {
|
package/src/transports/sse.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StatewireDocument } from "../document";
|
|
1
2
|
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
2
3
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
3
4
|
import { createSseParser } from "./sse-parser";
|
|
@@ -25,6 +26,7 @@ import { negotiationHeaders, parseNegotiation } from "../wire/protocol";
|
|
|
25
26
|
|
|
26
27
|
/** A client frame's lanes: stamped statements, strictly consecutive per lane. */
|
|
27
28
|
export type StatewireFrame = {
|
|
29
|
+
ctx?: ({ protocol: string } & StatewireDocument.Encoded)[];
|
|
28
30
|
cmd?: {
|
|
29
31
|
protocol: string;
|
|
30
32
|
method: string;
|
|
@@ -180,9 +182,14 @@ const attemptStream = async (
|
|
|
180
182
|
throw new Error("statewire: protocol error: stream response has no body");
|
|
181
183
|
c.lease = res.headers.get(LEASE_HEADER);
|
|
182
184
|
const reader = res.body.getReader();
|
|
185
|
+
const cancel = () => {
|
|
186
|
+
void reader.cancel().catch(() => {});
|
|
187
|
+
};
|
|
188
|
+
abort.signal.addEventListener("abort", cancel, { once: true });
|
|
183
189
|
try {
|
|
184
190
|
return await consume(c, reader, heartbeat, () => abort.signal.aborted);
|
|
185
191
|
} finally {
|
|
192
|
+
abort.signal.removeEventListener("abort", cancel);
|
|
186
193
|
reader.cancel().catch(() => {});
|
|
187
194
|
}
|
|
188
195
|
};
|
|
@@ -5,7 +5,7 @@ import type { StatewireFrame } from "./sse";
|
|
|
5
5
|
import type { Statewire } from "../types";
|
|
6
6
|
import { openSSEStream, sseFrame } from "./fake-sse";
|
|
7
7
|
import type { FakeStreamHandle } from "./fake-sse";
|
|
8
|
-
import { WIRE_VERSION } from "../wire/protocol";
|
|
8
|
+
import { WIRE_VERSION, parseNegotiation } from "../wire/protocol";
|
|
9
9
|
|
|
10
10
|
export * from "./fixtures";
|
|
11
11
|
|
|
@@ -19,6 +19,7 @@ export type MockTransport<TState> = {
|
|
|
19
19
|
transport: ResourceElement<Statewire<TState | undefined, any>>;
|
|
20
20
|
/** Captured statements in arrival order. */
|
|
21
21
|
commands: { protocol: string; method: string; params: unknown[] }[];
|
|
22
|
+
contexts: NonNullable<StatewireFrame["ctx"]>;
|
|
22
23
|
/** Replace the replicated state and broadcast it as a root snapshot op. */
|
|
23
24
|
setState: (state: TState) => Promise<void>;
|
|
24
25
|
/** Deliver a bare ack cmd packet on the stream. */
|
|
@@ -54,6 +55,7 @@ export const createMockTransport = <TState>(
|
|
|
54
55
|
const clients = new Map<string, Record<string, number>>();
|
|
55
56
|
const streams = new Set<FakeStreamHandle>();
|
|
56
57
|
const commands: MockTransport<unknown>["commands"] = [];
|
|
58
|
+
const contexts: MockTransport<unknown>["contexts"] = [];
|
|
57
59
|
|
|
58
60
|
const sendEnvelope = (envelope: StatewireEnvelope) => {
|
|
59
61
|
for (const stream of streams) stream.send(sseFrame(envelope));
|
|
@@ -62,6 +64,7 @@ export const createMockTransport = <TState>(
|
|
|
62
64
|
|
|
63
65
|
const admit = (id: string, frame: StatewireFrame) => {
|
|
64
66
|
const rec = clients.get(id)!;
|
|
67
|
+
contexts.push(...(frame.ctx ?? []));
|
|
65
68
|
for (const statement of frame.cmd ?? []) {
|
|
66
69
|
if (statement.seq <= (rec[statement.protocol] ?? 0)) continue;
|
|
67
70
|
rec[statement.protocol] = statement.seq;
|
|
@@ -86,6 +89,13 @@ export const createMockTransport = <TState>(
|
|
|
86
89
|
if (!clients.has(id)) clients.set(id, {});
|
|
87
90
|
const rec = clients.get(id)!;
|
|
88
91
|
const ephemeral = ephemeralLanes();
|
|
92
|
+
const offers = parseNegotiation(headers).applicationProtocols;
|
|
93
|
+
const applicationVersion =
|
|
94
|
+
offers === "*"
|
|
95
|
+
? WIRE_VERSION
|
|
96
|
+
: offers.find((offer) => offer.name === protocol)?.version;
|
|
97
|
+
if (applicationVersion === undefined)
|
|
98
|
+
throw new Error("mock protocol was not offered");
|
|
89
99
|
for (const lane of ephemeral) delete rec[lane];
|
|
90
100
|
const frame = JSON.parse(String(init?.body ?? "{}")) as StatewireFrame;
|
|
91
101
|
admit(id, frame);
|
|
@@ -101,12 +111,12 @@ export const createMockTransport = <TState>(
|
|
|
101
111
|
protocols: [
|
|
102
112
|
{
|
|
103
113
|
name: protocol,
|
|
104
|
-
version:
|
|
114
|
+
version: applicationVersion,
|
|
105
115
|
seq: rec[protocol] ?? 0,
|
|
106
116
|
},
|
|
107
117
|
...ephemeral.map((name) => ({
|
|
108
118
|
name,
|
|
109
|
-
version:
|
|
119
|
+
version: applicationVersion,
|
|
110
120
|
record: "ephemeral" as const,
|
|
111
121
|
seq: 0,
|
|
112
122
|
})),
|
|
@@ -132,6 +142,7 @@ export const createMockTransport = <TState>(
|
|
|
132
142
|
fetch: fakeFetch,
|
|
133
143
|
}),
|
|
134
144
|
commands,
|
|
145
|
+
contexts,
|
|
135
146
|
setState: (next) => {
|
|
136
147
|
state = next;
|
|
137
148
|
return sendEnvelope({ ops: [{ op: "replace", path: [], value: next }] });
|
package/src/types.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { StatewireClient } from "./statewire";
|
|
|
5
5
|
import type { StatewirePath } from "./wire/ops";
|
|
6
6
|
|
|
7
7
|
export namespace Statewire {
|
|
8
|
+
export type Context = Readonly<Record<string, unknown>>;
|
|
8
9
|
export type Commands = Record<string, (...args: never[]) => unknown>;
|
|
9
10
|
|
|
10
11
|
export type Command<TCommands extends Commands = Commands> = {
|
|
@@ -56,6 +57,17 @@ export interface Statewire<
|
|
|
56
57
|
state: TState;
|
|
57
58
|
documents?: StatewireDocuments.Source;
|
|
58
59
|
selection?: StatewireProtocol.Selection | undefined;
|
|
60
|
+
setContext?: (protocol: string, value: Statewire.Context) => void;
|
|
61
|
+
validateContext?: (
|
|
62
|
+
protocol: string,
|
|
63
|
+
value: Statewire.Context,
|
|
64
|
+
attach: StatewireHost.Attach,
|
|
65
|
+
) => void;
|
|
66
|
+
contextChanged?: (
|
|
67
|
+
protocol: string,
|
|
68
|
+
value: Statewire.Context,
|
|
69
|
+
attach: StatewireHost.Attach,
|
|
70
|
+
) => void;
|
|
59
71
|
invoke?: (
|
|
60
72
|
protocol: string,
|
|
61
73
|
method: string,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { decodeDocument, encodeDocument } from "../document";
|
|
2
|
+
import type { Statewire } from "../types";
|
|
3
|
+
import { MAX_CONTEXT_BYTES } from "./limits";
|
|
4
|
+
import { protocolToken } from "./protocol";
|
|
5
|
+
|
|
6
|
+
const freeze = (value: unknown): void => {
|
|
7
|
+
if (typeof value !== "object" || value === null) return;
|
|
8
|
+
for (const child of Object.values(value)) freeze(child);
|
|
9
|
+
Object.freeze(value);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const contextValue = (encoded: unknown): Statewire.Context => {
|
|
13
|
+
const value = decodeDocument(encoded);
|
|
14
|
+
if (
|
|
15
|
+
typeof value !== "object" ||
|
|
16
|
+
value === null ||
|
|
17
|
+
Array.isArray(value) ||
|
|
18
|
+
value instanceof Date
|
|
19
|
+
)
|
|
20
|
+
throw new TypeError("statewire: context root must be an object");
|
|
21
|
+
freeze(value);
|
|
22
|
+
return value as Statewire.Context;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const encodeContext = (protocol: string, value: Statewire.Context) => {
|
|
26
|
+
if (!protocolToken.test(protocol))
|
|
27
|
+
throw new TypeError("statewire: invalid context protocol");
|
|
28
|
+
const encoded = encodeDocument(value);
|
|
29
|
+
contextValue(encoded);
|
|
30
|
+
const entry = { protocol, ...encoded };
|
|
31
|
+
if (
|
|
32
|
+
new TextEncoder().encode(JSON.stringify(entry)).byteLength >
|
|
33
|
+
MAX_CONTEXT_BYTES
|
|
34
|
+
)
|
|
35
|
+
throw new TypeError("statewire: context entry too large");
|
|
36
|
+
return entry;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const captureContext = (value: Statewire.Context): Statewire.Context =>
|
|
40
|
+
contextValue(encodeDocument(value));
|
package/src/wire/limits.ts
CHANGED
package/src/wire/protocol.ts
CHANGED
|
@@ -18,7 +18,7 @@ export namespace StatewireProtocol {
|
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export const WIRE_VERSION = "2026-09-
|
|
21
|
+
export const WIRE_VERSION = "2026-09-30";
|
|
22
22
|
export const DEFAULT_PROTOCOL = "default";
|
|
23
23
|
export const PROTOCOL_HEADER = "Statewire-Protocol";
|
|
24
24
|
export const VERSION_HEADER = "Statewire-Version";
|