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.
Files changed (95) hide show
  1. package/README.md +29 -78
  2. package/dist/client/connection.d.ts +20 -6
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +120 -52
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +3 -2
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +10 -4
  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 +97 -17
  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 +2 -1
  23. package/dist/host/http.d.ts.map +1 -1
  24. package/dist/host/http.js +108 -47
  25. package/dist/host/http.js.map +1 -1
  26. package/dist/host/socket.d.ts +33 -8
  27. package/dist/host/socket.d.ts.map +1 -1
  28. package/dist/host/socket.js +388 -58
  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 +42 -4
  35. package/dist/host-internal.d.ts.map +1 -1
  36. package/dist/host-internal.js +2 -1
  37. package/dist/host.d.ts +16 -3
  38. package/dist/host.d.ts.map +1 -1
  39. package/dist/host.js +2 -1
  40. package/dist/statewire.d.ts +21 -11
  41. package/dist/statewire.d.ts.map +1 -1
  42. package/dist/statewire.js +2 -2
  43. package/dist/statewire.js.map +1 -1
  44. package/dist/transports/sse.d.ts +1 -4
  45. package/dist/transports/sse.d.ts.map +1 -1
  46. package/dist/transports/sse.js +47 -56
  47. package/dist/transports/sse.js.map +1 -1
  48. package/dist/transports/testing.js +2 -2
  49. package/dist/transports/testing.js.map +1 -1
  50. package/dist/transports/ws.d.ts +1 -1
  51. package/dist/transports/ws.d.ts.map +1 -1
  52. package/dist/transports/ws.js +54 -41
  53. package/dist/transports/ws.js.map +1 -1
  54. package/dist/types.d.ts +3 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/wire/documents.d.ts +20 -0
  58. package/dist/wire/documents.d.ts.map +1 -0
  59. package/dist/wire/documents.js +74 -0
  60. package/dist/wire/documents.js.map +1 -0
  61. package/dist/wire/limits.d.ts +10 -0
  62. package/dist/wire/limits.d.ts.map +1 -0
  63. package/dist/wire/limits.js +11 -0
  64. package/dist/wire/limits.js.map +1 -0
  65. package/dist/wire/meta.d.ts.map +1 -1
  66. package/dist/wire/meta.js +1 -1
  67. package/dist/wire/meta.js.map +1 -1
  68. package/dist/wire/ops.d.ts.map +1 -1
  69. package/dist/wire/ops.js +1 -2
  70. package/dist/wire/ops.js.map +1 -1
  71. package/dist/wire/version.d.ts +7 -0
  72. package/dist/wire/version.d.ts.map +1 -0
  73. package/dist/wire/version.js +8 -0
  74. package/dist/wire/version.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +213 -67
  77. package/src/client/core.ts +10 -3
  78. package/src/client/session.ts +106 -30
  79. package/src/host/adapter.ts +10 -2
  80. package/src/host/host.ts +54 -4
  81. package/src/host/http.ts +168 -64
  82. package/src/host/socket.ts +603 -60
  83. package/src/host/work.ts +50 -0
  84. package/src/host-internal.ts +44 -3
  85. package/src/host.ts +18 -2
  86. package/src/statewire.ts +33 -10
  87. package/src/transports/sse.ts +60 -68
  88. package/src/transports/testing.ts +2 -2
  89. package/src/transports/ws.ts +29 -48
  90. package/src/types.ts +1 -0
  91. package/src/wire/documents.ts +109 -0
  92. package/src/wire/limits.ts +6 -0
  93. package/src/wire/meta.ts +6 -1
  94. package/src/wire/ops.ts +1 -2
  95. package/src/wire/version.ts +3 -0
package/README.md CHANGED
@@ -1,88 +1,39 @@
1
1
  # statewire
2
2
 
3
- React/TypeScript client for the **Statewire** protocol: a server owns a JSON state object and streams it to clients as packets (`POST /stream` attach, hello-snapshot first); clients change it by submitting stamped statement frames (`POST /frames`, `{"cmd": [{"method", "params", "seq"}]}`). The client keeps your UI on the canonical state with optimistic updates rebased on top, survives disconnects, evictions, and reloads of the server, and tells you exactly what happened to every command you sent.
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
- ```tsx
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
- const onClick = async () => {
20
- await commands.increment(); // resolves when the server has provably applied it
21
- };
7
+ ```sh
8
+ pnpm add statewire react
22
9
  ```
23
10
 
24
- ## What you get
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
- An eviction replays every unsettled command under the rotated client id, in seq order — fresh seqs, relative order preserved, the original promise settles with the replay's outcome. Context submissions settle as session-lost failures and the stored context re-sends whole; replay may re-run a command the lost session already applied.
39
-
40
- Reconnects are not errors: transport drops and server evictions self-heal with exponential backoff while `state` stays renderable. Commands carry a monotonic seq, so retries are deduplicated server-side and a reconnect splices already-applied commands instead of re-running them.
41
-
42
- A client id may hold any number of concurrent attaches: each gets its own hello and lease, and cmd answers fan out to every live attach of the id. A lease lives and dies with its attach — a `423` means that attach is gone: re-attach and resend.
43
-
44
- ## Three ways to mount
45
-
46
- One implementation, three entry points:
47
-
48
- ```ts
49
- useStatewire(options) // React hook
50
- StatewireResource(options) // @assistant-ui/tap resource, for composition
51
- new StatewireClient(options) // framework-free: .state/.connection/.commands,
52
- // .subscribe(cb), .dispose()
53
- ```
54
-
55
- `StatewireClient#subscribe` follows the external-store contract (`useSyncExternalStore`-compatible). `dispose()` settles pending commands like an unmount.
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
- `StatewireWS({ url, protocols, query, webSocket })` is the full-duplex sibling over `/ws`. A transport is a tap resource producing the `Statewire` value the client consumes; `createMockTransport` from `statewire/testing` is a complete in-memory driver for tests.
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
- See `examples/vite-statewire-example` (frontend) and `python/statewire/examples/demo_app.py` (backend) for a complete runnable pair, and the repo's protocol spec for the wire format.
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" | "evicted" | "error" | "unauthorized";
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 useStatewireConn: (props: {
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
- }) => Conn;
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, useStatewireConn, validateRefusalSyn };
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":";;;;KAQY;EACV;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,SAAS;EACT;EACA,MAAM;EACN,MAAM;EACN,OAAO;EACP,UAAU;EACV,KAAK;EACL,WAAW,YAAY,gBAAgB;EACvC,cAAc,OAAO,oBAAoB,QAAQ;EACjD,SAAS,YAAY,gBAAgB;EACrC;;cA8CW,qBAAsB,mBAAiB;cA8NvC,iBACX,GAAG,MACH,mBACC;cA6FU,mBAAoB;EAC/B;EACA;EACA,aAAa,YAAY,gBAAgB;EACzC,KAAK;EACL,cAAc,GAAG,MAAM,OAAO,oBAAoB,QAAQ;MACxD;cAuDS,WAAY,SAAS,gBAAW"}
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
- const changed = next.status !== c.connection.status;
52
+ if (sameState(next, c.connection) && c.connection.error === c.error) return;
46
53
  c.connection = {
47
54
  ...next,
48
- reconnect: c.reconnect
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
- if (changed) c.onChange(c.connection);
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 !== 1 || !isObject(ops[0])) return protocolError("hello snapshot must be one root replace op");
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.hasSnapshot = true;
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: "live" });
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
- let finish = fin;
191
- if (finish.meta !== void 0) {
192
- const { meta, ...rest } = finish;
193
- finish = {
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
- setStatus(c, { status: "idle" });
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) c.authDenials += 1;
209
- c.ctx.onFinish(terminal ? finish : {
210
- ...finish,
211
- reason: "error"
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: transientCauseOf(finish.reason),
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: "gone",
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 !== "live") {
244
+ if (c.connection.status !== "connected") {
238
245
  c.attempt = 0;
239
246
  c.episode = null;
240
- setStatus(c, { status: "live" });
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: "reconnecting",
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 === "gone" || c.connection.status === "idle") {
272
+ if (c.connection.status === "disconnected") {
262
273
  c.attempt = 0;
263
274
  c.authDenials = 0;
264
- c.episode = { cause: "dropped" };
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) failure = error instanceof Error && error.message ? error.message : "network";
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 useStatewireConn = (props) => {
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
- status: "connecting",
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
- if (c.connection.status !== "connecting") setStatus(c, { status: "connecting" });
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 c;
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, useStatewireConn, validateRefusalSyn };
431
+ export { TERMINAL, attachFrame, classify, degradedOf, handleEnvelope, resolveHeaders, settleFin, unsupportedVersion, useLink, validateRefusalSyn };
364
432
 
365
433
  //# sourceMappingURL=connection.js.map