statewire 0.20.0 → 0.22.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 (143) hide show
  1. package/dist/client/changes.d.ts +3 -6
  2. package/dist/client/changes.d.ts.map +1 -1
  3. package/dist/client/changes.js +1 -2
  4. package/dist/client/connection.d.ts +80 -83
  5. package/dist/client/connection.d.ts.map +1 -1
  6. package/dist/client/connection.js +20 -7
  7. package/dist/client/connection.js.map +1 -1
  8. package/dist/client/core.d.ts +39 -41
  9. package/dist/client/core.d.ts.map +1 -1
  10. package/dist/client/core.js +2 -3
  11. package/dist/client/session.d.ts +93 -93
  12. package/dist/client/session.d.ts.map +1 -1
  13. package/dist/client/session.js +47 -23
  14. package/dist/client/session.js.map +1 -1
  15. package/dist/client/sessionStore.d.ts +11 -14
  16. package/dist/client/sessionStore.d.ts.map +1 -1
  17. package/dist/client/sessionStore.js +1 -2
  18. package/dist/client/storageError.d.ts +2 -5
  19. package/dist/client/storageError.d.ts.map +1 -1
  20. package/dist/client/storageError.js +1 -2
  21. package/dist/client/timing.d.ts +11 -14
  22. package/dist/client/timing.d.ts.map +1 -1
  23. package/dist/client/timing.js +1 -2
  24. package/dist/document.d.ts +17 -20
  25. package/dist/document.d.ts.map +1 -1
  26. package/dist/document.js +1 -2
  27. package/dist/host/adapter.d.ts +4 -7
  28. package/dist/host/adapter.d.ts.map +1 -1
  29. package/dist/host/adapter.js +1 -2
  30. package/dist/host/draft.d.ts +13 -16
  31. package/dist/host/draft.d.ts.map +1 -1
  32. package/dist/host/draft.js +1 -2
  33. package/dist/host/engine.d.ts +7 -10
  34. package/dist/host/engine.d.ts.map +1 -1
  35. package/dist/host/engine.js +1 -2
  36. package/dist/host/host.d.ts +42 -45
  37. package/dist/host/host.d.ts.map +1 -1
  38. package/dist/host/host.js +15 -13
  39. package/dist/host/host.js.map +1 -1
  40. package/dist/host/http.d.ts +4 -7
  41. package/dist/host/http.d.ts.map +1 -1
  42. package/dist/host/http.js +15 -11
  43. package/dist/host/http.js.map +1 -1
  44. package/dist/host/socket.d.ts +145 -140
  45. package/dist/host/socket.d.ts.map +1 -1
  46. package/dist/host/socket.js +99 -17
  47. package/dist/host/socket.js.map +1 -1
  48. package/dist/host/synced.d.ts +3 -6
  49. package/dist/host/synced.d.ts.map +1 -1
  50. package/dist/host/synced.js +1 -2
  51. package/dist/host/work.d.ts +2 -5
  52. package/dist/host/work.d.ts.map +1 -1
  53. package/dist/host/work.js +1 -2
  54. package/dist/host-internal.d.ts +99 -90
  55. package/dist/host-internal.d.ts.map +1 -1
  56. package/dist/host.d.ts +90 -91
  57. package/dist/host.d.ts.map +1 -1
  58. package/dist/index.d.ts +11 -8
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +1 -1
  61. package/dist/statewire.d.ts +265 -268
  62. package/dist/statewire.d.ts.map +1 -1
  63. package/dist/statewire.js +7 -7
  64. package/dist/statewire.js.map +1 -1
  65. package/dist/storage.d.ts +23 -26
  66. package/dist/storage.d.ts.map +1 -1
  67. package/dist/storage.js +1 -2
  68. package/dist/transports/fake-sse.d.ts +7 -10
  69. package/dist/transports/fake-sse.d.ts.map +1 -1
  70. package/dist/transports/fake-sse.js +1 -2
  71. package/dist/transports/fixtures.d.ts +19 -25
  72. package/dist/transports/fixtures.d.ts.map +1 -1
  73. package/dist/transports/fixtures.js +1 -2
  74. package/dist/transports/remote.d.ts +8 -11
  75. package/dist/transports/remote.d.ts.map +1 -1
  76. package/dist/transports/remote.js +1 -2
  77. package/dist/transports/sse-parser.d.ts +1 -4
  78. package/dist/transports/sse-parser.d.ts.map +1 -1
  79. package/dist/transports/sse-parser.js +1 -2
  80. package/dist/transports/sse.d.ts +50 -52
  81. package/dist/transports/sse.d.ts.map +1 -1
  82. package/dist/transports/sse.js +9 -3
  83. package/dist/transports/sse.js.map +1 -1
  84. package/dist/transports/testing.d.ts +31 -34
  85. package/dist/transports/testing.d.ts.map +1 -1
  86. package/dist/transports/testing.js +1 -2
  87. package/dist/transports/ws.d.ts +5 -7
  88. package/dist/transports/ws.d.ts.map +1 -1
  89. package/dist/transports/ws.js +3 -4
  90. package/dist/transports/ws.js.map +1 -1
  91. package/dist/types.d.ts +81 -78
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/types.js +1 -2
  94. package/dist/util/draftTraps.d.ts +2 -4
  95. package/dist/util/draftTraps.d.ts.map +1 -1
  96. package/dist/util/draftTraps.js +1 -2
  97. package/dist/util/macrotask.d.ts +1 -4
  98. package/dist/util/macrotask.d.ts.map +1 -1
  99. package/dist/util/macrotask.js +1 -2
  100. package/dist/util/methodProxy.d.ts +1 -4
  101. package/dist/util/methodProxy.d.ts.map +1 -1
  102. package/dist/util/methodProxy.js +1 -2
  103. package/dist/util/randomId.d.ts +1 -4
  104. package/dist/util/randomId.d.ts.map +1 -1
  105. package/dist/util/randomId.js +1 -2
  106. package/dist/util/recordMutations.d.ts +5 -7
  107. package/dist/util/recordMutations.d.ts.map +1 -1
  108. package/dist/util/recordMutations.js +1 -2
  109. package/dist/util/statePath.d.ts +9 -12
  110. package/dist/util/statePath.d.ts.map +1 -1
  111. package/dist/util/statePath.js +1 -2
  112. package/dist/wire/context.d.ts +7 -12
  113. package/dist/wire/context.d.ts.map +1 -1
  114. package/dist/wire/context.js +2 -3
  115. package/dist/wire/documents.d.ts +36 -39
  116. package/dist/wire/documents.d.ts.map +1 -1
  117. package/dist/wire/documents.js +2 -3
  118. package/dist/wire/documents.js.map +1 -1
  119. package/dist/wire/limits.d.ts +7 -10
  120. package/dist/wire/limits.d.ts.map +1 -1
  121. package/dist/wire/limits.js +1 -2
  122. package/dist/wire/meta.d.ts +9 -11
  123. package/dist/wire/meta.d.ts.map +1 -1
  124. package/dist/wire/meta.js +1 -2
  125. package/dist/wire/meta.js.map +1 -1
  126. package/dist/wire/ops.d.ts +9 -12
  127. package/dist/wire/ops.d.ts.map +1 -1
  128. package/dist/wire/ops.js +1 -2
  129. package/dist/wire/protocol.d.ts +53 -56
  130. package/dist/wire/protocol.d.ts.map +1 -1
  131. package/dist/wire/protocol.js +1 -2
  132. package/dist/wire/version.d.ts +1 -4
  133. package/dist/wire/version.d.ts.map +1 -1
  134. package/dist/wire/version.js +1 -2
  135. package/package.json +6 -6
  136. package/src/client/connection.ts +25 -15
  137. package/src/client/session.ts +54 -15
  138. package/src/host/host.ts +9 -3
  139. package/src/host/http.ts +37 -5
  140. package/src/host/socket.ts +158 -9
  141. package/src/host-internal.ts +14 -0
  142. package/src/statewire.ts +11 -6
  143. package/src/transports/sse.ts +12 -0
@@ -128,10 +128,15 @@ 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>;
134
136
  included: Set<string>;
137
+ onIncludedChange:
138
+ | ((included: readonly StatewireHostInternal.IncludedDocument[]) => void)
139
+ | undefined;
135
140
  view: Map<string, { doc: number; value: unknown }>;
136
141
  nextDoc: number;
137
142
  pendingApplied: Cmd[];
@@ -316,13 +321,11 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
316
321
  };
317
322
  }
318
323
  default:
324
+ console.error("statewire command failed", outcome.error);
319
325
  return {
320
326
  ...at,
321
327
  type: "failed",
322
- message:
323
- outcome.error instanceof Error
324
- ? outcome.error.message
325
- : String(outcome.error),
328
+ message: "the command failed",
326
329
  };
327
330
  }
328
331
  };
@@ -356,6 +359,7 @@ export type SocketHostState = {
356
359
  key: readonly string[] | undefined;
357
360
  wireVersion: StatewireProtocol.Range;
358
361
  heartbeatMs: number;
362
+ maxLeaseLifetimeMs: number | null;
359
363
  wsInitTimeoutMs: number;
360
364
  maxSubscribers: number;
361
365
  maxInflightCommands: number;
@@ -451,6 +455,10 @@ export const closeConn = (
451
455
  if (!conn.open) return;
452
456
  conn.open = false;
453
457
  h.conns.delete(conn);
458
+ if (conn.leaseTimer !== null) {
459
+ clearTimeout(conn.leaseTimer);
460
+ conn.leaseTimer = null;
461
+ }
454
462
  try {
455
463
  conn.socket.close(code, reason);
456
464
  } catch {
@@ -473,6 +481,49 @@ export const sendTo = (h: SocketHostState, conn: Conn, data: string) => {
473
481
  }
474
482
  };
475
483
 
484
+ const expiredAt = (h: SocketHostState, authorizedAt: number | undefined) => {
485
+ const now = Date.now();
486
+ return (
487
+ authorizedAt === undefined ||
488
+ !Number.isFinite(authorizedAt) ||
489
+ authorizedAt < 0 ||
490
+ authorizedAt > now ||
491
+ (h.maxLeaseLifetimeMs !== null &&
492
+ now - authorizedAt >= h.maxLeaseLifetimeMs)
493
+ );
494
+ };
495
+
496
+ const leaseExpiredFin = JSON.stringify({
497
+ fin: { reason: "reconnect", code: "lease-expired" },
498
+ });
499
+
500
+ export const expireLease = (h: SocketHostState, conn: Conn): boolean => {
501
+ if (!conn.open || expiredAt(h, conn.authorizedAt)) {
502
+ if (conn.open) {
503
+ sendTo(h, conn, leaseExpiredFin);
504
+ closeConn(h, conn, 1000, "lease-expired");
505
+ }
506
+ return true;
507
+ }
508
+ return false;
509
+ };
510
+
511
+ const armLeaseTimer = (h: SocketHostState, conn: Conn) => {
512
+ if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
513
+ conn.leaseTimer = null;
514
+ if (h.disposed || h.maxLeaseLifetimeMs === null || expireLease(h, conn))
515
+ return;
516
+ const delay = h.maxLeaseLifetimeMs - (Date.now() - conn.authorizedAt!);
517
+ conn.leaseTimer = setTimeout(
518
+ () => {
519
+ conn.leaseTimer = null;
520
+ if (!expireLease(h, conn)) armLeaseTimer(h, conn);
521
+ },
522
+ Math.min(delay, 2_147_483_647),
523
+ );
524
+ (conn.leaseTimer as unknown as { unref?: () => void }).unref?.();
525
+ };
526
+
476
527
  const violate = (h: SocketHostState, conn: Conn, reason: string) => {
477
528
  sendTo(
478
529
  h,
@@ -593,6 +644,33 @@ const diffView = (h: SocketHostState, conn: Conn): WireOp[] => {
593
644
  return ops;
594
645
  };
595
646
 
647
+ const notifyIncluded = (conn: Conn) =>
648
+ conn.onIncludedChange?.(
649
+ [...conn.included].map(
650
+ (id) => JSON.parse(id) as StatewireHostInternal.IncludedDocument,
651
+ ),
652
+ );
653
+
654
+ const restoredIncluded = (
655
+ included: readonly StatewireHostInternal.IncludedDocument[],
656
+ ): Set<string> =>
657
+ new Set(
658
+ included.map((entry) => {
659
+ if (
660
+ !Array.isArray(entry) ||
661
+ entry.length !== 2 ||
662
+ typeof entry[0] !== "string" ||
663
+ !(
664
+ entry[1] === null ||
665
+ (Array.isArray(entry[1]) &&
666
+ entry[1].every((part) => typeof part === "string"))
667
+ )
668
+ )
669
+ throw new TypeError("statewire: invalid restored include");
670
+ return documentIdentity(entry[0], entry[1] ?? undefined);
671
+ }),
672
+ );
673
+
596
674
  const attachOf = (h: SocketHostState, conn: Conn): StatewireHost.Attach => {
597
675
  const requireSelected = (protocol: string) => {
598
676
  const entry = conn.selection.protocols.find((p) => p.name === protocol);
@@ -652,11 +730,15 @@ const attachOf = (h: SocketHostState, conn: Conn): StatewireHost.Attach => {
652
730
  include: (protocol, key) => {
653
731
  if (requireSelected(protocol).record !== undefined)
654
732
  throw new Error(`statewire: protocol ${protocol} is not durable`);
655
- conn.included.add(documentIdentity(protocol, key));
733
+ const id = documentIdentity(protocol, key);
734
+ if (conn.included.has(id)) return;
735
+ conn.included.add(id);
736
+ notifyIncluded(conn);
656
737
  markViews(h);
657
738
  },
658
739
  exclude: (protocol, key) => {
659
- conn.included.delete(documentIdentity(protocol, key));
740
+ if (!conn.included.delete(documentIdentity(protocol, key))) return;
741
+ notifyIncluded(conn);
660
742
  markViews(h);
661
743
  },
662
744
  };
@@ -990,6 +1072,7 @@ const admitCmd = (
990
1072
  // never a close; an ephemeral gap is a violation (the lane has no replay).
991
1073
  export type FrameAdmission =
992
1074
  | { type: "ok" }
1075
+ | { type: "expired" }
993
1076
  | {
994
1077
  type: "refused";
995
1078
  syn: {
@@ -1071,6 +1154,7 @@ export const admitFrame = (
1071
1154
  conn: Conn,
1072
1155
  frame: ParsedFrame,
1073
1156
  ): FrameAdmission => {
1157
+ if (expireLease(h, conn)) return { type: "expired" };
1074
1158
  validateContexts(h, conn, frame);
1075
1159
  installContexts(h, conn, frame);
1076
1160
  const rec = h.clients.get(conn.clientId)!;
@@ -1120,6 +1204,7 @@ export const admitFrame = (
1120
1204
  return refusal(h, conn, retry);
1121
1205
  }
1122
1206
  }
1207
+ if (expireLease(h, conn)) return { type: "expired" };
1123
1208
  for (const s of fresh) admitCmd(h, lanes.get(s.protocol)!, conn, s);
1124
1209
  for (const [protocol, lane] of lanes) {
1125
1210
  const last = fresh.findLast((s) => s.protocol === protocol);
@@ -1133,7 +1218,8 @@ export const admitFrame = (
1133
1218
  };
1134
1219
 
1135
1220
  const message = (h: SocketHostState, conn: Conn, data: string) => {
1136
- if (!conn.open || h.disposed || h.finished !== null) return;
1221
+ if (!conn.open || h.disposed || h.finished !== null || expireLease(h, conn))
1222
+ return;
1137
1223
  if (frameIsOversized(data)) {
1138
1224
  sendTo(
1139
1225
  h,
@@ -1269,6 +1355,19 @@ const connectAuthorized = (
1269
1355
  ): StatewireHostInternal.SocketConnection | null => {
1270
1356
  const authorize = h.authorize;
1271
1357
  if (options.authorized === true) {
1358
+ if (expiredAt(h, options.authorizedAt)) {
1359
+ try {
1360
+ socket.send(leaseExpiredFin);
1361
+ } catch {
1362
+ /* peer already gone */
1363
+ }
1364
+ try {
1365
+ socket.close(1000, "lease-expired");
1366
+ } catch {
1367
+ /* peer already gone */
1368
+ }
1369
+ return null;
1370
+ }
1272
1371
  const registration = register(
1273
1372
  h,
1274
1373
  clientId,
@@ -1276,6 +1375,9 @@ const connectAuthorized = (
1276
1375
  options.authorized === true,
1277
1376
  options.auth,
1278
1377
  options.selection,
1378
+ undefined,
1379
+ options.authorizedAt,
1380
+ options,
1279
1381
  );
1280
1382
  return registration?.connection ?? null;
1281
1383
  }
@@ -1403,6 +1505,7 @@ const connectAuthorized = (
1403
1505
  .then(() => authorize?.({ headers }))
1404
1506
  .then((auth) => {
1405
1507
  if (!pending.open || h.disposed || h.finished !== null) return;
1508
+ const authorizedAt = Date.now();
1406
1509
  const selection = negotiateProtocols(
1407
1510
  new Headers(headers),
1408
1511
  h.wireVersion,
@@ -1413,7 +1516,7 @@ const connectAuthorized = (
1413
1516
  closePending(h, pending, 1008, selection.fin.message);
1414
1517
  return;
1415
1518
  }
1416
- options.onAuthorized?.(auth, selection);
1519
+ options.onAuthorized?.(auth, selection, authorizedAt);
1417
1520
  pending.open = false;
1418
1521
  h.pendingConns.delete(pending);
1419
1522
  if (pending.timer !== null) clearTimeout(pending.timer);
@@ -1427,6 +1530,10 @@ const connectAuthorized = (
1427
1530
  auth,
1428
1531
  selection,
1429
1532
  frame,
1533
+ authorizedAt,
1534
+ options.onIncludedChange === undefined
1535
+ ? {}
1536
+ : { onIncludedChange: options.onIncludedChange },
1430
1537
  );
1431
1538
  } catch (error) {
1432
1539
  if (violationReason(error) === null) throw error;
@@ -1489,7 +1596,13 @@ export const register = (
1489
1596
  protocols: [{ name: h.protocol.name, version: h.protocol.version }],
1490
1597
  },
1491
1598
  frame: ParsedFrame = { cmd: [] },
1599
+ authorizedAt?: number,
1600
+ included: Pick<
1601
+ StatewireHostInternal.SocketConnectOptions,
1602
+ "included" | "onIncludedChange"
1603
+ > = {},
1492
1604
  ): Registration | null => {
1605
+ const restored = restoredIncluded(included.included ?? []);
1493
1606
  const refuse = (code: number, reason: string) => {
1494
1607
  try {
1495
1608
  socket.close(code, reason);
@@ -1513,6 +1626,14 @@ export const register = (
1513
1626
  if (atSubscriberLimit(h)) {
1514
1627
  return refuseSubscriberLimit(socket);
1515
1628
  }
1629
+ if (expiredAt(h, authorizedAt)) {
1630
+ try {
1631
+ socket.send(leaseExpiredFin);
1632
+ } catch {
1633
+ /* peer already gone */
1634
+ }
1635
+ return refuse(1000, "lease-expired");
1636
+ }
1516
1637
  const known = h.clients.get(clientId);
1517
1638
  if (known !== undefined && !authEquals(known.principal, auth)) {
1518
1639
  try {
@@ -1553,10 +1674,13 @@ export const register = (
1553
1674
  lastSentAt: 0,
1554
1675
  lease: null,
1555
1676
  auth,
1677
+ authorizedAt,
1678
+ leaseTimer: null,
1556
1679
  lanes: new Map(),
1557
1680
  contexts: new Map(),
1558
1681
  documents: new Map(),
1559
- included: new Set(),
1682
+ included: restored,
1683
+ onIncludedChange: included.onIncludedChange,
1560
1684
  view: new Map(),
1561
1685
  nextDoc: 1,
1562
1686
  pendingApplied: [],
@@ -1577,6 +1701,8 @@ export const register = (
1577
1701
  }
1578
1702
  gcClients(h);
1579
1703
  ensureHeartbeat(h);
1704
+ armLeaseTimer(h, conn);
1705
+ if (!conn.open) return null;
1580
1706
  const isEphemeral = (s: CmdStatement) =>
1581
1707
  selection.protocols.find((p) => p.name === s.protocol)?.record ===
1582
1708
  "ephemeral";
@@ -1596,6 +1722,7 @@ export const register = (
1596
1722
  const earlyAdmission = admit(early);
1597
1723
  if (early.length > 0) flush(h);
1598
1724
  notifyClientRecords(h);
1725
+ if (expireLease(h, conn)) return null;
1599
1726
  const ops: WireOp[] = [];
1600
1727
  if (selects(conn, h.protocol.name)) {
1601
1728
  const { value, meta } = encodeDocument(h.stateHost!.snapshot());
@@ -1670,10 +1797,15 @@ const resume = (h: SocketHostState) => {
1670
1797
  h.disposed = false;
1671
1798
  h.stateHost?.setOnOp((op) => recordOp(h, op));
1672
1799
  h.stateHost?.setOnDocuments(() => markViews(h));
1800
+ for (const conn of h.conns) armLeaseTimer(h, conn);
1673
1801
  };
1674
1802
 
1675
1803
  const suspend = (h: SocketHostState) => {
1676
1804
  h.disposed = true;
1805
+ for (const conn of h.conns) {
1806
+ if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
1807
+ conn.leaseTimer = null;
1808
+ }
1677
1809
  if (h.heartbeatTimer !== null) {
1678
1810
  clearInterval(h.heartbeatTimer);
1679
1811
  h.heartbeatTimer = null;
@@ -1725,6 +1857,7 @@ const useSocketHost = (
1725
1857
  key: undefined,
1726
1858
  wireVersion: { version: WIRE_VERSION },
1727
1859
  heartbeatMs: 15_000,
1860
+ maxLeaseLifetimeMs: 86_400_000,
1728
1861
  wsInitTimeoutMs: 5_000,
1729
1862
  maxSubscribers: MAX_SUBSCRIBERS,
1730
1863
  maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
@@ -1768,6 +1901,22 @@ const useSocketHost = (
1768
1901
  h.key = options?.key;
1769
1902
  h.wireVersion = options?.wireVersion ?? { version: WIRE_VERSION };
1770
1903
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
1904
+ const maxLeaseLifetimeMs =
1905
+ options?.maxLeaseLifetimeMs === undefined
1906
+ ? 86_400_000
1907
+ : options.maxLeaseLifetimeMs;
1908
+ if (
1909
+ maxLeaseLifetimeMs !== null &&
1910
+ (!Number.isFinite(maxLeaseLifetimeMs) || maxLeaseLifetimeMs <= 0)
1911
+ ) {
1912
+ throw new TypeError(
1913
+ "maxLeaseLifetimeMs must be finite and positive or null",
1914
+ );
1915
+ }
1916
+ if (h.maxLeaseLifetimeMs !== maxLeaseLifetimeMs) {
1917
+ h.maxLeaseLifetimeMs = maxLeaseLifetimeMs;
1918
+ for (const conn of [...h.conns]) armLeaseTimer(h, conn);
1919
+ }
1771
1920
  h.pushback = options?.pushback;
1772
1921
  h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
1773
1922
  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,9 +29,20 @@ export namespace StatewireHostInternal {
28
29
  onAuthorized?: (
29
30
  auth: unknown,
30
31
  selection: StatewireProtocol.Selection,
32
+ authorizedAt: number,
31
33
  ) => void;
34
+ /** Documents the attach had included before the adapter retained its socket; they ride the hello. */
35
+ included?: readonly IncludedDocument[];
36
+ /** Called with the attach's whole include set each time `include` or `exclude` changes it. */
37
+ onIncludedChange?: (included: readonly IncludedDocument[]) => void;
32
38
  };
33
39
 
40
+ /** A durable document an attach included: `[protocol, key]`. */
41
+ export type IncludedDocument = readonly [
42
+ protocol: string,
43
+ key: readonly string[] | null,
44
+ ];
45
+
34
46
  export type SocketHost = {
35
47
  /** Null means refused: the socket was told (fin and/or close) and is dead. */
36
48
  connect: (
@@ -88,6 +100,8 @@ export namespace StatewireHostInternal {
88
100
  /** Time allowed to complete WebSocket initialization and authorization. */
89
101
  wsInitTimeoutMs?: number;
90
102
  heartbeatIntervalMs?: number;
103
+ /** Maximum attach lifetime in milliseconds; defaults to 24 hours, null disables expiry. */
104
+ maxLeaseLifetimeMs?: number | null;
91
105
  /** Maximum number of concurrently attached WebSocket and SSE streams. */
92
106
  maxSubscribers?: number;
93
107
  /** 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(() => {});