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/README.md
CHANGED
|
@@ -1,88 +1,39 @@
|
|
|
1
1
|
# statewire
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One JSON object replicated from a server to any number of clients, with typed commands back: the server streams snapshot-first ops, the client keeps optimistic updates rebased on top and settles every command it sent. Part of [harness-sdk](https://github.com/assistant-ui/harness-sdk), with `harness-sdk` and `pinned`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
import { useStatewire, StatewireSSE } from "statewire";
|
|
7
|
-
|
|
8
|
-
type Commands = {
|
|
9
|
-
increment(): void;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
const { state, connection, commands } = useStatewire<State, Commands>({
|
|
13
|
-
transport: StatewireSSE({ url: "/api/threads/demo" }),
|
|
14
|
-
optimistic: (draft, command) => {
|
|
15
|
-
if (command.method === "increment") draft.count += 1;
|
|
16
|
-
},
|
|
17
|
-
});
|
|
5
|
+
## Install
|
|
18
6
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
};
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add statewire react
|
|
22
9
|
```
|
|
23
10
|
|
|
24
|
-
##
|
|
25
|
-
|
|
26
|
-
- **`state`** — `undefined` until the first snapshot, then canonical server state with your pending commands' optimistic effects rebased on top. When the server's ack arrives, the optimistic overlay drops in the same tick: no flicker.
|
|
27
|
-
- **`connection`** — connection health as a tagged union: `connection.status` is `"connecting" | "live" | "reconnecting" | "gone"`, and each variant carries exactly the data that exists in that situation: `reconnecting` has `cause` (`"dropped"`, `"evicted"`, or `"error"`), `attempt`, `nextRetryAt`, `lastError?`, `message?`; `gone` carries the server's `message?` and `payload?`. `connection.reconnect()` re-attaches manually from `gone`, or "retry now" during backoff.
|
|
28
|
-
- **`commands`** — a typed proxy over the `Commands` method map: `commands.increment()` submits `{ method: "increment", params: [] }` and resolves with the handler's result only once the stream ack proves the effect is in canonical state. Rejects with `StatewireSendError`, whose `failure.fate` carries the verdict; the boundary is definitiveness:
|
|
29
|
-
- `"aborted"` — definitely not applied (never POSTed, or every attempt accounted for); safe to resend
|
|
30
|
-
- `"lost"` — a POST left the machine and never got a definitive answer; whether the server applied it is unknown — check the snapshot for your payload ids
|
|
31
|
-
- `"rejected"` — the server said no; not applied (`message`, optional `payload` from the backend's `StatewireReject`)
|
|
32
|
-
- `"unknown-command"` / `"invalid-params"` — the server does not know the method, or the args failed its schema
|
|
33
|
-
- `"crashed"` — acked, but the processing died before producing a result
|
|
34
|
-
- `"result-unavailable"` — acked, but the result can no longer be delivered
|
|
35
|
-
|
|
36
|
-
Every failure also carries `cause` — which channel settled it: `"server"` = the server's answer, `"stream-gone"` = terminal `finish(gone)`, `"session-lost"` = the server forgot the client id (eviction), `"unmount"` is client-side. An `"aborted"` failure carries `predecessorLost: true` when an earlier in-flight command of the same batch has uncertain fate. The taxonomy's single exported type is `StatewireClient.SendFailure`; the `fate` and `cause` unions are reachable as `SendFailure["fate"]` / `SendFailure["cause"]`.
|
|
11
|
+
## Use
|
|
37
12
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Every public type merges onto the class as a namespace — `StatewireClient.Connection`, `StatewireClient.ConnectionStatus`, `StatewireClient.Options`, `StatewireClient.SendFailure`, … — so one import covers the whole surface.
|
|
58
|
-
|
|
59
|
-
There is no session key: a transport `url` change reattaches in place and the attach snapshot arbitrates identity (an unknown client evicts — pending settles, the client id rotates). For a forced fresh session, key the resource — `withKey(threadId, StatewireResource(options))`; pending commands settle with reason `"unmount"`.
|
|
60
|
-
|
|
61
|
-
## Transport
|
|
62
|
-
|
|
63
|
-
```ts
|
|
64
|
-
StatewireSSE({
|
|
65
|
-
url, // endpoint root — the transport appends /stream and /commands
|
|
66
|
-
headers, // HeadersInit or (async) () => HeadersInit, re-evaluated per request
|
|
67
|
-
fetch, // custom fetch — the escape hatch for everything else
|
|
68
|
-
})
|
|
13
|
+
```tsx
|
|
14
|
+
import { StatewireSSE, useStatewire } from "statewire";
|
|
15
|
+
|
|
16
|
+
type CounterState = { count: number };
|
|
17
|
+
type CounterCommands = { increment(): void; reset(): void };
|
|
18
|
+
|
|
19
|
+
export const App = () => {
|
|
20
|
+
const { state, connection, commands } = useStatewire<
|
|
21
|
+
CounterState | undefined,
|
|
22
|
+
CounterCommands
|
|
23
|
+
>({ transport: StatewireSSE({ url: "/threads/main" }) });
|
|
24
|
+
return (
|
|
25
|
+
<main>
|
|
26
|
+
<p>{connection.status}</p>
|
|
27
|
+
<h1>{state?.count ?? 0}</h1>
|
|
28
|
+
<button onClick={() => void commands.increment()}>+1</button>
|
|
29
|
+
</main>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
69
32
|
```
|
|
70
33
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
## Backend
|
|
74
|
-
|
|
75
|
-
Any server speaking the Statewire convention works. The Python implementation lives in this repo (`python/statewire/statewire`):
|
|
76
|
-
|
|
77
|
-
```python
|
|
78
|
-
from statewire import Statewire, StatewireReject, command
|
|
79
|
-
|
|
80
|
-
class Counter(Statewire):
|
|
81
|
-
@command
|
|
82
|
-
async def increment(self):
|
|
83
|
-
if self.state["count"] >= 10:
|
|
84
|
-
raise StatewireReject("counter is full", payload={"max": 10})
|
|
85
|
-
self.state["count"] += 1
|
|
86
|
-
```
|
|
34
|
+
## Docs
|
|
87
35
|
|
|
88
|
-
|
|
36
|
+
- Get started: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/app/docs/get-started/statewire/page.mdx
|
|
37
|
+
- Guide: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/app/docs/guides/statewire/consume/page.mdx
|
|
38
|
+
- Reference: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/app/docs/reference/statewire/page.mdx
|
|
39
|
+
- Spec: https://github.com/assistant-ui/harness-sdk/blob/main/apps/docs/app/docs/spec/statewire-protocol/page.mdx
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { StatewireHost } from "../host.js";
|
|
2
|
-
import { StatewireRemoteProps } from "../transports/sse.js";
|
|
2
|
+
import { ConnectionState, RemoteLink, StatewireFrame, StatewireRemoteProps, StatewireSubmitResult } from "../transports/sse.js";
|
|
3
3
|
import { StatewireClient } from "../statewire.js";
|
|
4
4
|
//#region src/client/connection.d.ts
|
|
5
5
|
type Episode = {
|
|
6
|
-
cause: "dropped" | "
|
|
6
|
+
cause: "dropped" | "reconnect" | "error" | "unauthorized";
|
|
7
7
|
message?: string;
|
|
8
|
+
code?: string;
|
|
8
9
|
};
|
|
9
10
|
/** The terminal cmd verdict types; "applied" is the sole non-terminal type. */
|
|
10
11
|
declare const TERMINAL: readonly ["result", "rejected", "failed", "crashed", "expired"];
|
|
@@ -42,11 +43,13 @@ type AttemptOutcome = "stop" | "reattach" | "ended" | "idle";
|
|
|
42
43
|
type Conn = {
|
|
43
44
|
connection: StatewireClient.Connection;
|
|
44
45
|
parked: boolean;
|
|
45
|
-
hasSnapshot: boolean;
|
|
46
46
|
lease: string | null;
|
|
47
47
|
attempt: number;
|
|
48
48
|
authDenials: number;
|
|
49
|
+
authStale: boolean;
|
|
49
50
|
liveAt: number | null;
|
|
51
|
+
retryAt: number;
|
|
52
|
+
error: StatewireClient.ConnectionError | undefined;
|
|
50
53
|
episode: Episode | null;
|
|
51
54
|
awaitingHello: boolean;
|
|
52
55
|
held: StatewireCmd[];
|
|
@@ -54,21 +57,32 @@ type Conn = {
|
|
|
54
57
|
abort: AbortController | null;
|
|
55
58
|
lifetime: AbortController | null;
|
|
56
59
|
ctx: StatewireRemoteProps;
|
|
60
|
+
onError: (error: StatewireClient.ConnectionError) => void;
|
|
57
61
|
onChange: (connection: StatewireClient.Connection) => void;
|
|
58
62
|
attemptOnce: (abort: AbortController) => Promise<AttemptOutcome>;
|
|
59
63
|
notify: (connection: StatewireClient.Connection) => void;
|
|
60
64
|
reconnect: () => void;
|
|
61
65
|
};
|
|
66
|
+
declare const degradedOf: (state: ConnectionState) => boolean;
|
|
62
67
|
declare const validateRefusalSyn: (value: unknown) => StatewireSyn;
|
|
68
|
+
declare const settleFin: (c: Conn, fin: StatewireHost.Fin) => AttemptOutcome;
|
|
63
69
|
declare const handleEnvelope: (c: Conn, value: unknown) => AttemptOutcome | null;
|
|
64
|
-
declare const
|
|
70
|
+
declare const unsupportedVersion: (c: Conn, version: string) => AttemptOutcome;
|
|
71
|
+
declare const resolveHeaders: (headers: StatewireClient.HeadersOption | undefined, staleAuth: boolean) => Promise<Headers>;
|
|
72
|
+
declare const attachFrame: (c: Conn, headers: StatewireClient.HeadersOption | undefined) => Promise<{
|
|
73
|
+
headers: Headers;
|
|
74
|
+
body: string;
|
|
75
|
+
}>;
|
|
76
|
+
declare const useLink: (props: {
|
|
65
77
|
url: string;
|
|
66
78
|
isNew?: boolean | undefined;
|
|
67
79
|
onChange?: ((connection: StatewireClient.Connection) => void) | undefined;
|
|
80
|
+
onError?: ((error: StatewireClient.ConnectionError) => void) | undefined;
|
|
68
81
|
ctx: StatewireRemoteProps;
|
|
69
82
|
attemptOnce: (c: Conn, abort: AbortController) => Promise<AttemptOutcome>;
|
|
70
|
-
|
|
83
|
+
send: (c: Conn, frame: StatewireFrame) => Promise<StatewireSubmitResult>;
|
|
84
|
+
}) => RemoteLink;
|
|
71
85
|
declare const classify: (timeout: AbortSignal) => Error;
|
|
72
86
|
//#endregion
|
|
73
|
-
export { AttemptOutcome, Conn, Episode, Gate, StatewireCmd, StatewireEnvelope, StatewireSyn, TERMINAL, classify, handleEnvelope,
|
|
87
|
+
export { AttemptOutcome, Conn, Episode, Gate, StatewireCmd, StatewireEnvelope, StatewireSyn, TERMINAL, attachFrame, classify, degradedOf, handleEnvelope, resolveHeaders, settleFin, unsupportedVersion, useLink, validateRefusalSyn };
|
|
74
88
|
//# sourceMappingURL=connection.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connection.d.ts","names":[],"sources":["../../src/client/connection.ts"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"connection.d.ts","names":[],"sources":["../../src/client/connection.ts"],"mappings":";;;;KAcY;EACV;EACA;EACA;;;cAIW;KAQD;EACV;EACA,2BAA2B;EAC3B;EACA;EACA;EACA;;EAEA;;KAGU;EACV;EACA;EACA;EACA;EACA;EACA;;KAGU;EACV;EACA,MAAM;EACN;EACA;EACA,MAAM;EACN,MAAM,cAAc;;KAGV;EAAS;EAAkB;;KAoC3B;KAEA;EACV,YAAY,gBAAgB;EAC5B;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OAAO,gBAAgB;EACvB,SAAS;EACT;EACA,MAAM;EACN,MAAM;EACN,OAAO;EACP,UAAU;EACV,KAAK;EACL,UAAU,OAAO,gBAAgB;EACjC,WAAW,YAAY,gBAAgB;EACvC,cAAc,OAAO,oBAAoB,QAAQ;EACjD,SAAS,YAAY,gBAAgB;EACrC;;cAGW,aAAc,OAAO;cAuErB,qBAAsB,mBAAiB;cAyKvC,YAAa,GAAG,MAAM,KAAK,cAAc,QAAM;cA+D/C,iBACX,GAAG,MACH,mBACC;cAkIU,qBAAsB,GAAG,MAAM,oBAAkB;cAUjD,iBACX,SAAS,gBAAgB,2BACzB,uBACC,QAAQ;cAME,cACX,GAAG,MACH,SAAS,gBAAgB,8BAAyB;;;;cAcvC,UAAW;EACtB;EACA;EACA,aAAa,YAAY,gBAAgB;EACzC,YAAY,OAAO,gBAAgB;EACnC,KAAK;EACL,cAAc,GAAG,MAAM,OAAO,oBAAoB,QAAQ;EAC1D,OAAO,GAAG,MAAM,OAAO,mBAAmB,QAAQ;MAChD;cA+ES,WAAY,SAAS,gBAAW"}
|
|
@@ -41,14 +41,23 @@ const openGate = (g) => {
|
|
|
41
41
|
if (g.wake) g.wake();
|
|
42
42
|
else g.latched = true;
|
|
43
43
|
};
|
|
44
|
+
const degradedOf = (state) => state.status === "connecting" && state.attempt > 0 || state.status === "disconnected" && state.reason !== "idle" && state.reason !== "deferred";
|
|
45
|
+
const sameState = (a, b) => {
|
|
46
|
+
if (a.status !== b.status) return false;
|
|
47
|
+
if (a.status === "connecting" && b.status === "connecting") return a.attempt === b.attempt && a.cause === b.cause && a.code === b.code && a.nextRetryAt === b.nextRetryAt && a.lastError === b.lastError;
|
|
48
|
+
if (a.status === "disconnected" && b.status === "disconnected") return a.reason === b.reason;
|
|
49
|
+
return true;
|
|
50
|
+
};
|
|
44
51
|
const setStatus = (c, next) => {
|
|
45
|
-
|
|
52
|
+
if (sameState(next, c.connection) && c.connection.error === c.error) return;
|
|
46
53
|
c.connection = {
|
|
47
54
|
...next,
|
|
48
|
-
|
|
55
|
+
...c.error === void 0 ? {} : { error: c.error },
|
|
56
|
+
reconnect: c.reconnect,
|
|
57
|
+
degraded: degradedOf(next)
|
|
49
58
|
};
|
|
50
59
|
c.notify(c.connection);
|
|
51
|
-
|
|
60
|
+
c.onChange(c.connection);
|
|
52
61
|
};
|
|
53
62
|
const protocolError = (message) => {
|
|
54
63
|
throw new Error(`statewire: protocol error: ${message}`);
|
|
@@ -87,6 +96,9 @@ const validateFin = (value) => {
|
|
|
87
96
|
if (!isObject(value)) return protocolError("fin must be an object");
|
|
88
97
|
if (typeof value["reason"] !== "string") protocolError("fin.reason must be a string");
|
|
89
98
|
validateOptionalString(value["message"], "fin.message");
|
|
99
|
+
validateOptionalString(value["code"], "fin.code");
|
|
100
|
+
if (value["status"] !== void 0 && !isNonnegativeSafeInteger(value["status"])) protocolError("fin.status must be a nonnegative safe integer");
|
|
101
|
+
if (value["retry"] !== void 0 && !isNonnegativeSafeInteger(value["retry"])) protocolError("fin.retry must be a nonnegative safe integer");
|
|
90
102
|
return true;
|
|
91
103
|
};
|
|
92
104
|
const validateEnvelope = (value) => {
|
|
@@ -118,9 +130,7 @@ const cmdsOf = (env) => {
|
|
|
118
130
|
const snapshotOf = (env) => {
|
|
119
131
|
const ops = env.ops;
|
|
120
132
|
if (ops === void 0) return protocolError("hello missing snapshot");
|
|
121
|
-
if (ops.length
|
|
122
|
-
const op = ops[0];
|
|
123
|
-
if (op["op"] !== "replace" || !Array.isArray(op["path"]) || op["path"].length !== 0 || !Object.hasOwn(op, "value")) return protocolError("hello snapshot must be one root replace op");
|
|
133
|
+
if (ops.length === 0) return protocolError("hello missing snapshot");
|
|
124
134
|
return reviveOps(ops);
|
|
125
135
|
};
|
|
126
136
|
const helloSynOf = (env) => {
|
|
@@ -138,11 +148,11 @@ const handleHello = (c, env) => {
|
|
|
138
148
|
});
|
|
139
149
|
c.ctx.onIdle(syn.idle ?? false);
|
|
140
150
|
c.awaitingHello = false;
|
|
141
|
-
c.
|
|
151
|
+
c.error = void 0;
|
|
142
152
|
c.liveAt = Date.now();
|
|
143
153
|
c.authDenials = 0;
|
|
144
154
|
c.episode = null;
|
|
145
|
-
setStatus(c, { status: "
|
|
155
|
+
setStatus(c, { status: "connected" });
|
|
146
156
|
};
|
|
147
157
|
let warnedUnknownSynFact = false;
|
|
148
158
|
const warnUnknownSynFact = () => {
|
|
@@ -178,48 +188,45 @@ const deliverEnvelope = (c, env) => {
|
|
|
178
188
|
}
|
|
179
189
|
if (typeof env.idle === "boolean") c.ctx.onIdle(env.idle);
|
|
180
190
|
};
|
|
181
|
-
const transientCauseOf = (reason) => {
|
|
182
|
-
switch (reason) {
|
|
183
|
-
case "evicted": return "evicted";
|
|
184
|
-
case "error": return "error";
|
|
185
|
-
case "unauthorized": return "unauthorized";
|
|
186
|
-
default: return "error";
|
|
187
|
-
}
|
|
188
|
-
};
|
|
189
191
|
const settleFin = (c, fin) => {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
...rest,
|
|
195
|
-
payload: reviveValue(rest.payload, meta)
|
|
196
|
-
};
|
|
197
|
-
}
|
|
192
|
+
const finish = fin.meta === void 0 ? fin : {
|
|
193
|
+
...fin,
|
|
194
|
+
payload: reviveValue(fin.payload, fin.meta)
|
|
195
|
+
};
|
|
198
196
|
releaseHeld(c);
|
|
197
|
+
c.retryAt = Date.now() + (finish.retry ?? 0);
|
|
199
198
|
if (finish.reason === "idle") {
|
|
200
199
|
c.ctx.onIdle(true);
|
|
201
200
|
c.attempt = 0;
|
|
202
201
|
c.episode = null;
|
|
203
|
-
|
|
202
|
+
c.error = void 0;
|
|
203
|
+
setStatus(c, {
|
|
204
|
+
status: "disconnected",
|
|
205
|
+
reason: "idle"
|
|
206
|
+
});
|
|
204
207
|
return "idle";
|
|
205
208
|
}
|
|
206
209
|
const denial = finish.reason === "unauthorized";
|
|
207
|
-
const terminal = finish.reason === "gone" || denial && c.authDenials > 0;
|
|
208
|
-
if (denial)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
});
|
|
210
|
+
const terminal = finish.reason === "gone" || finish.reason === "forbidden" || finish.reason === "protocol-error" || denial && c.authDenials > 0;
|
|
211
|
+
if (denial) {
|
|
212
|
+
c.authDenials += 1;
|
|
213
|
+
c.authStale = true;
|
|
214
|
+
}
|
|
215
|
+
c.error = finish.reason !== "reconnect" && finish.reason !== "gone" && (!denial || terminal) ? Object.assign(new Error(finish.message ?? finish.reason), { fin: finish }) : void 0;
|
|
216
|
+
if (c.error) c.onError(c.error);
|
|
213
217
|
if (!terminal) {
|
|
214
218
|
c.episode = {
|
|
215
|
-
cause:
|
|
216
|
-
...finish.message !== void 0 && { message: finish.message }
|
|
219
|
+
cause: finish.reason === "reconnect" ? "reconnect" : denial ? "unauthorized" : "error",
|
|
220
|
+
...finish.message !== void 0 && { message: finish.message },
|
|
221
|
+
...finish.code !== void 0 && { code: finish.code }
|
|
217
222
|
};
|
|
218
223
|
if (denial) openGate(c.gate);
|
|
219
224
|
return "reattach";
|
|
220
225
|
}
|
|
226
|
+
c.ctx.onFinish(finish);
|
|
221
227
|
setStatus(c, {
|
|
222
|
-
status: "
|
|
228
|
+
status: "disconnected",
|
|
229
|
+
reason: denial ? "unauthorized" : finish.reason === "forbidden" ? "forbidden" : finish.reason === "protocol-error" ? "protocol-error" : "gone",
|
|
223
230
|
...finish.message !== void 0 && { message: finish.message },
|
|
224
231
|
...finish.payload !== void 0 && { payload: finish.payload }
|
|
225
232
|
});
|
|
@@ -234,23 +241,27 @@ const handleEnvelope = (c, value) => {
|
|
|
234
241
|
}
|
|
235
242
|
deliverEnvelope(c, env);
|
|
236
243
|
if (env.fin !== void 0) return settleFin(c, env.fin);
|
|
237
|
-
if (c.connection.status !== "
|
|
244
|
+
if (c.connection.status !== "connected") {
|
|
238
245
|
c.attempt = 0;
|
|
239
246
|
c.episode = null;
|
|
240
|
-
setStatus(c, { status: "
|
|
247
|
+
setStatus(c, { status: "connected" });
|
|
241
248
|
}
|
|
242
249
|
return null;
|
|
243
250
|
};
|
|
251
|
+
const dialing = (c) => setStatus(c, {
|
|
252
|
+
status: "connecting",
|
|
253
|
+
attempt: c.attempt
|
|
254
|
+
});
|
|
244
255
|
const scheduleRetry = (c, failure, immediate) => {
|
|
245
256
|
if (c.liveAt !== null && Date.now() - c.liveAt >= timing.backoffResetMs) c.attempt = 0;
|
|
246
257
|
c.liveAt = null;
|
|
247
258
|
c.attempt += 1;
|
|
248
259
|
c.episode ??= { cause: "dropped" };
|
|
249
|
-
const delay = immediate ? 0 : backoff(c.attempt);
|
|
260
|
+
const delay = Math.max(immediate ? 0 : backoff(c.attempt), c.retryAt - Date.now());
|
|
250
261
|
setStatus(c, {
|
|
251
|
-
status: "
|
|
252
|
-
...c.episode,
|
|
262
|
+
status: "connecting",
|
|
253
263
|
attempt: c.attempt,
|
|
264
|
+
...c.episode,
|
|
254
265
|
nextRetryAt: Date.now() + delay,
|
|
255
266
|
...failure !== void 0 && { lastError: failure }
|
|
256
267
|
});
|
|
@@ -258,13 +269,11 @@ const scheduleRetry = (c, failure, immediate) => {
|
|
|
258
269
|
};
|
|
259
270
|
const reconnectConn = (c) => {
|
|
260
271
|
if (!c.lifetime) return;
|
|
261
|
-
if (c.connection.status === "
|
|
272
|
+
if (c.connection.status === "disconnected") {
|
|
262
273
|
c.attempt = 0;
|
|
263
274
|
c.authDenials = 0;
|
|
264
|
-
c.episode =
|
|
265
|
-
flushTapSync(() =>
|
|
266
|
-
scheduleRetry(c, void 0, true);
|
|
267
|
-
});
|
|
275
|
+
c.episode = null;
|
|
276
|
+
flushTapSync(() => dialing(c));
|
|
268
277
|
}
|
|
269
278
|
openGate(c.gate);
|
|
270
279
|
c.abort?.abort();
|
|
@@ -277,6 +286,11 @@ const run = async (c, lifetime, gate) => {
|
|
|
277
286
|
c.parked = false;
|
|
278
287
|
}
|
|
279
288
|
while (alive()) {
|
|
289
|
+
if (c.retryAt > Date.now()) {
|
|
290
|
+
await waitGate(gate, c.retryAt - Date.now());
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
flushTapSync(() => dialing(c));
|
|
280
294
|
const abort = new AbortController();
|
|
281
295
|
c.abort = abort;
|
|
282
296
|
c.awaitingHello = true;
|
|
@@ -286,7 +300,17 @@ const run = async (c, lifetime, gate) => {
|
|
|
286
300
|
try {
|
|
287
301
|
outcome = await c.attemptOnce(abort);
|
|
288
302
|
} catch (error) {
|
|
289
|
-
if (!abort.signal.aborted)
|
|
303
|
+
if (!abort.signal.aborted) {
|
|
304
|
+
if (error instanceof SyntaxError || error instanceof Error && /^(statewire: protocol error:|Statewire cannot apply)/.test(error.message)) outcome = flushTapSync(() => settleFin(c, {
|
|
305
|
+
reason: "protocol-error",
|
|
306
|
+
message: error.message
|
|
307
|
+
}));
|
|
308
|
+
else if (c.authStale) outcome = flushTapSync(() => settleFin(c, {
|
|
309
|
+
reason: "unauthorized",
|
|
310
|
+
message: error instanceof Error ? error.message : "credential refresh failed"
|
|
311
|
+
}));
|
|
312
|
+
failure = error instanceof Error && error.message ? error.message : "network";
|
|
313
|
+
}
|
|
290
314
|
}
|
|
291
315
|
abort.abort();
|
|
292
316
|
if (!alive()) return;
|
|
@@ -295,6 +319,7 @@ const run = async (c, lifetime, gate) => {
|
|
|
295
319
|
c.ctx.onDetach();
|
|
296
320
|
});
|
|
297
321
|
if (!alive()) return;
|
|
322
|
+
if (c.connection.status === "disconnected" && !["deferred", "idle"].includes(c.connection.reason)) outcome = "stop";
|
|
298
323
|
if (outcome === "stop" || outcome === "idle") {
|
|
299
324
|
await waitGate(gate);
|
|
300
325
|
continue;
|
|
@@ -305,18 +330,37 @@ const run = async (c, lifetime, gate) => {
|
|
|
305
330
|
await waitGate(gate, delay);
|
|
306
331
|
}
|
|
307
332
|
};
|
|
308
|
-
const
|
|
333
|
+
const unsupportedVersion = (c, version) => flushTapSync(() => handleEnvelope(c, { fin: {
|
|
334
|
+
reason: "protocol-error",
|
|
335
|
+
message: `unsupported protocol version: ${version || "\"\""}`
|
|
336
|
+
} })) ?? "stop";
|
|
337
|
+
const resolveHeaders = async (headers, staleAuth) => new Headers(typeof headers === "function" ? await headers({ staleAuth }) : headers);
|
|
338
|
+
const attachFrame = async (c, headers) => {
|
|
339
|
+
const resolved = await resolveHeaders(headers, c.authStale);
|
|
340
|
+
c.authStale = false;
|
|
341
|
+
const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
|
|
342
|
+
return {
|
|
343
|
+
headers: resolved,
|
|
344
|
+
body: JSON.stringify({
|
|
345
|
+
headers: Object.fromEntries(resolved),
|
|
346
|
+
...lanes ?? {}
|
|
347
|
+
})
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
const useLink = (props) => {
|
|
309
351
|
const latest = useRef(props);
|
|
310
352
|
latest.current = props;
|
|
311
353
|
const [, setConnection] = useState(null);
|
|
312
354
|
const [c] = useState(() => {
|
|
313
355
|
const conn = {
|
|
314
356
|
parked: props.isNew ?? false,
|
|
315
|
-
hasSnapshot: false,
|
|
316
357
|
lease: null,
|
|
317
358
|
attempt: 0,
|
|
318
359
|
authDenials: 0,
|
|
360
|
+
authStale: false,
|
|
319
361
|
liveAt: null,
|
|
362
|
+
retryAt: 0,
|
|
363
|
+
error: void 0,
|
|
320
364
|
episode: null,
|
|
321
365
|
awaitingHello: true,
|
|
322
366
|
held: [],
|
|
@@ -326,25 +370,49 @@ const useStatewireConn = (props) => {
|
|
|
326
370
|
get ctx() {
|
|
327
371
|
return latest.current.ctx;
|
|
328
372
|
},
|
|
373
|
+
onError: (error) => latest.current.onError?.(error),
|
|
329
374
|
onChange: (connection) => latest.current.onChange?.(connection),
|
|
330
375
|
attemptOnce: (abort) => latest.current.attemptOnce(conn, abort),
|
|
331
376
|
notify: setConnection,
|
|
332
377
|
reconnect: () => reconnectConn(conn),
|
|
333
378
|
connection: {
|
|
334
|
-
|
|
379
|
+
...props.isNew ?? false ? {
|
|
380
|
+
status: "disconnected",
|
|
381
|
+
reason: "deferred"
|
|
382
|
+
} : {
|
|
383
|
+
status: "connecting",
|
|
384
|
+
attempt: 0
|
|
385
|
+
},
|
|
386
|
+
degraded: false,
|
|
335
387
|
reconnect: () => conn.reconnect()
|
|
336
388
|
}
|
|
337
389
|
};
|
|
338
390
|
return conn;
|
|
339
391
|
});
|
|
392
|
+
const [link] = useState(() => ({
|
|
393
|
+
get connection() {
|
|
394
|
+
return c.connection;
|
|
395
|
+
},
|
|
396
|
+
get parked() {
|
|
397
|
+
return c.parked;
|
|
398
|
+
},
|
|
399
|
+
send: (frame) => latest.current.send(c, frame)
|
|
400
|
+
}));
|
|
340
401
|
useEffect(() => {
|
|
341
|
-
c.hasSnapshot = false;
|
|
342
402
|
c.lease = null;
|
|
343
403
|
c.attempt = 0;
|
|
344
404
|
c.authDenials = 0;
|
|
405
|
+
c.authStale = false;
|
|
345
406
|
c.liveAt = null;
|
|
407
|
+
c.retryAt = 0;
|
|
346
408
|
c.episode = null;
|
|
347
|
-
|
|
409
|
+
setStatus(c, c.parked ? {
|
|
410
|
+
status: "disconnected",
|
|
411
|
+
reason: "deferred"
|
|
412
|
+
} : {
|
|
413
|
+
status: "connecting",
|
|
414
|
+
attempt: 0
|
|
415
|
+
});
|
|
348
416
|
const lifetime = new AbortController();
|
|
349
417
|
c.lifetime = lifetime;
|
|
350
418
|
c.gate = newGate();
|
|
@@ -356,10 +424,10 @@ const useStatewireConn = (props) => {
|
|
|
356
424
|
openGate(c.gate);
|
|
357
425
|
};
|
|
358
426
|
}, [c, props.url]);
|
|
359
|
-
return
|
|
427
|
+
return link;
|
|
360
428
|
};
|
|
361
429
|
const classify = (timeout) => /* @__PURE__ */ new Error(timeout.aborted ? "timeout" : "network");
|
|
362
430
|
//#endregion
|
|
363
|
-
export { TERMINAL, classify, handleEnvelope,
|
|
431
|
+
export { TERMINAL, attachFrame, classify, degradedOf, handleEnvelope, resolveHeaders, settleFin, unsupportedVersion, useLink, validateRefusalSyn };
|
|
364
432
|
|
|
365
433
|
//# sourceMappingURL=connection.js.map
|