@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 +2 -2
- package/src/__tests__/sse-stream.test.ts +52 -0
- package/src/dispatcher-live.ts +1 -0
- package/src/index.ts +2 -0
- package/src/sse-stream.ts +46 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dispatcher-live",
|
|
3
|
-
"version": "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.
|
|
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
|
});
|
package/src/dispatcher-live.ts
CHANGED
|
@@ -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
|
|
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 ===
|
|
55
|
+
if (frame.event === StreamFrame.ping) continue;
|
|
55
56
|
// skip: terminal SSE done frame — end the generator cleanly
|
|
56
|
-
if (frame.event ===
|
|
57
|
-
|
|
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 ===
|
|
61
|
-
yield
|
|
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 ===
|
|
68
|
-
yield
|
|
69
|
-
} else if (frame?.event ===
|
|
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
|
|
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 {
|