statewire 0.19.4 → 0.21.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 +15 -1
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +18 -4
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +10 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +92 -27
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +2 -0
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/host/host.d.ts +2 -1
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +20 -12
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +14 -9
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +14 -3
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +162 -20
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +5 -2
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +6 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/statewire.d.ts +3 -0
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +23 -5
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +3 -2
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +5 -0
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +12 -0
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +2 -0
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +11 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/context.d.ts +14 -0
- package/dist/wire/context.d.ts.map +1 -0
- package/dist/wire/context.js +31 -0
- package/dist/wire/context.js.map +1 -0
- package/dist/wire/limits.d.ts +2 -1
- package/dist/wire/limits.d.ts.map +1 -1
- package/dist/wire/limits.js +2 -1
- package/dist/wire/limits.js.map +1 -1
- package/dist/wire/protocol.d.ts +1 -1
- package/dist/wire/protocol.js +4 -4
- package/dist/wire/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +16 -4
- package/src/client/session.ts +128 -19
- package/src/client/sessionStore.ts +5 -1
- package/src/host/host.ts +18 -3
- package/src/host/http.ts +37 -5
- package/src/host/socket.ts +232 -13
- package/src/host-internal.ts +4 -0
- package/src/host.ts +5 -0
- package/src/statewire.ts +34 -3
- package/src/transports/remote.ts +3 -0
- package/src/transports/sse.ts +19 -0
- package/src/transports/testing.ts +14 -3
- package/src/types.ts +12 -0
- package/src/wire/context.ts +40 -0
- package/src/wire/limits.ts +2 -0
- package/src/wire/protocol.ts +1 -1
package/src/host/socket.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { contextValue, captureContext } from "../wire/context";
|
|
2
|
+
import { MAX_CONTEXT_BYTES } from "../wire/limits";
|
|
1
3
|
import { encodeDocument } from "../document";
|
|
2
4
|
import {
|
|
3
5
|
DEFAULT_PROTOCOL,
|
|
@@ -126,7 +128,10 @@ export type Conn = {
|
|
|
126
128
|
lease: string | null;
|
|
127
129
|
/** The awaited `authorize` return captured at this attach's authorization. */
|
|
128
130
|
auth: unknown;
|
|
131
|
+
authorizedAt: number | undefined;
|
|
132
|
+
leaseTimer: ReturnType<typeof setTimeout> | null;
|
|
129
133
|
lanes: Map<string, Lane>;
|
|
134
|
+
contexts: Map<string, Statewire.Context>;
|
|
130
135
|
documents: Map<string, StatewireHost.Document>;
|
|
131
136
|
included: Set<string>;
|
|
132
137
|
view: Map<string, { doc: number; value: unknown }>;
|
|
@@ -164,7 +169,10 @@ type CmdStatement = {
|
|
|
164
169
|
meta?: unknown;
|
|
165
170
|
};
|
|
166
171
|
|
|
167
|
-
export type ParsedFrame = {
|
|
172
|
+
export type ParsedFrame = {
|
|
173
|
+
cmd: CmdStatement[];
|
|
174
|
+
ctx?: { protocol: string; value: Statewire.Context }[];
|
|
175
|
+
};
|
|
168
176
|
|
|
169
177
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
170
178
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -175,12 +183,13 @@ export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
|
175
183
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
176
184
|
const keys = Object.keys(frame);
|
|
177
185
|
if (keys.length === 0) return violation("malformed frame");
|
|
178
|
-
const unknown = keys.filter((key) => key !== "cmd").sort();
|
|
186
|
+
const unknown = keys.filter((key) => key !== "cmd" && key !== "ctx").sort();
|
|
179
187
|
if (unknown.length > 0)
|
|
180
188
|
return violation(`unknown frame key: '${unknown[0]}'`);
|
|
181
|
-
const lane = frame["cmd"];
|
|
189
|
+
const lane = frame["cmd"] ?? [];
|
|
182
190
|
if (!Array.isArray(lane)) return violation("malformed frame");
|
|
183
|
-
if (lane.length === 0)
|
|
191
|
+
if (Object.hasOwn(frame, "cmd") && lane.length === 0)
|
|
192
|
+
return violation("empty lane");
|
|
184
193
|
if (lane.length > MAX_COMMANDS_PER_FRAME)
|
|
185
194
|
return violation("too many commands");
|
|
186
195
|
const prev = new Map<string, number>();
|
|
@@ -212,7 +221,47 @@ export const parseFrameValue = (frame: unknown): ParsedFrame => {
|
|
|
212
221
|
...(statement["meta"] !== undefined && { meta: statement["meta"] }),
|
|
213
222
|
});
|
|
214
223
|
}
|
|
215
|
-
|
|
224
|
+
const contexts = frame["ctx"];
|
|
225
|
+
const ctx: NonNullable<ParsedFrame["ctx"]> = [];
|
|
226
|
+
if (contexts !== undefined) {
|
|
227
|
+
if (!Array.isArray(contexts) || contexts.length === 0)
|
|
228
|
+
return violation("empty context lane");
|
|
229
|
+
const seen = new Set<string>();
|
|
230
|
+
for (const entry of contexts) {
|
|
231
|
+
if (
|
|
232
|
+
!isPlainObject(entry) ||
|
|
233
|
+
typeof entry["protocol"] !== "string" ||
|
|
234
|
+
!Object.hasOwn(entry, "value") ||
|
|
235
|
+
Object.keys(entry).some(
|
|
236
|
+
(key) => !["protocol", "value", "meta"].includes(key),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
return violation("malformed context");
|
|
240
|
+
const protocol = entry["protocol"];
|
|
241
|
+
if (seen.has(protocol)) return violation("duplicate context protocol");
|
|
242
|
+
seen.add(protocol);
|
|
243
|
+
if (
|
|
244
|
+
frameEncoder.encode(JSON.stringify(entry)).byteLength >
|
|
245
|
+
MAX_CONTEXT_BYTES
|
|
246
|
+
)
|
|
247
|
+
return violation("context entry too large");
|
|
248
|
+
try {
|
|
249
|
+
ctx.push({
|
|
250
|
+
protocol,
|
|
251
|
+
value: contextValue({
|
|
252
|
+
value: entry["value"],
|
|
253
|
+
...(entry["meta"] !== undefined && { meta: entry["meta"] }),
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return violation(
|
|
258
|
+
error instanceof Error ? error.message : "invalid context",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (cmd.length === 0 && ctx.length === 0) return violation("empty frame");
|
|
264
|
+
return { cmd, ...(ctx.length > 0 && { ctx }) };
|
|
216
265
|
};
|
|
217
266
|
|
|
218
267
|
export const parseFrame = (raw: string): ParsedFrame => {
|
|
@@ -269,13 +318,11 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
|
|
|
269
318
|
};
|
|
270
319
|
}
|
|
271
320
|
default:
|
|
321
|
+
console.error("statewire command failed", outcome.error);
|
|
272
322
|
return {
|
|
273
323
|
...at,
|
|
274
324
|
type: "failed",
|
|
275
|
-
message:
|
|
276
|
-
outcome.error instanceof Error
|
|
277
|
-
? outcome.error.message
|
|
278
|
-
: String(outcome.error),
|
|
325
|
+
message: "the command failed",
|
|
279
326
|
};
|
|
280
327
|
}
|
|
281
328
|
};
|
|
@@ -309,6 +356,7 @@ export type SocketHostState = {
|
|
|
309
356
|
key: readonly string[] | undefined;
|
|
310
357
|
wireVersion: StatewireProtocol.Range;
|
|
311
358
|
heartbeatMs: number;
|
|
359
|
+
maxLeaseLifetimeMs: number | null;
|
|
312
360
|
wsInitTimeoutMs: number;
|
|
313
361
|
maxSubscribers: number;
|
|
314
362
|
maxInflightCommands: number;
|
|
@@ -404,6 +452,10 @@ export const closeConn = (
|
|
|
404
452
|
if (!conn.open) return;
|
|
405
453
|
conn.open = false;
|
|
406
454
|
h.conns.delete(conn);
|
|
455
|
+
if (conn.leaseTimer !== null) {
|
|
456
|
+
clearTimeout(conn.leaseTimer);
|
|
457
|
+
conn.leaseTimer = null;
|
|
458
|
+
}
|
|
407
459
|
try {
|
|
408
460
|
conn.socket.close(code, reason);
|
|
409
461
|
} catch {
|
|
@@ -426,6 +478,49 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
426
478
|
}
|
|
427
479
|
};
|
|
428
480
|
|
|
481
|
+
const expiredAt = (h: SocketHostState, authorizedAt: number | undefined) => {
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
return (
|
|
484
|
+
authorizedAt === undefined ||
|
|
485
|
+
!Number.isFinite(authorizedAt) ||
|
|
486
|
+
authorizedAt < 0 ||
|
|
487
|
+
authorizedAt > now ||
|
|
488
|
+
(h.maxLeaseLifetimeMs !== null &&
|
|
489
|
+
now - authorizedAt >= h.maxLeaseLifetimeMs)
|
|
490
|
+
);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const leaseExpiredFin = JSON.stringify({
|
|
494
|
+
fin: { reason: "reconnect", code: "lease-expired" },
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
export const expireLease = (h: SocketHostState, conn: Conn): boolean => {
|
|
498
|
+
if (!conn.open || expiredAt(h, conn.authorizedAt)) {
|
|
499
|
+
if (conn.open) {
|
|
500
|
+
sendTo(h, conn, leaseExpiredFin);
|
|
501
|
+
closeConn(h, conn, 1000, "lease-expired");
|
|
502
|
+
}
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
return false;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const armLeaseTimer = (h: SocketHostState, conn: Conn) => {
|
|
509
|
+
if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
|
|
510
|
+
conn.leaseTimer = null;
|
|
511
|
+
if (h.disposed || h.maxLeaseLifetimeMs === null || expireLease(h, conn))
|
|
512
|
+
return;
|
|
513
|
+
const delay = h.maxLeaseLifetimeMs - (Date.now() - conn.authorizedAt!);
|
|
514
|
+
conn.leaseTimer = setTimeout(
|
|
515
|
+
() => {
|
|
516
|
+
conn.leaseTimer = null;
|
|
517
|
+
if (!expireLease(h, conn)) armLeaseTimer(h, conn);
|
|
518
|
+
},
|
|
519
|
+
Math.min(delay, 2_147_483_647),
|
|
520
|
+
);
|
|
521
|
+
(conn.leaseTimer as unknown as { unref?: () => void }).unref?.();
|
|
522
|
+
};
|
|
523
|
+
|
|
429
524
|
const violate = (h: SocketHostState, conn: Conn, reason: string) => {
|
|
430
525
|
sendTo(
|
|
431
526
|
h,
|
|
@@ -554,6 +649,13 @@ const attachOf = (h: SocketHostState, conn: Conn): StatewireHost.Attach => {
|
|
|
554
649
|
return entry;
|
|
555
650
|
};
|
|
556
651
|
return {
|
|
652
|
+
get caller() {
|
|
653
|
+
return callerOf(h, h.clients.get(conn.clientId)!, conn);
|
|
654
|
+
},
|
|
655
|
+
context: (protocol) => {
|
|
656
|
+
requireSelected(protocol);
|
|
657
|
+
return captureContext(conn.contexts.get(protocol) ?? {});
|
|
658
|
+
},
|
|
557
659
|
document: (protocol, key) => {
|
|
558
660
|
if (key === undefined && protocol === h.protocol.name)
|
|
559
661
|
return h.stateHost!.snapshot();
|
|
@@ -916,6 +1018,7 @@ const admitCmd = (
|
|
|
916
1018
|
clientId: rec.id,
|
|
917
1019
|
caller: callerOf(h, rec, conn),
|
|
918
1020
|
attach: conn.attach,
|
|
1021
|
+
context: captureContext(conn.contexts.get(statement.protocol) ?? {}),
|
|
919
1022
|
onApplied: () => {
|
|
920
1023
|
if (adm.settled || adm.applied) return;
|
|
921
1024
|
adm.applied = true;
|
|
@@ -935,6 +1038,7 @@ const admitCmd = (
|
|
|
935
1038
|
// never a close; an ephemeral gap is a violation (the lane has no replay).
|
|
936
1039
|
export type FrameAdmission =
|
|
937
1040
|
| { type: "ok" }
|
|
1041
|
+
| { type: "expired" }
|
|
938
1042
|
| {
|
|
939
1043
|
type: "refused";
|
|
940
1044
|
syn: {
|
|
@@ -959,11 +1063,66 @@ const refusal = (
|
|
|
959
1063
|
},
|
|
960
1064
|
});
|
|
961
1065
|
|
|
1066
|
+
const validateContexts = (
|
|
1067
|
+
h: SocketHostState,
|
|
1068
|
+
conn: Conn,
|
|
1069
|
+
frame: ParsedFrame,
|
|
1070
|
+
) => {
|
|
1071
|
+
for (const statement of frame.cmd) {
|
|
1072
|
+
if (!selects(conn, statement.protocol))
|
|
1073
|
+
return violation("unsupported protocol");
|
|
1074
|
+
const selected = conn.selection.protocols.find(
|
|
1075
|
+
(p) => p.name === statement.protocol,
|
|
1076
|
+
)!;
|
|
1077
|
+
if (selected.record === "ephemeral") {
|
|
1078
|
+
const through = watermark(h, conn, statement.protocol);
|
|
1079
|
+
const first = frame.cmd.find(
|
|
1080
|
+
(s) => s.protocol === statement.protocol && s.seq > through,
|
|
1081
|
+
);
|
|
1082
|
+
if (first && first.seq !== through + 1)
|
|
1083
|
+
return violation("ephemeral lane gap");
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
for (const entry of frame.ctx ?? []) {
|
|
1087
|
+
if (!selects(conn, entry.protocol))
|
|
1088
|
+
return violation("unsupported context protocol");
|
|
1089
|
+
try {
|
|
1090
|
+
h.current?.validateContext?.(
|
|
1091
|
+
entry.protocol,
|
|
1092
|
+
captureContext(entry.value),
|
|
1093
|
+
conn.attach,
|
|
1094
|
+
);
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
return violation(
|
|
1097
|
+
error instanceof Error ? error.message : "invalid context",
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
const installContexts = (
|
|
1104
|
+
h: SocketHostState,
|
|
1105
|
+
conn: Conn,
|
|
1106
|
+
frame: ParsedFrame,
|
|
1107
|
+
) => {
|
|
1108
|
+
for (const entry of frame.ctx ?? [])
|
|
1109
|
+
conn.contexts.set(entry.protocol, captureContext(entry.value));
|
|
1110
|
+
for (const entry of frame.ctx ?? [])
|
|
1111
|
+
h.current?.contextChanged?.(
|
|
1112
|
+
entry.protocol,
|
|
1113
|
+
captureContext(entry.value),
|
|
1114
|
+
conn.attach,
|
|
1115
|
+
);
|
|
1116
|
+
};
|
|
1117
|
+
|
|
962
1118
|
export const admitFrame = (
|
|
963
1119
|
h: SocketHostState,
|
|
964
1120
|
conn: Conn,
|
|
965
1121
|
frame: ParsedFrame,
|
|
966
1122
|
): FrameAdmission => {
|
|
1123
|
+
if (expireLease(h, conn)) return { type: "expired" };
|
|
1124
|
+
validateContexts(h, conn, frame);
|
|
1125
|
+
installContexts(h, conn, frame);
|
|
967
1126
|
const rec = h.clients.get(conn.clientId)!;
|
|
968
1127
|
rec.touched = Date.now();
|
|
969
1128
|
const lanes = new Map(
|
|
@@ -1011,6 +1170,7 @@ export const admitFrame = (
|
|
|
1011
1170
|
return refusal(h, conn, retry);
|
|
1012
1171
|
}
|
|
1013
1172
|
}
|
|
1173
|
+
if (expireLease(h, conn)) return { type: "expired" };
|
|
1014
1174
|
for (const s of fresh) admitCmd(h, lanes.get(s.protocol)!, conn, s);
|
|
1015
1175
|
for (const [protocol, lane] of lanes) {
|
|
1016
1176
|
const last = fresh.findLast((s) => s.protocol === protocol);
|
|
@@ -1024,7 +1184,8 @@ export const admitFrame = (
|
|
|
1024
1184
|
};
|
|
1025
1185
|
|
|
1026
1186
|
const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
1027
|
-
if (!conn.open || h.disposed || h.finished !== null)
|
|
1187
|
+
if (!conn.open || h.disposed || h.finished !== null || expireLease(h, conn))
|
|
1188
|
+
return;
|
|
1028
1189
|
if (frameIsOversized(data)) {
|
|
1029
1190
|
sendTo(
|
|
1030
1191
|
h,
|
|
@@ -1048,7 +1209,7 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
1048
1209
|
let admission: ReturnType<typeof admitFrame>;
|
|
1049
1210
|
try {
|
|
1050
1211
|
const frame = first ? parseInitialFrame(data).frame : parseFrame(data);
|
|
1051
|
-
if (frame.cmd.length === 0) return;
|
|
1212
|
+
if (frame.cmd.length === 0 && !frame.ctx?.length) return;
|
|
1052
1213
|
admission = admitFrame(h, conn, frame);
|
|
1053
1214
|
} catch (error) {
|
|
1054
1215
|
const reason = violationReason(error);
|
|
@@ -1160,6 +1321,19 @@ const connectAuthorized = (
|
|
|
1160
1321
|
): StatewireHostInternal.SocketConnection | null => {
|
|
1161
1322
|
const authorize = h.authorize;
|
|
1162
1323
|
if (options.authorized === true) {
|
|
1324
|
+
if (expiredAt(h, options.authorizedAt)) {
|
|
1325
|
+
try {
|
|
1326
|
+
socket.send(leaseExpiredFin);
|
|
1327
|
+
} catch {
|
|
1328
|
+
/* peer already gone */
|
|
1329
|
+
}
|
|
1330
|
+
try {
|
|
1331
|
+
socket.close(1000, "lease-expired");
|
|
1332
|
+
} catch {
|
|
1333
|
+
/* peer already gone */
|
|
1334
|
+
}
|
|
1335
|
+
return null;
|
|
1336
|
+
}
|
|
1163
1337
|
const registration = register(
|
|
1164
1338
|
h,
|
|
1165
1339
|
clientId,
|
|
@@ -1167,6 +1341,8 @@ const connectAuthorized = (
|
|
|
1167
1341
|
options.authorized === true,
|
|
1168
1342
|
options.auth,
|
|
1169
1343
|
options.selection,
|
|
1344
|
+
undefined,
|
|
1345
|
+
options.authorizedAt,
|
|
1170
1346
|
);
|
|
1171
1347
|
return registration?.connection ?? null;
|
|
1172
1348
|
}
|
|
@@ -1294,6 +1470,7 @@ const connectAuthorized = (
|
|
|
1294
1470
|
.then(() => authorize?.({ headers }))
|
|
1295
1471
|
.then((auth) => {
|
|
1296
1472
|
if (!pending.open || h.disposed || h.finished !== null) return;
|
|
1473
|
+
const authorizedAt = Date.now();
|
|
1297
1474
|
const selection = negotiateProtocols(
|
|
1298
1475
|
new Headers(headers),
|
|
1299
1476
|
h.wireVersion,
|
|
@@ -1304,7 +1481,7 @@ const connectAuthorized = (
|
|
|
1304
1481
|
closePending(h, pending, 1008, selection.fin.message);
|
|
1305
1482
|
return;
|
|
1306
1483
|
}
|
|
1307
|
-
options.onAuthorized?.(auth, selection);
|
|
1484
|
+
options.onAuthorized?.(auth, selection, authorizedAt);
|
|
1308
1485
|
pending.open = false;
|
|
1309
1486
|
h.pendingConns.delete(pending);
|
|
1310
1487
|
if (pending.timer !== null) clearTimeout(pending.timer);
|
|
@@ -1318,6 +1495,7 @@ const connectAuthorized = (
|
|
|
1318
1495
|
auth,
|
|
1319
1496
|
selection,
|
|
1320
1497
|
frame,
|
|
1498
|
+
authorizedAt,
|
|
1321
1499
|
);
|
|
1322
1500
|
} catch (error) {
|
|
1323
1501
|
if (violationReason(error) === null) throw error;
|
|
@@ -1380,6 +1558,7 @@ export const register = (
|
|
|
1380
1558
|
protocols: [{ name: h.protocol.name, version: h.protocol.version }],
|
|
1381
1559
|
},
|
|
1382
1560
|
frame: ParsedFrame = { cmd: [] },
|
|
1561
|
+
authorizedAt?: number,
|
|
1383
1562
|
): Registration | null => {
|
|
1384
1563
|
const refuse = (code: number, reason: string) => {
|
|
1385
1564
|
try {
|
|
@@ -1404,6 +1583,14 @@ export const register = (
|
|
|
1404
1583
|
if (atSubscriberLimit(h)) {
|
|
1405
1584
|
return refuseSubscriberLimit(socket);
|
|
1406
1585
|
}
|
|
1586
|
+
if (expiredAt(h, authorizedAt)) {
|
|
1587
|
+
try {
|
|
1588
|
+
socket.send(leaseExpiredFin);
|
|
1589
|
+
} catch {
|
|
1590
|
+
/* peer already gone */
|
|
1591
|
+
}
|
|
1592
|
+
return refuse(1000, "lease-expired");
|
|
1593
|
+
}
|
|
1407
1594
|
const known = h.clients.get(clientId);
|
|
1408
1595
|
if (known !== undefined && !authEquals(known.principal, auth)) {
|
|
1409
1596
|
try {
|
|
@@ -1444,7 +1631,10 @@ export const register = (
|
|
|
1444
1631
|
lastSentAt: 0,
|
|
1445
1632
|
lease: null,
|
|
1446
1633
|
auth,
|
|
1634
|
+
authorizedAt,
|
|
1635
|
+
leaseTimer: null,
|
|
1447
1636
|
lanes: new Map(),
|
|
1637
|
+
contexts: new Map(),
|
|
1448
1638
|
documents: new Map(),
|
|
1449
1639
|
included: new Set(),
|
|
1450
1640
|
view: new Map(),
|
|
@@ -1456,13 +1646,19 @@ export const register = (
|
|
|
1456
1646
|
conn.attach = attachOf(h, conn);
|
|
1457
1647
|
h.conns.add(conn);
|
|
1458
1648
|
try {
|
|
1649
|
+
validateContexts(h, conn, frame);
|
|
1650
|
+
installContexts(h, conn, frame);
|
|
1459
1651
|
h.current?.attaching?.(conn.attach);
|
|
1460
1652
|
} catch (error) {
|
|
1461
|
-
|
|
1653
|
+
const reason = violationReason(error);
|
|
1654
|
+
if (reason !== null) violate(h, conn, reason);
|
|
1655
|
+
else closeConn(h, conn, 1011, "attach failed");
|
|
1462
1656
|
throw error;
|
|
1463
1657
|
}
|
|
1464
1658
|
gcClients(h);
|
|
1465
1659
|
ensureHeartbeat(h);
|
|
1660
|
+
armLeaseTimer(h, conn);
|
|
1661
|
+
if (!conn.open) return null;
|
|
1466
1662
|
const isEphemeral = (s: CmdStatement) =>
|
|
1467
1663
|
selection.protocols.find((p) => p.name === s.protocol)?.record ===
|
|
1468
1664
|
"ephemeral";
|
|
@@ -1482,6 +1678,7 @@ export const register = (
|
|
|
1482
1678
|
const earlyAdmission = admit(early);
|
|
1483
1679
|
if (early.length > 0) flush(h);
|
|
1484
1680
|
notifyClientRecords(h);
|
|
1681
|
+
if (expireLease(h, conn)) return null;
|
|
1485
1682
|
const ops: WireOp[] = [];
|
|
1486
1683
|
if (selects(conn, h.protocol.name)) {
|
|
1487
1684
|
const { value, meta } = encodeDocument(h.stateHost!.snapshot());
|
|
@@ -1556,10 +1753,15 @@ const resume = (h: SocketHostState) => {
|
|
|
1556
1753
|
h.disposed = false;
|
|
1557
1754
|
h.stateHost?.setOnOp((op) => recordOp(h, op));
|
|
1558
1755
|
h.stateHost?.setOnDocuments(() => markViews(h));
|
|
1756
|
+
for (const conn of h.conns) armLeaseTimer(h, conn);
|
|
1559
1757
|
};
|
|
1560
1758
|
|
|
1561
1759
|
const suspend = (h: SocketHostState) => {
|
|
1562
1760
|
h.disposed = true;
|
|
1761
|
+
for (const conn of h.conns) {
|
|
1762
|
+
if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
|
|
1763
|
+
conn.leaseTimer = null;
|
|
1764
|
+
}
|
|
1563
1765
|
if (h.heartbeatTimer !== null) {
|
|
1564
1766
|
clearInterval(h.heartbeatTimer);
|
|
1565
1767
|
h.heartbeatTimer = null;
|
|
@@ -1611,6 +1813,7 @@ const useSocketHost = (
|
|
|
1611
1813
|
key: undefined,
|
|
1612
1814
|
wireVersion: { version: WIRE_VERSION },
|
|
1613
1815
|
heartbeatMs: 15_000,
|
|
1816
|
+
maxLeaseLifetimeMs: 86_400_000,
|
|
1614
1817
|
wsInitTimeoutMs: 5_000,
|
|
1615
1818
|
maxSubscribers: MAX_SUBSCRIBERS,
|
|
1616
1819
|
maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
|
|
@@ -1654,6 +1857,22 @@ const useSocketHost = (
|
|
|
1654
1857
|
h.key = options?.key;
|
|
1655
1858
|
h.wireVersion = options?.wireVersion ?? { version: WIRE_VERSION };
|
|
1656
1859
|
setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
|
|
1860
|
+
const maxLeaseLifetimeMs =
|
|
1861
|
+
options?.maxLeaseLifetimeMs === undefined
|
|
1862
|
+
? 86_400_000
|
|
1863
|
+
: options.maxLeaseLifetimeMs;
|
|
1864
|
+
if (
|
|
1865
|
+
maxLeaseLifetimeMs !== null &&
|
|
1866
|
+
(!Number.isFinite(maxLeaseLifetimeMs) || maxLeaseLifetimeMs <= 0)
|
|
1867
|
+
) {
|
|
1868
|
+
throw new TypeError(
|
|
1869
|
+
"maxLeaseLifetimeMs must be finite and positive or null",
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
if (h.maxLeaseLifetimeMs !== maxLeaseLifetimeMs) {
|
|
1873
|
+
h.maxLeaseLifetimeMs = maxLeaseLifetimeMs;
|
|
1874
|
+
for (const conn of [...h.conns]) armLeaseTimer(h, conn);
|
|
1875
|
+
}
|
|
1657
1876
|
h.pushback = options?.pushback;
|
|
1658
1877
|
h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
|
|
1659
1878
|
const maxSubscribers =
|
package/src/host-internal.ts
CHANGED
|
@@ -21,6 +21,7 @@ export namespace StatewireHostInternal {
|
|
|
21
21
|
headers?: Record<string, string>;
|
|
22
22
|
/** The adapter restored this socket from an already-authorized session. */
|
|
23
23
|
authorized?: boolean;
|
|
24
|
+
authorizedAt?: number;
|
|
24
25
|
selection?: StatewireProtocol.Selection;
|
|
25
26
|
/** The restored session's captured auth value; it must satisfy the client record's principal binding. */
|
|
26
27
|
auth?: unknown;
|
|
@@ -28,6 +29,7 @@ export namespace StatewireHostInternal {
|
|
|
28
29
|
onAuthorized?: (
|
|
29
30
|
auth: unknown,
|
|
30
31
|
selection: StatewireProtocol.Selection,
|
|
32
|
+
authorizedAt: number,
|
|
31
33
|
) => void;
|
|
32
34
|
};
|
|
33
35
|
|
|
@@ -88,6 +90,8 @@ export namespace StatewireHostInternal {
|
|
|
88
90
|
/** Time allowed to complete WebSocket initialization and authorization. */
|
|
89
91
|
wsInitTimeoutMs?: number;
|
|
90
92
|
heartbeatIntervalMs?: number;
|
|
93
|
+
/** Maximum attach lifetime in milliseconds; defaults to 24 hours, null disables expiry. */
|
|
94
|
+
maxLeaseLifetimeMs?: number | null;
|
|
91
95
|
/** Maximum number of concurrently attached WebSocket and SSE streams. */
|
|
92
96
|
maxSubscribers?: number;
|
|
93
97
|
/** Maximum number of admitted commands still running across all clients. */
|
package/src/host.ts
CHANGED
|
@@ -53,8 +53,11 @@ export namespace StatewireHost {
|
|
|
53
53
|
* optimistic overlay — before the handler settles (idempotent, a no-op after
|
|
54
54
|
* settle). The command settles when the handler's returned promise does.
|
|
55
55
|
*/
|
|
56
|
+
export type CommandsOptions = { context?: () => Statewire.Context };
|
|
57
|
+
|
|
56
58
|
export type Ctx = {
|
|
57
59
|
readonly caller: Caller;
|
|
60
|
+
readonly context: Statewire.Context;
|
|
58
61
|
readonly attach: Attach;
|
|
59
62
|
readonly signal: AbortSignal;
|
|
60
63
|
readonly applied: () => void;
|
|
@@ -76,6 +79,8 @@ export namespace StatewireHost {
|
|
|
76
79
|
|
|
77
80
|
/** The admitting attach's view: its ephemeral documents plus the shared documents included for it. */
|
|
78
81
|
export type Attach = {
|
|
82
|
+
readonly caller: Caller;
|
|
83
|
+
context: (protocol: string) => Statewire.Context;
|
|
79
84
|
document: (protocol: string, key?: readonly string[]) => unknown;
|
|
80
85
|
/** `protocol` must be ephemeral; the encoded value is bounded by `MAX_EPHEMERAL_DOCUMENT_BYTES`. */
|
|
81
86
|
mount: (
|
package/src/statewire.ts
CHANGED
|
@@ -40,7 +40,10 @@ const useSessionTransport = ({
|
|
|
40
40
|
session: StatewireClient.SessionStorage | undefined;
|
|
41
41
|
documents: StatewireDocuments.Storage | undefined;
|
|
42
42
|
protocol: StatewireProtocol.Options;
|
|
43
|
-
link: {
|
|
43
|
+
link: {
|
|
44
|
+
isNew?: boolean | undefined;
|
|
45
|
+
context?: Readonly<Record<string, Statewire.Context>> | undefined;
|
|
46
|
+
};
|
|
44
47
|
}): Statewire<unknown> =>
|
|
45
48
|
useContextProvider(SessionStorageContext, session, () =>
|
|
46
49
|
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
@@ -92,8 +95,11 @@ export const useStatewire = <
|
|
|
92
95
|
}),
|
|
93
96
|
};
|
|
94
97
|
const link = useMemo(
|
|
95
|
-
() => (
|
|
96
|
-
|
|
98
|
+
() => ({
|
|
99
|
+
...(options.isNew === undefined ? {} : { isNew: options.isNew }),
|
|
100
|
+
...(options.context === undefined ? {} : { context: options.context }),
|
|
101
|
+
}),
|
|
102
|
+
[options.isNew, options.context],
|
|
97
103
|
);
|
|
98
104
|
const value = useResource(
|
|
99
105
|
SessionTransport({
|
|
@@ -104,6 +110,14 @@ export const useStatewire = <
|
|
|
104
110
|
link,
|
|
105
111
|
}),
|
|
106
112
|
);
|
|
113
|
+
const localContext =
|
|
114
|
+
value.connection === undefined ? value.setContext : undefined;
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const current = latest.current;
|
|
117
|
+
if (localContext === undefined || current.context === undefined) return;
|
|
118
|
+
for (const [protocol, context] of Object.entries(current.context))
|
|
119
|
+
localContext(protocol, context);
|
|
120
|
+
}, [options.context, localContext]);
|
|
107
121
|
const core = useStatewireCore(() => latest.current, value);
|
|
108
122
|
useEffect(() => core.rematerialize(), [core, options.materializeKey]);
|
|
109
123
|
const [subscribe] = useState(
|
|
@@ -127,6 +141,7 @@ export const useStatewire = <
|
|
|
127
141
|
documents: source,
|
|
128
142
|
selection,
|
|
129
143
|
invoke: transportInvoke,
|
|
144
|
+
setContext: transportSetContext,
|
|
130
145
|
failures: transportFailures,
|
|
131
146
|
} = value;
|
|
132
147
|
|
|
@@ -141,6 +156,15 @@ export const useStatewire = <
|
|
|
141
156
|
[transportInvoke],
|
|
142
157
|
);
|
|
143
158
|
|
|
159
|
+
const setContext = useCallback(
|
|
160
|
+
(protocol: string, value: Statewire.Context) => {
|
|
161
|
+
if (!transportSetContext)
|
|
162
|
+
throw new Error("statewire: transport does not support context");
|
|
163
|
+
transportSetContext(protocol, value);
|
|
164
|
+
},
|
|
165
|
+
[transportSetContext],
|
|
166
|
+
);
|
|
167
|
+
|
|
144
168
|
return useMemo(() => {
|
|
145
169
|
const failures = (): NonNullable<typeof transportFailures> => {
|
|
146
170
|
if (!transportFailures) {
|
|
@@ -153,6 +177,7 @@ export const useStatewire = <
|
|
|
153
177
|
documents: source,
|
|
154
178
|
selection,
|
|
155
179
|
invoke,
|
|
180
|
+
setContext,
|
|
156
181
|
getState: core.getState as () => TState,
|
|
157
182
|
connection,
|
|
158
183
|
commands: core.commands as Statewire.CommandsProxy<TCommands>,
|
|
@@ -173,6 +198,7 @@ export const useStatewire = <
|
|
|
173
198
|
selection,
|
|
174
199
|
invoke,
|
|
175
200
|
transportFailures,
|
|
201
|
+
setContext,
|
|
176
202
|
]);
|
|
177
203
|
};
|
|
178
204
|
|
|
@@ -221,6 +247,9 @@ export class StatewireClient<
|
|
|
221
247
|
): Promise<unknown> {
|
|
222
248
|
return this.root.getValue().invoke(protocol, method, params);
|
|
223
249
|
}
|
|
250
|
+
setContext(protocol: string, value: Statewire.Context): void {
|
|
251
|
+
this.root.getValue().setContext(protocol, value);
|
|
252
|
+
}
|
|
224
253
|
get isBusy(): boolean {
|
|
225
254
|
return this.root.getValue().isBusy;
|
|
226
255
|
}
|
|
@@ -254,6 +283,7 @@ export namespace StatewireClient {
|
|
|
254
283
|
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
255
284
|
> = {
|
|
256
285
|
protocol?: string;
|
|
286
|
+
context?: Readonly<Record<string, Statewire.Context>>;
|
|
257
287
|
keyPrefixes?: Readonly<Record<string, readonly string[]>>;
|
|
258
288
|
wireVersion?: StatewireProtocol.Range;
|
|
259
289
|
applicationProtocols?: readonly StatewireProtocol.Offer[] | "*";
|
|
@@ -298,6 +328,7 @@ export namespace StatewireClient {
|
|
|
298
328
|
state: TState;
|
|
299
329
|
documents: StatewireDocuments.Source | undefined;
|
|
300
330
|
selection: StatewireProtocol.Selection | undefined;
|
|
331
|
+
setContext: (protocol: string, value: Statewire.Context) => void;
|
|
301
332
|
invoke: (
|
|
302
333
|
protocol: string,
|
|
303
334
|
method: string,
|
package/src/transports/remote.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
retryFailed,
|
|
10
10
|
setTransport,
|
|
11
11
|
useStatewireSession,
|
|
12
|
+
setContext,
|
|
12
13
|
} from "../client/session";
|
|
13
14
|
import type { StatewireSessionOptions } from "../client/session";
|
|
14
15
|
import type { StatewireClient } from "../statewire";
|
|
@@ -35,6 +36,8 @@ export const useStatewireRemote = <
|
|
|
35
36
|
const lanes = useMemo(
|
|
36
37
|
() => ({
|
|
37
38
|
documents: documentSource(s),
|
|
39
|
+
setContext: (protocol: string, value: Statewire.Context) =>
|
|
40
|
+
setContext(s, protocol, value),
|
|
38
41
|
invoke: (protocol: string, method: string, params: readonly unknown[]) =>
|
|
39
42
|
invokeProtocol(s, protocol, method, params),
|
|
40
43
|
failures: {
|
package/src/transports/sse.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StatewireDocument } from "../document";
|
|
1
2
|
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
2
3
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
3
4
|
import { createSseParser } from "./sse-parser";
|
|
@@ -25,6 +26,7 @@ import { negotiationHeaders, parseNegotiation } from "../wire/protocol";
|
|
|
25
26
|
|
|
26
27
|
/** A client frame's lanes: stamped statements, strictly consecutive per lane. */
|
|
27
28
|
export type StatewireFrame = {
|
|
29
|
+
ctx?: ({ protocol: string } & StatewireDocument.Encoded)[];
|
|
28
30
|
cmd?: {
|
|
29
31
|
protocol: string;
|
|
30
32
|
method: string;
|
|
@@ -59,6 +61,7 @@ export type ConnectionState = StatewireClient.ConnectionState;
|
|
|
59
61
|
/** The live wire a remote session drives: connection status plus frame submission. */
|
|
60
62
|
export type RemoteLink = {
|
|
61
63
|
storageFailed: (error: Error) => void;
|
|
64
|
+
protocolFailed: (error: Error) => void;
|
|
62
65
|
connection: StatewireClient.Connection;
|
|
63
66
|
/** A deferred first attach still waiting; `reconnect()` releases it. */
|
|
64
67
|
parked: boolean;
|
|
@@ -67,6 +70,16 @@ export type RemoteLink = {
|
|
|
67
70
|
|
|
68
71
|
const LEASE_HEADER = "Statewire-Lease";
|
|
69
72
|
|
|
73
|
+
let warnedNoLease = false;
|
|
74
|
+
|
|
75
|
+
const warnNoLease = () => {
|
|
76
|
+
if (warnedNoLease) return;
|
|
77
|
+
warnedNoLease = true;
|
|
78
|
+
console.warn(
|
|
79
|
+
"statewire: SSE attach returned no readable Statewire-Lease header; commands will re-attach the stream. Cross-origin hosts must expose it (Access-Control-Expose-Headers: Statewire-Lease).",
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
70
83
|
const baseUrl = (options: StatewireClient.HttpOptions) =>
|
|
71
84
|
options.url.replace(/\/$/, "");
|
|
72
85
|
|
|
@@ -179,10 +192,16 @@ const attemptStream = async (
|
|
|
179
192
|
if (!res.body)
|
|
180
193
|
throw new Error("statewire: protocol error: stream response has no body");
|
|
181
194
|
c.lease = res.headers.get(LEASE_HEADER);
|
|
195
|
+
if (c.lease === null) warnNoLease();
|
|
182
196
|
const reader = res.body.getReader();
|
|
197
|
+
const cancel = () => {
|
|
198
|
+
void reader.cancel().catch(() => {});
|
|
199
|
+
};
|
|
200
|
+
abort.signal.addEventListener("abort", cancel, { once: true });
|
|
183
201
|
try {
|
|
184
202
|
return await consume(c, reader, heartbeat, () => abort.signal.aborted);
|
|
185
203
|
} finally {
|
|
204
|
+
abort.signal.removeEventListener("abort", cancel);
|
|
186
205
|
reader.cancel().catch(() => {});
|
|
187
206
|
}
|
|
188
207
|
};
|