statewire 0.8.1 → 0.9.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 +2 -2
- package/dist/client/connection.d.ts +23 -19
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +143 -103
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +30 -3
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +222 -260
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +34 -39
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +380 -373
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +1 -0
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/client/timing.d.ts +1 -2
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +1 -5
- package/dist/client/timing.js.map +1 -1
- package/dist/host/draft.d.ts +2 -5
- package/dist/host/draft.d.ts.map +1 -1
- package/dist/host/draft.js.map +1 -1
- package/dist/host/host.d.ts +5 -8
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +23 -32
- package/dist/host/host.js.map +1 -1
- package/dist/host/socket.d.ts +3 -2
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +484 -424
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +4 -0
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +10 -10
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/statewire.d.ts +29 -24
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +8 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fake-sse.d.ts +1 -1
- package/dist/transports/fake-sse.d.ts.map +1 -1
- package/dist/transports/fake-sse.js +5 -2
- package/dist/transports/fake-sse.js.map +1 -1
- package/dist/transports/fixtures.d.ts +1 -2
- package/dist/transports/fixtures.d.ts.map +1 -1
- package/dist/transports/fixtures.js +5 -7
- package/dist/transports/fixtures.js.map +1 -1
- package/dist/transports/remote.d.ts +2 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +10 -4
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +32 -46
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +56 -59
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +7 -5
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +41 -14
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +2 -3
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +25 -80
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +8 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -4
- package/dist/types.js.map +1 -1
- package/dist/wire/ops.d.ts +1 -5
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -4
- package/dist/wire/ops.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +175 -128
- package/src/client/core.ts +287 -278
- package/src/client/session.ts +474 -480
- package/src/client/sessionStore.ts +1 -0
- package/src/client/timing.ts +0 -6
- package/src/host/draft.ts +1 -3
- package/src/host/host.ts +41 -58
- package/src/host/socket.ts +653 -448
- package/src/host-internal.ts +4 -0
- package/src/host.ts +13 -10
- package/src/index.ts +0 -1
- package/src/statewire.ts +38 -32
- package/src/transports/fake-sse.ts +2 -1
- package/src/transports/fixtures.ts +4 -9
- package/src/transports/remote.ts +12 -5
- package/src/transports/sse.ts +99 -115
- package/src/transports/testing.ts +47 -19
- package/src/transports/ws.ts +44 -115
- package/src/types.ts +15 -14
- package/src/wire/ops.ts +9 -16
package/src/transports/sse.ts
CHANGED
|
@@ -1,88 +1,77 @@
|
|
|
1
1
|
import { useRef, useState } from "react";
|
|
2
2
|
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
3
3
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
4
|
-
import { encodeParams, reviveResponse } from "../wire/meta";
|
|
5
4
|
import { createSseParser } from "./sse-parser";
|
|
6
5
|
import { timing } from "../client/timing";
|
|
7
6
|
import {
|
|
8
7
|
classify,
|
|
9
8
|
handleEnvelope,
|
|
10
|
-
overloadedError,
|
|
11
|
-
parseRetryAfter,
|
|
12
9
|
useStatewireConn,
|
|
13
10
|
} from "../client/connection";
|
|
14
11
|
import type {
|
|
12
|
+
AttemptOutcome,
|
|
15
13
|
Conn,
|
|
14
|
+
StatewireCmd,
|
|
16
15
|
StatewireEnvelope,
|
|
17
|
-
|
|
16
|
+
StatewireSyn,
|
|
18
17
|
} from "../client/connection";
|
|
19
18
|
import type { Statewire } from "../types";
|
|
20
19
|
import type { StatewireClient } from "../statewire";
|
|
21
20
|
import type { StatewireHost } from "../host";
|
|
22
21
|
import { useStatewireRemote } from "./remote";
|
|
23
22
|
|
|
23
|
+
/** A client frame's lanes: stamped statements, strictly consecutive per lane. */
|
|
24
|
+
export type StatewireFrame = {
|
|
25
|
+
ctx?: { ops: unknown[]; seq: number }[];
|
|
26
|
+
cmd?: { method: string; params: unknown[]; meta?: unknown; seq: number }[];
|
|
27
|
+
};
|
|
28
|
+
|
|
24
29
|
export type StatewireRemoteProps = {
|
|
25
30
|
clientId: string;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
/** Lanes to ride the attach frame (no-lease supersession only); records the included seqs. */
|
|
32
|
+
takeAttachLanes: () => StatewireFrame | null;
|
|
33
|
+
onHello: (args: { ops: unknown[]; syn: StatewireSyn }) => void;
|
|
34
|
+
onOps: (args: { ops: unknown[]; cmds: StatewireCmd[] }) => void;
|
|
35
|
+
onCmds: (cmds: StatewireCmd[]) => void;
|
|
36
|
+
onRefusal: (syn: StatewireSyn) => void;
|
|
37
|
+
onIdle: (level: boolean) => void;
|
|
38
|
+
onDetach: () => void;
|
|
32
39
|
onFinish: (args: StatewireHost.Fin) => void;
|
|
33
40
|
};
|
|
34
41
|
|
|
35
|
-
export type StatewireWireCommand =
|
|
36
|
-
| { method: string; params: readonly unknown[]; context?: never }
|
|
37
|
-
| { method?: never; params?: never; context: Record<string, unknown> };
|
|
38
|
-
|
|
39
|
-
/** The wire shape of a client context submission: one root replace op. */
|
|
40
|
-
export const contextOps = (value: Record<string, unknown>) => [
|
|
41
|
-
{ op: "replace", path: [], value },
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
export const encodeWireCommand = (command: StatewireWireCommand) => ({
|
|
45
|
-
...(command.method !== undefined && {
|
|
46
|
-
method: command.method,
|
|
47
|
-
...encodeParams(command.params),
|
|
48
|
-
}),
|
|
49
|
-
...(command.context !== undefined && {
|
|
50
|
-
context: contextOps(command.context),
|
|
51
|
-
}),
|
|
52
|
-
});
|
|
53
|
-
|
|
54
42
|
export type StatewireSubmitResult =
|
|
55
|
-
| { type: "
|
|
56
|
-
| { type: "
|
|
57
|
-
| { type: "
|
|
58
|
-
| { type: "
|
|
59
|
-
| { type: "stale-lease" };
|
|
43
|
+
| { type: "ok" }
|
|
44
|
+
| { type: "refusal"; syn: StatewireSyn }
|
|
45
|
+
| { type: "stale-lease" }
|
|
46
|
+
| { type: "no-lease" };
|
|
60
47
|
|
|
61
48
|
export type ConnectionState = StatewireClient.ConnectionState;
|
|
62
49
|
|
|
63
|
-
/** The live wire a remote session drives: connection status plus
|
|
50
|
+
/** The live wire a remote session drives: connection status plus frame submission. */
|
|
64
51
|
export type RemoteLink = {
|
|
65
52
|
connection: StatewireClient.Connection;
|
|
66
|
-
send: (
|
|
67
|
-
seq: number;
|
|
68
|
-
command: StatewireWireCommand;
|
|
69
|
-
}) => Promise<StatewireSubmitResult>;
|
|
53
|
+
send: (frame: StatewireFrame) => Promise<StatewireSubmitResult>;
|
|
70
54
|
};
|
|
71
55
|
|
|
56
|
+
const LEASE_HEADER = "Statewire-Lease";
|
|
57
|
+
|
|
72
58
|
const baseUrl = (options: StatewireClient.SSEOptions) =>
|
|
73
59
|
options.url.replace(/\/$/, "");
|
|
74
60
|
|
|
75
61
|
export type AuthState = { stale: boolean };
|
|
76
62
|
|
|
77
|
-
const
|
|
63
|
+
const resolveHeaders = async (
|
|
78
64
|
options: StatewireClient.SSEOptions,
|
|
79
|
-
extra: Record<string, string>,
|
|
80
65
|
ctx: { staleAuth: boolean },
|
|
81
|
-
) => {
|
|
66
|
+
): Promise<Headers> => {
|
|
82
67
|
const { headers } = options;
|
|
83
|
-
|
|
68
|
+
return new Headers(
|
|
84
69
|
typeof headers === "function" ? await headers(ctx) : headers,
|
|
85
70
|
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const withExtra = (headers: Headers, extra: Record<string, string>) => {
|
|
74
|
+
const merged = new Headers(headers);
|
|
86
75
|
for (const [name, value] of Object.entries(extra)) merged.set(name, value);
|
|
87
76
|
return merged;
|
|
88
77
|
};
|
|
@@ -105,7 +94,7 @@ const consume = async (
|
|
|
105
94
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
106
95
|
heartbeat: AbortController,
|
|
107
96
|
dead: () => boolean,
|
|
108
|
-
): Promise<
|
|
97
|
+
): Promise<AttemptOutcome> => {
|
|
109
98
|
const decoder = new TextDecoder();
|
|
110
99
|
const parse = createSseParser();
|
|
111
100
|
for (;;) {
|
|
@@ -136,17 +125,16 @@ const attemptStream = async (
|
|
|
136
125
|
options: StatewireClient.SSEOptions,
|
|
137
126
|
auth: AuthState,
|
|
138
127
|
abort: AbortController,
|
|
139
|
-
): Promise<
|
|
128
|
+
): Promise<AttemptOutcome> => {
|
|
140
129
|
const stale = auth.stale;
|
|
141
130
|
auth.stale = false;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
},
|
|
148
|
-
|
|
149
|
-
);
|
|
131
|
+
c.lease = null;
|
|
132
|
+
const userHeaders = await resolveHeaders(options, { staleAuth: stale });
|
|
133
|
+
const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
|
|
134
|
+
const frame = {
|
|
135
|
+
headers: Object.fromEntries(userHeaders),
|
|
136
|
+
...(lanes ?? {}),
|
|
137
|
+
};
|
|
150
138
|
const heartbeat = new AbortController();
|
|
151
139
|
const headerTimer = setTimeout(
|
|
152
140
|
() => heartbeat.abort(),
|
|
@@ -158,7 +146,13 @@ const attemptStream = async (
|
|
|
158
146
|
options,
|
|
159
147
|
`${baseUrl(options)}/stream`,
|
|
160
148
|
{
|
|
161
|
-
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: withExtra(userHeaders, {
|
|
151
|
+
"content-type": "application/json",
|
|
152
|
+
"Statewire-Client-Id": c.ctx.clientId,
|
|
153
|
+
accept: "text/event-stream",
|
|
154
|
+
}),
|
|
155
|
+
body: JSON.stringify(frame),
|
|
162
156
|
cache: "no-store",
|
|
163
157
|
signal: AbortSignal.any([abort.signal, heartbeat.signal]),
|
|
164
158
|
},
|
|
@@ -167,13 +161,13 @@ const attemptStream = async (
|
|
|
167
161
|
} finally {
|
|
168
162
|
clearTimeout(headerTimer);
|
|
169
163
|
}
|
|
170
|
-
if (res.status === 429 || res.status === 503) throw overloaded(res);
|
|
171
164
|
if (res.status === 401) {
|
|
172
165
|
if (stale) c.episode = { cause: "unauthorized" };
|
|
173
166
|
auth.stale = true;
|
|
174
167
|
throw new Error("unauthorized");
|
|
175
168
|
}
|
|
176
169
|
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
170
|
+
c.lease = res.headers.get(LEASE_HEADER);
|
|
177
171
|
const reader = res.body.getReader();
|
|
178
172
|
try {
|
|
179
173
|
return await consume(c, reader, heartbeat, () => abort.signal.aborted);
|
|
@@ -182,32 +176,25 @@ const attemptStream = async (
|
|
|
182
176
|
}
|
|
183
177
|
};
|
|
184
178
|
|
|
185
|
-
const
|
|
186
|
-
overloadedError(parseRetryAfter(res.headers.get("Retry-After")));
|
|
187
|
-
|
|
188
|
-
const postCommand = async (
|
|
179
|
+
const postFrame = async (
|
|
189
180
|
c: Conn,
|
|
190
181
|
options: StatewireClient.SSEOptions,
|
|
191
|
-
|
|
182
|
+
lease: string,
|
|
183
|
+
frame: StatewireFrame,
|
|
192
184
|
timeout: AbortSignal,
|
|
193
185
|
stale: boolean,
|
|
194
186
|
): Promise<Response> =>
|
|
195
187
|
timedFetch(
|
|
196
188
|
options,
|
|
197
|
-
`${baseUrl(options)}/
|
|
189
|
+
`${baseUrl(options)}/frames`,
|
|
198
190
|
{
|
|
199
191
|
method: "POST",
|
|
200
|
-
headers: await
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
...(c.lease !== null && { "Statewire-Lease": c.lease }),
|
|
207
|
-
},
|
|
208
|
-
{ staleAuth: stale },
|
|
209
|
-
),
|
|
210
|
-
body: JSON.stringify(encodeWireCommand(command)),
|
|
192
|
+
headers: withExtra(await resolveHeaders(options, { staleAuth: stale }), {
|
|
193
|
+
"content-type": "application/json",
|
|
194
|
+
"Statewire-Client-Id": c.ctx.clientId,
|
|
195
|
+
[LEASE_HEADER]: lease,
|
|
196
|
+
}),
|
|
197
|
+
body: JSON.stringify(frame),
|
|
211
198
|
signal: AbortSignal.any([
|
|
212
199
|
c.lifetime?.signal ?? AbortSignal.abort(),
|
|
213
200
|
timeout,
|
|
@@ -216,32 +203,51 @@ const postCommand = async (
|
|
|
216
203
|
timeout,
|
|
217
204
|
);
|
|
218
205
|
|
|
219
|
-
|
|
206
|
+
// Delta-seconds only; anything else reads as absent.
|
|
207
|
+
const retryAfterMs = (res: Response): number | null => {
|
|
208
|
+
const header = res.headers.get("retry-after")?.trim();
|
|
209
|
+
return header !== undefined && /^\d+$/.test(header)
|
|
210
|
+
? Number(header) * 1000
|
|
211
|
+
: null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const refusalSyn = async (res: Response): Promise<StatewireSyn> => {
|
|
215
|
+
const body = (await res.json().catch(() => null)) as {
|
|
216
|
+
syn?: StatewireSyn;
|
|
217
|
+
} | null;
|
|
218
|
+
const syn = body?.syn;
|
|
219
|
+
if (typeof syn !== "object" || syn === null) {
|
|
220
|
+
throw new Error(`HTTP ${res.status}`);
|
|
221
|
+
}
|
|
222
|
+
return syn;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const sendFrame = async (
|
|
220
226
|
c: Conn,
|
|
221
227
|
options: StatewireClient.SSEOptions,
|
|
222
228
|
auth: AuthState,
|
|
223
|
-
|
|
229
|
+
frame: StatewireFrame,
|
|
224
230
|
): Promise<StatewireSubmitResult> => {
|
|
231
|
+
const lease = c.lease;
|
|
232
|
+
if (lease === null) return { type: "no-lease" };
|
|
225
233
|
const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);
|
|
226
|
-
let res = await
|
|
234
|
+
let res = await postFrame(c, options, lease, frame, timeout, false);
|
|
227
235
|
if (res.status === 401 && typeof options.headers === "function") {
|
|
228
|
-
res = await
|
|
236
|
+
res = await postFrame(c, options, lease, frame, timeout, true);
|
|
229
237
|
}
|
|
230
|
-
if (res.ok) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return
|
|
241
|
-
? { type: "receipt", response: reviveResponse(body.res) }
|
|
242
|
-
: { type: "receipt" };
|
|
238
|
+
if (res.ok) return { type: "ok" };
|
|
239
|
+
if (res.status === 429 || res.status === 409) {
|
|
240
|
+
// The syn body retry wins; Retry-After covers 429s minted below the host.
|
|
241
|
+
const headerRetry = res.status === 429 ? retryAfterMs(res) : null;
|
|
242
|
+
const syn: StatewireSyn = await refusalSyn(res).catch((error: unknown) => {
|
|
243
|
+
if (headerRetry === null) throw error;
|
|
244
|
+
return { retry: headerRetry };
|
|
245
|
+
});
|
|
246
|
+
if (syn.retry === undefined && headerRetry !== null)
|
|
247
|
+
syn.retry = headerRetry;
|
|
248
|
+
return { type: "refusal", syn };
|
|
243
249
|
}
|
|
244
|
-
if (res.status ===
|
|
250
|
+
if (res.status === 423) return { type: "stale-lease" };
|
|
245
251
|
if (res.status === 401) {
|
|
246
252
|
// Fresh credentials did not clear the 401: signal auth-failed on the
|
|
247
253
|
// connection and let the send retry as a transport fault.
|
|
@@ -250,27 +256,6 @@ const sendCommand = async (
|
|
|
250
256
|
c.connection.reconnect();
|
|
251
257
|
throw new Error("unauthorized");
|
|
252
258
|
}
|
|
253
|
-
// Bodies settle, statuses pace: only a statewire-shaped body carries a
|
|
254
|
-
// verdict; any bare status is middleware noise — a transport fault, retried.
|
|
255
|
-
const body = (await res.json().catch(() => null)) as {
|
|
256
|
-
error?: string;
|
|
257
|
-
rejections?: { message?: string }[];
|
|
258
|
-
} | null;
|
|
259
|
-
if (body?.error === "rejected") {
|
|
260
|
-
return {
|
|
261
|
-
type: "rejected",
|
|
262
|
-
message: body.rejections?.[0]?.message ?? `HTTP ${res.status}`,
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
if (res.status === 409 || res.status === 412 || res.status === 423) {
|
|
266
|
-
const verdict =
|
|
267
|
-
res.status === 409
|
|
268
|
-
? ("seq-gap" as const)
|
|
269
|
-
: res.status === 412
|
|
270
|
-
? ("unknown-client" as const)
|
|
271
|
-
: ("stale-lease" as const);
|
|
272
|
-
if (body?.error === verdict) return { type: verdict };
|
|
273
|
-
}
|
|
274
259
|
throw new Error(`HTTP ${res.status}`);
|
|
275
260
|
};
|
|
276
261
|
|
|
@@ -292,7 +277,7 @@ const useSseLink = (
|
|
|
292
277
|
get connection() {
|
|
293
278
|
return c.connection;
|
|
294
279
|
},
|
|
295
|
-
send: (
|
|
280
|
+
send: (frame) => sendFrame(c, latest.current, auth, frame),
|
|
296
281
|
}),
|
|
297
282
|
);
|
|
298
283
|
return link;
|
|
@@ -300,12 +285,11 @@ const useSseLink = (
|
|
|
300
285
|
|
|
301
286
|
const useStatewireSSE = (
|
|
302
287
|
options: StatewireClient.SSEOptions,
|
|
303
|
-
): Statewire<any, any
|
|
288
|
+
): Statewire<any, any> => useStatewireRemote(options, useSseLink);
|
|
304
289
|
|
|
305
290
|
export const StatewireSSE = resource(useStatewireSSE) as <
|
|
306
291
|
TState = any,
|
|
307
292
|
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
308
|
-
TEvent = any,
|
|
309
293
|
>(
|
|
310
294
|
options: StatewireClient.SSEOptions,
|
|
311
|
-
) => ResourceElement<Statewire<TState | undefined, TCommands
|
|
295
|
+
) => ResourceElement<Statewire<TState | undefined, TCommands>>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
2
2
|
import type { StatewireEnvelope } from "../client/connection";
|
|
3
3
|
import { StatewireSSE } from "./sse";
|
|
4
|
+
import type { StatewireFrame } from "./sse";
|
|
4
5
|
import type { Statewire } from "../types";
|
|
5
6
|
import { openSSEStream, sseFrame } from "./fake-sse";
|
|
6
7
|
import type { FakeStreamHandle } from "./fake-sse";
|
|
@@ -14,13 +15,15 @@ export { applyOptimistic } from "../util/recordMutations";
|
|
|
14
15
|
|
|
15
16
|
export type MockTransport<TState> = {
|
|
16
17
|
/** Pass as the `transport:` option of useStatewire / Harness. */
|
|
17
|
-
transport: ResourceElement<Statewire<TState | undefined, any
|
|
18
|
-
/** Captured
|
|
18
|
+
transport: ResourceElement<Statewire<TState | undefined, any>>;
|
|
19
|
+
/** Captured statements in arrival order: cmd as `{method, params}`, ctx as `{context}`. */
|
|
19
20
|
commands: { method?: string; params?: unknown[]; context?: unknown }[];
|
|
20
21
|
/** Replace the replicated state and broadcast it as a root snapshot op. */
|
|
21
22
|
setState: (state: TState) => Promise<void>;
|
|
22
|
-
/** Deliver
|
|
23
|
-
|
|
23
|
+
/** Deliver a bare ack cmd packet on the stream. */
|
|
24
|
+
applied: (seq: number) => Promise<void>;
|
|
25
|
+
/** Deliver a terminal result cmd packet on the stream. */
|
|
26
|
+
settle: (seq: number, payload?: unknown) => Promise<void>;
|
|
24
27
|
/** Deliver an arbitrary envelope on the stream. */
|
|
25
28
|
sendEnvelope: (envelope: StatewireEnvelope) => Promise<void>;
|
|
26
29
|
/** End the stream with a fin; further attaches fail like a dead server. */
|
|
@@ -31,8 +34,8 @@ export type MockTransport<TState> = {
|
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
36
|
* A mock statewire backend: serves `initialState` as the attach snapshot,
|
|
34
|
-
* records
|
|
35
|
-
* test code), and exposes the wire as a transport ResourceElement.
|
|
37
|
+
* records submitted frames (receipt only — acks and results are delivered
|
|
38
|
+
* from test code), and exposes the wire as a transport ResourceElement.
|
|
36
39
|
*/
|
|
37
40
|
let mockCount = 0;
|
|
38
41
|
|
|
@@ -42,7 +45,7 @@ export const createMockTransport = <TState>(
|
|
|
42
45
|
let state: unknown = initialState;
|
|
43
46
|
let clientId = "";
|
|
44
47
|
let finished = false;
|
|
45
|
-
const clients = new Map<string, number>();
|
|
48
|
+
const clients = new Map<string, { seq: number; ctxSeq: number }>();
|
|
46
49
|
const streams = new Set<FakeStreamHandle>();
|
|
47
50
|
const commands: MockTransport<unknown>["commands"] = [];
|
|
48
51
|
|
|
@@ -51,6 +54,19 @@ export const createMockTransport = <TState>(
|
|
|
51
54
|
return tick();
|
|
52
55
|
};
|
|
53
56
|
|
|
57
|
+
const admit = (id: string, frame: StatewireFrame) => {
|
|
58
|
+
const rec = clients.get(id)!;
|
|
59
|
+
for (const statement of frame.ctx ?? []) {
|
|
60
|
+
rec.ctxSeq = Math.max(rec.ctxSeq, statement.seq);
|
|
61
|
+
const root = statement.ops[0] as { value?: unknown } | undefined;
|
|
62
|
+
commands.push({ context: root?.value });
|
|
63
|
+
}
|
|
64
|
+
for (const statement of frame.cmd ?? []) {
|
|
65
|
+
rec.seq = Math.max(rec.seq, statement.seq);
|
|
66
|
+
commands.push({ method: statement.method, params: statement.params });
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
54
70
|
const fakeFetch = (async (
|
|
55
71
|
input: RequestInfo | URL,
|
|
56
72
|
init?: RequestInit,
|
|
@@ -61,8 +77,11 @@ export const createMockTransport = <TState>(
|
|
|
61
77
|
const id = headers.get("Statewire-Client-Id") ?? "";
|
|
62
78
|
if (url.endsWith("/stream")) {
|
|
63
79
|
clientId = id;
|
|
64
|
-
const
|
|
65
|
-
clients.set(id,
|
|
80
|
+
const known = clients.has(id);
|
|
81
|
+
if (!known) clients.set(id, { seq: 0, ctxSeq: 0 });
|
|
82
|
+
const rec = clients.get(id)!;
|
|
83
|
+
const frame = JSON.parse(String(init?.body)) as StatewireFrame;
|
|
84
|
+
admit(id, frame);
|
|
66
85
|
return openSSEStream(
|
|
67
86
|
streams,
|
|
68
87
|
id,
|
|
@@ -70,21 +89,20 @@ export const createMockTransport = <TState>(
|
|
|
70
89
|
handle.send(
|
|
71
90
|
sseFrame({
|
|
72
91
|
ops: [{ op: "replace", path: [], value: state }],
|
|
73
|
-
syn: {
|
|
92
|
+
syn: {
|
|
93
|
+
seq: rec.seq,
|
|
94
|
+
ctxSeq: rec.ctxSeq,
|
|
95
|
+
record: known ? "resumed" : "fresh",
|
|
96
|
+
},
|
|
74
97
|
}),
|
|
75
98
|
);
|
|
76
99
|
},
|
|
77
100
|
init?.signal ?? undefined,
|
|
101
|
+
{ "Statewire-Lease": "mock-lease" },
|
|
78
102
|
);
|
|
79
103
|
}
|
|
80
|
-
if (url.endsWith("/
|
|
81
|
-
|
|
82
|
-
clients.set(id, Math.max(clients.get(id) ?? 0, seq));
|
|
83
|
-
commands.push(
|
|
84
|
-
JSON.parse(
|
|
85
|
-
String(init?.body),
|
|
86
|
-
) as MockTransport<unknown>["commands"][number],
|
|
87
|
-
);
|
|
104
|
+
if (url.endsWith("/frames")) {
|
|
105
|
+
admit(id, JSON.parse(String(init?.body)) as StatewireFrame);
|
|
88
106
|
return Response.json({});
|
|
89
107
|
}
|
|
90
108
|
return new Response(null, { status: 404 });
|
|
@@ -100,7 +118,17 @@ export const createMockTransport = <TState>(
|
|
|
100
118
|
state = next;
|
|
101
119
|
return sendEnvelope({ ops: [{ op: "replace", path: [], value: next }] });
|
|
102
120
|
},
|
|
103
|
-
|
|
121
|
+
applied: (seq) => sendEnvelope({ cmd: [{ seq, type: "applied" }] }),
|
|
122
|
+
settle: (seq, payload) =>
|
|
123
|
+
sendEnvelope({
|
|
124
|
+
cmd: [
|
|
125
|
+
{
|
|
126
|
+
seq,
|
|
127
|
+
type: "result",
|
|
128
|
+
...(payload !== undefined && { payload }),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
}),
|
|
104
132
|
sendEnvelope,
|
|
105
133
|
finish: async (reason, message) => {
|
|
106
134
|
await sendEnvelope({
|