@cosmicdrift/kumiko-dispatcher-live 0.165.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": "0.165.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": "0.165.0"
27
+ "@cosmicdrift/kumiko-headless": "2.0.0"
28
28
  },
29
29
  "publishConfig": {
30
30
  "registry": "https://registry.npmjs.org",
@@ -74,4 +74,56 @@ describe("iterateSseChunks", () => {
74
74
  }
75
75
  }).toThrow(/nope/);
76
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
+ });
77
129
  });
@@ -257,6 +257,7 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
257
257
  // body — both must map to the same `aborted` envelope so hooks
258
258
  // (useStreamHandler) can ignore user-cancel without an error toast.
259
259
  if (isAbortError(e) || opts?.signal?.aborted) throw buildAbortError();
260
+ observeNetworkOutcome(false);
260
261
  throw e;
261
262
  }
262
263
  },
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";
package/src/sse-stream.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
1
+ import { type DispatcherError, StreamFrame } from "@cosmicdrift/kumiko-headless";
2
2
  import { mapServerError } from "./error-mapping";
3
3
 
4
4
  // Parse the SSE wire format produced by Hono `streamSSE` for POST /api/stream:
@@ -41,6 +41,7 @@ export async function* iterateSseChunks<TChunk>(
41
41
  const reader = body.getReader();
42
42
  const decoder = new TextDecoder();
43
43
  let buffer = "";
44
+ let sawDone = false;
44
45
  try {
45
46
  while (true) {
46
47
  const { done, value } = await reader.read();
@@ -51,29 +52,64 @@ export async function* iterateSseChunks<TChunk>(
51
52
  for (const part of parts) {
52
53
  const frame = parseSseBlock(part);
53
54
  if (frame === null) continue;
54
- if (frame.event === "ping") continue;
55
+ if (frame.event === StreamFrame.ping) continue;
55
56
  // skip: terminal SSE done frame — end the generator cleanly
56
- if (frame.event === "done") return;
57
- if (frame.event === "error") {
57
+ if (frame.event === StreamFrame.done) {
58
+ sawDone = true;
59
+ return;
60
+ }
61
+ if (frame.event === StreamFrame.error) {
58
62
  throw frameDataToDispatcherError(frame.data);
59
63
  }
60
- if (frame.event === "chunk") {
61
- yield JSON.parse(frame.data) as TChunk;
64
+ if (frame.event === StreamFrame.chunk) {
65
+ yield parseChunkData<TChunk>(frame.data);
62
66
  }
63
67
  }
64
68
  }
65
69
  // Trailing buffer without final blank line (some runtimes).
66
70
  const frame = parseSseBlock(buffer);
67
- if (frame?.event === "chunk") {
68
- yield JSON.parse(frame.data) as TChunk;
69
- } else if (frame?.event === "error") {
71
+ if (frame?.event === StreamFrame.chunk) {
72
+ yield parseChunkData<TChunk>(frame.data);
73
+ } else if (frame?.event === StreamFrame.error) {
70
74
  throw frameDataToDispatcherError(frame.data);
75
+ } else if (frame?.event === StreamFrame.done) {
76
+ sawDone = true;
77
+ }
78
+ if (!sawDone) {
79
+ throw buildTruncatedStreamError();
71
80
  }
72
81
  } finally {
73
- reader.releaseLock();
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(() => {});
74
87
  }
75
88
  }
76
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
+
77
113
  function frameDataToDispatcherError(data: string): DispatcherError {
78
114
  let parsed: unknown;
79
115
  try {