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.
- package/README.md +29 -80
- package/dist/client/connection.d.ts +19 -6
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +79 -40
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +1 -0
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +5 -0
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +2 -0
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +96 -16
- package/dist/client/session.js.map +1 -1
- package/dist/host/adapter.d.ts +1 -1
- package/dist/host/adapter.d.ts.map +1 -1
- package/dist/host/adapter.js +7 -1
- package/dist/host/adapter.js.map +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +45 -5
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +75 -47
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +23 -7
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +275 -74
- package/dist/host/socket.js.map +1 -1
- package/dist/host/work.d.ts +7 -0
- package/dist/host/work.d.ts.map +1 -0
- package/dist/host/work.js +53 -0
- package/dist/host/work.js.map +1 -0
- package/dist/host-internal.d.ts +35 -5
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host-internal.js +2 -1
- package/dist/host.d.ts +16 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/statewire.d.ts +11 -2
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts +1 -4
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +47 -56
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.js +2 -2
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +54 -41
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/documents.d.ts +20 -0
- package/dist/wire/documents.d.ts.map +1 -0
- package/dist/wire/documents.js +74 -0
- package/dist/wire/documents.js.map +1 -0
- package/dist/wire/limits.d.ts +10 -0
- package/dist/wire/limits.d.ts.map +1 -0
- package/dist/wire/limits.js +11 -0
- package/dist/wire/limits.js.map +1 -0
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +1 -1
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -2
- package/dist/wire/ops.js.map +1 -1
- package/dist/wire/version.d.ts +7 -0
- package/dist/wire/version.d.ts.map +1 -0
- package/dist/wire/version.js +8 -0
- package/dist/wire/version.js.map +1 -0
- package/package.json +1 -1
- package/src/client/connection.ts +159 -52
- package/src/client/core.ts +6 -0
- package/src/client/session.ts +104 -29
- package/src/host/adapter.ts +10 -2
- package/src/host/host.ts +54 -4
- package/src/host/http.ts +132 -66
- package/src/host/socket.ts +410 -81
- package/src/host/work.ts +50 -0
- package/src/host-internal.ts +32 -4
- package/src/host.ts +18 -2
- package/src/statewire.ts +16 -2
- package/src/transports/sse.ts +60 -68
- package/src/transports/testing.ts +2 -2
- package/src/transports/ws.ts +29 -48
- package/src/types.ts +1 -0
- package/src/wire/documents.ts +109 -0
- package/src/wire/limits.ts +6 -0
- package/src/wire/meta.ts +6 -1
- package/src/wire/ops.ts +1 -2
- package/src/wire/version.ts +3 -0
package/src/host/work.ts
ADDED
|
@@ -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
|
+
};
|
package/src/host-internal.ts
CHANGED
|
@@ -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
|
-
/**
|
|
21
|
-
|
|
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
|
|
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
|
-
}) =>
|
|
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), `
|
|
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
|
-
| "
|
|
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:
|
|
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" | "
|
|
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
|
};
|
package/src/transports/sse.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
123
|
+
const headers = await resolveHeaders(options.headers, c.authStale);
|
|
124
|
+
c.authStale = false;
|
|
136
125
|
const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
|
|
137
|
-
const
|
|
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(
|
|
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:
|
|
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.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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,
|
|
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(
|
|
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 });
|
package/src/transports/ws.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
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 = () =>
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
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. */
|