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