statewire 0.0.0-0 → 0.0.2
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 +84 -1
- package/dist/client/changes.d.ts +7 -0
- package/dist/client/changes.d.ts.map +1 -0
- package/dist/client/changes.js +25 -0
- package/dist/client/changes.js.map +1 -0
- package/dist/client/connection.d.ts +63 -0
- package/dist/client/connection.d.ts.map +1 -0
- package/dist/client/connection.js +211 -0
- package/dist/client/connection.js.map +1 -0
- package/dist/client/core.d.ts +14 -0
- package/dist/client/core.d.ts.map +1 -0
- package/dist/client/core.js +272 -0
- package/dist/client/core.js.map +1 -0
- package/dist/client/session.d.ts +71 -0
- package/dist/client/session.d.ts.map +1 -0
- package/dist/client/session.js +529 -0
- package/dist/client/session.js.map +1 -0
- package/dist/client/sessionStore.d.ts +16 -0
- package/dist/client/sessionStore.d.ts.map +1 -0
- package/dist/client/sessionStore.js +14 -0
- package/dist/client/sessionStore.js.map +1 -0
- package/dist/client/timing.d.ts +11 -0
- package/dist/client/timing.d.ts.map +1 -0
- package/dist/client/timing.js +15 -0
- package/dist/client/timing.js.map +1 -0
- package/dist/host/adapter.d.ts +8 -0
- package/dist/host/adapter.d.ts.map +1 -0
- package/dist/host/adapter.js +23 -0
- package/dist/host/adapter.js.map +1 -0
- package/dist/host/draft.d.ts +28 -0
- package/dist/host/draft.d.ts.map +1 -0
- package/dist/host/draft.js +211 -0
- package/dist/host/draft.js.map +1 -0
- package/dist/host/engine.d.ts +14 -0
- package/dist/host/engine.d.ts.map +1 -0
- package/dist/host/engine.js +17 -0
- package/dist/host/engine.js.map +1 -0
- package/dist/host/host.d.ts +62 -0
- package/dist/host/host.d.ts.map +1 -0
- package/dist/host/host.js +331 -0
- package/dist/host/host.js.map +1 -0
- package/dist/host/socket.d.ts +14 -0
- package/dist/host/socket.d.ts.map +1 -0
- package/dist/host/socket.js +459 -0
- package/dist/host/socket.js.map +1 -0
- package/dist/host-internal.d.ts +27 -0
- package/dist/host-internal.d.ts.map +1 -0
- package/dist/host-internal.js +3 -0
- package/dist/host.d.ts +44 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/statewire.d.ts +104 -0
- package/dist/statewire.d.ts.map +1 -0
- package/dist/statewire.js +53 -0
- package/dist/statewire.js.map +1 -0
- package/dist/transports/fake-sse.d.ts +12 -0
- package/dist/transports/fake-sse.d.ts.map +1 -0
- package/dist/transports/fake-sse.js +39 -0
- package/dist/transports/fake-sse.js.map +1 -0
- package/dist/transports/fixtures.d.ts +33 -0
- package/dist/transports/fixtures.d.ts.map +1 -0
- package/dist/transports/fixtures.js +50 -0
- package/dist/transports/fixtures.js.map +1 -0
- package/dist/transports/remote.d.ts +12 -0
- package/dist/transports/remote.d.ts.map +1 -0
- package/dist/transports/remote.js +21 -0
- package/dist/transports/remote.js.map +1 -0
- package/dist/transports/sse-parser.d.ts +5 -0
- package/dist/transports/sse-parser.d.ts.map +1 -0
- package/dist/transports/sse-parser.js +26 -0
- package/dist/transports/sse-parser.js.map +1 -0
- package/dist/transports/sse.d.ts +64 -0
- package/dist/transports/sse.d.ts.map +1 -0
- package/dist/transports/sse.js +137 -0
- package/dist/transports/sse.js.map +1 -0
- package/dist/transports/testing.d.ts +32 -0
- package/dist/transports/testing.d.ts.map +1 -0
- package/dist/transports/testing.js +94 -0
- package/dist/transports/testing.js.map +1 -0
- package/dist/transports/ws.d.ts +9 -0
- package/dist/transports/ws.d.ts.map +1 -0
- package/dist/transports/ws.js +169 -0
- package/dist/transports/ws.js.map +1 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +24 -0
- package/dist/types.js.map +1 -0
- package/dist/util/draftTraps.d.ts +6 -0
- package/dist/util/draftTraps.d.ts.map +1 -0
- package/dist/util/draftTraps.js +25 -0
- package/dist/util/draftTraps.js.map +1 -0
- package/dist/util/macrotask.d.ts +5 -0
- package/dist/util/macrotask.d.ts.map +1 -0
- package/dist/util/macrotask.js +25 -0
- package/dist/util/macrotask.js.map +1 -0
- package/dist/util/methodProxy.d.ts +5 -0
- package/dist/util/methodProxy.d.ts.map +1 -0
- package/dist/util/methodProxy.js +17 -0
- package/dist/util/methodProxy.js.map +1 -0
- package/dist/util/randomId.d.ts +5 -0
- package/dist/util/randomId.d.ts.map +1 -0
- package/dist/util/randomId.js +6 -0
- package/dist/util/randomId.js.map +1 -0
- package/dist/util/recordMutations.d.ts +10 -0
- package/dist/util/recordMutations.d.ts.map +1 -0
- package/dist/util/recordMutations.js +109 -0
- package/dist/util/recordMutations.js.map +1 -0
- package/dist/util/statePath.d.ts +14 -0
- package/dist/util/statePath.d.ts.map +1 -0
- package/dist/util/statePath.js +31 -0
- package/dist/util/statePath.js.map +1 -0
- package/dist/wire/meta.d.ts +27 -0
- package/dist/wire/meta.d.ts.map +1 -0
- package/dist/wire/meta.js +75 -0
- package/dist/wire/meta.js.map +1 -0
- package/dist/wire/ops.d.ts +19 -0
- package/dist/wire/ops.d.ts.map +1 -0
- package/dist/wire/ops.js +112 -0
- package/dist/wire/ops.js.map +1 -0
- package/package.json +52 -5
- package/src/client/changes.ts +38 -0
- package/src/client/connection.ts +301 -0
- package/src/client/core.ts +312 -0
- package/src/client/session.ts +696 -0
- package/src/client/sessionStore.ts +23 -0
- package/src/client/timing.ts +14 -0
- package/src/host/adapter.ts +33 -0
- package/src/host/draft.ts +323 -0
- package/src/host/engine.ts +13 -0
- package/src/host/host.ts +482 -0
- package/src/host/socket.ts +542 -0
- package/src/host-internal.ts +28 -0
- package/src/host.ts +61 -0
- package/src/index.ts +5 -0
- package/src/statewire.ts +200 -0
- package/src/transports/fake-sse.ts +51 -0
- package/src/transports/fixtures.ts +60 -0
- package/src/transports/remote.ts +30 -0
- package/src/transports/sse-parser.ts +24 -0
- package/src/transports/sse.ts +267 -0
- package/src/transports/testing.ts +123 -0
- package/src/transports/ws.ts +256 -0
- package/src/types.ts +88 -0
- package/src/util/draftTraps.ts +32 -0
- package/src/util/macrotask.ts +25 -0
- package/src/util/methodProxy.ts +16 -0
- package/src/util/randomId.ts +4 -0
- package/src/util/recordMutations.ts +0 -0
- package/src/util/statePath.ts +61 -0
- package/src/wire/meta.ts +101 -0
- package/src/wire/ops.ts +202 -0
- package/index.js +0 -1
package/src/statewire.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createTapRoot,
|
|
4
|
+
flushTapSync,
|
|
5
|
+
resource,
|
|
6
|
+
useResource,
|
|
7
|
+
} from "@assistant-ui/tap";
|
|
8
|
+
import type { ResourceElement } from "@assistant-ui/tap";
|
|
9
|
+
import { LIVE_CONNECTION, useStatewireCore } from "./client/core";
|
|
10
|
+
import type { Statewire } from "./types";
|
|
11
|
+
import type { ConnectionState } from "./transports/sse";
|
|
12
|
+
import type { StatewireSessionStoreOptions } from "./client/sessionStore";
|
|
13
|
+
|
|
14
|
+
export const useStatewire = <
|
|
15
|
+
TState,
|
|
16
|
+
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
17
|
+
TEvent = any,
|
|
18
|
+
>(
|
|
19
|
+
options: StatewireClient.Options<TState, TCommands, TEvent>,
|
|
20
|
+
): StatewireClient.Helpers<TState, TCommands> => {
|
|
21
|
+
const latest = useRef(options);
|
|
22
|
+
latest.current = options;
|
|
23
|
+
|
|
24
|
+
const value = useResource(options.transport) as Statewire<unknown>;
|
|
25
|
+
const core = useStatewireCore(() => latest.current, value);
|
|
26
|
+
const [subscribe] = useState(
|
|
27
|
+
() => (listener: () => void) =>
|
|
28
|
+
core.subscribe(() => flushTapSync(listener)),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const state = useSyncExternalStore(
|
|
32
|
+
subscribe,
|
|
33
|
+
core.getState,
|
|
34
|
+
core.getState,
|
|
35
|
+
) as TState;
|
|
36
|
+
const connection = value.connection ?? LIVE_CONNECTION;
|
|
37
|
+
|
|
38
|
+
return useMemo(
|
|
39
|
+
() => ({
|
|
40
|
+
state,
|
|
41
|
+
getState: core.getState as () => TState,
|
|
42
|
+
connection,
|
|
43
|
+
commands: core.commands as Statewire.CommandsProxy<TCommands>,
|
|
44
|
+
}),
|
|
45
|
+
[state, connection, core],
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const StatewireResource = <
|
|
50
|
+
TState,
|
|
51
|
+
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
52
|
+
TEvent = any,
|
|
53
|
+
>(
|
|
54
|
+
options: StatewireClient.Options<TState, TCommands, TEvent>,
|
|
55
|
+
): ResourceElement<StatewireClient.Helpers<TState, TCommands>> =>
|
|
56
|
+
resource(useStatewire<TState, TCommands, TEvent>)(options);
|
|
57
|
+
|
|
58
|
+
export class StatewireClient<
|
|
59
|
+
TState,
|
|
60
|
+
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
61
|
+
TEvent = any,
|
|
62
|
+
> {
|
|
63
|
+
private readonly root;
|
|
64
|
+
private disposed = false;
|
|
65
|
+
|
|
66
|
+
constructor(options: StatewireClient.Options<TState, TCommands, TEvent>) {
|
|
67
|
+
const ClientRoot = () => useStatewire<TState, TCommands, TEvent>(options);
|
|
68
|
+
this.root = createTapRoot(ClientRoot);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get state(): TState {
|
|
72
|
+
return this.root.getValue().getState();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get connection(): StatewireClient.Connection {
|
|
76
|
+
return this.root.getValue().connection;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get commands(): Statewire.CommandsProxy<TCommands> {
|
|
80
|
+
return this.root.getValue().commands;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
subscribe(listener: () => void): () => void {
|
|
84
|
+
return this.root.subscribe(listener);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
dispose(): void {
|
|
88
|
+
if (this.disposed) return;
|
|
89
|
+
this.disposed = true;
|
|
90
|
+
this.root.unmount();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export namespace StatewireClient {
|
|
95
|
+
export type Options<
|
|
96
|
+
TState,
|
|
97
|
+
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
98
|
+
TEvent = any,
|
|
99
|
+
> = {
|
|
100
|
+
transport: ResourceElement<Statewire<TState, TCommands, TEvent>>;
|
|
101
|
+
optimistic?: Optimistic<NonNullable<TState>, TCommands>;
|
|
102
|
+
onEvent?: (event: TEvent, ctx: { state: NonNullable<TState> }) => void;
|
|
103
|
+
onStateChange?: (
|
|
104
|
+
state: NonNullable<TState>,
|
|
105
|
+
ctx: { changes: Statewire.Changes },
|
|
106
|
+
) => void;
|
|
107
|
+
onCommandChange?: (
|
|
108
|
+
update: Statewire.CommandUpdate<TCommands>,
|
|
109
|
+
ctx: { state: NonNullable<TState> },
|
|
110
|
+
) => void;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export type Helpers<
|
|
114
|
+
TState,
|
|
115
|
+
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
116
|
+
> = {
|
|
117
|
+
/** The statewire's state type — remote statewires include `undefined` until the first attach snapshot applies. */
|
|
118
|
+
state: TState;
|
|
119
|
+
/** Identity-stable across renders; reads the current canonical state without subscribing the caller. */
|
|
120
|
+
getState: () => TState;
|
|
121
|
+
connection: Connection;
|
|
122
|
+
commands: Statewire.CommandsProxy<TCommands>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export type Connection = ConnectionState & { reconnect: () => void };
|
|
126
|
+
export type ConnectionStatus = Connection["status"];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Mutate the draft in place; the client records the next state plus the exact
|
|
130
|
+
* changed paths. Re-run against the new base on every rebase.
|
|
131
|
+
*/
|
|
132
|
+
export type Optimistic<
|
|
133
|
+
TState,
|
|
134
|
+
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
135
|
+
> = (
|
|
136
|
+
draft: TState,
|
|
137
|
+
command: Statewire.Command<TCommands>,
|
|
138
|
+
ctx: OptimisticContext,
|
|
139
|
+
) => void;
|
|
140
|
+
|
|
141
|
+
export type OptimisticContext = {
|
|
142
|
+
ts: number;
|
|
143
|
+
seq: number;
|
|
144
|
+
clientId?: string;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* fate: "aborted" = never sent, "rejected" = the handler refused,
|
|
149
|
+
* "unknown-command" = no such command, "invalid-params" = the args did not
|
|
150
|
+
* match the command's schema, "lost" = sent but never covered by an ack,
|
|
151
|
+
* "crashed" = acked but the processing died, "result-unavailable" = acked
|
|
152
|
+
* but the result is gone.
|
|
153
|
+
* cause is which channel settled it: "server" = the server's answer;
|
|
154
|
+
* the rest are channel deaths the client settled locally.
|
|
155
|
+
*/
|
|
156
|
+
export type SendFailure =
|
|
157
|
+
| { fate: "rejected"; cause: "server"; message: string; payload?: unknown }
|
|
158
|
+
| { fate: "unknown-command"; cause: "server" }
|
|
159
|
+
| { fate: "invalid-params"; cause: "server"; message: string }
|
|
160
|
+
| { fate: "crashed"; cause: "server" }
|
|
161
|
+
| {
|
|
162
|
+
fate: "result-unavailable";
|
|
163
|
+
cause:
|
|
164
|
+
| "server"
|
|
165
|
+
| "unmount"
|
|
166
|
+
| "stream-gone"
|
|
167
|
+
| "session-lost"
|
|
168
|
+
| "superseded";
|
|
169
|
+
}
|
|
170
|
+
| {
|
|
171
|
+
fate: "aborted";
|
|
172
|
+
cause:
|
|
173
|
+
| "cascade"
|
|
174
|
+
| "unmount"
|
|
175
|
+
| "stream-gone"
|
|
176
|
+
| "session-lost"
|
|
177
|
+
| "superseded";
|
|
178
|
+
}
|
|
179
|
+
| {
|
|
180
|
+
fate: "lost";
|
|
181
|
+
cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export type SSEOptions = StatewireSessionStoreOptions & {
|
|
185
|
+
url: string;
|
|
186
|
+
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
187
|
+
fetch?: typeof globalThis.fetch;
|
|
188
|
+
onChange?: (connection: Connection) => void;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export type WSOptions = StatewireSessionStoreOptions & {
|
|
192
|
+
url: string;
|
|
193
|
+
protocols?: string | string[];
|
|
194
|
+
query?:
|
|
195
|
+
| Record<string, string>
|
|
196
|
+
| (() => Record<string, string> | Promise<Record<string, string>>);
|
|
197
|
+
webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
|
|
198
|
+
onChange?: (connection: Connection) => void;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { StatewireEnvelope } from "../client/connection";
|
|
2
|
+
|
|
3
|
+
export type FakeStreamHandle = {
|
|
4
|
+
clientId: string;
|
|
5
|
+
send: (text: string) => void;
|
|
6
|
+
close: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const sseFrame = (envelope: StatewireEnvelope) =>
|
|
10
|
+
`data: ${JSON.stringify(envelope)}\n\n`;
|
|
11
|
+
|
|
12
|
+
export const openSSEStream = (
|
|
13
|
+
streams: Set<FakeStreamHandle>,
|
|
14
|
+
clientId: string,
|
|
15
|
+
onOpen: (handle: FakeStreamHandle) => void,
|
|
16
|
+
signal?: AbortSignal,
|
|
17
|
+
): Response => {
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
let handle: FakeStreamHandle;
|
|
20
|
+
const body = new ReadableStream<Uint8Array>({
|
|
21
|
+
start: (controller) => {
|
|
22
|
+
let open = true;
|
|
23
|
+
handle = {
|
|
24
|
+
clientId,
|
|
25
|
+
send: (text) => {
|
|
26
|
+
if (open) controller.enqueue(encoder.encode(text));
|
|
27
|
+
},
|
|
28
|
+
close: () => {
|
|
29
|
+
if (!open) return;
|
|
30
|
+
open = false;
|
|
31
|
+
streams.delete(handle);
|
|
32
|
+
controller.close();
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
streams.add(handle);
|
|
36
|
+
onOpen(handle);
|
|
37
|
+
},
|
|
38
|
+
cancel: () => {
|
|
39
|
+
streams.delete(handle);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
signal?.addEventListener("abort", () => {
|
|
43
|
+
for (const stream of [...streams]) {
|
|
44
|
+
if (stream.clientId === clientId) stream.close();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return new Response(body, {
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { "content-type": "text/event-stream" },
|
|
50
|
+
});
|
|
51
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { resource } from "@assistant-ui/tap";
|
|
2
|
+
import {
|
|
3
|
+
useStatewireCommands,
|
|
4
|
+
useStatewireEmit,
|
|
5
|
+
useStatewireState,
|
|
6
|
+
} from "../host";
|
|
7
|
+
import { tick } from "./testing";
|
|
8
|
+
|
|
9
|
+
/** Let several rounds of queued microtasks and op batches drain. */
|
|
10
|
+
export const settle = async () => {
|
|
11
|
+
for (let i = 0; i < 3; i++) await tick();
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A minimal statewire host element: `{ count }` state with an `increment` command. */
|
|
15
|
+
export const createCounterElement = () =>
|
|
16
|
+
resource(() => {
|
|
17
|
+
const [state] = useStatewireState(() => ({ count: 0 }));
|
|
18
|
+
const emit = useStatewireEmit();
|
|
19
|
+
const commands = useStatewireCommands({
|
|
20
|
+
increment: () => {
|
|
21
|
+
state.count += 1;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return { state, commands, emit };
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
/** Serialize a command frame as a client would send it over a socket. */
|
|
28
|
+
export const commandFrame = (
|
|
29
|
+
method: string,
|
|
30
|
+
seq: number,
|
|
31
|
+
params: unknown[] = [],
|
|
32
|
+
) => JSON.stringify({ method, params, seq });
|
|
33
|
+
|
|
34
|
+
export type SocketRecorder = {
|
|
35
|
+
sent: string[];
|
|
36
|
+
closes: { code?: number; reason?: string }[];
|
|
37
|
+
last: () => any;
|
|
38
|
+
send: (data: string) => void;
|
|
39
|
+
close: (code?: number, reason?: string) => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Record sent frames and close calls; `last()` parses the latest frame. */
|
|
43
|
+
export const createSocketRecorder = (): SocketRecorder => {
|
|
44
|
+
const sent: string[] = [];
|
|
45
|
+
const closes: { code?: number; reason?: string }[] = [];
|
|
46
|
+
return {
|
|
47
|
+
sent,
|
|
48
|
+
closes,
|
|
49
|
+
last: () => JSON.parse(sent.at(-1)!),
|
|
50
|
+
send: (data) => {
|
|
51
|
+
sent.push(data);
|
|
52
|
+
},
|
|
53
|
+
close: (code, reason) => {
|
|
54
|
+
closes.push({
|
|
55
|
+
...(code !== undefined && { code }),
|
|
56
|
+
...(reason !== undefined && { reason }),
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useStatewireEmit } from "../host/host";
|
|
3
|
+
import type { Statewire } from "../types";
|
|
4
|
+
import { setTransport, useStatewireSession } from "../client/session";
|
|
5
|
+
import type { StatewireSessionStoreOptions } from "../client/sessionStore";
|
|
6
|
+
import type { StatewireClient } from "../statewire";
|
|
7
|
+
import type { RemoteLink, StatewireRemoteProps } from "./sse";
|
|
8
|
+
|
|
9
|
+
export const useStatewireRemote = <
|
|
10
|
+
TOptions extends {
|
|
11
|
+
url: string;
|
|
12
|
+
onChange?: (connection: StatewireClient.Connection) => void;
|
|
13
|
+
} & StatewireSessionStoreOptions,
|
|
14
|
+
>(
|
|
15
|
+
options: TOptions,
|
|
16
|
+
useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,
|
|
17
|
+
): Statewire<any, any, any> => {
|
|
18
|
+
const emit = useStatewireEmit<any>();
|
|
19
|
+
const s = useStatewireSession({ sessionStore: options.sessionStore }, emit);
|
|
20
|
+
const conn = useLink(options, s.ctx);
|
|
21
|
+
useEffect(() => setTransport(s, conn), [s, conn]);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
state: s.canonical,
|
|
25
|
+
commands: s.commands,
|
|
26
|
+
emit,
|
|
27
|
+
connection: conn.connection,
|
|
28
|
+
source: s.source,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const createSseParser = (): ((chunk: string) => string[]) => {
|
|
2
|
+
let buffer = "";
|
|
3
|
+
let dataLines: string[] = [];
|
|
4
|
+
|
|
5
|
+
return (chunk: string) => {
|
|
6
|
+
const events: string[] = [];
|
|
7
|
+
buffer += chunk;
|
|
8
|
+
const held = buffer.endsWith("\r") ? "\r" : "";
|
|
9
|
+
const lines = buffer
|
|
10
|
+
.slice(0, buffer.length - held.length)
|
|
11
|
+
.split(/\r\n|\r|\n/);
|
|
12
|
+
buffer = lines.pop()! + held;
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
if (line === "") {
|
|
15
|
+
if (dataLines.length > 0) events.push(dataLines.join("\n"));
|
|
16
|
+
dataLines = [];
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const match = /^data(?:: ?(.*))?$/.exec(line);
|
|
20
|
+
if (match) dataLines.push(match[1] ?? "");
|
|
21
|
+
}
|
|
22
|
+
return events;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
3
|
+
import type { ResourceElement } from "@assistant-ui/tap";
|
|
4
|
+
import { encodeParams, reviveResponse } from "../wire/meta";
|
|
5
|
+
import { createSseParser } from "./sse-parser";
|
|
6
|
+
import { timing } from "../client/timing";
|
|
7
|
+
import {
|
|
8
|
+
classify,
|
|
9
|
+
handleEnvelope,
|
|
10
|
+
overloadedError,
|
|
11
|
+
parseRetryAfter,
|
|
12
|
+
useStatewireConn,
|
|
13
|
+
} from "../client/connection";
|
|
14
|
+
import type {
|
|
15
|
+
Conn,
|
|
16
|
+
StatewireEnvelope,
|
|
17
|
+
StatewireResponse,
|
|
18
|
+
} from "../client/connection";
|
|
19
|
+
import type { Statewire } from "../types";
|
|
20
|
+
import type { StatewireClient } from "../statewire";
|
|
21
|
+
import type { StatewireHost } from "../host";
|
|
22
|
+
import { useStatewireRemote } from "./remote";
|
|
23
|
+
|
|
24
|
+
export type StatewireRemoteProps = {
|
|
25
|
+
clientId: string;
|
|
26
|
+
onSnapshot: (args: { ops: unknown[]; lastSeq: number }) => void;
|
|
27
|
+
onOps: (args: {
|
|
28
|
+
ops: unknown[];
|
|
29
|
+
res?: StatewireResponse[];
|
|
30
|
+
ack?: number;
|
|
31
|
+
}) => void;
|
|
32
|
+
onFinish: (args: StatewireHost.Fin) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type StatewireWireCommand = {
|
|
36
|
+
method: string;
|
|
37
|
+
params: readonly unknown[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type StatewireSubmitResult =
|
|
41
|
+
| { type: "receipt"; response?: StatewireResponse }
|
|
42
|
+
| { type: "rejected"; message: string }
|
|
43
|
+
| { type: "seq-gap" }
|
|
44
|
+
| { type: "unknown-client" }
|
|
45
|
+
| { type: "stale-lease" };
|
|
46
|
+
|
|
47
|
+
export type ConnectionState =
|
|
48
|
+
| { status: "connecting" }
|
|
49
|
+
| { status: "live" }
|
|
50
|
+
| {
|
|
51
|
+
status: "reconnecting";
|
|
52
|
+
cause: "dropped" | "evicted" | "error";
|
|
53
|
+
attempt: number;
|
|
54
|
+
nextRetryAt: number;
|
|
55
|
+
lastError?: string;
|
|
56
|
+
message?: string;
|
|
57
|
+
}
|
|
58
|
+
| { status: "gone"; message?: string; payload?: unknown };
|
|
59
|
+
|
|
60
|
+
/** The live wire a remote session drives: connection status plus command POST. */
|
|
61
|
+
export type RemoteLink = {
|
|
62
|
+
connection: StatewireClient.Connection;
|
|
63
|
+
send: (args: {
|
|
64
|
+
seq: number;
|
|
65
|
+
command: StatewireWireCommand;
|
|
66
|
+
}) => Promise<StatewireSubmitResult>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const baseUrl = (options: StatewireClient.SSEOptions) =>
|
|
70
|
+
options.url.replace(/\/$/, "");
|
|
71
|
+
|
|
72
|
+
const requestHeaders = async (
|
|
73
|
+
options: StatewireClient.SSEOptions,
|
|
74
|
+
extra: Record<string, string>,
|
|
75
|
+
) => {
|
|
76
|
+
const { headers } = options;
|
|
77
|
+
const merged = new Headers(
|
|
78
|
+
typeof headers === "function" ? await headers() : headers,
|
|
79
|
+
);
|
|
80
|
+
for (const [name, value] of Object.entries(extra)) merged.set(name, value);
|
|
81
|
+
return merged;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const timedFetch = async (
|
|
85
|
+
options: StatewireClient.SSEOptions,
|
|
86
|
+
input: string,
|
|
87
|
+
init: RequestInit & { signal: AbortSignal },
|
|
88
|
+
timeout: AbortSignal,
|
|
89
|
+
): Promise<Response> => {
|
|
90
|
+
try {
|
|
91
|
+
return await (options.fetch ?? globalThis.fetch)(input, init);
|
|
92
|
+
} catch {
|
|
93
|
+
throw classify(timeout);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const consume = async (
|
|
98
|
+
c: Conn,
|
|
99
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
100
|
+
heartbeat: AbortController,
|
|
101
|
+
dead: () => boolean,
|
|
102
|
+
): Promise<"stop" | "reattach" | "ended"> => {
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
const parse = createSseParser();
|
|
105
|
+
for (;;) {
|
|
106
|
+
const timer = setTimeout(
|
|
107
|
+
() => heartbeat.abort(),
|
|
108
|
+
timing.heartbeatTimeoutMs,
|
|
109
|
+
);
|
|
110
|
+
let result: ReadableStreamReadResult<Uint8Array>;
|
|
111
|
+
try {
|
|
112
|
+
result = await reader.read();
|
|
113
|
+
} catch {
|
|
114
|
+
throw classify(heartbeat.signal);
|
|
115
|
+
} finally {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
if (result.done) return "ended";
|
|
119
|
+
for (const data of parse(decoder.decode(result.value, { stream: true }))) {
|
|
120
|
+
if (dead()) return "ended";
|
|
121
|
+
const env = JSON.parse(data) as StatewireEnvelope;
|
|
122
|
+
const done = flushTapSync(() => handleEnvelope(c, env));
|
|
123
|
+
if (done) return done;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const attemptStream = async (
|
|
129
|
+
c: Conn,
|
|
130
|
+
options: StatewireClient.SSEOptions,
|
|
131
|
+
abort: AbortController,
|
|
132
|
+
): Promise<"stop" | "reattach" | "ended"> => {
|
|
133
|
+
const headers = await requestHeaders(options, {
|
|
134
|
+
"Statewire-Client-Id": c.ctx.clientId,
|
|
135
|
+
accept: "text/event-stream",
|
|
136
|
+
});
|
|
137
|
+
const heartbeat = new AbortController();
|
|
138
|
+
const headerTimer = setTimeout(
|
|
139
|
+
() => heartbeat.abort(),
|
|
140
|
+
timing.heartbeatTimeoutMs,
|
|
141
|
+
);
|
|
142
|
+
let res: Response;
|
|
143
|
+
try {
|
|
144
|
+
res = await timedFetch(
|
|
145
|
+
options,
|
|
146
|
+
`${baseUrl(options)}/stream`,
|
|
147
|
+
{
|
|
148
|
+
headers,
|
|
149
|
+
cache: "no-store",
|
|
150
|
+
signal: AbortSignal.any([abort.signal, heartbeat.signal]),
|
|
151
|
+
},
|
|
152
|
+
heartbeat.signal,
|
|
153
|
+
);
|
|
154
|
+
} finally {
|
|
155
|
+
clearTimeout(headerTimer);
|
|
156
|
+
}
|
|
157
|
+
if (res.status === 429 || res.status === 503) throw overloaded(res);
|
|
158
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
159
|
+
const reader = res.body.getReader();
|
|
160
|
+
try {
|
|
161
|
+
return await consume(c, reader, heartbeat, () => abort.signal.aborted);
|
|
162
|
+
} finally {
|
|
163
|
+
reader.cancel().catch(() => {});
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const overloaded = (res: Response) =>
|
|
168
|
+
overloadedError(parseRetryAfter(res.headers.get("Retry-After")));
|
|
169
|
+
|
|
170
|
+
const sendCommand = async (
|
|
171
|
+
c: Conn,
|
|
172
|
+
options: StatewireClient.SSEOptions,
|
|
173
|
+
{ seq, command }: { seq: number; command: StatewireWireCommand },
|
|
174
|
+
): Promise<StatewireSubmitResult> => {
|
|
175
|
+
const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);
|
|
176
|
+
const res = await timedFetch(
|
|
177
|
+
options,
|
|
178
|
+
`${baseUrl(options)}/commands`,
|
|
179
|
+
{
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: await requestHeaders(options, {
|
|
182
|
+
"content-type": "application/json",
|
|
183
|
+
"Statewire-Client-Id": c.ctx.clientId,
|
|
184
|
+
"Statewire-Command-Seq": String(seq),
|
|
185
|
+
...(c.lease !== null && { "Statewire-Lease": c.lease }),
|
|
186
|
+
}),
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
method: command.method,
|
|
189
|
+
...encodeParams(command.params),
|
|
190
|
+
}),
|
|
191
|
+
signal: AbortSignal.any([
|
|
192
|
+
c.lifetime?.signal ?? AbortSignal.abort(),
|
|
193
|
+
timeout,
|
|
194
|
+
]),
|
|
195
|
+
},
|
|
196
|
+
timeout,
|
|
197
|
+
);
|
|
198
|
+
if (res.ok) {
|
|
199
|
+
let body: { res?: StatewireResponse } | null;
|
|
200
|
+
try {
|
|
201
|
+
body = (await res.json()) as { res?: StatewireResponse } | null;
|
|
202
|
+
} catch {
|
|
203
|
+
return {
|
|
204
|
+
type: "rejected",
|
|
205
|
+
message: "statewire: protocol error: malformed /commands receipt body",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return body?.res !== undefined
|
|
209
|
+
? { type: "receipt", response: reviveResponse(body.res) }
|
|
210
|
+
: { type: "receipt" };
|
|
211
|
+
}
|
|
212
|
+
if (res.status === 409 || res.status === 412 || res.status === 423) {
|
|
213
|
+
// Only a body naming the same verdict as the status is statewire's own;
|
|
214
|
+
// a bare status is middleware noise — a transport fault, retried.
|
|
215
|
+
const verdict =
|
|
216
|
+
res.status === 409
|
|
217
|
+
? ("seq-gap" as const)
|
|
218
|
+
: res.status === 412
|
|
219
|
+
? ("unknown-client" as const)
|
|
220
|
+
: ("stale-lease" as const);
|
|
221
|
+
const body = (await res.json().catch(() => null)) as {
|
|
222
|
+
error?: string;
|
|
223
|
+
} | null;
|
|
224
|
+
if (body?.error === verdict) return { type: verdict };
|
|
225
|
+
throw new Error(`HTTP ${res.status}`);
|
|
226
|
+
}
|
|
227
|
+
if (res.status === 429 || res.status === 503) throw overloaded(res);
|
|
228
|
+
if (res.status >= 400 && res.status < 500) {
|
|
229
|
+
return { type: "rejected", message: `HTTP ${res.status}` };
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`HTTP ${res.status}`);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const useSseLink = (
|
|
235
|
+
options: StatewireClient.SSEOptions,
|
|
236
|
+
ctx: StatewireRemoteProps,
|
|
237
|
+
): RemoteLink => {
|
|
238
|
+
const latest = useRef(options);
|
|
239
|
+
latest.current = options;
|
|
240
|
+
const c = useStatewireConn({
|
|
241
|
+
url: options.url,
|
|
242
|
+
onChange: options.onChange,
|
|
243
|
+
ctx,
|
|
244
|
+
attemptOnce: (c, abort) => attemptStream(c, options, abort),
|
|
245
|
+
});
|
|
246
|
+
const [link] = useState(
|
|
247
|
+
(): RemoteLink => ({
|
|
248
|
+
get connection() {
|
|
249
|
+
return c.connection;
|
|
250
|
+
},
|
|
251
|
+
send: (args) => sendCommand(c, latest.current, args),
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
return link;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const useStatewireSSE = (
|
|
258
|
+
options: StatewireClient.SSEOptions,
|
|
259
|
+
): Statewire<any, any, any> => useStatewireRemote(options, useSseLink);
|
|
260
|
+
|
|
261
|
+
export const StatewireSSE = resource(useStatewireSSE) as <
|
|
262
|
+
TState = any,
|
|
263
|
+
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
264
|
+
TEvent = any,
|
|
265
|
+
>(
|
|
266
|
+
options: StatewireClient.SSEOptions,
|
|
267
|
+
) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;
|