statewire 0.10.0 → 0.12.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 (51) hide show
  1. package/README.md +2 -2
  2. package/dist/client/connection.d.ts +6 -4
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +10 -2
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +1 -1
  7. package/dist/client/session.d.ts +10 -2
  8. package/dist/client/session.d.ts.map +1 -1
  9. package/dist/client/session.js +246 -97
  10. package/dist/client/session.js.map +1 -1
  11. package/dist/host/host.d.ts +1 -1
  12. package/dist/host/http.js +4 -3
  13. package/dist/host/http.js.map +1 -1
  14. package/dist/host/socket.d.ts +6 -5
  15. package/dist/host/socket.d.ts.map +1 -1
  16. package/dist/host/socket.js +12 -20
  17. package/dist/host/socket.js.map +1 -1
  18. package/dist/host-internal.d.ts +2 -1
  19. package/dist/host-internal.d.ts.map +1 -1
  20. package/dist/host.d.ts +2 -2
  21. package/dist/host.d.ts.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/statewire.d.ts +29 -3
  24. package/dist/statewire.d.ts.map +1 -1
  25. package/dist/statewire.js.map +1 -1
  26. package/dist/transports/fake-sse.js +4 -4
  27. package/dist/transports/fake-sse.js.map +1 -1
  28. package/dist/transports/fixtures.d.ts +1 -1
  29. package/dist/transports/remote.d.ts +1 -1
  30. package/dist/transports/remote.js +1 -0
  31. package/dist/transports/remote.js.map +1 -1
  32. package/dist/transports/sse.d.ts +2 -2
  33. package/dist/transports/testing.d.ts +1 -1
  34. package/dist/transports/testing.js +2 -4
  35. package/dist/transports/testing.js.map +1 -1
  36. package/dist/wire/meta.d.ts +5 -3
  37. package/dist/wire/meta.d.ts.map +1 -1
  38. package/dist/wire/meta.js +7 -4
  39. package/dist/wire/meta.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/client/connection.ts +12 -6
  42. package/src/client/session.ts +332 -114
  43. package/src/host/http.ts +10 -9
  44. package/src/host/socket.ts +18 -28
  45. package/src/host-internal.ts +2 -1
  46. package/src/host.ts +0 -1
  47. package/src/statewire.ts +33 -3
  48. package/src/transports/fake-sse.ts +6 -6
  49. package/src/transports/remote.ts +1 -0
  50. package/src/transports/testing.ts +2 -7
  51. package/src/wire/meta.ts +12 -4
package/src/host/http.ts CHANGED
@@ -166,7 +166,7 @@ const getStream = async (
166
166
  const attachment = attachSse(h, client.clientId);
167
167
  const lease = attachment.registration === null ? null : mintLease();
168
168
  if (attachment.registration !== null)
169
- attachment.registration.rec.lease = lease;
169
+ attachment.registration.conn.lease = lease;
170
170
  return streamResponse(attachment, lease);
171
171
  };
172
172
 
@@ -202,7 +202,7 @@ const postStream = async (
202
202
  const attachment = attachSse(h, client.clientId);
203
203
  if (attachment.registration === null) return streamResponse(attachment, null);
204
204
  const lease = mintLease();
205
- attachment.registration.rec.lease = lease;
205
+ attachment.registration.conn.lease = lease;
206
206
  admitAttachFrame(h, attachment.registration, frame);
207
207
  return streamResponse(attachment, lease);
208
208
  };
@@ -254,13 +254,14 @@ export const frames = async (
254
254
  if ("response" in client) return client.response;
255
255
  const rec = h.clients.get(client.clientId);
256
256
  const lease = request.headers.get(LEASE_HEADER);
257
- if (
258
- h.disposed ||
259
- h.finished !== null ||
260
- rec === undefined ||
261
- lease === null ||
262
- lease !== rec.lease
263
- ) {
257
+ // A lease lives and dies with its attach: any live attach of the id accepts.
258
+ const live =
259
+ lease !== null &&
260
+ [...h.conns].some(
261
+ (conn) =>
262
+ conn.open && conn.clientId === client.clientId && conn.lease === lease,
263
+ );
264
+ if (h.disposed || h.finished !== null || rec === undefined || !live) {
264
265
  return Response.json({ error: "stale-lease" }, { status: 423 });
265
266
  }
266
267
  let frame: ParsedFrame;
@@ -38,7 +38,6 @@ export type ClientRec = {
38
38
  id: string;
39
39
  lastSeq: number;
40
40
  ctxSeq: number;
41
- lease: string | null;
42
41
  touched: number;
43
42
  context: Record<string, unknown> | null;
44
43
  results: Map<number, { ts: number; cmd: StatewireCmd }>;
@@ -51,6 +50,7 @@ export type Conn = {
51
50
  open: boolean;
52
51
  sawFrame: boolean;
53
52
  lastSentAt: number;
53
+ lease: string | null;
54
54
  };
55
55
 
56
56
  // ─── frame parsing ──────────────────────────────────────
@@ -115,7 +115,7 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
115
115
  }
116
116
  const value =
117
117
  op["meta"] !== undefined
118
- ? reviveValue(op["value"], op["meta"])
118
+ ? reviveValue(op["value"], op["meta"], { rejectUnknown: true })
119
119
  : op["value"];
120
120
  if (!isPlainObject(value)) {
121
121
  throw new Error("context value must be an object");
@@ -128,13 +128,13 @@ export const parseFrameValue = (
128
128
  first: boolean,
129
129
  ): ParsedFrame => {
130
130
  if (!isPlainObject(frame)) return violation("malformed frame");
131
- if (
132
- frame["headers"] === undefined &&
133
- frame["ctx"] === undefined &&
134
- frame["cmd"] === undefined
135
- ) {
136
- return violation("malformed frame");
137
- }
131
+ const keys = Object.keys(frame);
132
+ if (keys.length === 0) return violation("malformed frame");
133
+ const unknown = keys
134
+ .filter((key) => key !== "headers" && key !== "ctx" && key !== "cmd")
135
+ .sort();
136
+ if (unknown.length > 0)
137
+ return violation(`unknown frame key: '${unknown[0]}'`);
138
138
  let headers: Record<string, string> | null = null;
139
139
  if (frame["headers"] !== undefined) {
140
140
  if (!first) return violation("mid-session headers");
@@ -267,7 +267,7 @@ export type SocketHostState = {
267
267
  connect: (
268
268
  clientId: string,
269
269
  socket: StatewireHostInternal.Socket,
270
- ) => StatewireHostInternal.SocketConnection;
270
+ ) => StatewireHostInternal.SocketConnection | null;
271
271
  finish: (fin: StatewireHost.Fin) => void;
272
272
  stream: (request: Request) => Promise<Response>;
273
273
  frames: (request: Request) => Promise<Response>;
@@ -563,7 +563,9 @@ const admitCmd = (
563
563
  let params = statement.params;
564
564
  if (statement.meta !== undefined) {
565
565
  try {
566
- params = reviveValue(params, statement.meta) as unknown[];
566
+ params = reviveValue(params, statement.meta, {
567
+ rejectUnknown: true,
568
+ }) as unknown[];
567
569
  } catch (error) {
568
570
  rejectStatement(
569
571
  h,
@@ -717,7 +719,6 @@ export const register = (
717
719
  id: clientId,
718
720
  lastSeq: 0,
719
721
  ctxSeq: 0,
720
- lease: null,
721
722
  touched: Date.now(),
722
723
  context: null,
723
724
  results: new Map(),
@@ -727,18 +728,13 @@ export const register = (
727
728
  return created;
728
729
  })();
729
730
  rec.touched = Date.now();
730
- rec.lease = null;
731
- for (const other of [...h.conns]) {
732
- if (other.clientId !== clientId) continue;
733
- sendTo(h, other, JSON.stringify({ fin: { reason: "superseded" } }));
734
- closeConn(h, other);
735
- }
736
731
  const conn: Conn = {
737
732
  clientId,
738
733
  socket,
739
734
  open: true,
740
735
  sawFrame: false,
741
736
  lastSentAt: 0,
737
+ lease: null,
742
738
  };
743
739
  h.conns.add(conn);
744
740
  gcClients(h);
@@ -756,7 +752,6 @@ export const register = (
756
752
  syn: {
757
753
  seq: admittedThrough(rec),
758
754
  ctxSeq: rec.ctxSeq,
759
- record: known ? "resumed" : "fresh",
760
755
  ...(h.idleWire && { idle: true }),
761
756
  },
762
757
  };
@@ -773,15 +768,9 @@ const connect = (
773
768
  h: SocketHostState,
774
769
  clientId: string,
775
770
  socket: StatewireHostInternal.Socket,
776
- ): StatewireHostInternal.SocketConnection => {
771
+ ): StatewireHostInternal.SocketConnection | null => {
777
772
  const registration = register(h, clientId, socket);
778
- return (
779
- registration?.connection ?? {
780
- clientId,
781
- message: () => {},
782
- close: () => {},
783
- }
784
- );
773
+ return registration?.connection ?? null;
785
774
  };
786
775
 
787
776
  const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
@@ -862,7 +851,8 @@ const useSocketHost = (
862
851
  * feed it sockets (anything with `send`/`close`) and raw text frames, and it
863
852
  * speaks the Statewire packet protocol — snapshot-first hello with `syn`,
864
853
  * statement-lane client frames under one admission law, `{cmd}` answer
865
- * arrays, per-client-id supersede, idle edges, and `fin` on finish.
854
+ * arrays fanned out to every attach of a client id, idle edges, and `fin` on
855
+ * finish.
866
856
  */
867
857
  export const StatewireSocketHost = (
868
858
  transport: ResourceElement<Statewire>,
@@ -15,7 +15,8 @@ export namespace StatewireHostInternal {
15
15
  };
16
16
 
17
17
  export type SocketHost = {
18
- connect: (clientId: string, socket: Socket) => SocketConnection;
18
+ /** Null means refused: the socket was told (fin and/or close) and is dead. */
19
+ connect: (clientId: string, socket: Socket) => SocketConnection | null;
19
20
  stream: (request: Request) => Promise<Response>;
20
21
  frames: (request: Request) => Promise<Response>;
21
22
  finish: (fin: StatewireHost.Fin) => void;
package/src/host.ts CHANGED
@@ -58,7 +58,6 @@ export namespace StatewireHost {
58
58
  | "error"
59
59
  | "unauthorized"
60
60
  | "gone"
61
- | "superseded"
62
61
  | "idle"
63
62
  | (string & {});
64
63
  message?: string;
package/src/statewire.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import type { ResourceElement } from "@assistant-ui/tap";
12
12
  import { LIVE_CONNECTION, useStatewireCore } from "./client/core";
13
13
  import type { Statewire } from "./types";
14
+ import type { TERMINAL } from "./client/connection";
14
15
  import { SessionStorageContext } from "./client/sessionStore";
15
16
 
16
17
  const useSessionTransport = ({
@@ -218,7 +219,7 @@ export namespace StatewireClient {
218
219
  * fate: "aborted" = never sent, "rejected" = the server refused it
219
220
  * (`code` "unknown-command" | "invalid-params" for the static rejects,
220
221
  * code-less for handler rejects), "lost" = sent but no verdict will ever
221
- * arrive — the record is gone (`record: "fresh"`) or the channel died: it
222
+ * arrive — the record is gone (a proven seq gap) or the channel died: it
222
223
  * may or may not have run, "failed" = the handler ran and threw,
223
224
  * "crashed" = the handler died involuntarily, "expired" = settled —
224
225
  * result, rejected, failed, or crashed — but the verdict outlived the
@@ -239,14 +240,25 @@ export namespace StatewireClient {
239
240
  | { fate: "expired"; cause: "server" }
240
241
  | {
241
242
  fate: "aborted";
242
- cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
243
+ cause: "unmount" | "stream-gone" | "session-lost";
243
244
  predecessorLost?: true;
244
245
  }
245
246
  | {
246
247
  fate: "lost";
247
- cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
248
+ cause: "unmount" | "stream-gone" | "session-lost";
248
249
  };
249
250
 
251
+ /** Held commands a proven record gap settled `{fate: "lost", cause: "session-lost"}`; return "requeue" to re-enqueue them on the reset clocks. */
252
+ export type OnSessionLost = (
253
+ pending: ReadonlyArray<{
254
+ method: string;
255
+ params: readonly unknown[];
256
+ ts: number;
257
+ seq: number;
258
+ clientId?: string;
259
+ }>,
260
+ ) => "requeue" | "drop";
261
+
250
262
  /** A command as persisted while un-durable: its lane seq plus the wire statement. */
251
263
  export type SessionCommand = {
252
264
  seq: number;
@@ -261,12 +273,28 @@ export namespace StatewireClient {
261
273
  nextSeq: number;
262
274
  ctxNextSeq: number;
263
275
  commands: readonly SessionCommand[];
276
+ /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
277
+ verdicts?: readonly SessionVerdict[];
278
+ };
279
+
280
+ /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
281
+ export type SessionVerdict = {
282
+ seq: number;
283
+ type: (typeof TERMINAL)[number];
284
+ code?: string;
285
+ message?: string;
286
+ payload?: unknown;
287
+ meta?: unknown;
264
288
  };
265
289
 
266
290
  /** Session storage seam; `save` must persist synchronously in the caller's flow. */
267
291
  export type SessionStorage = {
268
292
  load: () => SessionRecord | null;
269
293
  save: (record: SessionRecord) => void;
294
+ /** Serializes record mutations across sharers; presence marks the storage shared. */
295
+ lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;
296
+ /** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */
297
+ subscribe?: (listener: () => void) => () => void;
270
298
  };
271
299
 
272
300
  /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
@@ -279,6 +307,7 @@ export namespace StatewireClient {
279
307
  url: string;
280
308
  headers?: HeadersOption;
281
309
  fetch?: typeof globalThis.fetch;
310
+ onSessionLost?: OnSessionLost;
282
311
  onChange?: (connection: Connection) => void;
283
312
  /** The broadcast idle flag's rising edge: the instance went quiet. */
284
313
  onDone?: () => void;
@@ -293,6 +322,7 @@ export namespace StatewireClient {
293
322
  | Record<string, string>
294
323
  | (() => Record<string, string> | Promise<Record<string, string>>);
295
324
  webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
325
+ onSessionLost?: OnSessionLost;
296
326
  onChange?: (connection: Connection) => void;
297
327
  /** The broadcast idle flag's rising edge: the instance went quiet. */
298
328
  onDone?: () => void;
@@ -30,7 +30,11 @@ export const openSSEStream = (
30
30
  if (!open) return;
31
31
  open = false;
32
32
  streams.delete(handle);
33
- controller.close();
33
+ try {
34
+ controller.close();
35
+ } catch {
36
+ // the reader already canceled
37
+ }
34
38
  },
35
39
  };
36
40
  streams.add(handle);
@@ -40,11 +44,7 @@ export const openSSEStream = (
40
44
  streams.delete(handle);
41
45
  },
42
46
  });
43
- signal?.addEventListener("abort", () => {
44
- for (const stream of [...streams]) {
45
- if (stream.clientId === clientId) stream.close();
46
- }
47
- });
47
+ signal?.addEventListener("abort", () => handle.close());
48
48
  return new Response(body, {
49
49
  status: 200,
50
50
  headers: { "content-type": "text/event-stream", ...headers },
@@ -21,6 +21,7 @@ export const useStatewireRemote = <
21
21
  ): Statewire<any, any> => {
22
22
  const s = useStatewireSession({
23
23
  sessionStore: options.sessionStore,
24
+ onSessionLost: options.onSessionLost,
24
25
  onDone: options.onDone,
25
26
  });
26
27
  const conn = useLink(options, s.ctx);
@@ -77,8 +77,7 @@ export const createMockTransport = <TState>(
77
77
  const id = headers.get("Statewire-Client-Id") ?? "";
78
78
  if (url.endsWith("/stream")) {
79
79
  clientId = id;
80
- const known = clients.has(id);
81
- if (!known) clients.set(id, { seq: 0, ctxSeq: 0 });
80
+ if (!clients.has(id)) clients.set(id, { seq: 0, ctxSeq: 0 });
82
81
  const rec = clients.get(id)!;
83
82
  const frame = JSON.parse(String(init?.body)) as StatewireFrame;
84
83
  admit(id, frame);
@@ -89,11 +88,7 @@ export const createMockTransport = <TState>(
89
88
  handle.send(
90
89
  sseFrame({
91
90
  ops: [{ op: "replace", path: [], value: state }],
92
- syn: {
93
- seq: rec.seq,
94
- ctxSeq: rec.ctxSeq,
95
- record: known ? "resumed" : "fresh",
96
- },
91
+ syn: { seq: rec.seq, ctxSeq: rec.ctxSeq },
97
92
  }),
98
93
  );
99
94
  },
package/src/wire/meta.ts CHANGED
@@ -17,15 +17,23 @@ const isPath = (path: unknown): path is (string | number)[] =>
17
17
  (typeof seg === "number" && Number.isInteger(seg)),
18
18
  );
19
19
 
20
- /** Revive meta-typed locations inside a wire value (in place). Unknown types
21
- * are skipped; a path that does not resolve to a valid target faults. */
22
- export const reviveValue = (value: unknown, meta: unknown): unknown => {
20
+ /** Revive meta-typed locations inside a wire value (in place). Clients skip
21
+ * unknown types; hosts pass `rejectUnknown` to fault on them instead. */
22
+ export const reviveValue = (
23
+ value: unknown,
24
+ meta: unknown,
25
+ { rejectUnknown = false } = {},
26
+ ): unknown => {
23
27
  if (!Array.isArray(meta)) return fault("meta must be an array");
24
28
  for (const entry of meta as { path?: unknown; type?: unknown }[]) {
25
29
  const path =
26
30
  typeof entry === "object" && entry !== null ? entry.path : null;
27
31
  if (!isPath(path)) return fault("meta entry must carry a path array");
28
- if (entry.type !== "date") continue;
32
+ if (entry.type !== "date") {
33
+ if (rejectUnknown)
34
+ return fault(`unknown meta type: ${String(entry.type)}`);
35
+ continue;
36
+ }
29
37
  const walked = walkStatePath(value, path, "resolve");
30
38
  if (walked === undefined) {
31
39
  return fault(`meta path ${path.join(".")} does not resolve`);