statewire 0.8.0 → 0.9.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 +2 -2
- package/dist/client/connection.d.ts +23 -19
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +143 -103
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +30 -3
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +222 -259
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +34 -39
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +380 -373
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +1 -0
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/client/timing.d.ts +1 -2
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +1 -5
- package/dist/client/timing.js.map +1 -1
- package/dist/host/draft.d.ts +2 -5
- package/dist/host/draft.d.ts.map +1 -1
- package/dist/host/draft.js.map +1 -1
- package/dist/host/host.d.ts +5 -8
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +23 -32
- package/dist/host/host.js.map +1 -1
- package/dist/host/socket.d.ts +3 -2
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +484 -424
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +4 -0
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +10 -10
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/statewire.d.ts +29 -24
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +8 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fake-sse.d.ts +1 -1
- package/dist/transports/fake-sse.d.ts.map +1 -1
- package/dist/transports/fake-sse.js +5 -2
- package/dist/transports/fake-sse.js.map +1 -1
- package/dist/transports/fixtures.d.ts +1 -2
- package/dist/transports/fixtures.d.ts.map +1 -1
- package/dist/transports/fixtures.js +5 -7
- package/dist/transports/fixtures.js.map +1 -1
- package/dist/transports/remote.d.ts +2 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +10 -4
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +32 -46
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +56 -59
- 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 +41 -14
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +2 -3
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +25 -80
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +8 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -4
- package/dist/types.js.map +1 -1
- package/dist/wire/ops.d.ts +1 -5
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -4
- package/dist/wire/ops.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +175 -128
- package/src/client/core.ts +287 -276
- package/src/client/session.ts +474 -480
- package/src/client/sessionStore.ts +1 -0
- package/src/client/timing.ts +0 -6
- package/src/host/draft.ts +1 -3
- package/src/host/host.ts +41 -58
- package/src/host/socket.ts +653 -448
- package/src/host-internal.ts +4 -0
- package/src/host.ts +13 -10
- package/src/index.ts +0 -1
- package/src/statewire.ts +38 -32
- package/src/transports/fake-sse.ts +2 -1
- package/src/transports/fixtures.ts +4 -9
- package/src/transports/remote.ts +12 -5
- package/src/transports/sse.ts +99 -115
- package/src/transports/testing.ts +47 -19
- package/src/transports/ws.ts +44 -115
- package/src/types.ts +15 -14
- package/src/wire/ops.ts +9 -16
package/src/host/socket.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
|
|
3
3
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
4
|
-
import { getCommandsHost,
|
|
4
|
+
import { getCommandsHost, getStateHost } from "./host";
|
|
5
5
|
import type { CommandsHost, CommandsOutcome } from "./host";
|
|
6
6
|
import { StatewireSendError } from "../types";
|
|
7
7
|
import type { Statewire } from "../types";
|
|
@@ -9,17 +9,14 @@ import type { StatewireHost } from "../host";
|
|
|
9
9
|
import type { StatewireHostInternal } from "../host-internal";
|
|
10
10
|
import { encodeValue, reviveValue } from "../wire/meta";
|
|
11
11
|
import { createMacrotaskPoster } from "../util/macrotask";
|
|
12
|
-
import { randomId } from "../util/randomId";
|
|
13
12
|
import type { StatewireMetaEntry } from "../wire/meta";
|
|
14
13
|
import type { StatewirePath } from "../wire/ops";
|
|
15
|
-
import type {
|
|
16
|
-
StatewireEnvelope,
|
|
17
|
-
StatewireResponse,
|
|
18
|
-
} from "../client/connection";
|
|
14
|
+
import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
|
|
19
15
|
|
|
20
16
|
const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
|
|
21
17
|
const RESULT_TTL_MS = 30_000;
|
|
22
18
|
const RESULT_LIMIT = 256;
|
|
19
|
+
const CLIENT_RECORD_LIMIT = 1024;
|
|
23
20
|
|
|
24
21
|
type WireOp = {
|
|
25
22
|
op: string;
|
|
@@ -31,17 +28,18 @@ type WireOp = {
|
|
|
31
28
|
type Admission = {
|
|
32
29
|
seq: number;
|
|
33
30
|
method: string;
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
applied: boolean;
|
|
32
|
+
appliedSent: boolean;
|
|
36
33
|
settled: boolean;
|
|
37
|
-
release: () => void;
|
|
38
34
|
};
|
|
39
35
|
|
|
40
36
|
type ClientRec = {
|
|
41
37
|
id: string;
|
|
42
38
|
lastSeq: number;
|
|
39
|
+
ctxSeq: number;
|
|
40
|
+
touched: number;
|
|
43
41
|
context: Record<string, unknown> | null;
|
|
44
|
-
results: Map<number, { ts: number;
|
|
42
|
+
results: Map<number, { ts: number; cmd: StatewireCmd }>;
|
|
45
43
|
inflight: Map<number, Admission>;
|
|
46
44
|
};
|
|
47
45
|
|
|
@@ -49,69 +47,140 @@ type Conn = {
|
|
|
49
47
|
clientId: string;
|
|
50
48
|
socket: StatewireHostInternal.Socket;
|
|
51
49
|
open: boolean;
|
|
50
|
+
sawFrame: boolean;
|
|
52
51
|
lastSentAt: number;
|
|
53
52
|
};
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
// ─── frame parsing ──────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const violation = (reason: string): never => {
|
|
57
|
+
throw Object.assign(new Error(reason), { name: "StatewireViolation" });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const violationReason = (error: unknown): string | null =>
|
|
61
|
+
error instanceof Error && error.name === "StatewireViolation"
|
|
62
|
+
? error.message
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
type CtxStatement = { seq: number; value: Record<string, unknown> };
|
|
66
|
+
type CmdStatement = {
|
|
57
67
|
seq: number;
|
|
58
|
-
|
|
59
|
-
|
|
68
|
+
method: string;
|
|
69
|
+
params: unknown;
|
|
70
|
+
meta?: unknown;
|
|
60
71
|
};
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (typeof method !== "string") return null;
|
|
82
|
-
if (!Array.isArray(params)) return null;
|
|
83
|
-
if (meta !== undefined) {
|
|
84
|
-
try {
|
|
85
|
-
reviveValue(params, meta);
|
|
86
|
-
} catch {
|
|
87
|
-
return null;
|
|
73
|
+
type ParsedFrame = {
|
|
74
|
+
headers: Record<string, string> | null;
|
|
75
|
+
ctx: CtxStatement[];
|
|
76
|
+
cmd: CmdStatement[];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
80
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
81
|
+
|
|
82
|
+
const parseLane = (value: unknown): Record<string, unknown>[] => {
|
|
83
|
+
if (!Array.isArray(value)) return violation("malformed frame");
|
|
84
|
+
if (value.length === 0) return violation("empty lane");
|
|
85
|
+
const statements: Record<string, unknown>[] = [];
|
|
86
|
+
let prev: number | null = null;
|
|
87
|
+
for (const statement of value) {
|
|
88
|
+
if (!isPlainObject(statement)) return violation("malformed statement");
|
|
89
|
+
const seq = statement["seq"];
|
|
90
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) {
|
|
91
|
+
return violation("malformed statement");
|
|
88
92
|
}
|
|
93
|
+
if (prev !== null && seq !== prev + 1) return violation("malformed frame");
|
|
94
|
+
prev = seq;
|
|
95
|
+
statements.push(statement);
|
|
89
96
|
}
|
|
90
|
-
return
|
|
97
|
+
return statements;
|
|
91
98
|
};
|
|
92
99
|
|
|
93
|
-
/** v1
|
|
94
|
-
const contextValue = (
|
|
95
|
-
if (!Array.isArray(
|
|
100
|
+
/** v1 ctx statements state the context whole: one root replace with an object value. */
|
|
101
|
+
const contextValue = (ops: unknown): Record<string, unknown> => {
|
|
102
|
+
if (!Array.isArray(ops) || ops.length !== 1) {
|
|
96
103
|
throw new Error("context must be a single-op array");
|
|
97
104
|
}
|
|
98
|
-
const op =
|
|
105
|
+
const op = ops[0] as Record<string, unknown> | null;
|
|
99
106
|
if (
|
|
100
|
-
|
|
101
|
-
op === null ||
|
|
107
|
+
!isPlainObject(op) ||
|
|
102
108
|
op["op"] !== "replace" ||
|
|
103
109
|
!Array.isArray(op["path"]) ||
|
|
104
110
|
op["path"].length !== 0
|
|
105
111
|
) {
|
|
106
112
|
throw new Error("context op must be a root replace");
|
|
107
113
|
}
|
|
108
|
-
const value =
|
|
109
|
-
|
|
114
|
+
const value =
|
|
115
|
+
op["meta"] !== undefined
|
|
116
|
+
? reviveValue(op["value"], op["meta"])
|
|
117
|
+
: op["value"];
|
|
118
|
+
if (!isPlainObject(value)) {
|
|
110
119
|
throw new Error("context value must be an object");
|
|
111
120
|
}
|
|
112
|
-
return value
|
|
121
|
+
return value;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const parseFrame = (raw: string, first: boolean): ParsedFrame => {
|
|
125
|
+
let frame: unknown;
|
|
126
|
+
try {
|
|
127
|
+
frame = JSON.parse(raw);
|
|
128
|
+
} catch {
|
|
129
|
+
return violation("malformed frame");
|
|
130
|
+
}
|
|
131
|
+
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
132
|
+
if (
|
|
133
|
+
frame["headers"] === undefined &&
|
|
134
|
+
frame["ctx"] === undefined &&
|
|
135
|
+
frame["cmd"] === undefined
|
|
136
|
+
) {
|
|
137
|
+
return violation("malformed frame");
|
|
138
|
+
}
|
|
139
|
+
let headers: Record<string, string> | null = null;
|
|
140
|
+
if (frame["headers"] !== undefined) {
|
|
141
|
+
if (!first) return violation("mid-session headers");
|
|
142
|
+
const raw = frame["headers"];
|
|
143
|
+
if (
|
|
144
|
+
!isPlainObject(raw) ||
|
|
145
|
+
Object.values(raw).some((value) => typeof value !== "string")
|
|
146
|
+
) {
|
|
147
|
+
return violation("malformed headers");
|
|
148
|
+
}
|
|
149
|
+
headers = raw as Record<string, string>;
|
|
150
|
+
}
|
|
151
|
+
const ctx: CtxStatement[] = [];
|
|
152
|
+
if (frame["ctx"] !== undefined) {
|
|
153
|
+
for (const statement of parseLane(frame["ctx"])) {
|
|
154
|
+
try {
|
|
155
|
+
ctx.push({
|
|
156
|
+
seq: statement["seq"] as number,
|
|
157
|
+
value: contextValue(statement["ops"]),
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
return violation("invalid-context");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const cmd: CmdStatement[] = [];
|
|
165
|
+
if (frame["cmd"] !== undefined) {
|
|
166
|
+
for (const statement of parseLane(frame["cmd"])) {
|
|
167
|
+
const method = statement["method"];
|
|
168
|
+
if (typeof method !== "string" || !("params" in statement)) {
|
|
169
|
+
return violation("malformed statement");
|
|
170
|
+
}
|
|
171
|
+
cmd.push({
|
|
172
|
+
seq: statement["seq"] as number,
|
|
173
|
+
method,
|
|
174
|
+
params: statement["params"],
|
|
175
|
+
...(statement["meta"] !== undefined && { meta: statement["meta"] }),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { headers, ctx, cmd };
|
|
113
180
|
};
|
|
114
181
|
|
|
182
|
+
// ─── verdict encoding ───────────────────────────────────
|
|
183
|
+
|
|
115
184
|
const encodePayload = (
|
|
116
185
|
payload: unknown,
|
|
117
186
|
): { payload?: unknown; meta?: StatewireMetaEntry[] } => {
|
|
@@ -120,47 +189,43 @@ const encodePayload = (
|
|
|
120
189
|
return { payload: value, ...(meta !== undefined && { meta }) };
|
|
121
190
|
};
|
|
122
191
|
|
|
123
|
-
const
|
|
124
|
-
adm: Admission,
|
|
125
|
-
outcome: CommandsOutcome,
|
|
126
|
-
): StatewireResponse => {
|
|
192
|
+
const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
|
|
127
193
|
if (outcome.type === "accepted") {
|
|
128
|
-
return {
|
|
129
|
-
seq: adm.seq,
|
|
130
|
-
type: "accepted",
|
|
131
|
-
...encodePayload(outcome.payload),
|
|
132
|
-
};
|
|
194
|
+
return { seq: adm.seq, type: "result", ...encodePayload(outcome.payload) };
|
|
133
195
|
}
|
|
134
196
|
const failure =
|
|
135
197
|
outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
|
|
136
198
|
switch (failure?.fate) {
|
|
137
|
-
case "
|
|
199
|
+
case "failed":
|
|
138
200
|
return {
|
|
139
201
|
seq: adm.seq,
|
|
140
|
-
type: "
|
|
141
|
-
|
|
142
|
-
message: `unknown method: '${adm.method}'`,
|
|
202
|
+
type: "failed",
|
|
203
|
+
...(failure.message !== undefined && { message: failure.message }),
|
|
143
204
|
};
|
|
144
|
-
case "
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
205
|
+
case "rejected": {
|
|
206
|
+
if (failure.code !== undefined) {
|
|
207
|
+
const message =
|
|
208
|
+
failure.code === "unknown-command"
|
|
209
|
+
? `unknown method: '${adm.method}'`
|
|
210
|
+
: failure.message;
|
|
211
|
+
return {
|
|
212
|
+
seq: adm.seq,
|
|
213
|
+
type: "rejected",
|
|
214
|
+
code: failure.code,
|
|
215
|
+
...(message !== undefined && { message }),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
154
218
|
return {
|
|
155
219
|
seq: adm.seq,
|
|
156
220
|
type: "rejected",
|
|
157
|
-
message: failure.message,
|
|
221
|
+
...(failure.message !== undefined && { message: failure.message }),
|
|
158
222
|
...encodePayload(failure.payload),
|
|
159
223
|
};
|
|
224
|
+
}
|
|
160
225
|
default:
|
|
161
226
|
return {
|
|
162
227
|
seq: adm.seq,
|
|
163
|
-
type: "
|
|
228
|
+
type: "failed",
|
|
164
229
|
message:
|
|
165
230
|
outcome.error instanceof Error
|
|
166
231
|
? outcome.error.message
|
|
@@ -169,425 +234,564 @@ const responseOf = (
|
|
|
169
234
|
}
|
|
170
235
|
};
|
|
171
236
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
237
|
+
// ─── host ───────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
type Host = {
|
|
240
|
+
heartbeatMs: number;
|
|
241
|
+
clientRecordLimit: number;
|
|
242
|
+
finOnIdle: boolean;
|
|
243
|
+
clients: Map<string, ClientRec>;
|
|
244
|
+
conns: Set<Conn>;
|
|
245
|
+
pendingOps: WireOp[];
|
|
246
|
+
pendingApplied: Map<string, number[]>;
|
|
247
|
+
pendingCmds: Map<string, StatewireCmd[]>;
|
|
248
|
+
idleWire: boolean;
|
|
249
|
+
finished: StatewireHost.Fin | null;
|
|
250
|
+
disposed: boolean;
|
|
251
|
+
flushScheduled: boolean;
|
|
252
|
+
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
|
253
|
+
stateHost: StatewireHost.StateHost | null;
|
|
254
|
+
commandsHost: CommandsHost | null;
|
|
255
|
+
current: Statewire<unknown> | null;
|
|
256
|
+
post: () => void;
|
|
257
|
+
connect: (
|
|
258
|
+
clientId: string,
|
|
259
|
+
socket: StatewireHostInternal.Socket,
|
|
260
|
+
) => StatewireHostInternal.SocketConnection;
|
|
261
|
+
finish: (fin: StatewireHost.Fin) => void;
|
|
262
|
+
};
|
|
192
263
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (flushScheduled || disposed) return;
|
|
199
|
-
flushScheduled = true;
|
|
200
|
-
post();
|
|
201
|
-
};
|
|
264
|
+
const scheduleFlush = (h: Host) => {
|
|
265
|
+
if (h.flushScheduled || h.disposed) return;
|
|
266
|
+
h.flushScheduled = true;
|
|
267
|
+
h.post();
|
|
268
|
+
};
|
|
202
269
|
|
|
203
|
-
|
|
270
|
+
const recordOp = (
|
|
271
|
+
h: Host,
|
|
272
|
+
op: {
|
|
204
273
|
op: string;
|
|
205
274
|
path?: StatewirePath;
|
|
206
275
|
value?: unknown;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
276
|
+
},
|
|
277
|
+
) => {
|
|
278
|
+
if (op.op === "replace" || op.op === "add") {
|
|
279
|
+
const { value, meta } = encodeValue(op.value);
|
|
280
|
+
h.pendingOps.push({ ...op, value, ...(meta !== undefined && { meta }) });
|
|
281
|
+
} else {
|
|
282
|
+
h.pendingOps.push(op);
|
|
283
|
+
}
|
|
284
|
+
scheduleFlush(h);
|
|
285
|
+
};
|
|
216
286
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (nextEmit !== emitHost) {
|
|
238
|
-
emitHost?.setSink(null);
|
|
239
|
-
emitHost = nextEmit;
|
|
240
|
-
emitHost.setSink((event) => recordOp({ op: "event", value: event }));
|
|
241
|
-
}
|
|
242
|
-
};
|
|
287
|
+
const attach = (h: Host, next: Statewire<unknown>) => {
|
|
288
|
+
if (next === h.current) return;
|
|
289
|
+
h.current = next;
|
|
290
|
+
const nextState =
|
|
291
|
+
typeof next.state === "object" && next.state !== null
|
|
292
|
+
? getStateHost(next.state)
|
|
293
|
+
: undefined;
|
|
294
|
+
const nextCommands = getCommandsHost(next.commands);
|
|
295
|
+
if (!nextState || !nextCommands?.submit) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
"StatewireSocketHost requires a statewire/host transport (useStatewireState with object state, useStatewireCommands)",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (nextState !== h.stateHost) {
|
|
301
|
+
h.stateHost?.setOnOp(null);
|
|
302
|
+
h.stateHost = nextState;
|
|
303
|
+
h.stateHost.setOnOp((op) => recordOp(h, op));
|
|
304
|
+
}
|
|
305
|
+
h.commandsHost = nextCommands;
|
|
306
|
+
};
|
|
243
307
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
308
|
+
const closeConn = (h: Host, conn: Conn, code?: number, reason?: string) => {
|
|
309
|
+
if (!conn.open) return;
|
|
310
|
+
conn.open = false;
|
|
311
|
+
h.conns.delete(conn);
|
|
312
|
+
try {
|
|
313
|
+
conn.socket.close(code, reason);
|
|
314
|
+
} catch {
|
|
315
|
+
/* peer already gone */
|
|
316
|
+
}
|
|
317
|
+
if (h.conns.size === 0 && h.heartbeatTimer !== null) {
|
|
318
|
+
clearInterval(h.heartbeatTimer);
|
|
319
|
+
h.heartbeatTimer = null;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
258
322
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
323
|
+
const sendTo = (h: Host, conn: Conn, data: string) => {
|
|
324
|
+
if (!conn.open) return;
|
|
325
|
+
conn.lastSentAt = Date.now();
|
|
326
|
+
try {
|
|
327
|
+
conn.socket.send(data);
|
|
328
|
+
} catch {
|
|
329
|
+
closeConn(h, conn);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const ensureHeartbeat = (h: Host) => {
|
|
334
|
+
if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
|
|
335
|
+
h.heartbeatTimer = setInterval(() => {
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
for (const conn of [...h.conns]) {
|
|
338
|
+
if (now - conn.lastSentAt >= h.heartbeatMs) sendTo(h, conn, "{}");
|
|
266
339
|
}
|
|
267
|
-
};
|
|
340
|
+
}, h.heartbeatMs);
|
|
341
|
+
(h.heartbeatTimer as unknown as { unref?: () => void }).unref?.();
|
|
342
|
+
};
|
|
268
343
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
};
|
|
344
|
+
const setHeartbeatMs = (h: Host, ms: number) => {
|
|
345
|
+
if (ms === h.heartbeatMs) return;
|
|
346
|
+
h.heartbeatMs = ms;
|
|
347
|
+
if (h.heartbeatTimer !== null) {
|
|
348
|
+
clearInterval(h.heartbeatTimer);
|
|
349
|
+
h.heartbeatTimer = null;
|
|
350
|
+
ensureHeartbeat(h);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
279
353
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
354
|
+
const idleLevel = (h: Host): boolean => {
|
|
355
|
+
for (const rec of h.clients.values()) {
|
|
356
|
+
if (rec.inflight.size > 0) return false;
|
|
357
|
+
}
|
|
358
|
+
return true;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const flush = (h: Host, fin?: StatewireHost.Fin) => {
|
|
362
|
+
flushTapSync(() => {});
|
|
363
|
+
h.stateHost?.takeChanged();
|
|
364
|
+
for (const rec of h.clients.values()) {
|
|
365
|
+
for (const adm of rec.inflight.values()) {
|
|
366
|
+
if (adm.applied && !adm.settled && !adm.appliedSent) {
|
|
367
|
+
adm.appliedSent = true;
|
|
368
|
+
queueApplied(h, rec.id, adm.seq);
|
|
289
369
|
}
|
|
290
370
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
371
|
+
}
|
|
372
|
+
const level = idleLevel(h);
|
|
373
|
+
const edge = level !== h.idleWire;
|
|
374
|
+
if (
|
|
375
|
+
h.pendingOps.length === 0 &&
|
|
376
|
+
h.pendingApplied.size === 0 &&
|
|
377
|
+
h.pendingCmds.size === 0 &&
|
|
378
|
+
fin === undefined &&
|
|
379
|
+
!edge
|
|
380
|
+
) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
h.idleWire = level;
|
|
384
|
+
if (fin === undefined && edge && level && h.finOnIdle) {
|
|
385
|
+
fin = { reason: "idle" };
|
|
386
|
+
}
|
|
387
|
+
const ops = h.pendingOps;
|
|
388
|
+
const applied = h.pendingApplied;
|
|
389
|
+
const cmds = h.pendingCmds;
|
|
390
|
+
h.pendingOps = [];
|
|
391
|
+
h.pendingApplied = new Map();
|
|
392
|
+
h.pendingCmds = new Map();
|
|
393
|
+
// Applied entries anchor to their effects: piggybacked on the ops packet
|
|
394
|
+
// when one is flushing, standalone otherwise. Terminal verdicts ride their
|
|
395
|
+
// own packet.
|
|
396
|
+
for (const conn of [...h.conns]) {
|
|
397
|
+
const appliedEntries: StatewireCmd[] = (
|
|
398
|
+
applied.get(conn.clientId) ?? []
|
|
399
|
+
).map((seq) => ({ seq, type: "applied" }));
|
|
400
|
+
if (ops.length > 0) {
|
|
401
|
+
const packet: StatewireEnvelope = {
|
|
402
|
+
ops,
|
|
403
|
+
...(appliedEntries.length > 0 && { cmd: appliedEntries }),
|
|
404
|
+
...(edge && { idle: level }),
|
|
405
|
+
};
|
|
406
|
+
sendTo(h, conn, JSON.stringify(packet));
|
|
407
|
+
} else if (appliedEntries.length > 0) {
|
|
408
|
+
sendTo(h, conn, JSON.stringify({ cmd: appliedEntries }));
|
|
298
409
|
}
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
pendingOps = [];
|
|
303
|
-
dirtyAcks = new Set();
|
|
304
|
-
pendingRes = new Map();
|
|
305
|
-
for (const conn of [...conns]) {
|
|
306
|
-
const envelope: StatewireEnvelope = {};
|
|
307
|
-
if (ops.length > 0) envelope.ops = ops;
|
|
308
|
-
const clientRes = res.get(conn.clientId);
|
|
309
|
-
if (clientRes !== undefined) envelope.res = clientRes;
|
|
310
|
-
if (acks.has(conn.clientId)) {
|
|
311
|
-
const rec = clients.get(conn.clientId);
|
|
312
|
-
if (rec !== undefined) envelope.ack = rec.lastSeq;
|
|
313
|
-
}
|
|
314
|
-
if (fin !== undefined) envelope.fin = fin;
|
|
315
|
-
if (Object.keys(envelope).length === 0) continue;
|
|
316
|
-
sendTo(conn, JSON.stringify(envelope));
|
|
317
|
-
if (fin !== undefined) closeConn(conn);
|
|
410
|
+
const verdicts = cmds.get(conn.clientId) ?? [];
|
|
411
|
+
if (verdicts.length > 0) {
|
|
412
|
+
sendTo(h, conn, JSON.stringify({ cmd: verdicts }));
|
|
318
413
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const queueRes = (clientId: string, response: StatewireResponse) => {
|
|
322
|
-
const list = pendingRes.get(clientId);
|
|
323
|
-
if (list) list.push(response);
|
|
324
|
-
else pendingRes.set(clientId, [response]);
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
const advance = (rec: ClientRec, seq: number) => {
|
|
328
|
-
rec.lastSeq = seq;
|
|
329
|
-
dirtyAcks.add(rec.id);
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
const retain = (rec: ClientRec, response: StatewireResponse) => {
|
|
333
|
-
const now = Date.now();
|
|
334
|
-
rec.results.set(response.seq, { ts: now, res: response });
|
|
335
|
-
const newest = Math.max(...rec.results.keys());
|
|
336
|
-
for (const [seq, entry] of rec.results) {
|
|
337
|
-
if (seq !== newest && now - entry.ts > RESULT_TTL_MS) {
|
|
338
|
-
rec.results.delete(seq);
|
|
339
|
-
}
|
|
414
|
+
if (edge && ops.length === 0) {
|
|
415
|
+
sendTo(h, conn, JSON.stringify({ idle: level }));
|
|
340
416
|
}
|
|
341
|
-
|
|
342
|
-
|
|
417
|
+
if (fin !== undefined) {
|
|
418
|
+
sendTo(h, conn, JSON.stringify({ fin }));
|
|
419
|
+
closeConn(h, conn);
|
|
343
420
|
}
|
|
344
|
-
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
345
423
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
424
|
+
const queueApplied = (h: Host, clientId: string, seq: number) => {
|
|
425
|
+
const list = h.pendingApplied.get(clientId);
|
|
426
|
+
if (list) list.push(seq);
|
|
427
|
+
else h.pendingApplied.set(clientId, [seq]);
|
|
428
|
+
scheduleFlush(h);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const queueCmd = (h: Host, clientId: string, cmd: StatewireCmd) => {
|
|
432
|
+
const list = h.pendingCmds.get(clientId);
|
|
433
|
+
if (list) list.push(cmd);
|
|
434
|
+
else h.pendingCmds.set(clientId, [cmd]);
|
|
435
|
+
scheduleFlush(h);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const retain = (rec: ClientRec, cmd: StatewireCmd) => {
|
|
439
|
+
const now = Date.now();
|
|
440
|
+
rec.results.set(cmd.seq, { ts: now, cmd });
|
|
441
|
+
const newest = Math.max(...rec.results.keys());
|
|
442
|
+
for (const [seq, entry] of rec.results) {
|
|
443
|
+
if (seq !== newest && now - entry.ts > RESULT_TTL_MS) {
|
|
353
444
|
rec.results.delete(seq);
|
|
354
445
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
446
|
+
}
|
|
447
|
+
while (rec.results.size > RESULT_LIMIT) {
|
|
448
|
+
rec.results.delete(Math.min(...rec.results.keys()));
|
|
449
|
+
}
|
|
450
|
+
};
|
|
358
451
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
adm.pendingSent ||
|
|
374
|
-
response.type !== "accepted" ||
|
|
375
|
-
response.payload !== undefined
|
|
376
|
-
) {
|
|
377
|
-
queueRes(rec.id, response);
|
|
452
|
+
/** Cmd-lane watermark: the last statement admitted, settled or not. */
|
|
453
|
+
const admittedThrough = (rec: ClientRec): number =>
|
|
454
|
+
Math.max(rec.lastSeq, ...rec.inflight.keys());
|
|
455
|
+
|
|
456
|
+
// A re-asked seq never re-runs: the stored verdict re-emits, an applied
|
|
457
|
+
// inflight run re-answers applied; one not yet applied answers nothing (its
|
|
458
|
+
// answers ride the stream when ready), an evicted verdict answers expired.
|
|
459
|
+
const reemit = (h: Host, rec: ClientRec, seq: number) => {
|
|
460
|
+
const cached = rec.results.get(seq);
|
|
461
|
+
if (cached) {
|
|
462
|
+
const newest = Math.max(...rec.results.keys());
|
|
463
|
+
if (seq === newest || Date.now() - cached.ts <= RESULT_TTL_MS) {
|
|
464
|
+
queueCmd(h, rec.id, cached.cmd);
|
|
465
|
+
return;
|
|
378
466
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
467
|
+
rec.results.delete(seq);
|
|
468
|
+
}
|
|
469
|
+
const adm = rec.inflight.get(seq);
|
|
470
|
+
if (adm) {
|
|
471
|
+
if (adm.applied) queueApplied(h, rec.id, seq);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
queueCmd(h, rec.id, { seq, type: "expired" });
|
|
475
|
+
};
|
|
382
476
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
477
|
+
const settleCmd = (
|
|
478
|
+
h: Host,
|
|
479
|
+
rec: ClientRec,
|
|
480
|
+
adm: Admission,
|
|
481
|
+
cmd: StatewireCmd,
|
|
482
|
+
) => {
|
|
483
|
+
if (adm.settled) return;
|
|
484
|
+
adm.settled = true;
|
|
485
|
+
rec.inflight.delete(adm.seq);
|
|
486
|
+
retain(rec, cmd);
|
|
487
|
+
rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
|
|
488
|
+
adm.applied = true;
|
|
489
|
+
queueCmd(h, rec.id, cmd);
|
|
490
|
+
};
|
|
388
491
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
492
|
+
const settleWire = (
|
|
493
|
+
h: Host,
|
|
494
|
+
rec: ClientRec,
|
|
495
|
+
adm: Admission,
|
|
496
|
+
outcome: CommandsOutcome,
|
|
497
|
+
) => settleCmd(h, rec, adm, cmdOf(adm, outcome));
|
|
498
|
+
|
|
499
|
+
const callerOf = (h: Host, rec: ClientRec): StatewireHost.Caller => ({
|
|
500
|
+
clientId: rec.id,
|
|
501
|
+
get context() {
|
|
502
|
+
return rec.context;
|
|
503
|
+
},
|
|
504
|
+
get isConnected() {
|
|
505
|
+
if (h.clients.get(rec.id) !== rec) return false;
|
|
506
|
+
for (const conn of h.conns) {
|
|
507
|
+
if (conn.clientId === rec.id && conn.open) return true;
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const rejectStatement = (
|
|
514
|
+
h: Host,
|
|
515
|
+
rec: ClientRec,
|
|
516
|
+
adm: Admission,
|
|
517
|
+
message: string,
|
|
518
|
+
) =>
|
|
519
|
+
settleCmd(h, rec, adm, {
|
|
520
|
+
seq: adm.seq,
|
|
521
|
+
type: "rejected",
|
|
522
|
+
code: "invalid-params",
|
|
523
|
+
message,
|
|
401
524
|
});
|
|
402
525
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
526
|
+
const admitCmd = (h: Host, rec: ClientRec, statement: CmdStatement) => {
|
|
527
|
+
const adm: Admission = {
|
|
528
|
+
seq: statement.seq,
|
|
529
|
+
method: statement.method,
|
|
530
|
+
applied: false,
|
|
531
|
+
appliedSent: false,
|
|
532
|
+
settled: false,
|
|
533
|
+
};
|
|
534
|
+
rec.inflight.set(statement.seq, adm);
|
|
535
|
+
if (!Array.isArray(statement.params)) {
|
|
536
|
+
rejectStatement(h, rec, adm, "params must be an array");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
let params = statement.params;
|
|
540
|
+
if (statement.meta !== undefined) {
|
|
541
|
+
try {
|
|
542
|
+
params = reviveValue(params, statement.meta) as unknown[];
|
|
543
|
+
} catch (error) {
|
|
544
|
+
rejectStatement(
|
|
545
|
+
h,
|
|
546
|
+
rec,
|
|
547
|
+
adm,
|
|
548
|
+
`invalid params: ${(error as Error).message}`,
|
|
416
549
|
);
|
|
417
550
|
return;
|
|
418
551
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
pendingSent: false,
|
|
424
|
-
settled: false,
|
|
425
|
-
release: () => {},
|
|
426
|
-
};
|
|
427
|
-
if (frame.context !== undefined) {
|
|
428
|
-
let value: Record<string, unknown>;
|
|
429
|
-
try {
|
|
430
|
-
value = contextValue(frame.context);
|
|
431
|
-
} catch (error) {
|
|
432
|
-
rec.inflight.set(frame.seq, adm);
|
|
433
|
-
settleResponse(rec, adm, {
|
|
434
|
-
seq: frame.seq,
|
|
435
|
-
type: "rejected",
|
|
436
|
-
code: "invalid-context",
|
|
437
|
-
message: `invalid context: ${(error as Error).message}`,
|
|
438
|
-
});
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
rec.context = value;
|
|
442
|
-
if (frame.method === undefined) {
|
|
443
|
-
rec.inflight.set(frame.seq, adm);
|
|
444
|
-
settleResponse(rec, adm, { seq: frame.seq, type: "accepted" });
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
const released = new Promise<void>((resolve) => {
|
|
449
|
-
adm.release = resolve;
|
|
450
|
-
});
|
|
451
|
-
rec.inflight.set(frame.seq, adm);
|
|
452
|
-
commandsHost!.submit!({
|
|
453
|
-
method: frame.method!,
|
|
454
|
-
params: frame.params,
|
|
455
|
-
clientId: rec.id,
|
|
456
|
-
caller: callerOf(rec),
|
|
457
|
-
onAck: () => {
|
|
458
|
-
if (adm.settled || adm.acked) return;
|
|
459
|
-
adm.acked = true;
|
|
460
|
-
advance(rec, adm.seq);
|
|
461
|
-
scheduleFlush();
|
|
462
|
-
adm.release();
|
|
463
|
-
},
|
|
464
|
-
onSettle: (outcome) => settleWire(rec, adm, outcome),
|
|
465
|
-
}).catch((error: unknown) => {
|
|
466
|
-
if (!adm.settled) settleWire(rec, adm, { type: "rejected", error });
|
|
467
|
-
});
|
|
468
|
-
return released;
|
|
469
|
-
};
|
|
470
|
-
|
|
471
|
-
const message = (conn: Conn, data: string) => {
|
|
472
|
-
if (!conn.open || disposed || finished !== null) return;
|
|
473
|
-
const frame = parseFrame(data);
|
|
474
|
-
if (frame === null) {
|
|
475
|
-
closeConn(conn, 1008, "invalid frame");
|
|
552
|
+
}
|
|
553
|
+
if (statement.method === "statewire/commands") {
|
|
554
|
+
if (params.length > 0) {
|
|
555
|
+
rejectStatement(h, rec, adm, "statewire/commands takes no params");
|
|
476
556
|
return;
|
|
477
557
|
}
|
|
478
|
-
|
|
479
|
-
|
|
558
|
+
const names = [
|
|
559
|
+
...(h.commandsHost?.methods?.() ?? []),
|
|
560
|
+
"statewire/commands",
|
|
561
|
+
].sort();
|
|
562
|
+
settleCmd(h, rec, adm, { seq: adm.seq, type: "result", payload: names });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
h.commandsHost!.submit!({
|
|
566
|
+
method: statement.method,
|
|
567
|
+
params,
|
|
568
|
+
clientId: rec.id,
|
|
569
|
+
caller: callerOf(h, rec),
|
|
570
|
+
onApplied: () => {
|
|
571
|
+
if (adm.settled || adm.applied) return;
|
|
572
|
+
adm.applied = true;
|
|
573
|
+
rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
|
|
574
|
+
scheduleFlush(h);
|
|
575
|
+
},
|
|
576
|
+
onSettle: (outcome) => settleWire(h, rec, adm, outcome),
|
|
577
|
+
}).catch((error: unknown) => {
|
|
578
|
+
if (!adm.settled) settleWire(h, rec, adm, { type: "rejected", error });
|
|
579
|
+
});
|
|
580
|
+
};
|
|
480
581
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
582
|
+
// One admission law, both lanes: absorbed statements answer from the record,
|
|
583
|
+
// then the new suffix is admitted whole or the frame is refused whole via a
|
|
584
|
+
// {syn} stating both watermarks, nothing consumed. A gap is never a close.
|
|
585
|
+
const admitFrame = (h: Host, conn: Conn, frame: ParsedFrame) => {
|
|
586
|
+
const rec = h.clients.get(conn.clientId)!;
|
|
587
|
+
rec.touched = Date.now();
|
|
588
|
+
const through = admittedThrough(rec);
|
|
589
|
+
for (const statement of frame.cmd) {
|
|
590
|
+
if (statement.seq <= through) reemit(h, rec, statement.seq);
|
|
591
|
+
}
|
|
592
|
+
const ctxNew = frame.ctx.filter((s) => s.seq > rec.ctxSeq);
|
|
593
|
+
const cmdNew = frame.cmd.filter((s) => s.seq > through);
|
|
594
|
+
if (
|
|
595
|
+
(ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
|
|
596
|
+
(cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
|
|
597
|
+
) {
|
|
598
|
+
sendTo(
|
|
599
|
+
h,
|
|
600
|
+
conn,
|
|
601
|
+
JSON.stringify({ syn: { seq: through, ctxSeq: rec.ctxSeq } }),
|
|
602
|
+
);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
for (const statement of ctxNew) {
|
|
606
|
+
rec.context = statement.value;
|
|
607
|
+
rec.ctxSeq = statement.seq;
|
|
608
|
+
}
|
|
609
|
+
for (const statement of cmdNew) admitCmd(h, rec, statement);
|
|
610
|
+
if (cmdNew.length > 0) {
|
|
611
|
+
// Memory host: admitted means retained — dur rides the admitting flush.
|
|
612
|
+
queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
|
|
613
|
+
}
|
|
614
|
+
scheduleFlush(h);
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const message = (h: Host, conn: Conn, data: string) => {
|
|
618
|
+
if (!conn.open || h.disposed || h.finished !== null) return;
|
|
619
|
+
const first = !conn.sawFrame;
|
|
620
|
+
conn.sawFrame = true;
|
|
621
|
+
let frame: ParsedFrame;
|
|
622
|
+
try {
|
|
623
|
+
frame = parseFrame(data, first);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
const reason = violationReason(error);
|
|
626
|
+
if (reason === null) throw error;
|
|
627
|
+
closeConn(h, conn, 1008, reason);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
|
|
631
|
+
admitFrame(h, conn, frame);
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const gcClients = (h: Host) => {
|
|
635
|
+
let excess = h.clients.size - h.clientRecordLimit;
|
|
636
|
+
if (excess <= 0) return;
|
|
637
|
+
const live = new Set([...h.conns].map((conn) => conn.clientId));
|
|
638
|
+
const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
|
|
639
|
+
for (const rec of oldest) {
|
|
640
|
+
if (excess <= 0) return;
|
|
641
|
+
if (live.has(rec.id) || rec.inflight.size > 0) continue;
|
|
642
|
+
h.clients.delete(rec.id);
|
|
643
|
+
excess -= 1;
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const connect = (
|
|
648
|
+
h: Host,
|
|
649
|
+
clientId: string,
|
|
650
|
+
socket: StatewireHostInternal.Socket,
|
|
651
|
+
): StatewireHostInternal.SocketConnection => {
|
|
652
|
+
const dead: StatewireHostInternal.SocketConnection = {
|
|
653
|
+
clientId,
|
|
654
|
+
message: () => {},
|
|
655
|
+
close: () => {},
|
|
656
|
+
};
|
|
657
|
+
const refuse = (code: number, reason: string) => {
|
|
658
|
+
try {
|
|
659
|
+
socket.close(code, reason);
|
|
660
|
+
} catch {
|
|
661
|
+
/* peer already gone */
|
|
508
662
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
};
|
|
520
|
-
clients.set(clientId, created);
|
|
521
|
-
return created;
|
|
522
|
-
})();
|
|
523
|
-
for (const other of [...conns]) {
|
|
524
|
-
if (other.clientId !== clientId) continue;
|
|
525
|
-
sendTo(other, JSON.stringify({ fin: { reason: "superseded" } }));
|
|
526
|
-
closeConn(other);
|
|
663
|
+
return dead;
|
|
664
|
+
};
|
|
665
|
+
if (h.disposed) return refuse(1001, "disposed");
|
|
666
|
+
if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
|
|
667
|
+
flush(h);
|
|
668
|
+
if (h.finished !== null) {
|
|
669
|
+
try {
|
|
670
|
+
socket.send(JSON.stringify({ fin: h.finished }));
|
|
671
|
+
} catch {
|
|
672
|
+
/* peer already gone */
|
|
527
673
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
{
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
674
|
+
return refuse(1000, "finished");
|
|
675
|
+
}
|
|
676
|
+
const known = h.clients.has(clientId);
|
|
677
|
+
const rec = known
|
|
678
|
+
? h.clients.get(clientId)!
|
|
679
|
+
: (() => {
|
|
680
|
+
const created: ClientRec = {
|
|
681
|
+
id: clientId,
|
|
682
|
+
lastSeq: 0,
|
|
683
|
+
ctxSeq: 0,
|
|
684
|
+
touched: Date.now(),
|
|
685
|
+
context: null,
|
|
686
|
+
results: new Map(),
|
|
687
|
+
inflight: new Map(),
|
|
688
|
+
};
|
|
689
|
+
h.clients.set(clientId, created);
|
|
690
|
+
return created;
|
|
691
|
+
})();
|
|
692
|
+
rec.touched = Date.now();
|
|
693
|
+
for (const other of [...h.conns]) {
|
|
694
|
+
if (other.clientId !== clientId) continue;
|
|
695
|
+
sendTo(h, other, JSON.stringify({ fin: { reason: "superseded" } }));
|
|
696
|
+
closeConn(h, other);
|
|
697
|
+
}
|
|
698
|
+
const conn: Conn = {
|
|
699
|
+
clientId,
|
|
700
|
+
socket,
|
|
701
|
+
open: true,
|
|
702
|
+
sawFrame: false,
|
|
703
|
+
lastSentAt: 0,
|
|
549
704
|
};
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
705
|
+
h.conns.add(conn);
|
|
706
|
+
gcClients(h);
|
|
707
|
+
ensureHeartbeat(h);
|
|
708
|
+
const { value, meta } = encodeValue(h.stateHost!.snapshot());
|
|
709
|
+
const hello: StatewireEnvelope = {
|
|
710
|
+
ops: [
|
|
711
|
+
{
|
|
712
|
+
op: "replace",
|
|
713
|
+
path: [],
|
|
714
|
+
value,
|
|
715
|
+
...(meta !== undefined && { meta }),
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
syn: {
|
|
719
|
+
seq: admittedThrough(rec),
|
|
720
|
+
ctxSeq: rec.ctxSeq,
|
|
721
|
+
record: known ? "resumed" : "fresh",
|
|
722
|
+
...(h.idleWire && { idle: true }),
|
|
723
|
+
},
|
|
555
724
|
};
|
|
556
|
-
|
|
725
|
+
sendTo(h, conn, JSON.stringify(hello));
|
|
557
726
|
return {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
resume: () => {
|
|
562
|
-
disposed = false;
|
|
563
|
-
stateHost?.setOnOp(recordOp);
|
|
564
|
-
emitHost?.setSink((event) => recordOp({ op: "event", value: event }));
|
|
565
|
-
},
|
|
566
|
-
suspend: () => {
|
|
567
|
-
disposed = true;
|
|
568
|
-
if (heartbeatTimer !== null) {
|
|
569
|
-
clearInterval(heartbeatTimer);
|
|
570
|
-
heartbeatTimer = null;
|
|
571
|
-
}
|
|
572
|
-
stateHost?.setOnOp(null);
|
|
573
|
-
emitHost?.setSink(null);
|
|
574
|
-
},
|
|
727
|
+
clientId,
|
|
728
|
+
message: (data) => message(h, conn, data),
|
|
729
|
+
close: () => closeConn(h, conn),
|
|
575
730
|
};
|
|
576
731
|
};
|
|
577
732
|
|
|
733
|
+
const finish = (h: Host, fin: StatewireHost.Fin) => {
|
|
734
|
+
if (h.finished !== null || h.disposed) return;
|
|
735
|
+
h.finished = fin;
|
|
736
|
+
flush(h, fin);
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const resume = (h: Host) => {
|
|
740
|
+
h.disposed = false;
|
|
741
|
+
h.stateHost?.setOnOp((op) => recordOp(h, op));
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
const suspend = (h: Host) => {
|
|
745
|
+
h.disposed = true;
|
|
746
|
+
if (h.heartbeatTimer !== null) {
|
|
747
|
+
clearInterval(h.heartbeatTimer);
|
|
748
|
+
h.heartbeatTimer = null;
|
|
749
|
+
}
|
|
750
|
+
h.stateHost?.setOnOp(null);
|
|
751
|
+
};
|
|
752
|
+
|
|
578
753
|
const useSocketHost = (
|
|
579
754
|
transport: ResourceElement<Statewire>,
|
|
580
755
|
options?: StatewireHostInternal.SocketHostOptions,
|
|
581
756
|
): Pick<StatewireHostInternal.SocketHost, "connect" | "finish"> => {
|
|
582
|
-
const [h] = useState(() =>
|
|
757
|
+
const [h] = useState((): Host => {
|
|
758
|
+
const host: Host = {
|
|
759
|
+
heartbeatMs: 15_000,
|
|
760
|
+
clientRecordLimit: CLIENT_RECORD_LIMIT,
|
|
761
|
+
finOnIdle: false,
|
|
762
|
+
clients: new Map(),
|
|
763
|
+
conns: new Set(),
|
|
764
|
+
pendingOps: [],
|
|
765
|
+
pendingApplied: new Map(),
|
|
766
|
+
pendingCmds: new Map(),
|
|
767
|
+
idleWire: true,
|
|
768
|
+
finished: null,
|
|
769
|
+
disposed: false,
|
|
770
|
+
flushScheduled: false,
|
|
771
|
+
heartbeatTimer: null,
|
|
772
|
+
stateHost: null,
|
|
773
|
+
commandsHost: null,
|
|
774
|
+
current: null,
|
|
775
|
+
post: createMacrotaskPoster(() => {
|
|
776
|
+
host.flushScheduled = false;
|
|
777
|
+
flush(host);
|
|
778
|
+
}),
|
|
779
|
+
connect: (clientId, socket) => connect(host, clientId, socket),
|
|
780
|
+
finish: (fin) => finish(host, fin),
|
|
781
|
+
};
|
|
782
|
+
return host;
|
|
783
|
+
});
|
|
784
|
+
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
|
|
785
|
+
h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
|
|
786
|
+
h.finOnIdle = options?.finOnIdle ?? false;
|
|
583
787
|
const value = useResource(transport) as Statewire<unknown>;
|
|
584
|
-
|
|
788
|
+
attach(h, value);
|
|
585
789
|
useEffect(() => {
|
|
586
|
-
|
|
790
|
+
attach(h, value);
|
|
587
791
|
});
|
|
588
792
|
useEffect(() => {
|
|
589
|
-
|
|
590
|
-
return () =>
|
|
793
|
+
resume(h);
|
|
794
|
+
return () => suspend(h);
|
|
591
795
|
}, [h]);
|
|
592
796
|
return h;
|
|
593
797
|
};
|
|
@@ -595,8 +799,9 @@ const useSocketHost = (
|
|
|
595
799
|
/**
|
|
596
800
|
* Transport-agnostic WS binding over a statewire/host transport:
|
|
597
801
|
* feed it sockets (anything with `send`/`close`) and raw text frames, and it
|
|
598
|
-
* speaks the Statewire
|
|
599
|
-
*
|
|
802
|
+
* speaks the Statewire packet protocol — snapshot-first hello with `syn`,
|
|
803
|
+
* statement-lane client frames under one admission law, `{cmd}` answer
|
|
804
|
+
* arrays, per-client-id supersede, idle edges, and `fin` on finish.
|
|
600
805
|
*/
|
|
601
806
|
export const StatewireSocketHost = (
|
|
602
807
|
transport: ResourceElement<Statewire>,
|