statewire 0.0.0-0 → 0.0.1

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -1
  3. package/dist/client/changes.d.ts +7 -0
  4. package/dist/client/changes.d.ts.map +1 -0
  5. package/dist/client/changes.js +32 -0
  6. package/dist/client/changes.js.map +1 -0
  7. package/dist/client/connection.d.ts +84 -0
  8. package/dist/client/connection.d.ts.map +1 -0
  9. package/dist/client/connection.js +204 -0
  10. package/dist/client/connection.js.map +1 -0
  11. package/dist/client/core.d.ts +18 -0
  12. package/dist/client/core.d.ts.map +1 -0
  13. package/dist/client/core.js +269 -0
  14. package/dist/client/core.js.map +1 -0
  15. package/dist/client/optimistic.d.ts +10 -0
  16. package/dist/client/optimistic.d.ts.map +1 -0
  17. package/dist/client/optimistic.js +128 -0
  18. package/dist/client/optimistic.js.map +1 -0
  19. package/dist/client/session.d.ts +88 -0
  20. package/dist/client/session.d.ts.map +1 -0
  21. package/dist/client/session.js +509 -0
  22. package/dist/client/session.js.map +1 -0
  23. package/dist/client/sessionStore.d.ts +16 -0
  24. package/dist/client/sessionStore.d.ts.map +1 -0
  25. package/dist/client/sessionStore.js +14 -0
  26. package/dist/client/sessionStore.js.map +1 -0
  27. package/dist/client/timing.d.ts +10 -0
  28. package/dist/client/timing.d.ts.map +1 -0
  29. package/dist/client/timing.js +11 -0
  30. package/dist/client/timing.js.map +1 -0
  31. package/dist/host/draft.d.ts +14 -0
  32. package/dist/host/draft.d.ts.map +1 -0
  33. package/dist/host/draft.js +124 -0
  34. package/dist/host/draft.js.map +1 -0
  35. package/dist/host/engine.d.ts +14 -0
  36. package/dist/host/engine.d.ts.map +1 -0
  37. package/dist/host/engine.js +17 -0
  38. package/dist/host/engine.js.map +1 -0
  39. package/dist/host/host.d.ts +79 -0
  40. package/dist/host/host.d.ts.map +1 -0
  41. package/dist/host/host.js +303 -0
  42. package/dist/host/host.js.map +1 -0
  43. package/dist/index.d.ts +28 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +6 -0
  46. package/dist/server.d.ts +15 -0
  47. package/dist/server.d.ts.map +1 -0
  48. package/dist/server.js +3 -0
  49. package/dist/statewire.d.ts +20 -0
  50. package/dist/statewire.d.ts.map +1 -0
  51. package/dist/statewire.js +65 -0
  52. package/dist/statewire.js.map +1 -0
  53. package/dist/transports/remote.d.ts +19 -0
  54. package/dist/transports/remote.d.ts.map +1 -0
  55. package/dist/transports/remote.js +40 -0
  56. package/dist/transports/remote.js.map +1 -0
  57. package/dist/transports/sse-parser.d.ts +5 -0
  58. package/dist/transports/sse-parser.d.ts.map +1 -0
  59. package/dist/transports/sse-parser.js +26 -0
  60. package/dist/transports/sse-parser.js.map +1 -0
  61. package/dist/transports/sse.d.ts +74 -0
  62. package/dist/transports/sse.d.ts.map +1 -0
  63. package/dist/transports/sse.js +136 -0
  64. package/dist/transports/sse.js.map +1 -0
  65. package/dist/transports/testing.d.ts +31 -0
  66. package/dist/transports/testing.d.ts.map +1 -0
  67. package/dist/transports/testing.js +121 -0
  68. package/dist/transports/testing.js.map +1 -0
  69. package/dist/transports/ws.d.ts +18 -0
  70. package/dist/transports/ws.d.ts.map +1 -0
  71. package/dist/transports/ws.js +172 -0
  72. package/dist/transports/ws.js.map +1 -0
  73. package/dist/types.d.ts +107 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/types.js +24 -0
  76. package/dist/types.js.map +1 -0
  77. package/dist/wire/meta.d.ts +22 -0
  78. package/dist/wire/meta.d.ts.map +1 -0
  79. package/dist/wire/meta.js +73 -0
  80. package/dist/wire/meta.js.map +1 -0
  81. package/dist/wire/ops.d.ts +40 -0
  82. package/dist/wire/ops.d.ts.map +1 -0
  83. package/dist/wire/ops.js +125 -0
  84. package/dist/wire/ops.js.map +1 -0
  85. package/package.json +49 -6
  86. package/src/client/changes.ts +48 -0
  87. package/src/client/connection.ts +327 -0
  88. package/src/client/core.ts +346 -0
  89. package/src/client/optimistic.ts +0 -0
  90. package/src/client/session.ts +679 -0
  91. package/src/client/sessionStore.ts +23 -0
  92. package/src/client/timing.ts +8 -0
  93. package/src/host/draft.ts +155 -0
  94. package/src/host/engine.ts +13 -0
  95. package/src/host/host.ts +481 -0
  96. package/src/index.ts +65 -0
  97. package/src/server.ts +32 -0
  98. package/src/statewire.ts +107 -0
  99. package/src/transports/remote.ts +72 -0
  100. package/src/transports/sse-parser.ts +24 -0
  101. package/src/transports/sse.ts +280 -0
  102. package/src/transports/testing.ts +155 -0
  103. package/src/transports/ws.ts +272 -0
  104. package/src/types.ts +169 -0
  105. package/src/wire/meta.ts +106 -0
  106. package/src/wire/ops.ts +201 -0
  107. package/index.js +0 -1
package/src/server.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type {
2
+ CommandExecution,
3
+ CommandExecutionResolvers,
4
+ StatewireHandledCommands,
5
+ StatewireStateCtl,
6
+ } from "./host/host";
7
+ import type {
8
+ StatewireCommands,
9
+ StatewireStateSource,
10
+ StatewireTransport,
11
+ } from "./types";
12
+
13
+ export { StatewireReject } from "./host/engine";
14
+ export {
15
+ CommandExecution,
16
+ useStatewireState,
17
+ useStatewireCommands,
18
+ useStatewireEmit,
19
+ } from "./host/host";
20
+ export namespace StatewireServer {
21
+ export type StateCtl = StatewireStateCtl;
22
+ export type StateSource = StatewireStateSource;
23
+ export type Execution<T = void> = CommandExecution<T>;
24
+ export type ExecutionResolvers<T = void> = CommandExecutionResolvers<T>;
25
+ export type HandledCommands<THandlers extends StatewireCommands> =
26
+ StatewireHandledCommands<THandlers>;
27
+ export type Transport<
28
+ TState = any,
29
+ TCommands extends StatewireCommands = StatewireCommands,
30
+ TEvent = any,
31
+ > = StatewireTransport<TState, TCommands, TEvent>;
32
+ }
@@ -0,0 +1,107 @@
1
+ import { useEffect, useRef, useState } 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 { createStatewireCore, LIVE_CONNECTION } from "./client/core";
10
+ import type { StatewireTransport } from "./types";
11
+ import type {
12
+ StatewireCommands,
13
+ StatewireCommandsProxy,
14
+ StatewireHelpers,
15
+ StatewireOptions,
16
+ } from "./types";
17
+ import type { StatewireConnection } from "./transports/sse";
18
+
19
+ export const useStatewire = <
20
+ TState,
21
+ TCommands extends StatewireCommands = StatewireCommands,
22
+ TEvent = any,
23
+ >(
24
+ options: StatewireOptions<TState, TCommands, TEvent>,
25
+ ): StatewireHelpers<TState, TCommands> => {
26
+ const latest = useRef(options);
27
+ latest.current = options;
28
+ const [, setVersion] = useState(0);
29
+ const [core] = useState(() => createStatewireCore(() => latest.current));
30
+
31
+ const value = useResource(options.transport) as StatewireTransport<unknown>;
32
+ core.attach(value);
33
+
34
+ useEffect(() => {
35
+ core.attach(value);
36
+ });
37
+ useEffect(() => {
38
+ const unsubscribe = core.subscribe(() =>
39
+ flushTapSync(() => setVersion((v) => v + 1)),
40
+ );
41
+ return () => {
42
+ unsubscribe();
43
+ core.detach();
44
+ };
45
+ }, [core]);
46
+
47
+ return {
48
+ state: core.getState() as TState,
49
+ connection: value.connection ?? LIVE_CONNECTION,
50
+ commands: core.commands as StatewireCommandsProxy<TCommands>,
51
+ };
52
+ };
53
+
54
+ export const StatewireResource = <
55
+ TState,
56
+ TCommands extends StatewireCommands = StatewireCommands,
57
+ TEvent = any,
58
+ >(
59
+ options: StatewireOptions<TState, TCommands, TEvent>,
60
+ ): ResourceElement<StatewireHelpers<TState, TCommands>> =>
61
+ resource(useStatewire<TState, TCommands, TEvent>)(options);
62
+
63
+ export class Statewire<
64
+ TState,
65
+ TCommands extends StatewireCommands = StatewireCommands,
66
+ TEvent = any,
67
+ > {
68
+ private readonly core;
69
+ private readonly root;
70
+ private disposed = false;
71
+
72
+ constructor(options: StatewireOptions<TState, TCommands, TEvent>) {
73
+ this.core = createStatewireCore(() => options);
74
+ const core = this.core;
75
+ this.root = createTapRoot(() => {
76
+ const value = useResource(
77
+ options.transport,
78
+ ) as StatewireTransport<unknown>;
79
+ core.attach(value);
80
+ return value;
81
+ });
82
+ flushTapSync(() => {});
83
+ }
84
+
85
+ get state(): TState {
86
+ return this.core.getState() as TState;
87
+ }
88
+
89
+ get connection(): StatewireConnection {
90
+ return this.root.getValue().connection ?? LIVE_CONNECTION;
91
+ }
92
+
93
+ get commands(): StatewireCommandsProxy<TCommands> {
94
+ return this.core.commands as StatewireCommandsProxy<TCommands>;
95
+ }
96
+
97
+ subscribe(listener: () => void): () => void {
98
+ return this.core.subscribe(listener);
99
+ }
100
+
101
+ dispose(): void {
102
+ if (this.disposed) return;
103
+ this.disposed = true;
104
+ this.root.unmount();
105
+ this.core.dispose();
106
+ }
107
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useStatewireConn } from "../client/connection";
3
+ import type { ConnBase } from "../client/connection";
4
+ import { useStatewireEmit } from "../host/host";
5
+ import type { StatewireTransport } from "../types";
6
+ import {
7
+ createSession,
8
+ handleFinish,
9
+ handleOps,
10
+ handleSnapshot,
11
+ setTransport,
12
+ start,
13
+ stop,
14
+ } from "../client/session";
15
+ import type { SessionOptions } from "../client/session";
16
+ import type {
17
+ RemoteLink,
18
+ StatewireConnection,
19
+ StatewireTransportProps,
20
+ } from "./sse";
21
+
22
+ export const useStatewireRemote = <
23
+ TOptions extends {
24
+ url: string;
25
+ onChange?: (connection: StatewireConnection) => void;
26
+ } & SessionOptions,
27
+ >(
28
+ options: TOptions,
29
+ create: (
30
+ latest: {
31
+ readonly current: { options: TOptions; ctx: StatewireTransportProps };
32
+ },
33
+ notify: (connection: StatewireConnection) => void,
34
+ ) => ConnBase & { send: RemoteLink["send"] },
35
+ ): StatewireTransport<any, any, any> => {
36
+ const emit = useStatewireEmit<any>();
37
+ const [s] = useState(() =>
38
+ createSession({ sessionStore: options.sessionStore }),
39
+ );
40
+
41
+ useEffect(() => {
42
+ s.emitEvent = emit;
43
+ return () => {
44
+ s.emitEvent = () => {};
45
+ };
46
+ }, [s, emit]);
47
+ useEffect(() => {
48
+ start(s);
49
+ return () => stop(s);
50
+ }, [s]);
51
+
52
+ const [ctx] = useState(
53
+ (): StatewireTransportProps => ({
54
+ get clientId() {
55
+ return s.record.clientId;
56
+ },
57
+ onSnapshot: (args) => handleSnapshot(s, args),
58
+ onOps: (args) => handleOps(s, args),
59
+ onFinish: (args) => handleFinish(s, args),
60
+ }),
61
+ );
62
+ const conn = useStatewireConn(options, ctx, create);
63
+ useEffect(() => setTransport(s, conn), [s, conn]);
64
+
65
+ return {
66
+ state: s.canonical,
67
+ commands: s.commands,
68
+ emit,
69
+ connection: conn.connection,
70
+ source: s.source,
71
+ };
72
+ };
@@ -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,280 @@
1
+ import { flushTapSync, resource } from "@assistant-ui/tap";
2
+ import type { ResourceElement } from "@assistant-ui/tap";
3
+ import { encodeParams, reviveResponse } from "../wire/meta";
4
+ import { createSseParser } from "./sse-parser";
5
+ import { timing } from "../client/timing";
6
+ import {
7
+ classify,
8
+ createGate,
9
+ handleEnvelope,
10
+ overloadedError,
11
+ parseRetryAfter,
12
+ reconnectConn,
13
+ } from "../client/connection";
14
+ import type {
15
+ ConnBase,
16
+ StatewireEnvelope,
17
+ StatewireFinish,
18
+ StatewireResponse,
19
+ } from "../client/connection";
20
+ import type { StatewireTransport } from "../types";
21
+ import { useStatewireRemote } from "./remote";
22
+ import type { StatewireSessionStore } from "../client/sessionStore";
23
+ import type { StatewireCommands } from "../types";
24
+
25
+ export type StatewireTransportProps = {
26
+ clientId: string;
27
+ onSnapshot: (args: { ops: unknown[]; lastSeq: number }) => void;
28
+ onOps: (args: {
29
+ ops: unknown[];
30
+ res?: StatewireResponse[];
31
+ ack?: number;
32
+ }) => void;
33
+ onFinish: (args: StatewireFinish) => void;
34
+ };
35
+
36
+ export type StatewireWireCommand = {
37
+ method: string;
38
+ params: readonly unknown[];
39
+ };
40
+
41
+ export type StatewireSubmitResult =
42
+ | { type: "receipt"; response?: StatewireResponse }
43
+ | { type: "rejected"; message: string; payload?: unknown }
44
+ | { type: "seq-gap" }
45
+ | { type: "unknown-client" }
46
+ | { type: "stale-lease" };
47
+
48
+ export type ConnectionState =
49
+ | { status: "connecting" }
50
+ | { status: "live" }
51
+ | {
52
+ status: "reconnecting";
53
+ cause: "dropped" | "ended" | "error";
54
+ attempt: number;
55
+ nextRetryAt: number;
56
+ lastError?: string;
57
+ message?: string;
58
+ }
59
+ | { status: "gone"; message?: string; payload?: unknown };
60
+
61
+ export type StatewireConnection = ConnectionState & { reconnect: () => void };
62
+
63
+ /** The live wire a remote session drives: connection status plus command POST. */
64
+ export type RemoteLink = {
65
+ connection: StatewireConnection;
66
+ send: (args: {
67
+ seq: number;
68
+ command: StatewireWireCommand;
69
+ }) => Promise<StatewireSubmitResult>;
70
+ };
71
+
72
+ export type StatewireSSEOptions = {
73
+ url: string;
74
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
75
+ fetch?: typeof globalThis.fetch;
76
+ onChange?: (connection: StatewireConnection) => void;
77
+ sessionStore?: StatewireSessionStore;
78
+ };
79
+
80
+ type Latest = {
81
+ options: StatewireSSEOptions;
82
+ ctx: StatewireTransportProps;
83
+ };
84
+
85
+ type Conn = ConnBase & {
86
+ latest: { readonly current: Latest };
87
+ send: RemoteLink["send"];
88
+ };
89
+
90
+ const createConn = (
91
+ latest: { readonly current: Latest },
92
+ notify: (connection: StatewireConnection) => void,
93
+ ): Conn => {
94
+ const c: Conn = {
95
+ hasSnapshot: false,
96
+ lease: null,
97
+ attempt: 0,
98
+ episode: null,
99
+ abort: null,
100
+ gate: createGate(),
101
+ lifetime: null,
102
+ latest,
103
+ notify,
104
+ reconnect: () => reconnectConn(c),
105
+ attemptOnce: (abort) => attemptStream(c, abort),
106
+ send: (args) => sendCommand(c, args),
107
+ connection: { status: "connecting", reconnect: () => c.reconnect() },
108
+ };
109
+ return c;
110
+ };
111
+
112
+ const baseUrl = (c: Conn) => c.latest.current.options.url.replace(/\/$/, "");
113
+
114
+ const requestHeaders = async (c: Conn, extra: Record<string, string>) => {
115
+ const { headers } = c.latest.current.options;
116
+ const merged = new Headers(
117
+ typeof headers === "function" ? await headers() : headers,
118
+ );
119
+ for (const [name, value] of Object.entries(extra)) merged.set(name, value);
120
+ return merged;
121
+ };
122
+
123
+ const timedFetch = async (
124
+ c: Conn,
125
+ input: string,
126
+ init: RequestInit & { signal: AbortSignal },
127
+ timeout: AbortSignal,
128
+ ): Promise<Response> => {
129
+ try {
130
+ return await (c.latest.current.options.fetch ?? globalThis.fetch)(
131
+ input,
132
+ init,
133
+ );
134
+ } catch {
135
+ throw classify(timeout);
136
+ }
137
+ };
138
+
139
+ const consume = async (
140
+ c: Conn,
141
+ reader: ReadableStreamDefaultReader<Uint8Array>,
142
+ heartbeat: AbortController,
143
+ dead: () => boolean,
144
+ ): Promise<"stop" | "reattach" | "ended"> => {
145
+ const decoder = new TextDecoder();
146
+ const parse = createSseParser();
147
+ let attached = false;
148
+ for (;;) {
149
+ const timer = setTimeout(
150
+ () => heartbeat.abort(),
151
+ timing.heartbeatTimeoutMs,
152
+ );
153
+ let result: ReadableStreamReadResult<Uint8Array>;
154
+ try {
155
+ result = await reader.read();
156
+ } catch {
157
+ throw classify(heartbeat.signal);
158
+ } finally {
159
+ clearTimeout(timer);
160
+ }
161
+ if (result.done) return "ended";
162
+ for (const data of parse(decoder.decode(result.value, { stream: true }))) {
163
+ if (dead()) return "ended";
164
+ const env = JSON.parse(data) as StatewireEnvelope;
165
+ const done = flushTapSync(() => handleEnvelope(c, env, attached));
166
+ attached = true;
167
+ if (done) return done;
168
+ }
169
+ }
170
+ };
171
+
172
+ const attemptStream = async (
173
+ c: Conn,
174
+ abort: AbortController,
175
+ ): Promise<"stop" | "reattach" | "ended"> => {
176
+ const headers = await requestHeaders(c, {
177
+ "Statewire-Client-Id": c.latest.current.ctx.clientId,
178
+ accept: "text/event-stream",
179
+ });
180
+ const heartbeat = new AbortController();
181
+ const headerTimer = setTimeout(
182
+ () => heartbeat.abort(),
183
+ timing.heartbeatTimeoutMs,
184
+ );
185
+ let res: Response;
186
+ try {
187
+ res = await timedFetch(
188
+ c,
189
+ `${baseUrl(c)}/stream`,
190
+ {
191
+ headers,
192
+ cache: "no-store",
193
+ signal: AbortSignal.any([abort.signal, heartbeat.signal]),
194
+ },
195
+ heartbeat.signal,
196
+ );
197
+ } finally {
198
+ clearTimeout(headerTimer);
199
+ }
200
+ if (res.status === 429 || res.status === 503) throw overloaded(res);
201
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
202
+ const reader = res.body.getReader();
203
+ try {
204
+ return await consume(c, reader, heartbeat, () => abort.signal.aborted);
205
+ } finally {
206
+ reader.cancel().catch(() => {});
207
+ }
208
+ };
209
+
210
+ const overloaded = (res: Response) =>
211
+ overloadedError(parseRetryAfter(res.headers.get("Retry-After")));
212
+
213
+ const sendCommand = async (
214
+ c: Conn,
215
+ { seq, command }: { seq: number; command: StatewireWireCommand },
216
+ ): Promise<StatewireSubmitResult> => {
217
+ const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);
218
+ const res = await timedFetch(
219
+ c,
220
+ `${baseUrl(c)}/commands`,
221
+ {
222
+ method: "POST",
223
+ headers: await requestHeaders(c, {
224
+ "content-type": "application/json",
225
+ "Statewire-Client-Id": c.latest.current.ctx.clientId,
226
+ "Statewire-Command-Seq": String(seq),
227
+ ...(c.lease !== null && { "Statewire-Lease": c.lease }),
228
+ }),
229
+ body: JSON.stringify({
230
+ method: command.method,
231
+ ...encodeParams(command.params),
232
+ }),
233
+ signal: AbortSignal.any([
234
+ c.lifetime?.signal ?? AbortSignal.abort(),
235
+ timeout,
236
+ ]),
237
+ },
238
+ timeout,
239
+ );
240
+ if (res.ok) {
241
+ const body = (await res.json().catch(() => null)) as {
242
+ res?: StatewireResponse;
243
+ } | null;
244
+ return body?.res !== undefined
245
+ ? { type: "receipt", response: reviveResponse(body.res) }
246
+ : { type: "receipt" };
247
+ }
248
+ if (res.status === 409 || res.status === 412 || res.status === 423) {
249
+ // Only a body naming the same verdict as the status is statewire's own;
250
+ // a bare status is middleware noise — a transport fault, retried.
251
+ const verdict =
252
+ res.status === 409
253
+ ? ("seq-gap" as const)
254
+ : res.status === 412
255
+ ? ("unknown-client" as const)
256
+ : ("stale-lease" as const);
257
+ const body = (await res.json().catch(() => null)) as {
258
+ error?: string;
259
+ } | null;
260
+ if (body?.error === verdict) return { type: verdict };
261
+ throw new Error(`HTTP ${res.status}`);
262
+ }
263
+ if (res.status === 429 || res.status === 503) throw overloaded(res);
264
+ if (res.status >= 400 && res.status < 500) {
265
+ return { type: "rejected", message: `HTTP ${res.status}` };
266
+ }
267
+ throw new Error(`HTTP ${res.status}`);
268
+ };
269
+
270
+ const useStatewireSSE = (
271
+ options: StatewireSSEOptions,
272
+ ): StatewireTransport<any, any, any> => useStatewireRemote(options, createConn);
273
+
274
+ export const StatewireSSE = resource(useStatewireSSE) as <
275
+ TState = any,
276
+ TCommands extends StatewireCommands = StatewireCommands,
277
+ TEvent = any,
278
+ >(
279
+ options: StatewireSSEOptions,
280
+ ) => ResourceElement<StatewireTransport<TState | undefined, TCommands, TEvent>>;
@@ -0,0 +1,155 @@
1
+ import type { ResourceElement } from "@assistant-ui/tap";
2
+ import type { StatewireEnvelope } from "../client/connection";
3
+ import { recordMutations } from "../client/optimistic";
4
+ import { StatewireSSE } from "./sse";
5
+ import type { StatewireTransport } from "../types";
6
+
7
+ /** Let queued microtasks and op batches drain through the statewire client. */
8
+ export const tick = () => new Promise<void>((r) => setTimeout(r, 0));
9
+
10
+ /** Run a mutator against a recording draft, as the client does for `optimistic`. */
11
+ export const applyOptimistic = <TState>(
12
+ state: TState,
13
+ mutate: (draft: TState) => void,
14
+ ): TState => recordMutations(state, mutate).state;
15
+
16
+ type StreamHandle = {
17
+ clientId: string;
18
+ send: (text: string) => void;
19
+ close: () => void;
20
+ };
21
+
22
+ export type MockTransport<TState> = {
23
+ /** Pass as the `transport:` option of useStatewire / Harness. */
24
+ transport: ResourceElement<StatewireTransport<TState | undefined, any, any>>;
25
+ /** Captured `{ method, params }` command bodies, in POST order. */
26
+ commands: { method: string; params: unknown[] }[];
27
+ /** Replace the replicated state and broadcast it as a root snapshot op. */
28
+ setState: (state: TState) => Promise<void>;
29
+ /** Deliver an ack watermark on the stream. */
30
+ ack: (seq: number) => Promise<void>;
31
+ /** Deliver an arbitrary envelope on the stream. */
32
+ sendEnvelope: (envelope: StatewireEnvelope) => Promise<void>;
33
+ /** End the stream with a fin; further attaches fail like a dead server. */
34
+ finish: (reason: string, message?: string) => Promise<void>;
35
+ readonly clientId: string;
36
+ readonly attached: boolean;
37
+ };
38
+
39
+ /**
40
+ * A mock statewire backend: serves `initialState` as the attach snapshot,
41
+ * records command POSTs (receipt only — acks and results are delivered from
42
+ * test code), and exposes the wire as a transport ResourceElement.
43
+ */
44
+ let mockCount = 0;
45
+
46
+ export const createMockTransport = <TState>(
47
+ initialState: TState,
48
+ ): MockTransport<TState> => {
49
+ let state: unknown = initialState;
50
+ let clientId = "";
51
+ let finished = false;
52
+ const clients = new Map<string, number>();
53
+ const streams = new Set<StreamHandle>();
54
+ const commands: { method: string; params: unknown[] }[] = [];
55
+
56
+ const sendEnvelope = (envelope: StatewireEnvelope) => {
57
+ for (const stream of streams) {
58
+ stream.send(`data: ${JSON.stringify(envelope)}\n\n`);
59
+ }
60
+ return tick();
61
+ };
62
+
63
+ const handleStream = (id: string, signal: AbortSignal | undefined) => {
64
+ clientId = id;
65
+ const lastSeq = clients.get(id) ?? 0;
66
+ clients.set(id, lastSeq);
67
+ const encoder = new TextEncoder();
68
+ let handle: StreamHandle;
69
+ const body = new ReadableStream<Uint8Array>({
70
+ start: (controller) => {
71
+ let open = true;
72
+ handle = {
73
+ clientId: id,
74
+ send: (text) => {
75
+ if (open) controller.enqueue(encoder.encode(text));
76
+ },
77
+ close: () => {
78
+ if (!open) return;
79
+ open = false;
80
+ streams.delete(handle);
81
+ controller.close();
82
+ },
83
+ };
84
+ streams.add(handle);
85
+ handle.send(
86
+ `data: ${JSON.stringify({
87
+ ops: [{ op: "replace", path: [], value: state }],
88
+ syn: { lastSeq, lease: "mock-lease" },
89
+ })}\n\n`,
90
+ );
91
+ },
92
+ cancel: () => {
93
+ streams.delete(handle);
94
+ },
95
+ });
96
+ signal?.addEventListener("abort", () => {
97
+ for (const stream of [...streams]) {
98
+ if (stream.clientId === id) stream.close();
99
+ }
100
+ });
101
+ return new Response(body, {
102
+ status: 200,
103
+ headers: { "content-type": "text/event-stream" },
104
+ });
105
+ };
106
+
107
+ const fakeFetch = (async (
108
+ input: RequestInfo | URL,
109
+ init?: RequestInit,
110
+ ): Promise<Response> => {
111
+ if (finished) throw new TypeError("fetch failed (finished)");
112
+ const url = String(input);
113
+ const headers = new Headers(init?.headers);
114
+ const id = headers.get("Statewire-Client-Id") ?? "";
115
+ if (url.endsWith("/stream"))
116
+ return handleStream(id, init?.signal ?? undefined);
117
+ if (url.endsWith("/commands")) {
118
+ const seq = Number(headers.get("Statewire-Command-Seq"));
119
+ clients.set(id, Math.max(clients.get(id) ?? 0, seq));
120
+ commands.push(
121
+ JSON.parse(String(init?.body)) as { method: string; params: unknown[] },
122
+ );
123
+ return Response.json({});
124
+ }
125
+ return new Response(null, { status: 404 });
126
+ }) as typeof fetch;
127
+
128
+ return {
129
+ transport: StatewireSSE({
130
+ url: `http://mock-${++mockCount}/api`,
131
+ fetch: fakeFetch,
132
+ }),
133
+ commands,
134
+ setState: (next) => {
135
+ state = next;
136
+ return sendEnvelope({ ops: [{ op: "replace", path: [], value: next }] });
137
+ },
138
+ ack: (seq) => sendEnvelope({ ack: seq }),
139
+ sendEnvelope,
140
+ finish: async (reason, message) => {
141
+ await sendEnvelope({
142
+ fin: { reason, ...(message !== undefined && { message }) },
143
+ });
144
+ finished = true;
145
+ for (const stream of [...streams]) stream.close();
146
+ await tick();
147
+ },
148
+ get clientId() {
149
+ return clientId;
150
+ },
151
+ get attached() {
152
+ return streams.size > 0;
153
+ },
154
+ };
155
+ };