statewire 0.13.1 → 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 -78
- package/dist/client/connection.d.ts +20 -6
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +120 -52
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +3 -2
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +10 -4
- 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 +97 -17
- 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 +2 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +108 -47
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +33 -8
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +388 -58
- 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 +42 -4
- 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 +21 -11
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +2 -2
- 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 +213 -67
- package/src/client/core.ts +10 -3
- package/src/client/session.ts +106 -30
- package/src/host/adapter.ts +10 -2
- package/src/host/host.ts +54 -4
- package/src/host/http.ts +168 -64
- package/src/host/socket.ts +603 -60
- package/src/host/work.ts +50 -0
- package/src/host-internal.ts +44 -3
- package/src/host.ts +18 -2
- package/src/statewire.ts +33 -10
- 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;
|
|
@@ -14,24 +16,63 @@ export namespace StatewireHostInternal {
|
|
|
14
16
|
close: () => void;
|
|
15
17
|
};
|
|
16
18
|
|
|
19
|
+
export type SocketConnectOptions = {
|
|
20
|
+
/** The adapter restored this socket from an already-authorized session. */
|
|
21
|
+
authorized?: boolean;
|
|
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;
|
|
26
|
+
};
|
|
27
|
+
|
|
17
28
|
export type SocketHost = {
|
|
18
29
|
/** Null means refused: the socket was told (fin and/or close) and is dead. */
|
|
19
|
-
connect: (
|
|
30
|
+
connect: (
|
|
31
|
+
clientId: string,
|
|
32
|
+
socket: Socket,
|
|
33
|
+
options?: SocketConnectOptions,
|
|
34
|
+
) => SocketConnection | null;
|
|
20
35
|
stream: (request: Request) => Promise<Response>;
|
|
21
36
|
frames: (request: Request) => Promise<Response>;
|
|
22
37
|
finish: (fin: StatewireHost.Fin) => void;
|
|
23
38
|
dispose: () => void;
|
|
24
39
|
};
|
|
25
40
|
|
|
26
|
-
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). */
|
|
27
58
|
authorize?: (ctx: {
|
|
28
59
|
headers: Record<string, string>;
|
|
29
|
-
}) =>
|
|
60
|
+
}) => TAuth | Promise<TAuth>;
|
|
61
|
+
/** Time allowed to complete WebSocket initialization and authorization. */
|
|
62
|
+
wsInitTimeoutMs?: number;
|
|
30
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;
|
|
31
70
|
/** LRU cap on stored client records; live or in-flight records are exempt. */
|
|
32
71
|
clientRecordLimit?: number;
|
|
33
72
|
/** Hang every attach up with `fin: idle` at the idle flag's rising edge. */
|
|
34
73
|
finOnIdle?: boolean;
|
|
74
|
+
/** Milliseconds to defer admission, or null to admit. */
|
|
75
|
+
pushback?: () => number | null;
|
|
35
76
|
};
|
|
36
77
|
|
|
37
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,
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
withKey,
|
|
10
11
|
} from "@assistant-ui/tap";
|
|
11
12
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
12
|
-
import {
|
|
13
|
+
import { CONNECTED_CONNECTION, useStatewireCore } from "./client/core";
|
|
13
14
|
import type { Statewire } from "./types";
|
|
14
15
|
import type { TERMINAL } from "./client/connection";
|
|
15
16
|
import { SessionStorageContext } from "./client/sessionStore";
|
|
@@ -64,7 +65,7 @@ export const useStatewire = <
|
|
|
64
65
|
core.getFailed,
|
|
65
66
|
core.getFailed,
|
|
66
67
|
);
|
|
67
|
-
const connection = value.connection ??
|
|
68
|
+
const connection = value.connection ?? CONNECTED_CONNECTION;
|
|
68
69
|
|
|
69
70
|
return useMemo(() => {
|
|
70
71
|
const failures = (): NonNullable<typeof value.failures> => {
|
|
@@ -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>,
|
|
@@ -211,20 +213,37 @@ export namespace StatewireClient {
|
|
|
211
213
|
discardFailed: (entry: FailedCommand) => void;
|
|
212
214
|
};
|
|
213
215
|
|
|
216
|
+
/** `cause`/`nextRetryAt`/`lastError` are present only while waiting out the backoff timer; a dial in flight carries `attempt` alone. */
|
|
214
217
|
export type ConnectionState =
|
|
215
|
-
| { status: "connecting" }
|
|
216
|
-
| { status: "live" }
|
|
217
|
-
| { status: "idle" }
|
|
218
218
|
| {
|
|
219
|
-
status: "
|
|
220
|
-
|
|
219
|
+
status: "disconnected";
|
|
220
|
+
reason:
|
|
221
|
+
| "deferred"
|
|
222
|
+
| "idle"
|
|
223
|
+
| "gone"
|
|
224
|
+
| "forbidden"
|
|
225
|
+
| "protocol-error"
|
|
226
|
+
| "unauthorized";
|
|
227
|
+
message?: string;
|
|
228
|
+
payload?: unknown;
|
|
229
|
+
}
|
|
230
|
+
| {
|
|
231
|
+
status: "connecting";
|
|
232
|
+
/** 0 = the episode's first dial, >0 = retry. */
|
|
221
233
|
attempt: number;
|
|
222
|
-
|
|
234
|
+
cause?: "dropped" | "reconnect" | "error" | "unauthorized";
|
|
235
|
+
code?: string;
|
|
236
|
+
nextRetryAt?: number;
|
|
223
237
|
lastError?: string;
|
|
224
238
|
message?: string;
|
|
225
239
|
}
|
|
226
|
-
| { status: "
|
|
227
|
-
export type Connection = ConnectionState & {
|
|
240
|
+
| { status: "connected" };
|
|
241
|
+
export type Connection = ConnectionState & {
|
|
242
|
+
error?: ConnectionError;
|
|
243
|
+
reconnect: () => void;
|
|
244
|
+
/** True while retrying (`connecting` with `attempt > 0`) or parked `gone`. */
|
|
245
|
+
degraded: boolean;
|
|
246
|
+
};
|
|
228
247
|
export type ConnectionStatus = Connection["status"];
|
|
229
248
|
|
|
230
249
|
/**
|
|
@@ -348,6 +367,8 @@ export namespace StatewireClient {
|
|
|
348
367
|
};
|
|
349
368
|
|
|
350
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
|
+
|
|
351
372
|
export type HeadersOption =
|
|
352
373
|
| HeadersInit
|
|
353
374
|
| ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);
|
|
@@ -361,6 +382,7 @@ export namespace StatewireClient {
|
|
|
361
382
|
fetch?: typeof globalThis.fetch;
|
|
362
383
|
onSessionLost?: OnSessionLost;
|
|
363
384
|
onChange?: (connection: Connection) => void;
|
|
385
|
+
onError?: (error: ConnectionError) => void;
|
|
364
386
|
/** The broadcast idle flag's rising edge: the instance went quiet. */
|
|
365
387
|
onDone?: () => void;
|
|
366
388
|
};
|
|
@@ -378,6 +400,7 @@ export namespace StatewireClient {
|
|
|
378
400
|
webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
|
|
379
401
|
onSessionLost?: OnSessionLost;
|
|
380
402
|
onChange?: (connection: Connection) => void;
|
|
403
|
+
onError?: (error: ConnectionError) => void;
|
|
381
404
|
/** The broadcast idle flag's rising edge: the instance went quiet. */
|
|
382
405
|
onDone?: () => void;
|
|
383
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 });
|