@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dispatcher-live",
3
- "version": "0.158.2",
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.158.2"
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
+ });
@@ -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
+ }