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/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,62 +434,271 @@ 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
|
};
|
|
522
|
+
const closePending = (h, pending, code, reason) => {
|
|
523
|
+
if (!pending.open) return;
|
|
524
|
+
pending.open = false;
|
|
525
|
+
h.pendingConns.delete(pending);
|
|
526
|
+
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
527
|
+
try {
|
|
528
|
+
pending.socket.close(code, reason);
|
|
529
|
+
} catch {}
|
|
530
|
+
};
|
|
531
|
+
const sendPending = (pending, data) => {
|
|
532
|
+
if (!pending.open) return false;
|
|
533
|
+
try {
|
|
534
|
+
pending.socket.send(data);
|
|
535
|
+
return true;
|
|
536
|
+
} catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
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
|
+
};
|
|
553
|
+
const rejectPendingAuthorization = (h, pending, error) => {
|
|
554
|
+
const response = error instanceof Response ? error : null;
|
|
555
|
+
const unauthorized = response?.status === 401 || response?.status === 403;
|
|
556
|
+
const status = response?.status;
|
|
557
|
+
const message = unauthorized ? "unauthorized" : response?.statusText || (status === void 0 ? "authorization failed" : `HTTP ${status}`);
|
|
558
|
+
sendPending(pending, JSON.stringify({ fin: {
|
|
559
|
+
reason: status === 403 ? "forbidden" : unauthorized ? "unauthorized" : "error",
|
|
560
|
+
message,
|
|
561
|
+
...status !== void 0 && { status }
|
|
562
|
+
} }));
|
|
563
|
+
closePending(h, pending, unauthorized ? 1008 : 1011, message);
|
|
564
|
+
};
|
|
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
|
+
};
|
|
574
|
+
const connectAuthorized = (h, clientId, socket, options) => {
|
|
575
|
+
const authorize = h.authorize;
|
|
576
|
+
if (options.authorized === true) return register(h, clientId, socket, options.authorized === true, options.auth)?.connection ?? null;
|
|
577
|
+
const refuse = (code, reason) => {
|
|
578
|
+
try {
|
|
579
|
+
socket.close(code, reason);
|
|
580
|
+
} catch {}
|
|
581
|
+
return null;
|
|
582
|
+
};
|
|
583
|
+
if (h.disposed) return refuse(1001, "disposed");
|
|
584
|
+
if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
|
|
585
|
+
flush(h);
|
|
586
|
+
if (h.finished !== null) {
|
|
587
|
+
try {
|
|
588
|
+
socket.send(JSON.stringify({ fin: h.finished }));
|
|
589
|
+
} catch {}
|
|
590
|
+
return refuse(1e3, "finished");
|
|
591
|
+
}
|
|
592
|
+
const pending = {
|
|
593
|
+
socket,
|
|
594
|
+
open: true,
|
|
595
|
+
authorizing: false,
|
|
596
|
+
queued: [],
|
|
597
|
+
timer: null,
|
|
598
|
+
connection: null
|
|
599
|
+
};
|
|
600
|
+
h.pendingConns.add(pending);
|
|
601
|
+
pending.timer = setTimeout(() => rejectPendingAuthorization(h, pending, new Response(null, { status: 401 })), h.wsInitTimeoutMs);
|
|
602
|
+
pending.timer.unref?.();
|
|
603
|
+
return {
|
|
604
|
+
clientId,
|
|
605
|
+
message: (data) => {
|
|
606
|
+
if (pending.connection !== null) {
|
|
607
|
+
pending.connection.message(data);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
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
|
+
}
|
|
619
|
+
if (pending.authorizing) {
|
|
620
|
+
pending.queued.push(data);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
let initial;
|
|
624
|
+
try {
|
|
625
|
+
initial = parseInitialFrame(data);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
if (error instanceof Response) {
|
|
628
|
+
rejectPendingAuthorization(h, pending, error);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const reason = violationReason(error);
|
|
632
|
+
if (reason === null) throw error;
|
|
633
|
+
sendPending(pending, JSON.stringify({ fin: {
|
|
634
|
+
reason: "protocol-error",
|
|
635
|
+
message: reason
|
|
636
|
+
} }));
|
|
637
|
+
closePending(h, pending, 1008, reason);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const frame = initial.frame;
|
|
641
|
+
let headers;
|
|
642
|
+
try {
|
|
643
|
+
headers = normalizedHeaders(initial.headers);
|
|
644
|
+
} catch {
|
|
645
|
+
sendPending(pending, JSON.stringify({ fin: {
|
|
646
|
+
reason: "protocol-error",
|
|
647
|
+
message: "malformed headers"
|
|
648
|
+
} }));
|
|
649
|
+
closePending(h, pending, 1008, "malformed headers");
|
|
650
|
+
return;
|
|
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
|
+
}
|
|
661
|
+
pending.authorizing = true;
|
|
662
|
+
Promise.resolve().then(() => authorize?.({ headers })).then((auth) => {
|
|
663
|
+
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
664
|
+
options.onAuthorized?.(auth);
|
|
665
|
+
pending.open = false;
|
|
666
|
+
h.pendingConns.delete(pending);
|
|
667
|
+
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
668
|
+
const registration = register(h, clientId, socket, true, auth);
|
|
669
|
+
if (registration === null) return;
|
|
670
|
+
pending.connection = registration.connection;
|
|
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);
|
|
676
|
+
}).catch((error) => {
|
|
677
|
+
if (pending.open) rejectPendingAuthorization(h, pending, error);
|
|
678
|
+
});
|
|
679
|
+
},
|
|
680
|
+
close: () => {
|
|
681
|
+
if (pending.connection !== null) pending.connection.close();
|
|
682
|
+
else closePending(h, pending);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
};
|
|
410
686
|
const gcClients = (h) => {
|
|
411
687
|
let excess = h.clients.size - h.clientRecordLimit;
|
|
412
688
|
if (excess <= 0) return;
|
|
689
|
+
let changed = false;
|
|
413
690
|
const live = new Set([...h.conns].map((conn) => conn.clientId));
|
|
414
691
|
const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
|
|
415
692
|
for (const rec of oldest) {
|
|
416
|
-
if (excess <= 0)
|
|
693
|
+
if (excess <= 0) break;
|
|
417
694
|
if (live.has(rec.id) || rec.inflight.size > 0) continue;
|
|
418
695
|
h.clients.delete(rec.id);
|
|
696
|
+
changed = true;
|
|
419
697
|
excess -= 1;
|
|
420
698
|
}
|
|
699
|
+
if (changed) notifyClientRecords(h);
|
|
421
700
|
};
|
|
422
|
-
const register = (h, clientId, socket) => {
|
|
701
|
+
const register = (h, clientId, socket, sawFrame = false, auth = void 0) => {
|
|
423
702
|
const refuse = (code, reason) => {
|
|
424
703
|
try {
|
|
425
704
|
socket.close(code, reason);
|
|
@@ -436,7 +715,19 @@ const register = (h, clientId, socket) => {
|
|
|
436
715
|
refuse(1e3, "finished");
|
|
437
716
|
return null;
|
|
438
717
|
}
|
|
439
|
-
|
|
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 ?? (() => {
|
|
440
731
|
const created = {
|
|
441
732
|
id: clientId,
|
|
442
733
|
lastSeq: 0,
|
|
@@ -444,7 +735,8 @@ const register = (h, clientId, socket) => {
|
|
|
444
735
|
touched: Date.now(),
|
|
445
736
|
context: null,
|
|
446
737
|
results: /* @__PURE__ */ new Map(),
|
|
447
|
-
inflight: /* @__PURE__ */ new Map()
|
|
738
|
+
inflight: /* @__PURE__ */ new Map(),
|
|
739
|
+
principal: auth
|
|
448
740
|
};
|
|
449
741
|
h.clients.set(clientId, created);
|
|
450
742
|
return created;
|
|
@@ -454,12 +746,14 @@ const register = (h, clientId, socket) => {
|
|
|
454
746
|
clientId,
|
|
455
747
|
socket,
|
|
456
748
|
open: true,
|
|
457
|
-
sawFrame
|
|
749
|
+
sawFrame,
|
|
458
750
|
lastSentAt: 0,
|
|
459
|
-
lease: null
|
|
751
|
+
lease: null,
|
|
752
|
+
auth
|
|
460
753
|
};
|
|
461
754
|
h.conns.add(conn);
|
|
462
755
|
gcClients(h);
|
|
756
|
+
notifyClientRecords(h);
|
|
463
757
|
ensureHeartbeat(h);
|
|
464
758
|
const { value, meta } = encodeValue(h.stateHost.snapshot());
|
|
465
759
|
const hello = {
|
|
@@ -486,12 +780,18 @@ const register = (h, clientId, socket) => {
|
|
|
486
780
|
}
|
|
487
781
|
};
|
|
488
782
|
};
|
|
489
|
-
const connect = (h, clientId, socket) => {
|
|
490
|
-
return
|
|
783
|
+
const connect = (h, clientId, socket, options = {}) => {
|
|
784
|
+
return connectAuthorized(h, clientId, socket, options);
|
|
491
785
|
};
|
|
492
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");
|
|
493
788
|
if (h.finished !== null || h.disposed) return;
|
|
789
|
+
h.commandsHost?.abort?.();
|
|
494
790
|
h.finished = fin;
|
|
791
|
+
for (const pending of [...h.pendingConns]) {
|
|
792
|
+
sendPending(pending, JSON.stringify({ fin }));
|
|
793
|
+
closePending(h, pending, 1e3, fin.reason);
|
|
794
|
+
}
|
|
495
795
|
flush(h, fin);
|
|
496
796
|
};
|
|
497
797
|
const resume = (h) => {
|
|
@@ -506,15 +806,29 @@ const suspend = (h) => {
|
|
|
506
806
|
}
|
|
507
807
|
h.stateHost?.setOnOp(null);
|
|
508
808
|
};
|
|
509
|
-
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) => {
|
|
510
818
|
const [h] = useState(() => {
|
|
511
819
|
const host = {
|
|
512
820
|
heartbeatMs: 15e3,
|
|
821
|
+
wsInitTimeoutMs: 5e3,
|
|
822
|
+
maxSubscribers: MAX_SUBSCRIBERS,
|
|
823
|
+
maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
824
|
+
streamQueueSize: STREAM_QUEUE_SIZE,
|
|
513
825
|
clientRecordLimit: CLIENT_RECORD_LIMIT,
|
|
514
826
|
finOnIdle: false,
|
|
827
|
+
pushback: void 0,
|
|
515
828
|
authorize: void 0,
|
|
516
|
-
clients:
|
|
829
|
+
clients: restoreClientRecords(persistence?.clientRecords),
|
|
517
830
|
conns: /* @__PURE__ */ new Set(),
|
|
831
|
+
pendingConns: /* @__PURE__ */ new Set(),
|
|
518
832
|
pendingOps: [],
|
|
519
833
|
pendingApplied: /* @__PURE__ */ new Map(),
|
|
520
834
|
pendingCmds: /* @__PURE__ */ new Map(),
|
|
@@ -526,11 +840,12 @@ const useSocketHost = (transport, options) => {
|
|
|
526
840
|
stateHost: null,
|
|
527
841
|
commandsHost: null,
|
|
528
842
|
current: null,
|
|
843
|
+
persistence,
|
|
529
844
|
post: createMacrotaskPoster(() => {
|
|
530
845
|
host.flushScheduled = false;
|
|
531
846
|
flush(host);
|
|
532
847
|
}),
|
|
533
|
-
connect: (clientId, socket) => connect(host, clientId, socket),
|
|
848
|
+
connect: (clientId, socket, connectOptions) => connect(host, clientId, socket, connectOptions),
|
|
534
849
|
finish: (fin) => finish(host, fin),
|
|
535
850
|
stream: (request) => stream(host, request),
|
|
536
851
|
frames: (request_0) => frames(host, request_0)
|
|
@@ -538,6 +853,17 @@ const useSocketHost = (transport, options) => {
|
|
|
538
853
|
return host;
|
|
539
854
|
});
|
|
540
855
|
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15e3);
|
|
856
|
+
h.pushback = options?.pushback;
|
|
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;
|
|
541
867
|
h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
|
|
542
868
|
h.finOnIdle = options?.finOnIdle ?? false;
|
|
543
869
|
h.authorize = options?.authorize;
|
|
@@ -545,6 +871,7 @@ const useSocketHost = (transport, options) => {
|
|
|
545
871
|
attach(h, value);
|
|
546
872
|
useEffect(() => {
|
|
547
873
|
attach(h, value);
|
|
874
|
+
scheduleFlush(h);
|
|
548
875
|
});
|
|
549
876
|
useEffect(() => {
|
|
550
877
|
resume(h);
|
|
@@ -560,26 +887,29 @@ const useSocketHost = (transport, options) => {
|
|
|
560
887
|
* arrays fanned out to every attach of a client id, idle edges, and `fin` on
|
|
561
888
|
* finish.
|
|
562
889
|
*/
|
|
563
|
-
const StatewireSocketHost = (transport, options) => {
|
|
890
|
+
const StatewireSocketHost = (transport, options, persistence) => {
|
|
564
891
|
const SocketHostRoot = () => {
|
|
565
|
-
return useSocketHost(transport, options);
|
|
892
|
+
return useSocketHost(transport, options, persistence);
|
|
566
893
|
};
|
|
567
894
|
const root = createTapRoot(SocketHostRoot);
|
|
568
895
|
let disposed = false;
|
|
569
896
|
return {
|
|
570
|
-
connect: (clientId, socket) => flushTapSync(() => root.getValue().connect(clientId, socket)),
|
|
897
|
+
connect: (clientId, socket, connectOptions) => flushTapSync(() => root.getValue().connect(clientId, socket, connectOptions)),
|
|
571
898
|
stream: (request) => flushTapSync(() => root.getValue().stream(request)),
|
|
572
899
|
frames: (request) => flushTapSync(() => root.getValue().frames(request)),
|
|
573
900
|
finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
|
|
574
901
|
dispose: () => {
|
|
575
902
|
if (disposed) return;
|
|
576
903
|
disposed = true;
|
|
577
|
-
flushTapSync(() => root.getValue().finish({
|
|
904
|
+
flushTapSync(() => root.getValue().finish({
|
|
905
|
+
reason: "reconnect",
|
|
906
|
+
code: "evicted"
|
|
907
|
+
}));
|
|
578
908
|
root.unmount();
|
|
579
909
|
}
|
|
580
910
|
};
|
|
581
911
|
};
|
|
582
912
|
//#endregion
|
|
583
|
-
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 };
|
|
584
914
|
|
|
585
915
|
//# sourceMappingURL=socket.js.map
|