statewire 0.14.0 → 0.16.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 (100) 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 +98 -19
  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/index.d.ts +3 -3
  41. package/dist/index.js +3 -3
  42. package/dist/statewire.d.ts +13 -4
  43. package/dist/statewire.d.ts.map +1 -1
  44. package/dist/statewire.js.map +1 -1
  45. package/dist/transports/sse.d.ts +2 -5
  46. package/dist/transports/sse.d.ts.map +1 -1
  47. package/dist/transports/sse.js +50 -59
  48. package/dist/transports/sse.js.map +1 -1
  49. package/dist/transports/testing.js +4 -4
  50. package/dist/transports/testing.js.map +1 -1
  51. package/dist/transports/ws.d.ts +3 -3
  52. package/dist/transports/ws.d.ts.map +1 -1
  53. package/dist/transports/ws.js +57 -44
  54. package/dist/transports/ws.js.map +1 -1
  55. package/dist/types.d.ts +3 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/types.js.map +1 -1
  58. package/dist/util/randomId.js +1 -1
  59. package/dist/util/randomId.js.map +1 -1
  60. package/dist/wire/documents.d.ts +20 -0
  61. package/dist/wire/documents.d.ts.map +1 -0
  62. package/dist/wire/documents.js +74 -0
  63. package/dist/wire/documents.js.map +1 -0
  64. package/dist/wire/limits.d.ts +10 -0
  65. package/dist/wire/limits.d.ts.map +1 -0
  66. package/dist/wire/limits.js +11 -0
  67. package/dist/wire/limits.js.map +1 -0
  68. package/dist/wire/meta.d.ts.map +1 -1
  69. package/dist/wire/meta.js +1 -1
  70. package/dist/wire/meta.js.map +1 -1
  71. package/dist/wire/ops.d.ts.map +1 -1
  72. package/dist/wire/ops.js +1 -2
  73. package/dist/wire/ops.js.map +1 -1
  74. package/dist/wire/version.d.ts +7 -0
  75. package/dist/wire/version.d.ts.map +1 -0
  76. package/dist/wire/version.js +8 -0
  77. package/dist/wire/version.js.map +1 -0
  78. package/package.json +1 -1
  79. package/src/client/connection.ts +159 -52
  80. package/src/client/core.ts +6 -0
  81. package/src/client/session.ts +105 -32
  82. package/src/host/adapter.ts +10 -2
  83. package/src/host/host.ts +54 -4
  84. package/src/host/http.ts +132 -66
  85. package/src/host/socket.ts +410 -81
  86. package/src/host/work.ts +50 -0
  87. package/src/host-internal.ts +32 -4
  88. package/src/host.ts +18 -2
  89. package/src/index.ts +2 -2
  90. package/src/statewire.ts +18 -4
  91. package/src/transports/sse.ts +70 -78
  92. package/src/transports/testing.ts +4 -4
  93. package/src/transports/ws.ts +36 -55
  94. package/src/types.ts +1 -0
  95. package/src/util/randomId.ts +3 -3
  96. package/src/wire/documents.ts +109 -0
  97. package/src/wire/limits.ts +6 -0
  98. package/src/wire/meta.ts +6 -1
  99. package/src/wire/ops.ts +1 -2
  100. 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/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { Statewire } from "./types";
2
2
  export { StatewireClient, useStatewire, StatewireResource } from "./statewire";
3
- export { StatewireSSE } from "./transports/sse";
4
- export { StatewireWS } from "./transports/ws";
3
+ export { StatewireHttp } from "./transports/sse";
4
+ export { StatewireWebsocket } from "./transports/ws";
5
5
  export { StatewireSendError } from "./types";
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,11 +367,13 @@ 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>);
363
375
 
364
- export type SSEOptions = {
376
+ export type HttpOptions = {
365
377
  sessionStore?: SessionStorage | undefined;
366
378
  url: string;
367
379
  /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
@@ -370,11 +382,12 @@ 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
  };
376
389
 
377
- export type WSOptions = {
390
+ export type WebsocketOptions = {
378
391
  sessionStore?: SessionStorage | undefined;
379
392
  url: string;
380
393
  /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
@@ -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 = {
@@ -58,21 +61,9 @@ export type RemoteLink = {
58
61
 
59
62
  const LEASE_HEADER = "Statewire-Lease";
60
63
 
61
- const baseUrl = (options: StatewireClient.SSEOptions) =>
64
+ const baseUrl = (options: StatewireClient.HttpOptions) =>
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);
@@ -80,7 +71,7 @@ const withExtra = (headers: Headers, extra: Record<string, string>) => {
80
71
  };
81
72
 
82
73
  const timedFetch = async (
83
- options: StatewireClient.SSEOptions,
74
+ options: StatewireClient.HttpOptions,
84
75
  input: string,
85
76
  init: RequestInit & { signal: AbortSignal },
86
77
  timeout: AbortSignal,
@@ -125,19 +116,14 @@ const consume = async (
125
116
 
126
117
  const attemptStream = async (
127
118
  c: Conn,
128
- options: StatewireClient.SSEOptions,
129
- auth: AuthState,
119
+ options: StatewireClient.HttpOptions,
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 {
@@ -181,20 +169,18 @@ const attemptStream = async (
181
169
 
182
170
  const postFrame = async (
183
171
  c: Conn,
184
- options: StatewireClient.SSEOptions,
172
+ options: StatewireClient.HttpOptions,
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;
@@ -227,17 +230,13 @@ const refusalSyn = async (res: Response): Promise<StatewireSyn> => {
227
230
 
228
231
  const sendFrame = async (
229
232
  c: Conn,
230
- options: StatewireClient.SSEOptions,
231
- auth: AuthState,
233
+ options: StatewireClient.HttpOptions,
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,52 +250,45 @@ 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
- options: StatewireClient.SSEOptions,
259
+ options: StatewireClient.HttpOptions,
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
- const useStatewireSSE = (
294
- options: StatewireClient.SSEOptions,
285
+ const useStatewireHttp = (
286
+ options: StatewireClient.HttpOptions,
295
287
  ): Statewire<any, any> => useStatewireRemote(options, useSseLink);
296
288
 
297
- export const StatewireSSE = resource(useStatewireSSE) as <
289
+ export const StatewireHttp = resource(useStatewireHttp) as <
298
290
  TState = any,
299
291
  TCommands extends Statewire.Commands = Statewire.Commands,
300
292
  >(
301
- options: StatewireClient.SSEOptions,
293
+ options: StatewireClient.HttpOptions,
302
294
  ) => ResourceElement<Statewire<TState | undefined, TCommands>>;
@@ -1,6 +1,6 @@
1
1
  import type { ResourceElement } from "@assistant-ui/tap";
2
2
  import type { StatewireEnvelope } from "../client/connection";
3
- import { StatewireSSE } from "./sse";
3
+ import { StatewireHttp } from "./sse";
4
4
  import type { StatewireFrame } from "./sse";
5
5
  import type { Statewire } from "../types";
6
6
  import { openSSEStream, sseFrame } from "./fake-sse";
@@ -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,14 +97,14 @@ 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 });
104
104
  }) as typeof fetch;
105
105
 
106
106
  return {
107
- transport: StatewireSSE({
107
+ transport: StatewireHttp({
108
108
  url: `http://mock-${++mockCount}/api`,
109
109
  fetch: fakeFetch,
110
110
  }),