statewire 0.14.0 → 0.16.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 -80
- package/dist/client/connection.d.ts +19 -6
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +79 -40
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +1 -0
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +5 -0
- 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 +98 -19
- 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 +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +75 -47
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +23 -7
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +275 -74
- 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 +35 -5
- 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/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/statewire.d.ts +13 -4
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.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 +50 -59
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.js +4 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +3 -3
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +57 -44
- 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/util/randomId.js +1 -1
- package/dist/util/randomId.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 +159 -52
- package/src/client/core.ts +6 -0
- package/src/client/session.ts +105 -32
- package/src/host/adapter.ts +10 -2
- package/src/host/host.ts +54 -4
- package/src/host/http.ts +132 -66
- package/src/host/socket.ts +410 -81
- package/src/host/work.ts +50 -0
- package/src/host-internal.ts +32 -4
- package/src/host.ts +18 -2
- package/src/index.ts +2 -2
- package/src/statewire.ts +18 -4
- package/src/transports/sse.ts +70 -78
- package/src/transports/testing.ts +4 -4
- package/src/transports/ws.ts +36 -55
- package/src/types.ts +1 -0
- package/src/util/randomId.ts +3 -3
- 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,12 +101,15 @@ 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;
|
|
54
106
|
};
|
|
55
107
|
|
|
56
108
|
type PendingConn = {
|
|
57
109
|
socket: StatewireHostInternal.Socket;
|
|
58
110
|
open: boolean;
|
|
59
111
|
authorizing: boolean;
|
|
112
|
+
queued: string[];
|
|
60
113
|
timer: ReturnType<typeof setTimeout> | null;
|
|
61
114
|
connection: StatewireHostInternal.SocketConnection | null;
|
|
62
115
|
};
|
|
@@ -81,7 +134,6 @@ type CmdStatement = {
|
|
|
81
134
|
};
|
|
82
135
|
|
|
83
136
|
export type ParsedFrame = {
|
|
84
|
-
headers: Record<string, string> | null;
|
|
85
137
|
ctx: CtxStatement[];
|
|
86
138
|
cmd: CmdStatement[];
|
|
87
139
|
};
|
|
@@ -107,6 +159,8 @@ const parseLane = (value: unknown): Record<string, unknown>[] => {
|
|
|
107
159
|
return statements;
|
|
108
160
|
};
|
|
109
161
|
|
|
162
|
+
const frameEncoder = new TextEncoder();
|
|
163
|
+
|
|
110
164
|
/** v1 ctx statements state the context whole: one root replace with an object value. */
|
|
111
165
|
const contextValue = (ops: unknown): Record<string, unknown> => {
|
|
112
166
|
if (!Array.isArray(ops) || ops.length !== 1) {
|
|
@@ -131,46 +185,42 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
|
|
|
131
185
|
return value;
|
|
132
186
|
};
|
|
133
187
|
|
|
134
|
-
export const parseFrameValue = (
|
|
135
|
-
frame: unknown,
|
|
136
|
-
first: boolean,
|
|
137
|
-
): ParsedFrame => {
|
|
188
|
+
export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
138
189
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
139
190
|
const keys = Object.keys(frame);
|
|
140
191
|
if (keys.length === 0) return violation("malformed frame");
|
|
141
|
-
const unknown = keys
|
|
142
|
-
.filter((key) => key !== "headers" && key !== "ctx" && key !== "cmd")
|
|
143
|
-
.sort();
|
|
192
|
+
const unknown = keys.filter((key) => key !== "ctx" && key !== "cmd").sort();
|
|
144
193
|
if (unknown.length > 0)
|
|
145
194
|
return violation(`unknown frame key: '${unknown[0]}'`);
|
|
146
|
-
let headers: Record<string, string> | null = null;
|
|
147
|
-
if (frame["headers"] !== undefined) {
|
|
148
|
-
if (!first) return violation("mid-session headers");
|
|
149
|
-
const raw = frame["headers"];
|
|
150
|
-
if (
|
|
151
|
-
!isPlainObject(raw) ||
|
|
152
|
-
Object.values(raw).some((value) => typeof value !== "string")
|
|
153
|
-
) {
|
|
154
|
-
return violation("malformed headers");
|
|
155
|
-
}
|
|
156
|
-
headers = raw as Record<string, string>;
|
|
157
|
-
}
|
|
158
195
|
const ctx: CtxStatement[] = [];
|
|
159
196
|
if (frame["ctx"] !== undefined) {
|
|
160
|
-
|
|
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>;
|
|
161
203
|
try {
|
|
162
|
-
|
|
163
|
-
seq: statement["seq"] as number,
|
|
164
|
-
value: contextValue(statement["ops"]),
|
|
165
|
-
});
|
|
204
|
+
value = contextValue(statement["ops"]);
|
|
166
205
|
} catch {
|
|
167
206
|
return violation("invalid-context");
|
|
168
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 });
|
|
169
215
|
}
|
|
170
216
|
}
|
|
171
217
|
const cmd: CmdStatement[] = [];
|
|
172
218
|
if (frame["cmd"] !== undefined) {
|
|
173
|
-
|
|
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)) {
|
|
174
224
|
const method = statement["method"];
|
|
175
225
|
if (typeof method !== "string" || !("params" in statement)) {
|
|
176
226
|
return violation("malformed statement");
|
|
@@ -183,19 +233,23 @@ export const parseFrameValue = (
|
|
|
183
233
|
});
|
|
184
234
|
}
|
|
185
235
|
}
|
|
186
|
-
return {
|
|
236
|
+
return { ctx, cmd };
|
|
187
237
|
};
|
|
188
238
|
|
|
189
|
-
export const parseFrame = (raw: string
|
|
239
|
+
export const parseFrame = (raw: string): ParsedFrame => {
|
|
190
240
|
let frame: unknown;
|
|
191
241
|
try {
|
|
192
242
|
frame = JSON.parse(raw);
|
|
193
243
|
} catch {
|
|
194
244
|
return violation("malformed frame");
|
|
195
245
|
}
|
|
196
|
-
return parseFrameValue(frame
|
|
246
|
+
return parseFrameValue(frame);
|
|
197
247
|
};
|
|
198
248
|
|
|
249
|
+
const frameIsOversized = (raw: string): boolean =>
|
|
250
|
+
raw.length > MAX_FRAME_BODY_BYTES ||
|
|
251
|
+
frameEncoder.encode(raw).byteLength > MAX_FRAME_BODY_BYTES;
|
|
252
|
+
|
|
199
253
|
// ─── verdict encoding ───────────────────────────────────
|
|
200
254
|
|
|
201
255
|
const encodePayload = (
|
|
@@ -213,6 +267,8 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
|
|
|
213
267
|
const failure =
|
|
214
268
|
outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
|
|
215
269
|
switch (failure?.fate) {
|
|
270
|
+
case "crashed":
|
|
271
|
+
return { seq: adm.seq, type: "crashed" };
|
|
216
272
|
case "failed":
|
|
217
273
|
return {
|
|
218
274
|
seq: adm.seq,
|
|
@@ -251,13 +307,41 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
|
|
|
251
307
|
}
|
|
252
308
|
};
|
|
253
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
|
+
|
|
254
334
|
// ─── host ───────────────────────────────────────────────
|
|
255
335
|
|
|
256
336
|
export type SocketHostState = {
|
|
257
337
|
heartbeatMs: number;
|
|
258
338
|
wsInitTimeoutMs: number;
|
|
339
|
+
maxSubscribers: number;
|
|
340
|
+
maxInflightCommands: number;
|
|
341
|
+
streamQueueSize: number;
|
|
259
342
|
clientRecordLimit: number;
|
|
260
343
|
finOnIdle: boolean;
|
|
344
|
+
pushback: (() => number | null) | undefined;
|
|
261
345
|
authorize: StatewireHostInternal.SocketHostOptions["authorize"];
|
|
262
346
|
clients: Map<string, ClientRec>;
|
|
263
347
|
conns: Set<Conn>;
|
|
@@ -273,6 +357,7 @@ export type SocketHostState = {
|
|
|
273
357
|
stateHost: StatewireHost.StateHost | null;
|
|
274
358
|
commandsHost: CommandsHost | null;
|
|
275
359
|
current: Statewire<unknown> | null;
|
|
360
|
+
persistence: StatewireHostInternal.SocketHostPersistence | undefined;
|
|
276
361
|
post: () => void;
|
|
277
362
|
connect: (
|
|
278
363
|
clientId: string,
|
|
@@ -358,6 +443,11 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
358
443
|
}
|
|
359
444
|
};
|
|
360
445
|
|
|
446
|
+
export const atSubscriberLimit = (h: SocketHostState): boolean =>
|
|
447
|
+
h.conns.size +
|
|
448
|
+
[...h.pendingConns].filter((conn) => conn.authorizing).length >=
|
|
449
|
+
h.maxSubscribers;
|
|
450
|
+
|
|
361
451
|
const ensureHeartbeat = (h: SocketHostState) => {
|
|
362
452
|
if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
|
|
363
453
|
h.heartbeatTimer = setInterval(() => {
|
|
@@ -380,6 +470,7 @@ const setHeartbeatMs = (h: SocketHostState, ms: number) => {
|
|
|
380
470
|
};
|
|
381
471
|
|
|
382
472
|
const idleLevel = (h: SocketHostState): boolean => {
|
|
473
|
+
if (h.current?.work?.isIdle === false) return false;
|
|
383
474
|
for (const rec of h.clients.values()) {
|
|
384
475
|
if (rec.inflight.size > 0) return false;
|
|
385
476
|
}
|
|
@@ -463,6 +554,24 @@ const queueCmd = (h: SocketHostState, clientId: string, cmd: StatewireCmd) => {
|
|
|
463
554
|
scheduleFlush(h);
|
|
464
555
|
};
|
|
465
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
|
+
|
|
466
575
|
const retain = (rec: ClientRec, cmd: StatewireCmd) => {
|
|
467
576
|
const now = Date.now();
|
|
468
577
|
rec.results.set(cmd.seq, { ts: now, cmd });
|
|
@@ -493,6 +602,7 @@ const reemit = (h: SocketHostState, rec: ClientRec, seq: number) => {
|
|
|
493
602
|
return;
|
|
494
603
|
}
|
|
495
604
|
rec.results.delete(seq);
|
|
605
|
+
notifyClientRecords(h);
|
|
496
606
|
}
|
|
497
607
|
const adm = rec.inflight.get(seq);
|
|
498
608
|
if (adm) {
|
|
@@ -515,6 +625,7 @@ const settleCmd = (
|
|
|
515
625
|
rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
|
|
516
626
|
adm.applied = true;
|
|
517
627
|
queueCmd(h, rec.id, cmd);
|
|
628
|
+
notifyClientRecords(h);
|
|
518
629
|
};
|
|
519
630
|
|
|
520
631
|
const settleWire = (
|
|
@@ -527,8 +638,10 @@ const settleWire = (
|
|
|
527
638
|
const callerOf = (
|
|
528
639
|
h: SocketHostState,
|
|
529
640
|
rec: ClientRec,
|
|
641
|
+
auth: unknown,
|
|
530
642
|
): StatewireHost.Caller => ({
|
|
531
643
|
clientId: rec.id,
|
|
644
|
+
auth,
|
|
532
645
|
get context() {
|
|
533
646
|
return rec.context;
|
|
534
647
|
},
|
|
@@ -558,6 +671,7 @@ const admitCmd = (
|
|
|
558
671
|
h: SocketHostState,
|
|
559
672
|
rec: ClientRec,
|
|
560
673
|
statement: CmdStatement,
|
|
674
|
+
auth: unknown,
|
|
561
675
|
) => {
|
|
562
676
|
const adm: Admission = {
|
|
563
677
|
seq: statement.seq,
|
|
@@ -567,6 +681,7 @@ const admitCmd = (
|
|
|
567
681
|
settled: false,
|
|
568
682
|
};
|
|
569
683
|
rec.inflight.set(statement.seq, adm);
|
|
684
|
+
notifyClientRecords(h);
|
|
570
685
|
if (!Array.isArray(statement.params)) {
|
|
571
686
|
rejectStatement(h, rec, adm, "params must be an array");
|
|
572
687
|
return;
|
|
@@ -603,11 +718,12 @@ const admitCmd = (
|
|
|
603
718
|
method: statement.method,
|
|
604
719
|
params,
|
|
605
720
|
clientId: rec.id,
|
|
606
|
-
caller: callerOf(h, rec),
|
|
721
|
+
caller: callerOf(h, rec, auth),
|
|
607
722
|
onApplied: () => {
|
|
608
723
|
if (adm.settled || adm.applied) return;
|
|
609
724
|
adm.applied = true;
|
|
610
725
|
rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
|
|
726
|
+
notifyClientRecords(h);
|
|
611
727
|
scheduleFlush(h);
|
|
612
728
|
},
|
|
613
729
|
onSettle: (outcome) => settleWire(h, rec, adm, outcome),
|
|
@@ -621,12 +737,18 @@ const admitCmd = (
|
|
|
621
737
|
// {syn} stating both watermarks, nothing consumed. A gap is never a close.
|
|
622
738
|
export type FrameAdmission =
|
|
623
739
|
| { type: "ok" }
|
|
624
|
-
| {
|
|
740
|
+
| {
|
|
741
|
+
type: "refused";
|
|
742
|
+
syn: {
|
|
743
|
+
syn: { seq: number; ctxSeq: number; retry?: number };
|
|
744
|
+
};
|
|
745
|
+
};
|
|
625
746
|
|
|
626
747
|
export const admitFrame = (
|
|
627
748
|
h: SocketHostState,
|
|
628
749
|
rec: ClientRec,
|
|
629
750
|
frame: ParsedFrame,
|
|
751
|
+
auth: unknown,
|
|
630
752
|
): FrameAdmission => {
|
|
631
753
|
rec.touched = Date.now();
|
|
632
754
|
const through = admittedThrough(rec);
|
|
@@ -639,40 +761,100 @@ export const admitFrame = (
|
|
|
639
761
|
(ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
|
|
640
762
|
(cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
|
|
641
763
|
) {
|
|
764
|
+
notifyClientRecords(h);
|
|
642
765
|
return {
|
|
643
766
|
type: "refused",
|
|
644
767
|
syn: { syn: { seq: through, ctxSeq: rec.ctxSeq } },
|
|
645
768
|
};
|
|
646
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
|
+
}
|
|
647
804
|
for (const statement of ctxNew) {
|
|
648
805
|
rec.context = statement.value;
|
|
649
806
|
rec.ctxSeq = statement.seq;
|
|
650
807
|
}
|
|
651
|
-
for (const statement of cmdNew) admitCmd(h, rec, statement);
|
|
808
|
+
for (const statement of cmdNew) admitCmd(h, rec, statement, auth);
|
|
652
809
|
if (cmdNew.length > 0) {
|
|
653
810
|
// Memory host: admitted means retained — dur rides the admitting flush.
|
|
654
811
|
queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
|
|
655
812
|
}
|
|
813
|
+
notifyClientRecords(h);
|
|
656
814
|
scheduleFlush(h);
|
|
657
815
|
return { type: "ok" };
|
|
658
816
|
};
|
|
659
817
|
|
|
660
818
|
const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
661
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
|
+
}
|
|
662
834
|
const first = !conn.sawFrame;
|
|
663
835
|
conn.sawFrame = true;
|
|
664
836
|
let frame: ParsedFrame;
|
|
665
837
|
try {
|
|
666
|
-
frame = parseFrame(data
|
|
838
|
+
frame = first ? parseInitialFrame(data).frame : parseFrame(data);
|
|
667
839
|
} catch (error) {
|
|
668
840
|
const reason = violationReason(error);
|
|
669
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
|
+
);
|
|
670
852
|
closeConn(h, conn, 1008, reason);
|
|
671
853
|
return;
|
|
672
854
|
}
|
|
673
855
|
if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
|
|
674
856
|
const rec = h.clients.get(conn.clientId)!;
|
|
675
|
-
const admission = admitFrame(h, rec, frame);
|
|
857
|
+
const admission = admitFrame(h, rec, frame, conn.auth);
|
|
676
858
|
if (admission.type === "refused") {
|
|
677
859
|
sendTo(h, conn, JSON.stringify(admission.syn));
|
|
678
860
|
}
|
|
@@ -705,6 +887,28 @@ const sendPending = (pending: PendingConn, data: string): boolean => {
|
|
|
705
887
|
}
|
|
706
888
|
};
|
|
707
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
|
+
|
|
708
912
|
const rejectPendingAuthorization = (
|
|
709
913
|
h: SocketHostState,
|
|
710
914
|
pending: PendingConn,
|
|
@@ -721,7 +925,12 @@ const rejectPendingAuthorization = (
|
|
|
721
925
|
pending,
|
|
722
926
|
JSON.stringify({
|
|
723
927
|
fin: {
|
|
724
|
-
reason:
|
|
928
|
+
reason:
|
|
929
|
+
status === 403
|
|
930
|
+
? "forbidden"
|
|
931
|
+
: unauthorized
|
|
932
|
+
? "unauthorized"
|
|
933
|
+
: "error",
|
|
725
934
|
message,
|
|
726
935
|
...(status !== undefined && { status }),
|
|
727
936
|
},
|
|
@@ -732,7 +941,15 @@ const rejectPendingAuthorization = (
|
|
|
732
941
|
|
|
733
942
|
const normalizedHeaders = (
|
|
734
943
|
headers: Record<string, string>,
|
|
735
|
-
): 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
|
+
};
|
|
736
953
|
|
|
737
954
|
const connectAuthorized = (
|
|
738
955
|
h: SocketHostState,
|
|
@@ -741,12 +958,13 @@ const connectAuthorized = (
|
|
|
741
958
|
options: StatewireHostInternal.SocketConnectOptions,
|
|
742
959
|
): StatewireHostInternal.SocketConnection | null => {
|
|
743
960
|
const authorize = h.authorize;
|
|
744
|
-
if (
|
|
961
|
+
if (options.authorized === true) {
|
|
745
962
|
const registration = register(
|
|
746
963
|
h,
|
|
747
964
|
clientId,
|
|
748
965
|
socket,
|
|
749
966
|
options.authorized === true,
|
|
967
|
+
options.auth,
|
|
750
968
|
);
|
|
751
969
|
return registration?.connection ?? null;
|
|
752
970
|
}
|
|
@@ -773,6 +991,7 @@ const connectAuthorized = (
|
|
|
773
991
|
socket,
|
|
774
992
|
open: true,
|
|
775
993
|
authorizing: false,
|
|
994
|
+
queued: [],
|
|
776
995
|
timer: null,
|
|
777
996
|
connection: null,
|
|
778
997
|
};
|
|
@@ -795,69 +1014,95 @@ const connectAuthorized = (
|
|
|
795
1014
|
return;
|
|
796
1015
|
}
|
|
797
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
|
+
}
|
|
798
1030
|
if (pending.authorizing) {
|
|
799
|
-
|
|
1031
|
+
pending.queued.push(data);
|
|
800
1032
|
return;
|
|
801
1033
|
}
|
|
802
|
-
let
|
|
1034
|
+
let initial: ReturnType<typeof parseInitialFrame>;
|
|
803
1035
|
try {
|
|
804
|
-
|
|
1036
|
+
initial = parseInitialFrame(data);
|
|
805
1037
|
} catch (error) {
|
|
1038
|
+
if (error instanceof Response) {
|
|
1039
|
+
rejectPendingAuthorization(h, pending, error);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
806
1042
|
const reason = violationReason(error);
|
|
807
1043
|
if (reason === null) throw error;
|
|
808
1044
|
sendPending(
|
|
809
1045
|
pending,
|
|
810
1046
|
JSON.stringify({
|
|
811
1047
|
fin: {
|
|
812
|
-
reason: "error",
|
|
813
|
-
message:
|
|
1048
|
+
reason: "protocol-error",
|
|
1049
|
+
message: reason,
|
|
814
1050
|
},
|
|
815
1051
|
}),
|
|
816
1052
|
);
|
|
817
1053
|
closePending(h, pending, 1008, reason);
|
|
818
1054
|
return;
|
|
819
1055
|
}
|
|
820
|
-
|
|
821
|
-
rejectPendingAuthorization(
|
|
822
|
-
h,
|
|
823
|
-
pending,
|
|
824
|
-
new Response(null, { status: 401 }),
|
|
825
|
-
);
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
1056
|
+
const frame = initial.frame;
|
|
828
1057
|
let headers: Record<string, string>;
|
|
829
1058
|
try {
|
|
830
|
-
headers = normalizedHeaders(
|
|
1059
|
+
headers = normalizedHeaders(initial.headers);
|
|
831
1060
|
} catch {
|
|
832
1061
|
sendPending(
|
|
833
1062
|
pending,
|
|
834
1063
|
JSON.stringify({
|
|
835
1064
|
fin: {
|
|
836
|
-
reason: "error",
|
|
837
|
-
message: "
|
|
1065
|
+
reason: "protocol-error",
|
|
1066
|
+
message: "malformed headers",
|
|
838
1067
|
},
|
|
839
1068
|
}),
|
|
840
1069
|
);
|
|
841
1070
|
closePending(h, pending, 1008, "malformed headers");
|
|
842
1071
|
return;
|
|
843
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
|
+
}
|
|
844
1087
|
pending.authorizing = true;
|
|
845
1088
|
Promise.resolve()
|
|
846
|
-
.then(() => authorize({ headers }))
|
|
847
|
-
.then(() => {
|
|
1089
|
+
.then(() => authorize?.({ headers }))
|
|
1090
|
+
.then((auth) => {
|
|
848
1091
|
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
849
|
-
options.onAuthorized?.();
|
|
1092
|
+
options.onAuthorized?.(auth);
|
|
850
1093
|
pending.open = false;
|
|
851
1094
|
h.pendingConns.delete(pending);
|
|
852
1095
|
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
853
|
-
const registration = register(h, clientId, socket, true);
|
|
1096
|
+
const registration = register(h, clientId, socket, true, auth);
|
|
854
1097
|
if (registration === null) return;
|
|
855
1098
|
pending.connection = registration.connection;
|
|
856
|
-
if (frame.ctx.length
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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));
|
|
860
1103
|
}
|
|
1104
|
+
for (const data of pending.queued.splice(0))
|
|
1105
|
+
registration.connection.message(data);
|
|
861
1106
|
})
|
|
862
1107
|
.catch((error: unknown) => {
|
|
863
1108
|
if (pending.open) rejectPendingAuthorization(h, pending, error);
|
|
@@ -873,14 +1118,17 @@ const connectAuthorized = (
|
|
|
873
1118
|
const gcClients = (h: SocketHostState) => {
|
|
874
1119
|
let excess = h.clients.size - h.clientRecordLimit;
|
|
875
1120
|
if (excess <= 0) return;
|
|
1121
|
+
let changed = false;
|
|
876
1122
|
const live = new Set([...h.conns].map((conn) => conn.clientId));
|
|
877
1123
|
const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
|
|
878
1124
|
for (const rec of oldest) {
|
|
879
|
-
if (excess <= 0)
|
|
1125
|
+
if (excess <= 0) break;
|
|
880
1126
|
if (live.has(rec.id) || rec.inflight.size > 0) continue;
|
|
881
1127
|
h.clients.delete(rec.id);
|
|
1128
|
+
changed = true;
|
|
882
1129
|
excess -= 1;
|
|
883
1130
|
}
|
|
1131
|
+
if (changed) notifyClientRecords(h);
|
|
884
1132
|
};
|
|
885
1133
|
|
|
886
1134
|
export type Registration = {
|
|
@@ -894,6 +1142,7 @@ export const register = (
|
|
|
894
1142
|
clientId: string,
|
|
895
1143
|
socket: StatewireHostInternal.Socket,
|
|
896
1144
|
sawFrame = false,
|
|
1145
|
+
auth: unknown = undefined,
|
|
897
1146
|
): Registration | null => {
|
|
898
1147
|
const refuse = (code: number, reason: string) => {
|
|
899
1148
|
try {
|
|
@@ -915,22 +1164,42 @@ export const register = (
|
|
|
915
1164
|
refuse(1000, "finished");
|
|
916
1165
|
return null;
|
|
917
1166
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
+
})();
|
|
934
1203
|
rec.touched = Date.now();
|
|
935
1204
|
const conn: Conn = {
|
|
936
1205
|
clientId,
|
|
@@ -939,9 +1208,11 @@ export const register = (
|
|
|
939
1208
|
sawFrame,
|
|
940
1209
|
lastSentAt: 0,
|
|
941
1210
|
lease: null,
|
|
1211
|
+
auth,
|
|
942
1212
|
};
|
|
943
1213
|
h.conns.add(conn);
|
|
944
1214
|
gcClients(h);
|
|
1215
|
+
notifyClientRecords(h);
|
|
945
1216
|
ensureHeartbeat(h);
|
|
946
1217
|
const { value, meta } = encodeValue(h.stateHost!.snapshot());
|
|
947
1218
|
const hello: StatewireEnvelope = {
|
|
@@ -978,7 +1249,14 @@ const connect = (
|
|
|
978
1249
|
};
|
|
979
1250
|
|
|
980
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
|
+
}
|
|
981
1258
|
if (h.finished !== null || h.disposed) return;
|
|
1259
|
+
h.commandsHost?.abort?.();
|
|
982
1260
|
h.finished = fin;
|
|
983
1261
|
for (const pending of [...h.pendingConns]) {
|
|
984
1262
|
sendPending(pending, JSON.stringify({ fin }));
|
|
@@ -1001,9 +1279,26 @@ const suspend = (h: SocketHostState) => {
|
|
|
1001
1279
|
h.stateHost?.setOnOp(null);
|
|
1002
1280
|
};
|
|
1003
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
|
+
|
|
1004
1298
|
const useSocketHost = (
|
|
1005
1299
|
transport: ResourceElement<Statewire>,
|
|
1006
1300
|
options?: StatewireHostInternal.SocketHostOptions,
|
|
1301
|
+
persistence?: StatewireHostInternal.SocketHostPersistence,
|
|
1007
1302
|
): Pick<
|
|
1008
1303
|
StatewireHostInternal.SocketHost,
|
|
1009
1304
|
"connect" | "finish" | "stream" | "frames"
|
|
@@ -1012,10 +1307,14 @@ const useSocketHost = (
|
|
|
1012
1307
|
const host: SocketHostState = {
|
|
1013
1308
|
heartbeatMs: 15_000,
|
|
1014
1309
|
wsInitTimeoutMs: 5_000,
|
|
1310
|
+
maxSubscribers: MAX_SUBSCRIBERS,
|
|
1311
|
+
maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
1312
|
+
streamQueueSize: STREAM_QUEUE_SIZE,
|
|
1015
1313
|
clientRecordLimit: CLIENT_RECORD_LIMIT,
|
|
1016
1314
|
finOnIdle: false,
|
|
1315
|
+
pushback: undefined,
|
|
1017
1316
|
authorize: undefined,
|
|
1018
|
-
clients:
|
|
1317
|
+
clients: restoreClientRecords(persistence?.clientRecords),
|
|
1019
1318
|
conns: new Set(),
|
|
1020
1319
|
pendingConns: new Set(),
|
|
1021
1320
|
pendingOps: [],
|
|
@@ -1029,6 +1328,7 @@ const useSocketHost = (
|
|
|
1029
1328
|
stateHost: null,
|
|
1030
1329
|
commandsHost: null,
|
|
1031
1330
|
current: null,
|
|
1331
|
+
persistence,
|
|
1032
1332
|
post: createMacrotaskPoster(() => {
|
|
1033
1333
|
host.flushScheduled = false;
|
|
1034
1334
|
flush(host);
|
|
@@ -1042,7 +1342,32 @@ const useSocketHost = (
|
|
|
1042
1342
|
return host;
|
|
1043
1343
|
});
|
|
1044
1344
|
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
|
|
1345
|
+
h.pushback = options?.pushback;
|
|
1045
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;
|
|
1046
1371
|
h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
|
|
1047
1372
|
h.finOnIdle = options?.finOnIdle ?? false;
|
|
1048
1373
|
h.authorize = options?.authorize;
|
|
@@ -1050,6 +1375,7 @@ const useSocketHost = (
|
|
|
1050
1375
|
attach(h, value);
|
|
1051
1376
|
useEffect(() => {
|
|
1052
1377
|
attach(h, value);
|
|
1378
|
+
scheduleFlush(h);
|
|
1053
1379
|
});
|
|
1054
1380
|
useEffect(() => {
|
|
1055
1381
|
resume(h);
|
|
@@ -1069,8 +1395,9 @@ const useSocketHost = (
|
|
|
1069
1395
|
export const StatewireSocketHost = (
|
|
1070
1396
|
transport: ResourceElement<Statewire>,
|
|
1071
1397
|
options?: StatewireHostInternal.SocketHostOptions,
|
|
1398
|
+
persistence?: StatewireHostInternal.SocketHostPersistence,
|
|
1072
1399
|
): StatewireHostInternal.SocketHost => {
|
|
1073
|
-
const SocketHostRoot = () => useSocketHost(transport, options);
|
|
1400
|
+
const SocketHostRoot = () => useSocketHost(transport, options, persistence);
|
|
1074
1401
|
const root = createTapRoot(SocketHostRoot);
|
|
1075
1402
|
let disposed = false;
|
|
1076
1403
|
return {
|
|
@@ -1084,7 +1411,9 @@ export const StatewireSocketHost = (
|
|
|
1084
1411
|
dispose: () => {
|
|
1085
1412
|
if (disposed) return;
|
|
1086
1413
|
disposed = true;
|
|
1087
|
-
flushTapSync(() =>
|
|
1414
|
+
flushTapSync(() =>
|
|
1415
|
+
root.getValue().finish({ reason: "reconnect", code: "evicted" }),
|
|
1416
|
+
);
|
|
1088
1417
|
root.unmount();
|
|
1089
1418
|
},
|
|
1090
1419
|
};
|