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/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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
package/src/host/socket.ts
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
203
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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: "
|
|
1222
|
-
|
|
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;
|
package/src/host-internal.ts
CHANGED
|
@@ -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?: (
|
|
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";
|