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.
Files changed (73) hide show
  1. package/README.md +15 -1
  2. package/dist/client/connection.d.ts.map +1 -1
  3. package/dist/client/connection.js +18 -4
  4. package/dist/client/connection.js.map +1 -1
  5. package/dist/client/session.d.ts +10 -1
  6. package/dist/client/session.d.ts.map +1 -1
  7. package/dist/client/session.js +92 -27
  8. package/dist/client/session.js.map +1 -1
  9. package/dist/client/sessionStore.d.ts +2 -0
  10. package/dist/client/sessionStore.d.ts.map +1 -1
  11. package/dist/client/sessionStore.js.map +1 -1
  12. package/dist/host/host.d.ts +2 -1
  13. package/dist/host/host.d.ts.map +1 -1
  14. package/dist/host/host.js +20 -12
  15. package/dist/host/host.js.map +1 -1
  16. package/dist/host/http.d.ts.map +1 -1
  17. package/dist/host/http.js +14 -9
  18. package/dist/host/http.js.map +1 -1
  19. package/dist/host/socket.d.ts +14 -3
  20. package/dist/host/socket.d.ts.map +1 -1
  21. package/dist/host/socket.js +162 -20
  22. package/dist/host/socket.js.map +1 -1
  23. package/dist/host-internal.d.ts +5 -2
  24. package/dist/host-internal.d.ts.map +1 -1
  25. package/dist/host.d.ts +6 -0
  26. package/dist/host.d.ts.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/statewire.d.ts +3 -0
  29. package/dist/statewire.d.ts.map +1 -1
  30. package/dist/statewire.js +23 -5
  31. package/dist/statewire.js.map +1 -1
  32. package/dist/transports/remote.d.ts.map +1 -1
  33. package/dist/transports/remote.js +3 -2
  34. package/dist/transports/remote.js.map +1 -1
  35. package/dist/transports/sse.d.ts +5 -0
  36. package/dist/transports/sse.d.ts.map +1 -1
  37. package/dist/transports/sse.js +12 -0
  38. package/dist/transports/sse.js.map +1 -1
  39. package/dist/transports/testing.d.ts +2 -0
  40. package/dist/transports/testing.d.ts.map +1 -1
  41. package/dist/transports/testing.js +11 -4
  42. package/dist/transports/testing.js.map +1 -1
  43. package/dist/types.d.ts +4 -0
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/types.js.map +1 -1
  46. package/dist/wire/context.d.ts +14 -0
  47. package/dist/wire/context.d.ts.map +1 -0
  48. package/dist/wire/context.js +31 -0
  49. package/dist/wire/context.js.map +1 -0
  50. package/dist/wire/limits.d.ts +2 -1
  51. package/dist/wire/limits.d.ts.map +1 -1
  52. package/dist/wire/limits.js +2 -1
  53. package/dist/wire/limits.js.map +1 -1
  54. package/dist/wire/protocol.d.ts +1 -1
  55. package/dist/wire/protocol.js +4 -4
  56. package/dist/wire/protocol.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/client/connection.ts +16 -4
  59. package/src/client/session.ts +128 -19
  60. package/src/client/sessionStore.ts +5 -1
  61. package/src/host/host.ts +18 -3
  62. package/src/host/http.ts +37 -5
  63. package/src/host/socket.ts +232 -13
  64. package/src/host-internal.ts +4 -0
  65. package/src/host.ts +5 -0
  66. package/src/statewire.ts +34 -3
  67. package/src/transports/remote.ts +3 -0
  68. package/src/transports/sse.ts +19 -0
  69. package/src/transports/testing.ts +14 -3
  70. package/src/types.ts +12 -0
  71. package/src/wire/context.ts +40 -0
  72. package/src/wire/limits.ts +2 -0
  73. package/src/wire/protocol.ts +1 -1
@@ -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 = { cmd: CmdStatement[] };
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) return violation("empty lane");
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
- return { cmd };
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) return;
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
- closeConn(h, conn, 1011, "attach failed");
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 =
@@ -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: { isNew?: boolean | undefined };
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
- () => (options.isNew === undefined ? {} : { isNew: options.isNew }),
96
- [options.isNew],
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,
@@ -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: {
@@ -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
  };