statewire 0.18.2 → 0.19.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/dist/client/connection.d.ts +8 -5
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +24 -13
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +13 -15
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +291 -256
- package/dist/client/session.js.map +1 -1
- package/dist/client/timing.d.ts +2 -1
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +2 -1
- package/dist/client/timing.js.map +1 -1
- package/dist/host/engine.d.ts +2 -0
- package/dist/host/engine.d.ts.map +1 -1
- package/dist/host/engine.js +2 -0
- package/dist/host/engine.js.map +1 -1
- package/dist/host/host.d.ts +3 -1
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +38 -7
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +8 -17
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +47 -23
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +389 -245
- package/dist/host/socket.js.map +1 -1
- package/dist/host/synced.d.ts +9 -0
- package/dist/host/synced.d.ts.map +1 -0
- package/dist/host/synced.js +45 -0
- package/dist/host/synced.js.map +1 -0
- package/dist/host-internal.d.ts +20 -8
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +33 -9
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/statewire.d.ts +8 -10
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +0 -14
- package/dist/statewire.js.map +1 -1
- package/dist/storage.d.ts +2 -5
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +55 -41
- package/dist/storage.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +1 -6
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +2 -5
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +7 -5
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +19 -19
- package/dist/transports/testing.js.map +1 -1
- package/dist/types.d.ts +3 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/limits.d.ts +2 -2
- package/dist/wire/limits.js +2 -2
- package/dist/wire/limits.js.map +1 -1
- package/dist/wire/protocol.d.ts +6 -1
- package/dist/wire/protocol.d.ts.map +1 -1
- package/dist/wire/protocol.js +33 -12
- package/dist/wire/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +46 -33
- package/src/client/session.ts +434 -365
- package/src/client/timing.ts +2 -0
- package/src/host/engine.ts +3 -1
- package/src/host/host.ts +50 -3
- package/src/host/http.ts +22 -23
- package/src/host/socket.ts +550 -321
- package/src/host/synced.ts +60 -0
- package/src/host-internal.ts +21 -3
- package/src/host.ts +40 -8
- package/src/index.ts +1 -0
- package/src/statewire.ts +8 -30
- package/src/storage.ts +96 -79
- package/src/transports/remote.ts +0 -7
- package/src/transports/sse.ts +8 -3
- package/src/transports/testing.ts +34 -19
- package/src/types.ts +3 -5
- package/src/wire/limits.ts +1 -1
- package/src/wire/protocol.ts +40 -11
package/src/host/socket.ts
CHANGED
|
@@ -5,22 +5,21 @@ import {
|
|
|
5
5
|
negotiateProtocols,
|
|
6
6
|
} from "../wire/protocol";
|
|
7
7
|
import type { StatewireProtocol } from "../wire/protocol";
|
|
8
|
-
import { applyDocumentOps, mainDocument } from "../wire/documents";
|
|
9
|
-
import type { StatewireDocuments } from "../wire/documents";
|
|
10
8
|
import { useEffect, useState } from "react";
|
|
11
9
|
import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
|
|
12
10
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
13
|
-
import { getCommandsHost, getStateHost } from "./host";
|
|
11
|
+
import { documentIdentity, getCommandsHost, getStateHost } from "./host";
|
|
14
12
|
import type { CommandsHost, CommandsOutcome } from "./host";
|
|
15
13
|
import { StatewireSendError } from "../types";
|
|
16
14
|
import type { Statewire } from "../types";
|
|
17
15
|
import type { StatewireHost } from "../host";
|
|
18
16
|
import type { StatewireHostInternal } from "../host-internal";
|
|
19
17
|
import { reviveValue } from "../wire/meta";
|
|
18
|
+
import { sanitizeValue } from "../wire/ops";
|
|
20
19
|
import {
|
|
21
20
|
COMMAND_CAPACITY_RETRY_MS,
|
|
22
21
|
MAX_COMMANDS_PER_FRAME,
|
|
23
|
-
|
|
22
|
+
MAX_EPHEMERAL_DOCUMENT_BYTES,
|
|
24
23
|
MAX_FRAME_BODY_BYTES,
|
|
25
24
|
MAX_INFLIGHT_COMMANDS_PER_CLIENT,
|
|
26
25
|
MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
@@ -28,7 +27,6 @@ import {
|
|
|
28
27
|
import { createMacrotaskPoster } from "../util/macrotask";
|
|
29
28
|
import type { StatewireDocument } from "../document";
|
|
30
29
|
import type { StatewirePath } from "../wire/ops";
|
|
31
|
-
import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
|
|
32
30
|
import { frames as serveFrames, stream as serveStream } from "./http";
|
|
33
31
|
|
|
34
32
|
export const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
|
|
@@ -76,14 +74,21 @@ const MAX_SUBSCRIBERS = 1024;
|
|
|
76
74
|
export const SUBSCRIBER_LIMIT_MESSAGE = "stream subscriber limit reached";
|
|
77
75
|
const STREAM_QUEUE_SIZE = 256;
|
|
78
76
|
|
|
77
|
+
type Cmd = StatewireHostInternal.Cmd;
|
|
78
|
+
|
|
79
79
|
type WireOp = {
|
|
80
80
|
op: string;
|
|
81
|
+
doc?: number;
|
|
81
82
|
path?: StatewirePath;
|
|
82
83
|
value?: unknown;
|
|
83
84
|
meta?: StatewireDocument.MetaEntry[];
|
|
85
|
+
protocol?: string;
|
|
86
|
+
key?: readonly string[];
|
|
87
|
+
main?: boolean;
|
|
84
88
|
};
|
|
85
89
|
|
|
86
90
|
type Admission = {
|
|
91
|
+
protocol: string;
|
|
87
92
|
seq: number;
|
|
88
93
|
method: string;
|
|
89
94
|
applied: boolean;
|
|
@@ -91,31 +96,44 @@ type Admission = {
|
|
|
91
96
|
settled: boolean;
|
|
92
97
|
};
|
|
93
98
|
|
|
99
|
+
/** One statement lane: durable lanes live on the client record, ephemeral lanes on the attach. */
|
|
100
|
+
type Lane = {
|
|
101
|
+
protocol: string;
|
|
102
|
+
clientId: string;
|
|
103
|
+
conn: Conn | null;
|
|
104
|
+
lastSeq: number;
|
|
105
|
+
results: Map<number, { ts: number; cmd: Cmd }>;
|
|
106
|
+
inflight: Map<number, Admission>;
|
|
107
|
+
};
|
|
108
|
+
|
|
94
109
|
export type ClientRec = {
|
|
95
110
|
id: string;
|
|
96
|
-
lastSeq: number;
|
|
97
111
|
touched: number;
|
|
98
|
-
|
|
99
|
-
inflight: Map<number, Admission>;
|
|
112
|
+
lanes: Map<string, Lane>;
|
|
100
113
|
/** The auth captured at the record's first attach; later attaches must match. */
|
|
101
114
|
principal: unknown;
|
|
102
115
|
};
|
|
103
116
|
|
|
104
|
-
//
|
|
105
|
-
// and outlives its socket so an admitted command keeps reading its last value.
|
|
117
|
+
// A Conn outlives its socket so an admitted command keeps its attach view.
|
|
106
118
|
export type Conn = {
|
|
107
119
|
selection: StatewireProtocol.Selection;
|
|
108
120
|
clientId: string;
|
|
109
121
|
socket: StatewireHostInternal.Socket;
|
|
110
122
|
open: boolean;
|
|
123
|
+
ready: boolean;
|
|
111
124
|
sawFrame: boolean;
|
|
112
125
|
lastSentAt: number;
|
|
113
126
|
lease: string | null;
|
|
114
127
|
/** The awaited `authorize` return captured at this attach's authorization. */
|
|
115
128
|
auth: unknown;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
129
|
+
lanes: Map<string, Lane>;
|
|
130
|
+
documents: Map<string, StatewireHost.Document>;
|
|
131
|
+
included: Set<string>;
|
|
132
|
+
view: Map<string, { doc: number; value: unknown }>;
|
|
133
|
+
nextDoc: number;
|
|
134
|
+
pendingApplied: Cmd[];
|
|
135
|
+
pendingCmds: Cmd[];
|
|
136
|
+
attach: StatewireHost.Attach;
|
|
119
137
|
};
|
|
120
138
|
|
|
121
139
|
type PendingConn = {
|
|
@@ -138,7 +156,6 @@ const violationReason = (error: unknown): string | null =>
|
|
|
138
156
|
? error.message
|
|
139
157
|
: null;
|
|
140
158
|
|
|
141
|
-
type CtxStatement = { seq: number; ops: unknown[] };
|
|
142
159
|
type CmdStatement = {
|
|
143
160
|
protocol: string;
|
|
144
161
|
seq: number;
|
|
@@ -147,84 +164,55 @@ type CmdStatement = {
|
|
|
147
164
|
meta?: unknown;
|
|
148
165
|
};
|
|
149
166
|
|
|
150
|
-
export type ParsedFrame = {
|
|
151
|
-
ctx: CtxStatement[];
|
|
152
|
-
cmd: CmdStatement[];
|
|
153
|
-
};
|
|
167
|
+
export type ParsedFrame = { cmd: CmdStatement[] };
|
|
154
168
|
|
|
155
169
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
156
170
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
157
171
|
|
|
158
|
-
const parseLane = (value: unknown): Record<string, unknown>[] => {
|
|
159
|
-
if (!Array.isArray(value)) return violation("malformed frame");
|
|
160
|
-
if (value.length === 0) return violation("empty lane");
|
|
161
|
-
const statements: Record<string, unknown>[] = [];
|
|
162
|
-
let prev: number | null = null;
|
|
163
|
-
for (const statement of value) {
|
|
164
|
-
if (!isPlainObject(statement)) return violation("malformed statement");
|
|
165
|
-
const seq = statement["seq"];
|
|
166
|
-
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) {
|
|
167
|
-
return violation("malformed statement");
|
|
168
|
-
}
|
|
169
|
-
if (prev !== null && seq !== prev + 1) return violation("malformed frame");
|
|
170
|
-
prev = seq;
|
|
171
|
-
statements.push(statement);
|
|
172
|
-
}
|
|
173
|
-
return statements;
|
|
174
|
-
};
|
|
175
|
-
|
|
176
172
|
const frameEncoder = new TextEncoder();
|
|
177
173
|
|
|
178
174
|
export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
179
175
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
180
176
|
const keys = Object.keys(frame);
|
|
181
177
|
if (keys.length === 0) return violation("malformed frame");
|
|
182
|
-
const unknown = keys.filter((key) => key !== "
|
|
178
|
+
const unknown = keys.filter((key) => key !== "cmd").sort();
|
|
183
179
|
if (unknown.length > 0)
|
|
184
180
|
return violation(`unknown frame key: '${unknown[0]}'`);
|
|
185
|
-
const
|
|
186
|
-
if (frame
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
for (const statement of parseLane(lane)) {
|
|
192
|
-
const ops = statement["ops"];
|
|
193
|
-
if (!Array.isArray(ops) || !ops.length)
|
|
194
|
-
return violation("invalid-context");
|
|
195
|
-
if (
|
|
196
|
-
frameEncoder.encode(JSON.stringify(ops)).byteLength >
|
|
197
|
-
MAX_CONTEXT_VALUE_BYTES
|
|
198
|
-
)
|
|
199
|
-
return violation("oversized-context");
|
|
200
|
-
ctx.push({ seq: statement["seq"] as number, ops });
|
|
201
|
-
}
|
|
202
|
-
}
|
|
181
|
+
const lane = frame["cmd"];
|
|
182
|
+
if (!Array.isArray(lane)) return violation("malformed frame");
|
|
183
|
+
if (lane.length === 0) return violation("empty lane");
|
|
184
|
+
if (lane.length > MAX_COMMANDS_PER_FRAME)
|
|
185
|
+
return violation("too many commands");
|
|
186
|
+
const prev = new Map<string, number>();
|
|
203
187
|
const cmd: CmdStatement[] = [];
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
cmd.push({
|
|
219
|
-
protocol: statement["protocol"] as string,
|
|
220
|
-
seq: statement["seq"] as number,
|
|
221
|
-
method,
|
|
222
|
-
params: statement["params"],
|
|
223
|
-
...(statement["meta"] !== undefined && { meta: statement["meta"] }),
|
|
224
|
-
});
|
|
188
|
+
for (const statement of lane) {
|
|
189
|
+
if (!isPlainObject(statement)) return violation("malformed statement");
|
|
190
|
+
const seq = statement["seq"];
|
|
191
|
+
const method = statement["method"];
|
|
192
|
+
const protocol = statement["protocol"];
|
|
193
|
+
if (
|
|
194
|
+
typeof seq !== "number" ||
|
|
195
|
+
!Number.isInteger(seq) ||
|
|
196
|
+
seq < 1 ||
|
|
197
|
+
typeof method !== "string" ||
|
|
198
|
+
typeof protocol !== "string" ||
|
|
199
|
+
!("params" in statement)
|
|
200
|
+
) {
|
|
201
|
+
return violation("malformed statement");
|
|
225
202
|
}
|
|
203
|
+
const last = prev.get(protocol);
|
|
204
|
+
if (last !== undefined && seq !== last + 1)
|
|
205
|
+
return violation("malformed frame");
|
|
206
|
+
prev.set(protocol, seq);
|
|
207
|
+
cmd.push({
|
|
208
|
+
protocol,
|
|
209
|
+
seq,
|
|
210
|
+
method,
|
|
211
|
+
params: statement["params"],
|
|
212
|
+
...(statement["meta"] !== undefined && { meta: statement["meta"] }),
|
|
213
|
+
});
|
|
226
214
|
}
|
|
227
|
-
return {
|
|
215
|
+
return { cmd };
|
|
228
216
|
};
|
|
229
217
|
|
|
230
218
|
export const parseFrame = (raw: string): ParsedFrame => {
|
|
@@ -251,18 +239,19 @@ const encodePayload = (
|
|
|
251
239
|
return { payload: value, ...(meta !== undefined && { meta }) };
|
|
252
240
|
};
|
|
253
241
|
|
|
254
|
-
const cmdOf = (adm: Admission, outcome: CommandsOutcome):
|
|
242
|
+
const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
|
|
243
|
+
const at = { protocol: adm.protocol, seq: adm.seq };
|
|
255
244
|
if (outcome.type === "accepted") {
|
|
256
|
-
return {
|
|
245
|
+
return { ...at, type: "result", ...encodePayload(outcome.payload) };
|
|
257
246
|
}
|
|
258
247
|
const failure =
|
|
259
248
|
outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
|
|
260
249
|
switch (failure?.fate) {
|
|
261
250
|
case "crashed":
|
|
262
|
-
return {
|
|
251
|
+
return { ...at, type: "crashed" };
|
|
263
252
|
case "failed":
|
|
264
253
|
return {
|
|
265
|
-
|
|
254
|
+
...at,
|
|
266
255
|
type: "failed",
|
|
267
256
|
...(failure.message !== undefined && { message: failure.message }),
|
|
268
257
|
};
|
|
@@ -273,14 +262,14 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
|
|
|
273
262
|
? `unknown method: '${adm.method}'`
|
|
274
263
|
: failure.message;
|
|
275
264
|
return {
|
|
276
|
-
|
|
265
|
+
...at,
|
|
277
266
|
type: "rejected",
|
|
278
267
|
code: failure.code,
|
|
279
268
|
...(message !== undefined && { message }),
|
|
280
269
|
};
|
|
281
270
|
}
|
|
282
271
|
return {
|
|
283
|
-
|
|
272
|
+
...at,
|
|
284
273
|
type: "rejected",
|
|
285
274
|
...(failure.message !== undefined && { message: failure.message }),
|
|
286
275
|
...encodePayload(failure.payload),
|
|
@@ -288,7 +277,7 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
|
|
|
288
277
|
}
|
|
289
278
|
default:
|
|
290
279
|
return {
|
|
291
|
-
|
|
280
|
+
...at,
|
|
292
281
|
type: "failed",
|
|
293
282
|
message:
|
|
294
283
|
outcome.error instanceof Error
|
|
@@ -316,9 +305,7 @@ const parseInitialFrame = (raw: string) => {
|
|
|
316
305
|
}
|
|
317
306
|
return {
|
|
318
307
|
headers: headers as Record<string, string>,
|
|
319
|
-
frame: Object.keys(lanes).length
|
|
320
|
-
? parseFrameValue(lanes)
|
|
321
|
-
: { ctx: [], cmd: [] },
|
|
308
|
+
frame: Object.keys(lanes).length ? parseFrameValue(lanes) : { cmd: [] },
|
|
322
309
|
};
|
|
323
310
|
};
|
|
324
311
|
|
|
@@ -340,8 +327,9 @@ export type SocketHostState = {
|
|
|
340
327
|
conns: Set<Conn>;
|
|
341
328
|
pendingConns: Set<PendingConn>;
|
|
342
329
|
pendingOps: WireOp[];
|
|
343
|
-
pendingApplied: Map<string,
|
|
344
|
-
pendingCmds: Map<string,
|
|
330
|
+
pendingApplied: Map<string, Cmd[]>;
|
|
331
|
+
pendingCmds: Map<string, Cmd[]>;
|
|
332
|
+
viewsDirty: boolean;
|
|
345
333
|
idleWire: boolean;
|
|
346
334
|
finished: StatewireHost.Fin | null;
|
|
347
335
|
disposed: boolean;
|
|
@@ -368,6 +356,11 @@ const scheduleFlush = (h: SocketHostState) => {
|
|
|
368
356
|
h.post();
|
|
369
357
|
};
|
|
370
358
|
|
|
359
|
+
const markViews = (h: SocketHostState) => {
|
|
360
|
+
h.viewsDirty = true;
|
|
361
|
+
scheduleFlush(h);
|
|
362
|
+
};
|
|
363
|
+
|
|
371
364
|
const recordOp = (
|
|
372
365
|
h: SocketHostState,
|
|
373
366
|
op: {
|
|
@@ -400,8 +393,10 @@ const attach = (h: SocketHostState, next: Statewire<unknown>) => {
|
|
|
400
393
|
}
|
|
401
394
|
if (nextState !== h.stateHost) {
|
|
402
395
|
h.stateHost?.setOnOp(null);
|
|
396
|
+
h.stateHost?.setOnDocuments(null);
|
|
403
397
|
h.stateHost = nextState;
|
|
404
398
|
h.stateHost.setOnOp((op) => recordOp(h, op));
|
|
399
|
+
h.stateHost.setOnDocuments(() => markViews(h));
|
|
405
400
|
}
|
|
406
401
|
h.commandsHost = nextCommands;
|
|
407
402
|
};
|
|
@@ -420,6 +415,7 @@ export const closeConn = (
|
|
|
420
415
|
} catch {
|
|
421
416
|
/* peer already gone */
|
|
422
417
|
}
|
|
418
|
+
h.current?.detached?.(conn.attach);
|
|
423
419
|
if (h.conns.size === 0 && h.heartbeatTimer !== null) {
|
|
424
420
|
clearInterval(h.heartbeatTimer);
|
|
425
421
|
h.heartbeatTimer = null;
|
|
@@ -436,6 +432,15 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
436
432
|
}
|
|
437
433
|
};
|
|
438
434
|
|
|
435
|
+
const violate = (h: SocketHostState, conn: Conn, reason: string) => {
|
|
436
|
+
sendTo(
|
|
437
|
+
h,
|
|
438
|
+
conn,
|
|
439
|
+
JSON.stringify({ fin: { reason: "protocol-error", message: reason } }),
|
|
440
|
+
);
|
|
441
|
+
closeConn(h, conn, 1008, reason);
|
|
442
|
+
};
|
|
443
|
+
|
|
439
444
|
export const atSubscriberLimit = (h: SocketHostState): boolean =>
|
|
440
445
|
h.conns.size +
|
|
441
446
|
[...h.pendingConns].filter((conn) => conn.authorizing).length >=
|
|
@@ -462,22 +467,163 @@ const setHeartbeatMs = (h: SocketHostState, ms: number) => {
|
|
|
462
467
|
}
|
|
463
468
|
};
|
|
464
469
|
|
|
470
|
+
const durableLanes = function* (h: SocketHostState) {
|
|
471
|
+
for (const rec of h.clients.values()) yield* rec.lanes.values();
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const allLanes = function* (h: SocketHostState) {
|
|
475
|
+
yield* durableLanes(h);
|
|
476
|
+
for (const conn of h.conns) yield* conn.lanes.values();
|
|
477
|
+
};
|
|
478
|
+
|
|
465
479
|
const idleLevel = (h: SocketHostState): boolean => {
|
|
466
480
|
if (h.current?.work?.isIdle === false) return false;
|
|
467
|
-
for (const
|
|
468
|
-
if (
|
|
481
|
+
for (const lane of allLanes(h)) {
|
|
482
|
+
if (lane.inflight.size > 0) return false;
|
|
469
483
|
}
|
|
470
484
|
return true;
|
|
471
485
|
};
|
|
472
486
|
|
|
487
|
+
const selects = (conn: Conn, protocol: string) =>
|
|
488
|
+
conn.selection.protocols.some((p) => p.name === protocol);
|
|
489
|
+
|
|
490
|
+
// ─── views ──────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
const viewOf = (
|
|
493
|
+
h: SocketHostState,
|
|
494
|
+
conn: Conn,
|
|
495
|
+
): Map<string, StatewireHost.Document> => {
|
|
496
|
+
const view = new Map<string, StatewireHost.Document>();
|
|
497
|
+
const shared = h.stateHost!.documents();
|
|
498
|
+
const hostMain = documentIdentity(h.protocol.name);
|
|
499
|
+
for (const p of conn.selection.protocols) {
|
|
500
|
+
if (p.record === "ephemeral") {
|
|
501
|
+
for (const [id, doc] of conn.documents)
|
|
502
|
+
if (doc.protocol === p.name) view.set(id, doc);
|
|
503
|
+
} else {
|
|
504
|
+
for (const [id, doc] of shared) {
|
|
505
|
+
if (
|
|
506
|
+
doc.protocol === p.name &&
|
|
507
|
+
id !== hostMain &&
|
|
508
|
+
(doc.main || conn.included.has(id))
|
|
509
|
+
)
|
|
510
|
+
view.set(id, doc);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return view;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const diffView = (h: SocketHostState, conn: Conn): WireOp[] => {
|
|
518
|
+
const current = viewOf(h, conn);
|
|
519
|
+
const ops: WireOp[] = [];
|
|
520
|
+
for (const [id, entry] of conn.view) {
|
|
521
|
+
if (current.has(id)) continue;
|
|
522
|
+
conn.view.delete(id);
|
|
523
|
+
ops.push({ op: "unmount", doc: entry.doc });
|
|
524
|
+
}
|
|
525
|
+
for (const [id, doc] of current) {
|
|
526
|
+
const entry = conn.view.get(id);
|
|
527
|
+
if (entry !== undefined && entry.value === doc.value) continue;
|
|
528
|
+
const { value, meta } = encodeDocument(doc.value);
|
|
529
|
+
if (entry === undefined) {
|
|
530
|
+
const number = conn.nextDoc++;
|
|
531
|
+
conn.view.set(id, { doc: number, value: doc.value });
|
|
532
|
+
ops.push({
|
|
533
|
+
op: "mount",
|
|
534
|
+
doc: number,
|
|
535
|
+
protocol: doc.protocol,
|
|
536
|
+
...(doc.key !== null && { key: doc.key }),
|
|
537
|
+
...(doc.main && { main: true }),
|
|
538
|
+
value,
|
|
539
|
+
...(meta !== undefined && { meta }),
|
|
540
|
+
});
|
|
541
|
+
} else {
|
|
542
|
+
entry.value = doc.value;
|
|
543
|
+
ops.push({
|
|
544
|
+
op: "replace",
|
|
545
|
+
doc: entry.doc,
|
|
546
|
+
path: [],
|
|
547
|
+
value,
|
|
548
|
+
...(meta !== undefined && { meta }),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return ops;
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const attachOf = (h: SocketHostState, conn: Conn): StatewireHost.Attach => {
|
|
556
|
+
const requireSelected = (protocol: string) => {
|
|
557
|
+
const entry = conn.selection.protocols.find((p) => p.name === protocol);
|
|
558
|
+
if (entry === undefined)
|
|
559
|
+
throw new Error(`statewire: protocol ${protocol} was not negotiated`);
|
|
560
|
+
return entry;
|
|
561
|
+
};
|
|
562
|
+
return {
|
|
563
|
+
document: (protocol, key) => {
|
|
564
|
+
if (key === undefined && protocol === h.protocol.name)
|
|
565
|
+
return h.stateHost!.snapshot();
|
|
566
|
+
const view = viewOf(h, conn);
|
|
567
|
+
if (key !== undefined)
|
|
568
|
+
return view.get(documentIdentity(protocol, key))?.value;
|
|
569
|
+
for (const doc of view.values())
|
|
570
|
+
if (doc.protocol === protocol && doc.main) return doc.value;
|
|
571
|
+
return undefined;
|
|
572
|
+
},
|
|
573
|
+
mount: (protocol, value, key, main = key === undefined) => {
|
|
574
|
+
if (requireSelected(protocol).record !== "ephemeral")
|
|
575
|
+
throw new Error(`statewire: protocol ${protocol} is not ephemeral`);
|
|
576
|
+
const encoded = encodeDocument(value);
|
|
577
|
+
if (
|
|
578
|
+
frameEncoder.encode(JSON.stringify(encoded)).byteLength >
|
|
579
|
+
MAX_EPHEMERAL_DOCUMENT_BYTES
|
|
580
|
+
)
|
|
581
|
+
throw new Error("statewire: oversized ephemeral document");
|
|
582
|
+
const id = documentIdentity(protocol, key);
|
|
583
|
+
if (main) {
|
|
584
|
+
for (const [other, doc] of conn.documents) {
|
|
585
|
+
if (doc.protocol === protocol && doc.main && other !== id)
|
|
586
|
+
throw new Error(
|
|
587
|
+
`statewire: ${protocol} already has a main document`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
conn.documents.set(id, {
|
|
592
|
+
protocol,
|
|
593
|
+
key: key === undefined ? null : [...key],
|
|
594
|
+
main,
|
|
595
|
+
value: sanitizeValue(value),
|
|
596
|
+
});
|
|
597
|
+
markViews(h);
|
|
598
|
+
},
|
|
599
|
+
unmount: (protocol, key) => {
|
|
600
|
+
if (!conn.documents.delete(documentIdentity(protocol, key)))
|
|
601
|
+
throw new Error("statewire: document is not mounted");
|
|
602
|
+
markViews(h);
|
|
603
|
+
},
|
|
604
|
+
include: (protocol, key) => {
|
|
605
|
+
if (requireSelected(protocol).record !== undefined)
|
|
606
|
+
throw new Error(`statewire: protocol ${protocol} is not durable`);
|
|
607
|
+
conn.included.add(documentIdentity(protocol, key));
|
|
608
|
+
markViews(h);
|
|
609
|
+
},
|
|
610
|
+
exclude: (protocol, key) => {
|
|
611
|
+
conn.included.delete(documentIdentity(protocol, key));
|
|
612
|
+
markViews(h);
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// ─── flush ──────────────────────────────────────────────
|
|
618
|
+
|
|
473
619
|
export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
|
|
474
620
|
flushTapSync(() => {});
|
|
475
621
|
h.stateHost?.takeChanged();
|
|
476
|
-
for (const
|
|
477
|
-
for (const adm of
|
|
622
|
+
for (const lane of allLanes(h)) {
|
|
623
|
+
for (const adm of lane.inflight.values()) {
|
|
478
624
|
if (adm.applied && !adm.settled && !adm.appliedSent) {
|
|
479
625
|
adm.appliedSent = true;
|
|
480
|
-
queueApplied(h,
|
|
626
|
+
queueApplied(h, lane, adm);
|
|
481
627
|
}
|
|
482
628
|
}
|
|
483
629
|
}
|
|
@@ -487,16 +633,21 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
|
|
|
487
633
|
h.pendingOps.length === 0 &&
|
|
488
634
|
h.pendingApplied.size === 0 &&
|
|
489
635
|
h.pendingCmds.size === 0 &&
|
|
636
|
+
!h.viewsDirty &&
|
|
490
637
|
fin === undefined &&
|
|
491
|
-
!edge
|
|
638
|
+
!edge &&
|
|
639
|
+
![...h.conns].some(
|
|
640
|
+
(conn) => conn.pendingApplied.length > 0 || conn.pendingCmds.length > 0,
|
|
641
|
+
)
|
|
492
642
|
) {
|
|
493
643
|
return;
|
|
494
644
|
}
|
|
495
645
|
h.idleWire = level;
|
|
646
|
+
h.viewsDirty = false;
|
|
496
647
|
if (fin === undefined && edge && level && h.finOnIdle) {
|
|
497
648
|
fin = { reason: "idle" };
|
|
498
649
|
}
|
|
499
|
-
const
|
|
650
|
+
const mainOps = h.pendingOps;
|
|
500
651
|
const applied = h.pendingApplied;
|
|
501
652
|
const cmds = h.pendingCmds;
|
|
502
653
|
h.pendingOps = [];
|
|
@@ -506,26 +657,34 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
|
|
|
506
657
|
// when one is flushing, standalone otherwise. Terminal verdicts ride their
|
|
507
658
|
// own packet.
|
|
508
659
|
for (const conn of [...h.conns]) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
660
|
+
if (!conn.ready) continue;
|
|
661
|
+
const forConn = (list: Cmd[] | undefined) =>
|
|
662
|
+
(list ?? []).filter((cmd) => selects(conn, cmd.protocol));
|
|
663
|
+
const ops = [
|
|
664
|
+
...(selects(conn, h.protocol.name) ? mainOps : []),
|
|
665
|
+
...diffView(h, conn),
|
|
666
|
+
];
|
|
667
|
+
const appliedEntries = [
|
|
668
|
+
...forConn(applied.get(conn.clientId)),
|
|
669
|
+
...conn.pendingApplied.splice(0),
|
|
670
|
+
];
|
|
512
671
|
if (ops.length > 0) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
672
|
+
sendTo(
|
|
673
|
+
h,
|
|
674
|
+
conn,
|
|
675
|
+
JSON.stringify({
|
|
676
|
+
ops,
|
|
677
|
+
...(appliedEntries.length > 0 && { cmd: appliedEntries }),
|
|
678
|
+
...(edge && { idle: level }),
|
|
679
|
+
}),
|
|
680
|
+
);
|
|
521
681
|
} else if (appliedEntries.length > 0) {
|
|
522
682
|
sendTo(h, conn, JSON.stringify({ cmd: appliedEntries }));
|
|
523
683
|
}
|
|
524
|
-
const verdicts =
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
);
|
|
684
|
+
const verdicts = [
|
|
685
|
+
...forConn(cmds.get(conn.clientId)),
|
|
686
|
+
...conn.pendingCmds.splice(0),
|
|
687
|
+
];
|
|
529
688
|
if (verdicts.length > 0) {
|
|
530
689
|
sendTo(h, conn, JSON.stringify({ cmd: verdicts }));
|
|
531
690
|
}
|
|
@@ -539,17 +698,24 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
|
|
|
539
698
|
}
|
|
540
699
|
};
|
|
541
700
|
|
|
542
|
-
const queueApplied = (h: SocketHostState,
|
|
543
|
-
const
|
|
544
|
-
if (
|
|
545
|
-
else
|
|
701
|
+
const queueApplied = (h: SocketHostState, lane: Lane, adm: Admission) => {
|
|
702
|
+
const cmd: Cmd = { protocol: adm.protocol, seq: adm.seq, type: "applied" };
|
|
703
|
+
if (lane.conn !== null) lane.conn.pendingApplied.push(cmd);
|
|
704
|
+
else {
|
|
705
|
+
const list = h.pendingApplied.get(lane.clientId);
|
|
706
|
+
if (list) list.push(cmd);
|
|
707
|
+
else h.pendingApplied.set(lane.clientId, [cmd]);
|
|
708
|
+
}
|
|
546
709
|
scheduleFlush(h);
|
|
547
710
|
};
|
|
548
711
|
|
|
549
|
-
const queueCmd = (h: SocketHostState,
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
712
|
+
const queueCmd = (h: SocketHostState, lane: Lane, cmd: Cmd) => {
|
|
713
|
+
if (lane.conn !== null) lane.conn.pendingCmds.push(cmd);
|
|
714
|
+
else {
|
|
715
|
+
const list = h.pendingCmds.get(lane.clientId);
|
|
716
|
+
if (list) list.push(cmd);
|
|
717
|
+
else h.pendingCmds.set(lane.clientId, [cmd]);
|
|
718
|
+
}
|
|
553
719
|
scheduleFlush(h);
|
|
554
720
|
};
|
|
555
721
|
|
|
@@ -557,80 +723,116 @@ const notifyClientRecords = (h: SocketHostState) => {
|
|
|
557
723
|
h.persistence?.onClientRecordsChange?.(
|
|
558
724
|
[...h.clients.values()].map((rec) => ({
|
|
559
725
|
id: rec.id,
|
|
560
|
-
lastSeq: admittedThrough(rec),
|
|
561
726
|
touched: rec.touched,
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
727
|
+
lanes: Object.fromEntries(
|
|
728
|
+
[...rec.lanes.values()].map((lane) => [
|
|
729
|
+
lane.protocol,
|
|
730
|
+
{
|
|
731
|
+
lastSeq: admittedThrough(lane),
|
|
732
|
+
results: [...lane.results].map(([seq, entry]) => ({
|
|
733
|
+
seq,
|
|
734
|
+
ts: entry.ts,
|
|
735
|
+
cmd: entry.cmd,
|
|
736
|
+
})),
|
|
737
|
+
},
|
|
738
|
+
]),
|
|
739
|
+
),
|
|
567
740
|
principal: rec.principal,
|
|
568
741
|
})),
|
|
569
742
|
);
|
|
570
743
|
};
|
|
571
744
|
|
|
572
|
-
const retain = (
|
|
745
|
+
const retain = (lane: Lane, cmd: Cmd) => {
|
|
573
746
|
const now = Date.now();
|
|
574
|
-
|
|
575
|
-
const newest = Math.max(...
|
|
576
|
-
for (const [seq, entry] of
|
|
747
|
+
lane.results.set(cmd.seq, { ts: now, cmd });
|
|
748
|
+
const newest = Math.max(...lane.results.keys());
|
|
749
|
+
for (const [seq, entry] of lane.results) {
|
|
577
750
|
if (seq !== newest && now - entry.ts > RESULT_TTL_MS) {
|
|
578
|
-
|
|
751
|
+
lane.results.delete(seq);
|
|
579
752
|
}
|
|
580
753
|
}
|
|
581
|
-
while (
|
|
582
|
-
|
|
754
|
+
while (lane.results.size > RESULT_LIMIT) {
|
|
755
|
+
lane.results.delete(Math.min(...lane.results.keys()));
|
|
583
756
|
}
|
|
584
757
|
};
|
|
585
758
|
|
|
586
|
-
/**
|
|
587
|
-
const admittedThrough = (
|
|
588
|
-
Math.max(
|
|
759
|
+
/** Lane watermark: the last statement admitted, settled or not. */
|
|
760
|
+
const admittedThrough = (lane: Lane): number =>
|
|
761
|
+
Math.max(lane.lastSeq, ...lane.inflight.keys());
|
|
762
|
+
|
|
763
|
+
const watermark = (h: SocketHostState, conn: Conn, protocol: string) => {
|
|
764
|
+
const entry = conn.selection.protocols.find((p) => p.name === protocol)!;
|
|
765
|
+
const lane =
|
|
766
|
+
entry.record === "ephemeral"
|
|
767
|
+
? conn.lanes.get(protocol)
|
|
768
|
+
: h.clients.get(conn.clientId)?.lanes.get(protocol);
|
|
769
|
+
return lane === undefined ? 0 : admittedThrough(lane);
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
const laneOf = (h: SocketHostState, conn: Conn, protocol: string): Lane => {
|
|
773
|
+
const entry = conn.selection.protocols.find((p) => p.name === protocol);
|
|
774
|
+
if (entry === undefined) return violation("unsupported protocol");
|
|
775
|
+
const ephemeral = entry.record === "ephemeral";
|
|
776
|
+
const lanes = ephemeral ? conn.lanes : h.clients.get(conn.clientId)!.lanes;
|
|
777
|
+
let lane = lanes.get(protocol);
|
|
778
|
+
if (lane === undefined) {
|
|
779
|
+
lane = {
|
|
780
|
+
protocol,
|
|
781
|
+
clientId: conn.clientId,
|
|
782
|
+
conn: ephemeral ? conn : null,
|
|
783
|
+
lastSeq: 0,
|
|
784
|
+
results: new Map(),
|
|
785
|
+
inflight: new Map(),
|
|
786
|
+
};
|
|
787
|
+
lanes.set(protocol, lane);
|
|
788
|
+
}
|
|
789
|
+
return lane;
|
|
790
|
+
};
|
|
589
791
|
|
|
590
792
|
// A re-asked seq never re-runs: the stored verdict re-emits, an applied
|
|
591
793
|
// inflight run re-answers applied; one not yet applied answers nothing (its
|
|
592
794
|
// answers ride the stream when ready), an evicted verdict answers expired.
|
|
593
|
-
const reemit = (h: SocketHostState,
|
|
594
|
-
const cached =
|
|
795
|
+
const reemit = (h: SocketHostState, lane: Lane, seq: number) => {
|
|
796
|
+
const cached = lane.results.get(seq);
|
|
595
797
|
if (cached) {
|
|
596
|
-
const newest = Math.max(...
|
|
798
|
+
const newest = Math.max(...lane.results.keys());
|
|
597
799
|
if (seq === newest || Date.now() - cached.ts <= RESULT_TTL_MS) {
|
|
598
|
-
queueCmd(h,
|
|
800
|
+
queueCmd(h, lane, cached.cmd);
|
|
599
801
|
return;
|
|
600
802
|
}
|
|
601
|
-
|
|
803
|
+
lane.results.delete(seq);
|
|
602
804
|
notifyClientRecords(h);
|
|
603
805
|
}
|
|
604
|
-
const adm =
|
|
806
|
+
const adm = lane.inflight.get(seq);
|
|
605
807
|
if (adm) {
|
|
606
|
-
if (adm.applied) queueApplied(h,
|
|
808
|
+
if (adm.applied) queueApplied(h, lane, adm);
|
|
607
809
|
return;
|
|
608
810
|
}
|
|
609
|
-
queueCmd(h,
|
|
811
|
+
queueCmd(h, lane, { protocol: lane.protocol, seq, type: "expired" });
|
|
610
812
|
};
|
|
611
813
|
|
|
612
814
|
const settleCmd = (
|
|
613
815
|
h: SocketHostState,
|
|
614
|
-
|
|
816
|
+
lane: Lane,
|
|
615
817
|
adm: Admission,
|
|
616
|
-
cmd:
|
|
818
|
+
cmd: Cmd,
|
|
617
819
|
) => {
|
|
618
820
|
if (adm.settled) return;
|
|
619
821
|
adm.settled = true;
|
|
620
|
-
|
|
621
|
-
retain(
|
|
622
|
-
|
|
822
|
+
lane.inflight.delete(adm.seq);
|
|
823
|
+
retain(lane, cmd);
|
|
824
|
+
lane.lastSeq = Math.max(lane.lastSeq, adm.seq);
|
|
623
825
|
adm.applied = true;
|
|
624
|
-
queueCmd(h,
|
|
625
|
-
notifyClientRecords(h);
|
|
826
|
+
queueCmd(h, lane, cmd);
|
|
827
|
+
if (lane.conn === null) notifyClientRecords(h);
|
|
626
828
|
};
|
|
627
829
|
|
|
628
830
|
const settleWire = (
|
|
629
831
|
h: SocketHostState,
|
|
630
|
-
|
|
832
|
+
lane: Lane,
|
|
631
833
|
adm: Admission,
|
|
632
834
|
outcome: CommandsOutcome,
|
|
633
|
-
) => settleCmd(h,
|
|
835
|
+
) => settleCmd(h, lane, adm, cmdOf(adm, outcome));
|
|
634
836
|
|
|
635
837
|
const callerOf = (
|
|
636
838
|
h: SocketHostState,
|
|
@@ -639,9 +841,6 @@ const callerOf = (
|
|
|
639
841
|
): StatewireHost.Caller => ({
|
|
640
842
|
clientId: rec.id,
|
|
641
843
|
auth: conn.auth,
|
|
642
|
-
get context() {
|
|
643
|
-
return conn.context;
|
|
644
|
-
},
|
|
645
844
|
get isConnected() {
|
|
646
845
|
if (h.clients.get(rec.id) !== rec) return false;
|
|
647
846
|
for (const conn of h.conns) {
|
|
@@ -653,11 +852,12 @@ const callerOf = (
|
|
|
653
852
|
|
|
654
853
|
const rejectStatement = (
|
|
655
854
|
h: SocketHostState,
|
|
656
|
-
|
|
855
|
+
lane: Lane,
|
|
657
856
|
adm: Admission,
|
|
658
857
|
message: string,
|
|
659
858
|
) =>
|
|
660
|
-
settleCmd(h,
|
|
859
|
+
settleCmd(h, lane, adm, {
|
|
860
|
+
protocol: adm.protocol,
|
|
661
861
|
seq: adm.seq,
|
|
662
862
|
type: "rejected",
|
|
663
863
|
code: "invalid-params",
|
|
@@ -666,33 +866,23 @@ const rejectStatement = (
|
|
|
666
866
|
|
|
667
867
|
const admitCmd = (
|
|
668
868
|
h: SocketHostState,
|
|
669
|
-
|
|
869
|
+
lane: Lane,
|
|
670
870
|
conn: Conn,
|
|
671
871
|
statement: CmdStatement,
|
|
672
872
|
) => {
|
|
873
|
+
const rec = h.clients.get(conn.clientId)!;
|
|
673
874
|
const adm: Admission = {
|
|
875
|
+
protocol: statement.protocol,
|
|
674
876
|
seq: statement.seq,
|
|
675
877
|
method: statement.method,
|
|
676
878
|
applied: false,
|
|
677
879
|
appliedSent: false,
|
|
678
880
|
settled: false,
|
|
679
881
|
};
|
|
680
|
-
|
|
681
|
-
notifyClientRecords(h);
|
|
682
|
-
if (
|
|
683
|
-
statement.protocol !== h.protocol.name ||
|
|
684
|
-
!conn.selection.protocols.some((p) => p.name === statement.protocol)
|
|
685
|
-
) {
|
|
686
|
-
settleCmd(h, rec, adm, {
|
|
687
|
-
seq: adm.seq,
|
|
688
|
-
type: "rejected",
|
|
689
|
-
code: "unsupported-protocol",
|
|
690
|
-
message: "command protocol was not negotiated",
|
|
691
|
-
});
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
882
|
+
lane.inflight.set(statement.seq, adm);
|
|
883
|
+
if (lane.conn === null) notifyClientRecords(h);
|
|
694
884
|
if (!Array.isArray(statement.params)) {
|
|
695
|
-
rejectStatement(h,
|
|
885
|
+
rejectStatement(h, lane, adm, "params must be an array");
|
|
696
886
|
return;
|
|
697
887
|
}
|
|
698
888
|
let params = statement.params;
|
|
@@ -704,7 +894,7 @@ const admitCmd = (
|
|
|
704
894
|
} catch (error) {
|
|
705
895
|
rejectStatement(
|
|
706
896
|
h,
|
|
707
|
-
|
|
897
|
+
lane,
|
|
708
898
|
adm,
|
|
709
899
|
`invalid params: ${(error as Error).message}`,
|
|
710
900
|
);
|
|
@@ -713,47 +903,75 @@ const admitCmd = (
|
|
|
713
903
|
}
|
|
714
904
|
if (statement.method === "statewire/commands") {
|
|
715
905
|
if (params.length > 0) {
|
|
716
|
-
rejectStatement(h,
|
|
906
|
+
rejectStatement(h, lane, adm, "statewire/commands takes no params");
|
|
717
907
|
return;
|
|
718
908
|
}
|
|
719
909
|
const names = [
|
|
720
910
|
...(h.commandsHost?.methods?.() ?? []),
|
|
721
911
|
"statewire/commands",
|
|
722
912
|
].sort();
|
|
723
|
-
settleCmd(h,
|
|
913
|
+
settleCmd(h, lane, adm, {
|
|
914
|
+
protocol: adm.protocol,
|
|
915
|
+
seq: adm.seq,
|
|
916
|
+
type: "result",
|
|
917
|
+
payload: names,
|
|
918
|
+
});
|
|
724
919
|
return;
|
|
725
920
|
}
|
|
921
|
+
// Subprotocol methods are registered under their full wire name.
|
|
922
|
+
const method =
|
|
923
|
+
statement.protocol === h.protocol.name
|
|
924
|
+
? statement.method
|
|
925
|
+
: `${statement.protocol}/${statement.method}`;
|
|
726
926
|
h.commandsHost!.submit!({
|
|
727
|
-
method
|
|
927
|
+
method,
|
|
728
928
|
params,
|
|
729
929
|
clientId: rec.id,
|
|
730
930
|
caller: callerOf(h, rec, conn),
|
|
931
|
+
attach: conn.attach,
|
|
731
932
|
onApplied: () => {
|
|
732
933
|
if (adm.settled || adm.applied) return;
|
|
733
934
|
adm.applied = true;
|
|
734
|
-
|
|
735
|
-
notifyClientRecords(h);
|
|
935
|
+
lane.lastSeq = Math.max(lane.lastSeq, adm.seq);
|
|
936
|
+
if (lane.conn === null) notifyClientRecords(h);
|
|
736
937
|
scheduleFlush(h);
|
|
737
938
|
},
|
|
738
|
-
onSettle: (outcome) => settleWire(h,
|
|
939
|
+
onSettle: (outcome) => settleWire(h, lane, adm, outcome),
|
|
739
940
|
}).catch((error: unknown) => {
|
|
740
|
-
if (!adm.settled) settleWire(h,
|
|
941
|
+
if (!adm.settled) settleWire(h, lane, adm, { type: "rejected", error });
|
|
741
942
|
});
|
|
742
943
|
};
|
|
743
944
|
|
|
744
|
-
// One admission law,
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
945
|
+
// One admission law, every lane: absorbed statements answer from their lane,
|
|
946
|
+
// then the new suffix is admitted whole or the frame is refused whole via a
|
|
947
|
+
// {syn} stating every lane's watermark, nothing consumed. A durable gap is
|
|
948
|
+
// never a close; an ephemeral gap is a violation (the lane has no replay).
|
|
748
949
|
export type FrameAdmission =
|
|
749
950
|
| { type: "ok" }
|
|
750
951
|
| {
|
|
751
952
|
type: "refused";
|
|
752
953
|
syn: {
|
|
753
|
-
syn: {
|
|
954
|
+
syn: { protocols: { name: string; seq: number }[]; retry?: number };
|
|
754
955
|
};
|
|
755
956
|
};
|
|
756
957
|
|
|
958
|
+
const refusal = (
|
|
959
|
+
h: SocketHostState,
|
|
960
|
+
conn: Conn,
|
|
961
|
+
retry?: number,
|
|
962
|
+
): FrameAdmission => ({
|
|
963
|
+
type: "refused",
|
|
964
|
+
syn: {
|
|
965
|
+
syn: {
|
|
966
|
+
protocols: conn.selection.protocols.map((p) => ({
|
|
967
|
+
name: p.name,
|
|
968
|
+
seq: watermark(h, conn, p.name),
|
|
969
|
+
})),
|
|
970
|
+
...(retry !== undefined && { retry }),
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
|
|
757
975
|
export const admitFrame = (
|
|
758
976
|
h: SocketHostState,
|
|
759
977
|
conn: Conn,
|
|
@@ -761,81 +979,57 @@ export const admitFrame = (
|
|
|
761
979
|
): FrameAdmission => {
|
|
762
980
|
const rec = h.clients.get(conn.clientId)!;
|
|
763
981
|
rec.touched = Date.now();
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
(
|
|
773
|
-
|
|
774
|
-
notifyClientRecords(h);
|
|
775
|
-
return {
|
|
776
|
-
type: "refused",
|
|
777
|
-
syn: { syn: { seq: through, ctxSeq: conn.ctxSeq } },
|
|
778
|
-
};
|
|
982
|
+
const lanes = new Map(
|
|
983
|
+
frame.cmd.map((s) => [s.protocol, laneOf(h, conn, s.protocol)]),
|
|
984
|
+
);
|
|
985
|
+
const through = new Map(
|
|
986
|
+
[...lanes].map(([protocol, lane]) => [protocol, admittedThrough(lane)]),
|
|
987
|
+
);
|
|
988
|
+
const fresh = frame.cmd.filter((s) => s.seq > through.get(s.protocol)!);
|
|
989
|
+
for (const s of frame.cmd) {
|
|
990
|
+
if (s.seq <= through.get(s.protocol)!)
|
|
991
|
+
reemit(h, lanes.get(s.protocol)!, s.seq);
|
|
779
992
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
993
|
+
let durableNew = 0;
|
|
994
|
+
for (const [protocol, lane] of lanes) {
|
|
995
|
+
const first = fresh.find((s) => s.protocol === protocol);
|
|
996
|
+
if (first === undefined) continue;
|
|
997
|
+
if (first.seq !== through.get(protocol)! + 1) {
|
|
998
|
+
if (lane.conn !== null) return violation("ephemeral lane gap");
|
|
999
|
+
notifyClientRecords(h);
|
|
1000
|
+
return refusal(h, conn);
|
|
784
1001
|
}
|
|
1002
|
+
if (lane.conn === null)
|
|
1003
|
+
durableNew += fresh.filter((s) => s.protocol === protocol).length;
|
|
1004
|
+
}
|
|
1005
|
+
if (durableNew > 0) {
|
|
1006
|
+
let clientLoad = 0;
|
|
1007
|
+
for (const lane of rec.lanes.values()) clientLoad += lane.inflight.size;
|
|
1008
|
+
let hostLoad = 0;
|
|
1009
|
+
for (const lane of durableLanes(h)) hostLoad += lane.inflight.size;
|
|
785
1010
|
if (
|
|
786
|
-
|
|
787
|
-
|
|
1011
|
+
clientLoad + durableNew > MAX_INFLIGHT_COMMANDS_PER_CLIENT ||
|
|
1012
|
+
hostLoad + durableNew > h.maxInflightCommands
|
|
788
1013
|
) {
|
|
789
|
-
return
|
|
790
|
-
type: "refused",
|
|
791
|
-
syn: {
|
|
792
|
-
syn: {
|
|
793
|
-
seq: through,
|
|
794
|
-
ctxSeq: conn.ctxSeq,
|
|
795
|
-
retry: COMMAND_CAPACITY_RETRY_MS,
|
|
796
|
-
},
|
|
797
|
-
},
|
|
798
|
-
};
|
|
1014
|
+
return refusal(h, conn, COMMAND_CAPACITY_RETRY_MS);
|
|
799
1015
|
}
|
|
800
1016
|
}
|
|
801
|
-
if (
|
|
1017
|
+
if (fresh.length > 0 && h.pushback) {
|
|
802
1018
|
const retry = h.pushback();
|
|
803
1019
|
if (retry !== null) {
|
|
804
1020
|
if (!Number.isSafeInteger(retry) || retry < 0)
|
|
805
1021
|
throw new TypeError(
|
|
806
1022
|
"pushback must return a nonnegative safe integer or null",
|
|
807
1023
|
);
|
|
808
|
-
return
|
|
809
|
-
type: "refused",
|
|
810
|
-
syn: { syn: { seq: through, ctxSeq: conn.ctxSeq, retry } },
|
|
811
|
-
};
|
|
1024
|
+
return refusal(h, conn, retry);
|
|
812
1025
|
}
|
|
813
1026
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
contextDocuments = applyDocumentOps(
|
|
818
|
-
contextDocuments,
|
|
819
|
-
statement.ops,
|
|
820
|
-
conn.selection.protocols,
|
|
821
|
-
true,
|
|
822
|
-
);
|
|
823
|
-
if (
|
|
824
|
-
frameEncoder.encode(JSON.stringify([...contextDocuments.documents]))
|
|
825
|
-
.byteLength > MAX_CONTEXT_VALUE_BYTES
|
|
826
|
-
)
|
|
827
|
-
return violation("oversized-context");
|
|
828
|
-
} catch {
|
|
829
|
-
return violation("invalid-context");
|
|
830
|
-
}
|
|
831
|
-
conn.contextDocuments = contextDocuments;
|
|
832
|
-
conn.context = (mainDocument(contextDocuments, h.protocol.name)?.value ??
|
|
833
|
-
null) as Record<string, unknown> | null;
|
|
834
|
-
if (ctxNew.length) conn.ctxSeq = ctxNew.at(-1)!.seq;
|
|
835
|
-
for (const statement of cmdNew) admitCmd(h, rec, conn, statement);
|
|
836
|
-
if (cmdNew.length > 0) {
|
|
1027
|
+
for (const s of fresh) admitCmd(h, lanes.get(s.protocol)!, conn, s);
|
|
1028
|
+
for (const [protocol, lane] of lanes) {
|
|
1029
|
+
const last = fresh.findLast((s) => s.protocol === protocol);
|
|
837
1030
|
// Memory host: admitted means retained — dur rides the admitting flush.
|
|
838
|
-
|
|
1031
|
+
if (last !== undefined && lane.conn === null)
|
|
1032
|
+
queueCmd(h, lane, { protocol, seq: last.seq, dur: true });
|
|
839
1033
|
}
|
|
840
1034
|
notifyClientRecords(h);
|
|
841
1035
|
scheduleFlush(h);
|
|
@@ -860,38 +1054,15 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
860
1054
|
}
|
|
861
1055
|
const first = !conn.sawFrame;
|
|
862
1056
|
conn.sawFrame = true;
|
|
863
|
-
let frame: ParsedFrame;
|
|
864
|
-
try {
|
|
865
|
-
frame = first ? parseInitialFrame(data).frame : parseFrame(data);
|
|
866
|
-
} catch (error) {
|
|
867
|
-
const reason = violationReason(error);
|
|
868
|
-
if (reason === null) throw error;
|
|
869
|
-
sendTo(
|
|
870
|
-
h,
|
|
871
|
-
conn,
|
|
872
|
-
JSON.stringify({
|
|
873
|
-
fin: {
|
|
874
|
-
reason: "protocol-error",
|
|
875
|
-
message: reason,
|
|
876
|
-
},
|
|
877
|
-
}),
|
|
878
|
-
);
|
|
879
|
-
closeConn(h, conn, 1008, reason);
|
|
880
|
-
return;
|
|
881
|
-
}
|
|
882
|
-
if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
|
|
883
1057
|
let admission: ReturnType<typeof admitFrame>;
|
|
884
1058
|
try {
|
|
1059
|
+
const frame = first ? parseInitialFrame(data).frame : parseFrame(data);
|
|
1060
|
+
if (frame.cmd.length === 0) return;
|
|
885
1061
|
admission = admitFrame(h, conn, frame);
|
|
886
1062
|
} catch (error) {
|
|
887
1063
|
const reason = violationReason(error);
|
|
888
1064
|
if (reason === null) throw error;
|
|
889
|
-
|
|
890
|
-
h,
|
|
891
|
-
conn,
|
|
892
|
-
JSON.stringify({ fin: { reason: "protocol-error", message: reason } }),
|
|
893
|
-
);
|
|
894
|
-
closeConn(h, conn, 1008, reason);
|
|
1065
|
+
violate(h, conn, reason);
|
|
895
1066
|
return;
|
|
896
1067
|
}
|
|
897
1068
|
if (admission.type === "refused") {
|
|
@@ -1146,26 +1317,23 @@ const connectAuthorized = (
|
|
|
1146
1317
|
pending.open = false;
|
|
1147
1318
|
h.pendingConns.delete(pending);
|
|
1148
1319
|
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
socket,
|
|
1153
|
-
true,
|
|
1154
|
-
auth,
|
|
1155
|
-
selection,
|
|
1156
|
-
);
|
|
1157
|
-
if (registration === null) return;
|
|
1158
|
-
pending.connection = registration.connection;
|
|
1159
|
-
if (frame.ctx.length || frame.cmd.length) {
|
|
1160
|
-
message(
|
|
1320
|
+
let registration: Registration | null;
|
|
1321
|
+
try {
|
|
1322
|
+
registration = register(
|
|
1161
1323
|
h,
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1324
|
+
clientId,
|
|
1325
|
+
socket,
|
|
1326
|
+
true,
|
|
1327
|
+
auth,
|
|
1328
|
+
selection,
|
|
1329
|
+
frame,
|
|
1167
1330
|
);
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
if (violationReason(error) === null) throw error;
|
|
1333
|
+
return;
|
|
1168
1334
|
}
|
|
1335
|
+
if (registration === null) return;
|
|
1336
|
+
pending.connection = registration.connection;
|
|
1169
1337
|
for (const data of pending.queued.splice(0))
|
|
1170
1338
|
registration.connection.message(data);
|
|
1171
1339
|
})
|
|
@@ -1188,7 +1356,11 @@ const gcClients = (h: SocketHostState) => {
|
|
|
1188
1356
|
const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
|
|
1189
1357
|
for (const rec of oldest) {
|
|
1190
1358
|
if (excess <= 0) break;
|
|
1191
|
-
if (
|
|
1359
|
+
if (
|
|
1360
|
+
live.has(rec.id) ||
|
|
1361
|
+
[...rec.lanes.values()].some((lane) => lane.inflight.size > 0)
|
|
1362
|
+
)
|
|
1363
|
+
continue;
|
|
1192
1364
|
h.clients.delete(rec.id);
|
|
1193
1365
|
changed = true;
|
|
1194
1366
|
excess -= 1;
|
|
@@ -1201,6 +1373,11 @@ export type Registration = {
|
|
|
1201
1373
|
connection: StatewireHostInternal.SocketConnection;
|
|
1202
1374
|
};
|
|
1203
1375
|
|
|
1376
|
+
/**
|
|
1377
|
+
* Attach a socket: ephemeral-lane statements of the attach frame are admitted
|
|
1378
|
+
* before the snapshot (their documents and answers ride the hello), durable
|
|
1379
|
+
* ones after it. A violation in the attach frame closes the attach and throws.
|
|
1380
|
+
*/
|
|
1204
1381
|
export const register = (
|
|
1205
1382
|
h: SocketHostState,
|
|
1206
1383
|
clientId: string,
|
|
@@ -1211,6 +1388,7 @@ export const register = (
|
|
|
1211
1388
|
version: h.wireVersion.version,
|
|
1212
1389
|
protocols: [{ name: h.protocol.name, version: h.protocol.version }],
|
|
1213
1390
|
},
|
|
1391
|
+
frame: ParsedFrame = { cmd: [] },
|
|
1214
1392
|
): Registration | null => {
|
|
1215
1393
|
const refuse = (code: number, reason: string) => {
|
|
1216
1394
|
try {
|
|
@@ -1257,10 +1435,8 @@ export const register = (
|
|
|
1257
1435
|
(() => {
|
|
1258
1436
|
const created: ClientRec = {
|
|
1259
1437
|
id: clientId,
|
|
1260
|
-
lastSeq: 0,
|
|
1261
1438
|
touched: Date.now(),
|
|
1262
|
-
|
|
1263
|
-
inflight: new Map(),
|
|
1439
|
+
lanes: new Map(),
|
|
1264
1440
|
principal: auth,
|
|
1265
1441
|
};
|
|
1266
1442
|
h.clients.set(clientId, created);
|
|
@@ -1272,39 +1448,78 @@ export const register = (
|
|
|
1272
1448
|
clientId,
|
|
1273
1449
|
socket,
|
|
1274
1450
|
open: true,
|
|
1451
|
+
ready: false,
|
|
1275
1452
|
sawFrame,
|
|
1276
1453
|
lastSentAt: 0,
|
|
1277
1454
|
lease: null,
|
|
1278
1455
|
auth,
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1456
|
+
lanes: new Map(),
|
|
1457
|
+
documents: new Map(),
|
|
1458
|
+
included: new Set(),
|
|
1459
|
+
view: new Map(),
|
|
1460
|
+
nextDoc: 1,
|
|
1461
|
+
pendingApplied: [],
|
|
1462
|
+
pendingCmds: [],
|
|
1463
|
+
attach: undefined!,
|
|
1282
1464
|
};
|
|
1465
|
+
conn.attach = attachOf(h, conn);
|
|
1283
1466
|
h.conns.add(conn);
|
|
1284
1467
|
gcClients(h);
|
|
1285
|
-
notifyClientRecords(h);
|
|
1286
1468
|
ensureHeartbeat(h);
|
|
1287
|
-
const
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1469
|
+
const isEphemeral = (s: CmdStatement) =>
|
|
1470
|
+
selection.protocols.find((p) => p.name === s.protocol)?.record ===
|
|
1471
|
+
"ephemeral";
|
|
1472
|
+
const early = frame.cmd.filter(isEphemeral);
|
|
1473
|
+
const late = frame.cmd.filter((s) => !isEphemeral(s));
|
|
1474
|
+
const admit = (statements: CmdStatement[]) => {
|
|
1475
|
+
if (statements.length === 0) return null;
|
|
1476
|
+
try {
|
|
1477
|
+
return admitFrame(h, conn, { cmd: statements });
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
const reason = violationReason(error);
|
|
1480
|
+
if (reason === null) throw error;
|
|
1481
|
+
violate(h, conn, reason);
|
|
1482
|
+
throw error;
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
const earlyAdmission = admit(early);
|
|
1486
|
+
if (early.length > 0) flush(h);
|
|
1487
|
+
notifyClientRecords(h);
|
|
1488
|
+
const ops: WireOp[] = [];
|
|
1489
|
+
if (selects(conn, h.protocol.name)) {
|
|
1490
|
+
const { value, meta } = encodeDocument(h.stateHost!.snapshot());
|
|
1491
|
+
ops.push({
|
|
1492
|
+
op: "mount",
|
|
1493
|
+
protocol: h.protocol.name,
|
|
1494
|
+
main: true,
|
|
1495
|
+
value,
|
|
1496
|
+
...(meta !== undefined && { meta }),
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
ops.push(...diffView(h, conn));
|
|
1500
|
+
const answers = [
|
|
1501
|
+
...conn.pendingApplied.splice(0),
|
|
1502
|
+
...conn.pendingCmds.splice(0),
|
|
1503
|
+
];
|
|
1504
|
+
const hello = {
|
|
1505
|
+
ops,
|
|
1298
1506
|
syn: {
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1507
|
+
version: selection.version,
|
|
1508
|
+
protocols: selection.protocols.map((p) => ({
|
|
1509
|
+
...p,
|
|
1510
|
+
seq: p.record === "ephemeral" ? 0 : watermark(h, conn, p.name),
|
|
1511
|
+
})),
|
|
1302
1512
|
...(h.idleWire && { idle: true }),
|
|
1303
1513
|
},
|
|
1514
|
+
...(answers.length > 0 && { cmd: answers }),
|
|
1304
1515
|
};
|
|
1305
|
-
|
|
1306
|
-
hello.ops = [];
|
|
1516
|
+
conn.ready = true;
|
|
1307
1517
|
sendTo(h, conn, JSON.stringify(hello));
|
|
1518
|
+
if (earlyAdmission?.type === "refused")
|
|
1519
|
+
sendTo(h, conn, JSON.stringify(earlyAdmission.syn));
|
|
1520
|
+
const lateAdmission = admit(late);
|
|
1521
|
+
if (lateAdmission?.type === "refused")
|
|
1522
|
+
sendTo(h, conn, JSON.stringify(lateAdmission.syn));
|
|
1308
1523
|
const connection: StatewireHostInternal.SocketConnection = {
|
|
1309
1524
|
clientId,
|
|
1310
1525
|
message: (data) => message(h, conn, data),
|
|
@@ -1342,6 +1557,7 @@ const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
|
|
|
1342
1557
|
const resume = (h: SocketHostState) => {
|
|
1343
1558
|
h.disposed = false;
|
|
1344
1559
|
h.stateHost?.setOnOp((op) => recordOp(h, op));
|
|
1560
|
+
h.stateHost?.setOnDocuments(() => markViews(h));
|
|
1345
1561
|
};
|
|
1346
1562
|
|
|
1347
1563
|
const suspend = (h: SocketHostState) => {
|
|
@@ -1351,6 +1567,7 @@ const suspend = (h: SocketHostState) => {
|
|
|
1351
1567
|
h.heartbeatTimer = null;
|
|
1352
1568
|
}
|
|
1353
1569
|
h.stateHost?.setOnOp(null);
|
|
1570
|
+
h.stateHost?.setOnDocuments(null);
|
|
1354
1571
|
};
|
|
1355
1572
|
|
|
1356
1573
|
const restoreClientRecords = (
|
|
@@ -1361,12 +1578,22 @@ const restoreClientRecords = (
|
|
|
1361
1578
|
record.id,
|
|
1362
1579
|
{
|
|
1363
1580
|
id: record.id,
|
|
1364
|
-
lastSeq: record.lastSeq,
|
|
1365
1581
|
touched: record.touched,
|
|
1366
|
-
|
|
1367
|
-
record.
|
|
1582
|
+
lanes: new Map(
|
|
1583
|
+
Object.entries(record.lanes).map(([protocol, lane]) => [
|
|
1584
|
+
protocol,
|
|
1585
|
+
{
|
|
1586
|
+
protocol,
|
|
1587
|
+
clientId: record.id,
|
|
1588
|
+
conn: null,
|
|
1589
|
+
lastSeq: lane.lastSeq,
|
|
1590
|
+
results: new Map(
|
|
1591
|
+
lane.results.map(({ seq, ts, cmd }) => [seq, { ts, cmd }]),
|
|
1592
|
+
),
|
|
1593
|
+
inflight: new Map(),
|
|
1594
|
+
},
|
|
1595
|
+
]),
|
|
1368
1596
|
),
|
|
1369
|
-
inflight: new Map(),
|
|
1370
1597
|
principal: record.principal,
|
|
1371
1598
|
},
|
|
1372
1599
|
]),
|
|
@@ -1399,6 +1626,7 @@ const useSocketHost = (
|
|
|
1399
1626
|
pendingOps: [],
|
|
1400
1627
|
pendingApplied: new Map(),
|
|
1401
1628
|
pendingCmds: new Map(),
|
|
1629
|
+
viewsDirty: false,
|
|
1402
1630
|
idleWire: true,
|
|
1403
1631
|
finished: null,
|
|
1404
1632
|
disposed: false,
|
|
@@ -1472,9 +1700,10 @@ const useSocketHost = (
|
|
|
1472
1700
|
* Transport-agnostic WS binding over a statewire/host transport:
|
|
1473
1701
|
* feed it sockets (anything with `send`/`close`) and raw text frames, and it
|
|
1474
1702
|
* speaks the Statewire packet protocol — snapshot-first hello with `syn`,
|
|
1475
|
-
*
|
|
1476
|
-
* arrays fanned out to every attach of a client id
|
|
1477
|
-
*
|
|
1703
|
+
* per-protocol statement lanes under one admission law, `{cmd}` answer
|
|
1704
|
+
* arrays fanned out to every attach of a client id (durable lanes) or the
|
|
1705
|
+
* admitting attach (ephemeral lanes), per-attach document views, idle edges,
|
|
1706
|
+
* and `fin` on finish.
|
|
1478
1707
|
*/
|
|
1479
1708
|
export const StatewireSocketHost = (
|
|
1480
1709
|
transport: ResourceElement<Statewire>,
|