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.
Files changed (73) hide show
  1. package/README.md +15 -1
  2. package/dist/client/connection.d.ts.map +1 -1
  3. package/dist/client/connection.js +5 -4
  4. package/dist/client/connection.js.map +1 -1
  5. package/dist/client/session.d.ts +8 -1
  6. package/dist/client/session.d.ts.map +1 -1
  7. package/dist/client/session.js +52 -12
  8. package/dist/client/session.js.map +1 -1
  9. package/dist/client/sessionStore.d.ts +2 -0
  10. package/dist/client/sessionStore.d.ts.map +1 -1
  11. package/dist/client/sessionStore.js.map +1 -1
  12. package/dist/client/timing.d.ts +2 -0
  13. package/dist/client/timing.d.ts.map +1 -1
  14. package/dist/client/timing.js +5 -4
  15. package/dist/client/timing.js.map +1 -1
  16. package/dist/host/host.d.ts +2 -1
  17. package/dist/host/host.d.ts.map +1 -1
  18. package/dist/host/host.js +6 -1
  19. package/dist/host/host.js.map +1 -1
  20. package/dist/host/http.js +1 -1
  21. package/dist/host/socket.d.ts +6 -1
  22. package/dist/host/socket.d.ts.map +1 -1
  23. package/dist/host/socket.js +79 -9
  24. package/dist/host/socket.js.map +1 -1
  25. package/dist/host-internal.d.ts +1 -1
  26. package/dist/host.d.ts +6 -0
  27. package/dist/host.d.ts.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/statewire.d.ts +3 -0
  30. package/dist/statewire.d.ts.map +1 -1
  31. package/dist/statewire.js +22 -5
  32. package/dist/statewire.js.map +1 -1
  33. package/dist/transports/remote.d.ts.map +1 -1
  34. package/dist/transports/remote.js +3 -2
  35. package/dist/transports/remote.js.map +1 -1
  36. package/dist/transports/sse.d.ts +4 -0
  37. package/dist/transports/sse.d.ts.map +1 -1
  38. package/dist/transports/sse.js +5 -0
  39. package/dist/transports/sse.js.map +1 -1
  40. package/dist/transports/testing.d.ts +2 -0
  41. package/dist/transports/testing.d.ts.map +1 -1
  42. package/dist/transports/testing.js +11 -4
  43. package/dist/transports/testing.js.map +1 -1
  44. package/dist/types.d.ts +4 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js.map +1 -1
  47. package/dist/wire/context.d.ts +14 -0
  48. package/dist/wire/context.d.ts.map +1 -0
  49. package/dist/wire/context.js +31 -0
  50. package/dist/wire/context.js.map +1 -0
  51. package/dist/wire/limits.d.ts +2 -1
  52. package/dist/wire/limits.d.ts.map +1 -1
  53. package/dist/wire/limits.js +2 -1
  54. package/dist/wire/limits.js.map +1 -1
  55. package/dist/wire/protocol.d.ts +1 -1
  56. package/dist/wire/protocol.js +4 -4
  57. package/dist/wire/protocol.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/client/connection.ts +6 -3
  60. package/src/client/session.ts +74 -4
  61. package/src/client/sessionStore.ts +5 -1
  62. package/src/client/timing.ts +12 -7
  63. package/src/host/host.ts +9 -0
  64. package/src/host/socket.ts +121 -7
  65. package/src/host.ts +5 -0
  66. package/src/statewire.ts +29 -3
  67. package/src/transports/remote.ts +3 -0
  68. package/src/transports/sse.ts +7 -0
  69. package/src/transports/testing.ts +14 -3
  70. package/src/types.ts +12 -0
  71. package/src/wire/context.ts +40 -0
  72. package/src/wire/limits.ts +2 -0
  73. package/src/wire/protocol.ts +1 -1
@@ -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 = { cmd: CmdStatement[] };
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) return violation("empty lane");
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
- return { cmd };
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
- closeConn(h, conn, 1011, "attach failed");
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: { isNew?: boolean | undefined };
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
- () => (options.isNew === undefined ? {} : { isNew: options.isNew }),
96
- [options.isNew],
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,
@@ -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: {
@@ -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: WIRE_VERSION,
114
+ version: applicationVersion,
105
115
  seq: rec[protocol] ?? 0,
106
116
  },
107
117
  ...ephemeral.map((name) => ({
108
118
  name,
109
- version: WIRE_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));
@@ -4,3 +4,5 @@ export const MAX_FRAME_BODY_BYTES = 8 * 1024 * 1024;
4
4
  export const MAX_EPHEMERAL_DOCUMENT_BYTES = 64 * 1024;
5
5
  export const MAX_INFLIGHT_COMMANDS_PER_HOST = 1024;
6
6
  export const COMMAND_CAPACITY_RETRY_MS = 1_000;
7
+
8
+ export const MAX_CONTEXT_BYTES = 64 * 1024;
@@ -18,7 +18,7 @@ export namespace StatewireProtocol {
18
18
  };
19
19
  }
20
20
 
21
- export const WIRE_VERSION = "2026-09-13";
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";