@cosmicdrift/kumiko-dispatcher-live 1.0.0 → 2.0.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/package.json +2 -2
- package/src/__tests__/dispatcher-live.test.ts +128 -1
- package/src/__tests__/sse-stream.test.ts +129 -0
- package/src/dispatcher-live.ts +98 -2
- package/src/index.ts +2 -0
- package/src/sse-stream.ts +133 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dispatcher-live",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "HTTP-only Dispatcher for Kumiko UIs. Always-online; failures surface immediately. Default client for web admin/cockpit apps.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@cosmicdrift/kumiko-headless": "
|
|
27
|
+
"@cosmicdrift/kumiko-headless": "2.0.0"
|
|
28
28
|
},
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"registry": "https://registry.npmjs.org",
|
|
@@ -46,7 +46,10 @@ describe("createLiveDispatcher", () => {
|
|
|
46
46
|
expect(headers["Content-Type"]).toBe("application/json");
|
|
47
47
|
expect(headers["X-CSRF-Token"]).toBe("csrf-abc");
|
|
48
48
|
const body = JSON.parse(call?.init.body as string);
|
|
49
|
-
expect(body).
|
|
49
|
+
expect(body).toMatchObject({ type: "app:write:task:create", payload: { title: "hello" } });
|
|
50
|
+
// Auto-generated idempotency key (#761) — always present.
|
|
51
|
+
expect(typeof body.requestId).toBe("string");
|
|
52
|
+
expect(body.requestId.length).toBeGreaterThan(8);
|
|
50
53
|
});
|
|
51
54
|
|
|
52
55
|
test("write: propagates requestId into body when provided", async () => {
|
|
@@ -59,6 +62,43 @@ describe("createLiveDispatcher", () => {
|
|
|
59
62
|
expect(body.requestId).toBe("idem-99");
|
|
60
63
|
});
|
|
61
64
|
|
|
65
|
+
test("write: generates a fresh requestId per invocation when none provided (#761)", async () => {
|
|
66
|
+
const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: {} } });
|
|
67
|
+
const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
|
|
68
|
+
|
|
69
|
+
await disp.write("app:write:x:create", { a: 1 });
|
|
70
|
+
await disp.write("app:write:x:create", { a: 1 });
|
|
71
|
+
|
|
72
|
+
const first = JSON.parse(calls[0]?.init.body as string).requestId;
|
|
73
|
+
const second = JSON.parse(calls[1]?.init.body as string).requestId;
|
|
74
|
+
expect(typeof first).toBe("string");
|
|
75
|
+
expect(typeof second).toBe("string");
|
|
76
|
+
expect(first).not.toBe(second);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("batch: generates one requestId for the whole batch when none provided (#761)", async () => {
|
|
80
|
+
const { fetch, calls } = makeFetch({ body: { isSuccess: true, results: [] } });
|
|
81
|
+
const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
|
|
82
|
+
|
|
83
|
+
await disp.batch([
|
|
84
|
+
{ type: "a", payload: {} },
|
|
85
|
+
{ type: "b", payload: {} },
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const body = JSON.parse(calls[0]?.init.body as string);
|
|
89
|
+
expect(typeof body.requestId).toBe("string");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("batch: propagates an explicit requestId (#761)", async () => {
|
|
93
|
+
const { fetch, calls } = makeFetch({ body: { isSuccess: true, results: [] } });
|
|
94
|
+
const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
|
|
95
|
+
|
|
96
|
+
await disp.batch([{ type: "a", payload: {} }], { requestId: "batch-7" });
|
|
97
|
+
|
|
98
|
+
const body = JSON.parse(calls[0]?.init.body as string);
|
|
99
|
+
expect(body.requestId).toBe("batch-7");
|
|
100
|
+
});
|
|
101
|
+
|
|
62
102
|
test("write: no CSRF token → header omitted, request still fires (server will 401/csrf-mismatch)", async () => {
|
|
63
103
|
const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: {} } });
|
|
64
104
|
const disp = createLiveDispatcher({ fetch, readCsrf: () => undefined });
|
|
@@ -259,6 +299,93 @@ describe("createLiveDispatcher", () => {
|
|
|
259
299
|
expect(disp.pendingFiles()).toEqual([]);
|
|
260
300
|
});
|
|
261
301
|
|
|
302
|
+
test("stream: POSTs to /api/stream, yields chunk frames, stops on done", async () => {
|
|
303
|
+
const sse = [
|
|
304
|
+
"event: chunk",
|
|
305
|
+
'data: {"i":0}',
|
|
306
|
+
"",
|
|
307
|
+
"event: ping",
|
|
308
|
+
"data: ",
|
|
309
|
+
"",
|
|
310
|
+
"event: chunk",
|
|
311
|
+
'data: {"i":1}',
|
|
312
|
+
"",
|
|
313
|
+
"event: done",
|
|
314
|
+
"data: ",
|
|
315
|
+
"",
|
|
316
|
+
].join("\n");
|
|
317
|
+
const calls: Array<{ url: string; init: RequestInit }> = [];
|
|
318
|
+
const fetchMock = mock(async (url: string, init: RequestInit) => {
|
|
319
|
+
calls.push({ url, init });
|
|
320
|
+
return new Response(sse, {
|
|
321
|
+
status: 200,
|
|
322
|
+
headers: { "content-type": "text/event-stream" },
|
|
323
|
+
});
|
|
324
|
+
}) as unknown as typeof globalThis.fetch;
|
|
325
|
+
|
|
326
|
+
const disp = createLiveDispatcher({ fetch: fetchMock, readCsrf: () => "csrf-s" });
|
|
327
|
+
const chunks: unknown[] = [];
|
|
328
|
+
for await (const c of disp.stream("app:stream:x:tail", { n: 2 })) chunks.push(c);
|
|
329
|
+
|
|
330
|
+
expect(chunks).toEqual([{ i: 0 }, { i: 1 }]);
|
|
331
|
+
expect(calls).toHaveLength(1);
|
|
332
|
+
expect(calls[0]?.url).toBe("/api/stream");
|
|
333
|
+
expect(calls[0]?.init.method).toBe("POST");
|
|
334
|
+
const headers = calls[0]?.init.headers as Record<string, string>;
|
|
335
|
+
expect(headers["Accept"]).toBe("text/event-stream");
|
|
336
|
+
expect(headers["X-CSRF-Token"]).toBe("csrf-s");
|
|
337
|
+
expect(JSON.parse(calls[0]?.init.body as string)).toEqual({
|
|
338
|
+
type: "app:stream:x:tail",
|
|
339
|
+
payload: { n: 2 },
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("stream: non-SSE JSON error envelope maps like query failures", async () => {
|
|
344
|
+
const fetchMock = mock(async () =>
|
|
345
|
+
Response.json(
|
|
346
|
+
{
|
|
347
|
+
error: {
|
|
348
|
+
code: "csrf_token_mismatch",
|
|
349
|
+
httpStatus: 403,
|
|
350
|
+
i18nKey: "errors.csrf",
|
|
351
|
+
message: "csrf",
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
{ status: 403 },
|
|
355
|
+
),
|
|
356
|
+
) as unknown as typeof globalThis.fetch;
|
|
357
|
+
|
|
358
|
+
const disp = createLiveDispatcher({ fetch: fetchMock, readCsrf: () => undefined });
|
|
359
|
+
let thrown: unknown;
|
|
360
|
+
try {
|
|
361
|
+
for await (const _ of disp.stream("app:stream:x:tail", {})) {
|
|
362
|
+
// no chunks
|
|
363
|
+
}
|
|
364
|
+
} catch (e) {
|
|
365
|
+
thrown = e;
|
|
366
|
+
}
|
|
367
|
+
expect(thrown).toMatchObject({ code: "csrf_token_mismatch" });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("stream: AbortError on fetch maps to code aborted", async () => {
|
|
371
|
+
const fetchMock = mock(async (_url: string, init?: RequestInit) => {
|
|
372
|
+
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
|
373
|
+
throw new DOMException("The operation was aborted.", "AbortError");
|
|
374
|
+
}) as unknown as typeof globalThis.fetch;
|
|
375
|
+
|
|
376
|
+
const disp = createLiveDispatcher({ fetch: fetchMock, readCsrf: () => "t" });
|
|
377
|
+
const ctrl = new AbortController();
|
|
378
|
+
let thrown: unknown;
|
|
379
|
+
try {
|
|
380
|
+
for await (const _ of disp.stream("app:stream:x:tail", {}, { signal: ctrl.signal })) {
|
|
381
|
+
// no chunks
|
|
382
|
+
}
|
|
383
|
+
} catch (e) {
|
|
384
|
+
thrown = e;
|
|
385
|
+
}
|
|
386
|
+
expect(thrown).toMatchObject({ code: "aborted" });
|
|
387
|
+
});
|
|
388
|
+
|
|
262
389
|
test("subscribeStatus returns unsubscribe handle", async () => {
|
|
263
390
|
const fetch = mock(async () => {
|
|
264
391
|
throw new Error("boom");
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { iterateSseChunks, parseSseFrames } from "../sse-stream";
|
|
3
|
+
|
|
4
|
+
describe("parseSseFrames", () => {
|
|
5
|
+
test("parses chunk + done frames from a complete body", () => {
|
|
6
|
+
const text = [
|
|
7
|
+
"event: chunk",
|
|
8
|
+
'data: {"i":0}',
|
|
9
|
+
"",
|
|
10
|
+
"event: chunk",
|
|
11
|
+
'data: {"i":1}',
|
|
12
|
+
"",
|
|
13
|
+
"event: ping",
|
|
14
|
+
"data: ",
|
|
15
|
+
"",
|
|
16
|
+
"event: done",
|
|
17
|
+
"data: ",
|
|
18
|
+
"",
|
|
19
|
+
].join("\n");
|
|
20
|
+
|
|
21
|
+
expect(parseSseFrames(text)).toEqual([
|
|
22
|
+
{ event: "chunk", data: '{"i":0}' },
|
|
23
|
+
{ event: "chunk", data: '{"i":1}' },
|
|
24
|
+
{ event: "ping", data: "" },
|
|
25
|
+
{ event: "done", data: "" },
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("iterateSseChunks", () => {
|
|
31
|
+
test("yields JSON chunks, swallows ping, stops on done", async () => {
|
|
32
|
+
const text = [
|
|
33
|
+
"event: chunk",
|
|
34
|
+
'data: {"i":0}',
|
|
35
|
+
"",
|
|
36
|
+
"event: ping",
|
|
37
|
+
"data: ",
|
|
38
|
+
"",
|
|
39
|
+
"event: chunk",
|
|
40
|
+
'data: {"i":1}',
|
|
41
|
+
"",
|
|
42
|
+
"event: done",
|
|
43
|
+
"data: ",
|
|
44
|
+
"",
|
|
45
|
+
].join("\n");
|
|
46
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
47
|
+
start(controller) {
|
|
48
|
+
controller.enqueue(new TextEncoder().encode(text));
|
|
49
|
+
controller.close();
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const chunks: unknown[] = [];
|
|
54
|
+
for await (const c of iterateSseChunks(stream)) chunks.push(c);
|
|
55
|
+
expect(chunks).toEqual([{ i: 0 }, { i: 1 }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("error frame throws mapped DispatcherError", async () => {
|
|
59
|
+
const text = [
|
|
60
|
+
"event: error",
|
|
61
|
+
'data: {"code":"access_denied","httpStatus":403,"i18nKey":"errors.access","message":"nope"}',
|
|
62
|
+
"",
|
|
63
|
+
].join("\n");
|
|
64
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
65
|
+
start(controller) {
|
|
66
|
+
controller.enqueue(new TextEncoder().encode(text));
|
|
67
|
+
controller.close();
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await expect(async () => {
|
|
72
|
+
for await (const _ of iterateSseChunks(stream)) {
|
|
73
|
+
// no chunks expected
|
|
74
|
+
}
|
|
75
|
+
}).toThrow(/nope/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("stream closed without a done frame throws instead of ending cleanly", async () => {
|
|
79
|
+
const text = ["event: chunk", 'data: {"i":0}', ""].join("\n");
|
|
80
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
81
|
+
start(controller) {
|
|
82
|
+
controller.enqueue(new TextEncoder().encode(text));
|
|
83
|
+
controller.close();
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const chunks: unknown[] = [];
|
|
88
|
+
await expect(async () => {
|
|
89
|
+
for await (const c of iterateSseChunks(stream)) chunks.push(c);
|
|
90
|
+
}).toThrow(/without a done frame/);
|
|
91
|
+
expect(chunks).toEqual([{ i: 0 }]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("malformed chunk JSON throws a DispatcherError instead of a raw SyntaxError", async () => {
|
|
95
|
+
const text = ["event: chunk", "data: {not json", "", "event: done", "data: ", ""].join("\n");
|
|
96
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
97
|
+
start(controller) {
|
|
98
|
+
controller.enqueue(new TextEncoder().encode(text));
|
|
99
|
+
controller.close();
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await expect(async () => {
|
|
104
|
+
for await (const _ of iterateSseChunks(stream)) {
|
|
105
|
+
// no chunks expected
|
|
106
|
+
}
|
|
107
|
+
}).toThrow(/malformed chunk frame/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("cancels the reader on early break instead of leaving the response open", async () => {
|
|
111
|
+
let cancelled = false;
|
|
112
|
+
const text = ["event: chunk", 'data: {"i":0}', "", "event: chunk", 'data: {"i":1}', ""].join(
|
|
113
|
+
"\n",
|
|
114
|
+
);
|
|
115
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
116
|
+
start(controller) {
|
|
117
|
+
controller.enqueue(new TextEncoder().encode(text));
|
|
118
|
+
},
|
|
119
|
+
cancel() {
|
|
120
|
+
cancelled = true;
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
for await (const _ of iterateSseChunks(stream)) {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
expect(cancelled).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/dispatcher-live.ts
CHANGED
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
type PendingWrite,
|
|
10
10
|
type QueryOpts,
|
|
11
11
|
type QueryResult,
|
|
12
|
+
type StreamOpts,
|
|
12
13
|
type WriteOpts,
|
|
13
14
|
type WriteResult,
|
|
14
15
|
} from "@cosmicdrift/kumiko-headless";
|
|
15
16
|
import { CSRF_HEADER_NAME, readCsrfToken } from "./csrf";
|
|
16
17
|
import { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping";
|
|
18
|
+
import { iterateSseChunks } from "./sse-stream";
|
|
17
19
|
|
|
18
20
|
// HTTP-only dispatcher. Maps Kumiko's client-side Dispatcher contract to
|
|
19
21
|
// `POST /api/{write,query,batch}`. No local store, no queue, no retry —
|
|
@@ -55,6 +57,7 @@ export type LiveDispatcherOptions = {
|
|
|
55
57
|
const PATH_WRITE = "/api/write";
|
|
56
58
|
const PATH_QUERY = "/api/query";
|
|
57
59
|
const PATH_BATCH = "/api/batch";
|
|
60
|
+
const PATH_STREAM = "/api/stream";
|
|
58
61
|
|
|
59
62
|
export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispatcher {
|
|
60
63
|
const baseUrl = options.baseUrl ?? "";
|
|
@@ -154,7 +157,11 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
|
|
|
154
157
|
opts?: WriteOpts,
|
|
155
158
|
): Promise<WriteResult<TData>> {
|
|
156
159
|
const body: Record<string, unknown> = { type, payload };
|
|
157
|
-
|
|
160
|
+
// Idempotency by default (#761): without a requestId the server-side
|
|
161
|
+
// dedup never engages and a transport-level double-send duplicates
|
|
162
|
+
// events. Callers with a logical-submit id (savable queue, form
|
|
163
|
+
// controllers) pass their own and keep it across their retries.
|
|
164
|
+
body["requestId"] = opts?.requestId ?? generateRequestId();
|
|
158
165
|
const call = await callJson(PATH_WRITE, body, opts?.signal);
|
|
159
166
|
return normalizeWriteResult<TData>(call);
|
|
160
167
|
},
|
|
@@ -171,11 +178,90 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
|
|
|
171
178
|
|
|
172
179
|
async batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult> {
|
|
173
180
|
const body: Record<string, unknown> = { commands };
|
|
174
|
-
|
|
181
|
+
// One id for the whole batch — the server caches the BatchResult
|
|
182
|
+
// under it, so a retried batch returns the cached outcome instead of
|
|
183
|
+
// re-executing the commands (#761).
|
|
184
|
+
body["requestId"] = opts?.requestId ?? generateRequestId();
|
|
175
185
|
const call = await callJson(PATH_BATCH, body, opts?.signal);
|
|
176
186
|
return normalizeBatchResponse(call);
|
|
177
187
|
},
|
|
178
188
|
|
|
189
|
+
async *stream<TChunk = unknown>(
|
|
190
|
+
type: string,
|
|
191
|
+
payload: unknown,
|
|
192
|
+
opts?: StreamOpts,
|
|
193
|
+
): AsyncGenerator<TChunk, void, undefined> {
|
|
194
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
195
|
+
if (!f) {
|
|
196
|
+
throw buildNetworkError(
|
|
197
|
+
"fetch is not available in this runtime — inject via LiveDispatcherOptions.fetch",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const headers: Record<string, string> = {
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
Accept: "text/event-stream",
|
|
204
|
+
};
|
|
205
|
+
const csrf = readCsrf();
|
|
206
|
+
if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
|
|
207
|
+
|
|
208
|
+
let response: Response;
|
|
209
|
+
try {
|
|
210
|
+
response = await f(`${baseUrl}${PATH_STREAM}`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
credentials: "include",
|
|
213
|
+
headers,
|
|
214
|
+
body: JSON.stringify({ type, payload }),
|
|
215
|
+
signal: opts?.signal,
|
|
216
|
+
});
|
|
217
|
+
} catch (e) {
|
|
218
|
+
if (isAbortError(e)) throw buildAbortError();
|
|
219
|
+
observeNetworkOutcome(false);
|
|
220
|
+
throw buildNetworkError(e);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
observeNetworkOutcome(true);
|
|
224
|
+
|
|
225
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
226
|
+
// Pre-SSE gate failures (PAT deny, etc.) return a normal JSON error
|
|
227
|
+
// envelope — map them like /api/query before trying to read SSE.
|
|
228
|
+
if (!contentType.includes("text/event-stream")) {
|
|
229
|
+
let parsed: unknown;
|
|
230
|
+
try {
|
|
231
|
+
parsed = await response.json();
|
|
232
|
+
} catch (e) {
|
|
233
|
+
throw buildNetworkError(
|
|
234
|
+
`invalid stream response (${response.status}): ${
|
|
235
|
+
e instanceof Error ? e.message : String(e)
|
|
236
|
+
}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const body = parsed as { error?: ServerErrorLike };
|
|
240
|
+
if (body?.error) {
|
|
241
|
+
throw mapServerError({
|
|
242
|
+
...body.error,
|
|
243
|
+
httpStatus: body.error.httpStatus ?? response.status,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
throw buildNetworkError(`unexpected non-SSE stream response (${response.status})`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!response.body) {
|
|
250
|
+
throw buildNetworkError("stream response has no body");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
yield* iterateSseChunks<TChunk>(response.body);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
// Abort can surface on the initial fetch OR while reading the SSE
|
|
257
|
+
// body — both must map to the same `aborted` envelope so hooks
|
|
258
|
+
// (useStreamHandler) can ignore user-cancel without an error toast.
|
|
259
|
+
if (isAbortError(e) || opts?.signal?.aborted) throw buildAbortError();
|
|
260
|
+
observeNetworkOutcome(false);
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
|
|
179
265
|
statusStore,
|
|
180
266
|
// Live dispatcher has no queue. Returning a constant empty array
|
|
181
267
|
// keeps the contract uniform with savable; UI code that renders
|
|
@@ -189,6 +275,16 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
|
|
|
189
275
|
const EMPTY_PENDING_WRITES: readonly PendingWrite[] = Object.freeze([]);
|
|
190
276
|
const EMPTY_PENDING_FILES: readonly PendingFile[] = Object.freeze([]);
|
|
191
277
|
|
|
278
|
+
// crypto.randomUUID where available (browser, Bun, Node); Math.random
|
|
279
|
+
// fallback for React-Native runtimes without the WebCrypto polyfill.
|
|
280
|
+
// Uniqueness only needs to hold per user within the server's dedup window —
|
|
281
|
+
// this is an idempotency key, not a security token.
|
|
282
|
+
function generateRequestId(): string {
|
|
283
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
284
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
285
|
+
return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
192
288
|
type CallOutcome =
|
|
193
289
|
| { readonly ok: true; readonly body: unknown; readonly status: number }
|
|
194
290
|
| { readonly ok: false; readonly networkFailure: DispatcherError };
|
package/src/index.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, readCsrfToken } from "./csrf";
|
|
|
2
2
|
export type { LiveDispatcherOptions } from "./dispatcher-live";
|
|
3
3
|
export { createLiveDispatcher } from "./dispatcher-live";
|
|
4
4
|
export { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping";
|
|
5
|
+
export type { SseFrame } from "./sse-stream";
|
|
6
|
+
export { iterateSseChunks, parseSseBlock, parseSseFrames } from "./sse-stream";
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type DispatcherError, StreamFrame } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { mapServerError } from "./error-mapping";
|
|
3
|
+
|
|
4
|
+
// Parse the SSE wire format produced by Hono `streamSSE` for POST /api/stream:
|
|
5
|
+
// event: chunk|ping|done|error
|
|
6
|
+
// data: <json-or-empty>
|
|
7
|
+
// <blank line>
|
|
8
|
+
//
|
|
9
|
+
// Exported for unit tests — live dispatcher is the only production caller.
|
|
10
|
+
|
|
11
|
+
export type SseFrame = {
|
|
12
|
+
readonly event: string;
|
|
13
|
+
readonly data: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function parseSseBlock(block: string): SseFrame | null {
|
|
17
|
+
const trimmed = block.trim();
|
|
18
|
+
if (trimmed.length === 0) return null;
|
|
19
|
+
const event = /^event: (.*)$/m.exec(trimmed)?.[1] ?? "";
|
|
20
|
+
const data = /^data: (.*)$/m.exec(trimmed)?.[1] ?? "";
|
|
21
|
+
return { event, data };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Split a complete SSE body (tests / non-streaming buffers) into frames. */
|
|
25
|
+
export function parseSseFrames(text: string): SseFrame[] {
|
|
26
|
+
return text
|
|
27
|
+
.split("\n\n")
|
|
28
|
+
.map(parseSseBlock)
|
|
29
|
+
.filter((f): f is SseFrame => f !== null);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Incremental SSE reader over a fetch body. Yields `chunk` payloads as
|
|
34
|
+
* parsed JSON; swallows `ping`; returns on `done`; throws DispatcherError
|
|
35
|
+
* on `error` frames. Caller is responsible for aborting the underlying
|
|
36
|
+
* fetch via AbortSignal.
|
|
37
|
+
*/
|
|
38
|
+
export async function* iterateSseChunks<TChunk>(
|
|
39
|
+
body: ReadableStream<Uint8Array>,
|
|
40
|
+
): AsyncGenerator<TChunk, void, undefined> {
|
|
41
|
+
const reader = body.getReader();
|
|
42
|
+
const decoder = new TextDecoder();
|
|
43
|
+
let buffer = "";
|
|
44
|
+
let sawDone = false;
|
|
45
|
+
try {
|
|
46
|
+
while (true) {
|
|
47
|
+
const { done, value } = await reader.read();
|
|
48
|
+
if (done) break;
|
|
49
|
+
buffer += decoder.decode(value, { stream: true });
|
|
50
|
+
const parts = buffer.split("\n\n");
|
|
51
|
+
buffer = parts.pop() ?? "";
|
|
52
|
+
for (const part of parts) {
|
|
53
|
+
const frame = parseSseBlock(part);
|
|
54
|
+
if (frame === null) continue;
|
|
55
|
+
if (frame.event === StreamFrame.ping) continue;
|
|
56
|
+
// skip: terminal SSE done frame — end the generator cleanly
|
|
57
|
+
if (frame.event === StreamFrame.done) {
|
|
58
|
+
sawDone = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (frame.event === StreamFrame.error) {
|
|
62
|
+
throw frameDataToDispatcherError(frame.data);
|
|
63
|
+
}
|
|
64
|
+
if (frame.event === StreamFrame.chunk) {
|
|
65
|
+
yield parseChunkData<TChunk>(frame.data);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Trailing buffer without final blank line (some runtimes).
|
|
70
|
+
const frame = parseSseBlock(buffer);
|
|
71
|
+
if (frame?.event === StreamFrame.chunk) {
|
|
72
|
+
yield parseChunkData<TChunk>(frame.data);
|
|
73
|
+
} else if (frame?.event === StreamFrame.error) {
|
|
74
|
+
throw frameDataToDispatcherError(frame.data);
|
|
75
|
+
} else if (frame?.event === StreamFrame.done) {
|
|
76
|
+
sawDone = true;
|
|
77
|
+
}
|
|
78
|
+
if (!sawDone) {
|
|
79
|
+
throw buildTruncatedStreamError();
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
// cancel() releases the reader's lock too (safe on an already-drained
|
|
83
|
+
// stream) and, unlike releaseLock() alone, tells the underlying HTTP
|
|
84
|
+
// response to close instead of leaving the connection open until the
|
|
85
|
+
// server finishes writing a body no one is reading anymore.
|
|
86
|
+
await reader.cancel().catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseChunkData<TChunk>(data: string): TChunk {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(data) as TChunk;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
const cause = e instanceof Error ? e.message : String(e);
|
|
95
|
+
throw {
|
|
96
|
+
code: "stream_error",
|
|
97
|
+
httpStatus: 200,
|
|
98
|
+
i18nKey: "errors.unknown",
|
|
99
|
+
message: `malformed chunk frame: ${cause}`,
|
|
100
|
+
} satisfies DispatcherError;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildTruncatedStreamError(): DispatcherError {
|
|
105
|
+
return {
|
|
106
|
+
code: "stream_error",
|
|
107
|
+
httpStatus: 200,
|
|
108
|
+
i18nKey: "errors.unknown",
|
|
109
|
+
message: "stream ended without a done frame",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function frameDataToDispatcherError(data: string): DispatcherError {
|
|
114
|
+
let parsed: unknown;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(data);
|
|
117
|
+
} catch {
|
|
118
|
+
return {
|
|
119
|
+
code: "stream_error",
|
|
120
|
+
httpStatus: 200,
|
|
121
|
+
i18nKey: "errors.unknown",
|
|
122
|
+
message: data.length > 0 ? data : "stream error frame",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// Server serializeError shape — mapServerError expects the same fields
|
|
126
|
+
// as /api/query failures (httpStatus may be absent; reinject 200 for
|
|
127
|
+
// mid-stream gates that flush SSE headers first).
|
|
128
|
+
const err = parsed as Parameters<typeof mapServerError>[0];
|
|
129
|
+
return mapServerError({
|
|
130
|
+
...err,
|
|
131
|
+
httpStatus: err.httpStatus ?? 200,
|
|
132
|
+
});
|
|
133
|
+
}
|