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,9 +1,10 @@
1
1
  import { encodeDocument, reviveDocumentValue } from "../document.js";
2
+ import { COMMAND_CAPACITY_RETRY_MS, MAX_INFLIGHT_COMMANDS_PER_HOST } from "../wire/limits.js";
2
3
  import { DEFAULT_PROTOCOL, WIRE_VERSION, negotiateProtocols } from "../wire/protocol.js";
4
+ import { captureContext, contextValue } from "../wire/context.js";
3
5
  import { sanitizeValue } from "../wire/ops.js";
4
6
  import { StatewireSendError } from "../types.js";
5
7
  import { documentIdentity, getCommandsHost, getStateHost } from "./host.js";
6
- import { COMMAND_CAPACITY_RETRY_MS, MAX_INFLIGHT_COMMANDS_PER_HOST } from "../wire/limits.js";
7
8
  import { createMacrotaskPoster } from "../util/macrotask.js";
8
9
  import { frames, stream } from "./http.js";
9
10
  import { useEffect, useState } from "@assistant-ui/tap/react-shim";
@@ -40,11 +41,11 @@ const parseFrameValue = (frame) => {
40
41
  if (!isPlainObject(frame)) return violation("malformed frame");
41
42
  const keys = Object.keys(frame);
42
43
  if (keys.length === 0) return violation("malformed frame");
43
- const unknown = keys.filter((key) => key !== "cmd").sort();
44
+ const unknown = keys.filter((key) => key !== "cmd" && key !== "ctx").sort();
44
45
  if (unknown.length > 0) return violation(`unknown frame key: '${unknown[0]}'`);
45
- const lane = frame["cmd"];
46
+ const lane = frame["cmd"] ?? [];
46
47
  if (!Array.isArray(lane)) return violation("malformed frame");
47
- if (lane.length === 0) return violation("empty lane");
48
+ if (Object.hasOwn(frame, "cmd") && lane.length === 0) return violation("empty lane");
48
49
  if (lane.length > 256) return violation("too many commands");
49
50
  const prev = /* @__PURE__ */ new Map();
50
51
  const cmd = [];
@@ -65,7 +66,39 @@ const parseFrameValue = (frame) => {
65
66
  ...statement["meta"] !== void 0 && { meta: statement["meta"] }
66
67
  });
67
68
  }
68
- return { cmd };
69
+ const contexts = frame["ctx"];
70
+ const ctx = [];
71
+ if (contexts !== void 0) {
72
+ if (!Array.isArray(contexts) || contexts.length === 0) return violation("empty context lane");
73
+ const seen = /* @__PURE__ */ new Set();
74
+ for (const entry of contexts) {
75
+ if (!isPlainObject(entry) || typeof entry["protocol"] !== "string" || !Object.hasOwn(entry, "value") || Object.keys(entry).some((key) => ![
76
+ "protocol",
77
+ "value",
78
+ "meta"
79
+ ].includes(key))) return violation("malformed context");
80
+ const protocol = entry["protocol"];
81
+ if (seen.has(protocol)) return violation("duplicate context protocol");
82
+ seen.add(protocol);
83
+ if (frameEncoder.encode(JSON.stringify(entry)).byteLength > 65536) return violation("context entry too large");
84
+ try {
85
+ ctx.push({
86
+ protocol,
87
+ value: contextValue({
88
+ value: entry["value"],
89
+ ...entry["meta"] !== void 0 && { meta: entry["meta"] }
90
+ })
91
+ });
92
+ } catch (error) {
93
+ return violation(error instanceof Error ? error.message : "invalid context");
94
+ }
95
+ }
96
+ }
97
+ if (cmd.length === 0 && ctx.length === 0) return violation("empty frame");
98
+ return {
99
+ cmd,
100
+ ...ctx.length > 0 && { ctx }
101
+ };
69
102
  };
70
103
  const parseFrame = (raw) => {
71
104
  let frame;
@@ -116,11 +149,13 @@ const cmdOf = (adm, outcome) => {
116
149
  ...encodePayload(failure.payload)
117
150
  };
118
151
  }
119
- default: return {
120
- ...at,
121
- type: "failed",
122
- message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
123
- };
152
+ default:
153
+ console.error("statewire command failed", outcome.error);
154
+ return {
155
+ ...at,
156
+ type: "failed",
157
+ message: "the command failed"
158
+ };
124
159
  }
125
160
  };
126
161
  const parseInitialFrame = (raw) => {
@@ -178,6 +213,10 @@ const closeConn = (h, conn, code, reason) => {
178
213
  if (!conn.open) return;
179
214
  conn.open = false;
180
215
  h.conns.delete(conn);
216
+ if (conn.leaseTimer !== null) {
217
+ clearTimeout(conn.leaseTimer);
218
+ conn.leaseTimer = null;
219
+ }
181
220
  try {
182
221
  conn.socket.close(code, reason);
183
222
  } catch {}
@@ -196,6 +235,35 @@ const sendTo = (h, conn, data) => {
196
235
  closeConn(h, conn);
197
236
  }
198
237
  };
238
+ const expiredAt = (h, authorizedAt) => {
239
+ const now = Date.now();
240
+ return authorizedAt === void 0 || !Number.isFinite(authorizedAt) || authorizedAt < 0 || authorizedAt > now || h.maxLeaseLifetimeMs !== null && now - authorizedAt >= h.maxLeaseLifetimeMs;
241
+ };
242
+ const leaseExpiredFin = JSON.stringify({ fin: {
243
+ reason: "reconnect",
244
+ code: "lease-expired"
245
+ } });
246
+ const expireLease = (h, conn) => {
247
+ if (!conn.open || expiredAt(h, conn.authorizedAt)) {
248
+ if (conn.open) {
249
+ sendTo(h, conn, leaseExpiredFin);
250
+ closeConn(h, conn, 1e3, "lease-expired");
251
+ }
252
+ return true;
253
+ }
254
+ return false;
255
+ };
256
+ const armLeaseTimer = (h, conn) => {
257
+ if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
258
+ conn.leaseTimer = null;
259
+ if (h.disposed || h.maxLeaseLifetimeMs === null || expireLease(h, conn)) return;
260
+ const delay = h.maxLeaseLifetimeMs - (Date.now() - conn.authorizedAt);
261
+ conn.leaseTimer = setTimeout(() => {
262
+ conn.leaseTimer = null;
263
+ if (!expireLease(h, conn)) armLeaseTimer(h, conn);
264
+ }, Math.min(delay, 2147483647));
265
+ conn.leaseTimer.unref?.();
266
+ };
199
267
  const violate = (h, conn, reason) => {
200
268
  sendTo(h, conn, JSON.stringify({ fin: {
201
269
  reason: "protocol-error",
@@ -293,6 +361,13 @@ const attachOf = (h, conn) => {
293
361
  return entry;
294
362
  };
295
363
  return {
364
+ get caller() {
365
+ return callerOf(h, h.clients.get(conn.clientId), conn);
366
+ },
367
+ context: (protocol) => {
368
+ requireSelected(protocol);
369
+ return captureContext(conn.contexts.get(protocol) ?? {});
370
+ },
296
371
  document: (protocol, key) => {
297
372
  if (key === void 0 && protocol === h.protocol.name) return h.stateHost.snapshot();
298
373
  const view = viewOf(h, conn);
@@ -537,6 +612,7 @@ const admitCmd = (h, lane, conn, statement) => {
537
612
  clientId: rec.id,
538
613
  caller: callerOf(h, rec, conn),
539
614
  attach: conn.attach,
615
+ context: captureContext(conn.contexts.get(statement.protocol) ?? {}),
540
616
  onApplied: () => {
541
617
  if (adm.settled || adm.applied) return;
542
618
  adm.applied = true;
@@ -562,7 +638,32 @@ const refusal = (h, conn, retry) => ({
562
638
  ...retry !== void 0 && { retry }
563
639
  } }
564
640
  });
641
+ const validateContexts = (h, conn, frame) => {
642
+ for (const statement of frame.cmd) {
643
+ if (!selects(conn, statement.protocol)) return violation("unsupported protocol");
644
+ if (conn.selection.protocols.find((p) => p.name === statement.protocol).record === "ephemeral") {
645
+ const through = watermark(h, conn, statement.protocol);
646
+ const first = frame.cmd.find((s) => s.protocol === statement.protocol && s.seq > through);
647
+ if (first && first.seq !== through + 1) return violation("ephemeral lane gap");
648
+ }
649
+ }
650
+ for (const entry of frame.ctx ?? []) {
651
+ if (!selects(conn, entry.protocol)) return violation("unsupported context protocol");
652
+ try {
653
+ h.current?.validateContext?.(entry.protocol, captureContext(entry.value), conn.attach);
654
+ } catch (error) {
655
+ return violation(error instanceof Error ? error.message : "invalid context");
656
+ }
657
+ }
658
+ };
659
+ const installContexts = (h, conn, frame) => {
660
+ for (const entry of frame.ctx ?? []) conn.contexts.set(entry.protocol, captureContext(entry.value));
661
+ for (const entry of frame.ctx ?? []) h.current?.contextChanged?.(entry.protocol, captureContext(entry.value), conn.attach);
662
+ };
565
663
  const admitFrame = (h, conn, frame) => {
664
+ if (expireLease(h, conn)) return { type: "expired" };
665
+ validateContexts(h, conn, frame);
666
+ installContexts(h, conn, frame);
566
667
  const rec = h.clients.get(conn.clientId);
567
668
  rec.touched = Date.now();
568
669
  const lanes = new Map(frame.cmd.map((s) => [s.protocol, laneOf(h, conn, s.protocol)]));
@@ -594,6 +695,7 @@ const admitFrame = (h, conn, frame) => {
594
695
  return refusal(h, conn, retry);
595
696
  }
596
697
  }
698
+ if (expireLease(h, conn)) return { type: "expired" };
597
699
  for (const s of fresh) admitCmd(h, lanes.get(s.protocol), conn, s);
598
700
  for (const [protocol, lane] of lanes) {
599
701
  const last = fresh.findLast((s) => s.protocol === protocol);
@@ -608,7 +710,7 @@ const admitFrame = (h, conn, frame) => {
608
710
  return { type: "ok" };
609
711
  };
610
712
  const message = (h, conn, data) => {
611
- if (!conn.open || h.disposed || h.finished !== null) return;
713
+ if (!conn.open || h.disposed || h.finished !== null || expireLease(h, conn)) return;
612
714
  if (frameIsOversized(data)) {
613
715
  sendTo(h, conn, JSON.stringify({ fin: {
614
716
  reason: "protocol-error",
@@ -626,7 +728,7 @@ const message = (h, conn, data) => {
626
728
  let admission;
627
729
  try {
628
730
  const frame = first ? parseInitialFrame(data).frame : parseFrame(data);
629
- if (frame.cmd.length === 0) return;
731
+ if (frame.cmd.length === 0 && !frame.ctx?.length) return;
630
732
  admission = admitFrame(h, conn, frame);
631
733
  } catch (error) {
632
734
  const reason = violationReason(error);
@@ -690,7 +792,18 @@ const normalizedHeaders = (headers) => {
690
792
  };
691
793
  const connectAuthorized = (h, clientId, socket, options) => {
692
794
  const authorize = h.authorize;
693
- if (options.authorized === true) return register(h, clientId, socket, options.authorized === true, options.auth, options.selection)?.connection ?? null;
795
+ if (options.authorized === true) {
796
+ if (expiredAt(h, options.authorizedAt)) {
797
+ try {
798
+ socket.send(leaseExpiredFin);
799
+ } catch {}
800
+ try {
801
+ socket.close(1e3, "lease-expired");
802
+ } catch {}
803
+ return null;
804
+ }
805
+ return register(h, clientId, socket, options.authorized === true, options.auth, options.selection, void 0, options.authorizedAt)?.connection ?? null;
806
+ }
694
807
  const refuse = (code, reason) => {
695
808
  try {
696
809
  socket.close(code, reason);
@@ -781,19 +894,20 @@ const connectAuthorized = (h, clientId, socket, options) => {
781
894
  pending.authorizing = true;
782
895
  Promise.resolve().then(() => authorize?.({ headers })).then((auth) => {
783
896
  if (!pending.open || h.disposed || h.finished !== null) return;
897
+ const authorizedAt = Date.now();
784
898
  const selection = negotiateProtocols(new Headers(headers), h.wireVersion, [h.protocol]);
785
899
  if ("fin" in selection) {
786
900
  sendPending(pending, JSON.stringify(selection));
787
901
  closePending(h, pending, 1008, selection.fin.message);
788
902
  return;
789
903
  }
790
- options.onAuthorized?.(auth, selection);
904
+ options.onAuthorized?.(auth, selection, authorizedAt);
791
905
  pending.open = false;
792
906
  h.pendingConns.delete(pending);
793
907
  if (pending.timer !== null) clearTimeout(pending.timer);
794
908
  let registration;
795
909
  try {
796
- registration = register(h, clientId, socket, true, auth, selection, frame);
910
+ registration = register(h, clientId, socket, true, auth, selection, frame, authorizedAt);
797
911
  } catch (error) {
798
912
  if (violationReason(error) === null) throw error;
799
913
  return;
@@ -837,7 +951,7 @@ const register = (h, clientId, socket, sawFrame = false, auth = void 0, selectio
837
951
  name: h.protocol.name,
838
952
  version: h.protocol.version
839
953
  }]
840
- }, frame = { cmd: [] }) => {
954
+ }, frame = { cmd: [] }, authorizedAt) => {
841
955
  const refuse = (code, reason) => {
842
956
  try {
843
957
  socket.close(code, reason);
@@ -855,6 +969,12 @@ const register = (h, clientId, socket, sawFrame = false, auth = void 0, selectio
855
969
  return null;
856
970
  }
857
971
  if (atSubscriberLimit(h)) return refuseSubscriberLimit(socket);
972
+ if (expiredAt(h, authorizedAt)) {
973
+ try {
974
+ socket.send(leaseExpiredFin);
975
+ } catch {}
976
+ return refuse(1e3, "lease-expired");
977
+ }
858
978
  const known = h.clients.get(clientId);
859
979
  if (known !== void 0 && !authEquals(known.principal, auth)) {
860
980
  try {
@@ -887,7 +1007,10 @@ const register = (h, clientId, socket, sawFrame = false, auth = void 0, selectio
887
1007
  lastSentAt: 0,
888
1008
  lease: null,
889
1009
  auth,
1010
+ authorizedAt,
1011
+ leaseTimer: null,
890
1012
  lanes: /* @__PURE__ */ new Map(),
1013
+ contexts: /* @__PURE__ */ new Map(),
891
1014
  documents: /* @__PURE__ */ new Map(),
892
1015
  included: /* @__PURE__ */ new Set(),
893
1016
  view: /* @__PURE__ */ new Map(),
@@ -899,13 +1022,19 @@ const register = (h, clientId, socket, sawFrame = false, auth = void 0, selectio
899
1022
  conn.attach = attachOf(h, conn);
900
1023
  h.conns.add(conn);
901
1024
  try {
1025
+ validateContexts(h, conn, frame);
1026
+ installContexts(h, conn, frame);
902
1027
  h.current?.attaching?.(conn.attach);
903
1028
  } catch (error) {
904
- closeConn(h, conn, 1011, "attach failed");
1029
+ const reason = violationReason(error);
1030
+ if (reason !== null) violate(h, conn, reason);
1031
+ else closeConn(h, conn, 1011, "attach failed");
905
1032
  throw error;
906
1033
  }
907
1034
  gcClients(h);
908
1035
  ensureHeartbeat(h);
1036
+ armLeaseTimer(h, conn);
1037
+ if (!conn.open) return null;
909
1038
  const isEphemeral = (s) => selection.protocols.find((p) => p.name === s.protocol)?.record === "ephemeral";
910
1039
  const early = frame.cmd.filter(isEphemeral);
911
1040
  const late = frame.cmd.filter((s) => !isEphemeral(s));
@@ -923,6 +1052,7 @@ const register = (h, clientId, socket, sawFrame = false, auth = void 0, selectio
923
1052
  const earlyAdmission = admit(early);
924
1053
  if (early.length > 0) flush(h);
925
1054
  notifyClientRecords(h);
1055
+ if (expireLease(h, conn)) return null;
926
1056
  const ops = [];
927
1057
  if (selects(conn, h.protocol.name)) {
928
1058
  const { value, meta } = encodeDocument(h.stateHost.snapshot());
@@ -981,9 +1111,14 @@ const resume = (h) => {
981
1111
  h.disposed = false;
982
1112
  h.stateHost?.setOnOp((op) => recordOp(h, op));
983
1113
  h.stateHost?.setOnDocuments(() => markViews(h));
1114
+ for (const conn of h.conns) armLeaseTimer(h, conn);
984
1115
  };
985
1116
  const suspend = (h) => {
986
1117
  h.disposed = true;
1118
+ for (const conn of h.conns) {
1119
+ if (conn.leaseTimer !== null) clearTimeout(conn.leaseTimer);
1120
+ conn.leaseTimer = null;
1121
+ }
987
1122
  if (h.heartbeatTimer !== null) {
988
1123
  clearInterval(h.heartbeatTimer);
989
1124
  h.heartbeatTimer = null;
@@ -1017,6 +1152,7 @@ const useSocketHost = (transport, options, persistence) => {
1017
1152
  key: void 0,
1018
1153
  wireVersion: { version: WIRE_VERSION },
1019
1154
  heartbeatMs: 15e3,
1155
+ maxLeaseLifetimeMs: 864e5,
1020
1156
  wsInitTimeoutMs: 5e3,
1021
1157
  maxSubscribers: MAX_SUBSCRIBERS,
1022
1158
  maxInflightCommands: MAX_INFLIGHT_COMMANDS_PER_HOST,
@@ -1054,11 +1190,17 @@ const useSocketHost = (transport, options, persistence) => {
1054
1190
  });
1055
1191
  h.protocol = options?.protocol ?? {
1056
1192
  name: "default",
1057
- version: "2026-09-13"
1193
+ version: "2026-09-30"
1058
1194
  };
1059
1195
  h.key = options?.key;
1060
- h.wireVersion = options?.wireVersion ?? { version: "2026-09-13" };
1196
+ h.wireVersion = options?.wireVersion ?? { version: "2026-09-30" };
1061
1197
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15e3);
1198
+ const maxLeaseLifetimeMs = options?.maxLeaseLifetimeMs === void 0 ? 864e5 : options.maxLeaseLifetimeMs;
1199
+ if (maxLeaseLifetimeMs !== null && (!Number.isFinite(maxLeaseLifetimeMs) || maxLeaseLifetimeMs <= 0)) throw new TypeError("maxLeaseLifetimeMs must be finite and positive or null");
1200
+ if (h.maxLeaseLifetimeMs !== maxLeaseLifetimeMs) {
1201
+ h.maxLeaseLifetimeMs = maxLeaseLifetimeMs;
1202
+ for (const conn of [...h.conns]) armLeaseTimer(h, conn);
1203
+ }
1062
1204
  h.pushback = options?.pushback;
1063
1205
  h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5e3;
1064
1206
  const maxSubscribers = options?.maxSubscribers === void 0 ? MAX_SUBSCRIBERS : options.maxSubscribers;
@@ -1117,6 +1259,6 @@ const StatewireSocketHost = (transport, options, persistence) => {
1117
1259
  };
1118
1260
  };
1119
1261
  //#endregion
1120
- export { CLIENT_ID_RE, PRINCIPAL_MISMATCH_MESSAGE, SUBSCRIBER_LIMIT_MESSAGE, StatewireSocketHost, admitFrame, atSubscriberLimit, authEquals, closeConn, flush, parseFrame, parseFrameValue, register, sendTo };
1262
+ export { CLIENT_ID_RE, PRINCIPAL_MISMATCH_MESSAGE, SUBSCRIBER_LIMIT_MESSAGE, StatewireSocketHost, admitFrame, atSubscriberLimit, authEquals, closeConn, expireLease, flush, parseFrame, parseFrameValue, register, sendTo };
1121
1263
 
1122
1264
  //# sourceMappingURL=socket.js.map