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/dist/host/socket.js
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import { StatewireSendError } from "../types.js";
|
|
2
2
|
import { getCommandsHost, getStateHost } from "./host.js";
|
|
3
3
|
import { encodeValue, reviveValue } from "../wire/meta.js";
|
|
4
|
+
import { COMMAND_CAPACITY_RETRY_MS, MAX_INFLIGHT_COMMANDS_PER_HOST } from "../wire/limits.js";
|
|
4
5
|
import { createMacrotaskPoster } from "../util/macrotask.js";
|
|
5
6
|
import { frames, stream } from "./http.js";
|
|
6
7
|
import { useEffect, useState } from "@assistant-ui/tap/react-shim";
|
|
7
8
|
import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
|
|
8
9
|
//#region src/host/socket.ts
|
|
9
10
|
const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
|
|
11
|
+
const PRINCIPAL_MISMATCH_MESSAGE = "client id is bound to another principal";
|
|
12
|
+
/** Structural equality over auth values: primitives, Dates, arrays, and plain
|
|
13
|
+
* objects compare by value; anything else by identity. */
|
|
14
|
+
const authEquals = (a, b) => {
|
|
15
|
+
if (Object.is(a, b)) return true;
|
|
16
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
17
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => authEquals(v, b[i]));
|
|
18
|
+
if (typeof a === "object" && typeof b === "object" && a !== null && b !== null && Object.getPrototypeOf(a) === Object.prototype && Object.getPrototypeOf(b) === Object.prototype) {
|
|
19
|
+
const keysA = Object.keys(a);
|
|
20
|
+
const keysB = Object.keys(b);
|
|
21
|
+
return keysA.length === keysB.length && keysA.every((key) => key in b && authEquals(a[key], b[key]));
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
};
|
|
10
25
|
const RESULT_TTL_MS = 3e4;
|
|
11
26
|
const RESULT_LIMIT = 256;
|
|
12
27
|
const CLIENT_RECORD_LIMIT = 1024;
|
|
28
|
+
const MAX_SUBSCRIBERS = 1024;
|
|
29
|
+
const SUBSCRIBER_LIMIT_MESSAGE = "stream subscriber limit reached";
|
|
30
|
+
const STREAM_QUEUE_SIZE = 256;
|
|
13
31
|
const violation = (reason) => {
|
|
14
32
|
throw Object.assign(new Error(reason), { name: "StatewireViolation" });
|
|
15
33
|
};
|
|
@@ -30,6 +48,7 @@ const parseLane = (value) => {
|
|
|
30
48
|
}
|
|
31
49
|
return statements;
|
|
32
50
|
};
|
|
51
|
+
const frameEncoder = new TextEncoder();
|
|
33
52
|
/** v1 ctx statements state the context whole: one root replace with an object value. */
|
|
34
53
|
const contextValue = (ops) => {
|
|
35
54
|
if (!Array.isArray(ops) || ops.length !== 1) throw new Error("context must be a single-op array");
|
|
@@ -39,54 +58,60 @@ const contextValue = (ops) => {
|
|
|
39
58
|
if (!isPlainObject(value)) throw new Error("context value must be an object");
|
|
40
59
|
return value;
|
|
41
60
|
};
|
|
42
|
-
const parseFrameValue = (frame
|
|
61
|
+
const parseFrameValue = (frame) => {
|
|
43
62
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
44
63
|
const keys = Object.keys(frame);
|
|
45
64
|
if (keys.length === 0) return violation("malformed frame");
|
|
46
|
-
const unknown = keys.filter((key) => key !== "
|
|
65
|
+
const unknown = keys.filter((key) => key !== "ctx" && key !== "cmd").sort();
|
|
47
66
|
if (unknown.length > 0) return violation(`unknown frame key: '${unknown[0]}'`);
|
|
48
|
-
let headers = null;
|
|
49
|
-
if (frame["headers"] !== void 0) {
|
|
50
|
-
if (!first) return violation("mid-session headers");
|
|
51
|
-
const raw = frame["headers"];
|
|
52
|
-
if (!isPlainObject(raw) || Object.values(raw).some((value) => typeof value !== "string")) return violation("malformed headers");
|
|
53
|
-
headers = raw;
|
|
54
|
-
}
|
|
55
67
|
const ctx = [];
|
|
56
|
-
if (frame["ctx"] !== void 0)
|
|
57
|
-
ctx
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
if (frame["ctx"] !== void 0) {
|
|
69
|
+
const lane = frame["ctx"];
|
|
70
|
+
if (Array.isArray(lane) && lane.length > 256) return violation("too many context statements");
|
|
71
|
+
for (const statement of parseLane(lane)) {
|
|
72
|
+
let value;
|
|
73
|
+
try {
|
|
74
|
+
value = contextValue(statement["ops"]);
|
|
75
|
+
} catch {
|
|
76
|
+
return violation("invalid-context");
|
|
77
|
+
}
|
|
78
|
+
if (frameEncoder.encode(JSON.stringify(value)).byteLength > 65536) return violation("oversized-context");
|
|
79
|
+
ctx.push({
|
|
80
|
+
seq: statement["seq"],
|
|
81
|
+
value
|
|
82
|
+
});
|
|
83
|
+
}
|
|
63
84
|
}
|
|
64
85
|
const cmd = [];
|
|
65
|
-
if (frame["cmd"] !== void 0)
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
method
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
86
|
+
if (frame["cmd"] !== void 0) {
|
|
87
|
+
const lane = frame["cmd"];
|
|
88
|
+
if (Array.isArray(lane) && lane.length > 256) return violation("too many commands");
|
|
89
|
+
for (const statement of parseLane(lane)) {
|
|
90
|
+
const method = statement["method"];
|
|
91
|
+
if (typeof method !== "string" || !("params" in statement)) return violation("malformed statement");
|
|
92
|
+
cmd.push({
|
|
93
|
+
seq: statement["seq"],
|
|
94
|
+
method,
|
|
95
|
+
params: statement["params"],
|
|
96
|
+
...statement["meta"] !== void 0 && { meta: statement["meta"] }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
74
99
|
}
|
|
75
100
|
return {
|
|
76
|
-
headers,
|
|
77
101
|
ctx,
|
|
78
102
|
cmd
|
|
79
103
|
};
|
|
80
104
|
};
|
|
81
|
-
const parseFrame = (raw
|
|
105
|
+
const parseFrame = (raw) => {
|
|
82
106
|
let frame;
|
|
83
107
|
try {
|
|
84
108
|
frame = JSON.parse(raw);
|
|
85
109
|
} catch {
|
|
86
110
|
return violation("malformed frame");
|
|
87
111
|
}
|
|
88
|
-
return parseFrameValue(frame
|
|
112
|
+
return parseFrameValue(frame);
|
|
89
113
|
};
|
|
114
|
+
const frameIsOversized = (raw) => raw.length > 8388608 || frameEncoder.encode(raw).byteLength > 8388608;
|
|
90
115
|
const encodePayload = (payload) => {
|
|
91
116
|
if (payload === void 0) return {};
|
|
92
117
|
const { value, meta } = encodeValue(payload);
|
|
@@ -103,6 +128,10 @@ const cmdOf = (adm, outcome) => {
|
|
|
103
128
|
};
|
|
104
129
|
const failure = outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
|
|
105
130
|
switch (failure?.fate) {
|
|
131
|
+
case "crashed": return {
|
|
132
|
+
seq: adm.seq,
|
|
133
|
+
type: "crashed"
|
|
134
|
+
};
|
|
106
135
|
case "failed": return {
|
|
107
136
|
seq: adm.seq,
|
|
108
137
|
type: "failed",
|
|
@@ -131,6 +160,25 @@ const cmdOf = (adm, outcome) => {
|
|
|
131
160
|
};
|
|
132
161
|
}
|
|
133
162
|
};
|
|
163
|
+
const parseInitialFrame = (raw) => {
|
|
164
|
+
let value;
|
|
165
|
+
try {
|
|
166
|
+
value = JSON.parse(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
return violation("malformed frame");
|
|
169
|
+
}
|
|
170
|
+
if (!isPlainObject(value)) return violation("malformed frame");
|
|
171
|
+
const { headers, ...lanes } = value;
|
|
172
|
+
if (headers === void 0) throw new Response(null, { status: 401 });
|
|
173
|
+
if (!isPlainObject(headers) || Object.values(headers).some((v) => typeof v !== "string")) return violation("malformed headers");
|
|
174
|
+
return {
|
|
175
|
+
headers,
|
|
176
|
+
frame: Object.keys(lanes).length ? parseFrameValue(lanes) : {
|
|
177
|
+
ctx: [],
|
|
178
|
+
cmd: []
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
};
|
|
134
182
|
const scheduleFlush = (h) => {
|
|
135
183
|
if (h.flushScheduled || h.disposed) return;
|
|
136
184
|
h.flushScheduled = true;
|
|
@@ -181,6 +229,7 @@ const sendTo = (h, conn, data) => {
|
|
|
181
229
|
closeConn(h, conn);
|
|
182
230
|
}
|
|
183
231
|
};
|
|
232
|
+
const atSubscriberLimit = (h) => h.conns.size + [...h.pendingConns].filter((conn) => conn.authorizing).length >= h.maxSubscribers;
|
|
184
233
|
const ensureHeartbeat = (h) => {
|
|
185
234
|
if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
|
|
186
235
|
h.heartbeatTimer = setInterval(() => {
|
|
@@ -199,6 +248,7 @@ const setHeartbeatMs = (h, ms) => {
|
|
|
199
248
|
}
|
|
200
249
|
};
|
|
201
250
|
const idleLevel = (h) => {
|
|
251
|
+
if (h.current?.work?.isIdle === false) return false;
|
|
202
252
|
for (const rec of h.clients.values()) if (rec.inflight.size > 0) return false;
|
|
203
253
|
return true;
|
|
204
254
|
};
|
|
@@ -254,6 +304,21 @@ const queueCmd = (h, clientId, cmd) => {
|
|
|
254
304
|
else h.pendingCmds.set(clientId, [cmd]);
|
|
255
305
|
scheduleFlush(h);
|
|
256
306
|
};
|
|
307
|
+
const notifyClientRecords = (h) => {
|
|
308
|
+
h.persistence?.onClientRecordsChange?.([...h.clients.values()].map((rec) => ({
|
|
309
|
+
id: rec.id,
|
|
310
|
+
lastSeq: admittedThrough(rec),
|
|
311
|
+
ctxSeq: rec.ctxSeq,
|
|
312
|
+
touched: rec.touched,
|
|
313
|
+
context: rec.context,
|
|
314
|
+
results: [...rec.results].map(([seq, entry]) => ({
|
|
315
|
+
seq,
|
|
316
|
+
ts: entry.ts,
|
|
317
|
+
cmd: entry.cmd
|
|
318
|
+
})),
|
|
319
|
+
principal: rec.principal
|
|
320
|
+
})));
|
|
321
|
+
};
|
|
257
322
|
const retain = (rec, cmd) => {
|
|
258
323
|
const now = Date.now();
|
|
259
324
|
rec.results.set(cmd.seq, {
|
|
@@ -274,6 +339,7 @@ const reemit = (h, rec, seq) => {
|
|
|
274
339
|
return;
|
|
275
340
|
}
|
|
276
341
|
rec.results.delete(seq);
|
|
342
|
+
notifyClientRecords(h);
|
|
277
343
|
}
|
|
278
344
|
const adm = rec.inflight.get(seq);
|
|
279
345
|
if (adm) {
|
|
@@ -293,10 +359,12 @@ const settleCmd = (h, rec, adm, cmd) => {
|
|
|
293
359
|
rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
|
|
294
360
|
adm.applied = true;
|
|
295
361
|
queueCmd(h, rec.id, cmd);
|
|
362
|
+
notifyClientRecords(h);
|
|
296
363
|
};
|
|
297
364
|
const settleWire = (h, rec, adm, outcome) => settleCmd(h, rec, adm, cmdOf(adm, outcome));
|
|
298
|
-
const callerOf = (h, rec) => ({
|
|
365
|
+
const callerOf = (h, rec, auth) => ({
|
|
299
366
|
clientId: rec.id,
|
|
367
|
+
auth,
|
|
300
368
|
get context() {
|
|
301
369
|
return rec.context;
|
|
302
370
|
},
|
|
@@ -312,7 +380,7 @@ const rejectStatement = (h, rec, adm, message) => settleCmd(h, rec, adm, {
|
|
|
312
380
|
code: "invalid-params",
|
|
313
381
|
message
|
|
314
382
|
});
|
|
315
|
-
const admitCmd = (h, rec, statement) => {
|
|
383
|
+
const admitCmd = (h, rec, statement, auth) => {
|
|
316
384
|
const adm = {
|
|
317
385
|
seq: statement.seq,
|
|
318
386
|
method: statement.method,
|
|
@@ -321,6 +389,7 @@ const admitCmd = (h, rec, statement) => {
|
|
|
321
389
|
settled: false
|
|
322
390
|
};
|
|
323
391
|
rec.inflight.set(statement.seq, adm);
|
|
392
|
+
notifyClientRecords(h);
|
|
324
393
|
if (!Array.isArray(statement.params)) {
|
|
325
394
|
rejectStatement(h, rec, adm, "params must be an array");
|
|
326
395
|
return;
|
|
@@ -349,11 +418,12 @@ const admitCmd = (h, rec, statement) => {
|
|
|
349
418
|
method: statement.method,
|
|
350
419
|
params,
|
|
351
420
|
clientId: rec.id,
|
|
352
|
-
caller: callerOf(h, rec),
|
|
421
|
+
caller: callerOf(h, rec, auth),
|
|
353
422
|
onApplied: () => {
|
|
354
423
|
if (adm.settled || adm.applied) return;
|
|
355
424
|
adm.applied = true;
|
|
356
425
|
rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
|
|
426
|
+
notifyClientRecords(h);
|
|
357
427
|
scheduleFlush(h);
|
|
358
428
|
},
|
|
359
429
|
onSettle: (outcome) => settleWire(h, rec, adm, outcome)
|
|
@@ -364,47 +434,89 @@ const admitCmd = (h, rec, statement) => {
|
|
|
364
434
|
});
|
|
365
435
|
});
|
|
366
436
|
};
|
|
367
|
-
const admitFrame = (h, rec, frame) => {
|
|
437
|
+
const admitFrame = (h, rec, frame, auth) => {
|
|
368
438
|
rec.touched = Date.now();
|
|
369
439
|
const through = admittedThrough(rec);
|
|
370
440
|
for (const statement of frame.cmd) if (statement.seq <= through) reemit(h, rec, statement.seq);
|
|
371
441
|
const ctxNew = frame.ctx.filter((s) => s.seq > rec.ctxSeq);
|
|
372
442
|
const cmdNew = frame.cmd.filter((s) => s.seq > through);
|
|
373
|
-
if (ctxNew.length > 0 && ctxNew[0].seq !== rec.ctxSeq + 1 || cmdNew.length > 0 && cmdNew[0].seq !== through + 1)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
443
|
+
if (ctxNew.length > 0 && ctxNew[0].seq !== rec.ctxSeq + 1 || cmdNew.length > 0 && cmdNew[0].seq !== through + 1) {
|
|
444
|
+
notifyClientRecords(h);
|
|
445
|
+
return {
|
|
446
|
+
type: "refused",
|
|
447
|
+
syn: { syn: {
|
|
448
|
+
seq: through,
|
|
449
|
+
ctxSeq: rec.ctxSeq
|
|
450
|
+
} }
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
if (cmdNew.length > 0) {
|
|
454
|
+
let hostCommandLoad = 0;
|
|
455
|
+
for (const client of h.clients.values()) hostCommandLoad += client.inflight.size;
|
|
456
|
+
if (rec.inflight.size + cmdNew.length > 256 || hostCommandLoad + cmdNew.length > h.maxInflightCommands) return {
|
|
457
|
+
type: "refused",
|
|
458
|
+
syn: { syn: {
|
|
459
|
+
seq: through,
|
|
460
|
+
ctxSeq: rec.ctxSeq,
|
|
461
|
+
retry: COMMAND_CAPACITY_RETRY_MS
|
|
462
|
+
} }
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if ((ctxNew.length || cmdNew.length) && h.pushback) {
|
|
466
|
+
const retry = h.pushback();
|
|
467
|
+
if (retry !== null) {
|
|
468
|
+
if (!Number.isSafeInteger(retry) || retry < 0) throw new TypeError("pushback must return a nonnegative safe integer or null");
|
|
469
|
+
return {
|
|
470
|
+
type: "refused",
|
|
471
|
+
syn: { syn: {
|
|
472
|
+
seq: through,
|
|
473
|
+
ctxSeq: rec.ctxSeq,
|
|
474
|
+
retry
|
|
475
|
+
} }
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
}
|
|
380
479
|
for (const statement of ctxNew) {
|
|
381
480
|
rec.context = statement.value;
|
|
382
481
|
rec.ctxSeq = statement.seq;
|
|
383
482
|
}
|
|
384
|
-
for (const statement of cmdNew) admitCmd(h, rec, statement);
|
|
483
|
+
for (const statement of cmdNew) admitCmd(h, rec, statement, auth);
|
|
385
484
|
if (cmdNew.length > 0) queueCmd(h, rec.id, {
|
|
386
485
|
seq: cmdNew.at(-1).seq,
|
|
387
486
|
dur: true
|
|
388
487
|
});
|
|
488
|
+
notifyClientRecords(h);
|
|
389
489
|
scheduleFlush(h);
|
|
390
490
|
return { type: "ok" };
|
|
391
491
|
};
|
|
392
492
|
const message = (h, conn, data) => {
|
|
393
493
|
if (!conn.open || h.disposed || h.finished !== null) return;
|
|
494
|
+
if (frameIsOversized(data)) {
|
|
495
|
+
sendTo(h, conn, JSON.stringify({ fin: {
|
|
496
|
+
reason: "protocol-error",
|
|
497
|
+
message: "frame too large"
|
|
498
|
+
} }));
|
|
499
|
+
closeConn(h, conn, 1009, "frame too large");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
394
502
|
const first = !conn.sawFrame;
|
|
395
503
|
conn.sawFrame = true;
|
|
396
504
|
let frame;
|
|
397
505
|
try {
|
|
398
|
-
frame = parseFrame(data
|
|
506
|
+
frame = first ? parseInitialFrame(data).frame : parseFrame(data);
|
|
399
507
|
} catch (error) {
|
|
400
508
|
const reason = violationReason(error);
|
|
401
509
|
if (reason === null) throw error;
|
|
510
|
+
sendTo(h, conn, JSON.stringify({ fin: {
|
|
511
|
+
reason: "protocol-error",
|
|
512
|
+
message: reason
|
|
513
|
+
} }));
|
|
402
514
|
closeConn(h, conn, 1008, reason);
|
|
403
515
|
return;
|
|
404
516
|
}
|
|
405
517
|
if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
|
|
406
518
|
const rec = h.clients.get(conn.clientId);
|
|
407
|
-
const admission = admitFrame(h, rec, frame);
|
|
519
|
+
const admission = admitFrame(h, rec, frame, conn.auth);
|
|
408
520
|
if (admission.type === "refused") sendTo(h, conn, JSON.stringify(admission.syn));
|
|
409
521
|
};
|
|
410
522
|
const closePending = (h, pending, code, reason) => {
|
|
@@ -425,22 +537,43 @@ const sendPending = (pending, data) => {
|
|
|
425
537
|
return false;
|
|
426
538
|
}
|
|
427
539
|
};
|
|
540
|
+
const refuseSubscriberLimit = (socket) => {
|
|
541
|
+
try {
|
|
542
|
+
socket.send(JSON.stringify({ fin: {
|
|
543
|
+
reason: "reconnect",
|
|
544
|
+
code: "overloaded",
|
|
545
|
+
message: SUBSCRIBER_LIMIT_MESSAGE
|
|
546
|
+
} }));
|
|
547
|
+
} catch {}
|
|
548
|
+
try {
|
|
549
|
+
socket.close(1013, SUBSCRIBER_LIMIT_MESSAGE);
|
|
550
|
+
} catch {}
|
|
551
|
+
return null;
|
|
552
|
+
};
|
|
428
553
|
const rejectPendingAuthorization = (h, pending, error) => {
|
|
429
554
|
const response = error instanceof Response ? error : null;
|
|
430
555
|
const unauthorized = response?.status === 401 || response?.status === 403;
|
|
431
556
|
const status = response?.status;
|
|
432
557
|
const message = unauthorized ? "unauthorized" : response?.statusText || (status === void 0 ? "authorization failed" : `HTTP ${status}`);
|
|
433
558
|
sendPending(pending, JSON.stringify({ fin: {
|
|
434
|
-
reason: unauthorized ? "unauthorized" : "error",
|
|
559
|
+
reason: status === 403 ? "forbidden" : unauthorized ? "unauthorized" : "error",
|
|
435
560
|
message,
|
|
436
561
|
...status !== void 0 && { status }
|
|
437
562
|
} }));
|
|
438
563
|
closePending(h, pending, unauthorized ? 1008 : 1011, message);
|
|
439
564
|
};
|
|
440
|
-
const normalizedHeaders = (headers) =>
|
|
565
|
+
const normalizedHeaders = (headers) => {
|
|
566
|
+
const seen = /* @__PURE__ */ new Set();
|
|
567
|
+
for (const name of Object.keys(headers)) {
|
|
568
|
+
const key = name.toLowerCase();
|
|
569
|
+
if (seen.has(key)) throw new TypeError("duplicate header name");
|
|
570
|
+
seen.add(key);
|
|
571
|
+
}
|
|
572
|
+
return Object.fromEntries(new Headers(headers));
|
|
573
|
+
};
|
|
441
574
|
const connectAuthorized = (h, clientId, socket, options) => {
|
|
442
575
|
const authorize = h.authorize;
|
|
443
|
-
if (
|
|
576
|
+
if (options.authorized === true) return register(h, clientId, socket, options.authorized === true, options.auth)?.connection ?? null;
|
|
444
577
|
const refuse = (code, reason) => {
|
|
445
578
|
try {
|
|
446
579
|
socket.close(code, reason);
|
|
@@ -460,6 +593,7 @@ const connectAuthorized = (h, clientId, socket, options) => {
|
|
|
460
593
|
socket,
|
|
461
594
|
open: true,
|
|
462
595
|
authorizing: false,
|
|
596
|
+
queued: [],
|
|
463
597
|
timer: null,
|
|
464
598
|
connection: null
|
|
465
599
|
};
|
|
@@ -474,51 +608,71 @@ const connectAuthorized = (h, clientId, socket, options) => {
|
|
|
474
608
|
return;
|
|
475
609
|
}
|
|
476
610
|
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
611
|
+
if (frameIsOversized(data)) {
|
|
612
|
+
sendPending(pending, JSON.stringify({ fin: {
|
|
613
|
+
reason: "protocol-error",
|
|
614
|
+
message: "frame too large"
|
|
615
|
+
} }));
|
|
616
|
+
closePending(h, pending, 1009, "frame too large");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
477
619
|
if (pending.authorizing) {
|
|
478
|
-
|
|
620
|
+
pending.queued.push(data);
|
|
479
621
|
return;
|
|
480
622
|
}
|
|
481
|
-
let
|
|
623
|
+
let initial;
|
|
482
624
|
try {
|
|
483
|
-
|
|
625
|
+
initial = parseInitialFrame(data);
|
|
484
626
|
} catch (error) {
|
|
627
|
+
if (error instanceof Response) {
|
|
628
|
+
rejectPendingAuthorization(h, pending, error);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
485
631
|
const reason = violationReason(error);
|
|
486
632
|
if (reason === null) throw error;
|
|
487
633
|
sendPending(pending, JSON.stringify({ fin: {
|
|
488
|
-
reason: "error",
|
|
489
|
-
message:
|
|
634
|
+
reason: "protocol-error",
|
|
635
|
+
message: reason
|
|
490
636
|
} }));
|
|
491
637
|
closePending(h, pending, 1008, reason);
|
|
492
638
|
return;
|
|
493
639
|
}
|
|
494
|
-
|
|
495
|
-
rejectPendingAuthorization(h, pending, new Response(null, { status: 401 }));
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
640
|
+
const frame = initial.frame;
|
|
498
641
|
let headers;
|
|
499
642
|
try {
|
|
500
|
-
headers = normalizedHeaders(
|
|
643
|
+
headers = normalizedHeaders(initial.headers);
|
|
501
644
|
} catch {
|
|
502
645
|
sendPending(pending, JSON.stringify({ fin: {
|
|
503
|
-
reason: "error",
|
|
504
|
-
message: "
|
|
646
|
+
reason: "protocol-error",
|
|
647
|
+
message: "malformed headers"
|
|
505
648
|
} }));
|
|
506
649
|
closePending(h, pending, 1008, "malformed headers");
|
|
507
650
|
return;
|
|
508
651
|
}
|
|
652
|
+
if (atSubscriberLimit(h)) {
|
|
653
|
+
sendPending(pending, JSON.stringify({ fin: {
|
|
654
|
+
reason: "reconnect",
|
|
655
|
+
code: "overloaded",
|
|
656
|
+
message: SUBSCRIBER_LIMIT_MESSAGE
|
|
657
|
+
} }));
|
|
658
|
+
closePending(h, pending, 1013, SUBSCRIBER_LIMIT_MESSAGE);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
509
661
|
pending.authorizing = true;
|
|
510
|
-
Promise.resolve().then(() => authorize({ headers })).then(() => {
|
|
662
|
+
Promise.resolve().then(() => authorize?.({ headers })).then((auth) => {
|
|
511
663
|
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
512
|
-
options.onAuthorized?.();
|
|
664
|
+
options.onAuthorized?.(auth);
|
|
513
665
|
pending.open = false;
|
|
514
666
|
h.pendingConns.delete(pending);
|
|
515
667
|
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
516
|
-
const registration = register(h, clientId, socket, true);
|
|
668
|
+
const registration = register(h, clientId, socket, true, auth);
|
|
517
669
|
if (registration === null) return;
|
|
518
670
|
pending.connection = registration.connection;
|
|
519
|
-
if (frame.ctx.length
|
|
520
|
-
|
|
521
|
-
|
|
671
|
+
if (frame.ctx.length || frame.cmd.length) {
|
|
672
|
+
const admission = admitFrame(h, registration.rec, frame, auth);
|
|
673
|
+
if (admission.type === "refused") sendTo(h, registration.conn, JSON.stringify(admission.syn));
|
|
674
|
+
}
|
|
675
|
+
for (const data of pending.queued.splice(0)) registration.connection.message(data);
|
|
522
676
|
}).catch((error) => {
|
|
523
677
|
if (pending.open) rejectPendingAuthorization(h, pending, error);
|
|
524
678
|
});
|
|
@@ -532,16 +686,19 @@ const connectAuthorized = (h, clientId, socket, options) => {
|
|
|
532
686
|
const gcClients = (h) => {
|
|
533
687
|
let excess = h.clients.size - h.clientRecordLimit;
|
|
534
688
|
if (excess <= 0) return;
|
|
689
|
+
let changed = false;
|
|
535
690
|
const live = new Set([...h.conns].map((conn) => conn.clientId));
|
|
536
691
|
const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
|
|
537
692
|
for (const rec of oldest) {
|
|
538
|
-
if (excess <= 0)
|
|
693
|
+
if (excess <= 0) break;
|
|
539
694
|
if (live.has(rec.id) || rec.inflight.size > 0) continue;
|
|
540
695
|
h.clients.delete(rec.id);
|
|
696
|
+
changed = true;
|
|
541
697
|
excess -= 1;
|
|
542
698
|
}
|
|
699
|
+
if (changed) notifyClientRecords(h);
|
|
543
700
|
};
|
|
544
|
-
const register = (h, clientId, socket, sawFrame = false) => {
|
|
701
|
+
const register = (h, clientId, socket, sawFrame = false, auth = void 0) => {
|
|
545
702
|
const refuse = (code, reason) => {
|
|
546
703
|
try {
|
|
547
704
|
socket.close(code, reason);
|
|
@@ -558,7 +715,19 @@ const register = (h, clientId, socket, sawFrame = false) => {
|
|
|
558
715
|
refuse(1e3, "finished");
|
|
559
716
|
return null;
|
|
560
717
|
}
|
|
561
|
-
|
|
718
|
+
if (atSubscriberLimit(h)) return refuseSubscriberLimit(socket);
|
|
719
|
+
const known = h.clients.get(clientId);
|
|
720
|
+
if (known !== void 0 && !authEquals(known.principal, auth)) {
|
|
721
|
+
try {
|
|
722
|
+
socket.send(JSON.stringify({ fin: {
|
|
723
|
+
reason: "forbidden",
|
|
724
|
+
message: PRINCIPAL_MISMATCH_MESSAGE,
|
|
725
|
+
status: 403
|
|
726
|
+
} }));
|
|
727
|
+
} catch {}
|
|
728
|
+
return refuse(1008, PRINCIPAL_MISMATCH_MESSAGE);
|
|
729
|
+
}
|
|
730
|
+
const rec = known ?? (() => {
|
|
562
731
|
const created = {
|
|
563
732
|
id: clientId,
|
|
564
733
|
lastSeq: 0,
|
|
@@ -566,7 +735,8 @@ const register = (h, clientId, socket, sawFrame = false) => {
|
|
|
566
735
|
touched: Date.now(),
|
|
567
736
|
context: null,
|
|
568
737
|
results: /* @__PURE__ */ new Map(),
|
|
569
|
-
inflight: /* @__PURE__ */ new Map()
|
|
738
|
+
inflight: /* @__PURE__ */ new Map(),
|
|
739
|
+
principal: auth
|
|
570
740
|
};
|
|
571
741
|
h.clients.set(clientId, created);
|
|
572
742
|
return created;
|
|
@@ -578,10 +748,12 @@ const register = (h, clientId, socket, sawFrame = false) => {
|
|
|
578
748
|
open: true,
|
|
579
749
|
sawFrame,
|
|
580
750
|
lastSentAt: 0,
|
|
581
|
-
lease: null
|
|
751
|
+
lease: null,
|
|
752
|
+
auth
|
|
582
753
|
};
|
|
583
754
|
h.conns.add(conn);
|
|
584
755
|
gcClients(h);
|
|
756
|
+
notifyClientRecords(h);
|
|
585
757
|
ensureHeartbeat(h);
|
|
586
758
|
const { value, meta } = encodeValue(h.stateHost.snapshot());
|
|
587
759
|
const hello = {
|
|
@@ -612,7 +784,9 @@ const connect = (h, clientId, socket, options = {}) => {
|
|
|
612
784
|
return connectAuthorized(h, clientId, socket, options);
|
|
613
785
|
};
|
|
614
786
|
const finish = (h, fin) => {
|
|
787
|
+
if (fin.retry !== void 0 && (!Number.isSafeInteger(fin.retry) || fin.retry < 0)) throw new Error("statewire: retry must be a nonnegative safe integer");
|
|
615
788
|
if (h.finished !== null || h.disposed) return;
|
|
789
|
+
h.commandsHost?.abort?.();
|
|
616
790
|
h.finished = fin;
|
|
617
791
|
for (const pending of [...h.pendingConns]) {
|
|
618
792
|
sendPending(pending, JSON.stringify({ fin }));
|
|
@@ -632,15 +806,27 @@ const suspend = (h) => {
|
|
|
632
806
|
}
|
|
633
807
|
h.stateHost?.setOnOp(null);
|
|
634
808
|
};
|
|
635
|
-
const
|
|
809
|
+
const restoreClientRecords = (records = []) => new Map(records.map((record) => [record.id, {
|
|
810
|
+
...record,
|
|
811
|
+
results: new Map(record.results.map(({ seq, ts, cmd }) => [seq, {
|
|
812
|
+
ts,
|
|
813
|
+
cmd
|
|
814
|
+
}])),
|
|
815
|
+
inflight: /* @__PURE__ */ new Map()
|
|
816
|
+
}]));
|
|
817
|
+
const useSocketHost = (transport, options, persistence) => {
|
|
636
818
|
const [h] = useState(() => {
|
|
637
819
|
const host = {
|
|
638
820
|
heartbeatMs: 15e3,
|
|
639
821
|
wsInitTimeoutMs: 5e3,
|
|
822
|
+
maxSubscribers: MAX_SUBSCRIBERS,
|
|
823
|
+
maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
824
|
+
streamQueueSize: STREAM_QUEUE_SIZE,
|
|
640
825
|
clientRecordLimit: CLIENT_RECORD_LIMIT,
|
|
641
826
|
finOnIdle: false,
|
|
827
|
+
pushback: void 0,
|
|
642
828
|
authorize: void 0,
|
|
643
|
-
clients:
|
|
829
|
+
clients: restoreClientRecords(persistence?.clientRecords),
|
|
644
830
|
conns: /* @__PURE__ */ new Set(),
|
|
645
831
|
pendingConns: /* @__PURE__ */ new Set(),
|
|
646
832
|
pendingOps: [],
|
|
@@ -654,6 +840,7 @@ const useSocketHost = (transport, options) => {
|
|
|
654
840
|
stateHost: null,
|
|
655
841
|
commandsHost: null,
|
|
656
842
|
current: null,
|
|
843
|
+
persistence,
|
|
657
844
|
post: createMacrotaskPoster(() => {
|
|
658
845
|
host.flushScheduled = false;
|
|
659
846
|
flush(host);
|
|
@@ -666,7 +853,17 @@ const useSocketHost = (transport, options) => {
|
|
|
666
853
|
return host;
|
|
667
854
|
});
|
|
668
855
|
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15e3);
|
|
856
|
+
h.pushback = options?.pushback;
|
|
669
857
|
h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5e3;
|
|
858
|
+
const maxSubscribers = options?.maxSubscribers === void 0 ? MAX_SUBSCRIBERS : options.maxSubscribers;
|
|
859
|
+
if (!Number.isInteger(maxSubscribers) || maxSubscribers < 1) throw new TypeError("maxSubscribers must be a positive integer");
|
|
860
|
+
h.maxSubscribers = maxSubscribers;
|
|
861
|
+
const maxInflightCommands = options?.maxInflightCommands === void 0 ? MAX_INFLIGHT_COMMANDS_PER_HOST : options.maxInflightCommands;
|
|
862
|
+
if (!Number.isInteger(maxInflightCommands) || maxInflightCommands < 1) throw new TypeError("maxInflightCommands must be a positive integer");
|
|
863
|
+
h.maxInflightCommands = maxInflightCommands;
|
|
864
|
+
const streamQueueSize = options?.streamQueueSize === void 0 ? STREAM_QUEUE_SIZE : options.streamQueueSize;
|
|
865
|
+
if (!Number.isInteger(streamQueueSize) || streamQueueSize < 1) throw new TypeError("streamQueueSize must be a positive integer");
|
|
866
|
+
h.streamQueueSize = streamQueueSize;
|
|
670
867
|
h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
|
|
671
868
|
h.finOnIdle = options?.finOnIdle ?? false;
|
|
672
869
|
h.authorize = options?.authorize;
|
|
@@ -674,6 +871,7 @@ const useSocketHost = (transport, options) => {
|
|
|
674
871
|
attach(h, value);
|
|
675
872
|
useEffect(() => {
|
|
676
873
|
attach(h, value);
|
|
874
|
+
scheduleFlush(h);
|
|
677
875
|
});
|
|
678
876
|
useEffect(() => {
|
|
679
877
|
resume(h);
|
|
@@ -689,9 +887,9 @@ const useSocketHost = (transport, options) => {
|
|
|
689
887
|
* arrays fanned out to every attach of a client id, idle edges, and `fin` on
|
|
690
888
|
* finish.
|
|
691
889
|
*/
|
|
692
|
-
const StatewireSocketHost = (transport, options) => {
|
|
890
|
+
const StatewireSocketHost = (transport, options, persistence) => {
|
|
693
891
|
const SocketHostRoot = () => {
|
|
694
|
-
return useSocketHost(transport, options);
|
|
892
|
+
return useSocketHost(transport, options, persistence);
|
|
695
893
|
};
|
|
696
894
|
const root = createTapRoot(SocketHostRoot);
|
|
697
895
|
let disposed = false;
|
|
@@ -703,12 +901,15 @@ const StatewireSocketHost = (transport, options) => {
|
|
|
703
901
|
dispose: () => {
|
|
704
902
|
if (disposed) return;
|
|
705
903
|
disposed = true;
|
|
706
|
-
flushTapSync(() => root.getValue().finish({
|
|
904
|
+
flushTapSync(() => root.getValue().finish({
|
|
905
|
+
reason: "reconnect",
|
|
906
|
+
code: "evicted"
|
|
907
|
+
}));
|
|
707
908
|
root.unmount();
|
|
708
909
|
}
|
|
709
910
|
};
|
|
710
911
|
};
|
|
711
912
|
//#endregion
|
|
712
|
-
export { CLIENT_ID_RE, StatewireSocketHost, admitFrame, closeConn, flush, parseFrame, parseFrameValue, register, sendTo };
|
|
913
|
+
export { CLIENT_ID_RE, PRINCIPAL_MISMATCH_MESSAGE, SUBSCRIBER_LIMIT_MESSAGE, StatewireSocketHost, admitFrame, atSubscriberLimit, authEquals, closeConn, flush, parseFrame, parseFrameValue, register, sendTo };
|
|
713
914
|
|
|
714
915
|
//# sourceMappingURL=socket.js.map
|