@cosmicdrift/kumiko-dispatcher-live 0.362.0 → 0.363.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.js +3 -2
- package/dist/dispatcher-live.js +54 -18
- package/dist/envelopes.d.ts +101 -0
- package/dist/envelopes.js +35 -0
- package/dist/error-mapping.js +9 -12
- package/dist/locale.js +4 -3
- package/dist/sse-stream.js +15 -9
- package/package.json +4 -3
- package/src/changes.json +6 -0
package/dist/csrf.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// cannot READ `document.cookie` of our origin — so they can't populate the
|
|
15
15
|
// header with the matching value.
|
|
16
16
|
import { CSRF_COOKIE_BASE, sessionCookieNameCandidates, } from "@cosmicdrift/kumiko-types/session-cookies";
|
|
17
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
17
18
|
// Kept here as a literal rather than imported from @cosmicdrift/kumiko-framework because this
|
|
18
19
|
// package must remain server-dep-free (runs in browsers and React Native).
|
|
19
20
|
// The framework's csrf-constants-sync test guards against drift.
|
|
@@ -54,6 +55,6 @@ function readDocumentCookie() {
|
|
|
54
55
|
// Guard for non-browser environments. Avoid `typeof document` on the
|
|
55
56
|
// left so a bundler that const-folds to "document is defined" still
|
|
56
57
|
// compiles — the actual runtime check is what matters.
|
|
57
|
-
const
|
|
58
|
-
return
|
|
58
|
+
const cookie = propertyOf(propertyOf(globalThis, "document"), "cookie");
|
|
59
|
+
return typeof cookie === "string" ? cookie : undefined;
|
|
59
60
|
}
|
package/dist/dispatcher-live.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createStore, } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
3
|
import { CSRF_HEADER_NAME, readCsrfToken } from "./csrf.js";
|
|
4
|
+
import { batchEnvelopeSchema, errorOnlyEnvelopeSchema, queryEnvelopeSchema, writeEnvelopeSchema, } from "./envelopes.js";
|
|
3
5
|
import { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping.js";
|
|
4
6
|
import { LOCALE_HEADER_NAME, readActiveLocale } from "./locale.js";
|
|
5
7
|
import { iterateSseChunks } from "./sse-stream.js";
|
|
@@ -153,9 +155,9 @@ export function createLiveDispatcher(options = {}) {
|
|
|
153
155
|
catch (e) {
|
|
154
156
|
throw buildNetworkError(`invalid stream response (${response.status}): ${e instanceof Error ? e.message : String(e)}`);
|
|
155
157
|
}
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
throw mapServerErrorWithStatus(
|
|
158
|
+
const gateFailure = errorOnlyEnvelopeSchema.safeParse(parsed);
|
|
159
|
+
if (gateFailure.success) {
|
|
160
|
+
throw mapServerErrorWithStatus(gateFailure.data.error, response.status);
|
|
159
161
|
}
|
|
160
162
|
throw buildNetworkError(`unexpected non-SSE stream response (${response.status})`);
|
|
161
163
|
}
|
|
@@ -191,34 +193,67 @@ const EMPTY_PENDING_FILES = Object.freeze([]);
|
|
|
191
193
|
// Uniqueness only needs to hold per user within the server's dedup window —
|
|
192
194
|
// this is an idempotency key, not a security token.
|
|
193
195
|
function generateRequestId() {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
}
|
|
197
203
|
return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
198
204
|
}
|
|
205
|
+
function buildUnexpectedShapeError(status) {
|
|
206
|
+
return buildNetworkError(`unexpected response shape (${status})`);
|
|
207
|
+
}
|
|
208
|
+
// Middleware gates (csrf, origin, auth, rate limits) answer write and batch
|
|
209
|
+
// calls with a bare `{ error }`, without the isSuccess envelope.
|
|
210
|
+
function gateOrShapeError(body, status) {
|
|
211
|
+
const gateFailure = errorOnlyEnvelopeSchema.safeParse(body);
|
|
212
|
+
return gateFailure.success
|
|
213
|
+
? mapServerErrorWithStatus(gateFailure.data.error, status)
|
|
214
|
+
: buildUnexpectedShapeError(status);
|
|
215
|
+
}
|
|
199
216
|
// Write / Batch share the same envelope: `{ isSuccess: true, data }` on
|
|
200
217
|
// success, `{ isSuccess: false, error: ServerErrorInfo, ... }` on failure.
|
|
201
218
|
// Query uses a different envelope (see normalizeQueryResponse).
|
|
202
219
|
function normalizeWriteResult(call) {
|
|
203
220
|
if (!call.ok)
|
|
204
221
|
return { isSuccess: false, error: call.networkFailure };
|
|
205
|
-
const
|
|
222
|
+
const envelope = writeEnvelopeSchema.safeParse(call.body);
|
|
223
|
+
if (!envelope.success) {
|
|
224
|
+
return { isSuccess: false, error: gateOrShapeError(call.body, call.status) };
|
|
225
|
+
}
|
|
226
|
+
const body = envelope.data;
|
|
206
227
|
if (body.isSuccess)
|
|
207
|
-
return body;
|
|
228
|
+
return { isSuccess: true, data: body.data };
|
|
208
229
|
return { isSuccess: false, error: mapServerErrorWithStatus(body.error, call.status) };
|
|
209
230
|
}
|
|
210
231
|
function normalizeBatchResponse(call) {
|
|
211
232
|
if (!call.ok) {
|
|
212
233
|
return { isSuccess: false, error: call.networkFailure, failedIndex: -1, results: [] };
|
|
213
234
|
}
|
|
214
|
-
const
|
|
235
|
+
const envelope = batchEnvelopeSchema.safeParse(call.body);
|
|
236
|
+
if (!envelope.success) {
|
|
237
|
+
return {
|
|
238
|
+
isSuccess: false,
|
|
239
|
+
error: gateOrShapeError(call.body, call.status),
|
|
240
|
+
failedIndex: -1,
|
|
241
|
+
results: [],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const body = envelope.data;
|
|
245
|
+
const results = body.results.map((item) => {
|
|
246
|
+
if (item.isSuccess)
|
|
247
|
+
return { isSuccess: true, data: item.data };
|
|
248
|
+
return { isSuccess: false, error: mapServerErrorWithStatus(item.error, call.status) };
|
|
249
|
+
});
|
|
215
250
|
if (body.isSuccess)
|
|
216
|
-
return
|
|
251
|
+
return { isSuccess: true, results };
|
|
217
252
|
return {
|
|
218
253
|
isSuccess: false,
|
|
219
254
|
error: mapServerErrorWithStatus(body.error, call.status),
|
|
220
255
|
failedIndex: body.failedIndex,
|
|
221
|
-
results
|
|
256
|
+
results,
|
|
222
257
|
};
|
|
223
258
|
}
|
|
224
259
|
// Query envelope: Kumiko's /api/query returns `{ data: ... }` on success
|
|
@@ -228,11 +263,15 @@ function normalizeBatchResponse(call) {
|
|
|
228
263
|
function normalizeQueryResponse(call) {
|
|
229
264
|
if (!call.ok)
|
|
230
265
|
return { isSuccess: false, error: call.networkFailure };
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
266
|
+
const envelope = queryEnvelopeSchema.safeParse(call.body);
|
|
267
|
+
if (!envelope.success) {
|
|
268
|
+
return { isSuccess: false, error: buildUnexpectedShapeError(call.status) };
|
|
269
|
+
}
|
|
270
|
+
const body = envelope.data;
|
|
271
|
+
if (body.error) {
|
|
233
272
|
return { isSuccess: false, error: mapServerErrorWithStatus(body.error, call.status) };
|
|
234
273
|
}
|
|
235
|
-
return { isSuccess: true, data: body
|
|
274
|
+
return { isSuccess: true, data: body.data };
|
|
236
275
|
}
|
|
237
276
|
// The HTTP status carries the failure-status; the error body itself
|
|
238
277
|
// doesn't repeat it (serializeError drops httpStatus to keep the wire
|
|
@@ -242,8 +281,5 @@ function mapServerErrorWithStatus(error, responseStatus) {
|
|
|
242
281
|
return mapServerError({ ...error, httpStatus: error.httpStatus ?? responseStatus });
|
|
243
282
|
}
|
|
244
283
|
function isAbortError(e) {
|
|
245
|
-
return
|
|
246
|
-
typeof e === "object" &&
|
|
247
|
-
"name" in e && // @cast-boundary error-details
|
|
248
|
-
e.name === "AbortError");
|
|
284
|
+
return !!e && typeof e === "object" && "name" in e && e.name === "AbortError";
|
|
249
285
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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>;
|
|
@@ -0,0 +1,35 @@
|
|
|
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.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
// Narrow cast into the DispatcherError shape. Pass-through for everything
|
|
2
3
|
// except `details.fields`, which we normalize: the server uses
|
|
3
4
|
// ValidationFieldIssue (path/code/i18nKey/params), which is structurally
|
|
@@ -17,27 +18,25 @@ export function mapServerError(serverError) {
|
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
function normalizeDetails(details) {
|
|
20
|
-
if (!details
|
|
21
|
+
if (!isPlainObject(details))
|
|
21
22
|
return undefined;
|
|
22
|
-
const
|
|
23
|
-
const fields = d["fields"];
|
|
23
|
+
const { fields, ...otherDetails } = details;
|
|
24
24
|
if (!Array.isArray(fields)) {
|
|
25
25
|
// Details without fields still pass through — non-validation errors
|
|
26
26
|
// (rate-limit, version-conflict, ...) carry their own structured
|
|
27
27
|
// payload here.
|
|
28
|
-
return
|
|
28
|
+
return otherDetails;
|
|
29
29
|
}
|
|
30
30
|
const mappedFields = [];
|
|
31
|
-
for (const
|
|
32
|
-
if (!
|
|
31
|
+
for (const r of fields) {
|
|
32
|
+
if (!isPlainObject(r)) {
|
|
33
33
|
// Server sent a non-object entry in details.fields — contract
|
|
34
34
|
// breach (ValidationFieldIssue shape is required). Warn so the
|
|
35
35
|
// skip doesn't hide a broken server build.
|
|
36
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):",
|
|
37
|
+
console.warn("[dispatcher-live] dropping malformed field issue (not an object):", r);
|
|
38
38
|
continue;
|
|
39
39
|
}
|
|
40
|
-
const r = f; // @cast-boundary error-details
|
|
41
40
|
if (typeof r["path"] !== "string" ||
|
|
42
41
|
typeof r["code"] !== "string" ||
|
|
43
42
|
typeof r["i18nKey"] !== "string") {
|
|
@@ -50,12 +49,10 @@ function normalizeDetails(details) {
|
|
|
50
49
|
path: r["path"],
|
|
51
50
|
code: r["code"],
|
|
52
51
|
i18nKey: r["i18nKey"],
|
|
53
|
-
...(r["params"]
|
|
54
|
-
params: r["params"],
|
|
55
|
-
}),
|
|
52
|
+
...(isPlainObject(r["params"]) && { params: r["params"] }),
|
|
56
53
|
});
|
|
57
54
|
}
|
|
58
|
-
return { ...
|
|
55
|
+
return { ...otherDetails, fields: mappedFields };
|
|
59
56
|
}
|
|
60
57
|
// Builds a DispatcherError for a failure that never reached the server —
|
|
61
58
|
// network dropped, DNS error, CORS block, fetch() threw. The UI should
|
package/dist/locale.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
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
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
8
9
|
// Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand rather
|
|
9
10
|
// than imported from @cosmicdrift/kumiko-framework — this package must
|
|
10
11
|
// remain server-dep-free (runs in browsers and React Native).
|
|
@@ -16,9 +17,9 @@ const LOCALE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
|
16
17
|
// the value is not a safe language tag — callers skip the header and the
|
|
17
18
|
// server falls back to Accept-Language, then its boot default.
|
|
18
19
|
export function readActiveLocale() {
|
|
19
|
-
const
|
|
20
|
-
const lang =
|
|
21
|
-
if (lang
|
|
20
|
+
const dataset = propertyOf(propertyOf(propertyOf(globalThis, "document"), "documentElement"), "dataset");
|
|
21
|
+
const lang = propertyOf(dataset, "kumikoLocale");
|
|
22
|
+
if (typeof lang !== "string" || lang.length === 0)
|
|
22
23
|
return undefined;
|
|
23
24
|
return LOCALE_TAG.test(lang) ? lang : undefined;
|
|
24
25
|
}
|
package/dist/sse-stream.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { StreamFrame } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { serverErrorSchema } from "./envelopes.js";
|
|
2
3
|
import { mapServerError } from "./error-mapping.js";
|
|
3
4
|
export function parseSseBlock(block) {
|
|
4
5
|
const trimmed = block.trim();
|
|
@@ -104,19 +105,24 @@ function frameDataToDispatcherError(data) {
|
|
|
104
105
|
parsed = JSON.parse(data);
|
|
105
106
|
}
|
|
106
107
|
catch {
|
|
107
|
-
return
|
|
108
|
-
code: "stream_error",
|
|
109
|
-
httpStatus: 200,
|
|
110
|
-
i18nKey: "errors.unknown",
|
|
111
|
-
message: data.length > 0 ? data : "stream error frame",
|
|
112
|
-
};
|
|
108
|
+
return unparsableErrorFrame(data);
|
|
113
109
|
}
|
|
114
110
|
// Server serializeError shape — mapServerError expects the same fields
|
|
115
111
|
// as /api/query failures (httpStatus may be absent; reinject 200 for
|
|
116
112
|
// mid-stream gates that flush SSE headers first).
|
|
117
|
-
const
|
|
113
|
+
const envelope = serverErrorSchema.safeParse(parsed);
|
|
114
|
+
if (!envelope.success)
|
|
115
|
+
return unparsableErrorFrame(data);
|
|
118
116
|
return mapServerError({
|
|
119
|
-
...
|
|
120
|
-
httpStatus:
|
|
117
|
+
...envelope.data,
|
|
118
|
+
httpStatus: envelope.data.httpStatus ?? 200,
|
|
121
119
|
});
|
|
122
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dispatcher-live",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.363.0",
|
|
4
4
|
"description": "HTTP-only Dispatcher for Kumiko UIs. Always-online; failures surface immediately. Default client for web admin/cockpit apps.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
28
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
27
|
+
"@cosmicdrift/kumiko-headless": "0.363.0",
|
|
28
|
+
"@cosmicdrift/kumiko-types": "0.363.0",
|
|
29
|
+
"zod": "^4.4.3"
|
|
29
30
|
},
|
|
30
31
|
"publishConfig": {
|
|
31
32
|
"registry": "https://registry.npmjs.org",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.363.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "dispatcher-live validates server envelopes with Zod",
|
|
6
|
+
"detail": "Query, write, batch and stream responses are parsed against Zod schemas for the envelope (isSuccess, error, failedIndex, results); the data payload stays typed by the caller. A malformed envelope now yields the network_error fallback (\"unexpected response shape\") instead of passing wrongly typed fields through; bare gate errors ({ error } from csrf, origin, auth or rate limits) keep their code, and a missing i18nKey defaults to errors.unknown; a malformed SSE error frame yields stream_error, and failing batch items get httpStatus filled from the response like top-level errors. renderer-web types CSS custom properties on React's CSSProperties instead of casting style objects."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.338.0",
|
|
4
10
|
"type": "fix",
|