statewire 0.20.0 → 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.
@@ -128,6 +128,8 @@ export type Conn = {
128
128
  lease: string | null;
129
129
  /** The awaited `authorize` return captured at this attach's authorization. */
130
130
  auth: unknown;
131
+ authorizedAt: number | undefined;
132
+ leaseTimer: ReturnType<typeof setTimeout> | null;
131
133
  lanes: Map<string, Lane>;
132
134
  contexts: Map<string, Statewire.Context>;
133
135
  documents: Map<string, StatewireHost.Document>;
@@ -316,13 +318,11 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
316
318
  };
317
319
  }
318
320
  default:
321
+ console.error("statewire command failed", outcome.error);
319
322
  return {
320
323
  ...at,
321
324
  type: "failed",
322
- message:
323
- outcome.error instanceof Error
324
- ? outcome.error.message
325
- : String(outcome.error),
325
+ message: "the command failed",
326
326
  };
327
327
  }
328
328
  };
@@ -356,6 +356,7 @@ export type SocketHostState = {
356
356
  key: readonly string[] | undefined;
357
357
  wireVersion: StatewireProtocol.Range;
358
358
  heartbeatMs: number;
359
+ maxLeaseLifetimeMs: number | null;
359
360
  wsInitTimeoutMs: number;
360
361
  maxSubscribers: number;
361
362
  maxInflightCommands: number;
@@ -451,6 +452,10 @@ export const closeConn = (
451
452
  if (!conn.open) return;
452
453
  conn.open = false;
453
454
  h.conns.delete(conn);
455
+ if (conn.leaseTimer !== null) {
456
+ clearTimeout(conn.leaseTimer);
457
+ conn.leaseTimer = null;
458
+ }
454
459
  try {
455
460
  conn.socket.close(code, reason);
456
461
  } catch {
@@ -473,6 +478,49 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
473
478
  }
474
479
  };
475
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
+
476
524
  const violate = (h: SocketHostState, conn: Conn, reason: string) => {
477
525
  sendTo(
478
526
  h,
@@ -990,6 +1038,7 @@ const admitCmd = (
990
1038
  // never a close; an ephemeral gap is a violation (the lane has no replay).
991
1039
  export type FrameAdmission =
992
1040
  | { type: "ok" }
1041
+ | { type: "expired" }
993
1042
  | {
994
1043
  type: "refused";
995
1044
  syn: {
@@ -1071,6 +1120,7 @@ export const admitFrame = (
1071
1120
  conn: Conn,
1072
1121
  frame: ParsedFrame,
1073
1122
  ): FrameAdmission => {
1123
+ if (expireLease(h, conn)) return { type: "expired" };
1074
1124
  validateContexts(h, conn, frame);
1075
1125
  installContexts(h, conn, frame);
1076
1126
  const rec = h.clients.get(conn.clientId)!;
@@ -1120,6 +1170,7 @@ export const admitFrame = (
1120
1170
  return refusal(h, conn, retry);
1121
1171
  }
1122
1172
  }
1173
+ if (expireLease(h, conn)) return { type: "expired" };
1123
1174
  for (const s of fresh) admitCmd(h, lanes.get(s.protocol)!, conn, s);
1124
1175
  for (const [protocol, lane] of lanes) {
1125
1176
  const last = fresh.findLast((s) => s.protocol === protocol);
@@ -1133,7 +1184,8 @@ export const admitFrame = (
1133
1184
  };
1134
1185
 
1135
1186
  const message = (h: SocketHostState, conn: Conn, data: string) => {
1136
- if (!conn.open || h.disposed || h.finished !== null) return;
1187
+ if (!conn.open || h.disposed || h.finished !== null || expireLease(h, conn))
1188
+ return;
1137
1189
  if (frameIsOversized(data)) {
1138
1190
  sendTo(
1139
1191
  h,
@@ -1269,6 +1321,19 @@ const connectAuthorized = (
1269
1321
  ): StatewireHostInternal.SocketConnection | null => {
1270
1322
  const authorize = h.authorize;
1271
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
+ }
1272
1337
  const registration = register(
1273
1338
  h,
1274
1339
  clientId,
@@ -1276,6 +1341,8 @@ const connectAuthorized = (
1276
1341
  options.authorized === true,
1277
1342
  options.auth,
1278
1343
  options.selection,
1344
+ undefined,
1345
+ options.authorizedAt,
1279
1346
  );
1280
1347
  return registration?.connection ?? null;
1281
1348
  }
@@ -1403,6 +1470,7 @@ const connectAuthorized = (
1403
1470
  .then(() => authorize?.({ headers }))
1404
1471
  .then((auth) => {
1405
1472
  if (!pending.open || h.disposed || h.finished !== null) return;
1473
+ const authorizedAt = Date.now();
1406
1474
  const selection = negotiateProtocols(
1407
1475
  new Headers(headers),
1408
1476
  h.wireVersion,
@@ -1413,7 +1481,7 @@ const connectAuthorized = (
1413
1481
  closePending(h, pending, 1008, selection.fin.message);
1414
1482
  return;
1415
1483
  }
1416
- options.onAuthorized?.(auth, selection);
1484
+ options.onAuthorized?.(auth, selection, authorizedAt);
1417
1485
  pending.open = false;
1418
1486
  h.pendingConns.delete(pending);
1419
1487
  if (pending.timer !== null) clearTimeout(pending.timer);
@@ -1427,6 +1495,7 @@ const connectAuthorized = (
1427
1495
  auth,
1428
1496
  selection,
1429
1497
  frame,
1498
+ authorizedAt,
1430
1499
  );
1431
1500
  } catch (error) {
1432
1501
  if (violationReason(error) === null) throw error;
@@ -1489,6 +1558,7 @@ export const register = (
1489
1558
  protocols: [{ name: h.protocol.name, version: h.protocol.version }],
1490
1559
  },
1491
1560
  frame: ParsedFrame = { cmd: [] },
1561
+ authorizedAt?: number,
1492
1562
  ): Registration | null => {
1493
1563
  const refuse = (code: number, reason: string) => {
1494
1564
  try {
@@ -1513,6 +1583,14 @@ export const register = (
1513
1583
  if (atSubscriberLimit(h)) {
1514
1584
  return refuseSubscriberLimit(socket);
1515
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
+ }
1516
1594
  const known = h.clients.get(clientId);
1517
1595
  if (known !== undefined && !authEquals(known.principal, auth)) {
1518
1596
  try {
@@ -1553,6 +1631,8 @@ export const register = (
1553
1631
  lastSentAt: 0,
1554
1632
  lease: null,
1555
1633
  auth,
1634
+ authorizedAt,
1635
+ leaseTimer: null,
1556
1636
  lanes: new Map(),
1557
1637
  contexts: new Map(),
1558
1638
  documents: new Map(),
@@ -1577,6 +1657,8 @@ export const register = (
1577
1657
  }
1578
1658
  gcClients(h);
1579
1659
  ensureHeartbeat(h);
1660
+ armLeaseTimer(h, conn);
1661
+ if (!conn.open) return null;
1580
1662
  const isEphemeral = (s: CmdStatement) =>
1581
1663
  selection.protocols.find((p) => p.name === s.protocol)?.record ===
1582
1664
  "ephemeral";
@@ -1596,6 +1678,7 @@ export const register = (
1596
1678
  const earlyAdmission = admit(early);
1597
1679
  if (early.length > 0) flush(h);
1598
1680
  notifyClientRecords(h);
1681
+ if (expireLease(h, conn)) return null;
1599
1682
  const ops: WireOp[] = [];
1600
1683
  if (selects(conn, h.protocol.name)) {
1601
1684
  const { value, meta } = encodeDocument(h.stateHost!.snapshot());
@@ -1670,10 +1753,15 @@ const resume = (h: SocketHostState) => {
1670
1753
  h.disposed = false;
1671
1754
  h.stateHost?.setOnOp((op) => recordOp(h, op));
1672
1755
  h.stateHost?.setOnDocuments(() => markViews(h));
1756
+ for (const conn of h.conns) armLeaseTimer(h, conn);
1673
1757
  };
1674
1758
 
1675
1759
  const suspend = (h: SocketHostState) => {
1676
1760
  h.disposed = true;
1761
+ for (const conn of h.conns) {
1762
+ if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
1763
+ conn.leaseTimer = null;
1764
+ }
1677
1765
  if (h.heartbeatTimer !== null) {
1678
1766
  clearInterval(h.heartbeatTimer);
1679
1767
  h.heartbeatTimer = null;
@@ -1725,6 +1813,7 @@ const useSocketHost = (
1725
1813
  key: undefined,
1726
1814
  wireVersion: { version: WIRE_VERSION },
1727
1815
  heartbeatMs: 15_000,
1816
+ maxLeaseLifetimeMs: 86_400_000,
1728
1817
  wsInitTimeoutMs: 5_000,
1729
1818
  maxSubscribers: MAX_SUBSCRIBERS,
1730
1819
  maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
@@ -1768,6 +1857,22 @@ const useSocketHost = (
1768
1857
  h.key = options?.key;
1769
1858
  h.wireVersion = options?.wireVersion ?? { version: WIRE_VERSION };
1770
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
+ }
1771
1876
  h.pushback = options?.pushback;
1772
1877
  h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
1773
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/statewire.ts CHANGED
@@ -156,6 +156,15 @@ export const useStatewire = <
156
156
  [transportInvoke],
157
157
  );
158
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
+
159
168
  return useMemo(() => {
160
169
  const failures = (): NonNullable<typeof transportFailures> => {
161
170
  if (!transportFailures) {
@@ -168,11 +177,7 @@ export const useStatewire = <
168
177
  documents: source,
169
178
  selection,
170
179
  invoke,
171
- setContext: (protocol: string, value: Statewire.Context) => {
172
- if (!transportSetContext)
173
- throw new Error("statewire: transport does not support context");
174
- transportSetContext(protocol, value);
175
- },
180
+ setContext,
176
181
  getState: core.getState as () => TState,
177
182
  connection,
178
183
  commands: core.commands as Statewire.CommandsProxy<TCommands>,
@@ -193,7 +198,7 @@ export const useStatewire = <
193
198
  selection,
194
199
  invoke,
195
200
  transportFailures,
196
- transportSetContext,
201
+ setContext,
197
202
  ]);
198
203
  };
199
204
 
@@ -61,6 +61,7 @@ export type ConnectionState = StatewireClient.ConnectionState;
61
61
  /** The live wire a remote session drives: connection status plus frame submission. */
62
62
  export type RemoteLink = {
63
63
  storageFailed: (error: Error) => void;
64
+ protocolFailed: (error: Error) => void;
64
65
  connection: StatewireClient.Connection;
65
66
  /** A deferred first attach still waiting; `reconnect()` releases it. */
66
67
  parked: boolean;
@@ -69,6 +70,16 @@ export type RemoteLink = {
69
70
 
70
71
  const LEASE_HEADER = "Statewire-Lease";
71
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
+
72
83
  const baseUrl = (options: StatewireClient.HttpOptions) =>
73
84
  options.url.replace(/\/$/, "");
74
85
 
@@ -181,6 +192,7 @@ const attemptStream = async (
181
192
  if (!res.body)
182
193
  throw new Error("statewire: protocol error: stream response has no body");
183
194
  c.lease = res.headers.get(LEASE_HEADER);
195
+ if (c.lease === null) warnNoLease();
184
196
  const reader = res.body.getReader();
185
197
  const cancel = () => {
186
198
  void reader.cancel().catch(() => {});