statewire 0.13.0 → 0.14.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 (43) hide show
  1. package/README.md +2 -0
  2. package/dist/client/connection.d.ts +5 -2
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +54 -19
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +2 -2
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +5 -4
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts.map +1 -1
  11. package/dist/client/session.js +6 -2
  12. package/dist/client/session.js.map +1 -1
  13. package/dist/host/http.d.ts +2 -1
  14. package/dist/host/http.d.ts.map +1 -1
  15. package/dist/host/http.js +36 -3
  16. package/dist/host/http.js.map +1 -1
  17. package/dist/host/socket.d.ts +11 -2
  18. package/dist/host/socket.d.ts.map +1 -1
  19. package/dist/host/socket.js +135 -6
  20. package/dist/host/socket.js.map +1 -1
  21. package/dist/host-internal.d.ts +9 -1
  22. package/dist/host-internal.d.ts.map +1 -1
  23. package/dist/statewire.d.ts +16 -11
  24. package/dist/statewire.d.ts.map +1 -1
  25. package/dist/statewire.js +2 -2
  26. package/dist/statewire.js.map +1 -1
  27. package/dist/transports/sse.d.ts +2 -0
  28. package/dist/transports/sse.d.ts.map +1 -1
  29. package/dist/transports/sse.js +4 -0
  30. package/dist/transports/sse.js.map +1 -1
  31. package/dist/transports/ws.d.ts.map +1 -1
  32. package/dist/transports/ws.js +4 -0
  33. package/dist/transports/ws.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/client/connection.ts +67 -20
  36. package/src/client/core.ts +4 -3
  37. package/src/client/session.ts +10 -2
  38. package/src/host/http.ts +40 -2
  39. package/src/host/socket.ts +220 -6
  40. package/src/host-internal.ts +14 -1
  41. package/src/statewire.ts +23 -10
  42. package/src/transports/sse.ts +6 -0
  43. package/src/transports/ws.ts +4 -0
@@ -53,6 +53,14 @@ export type Conn = {
53
53
  lease: string | null;
54
54
  };
55
55
 
56
+ type PendingConn = {
57
+ socket: StatewireHostInternal.Socket;
58
+ open: boolean;
59
+ authorizing: boolean;
60
+ timer: ReturnType<typeof setTimeout> | null;
61
+ connection: StatewireHostInternal.SocketConnection | null;
62
+ };
63
+
56
64
  // ─── frame parsing ──────────────────────────────────────
57
65
 
58
66
  const violation = (reason: string): never => {
@@ -247,11 +255,13 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
247
255
 
248
256
  export type SocketHostState = {
249
257
  heartbeatMs: number;
258
+ wsInitTimeoutMs: number;
250
259
  clientRecordLimit: number;
251
260
  finOnIdle: boolean;
252
261
  authorize: StatewireHostInternal.SocketHostOptions["authorize"];
253
262
  clients: Map<string, ClientRec>;
254
263
  conns: Set<Conn>;
264
+ pendingConns: Set<PendingConn>;
255
265
  pendingOps: WireOp[];
256
266
  pendingApplied: Map<string, number[]>;
257
267
  pendingCmds: Map<string, StatewireCmd[]>;
@@ -267,6 +277,7 @@ export type SocketHostState = {
267
277
  connect: (
268
278
  clientId: string,
269
279
  socket: StatewireHostInternal.Socket,
280
+ options?: StatewireHostInternal.SocketConnectOptions,
270
281
  ) => StatewireHostInternal.SocketConnection | null;
271
282
  finish: (fin: StatewireHost.Fin) => void;
272
283
  stream: (request: Request) => Promise<Response>;
@@ -667,6 +678,198 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
667
678
  }
668
679
  };
669
680
 
681
+ const closePending = (
682
+ h: SocketHostState,
683
+ pending: PendingConn,
684
+ code?: number,
685
+ reason?: string,
686
+ ) => {
687
+ if (!pending.open) return;
688
+ pending.open = false;
689
+ h.pendingConns.delete(pending);
690
+ if (pending.timer !== null) clearTimeout(pending.timer);
691
+ try {
692
+ pending.socket.close(code, reason);
693
+ } catch {
694
+ /* peer already gone */
695
+ }
696
+ };
697
+
698
+ const sendPending = (pending: PendingConn, data: string): boolean => {
699
+ if (!pending.open) return false;
700
+ try {
701
+ pending.socket.send(data);
702
+ return true;
703
+ } catch {
704
+ return false;
705
+ }
706
+ };
707
+
708
+ const rejectPendingAuthorization = (
709
+ h: SocketHostState,
710
+ pending: PendingConn,
711
+ error: unknown,
712
+ ) => {
713
+ const response = error instanceof Response ? error : null;
714
+ const unauthorized = response?.status === 401 || response?.status === 403;
715
+ const status = response?.status;
716
+ const message = unauthorized
717
+ ? "unauthorized"
718
+ : response?.statusText ||
719
+ (status === undefined ? "authorization failed" : `HTTP ${status}`);
720
+ sendPending(
721
+ pending,
722
+ JSON.stringify({
723
+ fin: {
724
+ reason: unauthorized ? "unauthorized" : "error",
725
+ message,
726
+ ...(status !== undefined && { status }),
727
+ },
728
+ }),
729
+ );
730
+ closePending(h, pending, unauthorized ? 1008 : 1011, message);
731
+ };
732
+
733
+ const normalizedHeaders = (
734
+ headers: Record<string, string>,
735
+ ): Record<string, string> => Object.fromEntries(new Headers(headers));
736
+
737
+ const connectAuthorized = (
738
+ h: SocketHostState,
739
+ clientId: string,
740
+ socket: StatewireHostInternal.Socket,
741
+ options: StatewireHostInternal.SocketConnectOptions,
742
+ ): StatewireHostInternal.SocketConnection | null => {
743
+ const authorize = h.authorize;
744
+ if (authorize === undefined || options.authorized === true) {
745
+ const registration = register(
746
+ h,
747
+ clientId,
748
+ socket,
749
+ options.authorized === true,
750
+ );
751
+ return registration?.connection ?? null;
752
+ }
753
+ const refuse = (code: number, reason: string) => {
754
+ try {
755
+ socket.close(code, reason);
756
+ } catch {
757
+ /* peer already gone */
758
+ }
759
+ return null;
760
+ };
761
+ if (h.disposed) return refuse(1001, "disposed");
762
+ if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
763
+ flush(h);
764
+ if (h.finished !== null) {
765
+ try {
766
+ socket.send(JSON.stringify({ fin: h.finished }));
767
+ } catch {
768
+ /* peer already gone */
769
+ }
770
+ return refuse(1000, "finished");
771
+ }
772
+ const pending: PendingConn = {
773
+ socket,
774
+ open: true,
775
+ authorizing: false,
776
+ timer: null,
777
+ connection: null,
778
+ };
779
+ h.pendingConns.add(pending);
780
+ pending.timer = setTimeout(
781
+ () =>
782
+ rejectPendingAuthorization(
783
+ h,
784
+ pending,
785
+ new Response(null, { status: 401 }),
786
+ ),
787
+ h.wsInitTimeoutMs,
788
+ );
789
+ (pending.timer as unknown as { unref?: () => void }).unref?.();
790
+ return {
791
+ clientId,
792
+ message: (data) => {
793
+ if (pending.connection !== null) {
794
+ pending.connection.message(data);
795
+ return;
796
+ }
797
+ if (!pending.open || h.disposed || h.finished !== null) return;
798
+ if (pending.authorizing) {
799
+ closePending(h, pending, 1008, "authorization pending");
800
+ return;
801
+ }
802
+ let frame: ParsedFrame;
803
+ try {
804
+ frame = parseFrame(data, true);
805
+ } catch (error) {
806
+ const reason = violationReason(error);
807
+ if (reason === null) throw error;
808
+ sendPending(
809
+ pending,
810
+ JSON.stringify({
811
+ fin: {
812
+ reason: "error",
813
+ message: `protocol violation: ${reason}`,
814
+ },
815
+ }),
816
+ );
817
+ closePending(h, pending, 1008, reason);
818
+ return;
819
+ }
820
+ if (frame.headers === null) {
821
+ rejectPendingAuthorization(
822
+ h,
823
+ pending,
824
+ new Response(null, { status: 401 }),
825
+ );
826
+ return;
827
+ }
828
+ let headers: Record<string, string>;
829
+ try {
830
+ headers = normalizedHeaders(frame.headers);
831
+ } catch {
832
+ sendPending(
833
+ pending,
834
+ JSON.stringify({
835
+ fin: {
836
+ reason: "error",
837
+ message: "protocol violation: malformed headers",
838
+ },
839
+ }),
840
+ );
841
+ closePending(h, pending, 1008, "malformed headers");
842
+ return;
843
+ }
844
+ pending.authorizing = true;
845
+ Promise.resolve()
846
+ .then(() => authorize({ headers }))
847
+ .then(() => {
848
+ if (!pending.open || h.disposed || h.finished !== null) return;
849
+ options.onAuthorized?.();
850
+ pending.open = false;
851
+ h.pendingConns.delete(pending);
852
+ if (pending.timer !== null) clearTimeout(pending.timer);
853
+ const registration = register(h, clientId, socket, true);
854
+ if (registration === null) return;
855
+ pending.connection = registration.connection;
856
+ if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
857
+ const admission = admitFrame(h, registration.rec, frame);
858
+ if (admission.type === "refused") {
859
+ sendTo(h, registration.conn, JSON.stringify(admission.syn));
860
+ }
861
+ })
862
+ .catch((error: unknown) => {
863
+ if (pending.open) rejectPendingAuthorization(h, pending, error);
864
+ });
865
+ },
866
+ close: () => {
867
+ if (pending.connection !== null) pending.connection.close();
868
+ else closePending(h, pending);
869
+ },
870
+ };
871
+ };
872
+
670
873
  const gcClients = (h: SocketHostState) => {
671
874
  let excess = h.clients.size - h.clientRecordLimit;
672
875
  if (excess <= 0) return;
@@ -690,6 +893,7 @@ export const register = (
690
893
  h: SocketHostState,
691
894
  clientId: string,
692
895
  socket: StatewireHostInternal.Socket,
896
+ sawFrame = false,
693
897
  ): Registration | null => {
694
898
  const refuse = (code: number, reason: string) => {
695
899
  try {
@@ -732,7 +936,7 @@ export const register = (
732
936
  clientId,
733
937
  socket,
734
938
  open: true,
735
- sawFrame: false,
939
+ sawFrame,
736
940
  lastSentAt: 0,
737
941
  lease: null,
738
942
  };
@@ -768,14 +972,18 @@ const connect = (
768
972
  h: SocketHostState,
769
973
  clientId: string,
770
974
  socket: StatewireHostInternal.Socket,
975
+ options: StatewireHostInternal.SocketConnectOptions = {},
771
976
  ): StatewireHostInternal.SocketConnection | null => {
772
- const registration = register(h, clientId, socket);
773
- return registration?.connection ?? null;
977
+ return connectAuthorized(h, clientId, socket, options);
774
978
  };
775
979
 
776
980
  const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
777
981
  if (h.finished !== null || h.disposed) return;
778
982
  h.finished = fin;
983
+ for (const pending of [...h.pendingConns]) {
984
+ sendPending(pending, JSON.stringify({ fin }));
985
+ closePending(h, pending, 1000, fin.reason);
986
+ }
779
987
  flush(h, fin);
780
988
  };
781
989
 
@@ -803,11 +1011,13 @@ const useSocketHost = (
803
1011
  const [h] = useState((): SocketHostState => {
804
1012
  const host: SocketHostState = {
805
1013
  heartbeatMs: 15_000,
1014
+ wsInitTimeoutMs: 5_000,
806
1015
  clientRecordLimit: CLIENT_RECORD_LIMIT,
807
1016
  finOnIdle: false,
808
1017
  authorize: undefined,
809
1018
  clients: new Map(),
810
1019
  conns: new Set(),
1020
+ pendingConns: new Set(),
811
1021
  pendingOps: [],
812
1022
  pendingApplied: new Map(),
813
1023
  pendingCmds: new Map(),
@@ -823,7 +1033,8 @@ const useSocketHost = (
823
1033
  host.flushScheduled = false;
824
1034
  flush(host);
825
1035
  }),
826
- connect: (clientId, socket) => connect(host, clientId, socket),
1036
+ connect: (clientId, socket, connectOptions) =>
1037
+ connect(host, clientId, socket, connectOptions),
827
1038
  finish: (fin) => finish(host, fin),
828
1039
  stream: (request) => serveStream(host, request),
829
1040
  frames: (request) => serveFrames(host, request),
@@ -831,6 +1042,7 @@ const useSocketHost = (
831
1042
  return host;
832
1043
  });
833
1044
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
1045
+ h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
834
1046
  h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
835
1047
  h.finOnIdle = options?.finOnIdle ?? false;
836
1048
  h.authorize = options?.authorize;
@@ -862,8 +1074,10 @@ export const StatewireSocketHost = (
862
1074
  const root = createTapRoot(SocketHostRoot);
863
1075
  let disposed = false;
864
1076
  return {
865
- connect: (clientId, socket) =>
866
- flushTapSync(() => root.getValue().connect(clientId, socket)),
1077
+ connect: (clientId, socket, connectOptions) =>
1078
+ flushTapSync(() =>
1079
+ root.getValue().connect(clientId, socket, connectOptions),
1080
+ ),
867
1081
  stream: (request) => flushTapSync(() => root.getValue().stream(request)),
868
1082
  frames: (request) => flushTapSync(() => root.getValue().frames(request)),
869
1083
  finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
@@ -14,9 +14,20 @@ export namespace StatewireHostInternal {
14
14
  close: () => void;
15
15
  };
16
16
 
17
+ export type SocketConnectOptions = {
18
+ /** The adapter restored this socket from an already-authorized session. */
19
+ authorized?: boolean;
20
+ /** Persist authorization before the first snapshot is released. */
21
+ onAuthorized?: () => void;
22
+ };
23
+
17
24
  export type SocketHost = {
18
25
  /** Null means refused: the socket was told (fin and/or close) and is dead. */
19
- connect: (clientId: string, socket: Socket) => SocketConnection | null;
26
+ connect: (
27
+ clientId: string,
28
+ socket: Socket,
29
+ options?: SocketConnectOptions,
30
+ ) => SocketConnection | null;
20
31
  stream: (request: Request) => Promise<Response>;
21
32
  frames: (request: Request) => Promise<Response>;
22
33
  finish: (fin: StatewireHost.Fin) => void;
@@ -27,6 +38,8 @@ export namespace StatewireHostInternal {
27
38
  authorize?: (ctx: {
28
39
  headers: Record<string, string>;
29
40
  }) => void | Promise<void>;
41
+ /** Time allowed to complete WebSocket initialization and authorization. */
42
+ wsInitTimeoutMs?: number;
30
43
  heartbeatIntervalMs?: number;
31
44
  /** LRU cap on stored client records; live or in-flight records are exempt. */
32
45
  clientRecordLimit?: number;
package/src/statewire.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  withKey,
10
10
  } from "@assistant-ui/tap";
11
11
  import type { ResourceElement } from "@assistant-ui/tap";
12
- import { LIVE_CONNECTION, useStatewireCore } from "./client/core";
12
+ import { CONNECTED_CONNECTION, useStatewireCore } from "./client/core";
13
13
  import type { Statewire } from "./types";
14
14
  import type { TERMINAL } from "./client/connection";
15
15
  import { SessionStorageContext } from "./client/sessionStore";
@@ -64,7 +64,7 @@ export const useStatewire = <
64
64
  core.getFailed,
65
65
  core.getFailed,
66
66
  );
67
- const connection = value.connection ?? LIVE_CONNECTION;
67
+ const connection = value.connection ?? CONNECTED_CONNECTION;
68
68
 
69
69
  return useMemo(() => {
70
70
  const failures = (): NonNullable<typeof value.failures> => {
@@ -211,20 +211,29 @@ export namespace StatewireClient {
211
211
  discardFailed: (entry: FailedCommand) => void;
212
212
  };
213
213
 
214
+ /** `cause`/`nextRetryAt`/`lastError` are present only while waiting out the backoff timer; a dial in flight carries `attempt` alone. */
214
215
  export type ConnectionState =
215
- | { status: "connecting" }
216
- | { status: "live" }
217
- | { status: "idle" }
218
216
  | {
219
- status: "reconnecting";
220
- cause: "dropped" | "evicted" | "error" | "unauthorized";
217
+ status: "disconnected";
218
+ reason: "deferred" | "idle" | "gone";
219
+ message?: string;
220
+ payload?: unknown;
221
+ }
222
+ | {
223
+ status: "connecting";
224
+ /** 0 = the episode's first dial, >0 = retry. */
221
225
  attempt: number;
222
- nextRetryAt: number;
226
+ cause?: "dropped" | "evicted" | "error" | "unauthorized";
227
+ nextRetryAt?: number;
223
228
  lastError?: string;
224
229
  message?: string;
225
230
  }
226
- | { status: "gone"; message?: string; payload?: unknown };
227
- export type Connection = ConnectionState & { reconnect: () => void };
231
+ | { status: "connected" };
232
+ export type Connection = ConnectionState & {
233
+ reconnect: () => void;
234
+ /** True while retrying (`connecting` with `attempt > 0`) or parked `gone`. */
235
+ degraded: boolean;
236
+ };
228
237
  export type ConnectionStatus = Connection["status"];
229
238
 
230
239
  /**
@@ -355,6 +364,8 @@ export namespace StatewireClient {
355
364
  export type SSEOptions = {
356
365
  sessionStore?: SessionStorage | undefined;
357
366
  url: string;
367
+ /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
368
+ isNew?: boolean;
358
369
  headers?: HeadersOption;
359
370
  fetch?: typeof globalThis.fetch;
360
371
  onSessionLost?: OnSessionLost;
@@ -366,6 +377,8 @@ export namespace StatewireClient {
366
377
  export type WSOptions = {
367
378
  sessionStore?: SessionStorage | undefined;
368
379
  url: string;
380
+ /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
381
+ isNew?: boolean;
369
382
  headers?: HeadersOption;
370
383
  protocols?: string | string[];
371
384
  query?:
@@ -51,6 +51,8 @@ export type ConnectionState = StatewireClient.ConnectionState;
51
51
  /** The live wire a remote session drives: connection status plus frame submission. */
52
52
  export type RemoteLink = {
53
53
  connection: StatewireClient.Connection;
54
+ /** A deferred first attach still waiting; `reconnect()` releases it. */
55
+ parked: boolean;
54
56
  send: (frame: StatewireFrame) => Promise<StatewireSubmitResult>;
55
57
  };
56
58
 
@@ -269,6 +271,7 @@ const useSseLink = (
269
271
  const [auth] = useState((): AuthState => ({ stale: false }));
270
272
  const c = useStatewireConn({
271
273
  url: options.url,
274
+ isNew: options.isNew,
272
275
  onChange: options.onChange,
273
276
  ctx,
274
277
  attemptOnce: (c, abort) => attemptStream(c, options, auth, abort),
@@ -278,6 +281,9 @@ const useSseLink = (
278
281
  get connection() {
279
282
  return c.connection;
280
283
  },
284
+ get parked() {
285
+ return c.parked;
286
+ },
281
287
  send: (frame) => sendFrame(c, latest.current, auth, frame),
282
288
  }),
283
289
  );
@@ -178,6 +178,7 @@ const useWsLink = (
178
178
  const [w] = useState((): WsChannel => ({ socket: null, authStale: false }));
179
179
  const c = useStatewireConn({
180
180
  url: options.url,
181
+ isNew: options.isNew,
181
182
  onChange: options.onChange,
182
183
  ctx,
183
184
  attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),
@@ -187,6 +188,9 @@ const useWsLink = (
187
188
  get connection() {
188
189
  return c.connection;
189
190
  },
191
+ get parked() {
192
+ return c.parked;
193
+ },
190
194
  send: (frame) => sendFrame(w, frame),
191
195
  }),
192
196
  );