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.
Files changed (83) hide show
  1. package/README.md +12 -4
  2. package/dist/client/connection.d.ts +5 -1
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +19 -4
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts.map +1 -1
  7. package/dist/client/core.js +2 -0
  8. package/dist/client/core.js.map +1 -1
  9. package/dist/client/session.d.ts +15 -4
  10. package/dist/client/session.d.ts.map +1 -1
  11. package/dist/client/session.js +232 -50
  12. package/dist/client/session.js.map +1 -1
  13. package/dist/client/sessionStore.d.ts +5 -1
  14. package/dist/client/sessionStore.d.ts.map +1 -1
  15. package/dist/client/sessionStore.js +3 -1
  16. package/dist/client/sessionStore.js.map +1 -1
  17. package/dist/host/host.d.ts +1 -0
  18. package/dist/host/host.d.ts.map +1 -1
  19. package/dist/host/host.js.map +1 -1
  20. package/dist/host/http.d.ts.map +1 -1
  21. package/dist/host/http.js +27 -9
  22. package/dist/host/http.js.map +1 -1
  23. package/dist/host/socket.d.ts +10 -3
  24. package/dist/host/socket.d.ts.map +1 -1
  25. package/dist/host/socket.js +103 -39
  26. package/dist/host/socket.js.map +1 -1
  27. package/dist/host-internal.d.ts +9 -1
  28. package/dist/host-internal.d.ts.map +1 -1
  29. package/dist/host-internal.js +1 -1
  30. package/dist/index.d.ts +3 -1
  31. package/dist/statewire.d.ts +33 -2
  32. package/dist/statewire.d.ts.map +1 -1
  33. package/dist/statewire.js +34 -3
  34. package/dist/statewire.js.map +1 -1
  35. package/dist/transports/fixtures.js +1 -0
  36. package/dist/transports/fixtures.js.map +1 -1
  37. package/dist/transports/remote.d.ts.map +1 -1
  38. package/dist/transports/remote.js +15 -8
  39. package/dist/transports/remote.js.map +1 -1
  40. package/dist/transports/sse.d.ts.map +1 -1
  41. package/dist/transports/sse.js +8 -8
  42. package/dist/transports/sse.js.map +1 -1
  43. package/dist/transports/testing.d.ts +1 -1
  44. package/dist/transports/testing.d.ts.map +1 -1
  45. package/dist/transports/testing.js +12 -4
  46. package/dist/transports/testing.js.map +1 -1
  47. package/dist/transports/ws.js +2 -2
  48. package/dist/transports/ws.js.map +1 -1
  49. package/dist/types.d.ts +7 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js.map +1 -1
  52. package/dist/wire/documents.d.ts +25 -2
  53. package/dist/wire/documents.d.ts.map +1 -1
  54. package/dist/wire/documents.js +27 -7
  55. package/dist/wire/documents.js.map +1 -1
  56. package/dist/wire/protocol.d.ts +55 -0
  57. package/dist/wire/protocol.d.ts.map +1 -0
  58. package/dist/wire/protocol.js +221 -0
  59. package/dist/wire/protocol.js.map +1 -0
  60. package/dist/wire/version.d.ts +1 -3
  61. package/dist/wire/version.d.ts.map +1 -1
  62. package/dist/wire/version.js +1 -3
  63. package/dist/wire/version.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/client/connection.ts +43 -13
  66. package/src/client/core.ts +6 -0
  67. package/src/client/session.ts +338 -39
  68. package/src/client/sessionStore.ts +8 -0
  69. package/src/host/host.ts +1 -0
  70. package/src/host/http.ts +36 -7
  71. package/src/host/socket.ts +141 -50
  72. package/src/host-internal.ts +12 -1
  73. package/src/index.ts +3 -0
  74. package/src/statewire.ts +109 -6
  75. package/src/transports/fixtures.ts +1 -1
  76. package/src/transports/remote.ts +16 -7
  77. package/src/transports/sse.ts +18 -8
  78. package/src/transports/testing.ts +10 -2
  79. package/src/transports/ws.ts +1 -1
  80. package/src/types.ts +11 -0
  81. package/src/wire/documents.ts +126 -8
  82. package/src/wire/protocol.ts +327 -0
  83. package/src/wire/version.ts +0 -2
package/src/host/http.ts CHANGED
@@ -15,7 +15,8 @@ import {
15
15
  } from "./socket";
16
16
  import type { ParsedFrame, Registration, SocketHostState } from "./socket";
17
17
  import { MAX_FRAME_BODY_BYTES } from "../wire/limits";
18
- import { PROTOCOL_VERSION, VERSION_HEADER } from "../wire/version";
18
+ import { negotiateProtocols } from "../wire/protocol";
19
+ import type { StatewireProtocol } from "../wire/protocol";
19
20
 
20
21
  export { MAX_FRAME_BODY_BYTES } from "../wire/limits";
21
22
 
@@ -139,6 +140,7 @@ const attachSse = (
139
140
  h: SocketHostState,
140
141
  clientId: string,
141
142
  auth: unknown,
143
+ selection: StatewireProtocol.Selection,
142
144
  ): SseAttachment => {
143
145
  let controller: ReadableStreamDefaultController<Uint8Array>;
144
146
  let connection: Registration["connection"] | null = null;
@@ -192,7 +194,7 @@ const attachSse = (
192
194
  }
193
195
  },
194
196
  };
195
- const registration = register(h, clientId, socket, false, auth);
197
+ const registration = register(h, clientId, socket, false, auth, selection);
196
198
  connection = registration?.connection ?? null;
197
199
  if (!open) connection?.close();
198
200
  return {
@@ -213,7 +215,6 @@ const streamResponse = (
213
215
  status: 200,
214
216
  headers: {
215
217
  "Content-Type": "text/event-stream",
216
- [VERSION_HEADER]: PROTOCOL_VERSION,
217
218
  ...(lease !== null && { [LEASE_HEADER]: lease }),
218
219
  },
219
220
  });
@@ -230,6 +231,7 @@ const admitAttachFrame = (
230
231
  registration.rec,
231
232
  frame,
232
233
  registration.conn.auth,
234
+ registration.conn.selection,
233
235
  );
234
236
  if (admission.type === "refused") {
235
237
  sendTo(h, registration.conn, JSON.stringify(admission.syn));
@@ -251,7 +253,12 @@ const getStream = async (
251
253
  );
252
254
  if (mismatch !== null) return mismatch;
253
255
  if (atSubscriberLimit(h)) return subscriberLimitResponse();
254
- const attachment = attachSse(h, client.clientId, authorized.auth);
256
+ const selection = negotiateProtocols(request.headers, h.wireVersion, [
257
+ h.protocol,
258
+ ]);
259
+ if ("fin" in selection)
260
+ return Response.json(selection, { status: selection.fin.status });
261
+ const attachment = attachSse(h, client.clientId, authorized.auth, selection);
255
262
  const lease = attachment.registration === null ? null : mintLease();
256
263
  if (attachment.registration !== null)
257
264
  attachment.registration.conn.lease = lease;
@@ -295,11 +302,26 @@ const postStream = async (
295
302
  );
296
303
  if (mismatch !== null) return mismatch;
297
304
  if (atSubscriberLimit(h)) return subscriberLimitResponse();
298
- const attachment = attachSse(h, client.clientId, authorized.auth);
305
+ const selection = negotiateProtocols(request.headers, h.wireVersion, [
306
+ h.protocol,
307
+ ]);
308
+ if ("fin" in selection)
309
+ return Response.json(selection, { status: selection.fin.status });
310
+ const attachment = attachSse(h, client.clientId, authorized.auth, selection);
299
311
  if (attachment.registration === null) return streamResponse(attachment, null);
300
312
  const lease = mintLease();
301
313
  attachment.registration.conn.lease = lease;
302
- admitAttachFrame(h, attachment.registration, frame);
314
+ try {
315
+ admitAttachFrame(h, attachment.registration, frame);
316
+ } catch (error) {
317
+ closeConn(h, attachment.registration.conn, 1008, "invalid-context");
318
+ if (!(error instanceof Error) || error.name !== "StatewireViolation")
319
+ throw error;
320
+ return Response.json(
321
+ { fin: { reason: "protocol-error", message: error.message } },
322
+ { status: 400 },
323
+ );
324
+ }
303
325
  return streamResponse(attachment, lease);
304
326
  };
305
327
 
@@ -371,7 +393,14 @@ export const frames = async (
371
393
  if (!leased.open || h.disposed || h.finished !== null) {
372
394
  return Response.json({ error: "stale-lease" }, { status: 423 });
373
395
  }
374
- const admission = admitFrame(h, rec, frame, leased.auth);
396
+ let admission: ReturnType<typeof admitFrame>;
397
+ try {
398
+ admission = admitFrame(h, rec, frame, leased.auth, leased.selection);
399
+ } catch (error) {
400
+ if (!(error instanceof Error) || error.name !== "StatewireViolation")
401
+ throw error;
402
+ return violationResponse(h, clientId, error.message);
403
+ }
375
404
  if (admission.type === "refused") {
376
405
  const retry = admission.syn.syn.retry;
377
406
  return Response.json(admission.syn, {
@@ -1,3 +1,11 @@
1
+ import {
2
+ DEFAULT_PROTOCOL,
3
+ WIRE_VERSION,
4
+ negotiateProtocols,
5
+ } from "../wire/protocol";
6
+ import type { StatewireProtocol } from "../wire/protocol";
7
+ import { applyDocumentOps, mainDocument } from "../wire/documents";
8
+ import type { StatewireDocuments } from "../wire/documents";
1
9
  import { useEffect, useState } from "react";
2
10
  import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
3
11
  import type { ResourceElement } from "@assistant-ui/tap";
@@ -88,6 +96,7 @@ export type ClientRec = {
88
96
  ctxSeq: number;
89
97
  touched: number;
90
98
  context: Record<string, unknown> | null;
99
+ contextDocuments: StatewireDocuments.State;
91
100
  results: Map<number, { ts: number; cmd: StatewireCmd }>;
92
101
  inflight: Map<number, Admission>;
93
102
  /** The auth captured at the record's first attach; later attaches must match. */
@@ -95,6 +104,7 @@ export type ClientRec = {
95
104
  };
96
105
 
97
106
  export type Conn = {
107
+ selection: StatewireProtocol.Selection;
98
108
  clientId: string;
99
109
  socket: StatewireHostInternal.Socket;
100
110
  open: boolean;
@@ -125,8 +135,9 @@ const violationReason = (error: unknown): string | null =>
125
135
  ? error.message
126
136
  : null;
127
137
 
128
- type CtxStatement = { seq: number; value: Record<string, unknown> };
138
+ type CtxStatement = { seq: number; ops: unknown[] };
129
139
  type CmdStatement = {
140
+ protocol: string;
130
141
  seq: number;
131
142
  method: string;
132
143
  params: unknown;
@@ -161,30 +172,6 @@ const parseLane = (value: unknown): Record<string, unknown>[] => {
161
172
 
162
173
  const frameEncoder = new TextEncoder();
163
174
 
164
- /** v1 ctx statements state the context whole: one root replace with an object value. */
165
- const contextValue = (ops: unknown): Record<string, unknown> => {
166
- if (!Array.isArray(ops) || ops.length !== 1) {
167
- throw new Error("context must be a single-op array");
168
- }
169
- const op = ops[0] as Record<string, unknown> | null;
170
- if (
171
- !isPlainObject(op) ||
172
- op["op"] !== "replace" ||
173
- !Array.isArray(op["path"]) ||
174
- op["path"].length !== 0
175
- ) {
176
- throw new Error("context op must be a root replace");
177
- }
178
- const value =
179
- op["meta"] !== undefined
180
- ? reviveValue(op["value"], op["meta"], { rejectUnknown: true })
181
- : op["value"];
182
- if (!isPlainObject(value)) {
183
- throw new Error("context value must be an object");
184
- }
185
- return value;
186
- };
187
-
188
175
  export const parseFrameValue = (frame: unknown): ParsedFrame => {
189
176
  if (!isPlainObject(frame)) return violation("malformed frame");
190
177
  const keys = Object.keys(frame);
@@ -199,19 +186,15 @@ export const parseFrameValue = (frame: unknown): ParsedFrame => {
199
186
  return violation("too many context statements");
200
187
  }
201
188
  for (const statement of parseLane(lane)) {
202
- let value: Record<string, unknown>;
203
- try {
204
- value = contextValue(statement["ops"]);
205
- } catch {
189
+ const ops = statement["ops"];
190
+ if (!Array.isArray(ops) || !ops.length)
206
191
  return violation("invalid-context");
207
- }
208
192
  if (
209
- frameEncoder.encode(JSON.stringify(value)).byteLength >
193
+ frameEncoder.encode(JSON.stringify(ops)).byteLength >
210
194
  MAX_CONTEXT_VALUE_BYTES
211
- ) {
195
+ )
212
196
  return violation("oversized-context");
213
- }
214
- ctx.push({ seq: statement["seq"] as number, value });
197
+ ctx.push({ seq: statement["seq"] as number, ops });
215
198
  }
216
199
  }
217
200
  const cmd: CmdStatement[] = [];
@@ -222,10 +205,15 @@ export const parseFrameValue = (frame: unknown): ParsedFrame => {
222
205
  }
223
206
  for (const statement of parseLane(lane)) {
224
207
  const method = statement["method"];
225
- if (typeof method !== "string" || !("params" in statement)) {
208
+ if (
209
+ typeof method !== "string" ||
210
+ typeof statement["protocol"] !== "string" ||
211
+ !("params" in statement)
212
+ ) {
226
213
  return violation("malformed statement");
227
214
  }
228
215
  cmd.push({
216
+ protocol: statement["protocol"] as string,
229
217
  seq: statement["seq"] as number,
230
218
  method,
231
219
  params: statement["params"],
@@ -334,6 +322,8 @@ const parseInitialFrame = (raw: string) => {
334
322
  // ─── host ───────────────────────────────────────────────
335
323
 
336
324
  export type SocketHostState = {
325
+ protocol: StatewireProtocol.Definition;
326
+ wireVersion: StatewireProtocol.Range;
337
327
  heartbeatMs: number;
338
328
  wsInitTimeoutMs: number;
339
329
  maxSubscribers: number;
@@ -518,7 +508,9 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
518
508
  ).map((seq) => ({ seq, type: "applied" }));
519
509
  if (ops.length > 0) {
520
510
  const packet: StatewireEnvelope = {
521
- ops,
511
+ ops: conn.selection.protocols.some((p) => p.name === h.protocol.name)
512
+ ? ops
513
+ : [],
522
514
  ...(appliedEntries.length > 0 && { cmd: appliedEntries }),
523
515
  ...(edge && { idle: level }),
524
516
  };
@@ -526,7 +518,11 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
526
518
  } else if (appliedEntries.length > 0) {
527
519
  sendTo(h, conn, JSON.stringify({ cmd: appliedEntries }));
528
520
  }
529
- const verdicts = cmds.get(conn.clientId) ?? [];
521
+ const verdicts = (cmds.get(conn.clientId) ?? []).map((cmd) =>
522
+ conn.selection.protocols.some((p) => p.name === h.protocol.name)
523
+ ? cmd
524
+ : { seq: cmd.seq, ...(cmd.dur === undefined ? {} : { dur: cmd.dur }) },
525
+ );
530
526
  if (verdicts.length > 0) {
531
527
  sendTo(h, conn, JSON.stringify({ cmd: verdicts }));
532
528
  }
@@ -562,6 +558,7 @@ const notifyClientRecords = (h: SocketHostState) => {
562
558
  ctxSeq: rec.ctxSeq,
563
559
  touched: rec.touched,
564
560
  context: rec.context,
561
+ contextDocuments: [...rec.contextDocuments.documents],
565
562
  results: [...rec.results].map(([seq, entry]) => ({
566
563
  seq,
567
564
  ts: entry.ts,
@@ -672,6 +669,7 @@ const admitCmd = (
672
669
  rec: ClientRec,
673
670
  statement: CmdStatement,
674
671
  auth: unknown,
672
+ selection: StatewireProtocol.Selection,
675
673
  ) => {
676
674
  const adm: Admission = {
677
675
  seq: statement.seq,
@@ -682,6 +680,18 @@ const admitCmd = (
682
680
  };
683
681
  rec.inflight.set(statement.seq, adm);
684
682
  notifyClientRecords(h);
683
+ if (
684
+ statement.protocol !== h.protocol.name ||
685
+ !selection.protocols.some((p) => p.name === statement.protocol)
686
+ ) {
687
+ settleCmd(h, rec, adm, {
688
+ seq: adm.seq,
689
+ type: "rejected",
690
+ code: "unsupported-protocol",
691
+ message: "command protocol was not negotiated",
692
+ });
693
+ return;
694
+ }
685
695
  if (!Array.isArray(statement.params)) {
686
696
  rejectStatement(h, rec, adm, "params must be an array");
687
697
  return;
@@ -749,6 +759,10 @@ export const admitFrame = (
749
759
  rec: ClientRec,
750
760
  frame: ParsedFrame,
751
761
  auth: unknown,
762
+ selection: StatewireProtocol.Selection = {
763
+ version: h.wireVersion.version,
764
+ protocols: [{ name: h.protocol.name, version: h.protocol.version }],
765
+ },
752
766
  ): FrameAdmission => {
753
767
  rec.touched = Date.now();
754
768
  const through = admittedThrough(rec);
@@ -801,11 +815,28 @@ export const admitFrame = (
801
815
  };
802
816
  }
803
817
  }
804
- for (const statement of ctxNew) {
805
- rec.context = statement.value;
806
- rec.ctxSeq = statement.seq;
818
+ let contextDocuments = rec.contextDocuments;
819
+ try {
820
+ for (const statement of ctxNew)
821
+ contextDocuments = applyDocumentOps(
822
+ contextDocuments,
823
+ statement.ops,
824
+ selection.protocols,
825
+ true,
826
+ );
827
+ if (
828
+ frameEncoder.encode(JSON.stringify([...contextDocuments.documents]))
829
+ .byteLength > MAX_CONTEXT_VALUE_BYTES
830
+ )
831
+ return violation("oversized-context");
832
+ } catch {
833
+ return violation("invalid-context");
807
834
  }
808
- for (const statement of cmdNew) admitCmd(h, rec, statement, auth);
835
+ rec.contextDocuments = contextDocuments;
836
+ rec.context = (mainDocument(contextDocuments, h.protocol.name)?.value ??
837
+ null) as Record<string, unknown> | null;
838
+ if (ctxNew.length) rec.ctxSeq = ctxNew.at(-1)!.seq;
839
+ for (const statement of cmdNew) admitCmd(h, rec, statement, auth, selection);
809
840
  if (cmdNew.length > 0) {
810
841
  // Memory host: admitted means retained — dur rides the admitting flush.
811
842
  queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
@@ -854,7 +885,20 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
854
885
  }
855
886
  if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
856
887
  const rec = h.clients.get(conn.clientId)!;
857
- const admission = admitFrame(h, rec, frame, conn.auth);
888
+ let admission: ReturnType<typeof admitFrame>;
889
+ try {
890
+ admission = admitFrame(h, rec, frame, conn.auth, conn.selection);
891
+ } catch (error) {
892
+ const reason = violationReason(error);
893
+ if (reason === null) throw error;
894
+ sendTo(
895
+ h,
896
+ conn,
897
+ JSON.stringify({ fin: { reason: "protocol-error", message: reason } }),
898
+ );
899
+ closeConn(h, conn, 1008, reason);
900
+ return;
901
+ }
858
902
  if (admission.type === "refused") {
859
903
  sendTo(h, conn, JSON.stringify(admission.syn));
860
904
  }
@@ -965,6 +1009,7 @@ const connectAuthorized = (
965
1009
  socket,
966
1010
  options.authorized === true,
967
1011
  options.auth,
1012
+ options.selection,
968
1013
  );
969
1014
  return registration?.connection ?? null;
970
1015
  }
@@ -1056,7 +1101,10 @@ const connectAuthorized = (
1056
1101
  const frame = initial.frame;
1057
1102
  let headers: Record<string, string>;
1058
1103
  try {
1059
- headers = normalizedHeaders(initial.headers);
1104
+ headers = {
1105
+ ...normalizedHeaders(initial.headers),
1106
+ ...normalizedHeaders(options.headers ?? {}),
1107
+ };
1060
1108
  } catch {
1061
1109
  sendPending(
1062
1110
  pending,
@@ -1089,17 +1137,39 @@ const connectAuthorized = (
1089
1137
  .then(() => authorize?.({ headers }))
1090
1138
  .then((auth) => {
1091
1139
  if (!pending.open || h.disposed || h.finished !== null) return;
1092
- options.onAuthorized?.(auth);
1140
+ const selection = negotiateProtocols(
1141
+ new Headers(headers),
1142
+ h.wireVersion,
1143
+ [h.protocol],
1144
+ );
1145
+ if ("fin" in selection) {
1146
+ sendPending(pending, JSON.stringify(selection));
1147
+ closePending(h, pending, 1008, selection.fin.message);
1148
+ return;
1149
+ }
1150
+ options.onAuthorized?.(auth, selection);
1093
1151
  pending.open = false;
1094
1152
  h.pendingConns.delete(pending);
1095
1153
  if (pending.timer !== null) clearTimeout(pending.timer);
1096
- const registration = register(h, clientId, socket, true, auth);
1154
+ const registration = register(
1155
+ h,
1156
+ clientId,
1157
+ socket,
1158
+ true,
1159
+ auth,
1160
+ selection,
1161
+ );
1097
1162
  if (registration === null) return;
1098
1163
  pending.connection = registration.connection;
1099
1164
  if (frame.ctx.length || frame.cmd.length) {
1100
- const admission = admitFrame(h, registration.rec, frame, auth);
1101
- if (admission.type === "refused")
1102
- sendTo(h, registration.conn, JSON.stringify(admission.syn));
1165
+ message(
1166
+ h,
1167
+ registration.conn,
1168
+ JSON.stringify({
1169
+ ...(frame.ctx.length ? { ctx: frame.ctx } : {}),
1170
+ ...(frame.cmd.length ? { cmd: frame.cmd } : {}),
1171
+ }),
1172
+ );
1103
1173
  }
1104
1174
  for (const data of pending.queued.splice(0))
1105
1175
  registration.connection.message(data);
@@ -1143,6 +1213,10 @@ export const register = (
1143
1213
  socket: StatewireHostInternal.Socket,
1144
1214
  sawFrame = false,
1145
1215
  auth: unknown = undefined,
1216
+ selection: StatewireProtocol.Selection = {
1217
+ version: h.wireVersion.version,
1218
+ protocols: [{ name: h.protocol.name, version: h.protocol.version }],
1219
+ },
1146
1220
  ): Registration | null => {
1147
1221
  const refuse = (code: number, reason: string) => {
1148
1222
  try {
@@ -1193,6 +1267,7 @@ export const register = (
1193
1267
  ctxSeq: 0,
1194
1268
  touched: Date.now(),
1195
1269
  context: null,
1270
+ contextDocuments: { mode: "mounted", documents: new Map() },
1196
1271
  results: new Map(),
1197
1272
  inflight: new Map(),
1198
1273
  principal: auth,
@@ -1202,6 +1277,7 @@ export const register = (
1202
1277
  })();
1203
1278
  rec.touched = Date.now();
1204
1279
  const conn: Conn = {
1280
+ selection,
1205
1281
  clientId,
1206
1282
  socket,
1207
1283
  open: true,
@@ -1218,18 +1294,22 @@ export const register = (
1218
1294
  const hello: StatewireEnvelope = {
1219
1295
  ops: [
1220
1296
  {
1221
- op: "replace",
1222
- path: [],
1297
+ op: "mount",
1298
+ protocol: h.protocol.name,
1299
+ main: true,
1223
1300
  value,
1224
1301
  ...(meta !== undefined && { meta }),
1225
1302
  },
1226
1303
  ],
1227
1304
  syn: {
1305
+ ...selection,
1228
1306
  seq: admittedThrough(rec),
1229
1307
  ctxSeq: rec.ctxSeq,
1230
1308
  ...(h.idleWire && { idle: true }),
1231
1309
  },
1232
1310
  };
1311
+ if (!selection.protocols.some((p) => p.name === h.protocol.name))
1312
+ hello.ops = [];
1233
1313
  sendTo(h, conn, JSON.stringify(hello));
1234
1314
  const connection: StatewireHostInternal.SocketConnection = {
1235
1315
  clientId,
@@ -1287,6 +1367,10 @@ const restoreClientRecords = (
1287
1367
  record.id,
1288
1368
  {
1289
1369
  ...record,
1370
+ contextDocuments: {
1371
+ mode: "mounted",
1372
+ documents: new Map(record.contextDocuments ?? []),
1373
+ },
1290
1374
  results: new Map(
1291
1375
  record.results.map(({ seq, ts, cmd }) => [seq, { ts, cmd }]),
1292
1376
  ),
@@ -1305,6 +1389,8 @@ const useSocketHost = (
1305
1389
  > => {
1306
1390
  const [h] = useState((): SocketHostState => {
1307
1391
  const host: SocketHostState = {
1392
+ protocol: { name: DEFAULT_PROTOCOL, version: WIRE_VERSION },
1393
+ wireVersion: { version: WIRE_VERSION },
1308
1394
  heartbeatMs: 15_000,
1309
1395
  wsInitTimeoutMs: 5_000,
1310
1396
  maxSubscribers: MAX_SUBSCRIBERS,
@@ -1341,6 +1427,11 @@ const useSocketHost = (
1341
1427
  };
1342
1428
  return host;
1343
1429
  });
1430
+ h.protocol = options?.protocol ?? {
1431
+ name: DEFAULT_PROTOCOL,
1432
+ version: WIRE_VERSION,
1433
+ };
1434
+ h.wireVersion = options?.wireVersion ?? { version: WIRE_VERSION };
1344
1435
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
1345
1436
  h.pushback = options?.pushback;
1346
1437
  h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
@@ -1,3 +1,5 @@
1
+ import type { StatewireProtocol } from "./wire/protocol";
2
+ import type { StatewireDocuments } from "./wire/documents";
1
3
  import type { StatewireHost } from "./host";
2
4
  import type { StatewireCmd } from "./client/connection";
3
5
 
@@ -17,12 +19,18 @@ export namespace StatewireHostInternal {
17
19
  };
18
20
 
19
21
  export type SocketConnectOptions = {
22
+ /** Upgrade headers take precedence over initialization headers. */
23
+ headers?: Record<string, string>;
20
24
  /** The adapter restored this socket from an already-authorized session. */
21
25
  authorized?: boolean;
26
+ selection?: StatewireProtocol.Selection;
22
27
  /** The restored session's captured auth value; it must satisfy the client record's principal binding. */
23
28
  auth?: unknown;
24
29
  /** Persist authorization (and the captured auth value) before the first snapshot is released. */
25
- onAuthorized?: (auth: unknown) => void;
30
+ onAuthorized?: (
31
+ auth: unknown,
32
+ selection: StatewireProtocol.Selection,
33
+ ) => void;
26
34
  };
27
35
 
28
36
  export type SocketHost = {
@@ -44,6 +52,7 @@ export namespace StatewireHostInternal {
44
52
  ctxSeq: number;
45
53
  touched: number;
46
54
  context: Record<string, unknown> | null;
55
+ contextDocuments?: readonly [number, StatewireDocuments.Document][];
47
56
  results: readonly { seq: number; ts: number; cmd: StatewireCmd }[];
48
57
  principal: unknown;
49
58
  };
@@ -54,6 +63,8 @@ export namespace StatewireHostInternal {
54
63
  };
55
64
 
56
65
  export type SocketHostOptions<TAuth = unknown> = {
66
+ protocol?: StatewireProtocol.Definition;
67
+ wireVersion?: StatewireProtocol.Range;
57
68
  /** Gates every attach; the awaited return value is the attach's auth, exposed to command handlers as `this.caller.auth`. The first attach binds the client id's record to this value; a later attach whose value is not structurally equal is refused (HTTP 403, socket close 1008). */
58
69
  authorize?: (ctx: {
59
70
  headers: Record<string, string>;
package/src/index.ts CHANGED
@@ -3,3 +3,6 @@ export { StatewireClient, useStatewire, StatewireResource } from "./statewire";
3
3
  export { StatewireHttp } from "./transports/sse";
4
4
  export { StatewireWebsocket } from "./transports/ws";
5
5
  export { StatewireSendError } from "./types";
6
+
7
+ export type { StatewireProtocol } from "./wire/protocol";
8
+ export type { StatewireDocuments } from "./wire/documents";