@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dispatcher-live",
3
- "version": "1.0.0",
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": "1.0.0"
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).toEqual({ type: "app:write:task:create", payload: { title: "hello" } });
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
+ });
@@ -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
- if (opts?.requestId) body["requestId"] = opts.requestId;
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
- if (opts?.requestId) body["requestId"] = opts.requestId;
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
+ }