@cosmicdrift/kumiko-dispatcher-live 0.328.0 → 0.329.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/dist/csrf.d.ts +3 -0
- package/{src/csrf.ts → dist/csrf.js} +24 -25
- package/dist/dispatcher-live.d.ts +7 -0
- package/dist/dispatcher-live.js +253 -0
- package/dist/error-mapping.d.ts +16 -0
- package/dist/error-mapping.js +85 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/locale.d.ts +2 -0
- package/{src/locale.ts → dist/locale.js} +6 -10
- package/dist/sse-stream.d.ts +14 -0
- package/dist/sse-stream.js +122 -0
- package/package.json +13 -6
- package/src/__tests__/csrf.test.ts +0 -62
- package/src/__tests__/dispatcher-live.test.ts +0 -420
- package/src/__tests__/error-mapping.test.ts +0 -155
- package/src/__tests__/sse-stream.test.ts +0 -129
- package/src/dispatcher-live.ts +0 -370
- package/src/error-mapping.ts +0 -113
- package/src/index.ts +0 -7
- package/src/sse-stream.ts +0 -133
package/dist/csrf.d.ts
ADDED
|
@@ -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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/locale.d.ts
ADDED
|
@@ -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()
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
}
|