@cosmicdrift/kumiko-dispatcher-live 0.327.0 → 0.328.1

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/dist/csrf.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare const CSRF_COOKIE_NAME = "kumiko_csrf";
2
+ export declare const CSRF_HEADER_NAME = "X-CSRF-Token";
3
+ export declare function readCsrfToken(cookieSource?: string): string | undefined;
@@ -11,14 +11,12 @@
11
11
  // SameSite=Lax on top-level GETs, or Strict blocks them entirely) but
12
12
  // cannot READ `document.cookie` of our origin — so they can't populate the
13
13
  // header with the matching value.
14
-
15
14
  // Exported constants stay in sync with auth-middleware.ts. Kept here as
16
15
  // literals rather than imported from @cosmicdrift/kumiko-framework because this
17
16
  // package must remain server-dep-free (runs in browsers and React Native).
18
17
  // If the server ever renames the cookie, this file needs a one-line bump.
19
18
  export const CSRF_COOKIE_NAME = "kumiko_csrf";
20
19
  export const CSRF_HEADER_NAME = "X-CSRF-Token";
21
-
22
20
  // Reads the kumiko_csrf token from document.cookie. Returns undefined when:
23
21
  // - `document` isn't available (SSR, Web Worker, React Native)
24
22
  // - the cookie was never set (before login, after logout)
@@ -27,29 +25,30 @@ export const CSRF_HEADER_NAME = "X-CSRF-Token";
27
25
  // rejects with csrf_token_missing and the UI surfaces an auth-expired
28
26
  // toast. That's the right failure mode: silently skipping the header
29
27
  // would hide a broken auth state.
30
- export function readCsrfToken(cookieSource?: string): string | undefined {
31
- const raw = cookieSource ?? readDocumentCookie();
32
- if (!raw) return undefined;
33
- // cookie format: "a=1; b=2; kumiko_csrf=<uuid>; c=3"
34
- // Parse by splitting on "; " — cookie values never contain that
35
- // delimiter literally (they're percent-encoded if needed).
36
- const pairs = raw.split(/;\s*/);
37
- for (const pair of pairs) {
38
- const eq = pair.indexOf("=");
39
- if (eq < 0) continue;
40
- const name = pair.slice(0, eq);
41
- if (name === CSRF_COOKIE_NAME) {
42
- const value = pair.slice(eq + 1);
43
- return value.length > 0 ? decodeURIComponent(value) : undefined;
28
+ export function readCsrfToken(cookieSource) {
29
+ const raw = cookieSource ?? readDocumentCookie();
30
+ if (!raw)
31
+ return undefined;
32
+ // cookie format: "a=1; b=2; kumiko_csrf=<uuid>; c=3"
33
+ // Parse by splitting on "; " — cookie values never contain that
34
+ // delimiter literally (they're percent-encoded if needed).
35
+ const pairs = raw.split(/;\s*/);
36
+ for (const pair of pairs) {
37
+ const eq = pair.indexOf("=");
38
+ if (eq < 0)
39
+ continue;
40
+ const name = pair.slice(0, eq);
41
+ if (name === CSRF_COOKIE_NAME) {
42
+ const value = pair.slice(eq + 1);
43
+ return value.length > 0 ? decodeURIComponent(value) : undefined;
44
+ }
44
45
  }
45
- }
46
- return undefined;
46
+ return undefined;
47
47
  }
48
-
49
- function readDocumentCookie(): string | undefined {
50
- // Guard for non-browser environments. Avoid `typeof document` on the
51
- // left so a bundler that const-folds to "document is defined" still
52
- // compiles — the actual runtime check is what matters.
53
- const g = globalThis as { document?: { cookie?: string } };
54
- return g.document?.cookie;
48
+ function readDocumentCookie() {
49
+ // Guard for non-browser environments. Avoid `typeof document` on the
50
+ // left so a bundler that const-folds to "document is defined" still
51
+ // compiles — the actual runtime check is what matters.
52
+ const g = globalThis;
53
+ return g.document?.cookie;
55
54
  }
@@ -0,0 +1,7 @@
1
+ import { type Dispatcher } from "@cosmicdrift/kumiko-headless";
2
+ export type LiveDispatcherOptions = {
3
+ readonly baseUrl?: string;
4
+ readonly fetch?: typeof fetch;
5
+ readonly readCsrf?: () => string | undefined;
6
+ };
7
+ export declare function createLiveDispatcher(options?: LiveDispatcherOptions): Dispatcher;
@@ -0,0 +1,253 @@
1
+ import { createStore, } from "@cosmicdrift/kumiko-headless";
2
+ import { CSRF_HEADER_NAME, readCsrfToken } from "./csrf.js";
3
+ import { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping.js";
4
+ import { LOCALE_HEADER_NAME, readActiveLocale } from "./locale.js";
5
+ import { iterateSseChunks } from "./sse-stream.js";
6
+ // Paths — matched against the server routes (packages/framework/src/api/routes.ts).
7
+ // Kept as constants so refactors elsewhere (api-constants.ts bumped on the
8
+ // server) flag a mismatch here in a code review instead of at runtime.
9
+ const PATH_WRITE = "/api/write";
10
+ const PATH_QUERY = "/api/query";
11
+ const PATH_BATCH = "/api/batch";
12
+ const PATH_STREAM = "/api/stream";
13
+ export function createLiveDispatcher(options = {}) {
14
+ const baseUrl = options.baseUrl ?? "";
15
+ const readCsrf = options.readCsrf ?? (() => readCsrfToken());
16
+ // Status state — transitions between "online" and "offline" driven
17
+ // purely by call outcomes. "syncing" never fires here (live has
18
+ // nothing to catch up on). Initial "online": optimism — we haven't
19
+ // proven the server is unreachable, and a down-state on boot would
20
+ // show an offline-toast before the user has even clicked anything.
21
+ const statusStore = createStore("online");
22
+ // A status-flip drives network-error → "offline" and any subsequent
23
+ // success → "online". Typed server failures (400, 403, ...) don't flip
24
+ // status — the network reached the server, the server answered, we're
25
+ // online in every operational sense.
26
+ function observeNetworkOutcome(ok) {
27
+ statusStore.setState(ok ? "online" : "offline");
28
+ }
29
+ async function callJson(path, body, signal) {
30
+ const f = options.fetch ?? globalThis.fetch;
31
+ if (!f) {
32
+ return {
33
+ ok: false,
34
+ networkFailure: buildNetworkError("fetch is not available in this runtime — inject via LiveDispatcherOptions.fetch"),
35
+ };
36
+ }
37
+ const headers = {
38
+ "Content-Type": "application/json",
39
+ Accept: "application/json",
40
+ };
41
+ const csrf = readCsrf();
42
+ if (csrf !== undefined)
43
+ headers[CSRF_HEADER_NAME] = csrf;
44
+ const locale = readActiveLocale();
45
+ if (locale !== undefined)
46
+ headers[LOCALE_HEADER_NAME] = locale;
47
+ // If no CSRF token, still send the request — public / pre-login
48
+ // routes like /auth/login don't require CSRF, and POST /write/query/
49
+ // batch against a logged-out client returns 401 via auth-middleware
50
+ // which is a cleaner error than a csrf-mismatch.
51
+ let response;
52
+ try {
53
+ response = await f(`${baseUrl}${path}`, {
54
+ method: "POST",
55
+ credentials: "include",
56
+ headers,
57
+ body: JSON.stringify(body),
58
+ signal,
59
+ });
60
+ }
61
+ catch (e) {
62
+ // Abort surfaces as a DOMException with name="AbortError" in the
63
+ // standard fetch contract. Handle it distinctly so the caller
64
+ // doesn't see the user-cancellation as a network drop.
65
+ if (isAbortError(e)) {
66
+ return { ok: false, networkFailure: buildAbortError() };
67
+ }
68
+ observeNetworkOutcome(false);
69
+ return { ok: false, networkFailure: buildNetworkError(e) };
70
+ }
71
+ observeNetworkOutcome(true);
72
+ // JSON body parse. A server that returned a non-JSON body for any
73
+ // reason (HTML error page from a reverse-proxy, empty body on a
74
+ // weird 502) maps to network-error — structurally the same as a
75
+ // fetch-throw from the UI's perspective.
76
+ let parsed;
77
+ try {
78
+ parsed = await response.json();
79
+ }
80
+ catch (e) {
81
+ return {
82
+ ok: false,
83
+ networkFailure: buildNetworkError(`invalid JSON response (${response.status}): ${e instanceof Error ? e.message : String(e)}`),
84
+ };
85
+ }
86
+ return { ok: true, body: parsed, status: response.status };
87
+ }
88
+ return {
89
+ async write(type, payload, opts) {
90
+ const body = { type, payload };
91
+ // Idempotency by default (#761): without a requestId the server-side
92
+ // dedup never engages and a transport-level double-send duplicates
93
+ // events. Callers with a logical-submit id (savable queue, form
94
+ // controllers) pass their own and keep it across their retries.
95
+ body["requestId"] = opts?.requestId ?? generateRequestId();
96
+ const call = await callJson(PATH_WRITE, body, opts?.signal);
97
+ return normalizeWriteResult(call);
98
+ },
99
+ async query(type, payload, opts) {
100
+ const body = { type, payload };
101
+ const call = await callJson(PATH_QUERY, body, opts?.signal);
102
+ return normalizeQueryResponse(call);
103
+ },
104
+ async batch(commands, opts) {
105
+ const body = { commands };
106
+ // One id for the whole batch — the server caches the BatchResult
107
+ // under it, so a retried batch returns the cached outcome instead of
108
+ // re-executing the commands (#761).
109
+ body["requestId"] = opts?.requestId ?? generateRequestId();
110
+ const call = await callJson(PATH_BATCH, body, opts?.signal);
111
+ return normalizeBatchResponse(call);
112
+ },
113
+ async *stream(type, payload, opts) {
114
+ const f = options.fetch ?? globalThis.fetch;
115
+ if (!f) {
116
+ throw buildNetworkError("fetch is not available in this runtime — inject via LiveDispatcherOptions.fetch");
117
+ }
118
+ const headers = {
119
+ "Content-Type": "application/json",
120
+ Accept: "text/event-stream",
121
+ };
122
+ const csrf = readCsrf();
123
+ if (csrf !== undefined)
124
+ headers[CSRF_HEADER_NAME] = csrf;
125
+ const locale = readActiveLocale();
126
+ if (locale !== undefined)
127
+ headers[LOCALE_HEADER_NAME] = locale;
128
+ let response;
129
+ try {
130
+ response = await f(`${baseUrl}${PATH_STREAM}`, {
131
+ method: "POST",
132
+ credentials: "include",
133
+ headers,
134
+ body: JSON.stringify({ type, payload }),
135
+ signal: opts?.signal,
136
+ });
137
+ }
138
+ catch (e) {
139
+ if (isAbortError(e))
140
+ throw buildAbortError();
141
+ observeNetworkOutcome(false);
142
+ throw buildNetworkError(e);
143
+ }
144
+ observeNetworkOutcome(true);
145
+ const contentType = response.headers.get("content-type") ?? "";
146
+ // Pre-SSE gate failures (PAT deny, etc.) return a normal JSON error
147
+ // envelope — map them like /api/query before trying to read SSE.
148
+ if (!contentType.includes("text/event-stream")) {
149
+ let parsed;
150
+ try {
151
+ parsed = await response.json();
152
+ }
153
+ catch (e) {
154
+ throw buildNetworkError(`invalid stream response (${response.status}): ${e instanceof Error ? e.message : String(e)}`);
155
+ }
156
+ const body = parsed;
157
+ if (body?.error) {
158
+ throw mapServerError({
159
+ ...body.error,
160
+ httpStatus: body.error.httpStatus ?? response.status,
161
+ });
162
+ }
163
+ throw buildNetworkError(`unexpected non-SSE stream response (${response.status})`);
164
+ }
165
+ if (!response.body) {
166
+ throw buildNetworkError("stream response has no body");
167
+ }
168
+ try {
169
+ yield* iterateSseChunks(response.body);
170
+ }
171
+ catch (e) {
172
+ // Abort can surface on the initial fetch OR while reading the SSE
173
+ // body — both must map to the same `aborted` envelope so hooks
174
+ // (useStreamHandler) can ignore user-cancel without an error toast.
175
+ if (isAbortError(e) || opts?.signal?.aborted)
176
+ throw buildAbortError();
177
+ observeNetworkOutcome(false);
178
+ throw e;
179
+ }
180
+ },
181
+ statusStore,
182
+ // Live dispatcher has no queue. Returning a constant empty array
183
+ // keeps the contract uniform with savable; UI code that renders
184
+ // pending-badges draws nothing instead of branching on dispatcher
185
+ // type.
186
+ pendingWrites: () => EMPTY_PENDING_WRITES,
187
+ pendingFiles: () => EMPTY_PENDING_FILES,
188
+ };
189
+ }
190
+ const EMPTY_PENDING_WRITES = Object.freeze([]);
191
+ const EMPTY_PENDING_FILES = Object.freeze([]);
192
+ // crypto.randomUUID where available (browser, Bun, Node); Math.random
193
+ // fallback for React-Native runtimes without the WebCrypto polyfill.
194
+ // Uniqueness only needs to hold per user within the server's dedup window —
195
+ // this is an idempotency key, not a security token.
196
+ function generateRequestId() {
197
+ const c = globalThis.crypto;
198
+ if (typeof c?.randomUUID === "function")
199
+ return c.randomUUID();
200
+ return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
201
+ }
202
+ // Write / Batch share the same envelope: `{ isSuccess: true, data }` on
203
+ // success, `{ isSuccess: false, error: ServerErrorInfo, ... }` on failure.
204
+ // Query uses a different envelope (see normalizeQueryResponse).
205
+ function normalizeWriteResult(call) {
206
+ if (!call.ok)
207
+ return { isSuccess: false, error: call.networkFailure };
208
+ const body = call.body;
209
+ if (body.isSuccess)
210
+ return body;
211
+ return { isSuccess: false, error: mapServerError(body.error) };
212
+ }
213
+ function normalizeBatchResponse(call) {
214
+ if (!call.ok) {
215
+ return { isSuccess: false, error: call.networkFailure, failedIndex: -1, results: [] };
216
+ }
217
+ const body = call.body;
218
+ if (body.isSuccess)
219
+ return body;
220
+ return {
221
+ isSuccess: false,
222
+ error: mapServerError(body.error),
223
+ failedIndex: body.failedIndex,
224
+ results: body.results,
225
+ };
226
+ }
227
+ // Query envelope: Kumiko's /api/query returns `{ data: ... }` on success
228
+ // (no isSuccess flag) and `{ error: { code, i18nKey, message, ... } }`
229
+ // on failure. Source of truth: packages/framework/src/api/routes.ts:85
230
+ // (the query route handler that emits `c.json({ data: result })`).
231
+ // The HTTP status carries the failure-status; the error body itself
232
+ // doesn't repeat it (serializeError drops httpStatus to keep the wire
233
+ // payload lean). We have to reinject httpStatus from the Response.status
234
+ // here.
235
+ function normalizeQueryResponse(call) {
236
+ if (!call.ok)
237
+ return { isSuccess: false, error: call.networkFailure };
238
+ const body = call.body;
239
+ if (body && "error" in body && body.error) {
240
+ const errorWithStatus = {
241
+ ...body.error,
242
+ httpStatus: body.error.httpStatus ?? call.status,
243
+ };
244
+ return { isSuccess: false, error: mapServerError(errorWithStatus) };
245
+ }
246
+ return { isSuccess: true, data: body?.data };
247
+ }
248
+ function isAbortError(e) {
249
+ return (!!e &&
250
+ typeof e === "object" &&
251
+ "name" in e && // @cast-boundary error-details
252
+ e.name === "AbortError");
253
+ }
@@ -0,0 +1,16 @@
1
+ import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
2
+ type ServerErrorInfo = {
3
+ readonly code: string;
4
+ readonly httpStatus: number;
5
+ readonly i18nKey: string;
6
+ readonly i18nParams?: Readonly<Record<string, unknown>>;
7
+ readonly message: string;
8
+ readonly details?: unknown;
9
+ readonly docsUrl?: string;
10
+ readonly requestId?: string;
11
+ readonly timestamp?: string;
12
+ };
13
+ export declare function mapServerError(serverError: ServerErrorInfo): DispatcherError;
14
+ export declare function buildNetworkError(cause: unknown): DispatcherError;
15
+ export declare function buildAbortError(): DispatcherError;
16
+ export {};
@@ -0,0 +1,85 @@
1
+ // Narrow cast into the DispatcherError shape. Pass-through for everything
2
+ // except `details.fields`, which we normalize: the server uses
3
+ // ValidationFieldIssue (path/code/i18nKey/params), which is structurally
4
+ // identical to FieldIssue — but we re-build it so a future field-addition
5
+ // on either side forces a compile error here, the right place to update.
6
+ export function mapServerError(serverError) {
7
+ const normalizedDetails = normalizeDetails(serverError.details);
8
+ return {
9
+ code: serverError.code,
10
+ httpStatus: serverError.httpStatus,
11
+ i18nKey: serverError.i18nKey,
12
+ message: serverError.message,
13
+ ...(serverError.i18nParams && { i18nParams: serverError.i18nParams }),
14
+ ...(normalizedDetails && { details: normalizedDetails }),
15
+ ...(serverError.docsUrl && { docsUrl: serverError.docsUrl }),
16
+ ...(serverError.requestId && { requestId: serverError.requestId }),
17
+ };
18
+ }
19
+ function normalizeDetails(details) {
20
+ if (!details || typeof details !== "object")
21
+ return undefined;
22
+ const d = details; // @cast-boundary error-details — generic über alle DispatcherError-shapes
23
+ const fields = d["fields"];
24
+ if (!Array.isArray(fields)) {
25
+ // Details without fields still pass through — non-validation errors
26
+ // (rate-limit, version-conflict, ...) carry their own structured
27
+ // payload here.
28
+ return d;
29
+ }
30
+ const mappedFields = [];
31
+ for (const f of fields) {
32
+ if (!f || typeof f !== "object") {
33
+ // Server sent a non-object entry in details.fields — contract
34
+ // breach (ValidationFieldIssue shape is required). Warn so the
35
+ // skip doesn't hide a broken server build.
36
+ // biome-ignore lint/suspicious/noConsole: ops-visible warning when the server breaks the validation-error contract
37
+ console.warn("[dispatcher-live] dropping malformed field issue (not an object):", f);
38
+ continue;
39
+ }
40
+ const r = f; // @cast-boundary error-details
41
+ if (typeof r["path"] !== "string" ||
42
+ typeof r["code"] !== "string" ||
43
+ typeof r["i18nKey"] !== "string") {
44
+ // Entry is an object but missing required keys — same reasoning.
45
+ // biome-ignore lint/suspicious/noConsole: ops-visible warning when the server breaks the validation-error contract
46
+ console.warn("[dispatcher-live] dropping malformed field issue (missing keys):", r);
47
+ continue;
48
+ }
49
+ mappedFields.push({
50
+ path: r["path"],
51
+ code: r["code"],
52
+ i18nKey: r["i18nKey"],
53
+ ...(r["params"] !== undefined && {
54
+ params: r["params"],
55
+ }),
56
+ });
57
+ }
58
+ return { ...d, fields: mappedFields };
59
+ }
60
+ // Builds a DispatcherError for a failure that never reached the server —
61
+ // network dropped, DNS error, CORS block, fetch() threw. The UI should
62
+ // surface this differently (offline indicator, retry button) from a
63
+ // typed server-rejection; callers key off `code === "network_error"`.
64
+ export function buildNetworkError(cause) {
65
+ const message = cause instanceof Error ? cause.message : String(cause ?? "network error");
66
+ return {
67
+ code: "network_error",
68
+ // 0 is the JS fetch-failure convention (xhr.status is 0 on abort/fail
69
+ // too). Distinguishes network from typed 5xx server errors.
70
+ httpStatus: 0,
71
+ i18nKey: "dispatcher.errors.network",
72
+ message,
73
+ };
74
+ }
75
+ // Abort-specific error — used when the caller canceled via AbortSignal.
76
+ // Distinct code so a form submit that was cancelled because the user
77
+ // closed the modal doesn't toast "network error" (confusing).
78
+ export function buildAbortError() {
79
+ return {
80
+ code: "aborted",
81
+ httpStatus: 0,
82
+ i18nKey: "dispatcher.errors.aborted",
83
+ message: "request was aborted",
84
+ };
85
+ }
@@ -0,0 +1,7 @@
1
+ export { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, readCsrfToken } from "./csrf.js";
2
+ export type { LiveDispatcherOptions } from "./dispatcher-live.js";
3
+ export { createLiveDispatcher } from "./dispatcher-live.js";
4
+ export { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping.js";
5
+ export { LOCALE_HEADER_NAME, readActiveLocale } from "./locale.js";
6
+ export type { SseFrame } from "./sse-stream.js";
7
+ export { iterateSseChunks, parseSseBlock, parseSseFrames } from "./sse-stream.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, readCsrfToken } from "./csrf.js";
2
+ export { createLiveDispatcher } from "./dispatcher-live.js";
3
+ export { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping.js";
4
+ export { LOCALE_HEADER_NAME, readActiveLocale } from "./locale.js";
5
+ export { iterateSseChunks, parseSseBlock, parseSseFrames } from "./sse-stream.js";
@@ -0,0 +1,2 @@
1
+ export declare const LOCALE_HEADER_NAME = "X-Locale";
2
+ export declare function readActiveLocale(): string | undefined;
@@ -5,24 +5,20 @@
5
5
  // LocaleResolver. This module reads that marker back so every outgoing
6
6
  // request carries the language the user is ACTUALLY using — not a static
7
7
  // <html lang> — with zero app-side wiring.
8
-
9
8
  // Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand rather
10
9
  // than imported from @cosmicdrift/kumiko-framework — this package must
11
10
  // remain server-dep-free (runs in browsers and React Native).
12
11
  export const LOCALE_HEADER_NAME = "X-Locale";
13
-
14
12
  // Loose BCP-47 tag check — reject newlines/junk that would break fetch headers.
15
13
  const LOCALE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
16
-
17
14
  // Reads dataset.kumikoLocale. Returns undefined for non-browser environments
18
15
  // (SSR, Web Worker, React Native), before DocumentLangSync has run, or when
19
16
  // the value is not a safe language tag — callers skip the header and the
20
17
  // server falls back to Accept-Language, then its boot default.
21
- export function readActiveLocale(): string | undefined {
22
- const g = globalThis as {
23
- document?: { documentElement?: { dataset?: { kumikoLocale?: string } } };
24
- };
25
- const lang = g.document?.documentElement?.dataset?.kumikoLocale;
26
- if (lang === undefined || lang.length === 0) return undefined;
27
- return LOCALE_TAG.test(lang) ? lang : undefined;
18
+ export function readActiveLocale() {
19
+ const g = globalThis;
20
+ const lang = g.document?.documentElement?.dataset?.kumikoLocale;
21
+ if (lang === undefined || lang.length === 0)
22
+ return undefined;
23
+ return LOCALE_TAG.test(lang) ? lang : undefined;
28
24
  }
@@ -0,0 +1,14 @@
1
+ export type SseFrame = {
2
+ readonly event: string;
3
+ readonly data: string;
4
+ };
5
+ export declare function parseSseBlock(block: string): SseFrame | null;
6
+ /** Split a complete SSE body (tests / non-streaming buffers) into frames. */
7
+ export declare function parseSseFrames(text: string): SseFrame[];
8
+ /**
9
+ * Incremental SSE reader over a fetch body. Yields `chunk` payloads as
10
+ * parsed JSON; swallows `ping`; returns on `done`; throws DispatcherError
11
+ * on `error` frames. Caller is responsible for aborting the underlying
12
+ * fetch via AbortSignal.
13
+ */
14
+ export declare function iterateSseChunks<TChunk>(body: ReadableStream<Uint8Array>): AsyncGenerator<TChunk, void, undefined>;
@@ -0,0 +1,122 @@
1
+ import { StreamFrame } from "@cosmicdrift/kumiko-headless";
2
+ import { mapServerError } from "./error-mapping.js";
3
+ export function parseSseBlock(block) {
4
+ const trimmed = block.trim();
5
+ if (trimmed.length === 0)
6
+ return null;
7
+ const event = /^event: (.*)$/m.exec(trimmed)?.[1] ?? "";
8
+ const data = /^data: (.*)$/m.exec(trimmed)?.[1] ?? "";
9
+ return { event, data };
10
+ }
11
+ /** Split a complete SSE body (tests / non-streaming buffers) into frames. */
12
+ export function parseSseFrames(text) {
13
+ return text
14
+ .split("\n\n")
15
+ .map(parseSseBlock)
16
+ .filter((f) => f !== null);
17
+ }
18
+ /**
19
+ * Incremental SSE reader over a fetch body. Yields `chunk` payloads as
20
+ * parsed JSON; swallows `ping`; returns on `done`; throws DispatcherError
21
+ * on `error` frames. Caller is responsible for aborting the underlying
22
+ * fetch via AbortSignal.
23
+ */
24
+ export async function* iterateSseChunks(body) {
25
+ const reader = body.getReader();
26
+ const decoder = new TextDecoder();
27
+ let buffer = "";
28
+ let sawDone = false;
29
+ try {
30
+ while (true) {
31
+ const { done, value } = await reader.read();
32
+ if (done)
33
+ break;
34
+ buffer += decoder.decode(value, { stream: true });
35
+ const parts = buffer.split("\n\n");
36
+ buffer = parts.pop() ?? "";
37
+ for (const part of parts) {
38
+ const frame = parseSseBlock(part);
39
+ if (frame === null)
40
+ continue;
41
+ if (frame.event === StreamFrame.ping)
42
+ continue;
43
+ // skip: terminal SSE done frame — end the generator cleanly
44
+ if (frame.event === StreamFrame.done) {
45
+ sawDone = true;
46
+ return;
47
+ }
48
+ if (frame.event === StreamFrame.error) {
49
+ throw frameDataToDispatcherError(frame.data);
50
+ }
51
+ if (frame.event === StreamFrame.chunk) {
52
+ yield parseChunkData(frame.data);
53
+ }
54
+ }
55
+ }
56
+ // Trailing buffer without final blank line (some runtimes).
57
+ const frame = parseSseBlock(buffer);
58
+ if (frame?.event === StreamFrame.chunk) {
59
+ yield parseChunkData(frame.data);
60
+ }
61
+ else if (frame?.event === StreamFrame.error) {
62
+ throw frameDataToDispatcherError(frame.data);
63
+ }
64
+ else if (frame?.event === StreamFrame.done) {
65
+ sawDone = true;
66
+ }
67
+ if (!sawDone) {
68
+ throw buildTruncatedStreamError();
69
+ }
70
+ }
71
+ finally {
72
+ // cancel() releases the reader's lock too (safe on an already-drained
73
+ // stream) and, unlike releaseLock() alone, tells the underlying HTTP
74
+ // response to close instead of leaving the connection open until the
75
+ // server finishes writing a body no one is reading anymore.
76
+ await reader.cancel().catch(() => { });
77
+ }
78
+ }
79
+ function parseChunkData(data) {
80
+ try {
81
+ return JSON.parse(data);
82
+ }
83
+ catch (e) {
84
+ const cause = e instanceof Error ? e.message : String(e);
85
+ throw {
86
+ code: "stream_error",
87
+ httpStatus: 200,
88
+ i18nKey: "errors.unknown",
89
+ message: `malformed chunk frame: ${cause}`,
90
+ };
91
+ }
92
+ }
93
+ function buildTruncatedStreamError() {
94
+ return {
95
+ code: "stream_error",
96
+ httpStatus: 200,
97
+ i18nKey: "errors.unknown",
98
+ message: "stream ended without a done frame",
99
+ };
100
+ }
101
+ function frameDataToDispatcherError(data) {
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(data);
105
+ }
106
+ catch {
107
+ return {
108
+ code: "stream_error",
109
+ httpStatus: 200,
110
+ i18nKey: "errors.unknown",
111
+ message: data.length > 0 ? data : "stream error frame",
112
+ };
113
+ }
114
+ // Server serializeError shape — mapServerError expects the same fields
115
+ // as /api/query failures (httpStatus may be absent; reinject 200 for
116
+ // mid-stream gates that flush SSE headers first).
117
+ const err = parsed;
118
+ return mapServerError({
119
+ ...err,
120
+ httpStatus: err.httpStatus ?? 200,
121
+ });
122
+ }