statewire 0.13.1 → 0.15.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 +29 -78
- package/dist/client/connection.d.ts +20 -6
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +120 -52
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +3 -2
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +10 -4
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +2 -0
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +97 -17
- package/dist/client/session.js.map +1 -1
- package/dist/host/adapter.d.ts +1 -1
- package/dist/host/adapter.d.ts.map +1 -1
- package/dist/host/adapter.js +7 -1
- package/dist/host/adapter.js.map +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +45 -5
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts +2 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +108 -47
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +33 -8
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +388 -58
- package/dist/host/socket.js.map +1 -1
- package/dist/host/work.d.ts +7 -0
- package/dist/host/work.d.ts.map +1 -0
- package/dist/host/work.js +53 -0
- package/dist/host/work.js.map +1 -0
- package/dist/host-internal.d.ts +42 -4
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host-internal.js +2 -1
- package/dist/host.d.ts +16 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/statewire.d.ts +21 -11
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +2 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts +1 -4
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +47 -56
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.js +2 -2
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +54 -41
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/documents.d.ts +20 -0
- package/dist/wire/documents.d.ts.map +1 -0
- package/dist/wire/documents.js +74 -0
- package/dist/wire/documents.js.map +1 -0
- package/dist/wire/limits.d.ts +10 -0
- package/dist/wire/limits.d.ts.map +1 -0
- package/dist/wire/limits.js +11 -0
- package/dist/wire/limits.js.map +1 -0
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +1 -1
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -2
- package/dist/wire/ops.js.map +1 -1
- package/dist/wire/version.d.ts +7 -0
- package/dist/wire/version.d.ts.map +1 -0
- package/dist/wire/version.js +8 -0
- package/dist/wire/version.js.map +1 -0
- package/package.json +1 -1
- package/src/client/connection.ts +213 -67
- package/src/client/core.ts +10 -3
- package/src/client/session.ts +106 -30
- package/src/host/adapter.ts +10 -2
- package/src/host/host.ts +54 -4
- package/src/host/http.ts +168 -64
- package/src/host/socket.ts +603 -60
- package/src/host/work.ts +50 -0
- package/src/host-internal.ts +44 -3
- package/src/host.ts +18 -2
- package/src/statewire.ts +33 -10
- package/src/transports/sse.ts +60 -68
- package/src/transports/testing.ts +2 -2
- package/src/transports/ws.ts +29 -48
- package/src/types.ts +1 -0
- package/src/wire/documents.ts +109 -0
- package/src/wire/limits.ts +6 -0
- package/src/wire/meta.ts +6 -1
- package/src/wire/ops.ts +1 -2
- package/src/wire/version.ts +3 -0
package/src/host/socket.ts
CHANGED
|
@@ -8,6 +8,14 @@ import type { Statewire } from "../types";
|
|
|
8
8
|
import type { StatewireHost } from "../host";
|
|
9
9
|
import type { StatewireHostInternal } from "../host-internal";
|
|
10
10
|
import { encodeValue, reviveValue } from "../wire/meta";
|
|
11
|
+
import {
|
|
12
|
+
COMMAND_CAPACITY_RETRY_MS,
|
|
13
|
+
MAX_COMMANDS_PER_FRAME,
|
|
14
|
+
MAX_CONTEXT_VALUE_BYTES,
|
|
15
|
+
MAX_FRAME_BODY_BYTES,
|
|
16
|
+
MAX_INFLIGHT_COMMANDS_PER_CLIENT,
|
|
17
|
+
MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
18
|
+
} from "../wire/limits";
|
|
11
19
|
import { createMacrotaskPoster } from "../util/macrotask";
|
|
12
20
|
import type { StatewireMetaEntry } from "../wire/meta";
|
|
13
21
|
import type { StatewirePath } from "../wire/ops";
|
|
@@ -15,9 +23,49 @@ import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
|
|
|
15
23
|
import { frames as serveFrames, stream as serveStream } from "./http";
|
|
16
24
|
|
|
17
25
|
export const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
|
|
26
|
+
export const PRINCIPAL_MISMATCH_MESSAGE =
|
|
27
|
+
"client id is bound to another principal";
|
|
28
|
+
|
|
29
|
+
/** Structural equality over auth values: primitives, Dates, arrays, and plain
|
|
30
|
+
* objects compare by value; anything else by identity. */
|
|
31
|
+
export const authEquals = (a: unknown, b: unknown): boolean => {
|
|
32
|
+
if (Object.is(a, b)) return true;
|
|
33
|
+
if (a instanceof Date && b instanceof Date)
|
|
34
|
+
return a.getTime() === b.getTime();
|
|
35
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
36
|
+
return a.length === b.length && a.every((v, i) => authEquals(v, b[i]));
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
typeof a === "object" &&
|
|
40
|
+
typeof b === "object" &&
|
|
41
|
+
a !== null &&
|
|
42
|
+
b !== null &&
|
|
43
|
+
Object.getPrototypeOf(a) === Object.prototype &&
|
|
44
|
+
Object.getPrototypeOf(b) === Object.prototype
|
|
45
|
+
) {
|
|
46
|
+
const keysA = Object.keys(a);
|
|
47
|
+
const keysB = Object.keys(b);
|
|
48
|
+
return (
|
|
49
|
+
keysA.length === keysB.length &&
|
|
50
|
+
keysA.every(
|
|
51
|
+
(key) =>
|
|
52
|
+
key in b &&
|
|
53
|
+
authEquals(
|
|
54
|
+
(a as Record<string, unknown>)[key],
|
|
55
|
+
(b as Record<string, unknown>)[key],
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
};
|
|
62
|
+
|
|
18
63
|
const RESULT_TTL_MS = 30_000;
|
|
19
64
|
const RESULT_LIMIT = 256;
|
|
20
65
|
const CLIENT_RECORD_LIMIT = 1024;
|
|
66
|
+
const MAX_SUBSCRIBERS = 1024;
|
|
67
|
+
export const SUBSCRIBER_LIMIT_MESSAGE = "stream subscriber limit reached";
|
|
68
|
+
const STREAM_QUEUE_SIZE = 256;
|
|
21
69
|
|
|
22
70
|
type WireOp = {
|
|
23
71
|
op: string;
|
|
@@ -42,6 +90,8 @@ export type ClientRec = {
|
|
|
42
90
|
context: Record<string, unknown> | null;
|
|
43
91
|
results: Map<number, { ts: number; cmd: StatewireCmd }>;
|
|
44
92
|
inflight: Map<number, Admission>;
|
|
93
|
+
/** The auth captured at the record's first attach; later attaches must match. */
|
|
94
|
+
principal: unknown;
|
|
45
95
|
};
|
|
46
96
|
|
|
47
97
|
export type Conn = {
|
|
@@ -51,6 +101,17 @@ export type Conn = {
|
|
|
51
101
|
sawFrame: boolean;
|
|
52
102
|
lastSentAt: number;
|
|
53
103
|
lease: string | null;
|
|
104
|
+
/** The awaited `authorize` return captured at this attach's authorization. */
|
|
105
|
+
auth: unknown;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type PendingConn = {
|
|
109
|
+
socket: StatewireHostInternal.Socket;
|
|
110
|
+
open: boolean;
|
|
111
|
+
authorizing: boolean;
|
|
112
|
+
queued: string[];
|
|
113
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
114
|
+
connection: StatewireHostInternal.SocketConnection | null;
|
|
54
115
|
};
|
|
55
116
|
|
|
56
117
|
// ─── frame parsing ──────────────────────────────────────
|
|
@@ -73,7 +134,6 @@ type CmdStatement = {
|
|
|
73
134
|
};
|
|
74
135
|
|
|
75
136
|
export type ParsedFrame = {
|
|
76
|
-
headers: Record<string, string> | null;
|
|
77
137
|
ctx: CtxStatement[];
|
|
78
138
|
cmd: CmdStatement[];
|
|
79
139
|
};
|
|
@@ -99,6 +159,8 @@ const parseLane = (value: unknown): Record<string, unknown>[] => {
|
|
|
99
159
|
return statements;
|
|
100
160
|
};
|
|
101
161
|
|
|
162
|
+
const frameEncoder = new TextEncoder();
|
|
163
|
+
|
|
102
164
|
/** v1 ctx statements state the context whole: one root replace with an object value. */
|
|
103
165
|
const contextValue = (ops: unknown): Record<string, unknown> => {
|
|
104
166
|
if (!Array.isArray(ops) || ops.length !== 1) {
|
|
@@ -123,46 +185,42 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
|
|
|
123
185
|
return value;
|
|
124
186
|
};
|
|
125
187
|
|
|
126
|
-
export const parseFrameValue = (
|
|
127
|
-
frame: unknown,
|
|
128
|
-
first: boolean,
|
|
129
|
-
): ParsedFrame => {
|
|
188
|
+
export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
130
189
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
131
190
|
const keys = Object.keys(frame);
|
|
132
191
|
if (keys.length === 0) return violation("malformed frame");
|
|
133
|
-
const unknown = keys
|
|
134
|
-
.filter((key) => key !== "headers" && key !== "ctx" && key !== "cmd")
|
|
135
|
-
.sort();
|
|
192
|
+
const unknown = keys.filter((key) => key !== "ctx" && key !== "cmd").sort();
|
|
136
193
|
if (unknown.length > 0)
|
|
137
194
|
return violation(`unknown frame key: '${unknown[0]}'`);
|
|
138
|
-
let headers: Record<string, string> | null = null;
|
|
139
|
-
if (frame["headers"] !== undefined) {
|
|
140
|
-
if (!first) return violation("mid-session headers");
|
|
141
|
-
const raw = frame["headers"];
|
|
142
|
-
if (
|
|
143
|
-
!isPlainObject(raw) ||
|
|
144
|
-
Object.values(raw).some((value) => typeof value !== "string")
|
|
145
|
-
) {
|
|
146
|
-
return violation("malformed headers");
|
|
147
|
-
}
|
|
148
|
-
headers = raw as Record<string, string>;
|
|
149
|
-
}
|
|
150
195
|
const ctx: CtxStatement[] = [];
|
|
151
196
|
if (frame["ctx"] !== undefined) {
|
|
152
|
-
|
|
197
|
+
const lane = frame["ctx"];
|
|
198
|
+
if (Array.isArray(lane) && lane.length > MAX_COMMANDS_PER_FRAME) {
|
|
199
|
+
return violation("too many context statements");
|
|
200
|
+
}
|
|
201
|
+
for (const statement of parseLane(lane)) {
|
|
202
|
+
let value: Record<string, unknown>;
|
|
153
203
|
try {
|
|
154
|
-
|
|
155
|
-
seq: statement["seq"] as number,
|
|
156
|
-
value: contextValue(statement["ops"]),
|
|
157
|
-
});
|
|
204
|
+
value = contextValue(statement["ops"]);
|
|
158
205
|
} catch {
|
|
159
206
|
return violation("invalid-context");
|
|
160
207
|
}
|
|
208
|
+
if (
|
|
209
|
+
frameEncoder.encode(JSON.stringify(value)).byteLength >
|
|
210
|
+
MAX_CONTEXT_VALUE_BYTES
|
|
211
|
+
) {
|
|
212
|
+
return violation("oversized-context");
|
|
213
|
+
}
|
|
214
|
+
ctx.push({ seq: statement["seq"] as number, value });
|
|
161
215
|
}
|
|
162
216
|
}
|
|
163
217
|
const cmd: CmdStatement[] = [];
|
|
164
218
|
if (frame["cmd"] !== undefined) {
|
|
165
|
-
|
|
219
|
+
const lane = frame["cmd"];
|
|
220
|
+
if (Array.isArray(lane) && lane.length > MAX_COMMANDS_PER_FRAME) {
|
|
221
|
+
return violation("too many commands");
|
|
222
|
+
}
|
|
223
|
+
for (const statement of parseLane(lane)) {
|
|
166
224
|
const method = statement["method"];
|
|
167
225
|
if (typeof method !== "string" || !("params" in statement)) {
|
|
168
226
|
return violation("malformed statement");
|
|
@@ -175,19 +233,23 @@ export const parseFrameValue = (
|
|
|
175
233
|
});
|
|
176
234
|
}
|
|
177
235
|
}
|
|
178
|
-
return {
|
|
236
|
+
return { ctx, cmd };
|
|
179
237
|
};
|
|
180
238
|
|
|
181
|
-
export const parseFrame = (raw: string
|
|
239
|
+
export const parseFrame = (raw: string): ParsedFrame => {
|
|
182
240
|
let frame: unknown;
|
|
183
241
|
try {
|
|
184
242
|
frame = JSON.parse(raw);
|
|
185
243
|
} catch {
|
|
186
244
|
return violation("malformed frame");
|
|
187
245
|
}
|
|
188
|
-
return parseFrameValue(frame
|
|
246
|
+
return parseFrameValue(frame);
|
|
189
247
|
};
|
|
190
248
|
|
|
249
|
+
const frameIsOversized = (raw: string): boolean =>
|
|
250
|
+
raw.length > MAX_FRAME_BODY_BYTES ||
|
|
251
|
+
frameEncoder.encode(raw).byteLength > MAX_FRAME_BODY_BYTES;
|
|
252
|
+
|
|
191
253
|
// ─── verdict encoding ───────────────────────────────────
|
|
192
254
|
|
|
193
255
|
const encodePayload = (
|
|
@@ -205,6 +267,8 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
|
|
|
205
267
|
const failure =
|
|
206
268
|
outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
|
|
207
269
|
switch (failure?.fate) {
|
|
270
|
+
case "crashed":
|
|
271
|
+
return { seq: adm.seq, type: "crashed" };
|
|
208
272
|
case "failed":
|
|
209
273
|
return {
|
|
210
274
|
seq: adm.seq,
|
|
@@ -243,15 +307,45 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
|
|
|
243
307
|
}
|
|
244
308
|
};
|
|
245
309
|
|
|
310
|
+
const parseInitialFrame = (raw: string) => {
|
|
311
|
+
let value: unknown;
|
|
312
|
+
try {
|
|
313
|
+
value = JSON.parse(raw);
|
|
314
|
+
} catch {
|
|
315
|
+
return violation("malformed frame");
|
|
316
|
+
}
|
|
317
|
+
if (!isPlainObject(value)) return violation("malformed frame");
|
|
318
|
+
const { headers, ...lanes } = value;
|
|
319
|
+
if (headers === undefined) throw new Response(null, { status: 401 });
|
|
320
|
+
if (
|
|
321
|
+
!isPlainObject(headers) ||
|
|
322
|
+
Object.values(headers).some((v) => typeof v !== "string")
|
|
323
|
+
) {
|
|
324
|
+
return violation("malformed headers");
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
headers: headers as Record<string, string>,
|
|
328
|
+
frame: Object.keys(lanes).length
|
|
329
|
+
? parseFrameValue(lanes)
|
|
330
|
+
: { ctx: [], cmd: [] },
|
|
331
|
+
};
|
|
332
|
+
};
|
|
333
|
+
|
|
246
334
|
// ─── host ───────────────────────────────────────────────
|
|
247
335
|
|
|
248
336
|
export type SocketHostState = {
|
|
249
337
|
heartbeatMs: number;
|
|
338
|
+
wsInitTimeoutMs: number;
|
|
339
|
+
maxSubscribers: number;
|
|
340
|
+
maxInflightCommands: number;
|
|
341
|
+
streamQueueSize: number;
|
|
250
342
|
clientRecordLimit: number;
|
|
251
343
|
finOnIdle: boolean;
|
|
344
|
+
pushback: (() => number | null) | undefined;
|
|
252
345
|
authorize: StatewireHostInternal.SocketHostOptions["authorize"];
|
|
253
346
|
clients: Map<string, ClientRec>;
|
|
254
347
|
conns: Set<Conn>;
|
|
348
|
+
pendingConns: Set<PendingConn>;
|
|
255
349
|
pendingOps: WireOp[];
|
|
256
350
|
pendingApplied: Map<string, number[]>;
|
|
257
351
|
pendingCmds: Map<string, StatewireCmd[]>;
|
|
@@ -263,10 +357,12 @@ export type SocketHostState = {
|
|
|
263
357
|
stateHost: StatewireHost.StateHost | null;
|
|
264
358
|
commandsHost: CommandsHost | null;
|
|
265
359
|
current: Statewire<unknown> | null;
|
|
360
|
+
persistence: StatewireHostInternal.SocketHostPersistence | undefined;
|
|
266
361
|
post: () => void;
|
|
267
362
|
connect: (
|
|
268
363
|
clientId: string,
|
|
269
364
|
socket: StatewireHostInternal.Socket,
|
|
365
|
+
options?: StatewireHostInternal.SocketConnectOptions,
|
|
270
366
|
) => StatewireHostInternal.SocketConnection | null;
|
|
271
367
|
finish: (fin: StatewireHost.Fin) => void;
|
|
272
368
|
stream: (request: Request) => Promise<Response>;
|
|
@@ -347,6 +443,11 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
347
443
|
}
|
|
348
444
|
};
|
|
349
445
|
|
|
446
|
+
export const atSubscriberLimit = (h: SocketHostState): boolean =>
|
|
447
|
+
h.conns.size +
|
|
448
|
+
[...h.pendingConns].filter((conn) => conn.authorizing).length >=
|
|
449
|
+
h.maxSubscribers;
|
|
450
|
+
|
|
350
451
|
const ensureHeartbeat = (h: SocketHostState) => {
|
|
351
452
|
if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
|
|
352
453
|
h.heartbeatTimer = setInterval(() => {
|
|
@@ -369,6 +470,7 @@ const setHeartbeatMs = (h: SocketHostState, ms: number) => {
|
|
|
369
470
|
};
|
|
370
471
|
|
|
371
472
|
const idleLevel = (h: SocketHostState): boolean => {
|
|
473
|
+
if (h.current?.work?.isIdle === false) return false;
|
|
372
474
|
for (const rec of h.clients.values()) {
|
|
373
475
|
if (rec.inflight.size > 0) return false;
|
|
374
476
|
}
|
|
@@ -452,6 +554,24 @@ const queueCmd = (h: SocketHostState, clientId: string, cmd: StatewireCmd) => {
|
|
|
452
554
|
scheduleFlush(h);
|
|
453
555
|
};
|
|
454
556
|
|
|
557
|
+
const notifyClientRecords = (h: SocketHostState) => {
|
|
558
|
+
h.persistence?.onClientRecordsChange?.(
|
|
559
|
+
[...h.clients.values()].map((rec) => ({
|
|
560
|
+
id: rec.id,
|
|
561
|
+
lastSeq: admittedThrough(rec),
|
|
562
|
+
ctxSeq: rec.ctxSeq,
|
|
563
|
+
touched: rec.touched,
|
|
564
|
+
context: rec.context,
|
|
565
|
+
results: [...rec.results].map(([seq, entry]) => ({
|
|
566
|
+
seq,
|
|
567
|
+
ts: entry.ts,
|
|
568
|
+
cmd: entry.cmd,
|
|
569
|
+
})),
|
|
570
|
+
principal: rec.principal,
|
|
571
|
+
})),
|
|
572
|
+
);
|
|
573
|
+
};
|
|
574
|
+
|
|
455
575
|
const retain = (rec: ClientRec, cmd: StatewireCmd) => {
|
|
456
576
|
const now = Date.now();
|
|
457
577
|
rec.results.set(cmd.seq, { ts: now, cmd });
|
|
@@ -482,6 +602,7 @@ const reemit = (h: SocketHostState, rec: ClientRec, seq: number) => {
|
|
|
482
602
|
return;
|
|
483
603
|
}
|
|
484
604
|
rec.results.delete(seq);
|
|
605
|
+
notifyClientRecords(h);
|
|
485
606
|
}
|
|
486
607
|
const adm = rec.inflight.get(seq);
|
|
487
608
|
if (adm) {
|
|
@@ -504,6 +625,7 @@ const settleCmd = (
|
|
|
504
625
|
rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
|
|
505
626
|
adm.applied = true;
|
|
506
627
|
queueCmd(h, rec.id, cmd);
|
|
628
|
+
notifyClientRecords(h);
|
|
507
629
|
};
|
|
508
630
|
|
|
509
631
|
const settleWire = (
|
|
@@ -516,8 +638,10 @@ const settleWire = (
|
|
|
516
638
|
const callerOf = (
|
|
517
639
|
h: SocketHostState,
|
|
518
640
|
rec: ClientRec,
|
|
641
|
+
auth: unknown,
|
|
519
642
|
): StatewireHost.Caller => ({
|
|
520
643
|
clientId: rec.id,
|
|
644
|
+
auth,
|
|
521
645
|
get context() {
|
|
522
646
|
return rec.context;
|
|
523
647
|
},
|
|
@@ -547,6 +671,7 @@ const admitCmd = (
|
|
|
547
671
|
h: SocketHostState,
|
|
548
672
|
rec: ClientRec,
|
|
549
673
|
statement: CmdStatement,
|
|
674
|
+
auth: unknown,
|
|
550
675
|
) => {
|
|
551
676
|
const adm: Admission = {
|
|
552
677
|
seq: statement.seq,
|
|
@@ -556,6 +681,7 @@ const admitCmd = (
|
|
|
556
681
|
settled: false,
|
|
557
682
|
};
|
|
558
683
|
rec.inflight.set(statement.seq, adm);
|
|
684
|
+
notifyClientRecords(h);
|
|
559
685
|
if (!Array.isArray(statement.params)) {
|
|
560
686
|
rejectStatement(h, rec, adm, "params must be an array");
|
|
561
687
|
return;
|
|
@@ -592,11 +718,12 @@ const admitCmd = (
|
|
|
592
718
|
method: statement.method,
|
|
593
719
|
params,
|
|
594
720
|
clientId: rec.id,
|
|
595
|
-
caller: callerOf(h, rec),
|
|
721
|
+
caller: callerOf(h, rec, auth),
|
|
596
722
|
onApplied: () => {
|
|
597
723
|
if (adm.settled || adm.applied) return;
|
|
598
724
|
adm.applied = true;
|
|
599
725
|
rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
|
|
726
|
+
notifyClientRecords(h);
|
|
600
727
|
scheduleFlush(h);
|
|
601
728
|
},
|
|
602
729
|
onSettle: (outcome) => settleWire(h, rec, adm, outcome),
|
|
@@ -610,12 +737,18 @@ const admitCmd = (
|
|
|
610
737
|
// {syn} stating both watermarks, nothing consumed. A gap is never a close.
|
|
611
738
|
export type FrameAdmission =
|
|
612
739
|
| { type: "ok" }
|
|
613
|
-
| {
|
|
740
|
+
| {
|
|
741
|
+
type: "refused";
|
|
742
|
+
syn: {
|
|
743
|
+
syn: { seq: number; ctxSeq: number; retry?: number };
|
|
744
|
+
};
|
|
745
|
+
};
|
|
614
746
|
|
|
615
747
|
export const admitFrame = (
|
|
616
748
|
h: SocketHostState,
|
|
617
749
|
rec: ClientRec,
|
|
618
750
|
frame: ParsedFrame,
|
|
751
|
+
auth: unknown,
|
|
619
752
|
): FrameAdmission => {
|
|
620
753
|
rec.touched = Date.now();
|
|
621
754
|
const through = admittedThrough(rec);
|
|
@@ -628,56 +761,374 @@ export const admitFrame = (
|
|
|
628
761
|
(ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
|
|
629
762
|
(cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
|
|
630
763
|
) {
|
|
764
|
+
notifyClientRecords(h);
|
|
631
765
|
return {
|
|
632
766
|
type: "refused",
|
|
633
767
|
syn: { syn: { seq: through, ctxSeq: rec.ctxSeq } },
|
|
634
768
|
};
|
|
635
769
|
}
|
|
770
|
+
if (cmdNew.length > 0) {
|
|
771
|
+
let hostCommandLoad = 0;
|
|
772
|
+
for (const client of h.clients.values()) {
|
|
773
|
+
hostCommandLoad += client.inflight.size;
|
|
774
|
+
}
|
|
775
|
+
if (
|
|
776
|
+
rec.inflight.size + cmdNew.length > MAX_INFLIGHT_COMMANDS_PER_CLIENT ||
|
|
777
|
+
hostCommandLoad + cmdNew.length > h.maxInflightCommands
|
|
778
|
+
) {
|
|
779
|
+
return {
|
|
780
|
+
type: "refused",
|
|
781
|
+
syn: {
|
|
782
|
+
syn: {
|
|
783
|
+
seq: through,
|
|
784
|
+
ctxSeq: rec.ctxSeq,
|
|
785
|
+
retry: COMMAND_CAPACITY_RETRY_MS,
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if ((ctxNew.length || cmdNew.length) && h.pushback) {
|
|
792
|
+
const retry = h.pushback();
|
|
793
|
+
if (retry !== null) {
|
|
794
|
+
if (!Number.isSafeInteger(retry) || retry < 0)
|
|
795
|
+
throw new TypeError(
|
|
796
|
+
"pushback must return a nonnegative safe integer or null",
|
|
797
|
+
);
|
|
798
|
+
return {
|
|
799
|
+
type: "refused",
|
|
800
|
+
syn: { syn: { seq: through, ctxSeq: rec.ctxSeq, retry } },
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
}
|
|
636
804
|
for (const statement of ctxNew) {
|
|
637
805
|
rec.context = statement.value;
|
|
638
806
|
rec.ctxSeq = statement.seq;
|
|
639
807
|
}
|
|
640
|
-
for (const statement of cmdNew) admitCmd(h, rec, statement);
|
|
808
|
+
for (const statement of cmdNew) admitCmd(h, rec, statement, auth);
|
|
641
809
|
if (cmdNew.length > 0) {
|
|
642
810
|
// Memory host: admitted means retained — dur rides the admitting flush.
|
|
643
811
|
queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
|
|
644
812
|
}
|
|
813
|
+
notifyClientRecords(h);
|
|
645
814
|
scheduleFlush(h);
|
|
646
815
|
return { type: "ok" };
|
|
647
816
|
};
|
|
648
817
|
|
|
649
818
|
const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
650
819
|
if (!conn.open || h.disposed || h.finished !== null) return;
|
|
820
|
+
if (frameIsOversized(data)) {
|
|
821
|
+
sendTo(
|
|
822
|
+
h,
|
|
823
|
+
conn,
|
|
824
|
+
JSON.stringify({
|
|
825
|
+
fin: {
|
|
826
|
+
reason: "protocol-error",
|
|
827
|
+
message: "frame too large",
|
|
828
|
+
},
|
|
829
|
+
}),
|
|
830
|
+
);
|
|
831
|
+
closeConn(h, conn, 1009, "frame too large");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
651
834
|
const first = !conn.sawFrame;
|
|
652
835
|
conn.sawFrame = true;
|
|
653
836
|
let frame: ParsedFrame;
|
|
654
837
|
try {
|
|
655
|
-
frame = parseFrame(data
|
|
838
|
+
frame = first ? parseInitialFrame(data).frame : parseFrame(data);
|
|
656
839
|
} catch (error) {
|
|
657
840
|
const reason = violationReason(error);
|
|
658
841
|
if (reason === null) throw error;
|
|
842
|
+
sendTo(
|
|
843
|
+
h,
|
|
844
|
+
conn,
|
|
845
|
+
JSON.stringify({
|
|
846
|
+
fin: {
|
|
847
|
+
reason: "protocol-error",
|
|
848
|
+
message: reason,
|
|
849
|
+
},
|
|
850
|
+
}),
|
|
851
|
+
);
|
|
659
852
|
closeConn(h, conn, 1008, reason);
|
|
660
853
|
return;
|
|
661
854
|
}
|
|
662
855
|
if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
|
|
663
856
|
const rec = h.clients.get(conn.clientId)!;
|
|
664
|
-
const admission = admitFrame(h, rec, frame);
|
|
857
|
+
const admission = admitFrame(h, rec, frame, conn.auth);
|
|
665
858
|
if (admission.type === "refused") {
|
|
666
859
|
sendTo(h, conn, JSON.stringify(admission.syn));
|
|
667
860
|
}
|
|
668
861
|
};
|
|
669
862
|
|
|
863
|
+
const closePending = (
|
|
864
|
+
h: SocketHostState,
|
|
865
|
+
pending: PendingConn,
|
|
866
|
+
code?: number,
|
|
867
|
+
reason?: string,
|
|
868
|
+
) => {
|
|
869
|
+
if (!pending.open) return;
|
|
870
|
+
pending.open = false;
|
|
871
|
+
h.pendingConns.delete(pending);
|
|
872
|
+
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
873
|
+
try {
|
|
874
|
+
pending.socket.close(code, reason);
|
|
875
|
+
} catch {
|
|
876
|
+
/* peer already gone */
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
const sendPending = (pending: PendingConn, data: string): boolean => {
|
|
881
|
+
if (!pending.open) return false;
|
|
882
|
+
try {
|
|
883
|
+
pending.socket.send(data);
|
|
884
|
+
return true;
|
|
885
|
+
} catch {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
const refuseSubscriberLimit = (socket: StatewireHostInternal.Socket): null => {
|
|
891
|
+
try {
|
|
892
|
+
socket.send(
|
|
893
|
+
JSON.stringify({
|
|
894
|
+
fin: {
|
|
895
|
+
reason: "reconnect",
|
|
896
|
+
code: "overloaded",
|
|
897
|
+
message: SUBSCRIBER_LIMIT_MESSAGE,
|
|
898
|
+
},
|
|
899
|
+
}),
|
|
900
|
+
);
|
|
901
|
+
} catch {
|
|
902
|
+
/* peer already gone */
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
socket.close(1013, SUBSCRIBER_LIMIT_MESSAGE);
|
|
906
|
+
} catch {
|
|
907
|
+
/* peer already gone */
|
|
908
|
+
}
|
|
909
|
+
return null;
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
const rejectPendingAuthorization = (
|
|
913
|
+
h: SocketHostState,
|
|
914
|
+
pending: PendingConn,
|
|
915
|
+
error: unknown,
|
|
916
|
+
) => {
|
|
917
|
+
const response = error instanceof Response ? error : null;
|
|
918
|
+
const unauthorized = response?.status === 401 || response?.status === 403;
|
|
919
|
+
const status = response?.status;
|
|
920
|
+
const message = unauthorized
|
|
921
|
+
? "unauthorized"
|
|
922
|
+
: response?.statusText ||
|
|
923
|
+
(status === undefined ? "authorization failed" : `HTTP ${status}`);
|
|
924
|
+
sendPending(
|
|
925
|
+
pending,
|
|
926
|
+
JSON.stringify({
|
|
927
|
+
fin: {
|
|
928
|
+
reason:
|
|
929
|
+
status === 403
|
|
930
|
+
? "forbidden"
|
|
931
|
+
: unauthorized
|
|
932
|
+
? "unauthorized"
|
|
933
|
+
: "error",
|
|
934
|
+
message,
|
|
935
|
+
...(status !== undefined && { status }),
|
|
936
|
+
},
|
|
937
|
+
}),
|
|
938
|
+
);
|
|
939
|
+
closePending(h, pending, unauthorized ? 1008 : 1011, message);
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
const normalizedHeaders = (
|
|
943
|
+
headers: Record<string, string>,
|
|
944
|
+
): Record<string, string> => {
|
|
945
|
+
const seen = new Set<string>();
|
|
946
|
+
for (const name of Object.keys(headers)) {
|
|
947
|
+
const key = name.toLowerCase();
|
|
948
|
+
if (seen.has(key)) throw new TypeError("duplicate header name");
|
|
949
|
+
seen.add(key);
|
|
950
|
+
}
|
|
951
|
+
return Object.fromEntries(new Headers(headers));
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const connectAuthorized = (
|
|
955
|
+
h: SocketHostState,
|
|
956
|
+
clientId: string,
|
|
957
|
+
socket: StatewireHostInternal.Socket,
|
|
958
|
+
options: StatewireHostInternal.SocketConnectOptions,
|
|
959
|
+
): StatewireHostInternal.SocketConnection | null => {
|
|
960
|
+
const authorize = h.authorize;
|
|
961
|
+
if (options.authorized === true) {
|
|
962
|
+
const registration = register(
|
|
963
|
+
h,
|
|
964
|
+
clientId,
|
|
965
|
+
socket,
|
|
966
|
+
options.authorized === true,
|
|
967
|
+
options.auth,
|
|
968
|
+
);
|
|
969
|
+
return registration?.connection ?? null;
|
|
970
|
+
}
|
|
971
|
+
const refuse = (code: number, reason: string) => {
|
|
972
|
+
try {
|
|
973
|
+
socket.close(code, reason);
|
|
974
|
+
} catch {
|
|
975
|
+
/* peer already gone */
|
|
976
|
+
}
|
|
977
|
+
return null;
|
|
978
|
+
};
|
|
979
|
+
if (h.disposed) return refuse(1001, "disposed");
|
|
980
|
+
if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
|
|
981
|
+
flush(h);
|
|
982
|
+
if (h.finished !== null) {
|
|
983
|
+
try {
|
|
984
|
+
socket.send(JSON.stringify({ fin: h.finished }));
|
|
985
|
+
} catch {
|
|
986
|
+
/* peer already gone */
|
|
987
|
+
}
|
|
988
|
+
return refuse(1000, "finished");
|
|
989
|
+
}
|
|
990
|
+
const pending: PendingConn = {
|
|
991
|
+
socket,
|
|
992
|
+
open: true,
|
|
993
|
+
authorizing: false,
|
|
994
|
+
queued: [],
|
|
995
|
+
timer: null,
|
|
996
|
+
connection: null,
|
|
997
|
+
};
|
|
998
|
+
h.pendingConns.add(pending);
|
|
999
|
+
pending.timer = setTimeout(
|
|
1000
|
+
() =>
|
|
1001
|
+
rejectPendingAuthorization(
|
|
1002
|
+
h,
|
|
1003
|
+
pending,
|
|
1004
|
+
new Response(null, { status: 401 }),
|
|
1005
|
+
),
|
|
1006
|
+
h.wsInitTimeoutMs,
|
|
1007
|
+
);
|
|
1008
|
+
(pending.timer as unknown as { unref?: () => void }).unref?.();
|
|
1009
|
+
return {
|
|
1010
|
+
clientId,
|
|
1011
|
+
message: (data) => {
|
|
1012
|
+
if (pending.connection !== null) {
|
|
1013
|
+
pending.connection.message(data);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
1017
|
+
if (frameIsOversized(data)) {
|
|
1018
|
+
sendPending(
|
|
1019
|
+
pending,
|
|
1020
|
+
JSON.stringify({
|
|
1021
|
+
fin: {
|
|
1022
|
+
reason: "protocol-error",
|
|
1023
|
+
message: "frame too large",
|
|
1024
|
+
},
|
|
1025
|
+
}),
|
|
1026
|
+
);
|
|
1027
|
+
closePending(h, pending, 1009, "frame too large");
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (pending.authorizing) {
|
|
1031
|
+
pending.queued.push(data);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
let initial: ReturnType<typeof parseInitialFrame>;
|
|
1035
|
+
try {
|
|
1036
|
+
initial = parseInitialFrame(data);
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
if (error instanceof Response) {
|
|
1039
|
+
rejectPendingAuthorization(h, pending, error);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const reason = violationReason(error);
|
|
1043
|
+
if (reason === null) throw error;
|
|
1044
|
+
sendPending(
|
|
1045
|
+
pending,
|
|
1046
|
+
JSON.stringify({
|
|
1047
|
+
fin: {
|
|
1048
|
+
reason: "protocol-error",
|
|
1049
|
+
message: reason,
|
|
1050
|
+
},
|
|
1051
|
+
}),
|
|
1052
|
+
);
|
|
1053
|
+
closePending(h, pending, 1008, reason);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
const frame = initial.frame;
|
|
1057
|
+
let headers: Record<string, string>;
|
|
1058
|
+
try {
|
|
1059
|
+
headers = normalizedHeaders(initial.headers);
|
|
1060
|
+
} catch {
|
|
1061
|
+
sendPending(
|
|
1062
|
+
pending,
|
|
1063
|
+
JSON.stringify({
|
|
1064
|
+
fin: {
|
|
1065
|
+
reason: "protocol-error",
|
|
1066
|
+
message: "malformed headers",
|
|
1067
|
+
},
|
|
1068
|
+
}),
|
|
1069
|
+
);
|
|
1070
|
+
closePending(h, pending, 1008, "malformed headers");
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (atSubscriberLimit(h)) {
|
|
1074
|
+
sendPending(
|
|
1075
|
+
pending,
|
|
1076
|
+
JSON.stringify({
|
|
1077
|
+
fin: {
|
|
1078
|
+
reason: "reconnect",
|
|
1079
|
+
code: "overloaded",
|
|
1080
|
+
message: SUBSCRIBER_LIMIT_MESSAGE,
|
|
1081
|
+
},
|
|
1082
|
+
}),
|
|
1083
|
+
);
|
|
1084
|
+
closePending(h, pending, 1013, SUBSCRIBER_LIMIT_MESSAGE);
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
pending.authorizing = true;
|
|
1088
|
+
Promise.resolve()
|
|
1089
|
+
.then(() => authorize?.({ headers }))
|
|
1090
|
+
.then((auth) => {
|
|
1091
|
+
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
1092
|
+
options.onAuthorized?.(auth);
|
|
1093
|
+
pending.open = false;
|
|
1094
|
+
h.pendingConns.delete(pending);
|
|
1095
|
+
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
1096
|
+
const registration = register(h, clientId, socket, true, auth);
|
|
1097
|
+
if (registration === null) return;
|
|
1098
|
+
pending.connection = registration.connection;
|
|
1099
|
+
if (frame.ctx.length || frame.cmd.length) {
|
|
1100
|
+
const admission = admitFrame(h, registration.rec, frame, auth);
|
|
1101
|
+
if (admission.type === "refused")
|
|
1102
|
+
sendTo(h, registration.conn, JSON.stringify(admission.syn));
|
|
1103
|
+
}
|
|
1104
|
+
for (const data of pending.queued.splice(0))
|
|
1105
|
+
registration.connection.message(data);
|
|
1106
|
+
})
|
|
1107
|
+
.catch((error: unknown) => {
|
|
1108
|
+
if (pending.open) rejectPendingAuthorization(h, pending, error);
|
|
1109
|
+
});
|
|
1110
|
+
},
|
|
1111
|
+
close: () => {
|
|
1112
|
+
if (pending.connection !== null) pending.connection.close();
|
|
1113
|
+
else closePending(h, pending);
|
|
1114
|
+
},
|
|
1115
|
+
};
|
|
1116
|
+
};
|
|
1117
|
+
|
|
670
1118
|
const gcClients = (h: SocketHostState) => {
|
|
671
1119
|
let excess = h.clients.size - h.clientRecordLimit;
|
|
672
1120
|
if (excess <= 0) return;
|
|
1121
|
+
let changed = false;
|
|
673
1122
|
const live = new Set([...h.conns].map((conn) => conn.clientId));
|
|
674
1123
|
const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
|
|
675
1124
|
for (const rec of oldest) {
|
|
676
|
-
if (excess <= 0)
|
|
1125
|
+
if (excess <= 0) break;
|
|
677
1126
|
if (live.has(rec.id) || rec.inflight.size > 0) continue;
|
|
678
1127
|
h.clients.delete(rec.id);
|
|
1128
|
+
changed = true;
|
|
679
1129
|
excess -= 1;
|
|
680
1130
|
}
|
|
1131
|
+
if (changed) notifyClientRecords(h);
|
|
681
1132
|
};
|
|
682
1133
|
|
|
683
1134
|
export type Registration = {
|
|
@@ -690,6 +1141,8 @@ export const register = (
|
|
|
690
1141
|
h: SocketHostState,
|
|
691
1142
|
clientId: string,
|
|
692
1143
|
socket: StatewireHostInternal.Socket,
|
|
1144
|
+
sawFrame = false,
|
|
1145
|
+
auth: unknown = undefined,
|
|
693
1146
|
): Registration | null => {
|
|
694
1147
|
const refuse = (code: number, reason: string) => {
|
|
695
1148
|
try {
|
|
@@ -711,33 +1164,55 @@ export const register = (
|
|
|
711
1164
|
refuse(1000, "finished");
|
|
712
1165
|
return null;
|
|
713
1166
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1167
|
+
if (atSubscriberLimit(h)) {
|
|
1168
|
+
return refuseSubscriberLimit(socket);
|
|
1169
|
+
}
|
|
1170
|
+
const known = h.clients.get(clientId);
|
|
1171
|
+
if (known !== undefined && !authEquals(known.principal, auth)) {
|
|
1172
|
+
try {
|
|
1173
|
+
socket.send(
|
|
1174
|
+
JSON.stringify({
|
|
1175
|
+
fin: {
|
|
1176
|
+
reason: "forbidden",
|
|
1177
|
+
message: PRINCIPAL_MISMATCH_MESSAGE,
|
|
1178
|
+
status: 403,
|
|
1179
|
+
},
|
|
1180
|
+
}),
|
|
1181
|
+
);
|
|
1182
|
+
} catch {
|
|
1183
|
+
/* peer already gone */
|
|
1184
|
+
}
|
|
1185
|
+
return refuse(1008, PRINCIPAL_MISMATCH_MESSAGE);
|
|
1186
|
+
}
|
|
1187
|
+
const rec =
|
|
1188
|
+
known ??
|
|
1189
|
+
(() => {
|
|
1190
|
+
const created: ClientRec = {
|
|
1191
|
+
id: clientId,
|
|
1192
|
+
lastSeq: 0,
|
|
1193
|
+
ctxSeq: 0,
|
|
1194
|
+
touched: Date.now(),
|
|
1195
|
+
context: null,
|
|
1196
|
+
results: new Map(),
|
|
1197
|
+
inflight: new Map(),
|
|
1198
|
+
principal: auth,
|
|
1199
|
+
};
|
|
1200
|
+
h.clients.set(clientId, created);
|
|
1201
|
+
return created;
|
|
1202
|
+
})();
|
|
730
1203
|
rec.touched = Date.now();
|
|
731
1204
|
const conn: Conn = {
|
|
732
1205
|
clientId,
|
|
733
1206
|
socket,
|
|
734
1207
|
open: true,
|
|
735
|
-
sawFrame
|
|
1208
|
+
sawFrame,
|
|
736
1209
|
lastSentAt: 0,
|
|
737
1210
|
lease: null,
|
|
1211
|
+
auth,
|
|
738
1212
|
};
|
|
739
1213
|
h.conns.add(conn);
|
|
740
1214
|
gcClients(h);
|
|
1215
|
+
notifyClientRecords(h);
|
|
741
1216
|
ensureHeartbeat(h);
|
|
742
1217
|
const { value, meta } = encodeValue(h.stateHost!.snapshot());
|
|
743
1218
|
const hello: StatewireEnvelope = {
|
|
@@ -768,14 +1243,25 @@ const connect = (
|
|
|
768
1243
|
h: SocketHostState,
|
|
769
1244
|
clientId: string,
|
|
770
1245
|
socket: StatewireHostInternal.Socket,
|
|
1246
|
+
options: StatewireHostInternal.SocketConnectOptions = {},
|
|
771
1247
|
): StatewireHostInternal.SocketConnection | null => {
|
|
772
|
-
|
|
773
|
-
return registration?.connection ?? null;
|
|
1248
|
+
return connectAuthorized(h, clientId, socket, options);
|
|
774
1249
|
};
|
|
775
1250
|
|
|
776
1251
|
const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
|
|
1252
|
+
if (
|
|
1253
|
+
fin.retry !== undefined &&
|
|
1254
|
+
(!Number.isSafeInteger(fin.retry) || fin.retry < 0)
|
|
1255
|
+
) {
|
|
1256
|
+
throw new Error("statewire: retry must be a nonnegative safe integer");
|
|
1257
|
+
}
|
|
777
1258
|
if (h.finished !== null || h.disposed) return;
|
|
1259
|
+
h.commandsHost?.abort?.();
|
|
778
1260
|
h.finished = fin;
|
|
1261
|
+
for (const pending of [...h.pendingConns]) {
|
|
1262
|
+
sendPending(pending, JSON.stringify({ fin }));
|
|
1263
|
+
closePending(h, pending, 1000, fin.reason);
|
|
1264
|
+
}
|
|
779
1265
|
flush(h, fin);
|
|
780
1266
|
};
|
|
781
1267
|
|
|
@@ -793,9 +1279,26 @@ const suspend = (h: SocketHostState) => {
|
|
|
793
1279
|
h.stateHost?.setOnOp(null);
|
|
794
1280
|
};
|
|
795
1281
|
|
|
1282
|
+
const restoreClientRecords = (
|
|
1283
|
+
records: readonly StatewireHostInternal.ClientRecord[] = [],
|
|
1284
|
+
): Map<string, ClientRec> =>
|
|
1285
|
+
new Map(
|
|
1286
|
+
records.map((record) => [
|
|
1287
|
+
record.id,
|
|
1288
|
+
{
|
|
1289
|
+
...record,
|
|
1290
|
+
results: new Map(
|
|
1291
|
+
record.results.map(({ seq, ts, cmd }) => [seq, { ts, cmd }]),
|
|
1292
|
+
),
|
|
1293
|
+
inflight: new Map(),
|
|
1294
|
+
},
|
|
1295
|
+
]),
|
|
1296
|
+
);
|
|
1297
|
+
|
|
796
1298
|
const useSocketHost = (
|
|
797
1299
|
transport: ResourceElement<Statewire>,
|
|
798
1300
|
options?: StatewireHostInternal.SocketHostOptions,
|
|
1301
|
+
persistence?: StatewireHostInternal.SocketHostPersistence,
|
|
799
1302
|
): Pick<
|
|
800
1303
|
StatewireHostInternal.SocketHost,
|
|
801
1304
|
"connect" | "finish" | "stream" | "frames"
|
|
@@ -803,11 +1306,17 @@ const useSocketHost = (
|
|
|
803
1306
|
const [h] = useState((): SocketHostState => {
|
|
804
1307
|
const host: SocketHostState = {
|
|
805
1308
|
heartbeatMs: 15_000,
|
|
1309
|
+
wsInitTimeoutMs: 5_000,
|
|
1310
|
+
maxSubscribers: MAX_SUBSCRIBERS,
|
|
1311
|
+
maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
1312
|
+
streamQueueSize: STREAM_QUEUE_SIZE,
|
|
806
1313
|
clientRecordLimit: CLIENT_RECORD_LIMIT,
|
|
807
1314
|
finOnIdle: false,
|
|
1315
|
+
pushback: undefined,
|
|
808
1316
|
authorize: undefined,
|
|
809
|
-
clients:
|
|
1317
|
+
clients: restoreClientRecords(persistence?.clientRecords),
|
|
810
1318
|
conns: new Set(),
|
|
1319
|
+
pendingConns: new Set(),
|
|
811
1320
|
pendingOps: [],
|
|
812
1321
|
pendingApplied: new Map(),
|
|
813
1322
|
pendingCmds: new Map(),
|
|
@@ -819,11 +1328,13 @@ const useSocketHost = (
|
|
|
819
1328
|
stateHost: null,
|
|
820
1329
|
commandsHost: null,
|
|
821
1330
|
current: null,
|
|
1331
|
+
persistence,
|
|
822
1332
|
post: createMacrotaskPoster(() => {
|
|
823
1333
|
host.flushScheduled = false;
|
|
824
1334
|
flush(host);
|
|
825
1335
|
}),
|
|
826
|
-
connect: (clientId, socket) =>
|
|
1336
|
+
connect: (clientId, socket, connectOptions) =>
|
|
1337
|
+
connect(host, clientId, socket, connectOptions),
|
|
827
1338
|
finish: (fin) => finish(host, fin),
|
|
828
1339
|
stream: (request) => serveStream(host, request),
|
|
829
1340
|
frames: (request) => serveFrames(host, request),
|
|
@@ -831,6 +1342,32 @@ const useSocketHost = (
|
|
|
831
1342
|
return host;
|
|
832
1343
|
});
|
|
833
1344
|
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
|
|
1345
|
+
h.pushback = options?.pushback;
|
|
1346
|
+
h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
|
|
1347
|
+
const maxSubscribers =
|
|
1348
|
+
options?.maxSubscribers === undefined
|
|
1349
|
+
? MAX_SUBSCRIBERS
|
|
1350
|
+
: options.maxSubscribers;
|
|
1351
|
+
if (!Number.isInteger(maxSubscribers) || maxSubscribers < 1) {
|
|
1352
|
+
throw new TypeError("maxSubscribers must be a positive integer");
|
|
1353
|
+
}
|
|
1354
|
+
h.maxSubscribers = maxSubscribers;
|
|
1355
|
+
const maxInflightCommands =
|
|
1356
|
+
options?.maxInflightCommands === undefined
|
|
1357
|
+
? MAX_INFLIGHT_COMMANDS_PER_HOST
|
|
1358
|
+
: options.maxInflightCommands;
|
|
1359
|
+
if (!Number.isInteger(maxInflightCommands) || maxInflightCommands < 1) {
|
|
1360
|
+
throw new TypeError("maxInflightCommands must be a positive integer");
|
|
1361
|
+
}
|
|
1362
|
+
h.maxInflightCommands = maxInflightCommands;
|
|
1363
|
+
const streamQueueSize =
|
|
1364
|
+
options?.streamQueueSize === undefined
|
|
1365
|
+
? STREAM_QUEUE_SIZE
|
|
1366
|
+
: options.streamQueueSize;
|
|
1367
|
+
if (!Number.isInteger(streamQueueSize) || streamQueueSize < 1) {
|
|
1368
|
+
throw new TypeError("streamQueueSize must be a positive integer");
|
|
1369
|
+
}
|
|
1370
|
+
h.streamQueueSize = streamQueueSize;
|
|
834
1371
|
h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
|
|
835
1372
|
h.finOnIdle = options?.finOnIdle ?? false;
|
|
836
1373
|
h.authorize = options?.authorize;
|
|
@@ -838,6 +1375,7 @@ const useSocketHost = (
|
|
|
838
1375
|
attach(h, value);
|
|
839
1376
|
useEffect(() => {
|
|
840
1377
|
attach(h, value);
|
|
1378
|
+
scheduleFlush(h);
|
|
841
1379
|
});
|
|
842
1380
|
useEffect(() => {
|
|
843
1381
|
resume(h);
|
|
@@ -857,20 +1395,25 @@ const useSocketHost = (
|
|
|
857
1395
|
export const StatewireSocketHost = (
|
|
858
1396
|
transport: ResourceElement<Statewire>,
|
|
859
1397
|
options?: StatewireHostInternal.SocketHostOptions,
|
|
1398
|
+
persistence?: StatewireHostInternal.SocketHostPersistence,
|
|
860
1399
|
): StatewireHostInternal.SocketHost => {
|
|
861
|
-
const SocketHostRoot = () => useSocketHost(transport, options);
|
|
1400
|
+
const SocketHostRoot = () => useSocketHost(transport, options, persistence);
|
|
862
1401
|
const root = createTapRoot(SocketHostRoot);
|
|
863
1402
|
let disposed = false;
|
|
864
1403
|
return {
|
|
865
|
-
connect: (clientId, socket) =>
|
|
866
|
-
flushTapSync(() =>
|
|
1404
|
+
connect: (clientId, socket, connectOptions) =>
|
|
1405
|
+
flushTapSync(() =>
|
|
1406
|
+
root.getValue().connect(clientId, socket, connectOptions),
|
|
1407
|
+
),
|
|
867
1408
|
stream: (request) => flushTapSync(() => root.getValue().stream(request)),
|
|
868
1409
|
frames: (request) => flushTapSync(() => root.getValue().frames(request)),
|
|
869
1410
|
finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
|
|
870
1411
|
dispose: () => {
|
|
871
1412
|
if (disposed) return;
|
|
872
1413
|
disposed = true;
|
|
873
|
-
flushTapSync(() =>
|
|
1414
|
+
flushTapSync(() =>
|
|
1415
|
+
root.getValue().finish({ reason: "reconnect", code: "evicted" }),
|
|
1416
|
+
);
|
|
874
1417
|
root.unmount();
|
|
875
1418
|
},
|
|
876
1419
|
};
|