statewire 0.14.0 → 0.15.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 (94) hide show
  1. package/README.md +29 -80
  2. package/dist/client/connection.d.ts +19 -6
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +79 -40
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +1 -0
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +5 -0
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts +2 -0
  11. package/dist/client/session.d.ts.map +1 -1
  12. package/dist/client/session.js +96 -16
  13. package/dist/client/session.js.map +1 -1
  14. package/dist/host/adapter.d.ts +1 -1
  15. package/dist/host/adapter.d.ts.map +1 -1
  16. package/dist/host/adapter.js +7 -1
  17. package/dist/host/adapter.js.map +1 -1
  18. package/dist/host/host.d.ts +1 -0
  19. package/dist/host/host.d.ts.map +1 -1
  20. package/dist/host/host.js +45 -5
  21. package/dist/host/host.js.map +1 -1
  22. package/dist/host/http.d.ts +1 -1
  23. package/dist/host/http.d.ts.map +1 -1
  24. package/dist/host/http.js +75 -47
  25. package/dist/host/http.js.map +1 -1
  26. package/dist/host/socket.d.ts +23 -7
  27. package/dist/host/socket.d.ts.map +1 -1
  28. package/dist/host/socket.js +275 -74
  29. package/dist/host/socket.js.map +1 -1
  30. package/dist/host/work.d.ts +7 -0
  31. package/dist/host/work.d.ts.map +1 -0
  32. package/dist/host/work.js +53 -0
  33. package/dist/host/work.js.map +1 -0
  34. package/dist/host-internal.d.ts +35 -5
  35. package/dist/host-internal.d.ts.map +1 -1
  36. package/dist/host-internal.js +2 -1
  37. package/dist/host.d.ts +16 -3
  38. package/dist/host.d.ts.map +1 -1
  39. package/dist/host.js +2 -1
  40. package/dist/statewire.d.ts +11 -2
  41. package/dist/statewire.d.ts.map +1 -1
  42. package/dist/statewire.js.map +1 -1
  43. package/dist/transports/sse.d.ts +1 -4
  44. package/dist/transports/sse.d.ts.map +1 -1
  45. package/dist/transports/sse.js +47 -56
  46. package/dist/transports/sse.js.map +1 -1
  47. package/dist/transports/testing.js +2 -2
  48. package/dist/transports/testing.js.map +1 -1
  49. package/dist/transports/ws.d.ts +1 -1
  50. package/dist/transports/ws.d.ts.map +1 -1
  51. package/dist/transports/ws.js +54 -41
  52. package/dist/transports/ws.js.map +1 -1
  53. package/dist/types.d.ts +3 -0
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js.map +1 -1
  56. package/dist/wire/documents.d.ts +20 -0
  57. package/dist/wire/documents.d.ts.map +1 -0
  58. package/dist/wire/documents.js +74 -0
  59. package/dist/wire/documents.js.map +1 -0
  60. package/dist/wire/limits.d.ts +10 -0
  61. package/dist/wire/limits.d.ts.map +1 -0
  62. package/dist/wire/limits.js +11 -0
  63. package/dist/wire/limits.js.map +1 -0
  64. package/dist/wire/meta.d.ts.map +1 -1
  65. package/dist/wire/meta.js +1 -1
  66. package/dist/wire/meta.js.map +1 -1
  67. package/dist/wire/ops.d.ts.map +1 -1
  68. package/dist/wire/ops.js +1 -2
  69. package/dist/wire/ops.js.map +1 -1
  70. package/dist/wire/version.d.ts +7 -0
  71. package/dist/wire/version.d.ts.map +1 -0
  72. package/dist/wire/version.js +8 -0
  73. package/dist/wire/version.js.map +1 -0
  74. package/package.json +1 -1
  75. package/src/client/connection.ts +159 -52
  76. package/src/client/core.ts +6 -0
  77. package/src/client/session.ts +104 -29
  78. package/src/host/adapter.ts +10 -2
  79. package/src/host/host.ts +54 -4
  80. package/src/host/http.ts +132 -66
  81. package/src/host/socket.ts +410 -81
  82. package/src/host/work.ts +50 -0
  83. package/src/host-internal.ts +32 -4
  84. package/src/host.ts +18 -2
  85. package/src/statewire.ts +16 -2
  86. package/src/transports/sse.ts +60 -68
  87. package/src/transports/testing.ts +2 -2
  88. package/src/transports/ws.ts +29 -48
  89. package/src/types.ts +1 -0
  90. package/src/wire/documents.ts +109 -0
  91. package/src/wire/limits.ts +6 -0
  92. package/src/wire/meta.ts +6 -1
  93. package/src/wire/ops.ts +1 -2
  94. package/src/wire/version.ts +3 -0
@@ -0,0 +1,50 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { StatewireHost } from "../host";
3
+
4
+ /** Track background tasks and scheduled callbacks in the host's idle status. */
5
+ export const useStatewireWork = (): StatewireHost.Work => {
6
+ const [, notify] = useState(0);
7
+ const [cell] = useState(() => ({
8
+ tasks: new Set<Promise<unknown>>(),
9
+ scheduled: new Set<() => void>(),
10
+ active: true,
11
+ }));
12
+ useEffect(() => {
13
+ cell.active = true;
14
+ return () => {
15
+ cell.active = false;
16
+ cell.scheduled.clear();
17
+ };
18
+ }, [cell]);
19
+ const [work] = useState<StatewireHost.Work>(() => ({
20
+ get isIdle() {
21
+ return cell.tasks.size === 0 && cell.scheduled.size === 0;
22
+ },
23
+ track: (task) => {
24
+ if (!cell.active) throw new Error("statewire: work called after unmount");
25
+ cell.tasks.add(task);
26
+ notify((n) => n + 1);
27
+ const release = () => {
28
+ cell.tasks.delete(task);
29
+ if (cell.active) notify((n) => n + 1);
30
+ };
31
+ void task.then(release, release);
32
+ return task;
33
+ },
34
+ schedule: (callback) => {
35
+ if (!cell.active) throw new Error("statewire: work called after unmount");
36
+ if (cell.scheduled.has(callback)) return;
37
+ cell.scheduled.add(callback);
38
+ notify((n) => n + 1);
39
+ queueMicrotask(() => {
40
+ if (!cell.scheduled.delete(callback)) return;
41
+ try {
42
+ callback();
43
+ } finally {
44
+ if (cell.active) notify((n) => n + 1);
45
+ }
46
+ });
47
+ },
48
+ }));
49
+ return work;
50
+ };
@@ -1,7 +1,9 @@
1
1
  import type { StatewireHost } from "./host";
2
+ import type { StatewireCmd } from "./client/connection";
2
3
 
3
4
  export { StatewireSocketHost } from "./host/socket";
4
5
  export { clientIdFromUrl, textFrameOrClose } from "./host/adapter";
6
+ export { WS_SUBPROTOCOL } from "./wire/version";
5
7
  export namespace StatewireHostInternal {
6
8
  export type Socket = {
7
9
  send: (data: string) => void;
@@ -17,8 +19,10 @@ export namespace StatewireHostInternal {
17
19
  export type SocketConnectOptions = {
18
20
  /** The adapter restored this socket from an already-authorized session. */
19
21
  authorized?: boolean;
20
- /** Persist authorization before the first snapshot is released. */
21
- onAuthorized?: () => void;
22
+ /** The restored session's captured auth value; it must satisfy the client record's principal binding. */
23
+ auth?: unknown;
24
+ /** Persist authorization (and the captured auth value) before the first snapshot is released. */
25
+ onAuthorized?: (auth: unknown) => void;
22
26
  };
23
27
 
24
28
  export type SocketHost = {
@@ -34,17 +38,41 @@ export namespace StatewireHostInternal {
34
38
  dispose: () => void;
35
39
  };
36
40
 
37
- export type SocketHostOptions = {
41
+ export type ClientRecord = {
42
+ id: string;
43
+ lastSeq: number;
44
+ ctxSeq: number;
45
+ touched: number;
46
+ context: Record<string, unknown> | null;
47
+ results: readonly { seq: number; ts: number; cmd: StatewireCmd }[];
48
+ principal: unknown;
49
+ };
50
+
51
+ export type SocketHostPersistence = {
52
+ clientRecords?: readonly ClientRecord[];
53
+ onClientRecordsChange?: (records: readonly ClientRecord[]) => void;
54
+ };
55
+
56
+ export type SocketHostOptions<TAuth = unknown> = {
57
+ /** Gates every attach; the awaited return value is the attach's auth, exposed to command handlers as `this.caller.auth`. The first attach binds the client id's record to this value; a later attach whose value is not structurally equal is refused (HTTP 403, socket close 1008). */
38
58
  authorize?: (ctx: {
39
59
  headers: Record<string, string>;
40
- }) => void | Promise<void>;
60
+ }) => TAuth | Promise<TAuth>;
41
61
  /** Time allowed to complete WebSocket initialization and authorization. */
42
62
  wsInitTimeoutMs?: number;
43
63
  heartbeatIntervalMs?: number;
64
+ /** Maximum number of concurrently attached WebSocket and SSE streams. */
65
+ maxSubscribers?: number;
66
+ /** Maximum number of admitted commands still running across all clients. */
67
+ maxInflightCommands?: number;
68
+ /** Maximum number of queued SSE packets before a lagging stream is closed. */
69
+ streamQueueSize?: number;
44
70
  /** LRU cap on stored client records; live or in-flight records are exempt. */
45
71
  clientRecordLimit?: number;
46
72
  /** Hang every attach up with `fin: idle` at the idle flag's rising edge. */
47
73
  finOnIdle?: boolean;
74
+ /** Milliseconds to defer admission, or null to admit. */
75
+ pushback?: () => number | null;
48
76
  };
49
77
 
50
78
  export type Fin = StatewireHost.Fin;
package/src/host.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Statewire } from "./types";
2
2
 
3
3
  export { StatewireReject } from "./host/engine";
4
+ export { useStatewireWork } from "./host/work";
4
5
  export { append } from "./host/draft";
5
6
  export {
6
7
  getStateHost,
@@ -9,6 +10,12 @@ export {
9
10
  } from "./host/host";
10
11
 
11
12
  export namespace StatewireHost {
13
+ export type Work = {
14
+ readonly isIdle: boolean;
15
+ track: <T>(task: Promise<T>) => Promise<T>;
16
+ schedule: (callback: () => void) => void;
17
+ };
18
+
12
19
  export type StateCtl<TState = unknown> = {
13
20
  /** Apply server-vocabulary ops (root replace = whole-state swap). */
14
21
  apply: (ops: readonly Statewire.Op[]) => void;
@@ -30,18 +37,22 @@ export namespace StatewireHost {
30
37
  */
31
38
  export type Ctx = {
32
39
  readonly caller: Caller;
40
+ readonly signal: AbortSignal;
33
41
  readonly applied: () => void;
34
42
  };
35
43
 
36
44
  /**
37
45
  * The submitting client (`this.caller` in a command handler). Holdable
38
46
  * across a long run: `clientId` is the client's `Statewire-Client-Id`
39
- * (in-process dispatch has none — reading it throws), `context` reads the
47
+ * (in-process dispatch has none — reading it throws), `auth` is the value
48
+ * `authorize` returned for the attach that admitted the command (undefined
49
+ * for a void `authorize` and for in-process dispatch), `context` reads the
40
50
  * client's current ambient context live (null until the client sends one),
41
51
  * `isConnected` reports whether the client has an active stream attachment.
42
52
  */
43
53
  export type Caller = {
44
54
  readonly clientId: string;
55
+ readonly auth: unknown;
45
56
  readonly context: Record<string, unknown> | null;
46
57
  readonly isConnected: boolean;
47
58
  };
@@ -54,13 +65,18 @@ export namespace StatewireHost {
54
65
 
55
66
  export type Fin = {
56
67
  reason:
57
- | "evicted"
68
+ | "reconnect"
58
69
  | "error"
70
+ | "protocol-error"
71
+ | "forbidden"
59
72
  | "unauthorized"
60
73
  | "gone"
61
74
  | "idle"
62
75
  | (string & {});
63
76
  message?: string;
77
+ code?: string;
78
+ status?: number;
79
+ retry?: number;
64
80
  payload?: unknown;
65
81
  meta?: unknown;
66
82
  };
package/src/statewire.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { StatewireHost } from "./host";
1
2
  import { useMemo, useRef, useState, useSyncExternalStore } from "react";
2
3
  import {
3
4
  createTapRoot,
@@ -182,6 +183,7 @@ export namespace StatewireClient {
182
183
  ctx: { state: NonNullable<TState> },
183
184
  ) => void;
184
185
  onConnectionChange?: (connection: Connection) => void;
186
+ onError?: (error: ConnectionError) => void;
185
187
  /** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */
186
188
  failureCovered?: (
187
189
  state: NonNullable<TState>,
@@ -215,7 +217,13 @@ export namespace StatewireClient {
215
217
  export type ConnectionState =
216
218
  | {
217
219
  status: "disconnected";
218
- reason: "deferred" | "idle" | "gone";
220
+ reason:
221
+ | "deferred"
222
+ | "idle"
223
+ | "gone"
224
+ | "forbidden"
225
+ | "protocol-error"
226
+ | "unauthorized";
219
227
  message?: string;
220
228
  payload?: unknown;
221
229
  }
@@ -223,13 +231,15 @@ export namespace StatewireClient {
223
231
  status: "connecting";
224
232
  /** 0 = the episode's first dial, >0 = retry. */
225
233
  attempt: number;
226
- cause?: "dropped" | "evicted" | "error" | "unauthorized";
234
+ cause?: "dropped" | "reconnect" | "error" | "unauthorized";
235
+ code?: string;
227
236
  nextRetryAt?: number;
228
237
  lastError?: string;
229
238
  message?: string;
230
239
  }
231
240
  | { status: "connected" };
232
241
  export type Connection = ConnectionState & {
242
+ error?: ConnectionError;
233
243
  reconnect: () => void;
234
244
  /** True while retrying (`connecting` with `attempt > 0`) or parked `gone`. */
235
245
  degraded: boolean;
@@ -357,6 +367,8 @@ export namespace StatewireClient {
357
367
  };
358
368
 
359
369
  /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
370
+ export type ConnectionError = Error & { fin: StatewireHost.Fin };
371
+
360
372
  export type HeadersOption =
361
373
  | HeadersInit
362
374
  | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);
@@ -370,6 +382,7 @@ export namespace StatewireClient {
370
382
  fetch?: typeof globalThis.fetch;
371
383
  onSessionLost?: OnSessionLost;
372
384
  onChange?: (connection: Connection) => void;
385
+ onError?: (error: ConnectionError) => void;
373
386
  /** The broadcast idle flag's rising edge: the instance went quiet. */
374
387
  onDone?: () => void;
375
388
  };
@@ -387,6 +400,7 @@ export namespace StatewireClient {
387
400
  webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
388
401
  onSessionLost?: OnSessionLost;
389
402
  onChange?: (connection: Connection) => void;
403
+ onError?: (error: ConnectionError) => void;
390
404
  /** The broadcast idle flag's rising edge: the instance went quiet. */
391
405
  onDone?: () => void;
392
406
  };
@@ -1,12 +1,14 @@
1
- import { useRef, useState } from "react";
2
1
  import { flushTapSync, resource } from "@assistant-ui/tap";
3
2
  import type { ResourceElement } from "@assistant-ui/tap";
4
3
  import { createSseParser } from "./sse-parser";
5
4
  import { timing } from "../client/timing";
6
5
  import {
6
+ settleFin,
7
7
  classify,
8
8
  handleEnvelope,
9
- useStatewireConn,
9
+ resolveHeaders,
10
+ unsupportedVersion,
11
+ useLink,
10
12
  validateRefusalSyn,
11
13
  } from "../client/connection";
12
14
  import type {
@@ -20,6 +22,7 @@ import type { Statewire } from "../types";
20
22
  import type { StatewireClient } from "../statewire";
21
23
  import type { StatewireHost } from "../host";
22
24
  import { useStatewireRemote } from "./remote";
25
+ import { PROTOCOL_VERSION, VERSION_HEADER } from "../wire/version";
23
26
 
24
27
  /** A client frame's lanes: stamped statements, strictly consecutive per lane. */
25
28
  export type StatewireFrame = {
@@ -61,18 +64,6 @@ const LEASE_HEADER = "Statewire-Lease";
61
64
  const baseUrl = (options: StatewireClient.SSEOptions) =>
62
65
  options.url.replace(/\/$/, "");
63
66
 
64
- export type AuthState = { stale: boolean };
65
-
66
- const resolveHeaders = async (
67
- options: StatewireClient.SSEOptions,
68
- ctx: { staleAuth: boolean },
69
- ): Promise<Headers> => {
70
- const { headers } = options;
71
- return new Headers(
72
- typeof headers === "function" ? await headers(ctx) : headers,
73
- );
74
- };
75
-
76
67
  const withExtra = (headers: Headers, extra: Record<string, string>) => {
77
68
  const merged = new Headers(headers);
78
69
  for (const [name, value] of Object.entries(extra)) merged.set(name, value);
@@ -126,18 +117,13 @@ const consume = async (
126
117
  const attemptStream = async (
127
118
  c: Conn,
128
119
  options: StatewireClient.SSEOptions,
129
- auth: AuthState,
130
120
  abort: AbortController,
131
121
  ): Promise<AttemptOutcome> => {
132
- const stale = auth.stale;
133
- auth.stale = false;
134
122
  c.lease = null;
135
- const userHeaders = await resolveHeaders(options, { staleAuth: stale });
123
+ const headers = await resolveHeaders(options.headers, c.authStale);
124
+ c.authStale = false;
136
125
  const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
137
- const frame = {
138
- headers: Object.fromEntries(userHeaders),
139
- ...(lanes ?? {}),
140
- };
126
+ const body = lanes === null ? undefined : JSON.stringify(lanes);
141
127
  const heartbeat = new AbortController();
142
128
  const headerTimer = setTimeout(
143
129
  () => heartbeat.abort(),
@@ -149,13 +135,13 @@ const attemptStream = async (
149
135
  options,
150
136
  `${baseUrl(options)}/stream`,
151
137
  {
152
- method: "POST",
153
- headers: withExtra(userHeaders, {
138
+ method: body === undefined ? "GET" : "POST",
139
+ headers: withExtra(headers, {
154
140
  "content-type": "application/json",
155
141
  "Statewire-Client-Id": c.ctx.clientId,
156
142
  accept: "text/event-stream",
157
143
  }),
158
- body: JSON.stringify(frame),
144
+ ...(body === undefined ? {} : { body }),
159
145
  cache: "no-store",
160
146
  signal: AbortSignal.any([abort.signal, heartbeat.signal]),
161
147
  },
@@ -164,12 +150,14 @@ const attemptStream = async (
164
150
  } finally {
165
151
  clearTimeout(headerTimer);
166
152
  }
167
- if (res.status === 401) {
168
- c.episode = { cause: "unauthorized" };
169
- auth.stale = true;
170
- throw new Error("unauthorized");
153
+ if (!res.ok) return flushTapSync(() => settleFin(c, httpFin(res)));
154
+ if (!res.body)
155
+ throw new Error("statewire: protocol error: stream response has no body");
156
+ const version = res.headers.get(VERSION_HEADER)?.trim();
157
+ if (version !== undefined && version !== PROTOCOL_VERSION) {
158
+ res.body.cancel().catch(() => {});
159
+ return unsupportedVersion(c, version);
171
160
  }
172
- if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
173
161
  c.lease = res.headers.get(LEASE_HEADER);
174
162
  const reader = res.body.getReader();
175
163
  try {
@@ -185,16 +173,14 @@ const postFrame = async (
185
173
  lease: string,
186
174
  frame: StatewireFrame,
187
175
  timeout: AbortSignal,
188
- stale: boolean,
189
176
  ): Promise<Response> =>
190
177
  timedFetch(
191
178
  options,
192
179
  `${baseUrl(options)}/frames`,
193
180
  {
194
181
  method: "POST",
195
- headers: withExtra(await resolveHeaders(options, { staleAuth: stale }), {
182
+ headers: withExtra(new Headers(), {
196
183
  "content-type": "application/json",
197
- "Statewire-Client-Id": c.ctx.clientId,
198
184
  [LEASE_HEADER]: lease,
199
185
  }),
200
186
  body: JSON.stringify(frame),
@@ -214,6 +200,23 @@ const retryAfterMs = (res: Response): number | null => {
214
200
  return Number.isSafeInteger(retry) ? retry : null;
215
201
  };
216
202
 
203
+ const httpFin = (res: Response): StatewireHost.Fin => ({
204
+ reason:
205
+ res.status === 401
206
+ ? "unauthorized"
207
+ : res.status === 403
208
+ ? "forbidden"
209
+ : res.status === 429 || res.status === 503
210
+ ? "reconnect"
211
+ : res.status >= 400 && res.status < 500
212
+ ? "protocol-error"
213
+ : "error",
214
+ status: res.status,
215
+ message: `HTTP ${res.status}`,
216
+ ...(res.status === 429 || res.status === 503 ? { code: "overloaded" } : {}),
217
+ ...(retryAfterMs(res) !== null ? { retry: retryAfterMs(res)! } : {}),
218
+ });
219
+
217
220
  const refusalSyn = async (res: Response): Promise<StatewireSyn> => {
218
221
  const body = (await res.json().catch(() => null)) as {
219
222
  syn?: StatewireSyn;
@@ -228,16 +231,12 @@ const refusalSyn = async (res: Response): Promise<StatewireSyn> => {
228
231
  const sendFrame = async (
229
232
  c: Conn,
230
233
  options: StatewireClient.SSEOptions,
231
- auth: AuthState,
232
234
  frame: StatewireFrame,
233
235
  ): Promise<StatewireSubmitResult> => {
234
236
  const lease = c.lease;
235
237
  if (lease === null) return { type: "no-lease" };
236
238
  const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);
237
- let res = await postFrame(c, options, lease, frame, timeout, false);
238
- if (res.status === 401 && typeof options.headers === "function") {
239
- res = await postFrame(c, options, lease, frame, timeout, true);
240
- }
239
+ const res = await postFrame(c, options, lease, frame, timeout);
241
240
  if (res.ok) return { type: "ok" };
242
241
  if (res.status === 429 || res.status === 409) {
243
242
  // The syn body retry wins; Retry-After covers 429s minted below the host.
@@ -251,44 +250,37 @@ const sendFrame = async (
251
250
  return { type: "refusal", syn };
252
251
  }
253
252
  if (res.status === 423) return { type: "stale-lease" };
254
- if (res.status === 401) {
255
- // Fresh credentials did not clear the 401: signal auth-failed on the
256
- // connection and let the send retry as a transport fault.
257
- auth.stale = true;
258
- c.episode = { cause: "unauthorized" };
259
- c.connection.reconnect();
260
- throw new Error("unauthorized");
261
- }
253
+ flushTapSync(() => settleFin(c, httpFin(res)));
254
+ c.abort?.abort();
262
255
  throw new Error(`HTTP ${res.status}`);
263
256
  };
264
257
 
265
258
  const useSseLink = (
266
259
  options: StatewireClient.SSEOptions,
267
260
  ctx: StatewireRemoteProps,
268
- ): RemoteLink => {
269
- const latest = useRef(options);
270
- latest.current = options;
271
- const [auth] = useState((): AuthState => ({ stale: false }));
272
- const c = useStatewireConn({
273
- url: options.url,
274
- isNew: options.isNew,
275
- onChange: options.onChange,
261
+ ): RemoteLink =>
262
+ useLink({
263
+ ...options,
276
264
  ctx,
277
- attemptOnce: (c, abort) => attemptStream(c, options, auth, abort),
265
+ attemptOnce: (c, abort) => attemptStream(c, options, abort),
266
+ send: async (c, frame) => {
267
+ try {
268
+ return await sendFrame(c, options, frame);
269
+ } catch (error) {
270
+ if (
271
+ error instanceof SyntaxError ||
272
+ (error instanceof Error &&
273
+ error.message.startsWith("statewire: protocol error:"))
274
+ ) {
275
+ flushTapSync(() =>
276
+ settleFin(c, { reason: "protocol-error", message: error.message }),
277
+ );
278
+ c.abort?.abort();
279
+ }
280
+ throw error;
281
+ }
282
+ },
278
283
  });
279
- const [link] = useState(
280
- (): RemoteLink => ({
281
- get connection() {
282
- return c.connection;
283
- },
284
- get parked() {
285
- return c.parked;
286
- },
287
- send: (frame) => sendFrame(c, latest.current, auth, frame),
288
- }),
289
- );
290
- return link;
291
- };
292
284
 
293
285
  const useStatewireSSE = (
294
286
  options: StatewireClient.SSEOptions,
@@ -79,7 +79,7 @@ export const createMockTransport = <TState>(
79
79
  clientId = id;
80
80
  if (!clients.has(id)) clients.set(id, { seq: 0, ctxSeq: 0 });
81
81
  const rec = clients.get(id)!;
82
- const frame = JSON.parse(String(init?.body)) as StatewireFrame;
82
+ const frame = JSON.parse(String(init?.body ?? "{}")) as StatewireFrame;
83
83
  admit(id, frame);
84
84
  return openSSEStream(
85
85
  streams,
@@ -97,7 +97,7 @@ export const createMockTransport = <TState>(
97
97
  );
98
98
  }
99
99
  if (url.endsWith("/frames")) {
100
- admit(id, JSON.parse(String(init?.body)) as StatewireFrame);
100
+ admit(clientId, JSON.parse(String(init?.body ?? "{}")) as StatewireFrame);
101
101
  return Response.json({});
102
102
  }
103
103
  return new Response(null, { status: 404 });
@@ -2,7 +2,12 @@ import { useState } from "react";
2
2
  import { flushTapSync, resource } from "@assistant-ui/tap";
3
3
  import type { ResourceElement } from "@assistant-ui/tap";
4
4
  import { timing } from "../client/timing";
5
- import { handleEnvelope, useStatewireConn } from "../client/connection";
5
+ import {
6
+ attachFrame,
7
+ handleEnvelope,
8
+ unsupportedVersion,
9
+ useLink,
10
+ } from "../client/connection";
6
11
  import type {
7
12
  AttemptOutcome,
8
13
  Conn,
@@ -12,6 +17,7 @@ import type { Statewire } from "../types";
12
17
  import type { StatewireClient } from "../statewire";
13
18
  import { useStatewireRemote } from "./remote";
14
19
  import { CLIENT_QUERY_PARAM } from "../host/adapter";
20
+ import { WS_SUBPROTOCOL } from "../wire/version";
15
21
  import type {
16
22
  RemoteLink,
17
23
  StatewireFrame,
@@ -21,12 +27,11 @@ import type {
21
27
 
22
28
  type WsChannel = {
23
29
  socket: WebSocket | null;
24
- authStale: boolean;
25
30
  };
26
31
 
27
32
  const OPEN = 1;
28
33
 
29
- export const WS_SUBPROTOCOL = "statewire.v1";
34
+ export { WS_SUBPROTOCOL };
30
35
 
31
36
  const socketUrl = async (
32
37
  options: StatewireClient.WSOptions,
@@ -37,30 +42,15 @@ const socketUrl = async (
37
42
  typeof query === "function" ? await query() : query,
38
43
  );
39
44
  params.set(CLIENT_QUERY_PARAM, clientId);
40
- const base = url.replace(/\/$/, "").replace(/^http/, "ws");
41
- return `${base}/ws?${params.toString()}`;
42
- };
43
-
44
- const firstFrame = async (
45
- options: StatewireClient.WSOptions,
46
- stale: boolean,
47
- lanes: StatewireFrame | null,
48
- ): Promise<string> => {
49
- const { headers } = options;
50
- const resolved = new Headers(
51
- typeof headers === "function"
52
- ? await headers({ staleAuth: stale })
53
- : headers,
54
- );
55
- return JSON.stringify({
56
- headers: Object.fromEntries(resolved),
57
- ...(lanes ?? {}),
58
- });
45
+ const resolved = new URL(url, globalThis.location?.href);
46
+ resolved.protocol = resolved.protocol.replace(/^http/, "ws");
47
+ resolved.pathname = `${resolved.pathname.replace(/\/$/, "")}/ws`;
48
+ resolved.search = params.toString();
49
+ return resolved.href;
59
50
  };
60
51
 
61
52
  const runSocket = (
62
53
  c: Conn,
63
- w: WsChannel,
64
54
  ws: WebSocket,
65
55
  frame: string,
66
56
  abort: AbortController,
@@ -80,7 +70,15 @@ const runSocket = (
80
70
  arm();
81
71
  abort.signal.addEventListener("abort", () => ws.close());
82
72
  ws.onerror = () => {};
83
- ws.onopen = () => ws.send(frame);
73
+ ws.onopen = () => {
74
+ const negotiated = ws.protocol.trim();
75
+ if (negotiated !== "" && negotiated !== WS_SUBPROTOCOL) {
76
+ outcome = unsupportedVersion(c, negotiated);
77
+ ws.close();
78
+ return;
79
+ }
80
+ ws.send(frame);
81
+ };
84
82
  const stale = () => Boolean(outcome) || closed || abort.signal.aborted;
85
83
  const fail = (error: unknown) => {
86
84
  clearTimeout(timer);
@@ -94,7 +92,7 @@ const runSocket = (
94
92
  try {
95
93
  env = JSON.parse(String(event.data)) as StatewireEnvelope;
96
94
  } catch {
97
- fail(new Error("protocol"));
95
+ fail(new SyntaxError("invalid JSON packet"));
98
96
  return;
99
97
  }
100
98
  // {} heartbeats live below the packet layer: invisible to handlers.
@@ -108,7 +106,6 @@ const runSocket = (
108
106
  try {
109
107
  const done = flushTapSync(() => handleEnvelope(c, env));
110
108
  if (done) {
111
- if (env.fin?.reason === "unauthorized") w.authStale = true;
112
109
  outcome = done;
113
110
  ws.close();
114
111
  }
@@ -133,10 +130,7 @@ const attemptSocket = async (
133
130
  let ws: WebSocket | null = null;
134
131
  try {
135
132
  const url = await socketUrl(options, c.ctx.clientId);
136
- const stale = w.authStale;
137
- w.authStale = false;
138
- const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
139
- const frame = await firstFrame(options, stale, lanes);
133
+ const { body } = await attachFrame(c, options.headers);
140
134
  abort.signal.throwIfAborted();
141
135
  const { protocols, webSocket } = options;
142
136
  const WS = webSocket ?? globalThis.WebSocket;
@@ -149,7 +143,7 @@ const attemptSocket = async (
149
143
  : protocols;
150
144
  ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);
151
145
  w.socket = ws;
152
- return await runSocket(c, w, ws, frame, abort);
146
+ return await runSocket(c, ws, body, abort);
153
147
  } finally {
154
148
  if (w.socket === ws) w.socket = null;
155
149
  }
@@ -175,26 +169,13 @@ const useWsLink = (
175
169
  options: StatewireClient.WSOptions,
176
170
  ctx: StatewireRemoteProps,
177
171
  ): RemoteLink => {
178
- const [w] = useState((): WsChannel => ({ socket: null, authStale: false }));
179
- const c = useStatewireConn({
180
- url: options.url,
181
- isNew: options.isNew,
182
- onChange: options.onChange,
172
+ const [w] = useState((): WsChannel => ({ socket: null }));
173
+ return useLink({
174
+ ...options,
183
175
  ctx,
184
176
  attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),
177
+ send: (_, frame) => sendFrame(w, frame),
185
178
  });
186
- const [link] = useState(
187
- (): RemoteLink => ({
188
- get connection() {
189
- return c.connection;
190
- },
191
- get parked() {
192
- return c.parked;
193
- },
194
- send: (frame) => sendFrame(w, frame),
195
- }),
196
- );
197
- return link;
198
179
  };
199
180
 
200
181
  const useStatewireWS = (
package/src/types.ts CHANGED
@@ -57,6 +57,7 @@ export interface Statewire<
57
57
  durableSeq?: number;
58
58
  /** Negation of the server's broadcast idle level; absent means always busy. */
59
59
  isBusy?: boolean;
60
+ work?: { readonly isIdle: boolean };
60
61
  /** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */
61
62
  setContext?: (value: Record<string, unknown>) => void;
62
63
  /** The retained failed-send lane; present on transports with a durable session. */