statewire 0.16.0 → 0.17.1
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 +22 -6
- 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 +235 -52
- 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/document.d.ts +25 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +88 -0
- package/dist/document.js.map +1 -0
- package/dist/host/draft.js +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 +12 -5
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +107 -43
- 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 +4 -1
- package/dist/index.js +2 -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 +28 -8
- package/dist/wire/documents.js.map +1 -1
- package/dist/wire/meta.d.ts +3 -18
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +8 -57
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.js +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 +340 -45
- package/src/client/sessionStore.ts +8 -0
- package/src/document.ts +185 -0
- package/src/host/host.ts +1 -0
- package/src/host/http.ts +36 -7
- package/src/host/socket.ts +149 -57
- package/src/host-internal.ts +12 -1
- package/src/index.ts +5 -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/meta.ts +11 -94
- package/src/wire/protocol.ts +327 -0
- package/src/wire/version.ts +0 -2
package/src/host/socket.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { encodeDocument } from "../document";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PROTOCOL,
|
|
4
|
+
WIRE_VERSION,
|
|
5
|
+
negotiateProtocols,
|
|
6
|
+
} from "../wire/protocol";
|
|
7
|
+
import type { StatewireProtocol } from "../wire/protocol";
|
|
8
|
+
import { applyDocumentOps, mainDocument } from "../wire/documents";
|
|
9
|
+
import type { StatewireDocuments } from "../wire/documents";
|
|
1
10
|
import { useEffect, useState } from "react";
|
|
2
11
|
import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
|
|
3
12
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
@@ -7,7 +16,7 @@ import { StatewireSendError } from "../types";
|
|
|
7
16
|
import type { Statewire } from "../types";
|
|
8
17
|
import type { StatewireHost } from "../host";
|
|
9
18
|
import type { StatewireHostInternal } from "../host-internal";
|
|
10
|
-
import {
|
|
19
|
+
import { reviveValue } from "../wire/meta";
|
|
11
20
|
import {
|
|
12
21
|
COMMAND_CAPACITY_RETRY_MS,
|
|
13
22
|
MAX_COMMANDS_PER_FRAME,
|
|
@@ -17,7 +26,7 @@ import {
|
|
|
17
26
|
MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
18
27
|
} from "../wire/limits";
|
|
19
28
|
import { createMacrotaskPoster } from "../util/macrotask";
|
|
20
|
-
import type {
|
|
29
|
+
import type { StatewireDocument } from "../document";
|
|
21
30
|
import type { StatewirePath } from "../wire/ops";
|
|
22
31
|
import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
|
|
23
32
|
import { frames as serveFrames, stream as serveStream } from "./http";
|
|
@@ -71,7 +80,7 @@ type WireOp = {
|
|
|
71
80
|
op: string;
|
|
72
81
|
path?: StatewirePath;
|
|
73
82
|
value?: unknown;
|
|
74
|
-
meta?:
|
|
83
|
+
meta?: StatewireDocument.MetaEntry[];
|
|
75
84
|
};
|
|
76
85
|
|
|
77
86
|
type Admission = {
|
|
@@ -88,6 +97,7 @@ export type ClientRec = {
|
|
|
88
97
|
ctxSeq: number;
|
|
89
98
|
touched: number;
|
|
90
99
|
context: Record<string, unknown> | null;
|
|
100
|
+
contextDocuments: StatewireDocuments.State;
|
|
91
101
|
results: Map<number, { ts: number; cmd: StatewireCmd }>;
|
|
92
102
|
inflight: Map<number, Admission>;
|
|
93
103
|
/** The auth captured at the record's first attach; later attaches must match. */
|
|
@@ -95,6 +105,7 @@ export type ClientRec = {
|
|
|
95
105
|
};
|
|
96
106
|
|
|
97
107
|
export type Conn = {
|
|
108
|
+
selection: StatewireProtocol.Selection;
|
|
98
109
|
clientId: string;
|
|
99
110
|
socket: StatewireHostInternal.Socket;
|
|
100
111
|
open: boolean;
|
|
@@ -125,8 +136,9 @@ const violationReason = (error: unknown): string | null =>
|
|
|
125
136
|
? error.message
|
|
126
137
|
: null;
|
|
127
138
|
|
|
128
|
-
type CtxStatement = { seq: number;
|
|
139
|
+
type CtxStatement = { seq: number; ops: unknown[] };
|
|
129
140
|
type CmdStatement = {
|
|
141
|
+
protocol: string;
|
|
130
142
|
seq: number;
|
|
131
143
|
method: string;
|
|
132
144
|
params: unknown;
|
|
@@ -161,30 +173,6 @@ const parseLane = (value: unknown): Record<string, unknown>[] => {
|
|
|
161
173
|
|
|
162
174
|
const frameEncoder = new TextEncoder();
|
|
163
175
|
|
|
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
176
|
export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
189
177
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
190
178
|
const keys = Object.keys(frame);
|
|
@@ -199,19 +187,15 @@ export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
|
199
187
|
return violation("too many context statements");
|
|
200
188
|
}
|
|
201
189
|
for (const statement of parseLane(lane)) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
value = contextValue(statement["ops"]);
|
|
205
|
-
} catch {
|
|
190
|
+
const ops = statement["ops"];
|
|
191
|
+
if (!Array.isArray(ops) || !ops.length)
|
|
206
192
|
return violation("invalid-context");
|
|
207
|
-
}
|
|
208
193
|
if (
|
|
209
|
-
frameEncoder.encode(JSON.stringify(
|
|
194
|
+
frameEncoder.encode(JSON.stringify(ops)).byteLength >
|
|
210
195
|
MAX_CONTEXT_VALUE_BYTES
|
|
211
|
-
)
|
|
196
|
+
)
|
|
212
197
|
return violation("oversized-context");
|
|
213
|
-
}
|
|
214
|
-
ctx.push({ seq: statement["seq"] as number, value });
|
|
198
|
+
ctx.push({ seq: statement["seq"] as number, ops });
|
|
215
199
|
}
|
|
216
200
|
}
|
|
217
201
|
const cmd: CmdStatement[] = [];
|
|
@@ -222,10 +206,15 @@ export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
|
222
206
|
}
|
|
223
207
|
for (const statement of parseLane(lane)) {
|
|
224
208
|
const method = statement["method"];
|
|
225
|
-
if (
|
|
209
|
+
if (
|
|
210
|
+
typeof method !== "string" ||
|
|
211
|
+
typeof statement["protocol"] !== "string" ||
|
|
212
|
+
!("params" in statement)
|
|
213
|
+
) {
|
|
226
214
|
return violation("malformed statement");
|
|
227
215
|
}
|
|
228
216
|
cmd.push({
|
|
217
|
+
protocol: statement["protocol"] as string,
|
|
229
218
|
seq: statement["seq"] as number,
|
|
230
219
|
method,
|
|
231
220
|
params: statement["params"],
|
|
@@ -254,9 +243,9 @@ const frameIsOversized = (raw: string): boolean =>
|
|
|
254
243
|
|
|
255
244
|
const encodePayload = (
|
|
256
245
|
payload: unknown,
|
|
257
|
-
): { payload?: unknown; meta?:
|
|
246
|
+
): { payload?: unknown; meta?: StatewireDocument.MetaEntry[] } => {
|
|
258
247
|
if (payload === undefined) return {};
|
|
259
|
-
const { value, meta } =
|
|
248
|
+
const { value, meta } = encodeDocument(payload);
|
|
260
249
|
return { payload: value, ...(meta !== undefined && { meta }) };
|
|
261
250
|
};
|
|
262
251
|
|
|
@@ -334,6 +323,8 @@ const parseInitialFrame = (raw: string) => {
|
|
|
334
323
|
// ─── host ───────────────────────────────────────────────
|
|
335
324
|
|
|
336
325
|
export type SocketHostState = {
|
|
326
|
+
protocol: StatewireProtocol.Definition;
|
|
327
|
+
wireVersion: StatewireProtocol.Range;
|
|
337
328
|
heartbeatMs: number;
|
|
338
329
|
wsInitTimeoutMs: number;
|
|
339
330
|
maxSubscribers: number;
|
|
@@ -384,7 +375,7 @@ const recordOp = (
|
|
|
384
375
|
},
|
|
385
376
|
) => {
|
|
386
377
|
if (op.op === "replace" || op.op === "add") {
|
|
387
|
-
const { value, meta } =
|
|
378
|
+
const { value, meta } = encodeDocument(op.value);
|
|
388
379
|
h.pendingOps.push({ ...op, value, ...(meta !== undefined && { meta }) });
|
|
389
380
|
} else {
|
|
390
381
|
h.pendingOps.push(op);
|
|
@@ -518,7 +509,9 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
|
|
|
518
509
|
).map((seq) => ({ seq, type: "applied" }));
|
|
519
510
|
if (ops.length > 0) {
|
|
520
511
|
const packet: StatewireEnvelope = {
|
|
521
|
-
ops
|
|
512
|
+
ops: conn.selection.protocols.some((p) => p.name === h.protocol.name)
|
|
513
|
+
? ops
|
|
514
|
+
: [],
|
|
522
515
|
...(appliedEntries.length > 0 && { cmd: appliedEntries }),
|
|
523
516
|
...(edge && { idle: level }),
|
|
524
517
|
};
|
|
@@ -526,7 +519,11 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
|
|
|
526
519
|
} else if (appliedEntries.length > 0) {
|
|
527
520
|
sendTo(h, conn, JSON.stringify({ cmd: appliedEntries }));
|
|
528
521
|
}
|
|
529
|
-
const verdicts = cmds.get(conn.clientId) ?? []
|
|
522
|
+
const verdicts = (cmds.get(conn.clientId) ?? []).map((cmd) =>
|
|
523
|
+
conn.selection.protocols.some((p) => p.name === h.protocol.name)
|
|
524
|
+
? cmd
|
|
525
|
+
: { seq: cmd.seq, ...(cmd.dur === undefined ? {} : { dur: cmd.dur }) },
|
|
526
|
+
);
|
|
530
527
|
if (verdicts.length > 0) {
|
|
531
528
|
sendTo(h, conn, JSON.stringify({ cmd: verdicts }));
|
|
532
529
|
}
|
|
@@ -562,6 +559,7 @@ const notifyClientRecords = (h: SocketHostState) => {
|
|
|
562
559
|
ctxSeq: rec.ctxSeq,
|
|
563
560
|
touched: rec.touched,
|
|
564
561
|
context: rec.context,
|
|
562
|
+
contextDocuments: [...rec.contextDocuments.documents],
|
|
565
563
|
results: [...rec.results].map(([seq, entry]) => ({
|
|
566
564
|
seq,
|
|
567
565
|
ts: entry.ts,
|
|
@@ -672,6 +670,7 @@ const admitCmd = (
|
|
|
672
670
|
rec: ClientRec,
|
|
673
671
|
statement: CmdStatement,
|
|
674
672
|
auth: unknown,
|
|
673
|
+
selection: StatewireProtocol.Selection,
|
|
675
674
|
) => {
|
|
676
675
|
const adm: Admission = {
|
|
677
676
|
seq: statement.seq,
|
|
@@ -682,6 +681,18 @@ const admitCmd = (
|
|
|
682
681
|
};
|
|
683
682
|
rec.inflight.set(statement.seq, adm);
|
|
684
683
|
notifyClientRecords(h);
|
|
684
|
+
if (
|
|
685
|
+
statement.protocol !== h.protocol.name ||
|
|
686
|
+
!selection.protocols.some((p) => p.name === statement.protocol)
|
|
687
|
+
) {
|
|
688
|
+
settleCmd(h, rec, adm, {
|
|
689
|
+
seq: adm.seq,
|
|
690
|
+
type: "rejected",
|
|
691
|
+
code: "unsupported-protocol",
|
|
692
|
+
message: "command protocol was not negotiated",
|
|
693
|
+
});
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
685
696
|
if (!Array.isArray(statement.params)) {
|
|
686
697
|
rejectStatement(h, rec, adm, "params must be an array");
|
|
687
698
|
return;
|
|
@@ -749,6 +760,10 @@ export const admitFrame = (
|
|
|
749
760
|
rec: ClientRec,
|
|
750
761
|
frame: ParsedFrame,
|
|
751
762
|
auth: unknown,
|
|
763
|
+
selection: StatewireProtocol.Selection = {
|
|
764
|
+
version: h.wireVersion.version,
|
|
765
|
+
protocols: [{ name: h.protocol.name, version: h.protocol.version }],
|
|
766
|
+
},
|
|
752
767
|
): FrameAdmission => {
|
|
753
768
|
rec.touched = Date.now();
|
|
754
769
|
const through = admittedThrough(rec);
|
|
@@ -801,11 +816,28 @@ export const admitFrame = (
|
|
|
801
816
|
};
|
|
802
817
|
}
|
|
803
818
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
819
|
+
let contextDocuments = rec.contextDocuments;
|
|
820
|
+
try {
|
|
821
|
+
for (const statement of ctxNew)
|
|
822
|
+
contextDocuments = applyDocumentOps(
|
|
823
|
+
contextDocuments,
|
|
824
|
+
statement.ops,
|
|
825
|
+
selection.protocols,
|
|
826
|
+
true,
|
|
827
|
+
);
|
|
828
|
+
if (
|
|
829
|
+
frameEncoder.encode(JSON.stringify([...contextDocuments.documents]))
|
|
830
|
+
.byteLength > MAX_CONTEXT_VALUE_BYTES
|
|
831
|
+
)
|
|
832
|
+
return violation("oversized-context");
|
|
833
|
+
} catch {
|
|
834
|
+
return violation("invalid-context");
|
|
807
835
|
}
|
|
808
|
-
|
|
836
|
+
rec.contextDocuments = contextDocuments;
|
|
837
|
+
rec.context = (mainDocument(contextDocuments, h.protocol.name)?.value ??
|
|
838
|
+
null) as Record<string, unknown> | null;
|
|
839
|
+
if (ctxNew.length) rec.ctxSeq = ctxNew.at(-1)!.seq;
|
|
840
|
+
for (const statement of cmdNew) admitCmd(h, rec, statement, auth, selection);
|
|
809
841
|
if (cmdNew.length > 0) {
|
|
810
842
|
// Memory host: admitted means retained — dur rides the admitting flush.
|
|
811
843
|
queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
|
|
@@ -854,7 +886,20 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
854
886
|
}
|
|
855
887
|
if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
|
|
856
888
|
const rec = h.clients.get(conn.clientId)!;
|
|
857
|
-
|
|
889
|
+
let admission: ReturnType<typeof admitFrame>;
|
|
890
|
+
try {
|
|
891
|
+
admission = admitFrame(h, rec, frame, conn.auth, conn.selection);
|
|
892
|
+
} catch (error) {
|
|
893
|
+
const reason = violationReason(error);
|
|
894
|
+
if (reason === null) throw error;
|
|
895
|
+
sendTo(
|
|
896
|
+
h,
|
|
897
|
+
conn,
|
|
898
|
+
JSON.stringify({ fin: { reason: "protocol-error", message: reason } }),
|
|
899
|
+
);
|
|
900
|
+
closeConn(h, conn, 1008, reason);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
858
903
|
if (admission.type === "refused") {
|
|
859
904
|
sendTo(h, conn, JSON.stringify(admission.syn));
|
|
860
905
|
}
|
|
@@ -965,6 +1010,7 @@ const connectAuthorized = (
|
|
|
965
1010
|
socket,
|
|
966
1011
|
options.authorized === true,
|
|
967
1012
|
options.auth,
|
|
1013
|
+
options.selection,
|
|
968
1014
|
);
|
|
969
1015
|
return registration?.connection ?? null;
|
|
970
1016
|
}
|
|
@@ -1056,7 +1102,10 @@ const connectAuthorized = (
|
|
|
1056
1102
|
const frame = initial.frame;
|
|
1057
1103
|
let headers: Record<string, string>;
|
|
1058
1104
|
try {
|
|
1059
|
-
headers =
|
|
1105
|
+
headers = {
|
|
1106
|
+
...normalizedHeaders(initial.headers),
|
|
1107
|
+
...normalizedHeaders(options.headers ?? {}),
|
|
1108
|
+
};
|
|
1060
1109
|
} catch {
|
|
1061
1110
|
sendPending(
|
|
1062
1111
|
pending,
|
|
@@ -1089,17 +1138,39 @@ const connectAuthorized = (
|
|
|
1089
1138
|
.then(() => authorize?.({ headers }))
|
|
1090
1139
|
.then((auth) => {
|
|
1091
1140
|
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
1092
|
-
|
|
1141
|
+
const selection = negotiateProtocols(
|
|
1142
|
+
new Headers(headers),
|
|
1143
|
+
h.wireVersion,
|
|
1144
|
+
[h.protocol],
|
|
1145
|
+
);
|
|
1146
|
+
if ("fin" in selection) {
|
|
1147
|
+
sendPending(pending, JSON.stringify(selection));
|
|
1148
|
+
closePending(h, pending, 1008, selection.fin.message);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
options.onAuthorized?.(auth, selection);
|
|
1093
1152
|
pending.open = false;
|
|
1094
1153
|
h.pendingConns.delete(pending);
|
|
1095
1154
|
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
1096
|
-
const registration = register(
|
|
1155
|
+
const registration = register(
|
|
1156
|
+
h,
|
|
1157
|
+
clientId,
|
|
1158
|
+
socket,
|
|
1159
|
+
true,
|
|
1160
|
+
auth,
|
|
1161
|
+
selection,
|
|
1162
|
+
);
|
|
1097
1163
|
if (registration === null) return;
|
|
1098
1164
|
pending.connection = registration.connection;
|
|
1099
1165
|
if (frame.ctx.length || frame.cmd.length) {
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1166
|
+
message(
|
|
1167
|
+
h,
|
|
1168
|
+
registration.conn,
|
|
1169
|
+
JSON.stringify({
|
|
1170
|
+
...(frame.ctx.length ? { ctx: frame.ctx } : {}),
|
|
1171
|
+
...(frame.cmd.length ? { cmd: frame.cmd } : {}),
|
|
1172
|
+
}),
|
|
1173
|
+
);
|
|
1103
1174
|
}
|
|
1104
1175
|
for (const data of pending.queued.splice(0))
|
|
1105
1176
|
registration.connection.message(data);
|
|
@@ -1143,6 +1214,10 @@ export const register = (
|
|
|
1143
1214
|
socket: StatewireHostInternal.Socket,
|
|
1144
1215
|
sawFrame = false,
|
|
1145
1216
|
auth: unknown = undefined,
|
|
1217
|
+
selection: StatewireProtocol.Selection = {
|
|
1218
|
+
version: h.wireVersion.version,
|
|
1219
|
+
protocols: [{ name: h.protocol.name, version: h.protocol.version }],
|
|
1220
|
+
},
|
|
1146
1221
|
): Registration | null => {
|
|
1147
1222
|
const refuse = (code: number, reason: string) => {
|
|
1148
1223
|
try {
|
|
@@ -1193,6 +1268,7 @@ export const register = (
|
|
|
1193
1268
|
ctxSeq: 0,
|
|
1194
1269
|
touched: Date.now(),
|
|
1195
1270
|
context: null,
|
|
1271
|
+
contextDocuments: { mode: "mounted", documents: new Map() },
|
|
1196
1272
|
results: new Map(),
|
|
1197
1273
|
inflight: new Map(),
|
|
1198
1274
|
principal: auth,
|
|
@@ -1202,6 +1278,7 @@ export const register = (
|
|
|
1202
1278
|
})();
|
|
1203
1279
|
rec.touched = Date.now();
|
|
1204
1280
|
const conn: Conn = {
|
|
1281
|
+
selection,
|
|
1205
1282
|
clientId,
|
|
1206
1283
|
socket,
|
|
1207
1284
|
open: true,
|
|
@@ -1214,22 +1291,26 @@ export const register = (
|
|
|
1214
1291
|
gcClients(h);
|
|
1215
1292
|
notifyClientRecords(h);
|
|
1216
1293
|
ensureHeartbeat(h);
|
|
1217
|
-
const { value, meta } =
|
|
1294
|
+
const { value, meta } = encodeDocument(h.stateHost!.snapshot());
|
|
1218
1295
|
const hello: StatewireEnvelope = {
|
|
1219
1296
|
ops: [
|
|
1220
1297
|
{
|
|
1221
|
-
op: "
|
|
1222
|
-
|
|
1298
|
+
op: "mount",
|
|
1299
|
+
protocol: h.protocol.name,
|
|
1300
|
+
main: true,
|
|
1223
1301
|
value,
|
|
1224
1302
|
...(meta !== undefined && { meta }),
|
|
1225
1303
|
},
|
|
1226
1304
|
],
|
|
1227
1305
|
syn: {
|
|
1306
|
+
...selection,
|
|
1228
1307
|
seq: admittedThrough(rec),
|
|
1229
1308
|
ctxSeq: rec.ctxSeq,
|
|
1230
1309
|
...(h.idleWire && { idle: true }),
|
|
1231
1310
|
},
|
|
1232
1311
|
};
|
|
1312
|
+
if (!selection.protocols.some((p) => p.name === h.protocol.name))
|
|
1313
|
+
hello.ops = [];
|
|
1233
1314
|
sendTo(h, conn, JSON.stringify(hello));
|
|
1234
1315
|
const connection: StatewireHostInternal.SocketConnection = {
|
|
1235
1316
|
clientId,
|
|
@@ -1287,6 +1368,10 @@ const restoreClientRecords = (
|
|
|
1287
1368
|
record.id,
|
|
1288
1369
|
{
|
|
1289
1370
|
...record,
|
|
1371
|
+
contextDocuments: {
|
|
1372
|
+
mode: "mounted",
|
|
1373
|
+
documents: new Map(record.contextDocuments ?? []),
|
|
1374
|
+
},
|
|
1290
1375
|
results: new Map(
|
|
1291
1376
|
record.results.map(({ seq, ts, cmd }) => [seq, { ts, cmd }]),
|
|
1292
1377
|
),
|
|
@@ -1305,6 +1390,8 @@ const useSocketHost = (
|
|
|
1305
1390
|
> => {
|
|
1306
1391
|
const [h] = useState((): SocketHostState => {
|
|
1307
1392
|
const host: SocketHostState = {
|
|
1393
|
+
protocol: { name: DEFAULT_PROTOCOL, version: WIRE_VERSION },
|
|
1394
|
+
wireVersion: { version: WIRE_VERSION },
|
|
1308
1395
|
heartbeatMs: 15_000,
|
|
1309
1396
|
wsInitTimeoutMs: 5_000,
|
|
1310
1397
|
maxSubscribers: MAX_SUBSCRIBERS,
|
|
@@ -1341,6 +1428,11 @@ const useSocketHost = (
|
|
|
1341
1428
|
};
|
|
1342
1429
|
return host;
|
|
1343
1430
|
});
|
|
1431
|
+
h.protocol = options?.protocol ?? {
|
|
1432
|
+
name: DEFAULT_PROTOCOL,
|
|
1433
|
+
version: WIRE_VERSION,
|
|
1434
|
+
};
|
|
1435
|
+
h.wireVersion = options?.wireVersion ?? { version: WIRE_VERSION };
|
|
1344
1436
|
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
|
|
1345
1437
|
h.pushback = options?.pushback;
|
|
1346
1438
|
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
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export type { Statewire } from "./types";
|
|
2
|
+
export { encodeDocument, decodeDocument } from "./document";
|
|
3
|
+
export type { StatewireDocument } from "./document";
|
|
2
4
|
export { StatewireClient, useStatewire, StatewireResource } from "./statewire";
|
|
3
5
|
export { StatewireHttp } from "./transports/sse";
|
|
4
6
|
export { StatewireWebsocket } from "./transports/ws";
|
|
5
7
|
export { StatewireSendError } from "./types";
|
|
8
|
+
|
|
9
|
+
export type { StatewireProtocol } from "./wire/protocol";
|
|
10
|
+
export type { StatewireDocuments } from "./wire/documents";
|