@cosmicdrift/kumiko-dispatcher-live 0.158.2 → 0.160.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 +87 -0
- package/src/__tests__/sse-stream.test.ts +77 -0
- package/src/dispatcher-live.ts +78 -0
- package/src/index.ts +2 -0
- package/src/sse-stream.ts +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dispatcher-live",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.160.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": "0.
|
|
27
|
+
"@cosmicdrift/kumiko-headless": "0.160.0"
|
|
28
28
|
},
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"registry": "https://registry.npmjs.org",
|
|
@@ -299,6 +299,93 @@ describe("createLiveDispatcher", () => {
|
|
|
299
299
|
expect(disp.pendingFiles()).toEqual([]);
|
|
300
300
|
});
|
|
301
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
|
+
|
|
302
389
|
test("subscribeStatus returns unsubscribe handle", async () => {
|
|
303
390
|
const fetch = mock(async () => {
|
|
304
391
|
throw new Error("boom");
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
});
|
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 ?? "";
|
|
@@ -183,6 +186,81 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
|
|
|
183
186
|
return normalizeBatchResponse(call);
|
|
184
187
|
},
|
|
185
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
|
+
throw e;
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
|
|
186
264
|
statusStore,
|
|
187
265
|
// Live dispatcher has no queue. Returning a constant empty array
|
|
188
266
|
// keeps the contract uniform with savable; UI code that renders
|
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, parseSseFrames } from "./sse-stream";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { DispatcherError } 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
|
+
try {
|
|
45
|
+
while (true) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done) break;
|
|
48
|
+
buffer += decoder.decode(value, { stream: true });
|
|
49
|
+
const parts = buffer.split("\n\n");
|
|
50
|
+
buffer = parts.pop() ?? "";
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
const frame = parseSseBlock(part);
|
|
53
|
+
if (frame === null) continue;
|
|
54
|
+
if (frame.event === "ping") continue;
|
|
55
|
+
// skip: terminal SSE done frame — end the generator cleanly
|
|
56
|
+
if (frame.event === "done") return;
|
|
57
|
+
if (frame.event === "error") {
|
|
58
|
+
throw frameDataToDispatcherError(frame.data);
|
|
59
|
+
}
|
|
60
|
+
if (frame.event === "chunk") {
|
|
61
|
+
yield JSON.parse(frame.data) as TChunk;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Trailing buffer without final blank line (some runtimes).
|
|
66
|
+
const frame = parseSseBlock(buffer);
|
|
67
|
+
if (frame?.event === "chunk") {
|
|
68
|
+
yield JSON.parse(frame.data) as TChunk;
|
|
69
|
+
} else if (frame?.event === "error") {
|
|
70
|
+
throw frameDataToDispatcherError(frame.data);
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
reader.releaseLock();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function frameDataToDispatcherError(data: string): DispatcherError {
|
|
78
|
+
let parsed: unknown;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(data);
|
|
81
|
+
} catch {
|
|
82
|
+
return {
|
|
83
|
+
code: "stream_error",
|
|
84
|
+
httpStatus: 200,
|
|
85
|
+
i18nKey: "errors.unknown",
|
|
86
|
+
message: data.length > 0 ? data : "stream error frame",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Server serializeError shape — mapServerError expects the same fields
|
|
90
|
+
// as /api/query failures (httpStatus may be absent; reinject 200 for
|
|
91
|
+
// mid-stream gates that flush SSE headers first).
|
|
92
|
+
const err = parsed as Parameters<typeof mapServerError>[0];
|
|
93
|
+
return mapServerError({
|
|
94
|
+
...err,
|
|
95
|
+
httpStatus: err.httpStatus ?? 200,
|
|
96
|
+
});
|
|
97
|
+
}
|