@cosmicdrift/kumiko-dispatcher-live 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-dispatcher-live",
3
+ "version": "0.1.0",
4
+ "description": "HTTP-only Dispatcher for Kumiko UIs. Always-online; failures surface immediately. Default client for web admin/cockpit apps.",
5
+ "license": "BUSL-1.1",
6
+ "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
10
+ "directory": "packages/dispatcher-live"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
14
+ },
15
+ "homepage": "https://kumiko.so",
16
+ "type": "module",
17
+ "kumiko": {
18
+ "runtime": "client"
19
+ },
20
+ "exports": {
21
+ ".": "./src/index.ts"
22
+ },
23
+ "dependencies": {
24
+ "@cosmicdrift/kumiko-headless": "workspace:*"
25
+ },
26
+ "publishConfig": {
27
+ "registry": "https://registry.npmjs.org",
28
+ "access": "public"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "README.md",
33
+ "LICENSE"
34
+ ]
35
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, readCsrfToken } from "../csrf";
3
+
4
+ describe("CSRF token extraction", () => {
5
+ test("constants match server-side expectations", () => {
6
+ // If the server renames the cookie or header, these literals must
7
+ // be bumped in sync — see auth-middleware.ts.
8
+ expect(CSRF_COOKIE_NAME).toBe("kumiko_csrf");
9
+ expect(CSRF_HEADER_NAME).toBe("X-CSRF-Token");
10
+ });
11
+
12
+ test("reads the token from a cookie string with one entry", () => {
13
+ const token = readCsrfToken("kumiko_csrf=abc-123");
14
+ expect(token).toBe("abc-123");
15
+ });
16
+
17
+ test("reads the token from a cookie string with multiple entries", () => {
18
+ const raw = "kumiko_auth=xxx; kumiko_csrf=the-token; tenant=42";
19
+ expect(readCsrfToken(raw)).toBe("the-token");
20
+ });
21
+
22
+ test("handles whitespace variations between entries", () => {
23
+ // Browsers typically emit "; " but servers or testing tools can
24
+ // emit a single space, no space, or trailing semicolons.
25
+ expect(readCsrfToken("a=1;kumiko_csrf=x")).toBe("x");
26
+ expect(readCsrfToken("a=1; kumiko_csrf=x")).toBe("x");
27
+ expect(readCsrfToken("kumiko_csrf=x;")).toBe("x");
28
+ });
29
+
30
+ test("decodes percent-encoded values", () => {
31
+ // UUIDs don't need encoding, but a future migration to opaque
32
+ // tokens might. Decoding is the standard cookie-semantic.
33
+ expect(readCsrfToken("kumiko_csrf=a%20b")).toBe("a b");
34
+ });
35
+
36
+ test("returns undefined when the cookie is absent", () => {
37
+ expect(readCsrfToken("other=value")).toBeUndefined();
38
+ });
39
+
40
+ test("returns undefined for an empty cookie value", () => {
41
+ // `kumiko_csrf=` with no value — treated as "not set", caller will
42
+ // send the request without the header and the server will reject
43
+ // with csrf_token_missing (correct failure mode).
44
+ expect(readCsrfToken("kumiko_csrf=")).toBeUndefined();
45
+ });
46
+
47
+ test("returns undefined when no cookieSource and no document", () => {
48
+ // Runs in Node without a document — the safe fallback.
49
+ expect(readCsrfToken()).toBeUndefined();
50
+ });
51
+
52
+ test("substring matches don't fool the parser", () => {
53
+ // "not_kumiko_csrf" starts with "not_" — shouldn't be mistaken for
54
+ // the real cookie name. The parser splits on ; first, then on =,
55
+ // then compares the NAME exactly.
56
+ const raw = "not_kumiko_csrf=other; kumiko_csrf=real";
57
+ expect(readCsrfToken(raw)).toBe("real");
58
+
59
+ const onlyFake = "not_kumiko_csrf=other";
60
+ expect(readCsrfToken(onlyFake)).toBeUndefined();
61
+ });
62
+ });
@@ -0,0 +1,293 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { createLiveDispatcher } from "../dispatcher-live";
3
+
4
+ // Builds a fake fetch that returns a JSON body with the given
5
+ // payload and status. Exposes the captured Request argv so tests can
6
+ // assert on URL/headers/body.
7
+ function makeFetch(respond: { readonly status?: number; readonly body: unknown }): {
8
+ readonly fetch: typeof fetch;
9
+ readonly calls: Array<{ url: string; init: RequestInit }>;
10
+ } {
11
+ const calls: Array<{ url: string; init: RequestInit }> = [];
12
+ const fetchMock = vi.fn(async (url: string, init: RequestInit) => {
13
+ calls.push({ url, init });
14
+ return {
15
+ ok: (respond.status ?? 200) < 400,
16
+ status: respond.status ?? 200,
17
+ async json() {
18
+ return respond.body;
19
+ },
20
+ } as unknown as Response;
21
+ });
22
+ return { fetch: fetchMock as unknown as typeof globalThis.fetch, calls };
23
+ }
24
+
25
+ describe("createLiveDispatcher", () => {
26
+ test("write: POSTs to /api/write with Content-Type, Accept, credentials, CSRF header", async () => {
27
+ const { fetch, calls } = makeFetch({
28
+ body: { isSuccess: true, data: { id: "srv-1" } },
29
+ });
30
+ const disp = createLiveDispatcher({
31
+ fetch,
32
+ readCsrf: () => "csrf-abc",
33
+ });
34
+
35
+ const result = await disp.write("app:write:task:create", { title: "hello" });
36
+
37
+ expect(result.isSuccess).toBe(true);
38
+ if (result.isSuccess) expect((result.data as { id: string }).id).toBe("srv-1");
39
+
40
+ expect(calls).toHaveLength(1);
41
+ const call = calls[0];
42
+ expect(call?.url).toBe("/api/write");
43
+ expect(call?.init.method).toBe("POST");
44
+ expect(call?.init.credentials).toBe("include");
45
+ const headers = call?.init.headers as Record<string, string>;
46
+ expect(headers["Content-Type"]).toBe("application/json");
47
+ expect(headers["X-CSRF-Token"]).toBe("csrf-abc");
48
+ const body = JSON.parse(call?.init.body as string);
49
+ expect(body).toEqual({ type: "app:write:task:create", payload: { title: "hello" } });
50
+ });
51
+
52
+ test("write: propagates requestId into body when provided", async () => {
53
+ const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: {} } });
54
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
55
+
56
+ await disp.write("app:write:x:create", { a: 1 }, { requestId: "idem-99" });
57
+
58
+ const body = JSON.parse(calls[0]?.init.body as string);
59
+ expect(body.requestId).toBe("idem-99");
60
+ });
61
+
62
+ test("write: no CSRF token → header omitted, request still fires (server will 401/csrf-mismatch)", async () => {
63
+ const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: {} } });
64
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => undefined });
65
+
66
+ await disp.write("x", {});
67
+
68
+ const headers = calls[0]?.init.headers as Record<string, string>;
69
+ expect("X-CSRF-Token" in headers).toBe(false);
70
+ });
71
+
72
+ test("write: maps server-failure envelope to DispatcherError", async () => {
73
+ const { fetch } = makeFetch({
74
+ status: 400,
75
+ body: {
76
+ isSuccess: false,
77
+ error: {
78
+ code: "validation_error",
79
+ httpStatus: 400,
80
+ i18nKey: "errors.validation.failed",
81
+ message: "Validation failed",
82
+ details: {
83
+ fields: [{ path: "title", code: "too_small", i18nKey: "errors.validation.too_small" }],
84
+ },
85
+ },
86
+ },
87
+ });
88
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
89
+
90
+ const result = await disp.write("x", {});
91
+
92
+ expect(result.isSuccess).toBe(false);
93
+ if (!result.isSuccess) {
94
+ expect(result.error.code).toBe("validation_error");
95
+ expect(result.error.details?.fields?.[0]?.path).toBe("title");
96
+ }
97
+ });
98
+
99
+ test("query: POSTs to /api/query", async () => {
100
+ const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: [] } });
101
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
102
+
103
+ await disp.query("app:query:task:list", { limit: 10 });
104
+
105
+ expect(calls[0]?.url).toBe("/api/query");
106
+ const body = JSON.parse(calls[0]?.init.body as string);
107
+ expect(body).toEqual({ type: "app:query:task:list", payload: { limit: 10 } });
108
+ });
109
+
110
+ test("batch: POSTs to /api/batch with commands array", async () => {
111
+ const { fetch, calls } = makeFetch({
112
+ body: { isSuccess: true, results: [] },
113
+ });
114
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
115
+
116
+ const commands = [
117
+ { type: "x:write:a:create", payload: { a: 1 } },
118
+ { type: "x:write:b:create", payload: { b: 2 } },
119
+ ];
120
+ const result = await disp.batch(commands);
121
+
122
+ expect(result.isSuccess).toBe(true);
123
+ expect(calls[0]?.url).toBe("/api/batch");
124
+ const body = JSON.parse(calls[0]?.init.body as string);
125
+ expect(body.commands).toEqual(commands);
126
+ });
127
+
128
+ test("baseUrl prefix: full origin is prepended to path", async () => {
129
+ const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: {} } });
130
+ const disp = createLiveDispatcher({
131
+ baseUrl: "https://api.example.com",
132
+ fetch,
133
+ readCsrf: () => "t",
134
+ });
135
+
136
+ await disp.write("x", {});
137
+
138
+ expect(calls[0]?.url).toBe("https://api.example.com/api/write");
139
+ });
140
+
141
+ test("network error → failure with code='network_error', status flips offline", async () => {
142
+ const fetch = vi.fn(async () => {
143
+ throw new Error("ECONNREFUSED");
144
+ }) as unknown as typeof globalThis.fetch;
145
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
146
+
147
+ const seen: string[] = [];
148
+ disp.statusStore.subscribe(() => seen.push(disp.statusStore.getSnapshot()));
149
+
150
+ const result = await disp.write("x", {});
151
+
152
+ expect(result.isSuccess).toBe(false);
153
+ if (!result.isSuccess) expect(result.error.code).toBe("network_error");
154
+ expect(disp.statusStore.getSnapshot()).toBe("offline");
155
+ expect(seen).toEqual(["offline"]);
156
+ });
157
+
158
+ test("network recovery: after offline → successful call flips back to online", async () => {
159
+ let failNext = true;
160
+ const fetch = vi.fn(async () => {
161
+ if (failNext) {
162
+ failNext = false;
163
+ throw new Error("boom");
164
+ }
165
+ return {
166
+ ok: true,
167
+ status: 200,
168
+ async json() {
169
+ return { isSuccess: true, data: {} };
170
+ },
171
+ } as unknown as Response;
172
+ }) as unknown as typeof globalThis.fetch;
173
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
174
+
175
+ const seen: string[] = [];
176
+ disp.statusStore.subscribe(() => seen.push(disp.statusStore.getSnapshot()));
177
+
178
+ await disp.write("x", {}); // offline
179
+ await disp.write("x", {}); // online
180
+
181
+ expect(seen).toEqual(["offline", "online"]);
182
+ expect(disp.statusStore.getSnapshot()).toBe("online");
183
+ });
184
+
185
+ test("abort signal: request propagated + AbortError mapped to 'aborted'", async () => {
186
+ const controller = new AbortController();
187
+ const fetch = vi.fn(async (_url: string, init: RequestInit) => {
188
+ // Simulate real fetch: throw AbortError synchronously when signal
189
+ // is already aborted.
190
+ if (init.signal?.aborted) {
191
+ const e = new Error("The operation was aborted.");
192
+ e.name = "AbortError";
193
+ throw e;
194
+ }
195
+ return {
196
+ ok: true,
197
+ status: 200,
198
+ async json() {
199
+ return { isSuccess: true, data: {} };
200
+ },
201
+ } as unknown as Response;
202
+ }) as unknown as typeof globalThis.fetch;
203
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
204
+
205
+ controller.abort();
206
+ const result = await disp.write("x", {}, { signal: controller.signal });
207
+
208
+ expect(result.isSuccess).toBe(false);
209
+ if (!result.isSuccess) expect(result.error.code).toBe("aborted");
210
+ // Abort is a user cancellation, not a network outage — status MUST
211
+ // not flip to offline (UX: cancelling a save shouldn't make the
212
+ // online-indicator light up red).
213
+ expect(disp.statusStore.getSnapshot()).toBe("online");
214
+ });
215
+
216
+ test("non-JSON server response (HTML 502 page) maps to network_error", async () => {
217
+ const fetch = vi.fn(async () => {
218
+ return {
219
+ ok: false,
220
+ status: 502,
221
+ async json() {
222
+ throw new SyntaxError("Unexpected token < in JSON at position 0");
223
+ },
224
+ } as unknown as Response;
225
+ }) as unknown as typeof globalThis.fetch;
226
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
227
+
228
+ const result = await disp.write("x", {});
229
+ expect(result.isSuccess).toBe(false);
230
+ if (!result.isSuccess) expect(result.error.code).toBe("network_error");
231
+ });
232
+
233
+ test("typed server failure (400) does NOT flip status to offline — server WAS reached", async () => {
234
+ const { fetch } = makeFetch({
235
+ status: 400,
236
+ body: {
237
+ isSuccess: false,
238
+ error: {
239
+ code: "validation_error",
240
+ httpStatus: 400,
241
+ i18nKey: "errors.validation.failed",
242
+ message: "nope",
243
+ },
244
+ },
245
+ });
246
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
247
+ const seen: string[] = [];
248
+ disp.statusStore.subscribe(() => seen.push(disp.statusStore.getSnapshot()));
249
+
250
+ await disp.write("x", {});
251
+
252
+ expect(disp.statusStore.getSnapshot()).toBe("online");
253
+ expect(seen).toEqual([]); // no status flip
254
+ });
255
+
256
+ test("pendingWrites / pendingFiles always return empty arrays for live dispatcher", () => {
257
+ const disp = createLiveDispatcher({ fetch: vi.fn() as unknown as typeof globalThis.fetch });
258
+ expect(disp.pendingWrites()).toEqual([]);
259
+ expect(disp.pendingFiles()).toEqual([]);
260
+ });
261
+
262
+ test("subscribeStatus returns unsubscribe handle", async () => {
263
+ const fetch = vi.fn(async () => {
264
+ throw new Error("boom");
265
+ }) as unknown as typeof globalThis.fetch;
266
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
267
+
268
+ const listener = vi.fn();
269
+ const unsub = disp.statusStore.subscribe(listener);
270
+ await disp.write("x", {});
271
+ expect(listener).toHaveBeenCalledTimes(1);
272
+
273
+ unsub();
274
+ // A second status change (back to online) should not fire listener.
275
+ // But — if fetch keeps throwing we stay offline (no transition).
276
+ // Force a flip back by resetting fetch to success:
277
+ const fetchOk = vi.fn(async () => ({
278
+ ok: true,
279
+ status: 200,
280
+ async json() {
281
+ return { isSuccess: true, data: {} };
282
+ },
283
+ })) as unknown as typeof globalThis.fetch;
284
+ const disp2 = createLiveDispatcher({ fetch: fetchOk, readCsrf: () => "t" });
285
+ disp2.statusStore.subscribe(listener);
286
+ unsub(); // original unsub — no-op on disp2
287
+ await disp2.write("x", {});
288
+ // listener was triggered once above (initial offline), and disp2's
289
+ // listener subscription is separate — disp2 stays online, no flip,
290
+ // so listener total is still 1.
291
+ expect(listener).toHaveBeenCalledTimes(1);
292
+ });
293
+ });
@@ -0,0 +1,155 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { buildAbortError, buildNetworkError, mapServerError } from "../error-mapping";
3
+
4
+ describe("mapServerError", () => {
5
+ test("maps a minimal error envelope 1:1", () => {
6
+ const mapped = mapServerError({
7
+ code: "not_found",
8
+ httpStatus: 404,
9
+ i18nKey: "errors.notFound",
10
+ message: "entity foo not found",
11
+ });
12
+
13
+ expect(mapped).toEqual({
14
+ code: "not_found",
15
+ httpStatus: 404,
16
+ i18nKey: "errors.notFound",
17
+ message: "entity foo not found",
18
+ });
19
+ });
20
+
21
+ test("preserves i18nParams and requestId when present", () => {
22
+ const mapped = mapServerError({
23
+ code: "not_found",
24
+ httpStatus: 404,
25
+ i18nKey: "errors.notFound",
26
+ message: "not found",
27
+ i18nParams: { entity: "order", id: "42" },
28
+ requestId: "req-abc",
29
+ timestamp: "2026-04-22T10:00:00Z", // intentionally dropped
30
+ });
31
+
32
+ expect(mapped.i18nParams).toEqual({ entity: "order", id: "42" });
33
+ expect(mapped.requestId).toBe("req-abc");
34
+ expect("timestamp" in mapped).toBe(false);
35
+ });
36
+
37
+ test("maps validation-error with fields array, preserving path/code/i18nKey/params", () => {
38
+ const mapped = mapServerError({
39
+ code: "validation_error",
40
+ httpStatus: 400,
41
+ i18nKey: "errors.validation.failed",
42
+ message: "Validation failed",
43
+ details: {
44
+ fields: [
45
+ {
46
+ path: "title",
47
+ code: "too_small",
48
+ i18nKey: "errors.validation.too_small",
49
+ params: { minimum: 3 },
50
+ },
51
+ {
52
+ path: "tasks.0.title",
53
+ code: "invalid_type",
54
+ i18nKey: "errors.validation.invalid_type",
55
+ },
56
+ ],
57
+ },
58
+ });
59
+
60
+ const fields = mapped.details?.fields;
61
+ expect(fields).toHaveLength(2);
62
+ expect(fields?.[0]).toEqual({
63
+ path: "title",
64
+ code: "too_small",
65
+ i18nKey: "errors.validation.too_small",
66
+ params: { minimum: 3 },
67
+ });
68
+ expect(fields?.[1]).toEqual({
69
+ path: "tasks.0.title",
70
+ code: "invalid_type",
71
+ i18nKey: "errors.validation.invalid_type",
72
+ });
73
+ });
74
+
75
+ test("skips malformed field entries — stricter than the server, by design", () => {
76
+ // A defensive parse: if a future server emits an extra intermediate
77
+ // wrapper or garbled JSON, we drop the malformed entry instead of
78
+ // rendering undefined fields in the UI. Der Code warnt bei jedem
79
+ // Drop (ops-visible Contract-Bruch) — im Test stummschalten UND
80
+ // gleichzeitig prüfen: jeder der drei Malformed-Inputs muss genau
81
+ // einen warn() ausgelöst haben. Das macht das "silence" im Test-
82
+ // Output zur Assertion, nicht zu einem Sweep-under-the-rug.
83
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
84
+ try {
85
+ const mapped = mapServerError({
86
+ code: "validation_error",
87
+ httpStatus: 400,
88
+ i18nKey: "errors.validation.failed",
89
+ message: "x",
90
+ details: {
91
+ fields: [
92
+ { path: "ok", code: "bad", i18nKey: "k" },
93
+ { path: "missing-code" }, // malformed
94
+ null,
95
+ "not-even-an-object",
96
+ ],
97
+ },
98
+ });
99
+
100
+ expect(mapped.details?.fields).toHaveLength(1);
101
+ expect(warnSpy).toHaveBeenCalledTimes(3);
102
+ } finally {
103
+ warnSpy.mockRestore();
104
+ }
105
+ });
106
+
107
+ test("passes through non-validation details unchanged", () => {
108
+ // Rate-limit, version-conflict etc. carry their own structured
109
+ // payload under details — we don't know the shape, don't transform.
110
+ const mapped = mapServerError({
111
+ code: "version_conflict",
112
+ httpStatus: 409,
113
+ i18nKey: "errors.versionConflict",
114
+ message: "stale write",
115
+ details: { expected: 5, actual: 7 },
116
+ });
117
+
118
+ expect(mapped.details).toEqual({ expected: 5, actual: 7 });
119
+ });
120
+
121
+ test("omits details entirely when server sent none", () => {
122
+ const mapped = mapServerError({
123
+ code: "internal",
124
+ httpStatus: 500,
125
+ i18nKey: "errors.internal",
126
+ message: "boom",
127
+ });
128
+ expect("details" in mapped).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe("buildNetworkError", () => {
133
+ test("code + httpStatus=0 signal 'never reached server'", () => {
134
+ const err = buildNetworkError(new Error("ECONNREFUSED"));
135
+ expect(err.code).toBe("network_error");
136
+ expect(err.httpStatus).toBe(0);
137
+ expect(err.message).toBe("ECONNREFUSED");
138
+ });
139
+
140
+ test("handles non-Error causes gracefully", () => {
141
+ const err = buildNetworkError("string reason");
142
+ expect(err.message).toBe("string reason");
143
+
144
+ const fromNull = buildNetworkError(null);
145
+ expect(fromNull.message).toBe("network error"); // fallback
146
+ });
147
+ });
148
+
149
+ describe("buildAbortError", () => {
150
+ test("distinct code 'aborted' for user-triggered cancel", () => {
151
+ const err = buildAbortError();
152
+ expect(err.code).toBe("aborted");
153
+ expect(err.httpStatus).toBe(0);
154
+ });
155
+ });
package/src/csrf.ts ADDED
@@ -0,0 +1,55 @@
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
+ //
7
+ // The double-submit CSRF pattern: every state-changing request echoes the
8
+ // `kumiko_csrf` value into an `X-CSRF-Token` header. Server compares header
9
+ // vs cookie in csrfMiddleware and rejects on mismatch. An attacker on a
10
+ // third-party origin can trigger a cross-site fetch (cookies fly with
11
+ // SameSite=Lax on top-level GETs, or Strict blocks them entirely) but
12
+ // cannot READ `document.cookie` of our origin — so they can't populate the
13
+ // header with the matching value.
14
+
15
+ // Exported constants stay in sync with auth-middleware.ts. Kept here as
16
+ // literals rather than imported from @cosmicdrift/kumiko-framework because this
17
+ // package must remain server-dep-free (runs in browsers and React Native).
18
+ // If the server ever renames the cookie, this file needs a one-line bump.
19
+ export const CSRF_COOKIE_NAME = "kumiko_csrf";
20
+ export const CSRF_HEADER_NAME = "X-CSRF-Token";
21
+
22
+ // Reads the kumiko_csrf token from document.cookie. Returns undefined when:
23
+ // - `document` isn't available (SSR, Web Worker, React Native)
24
+ // - the cookie was never set (before login, after logout)
25
+ //
26
+ // Callers ALWAYS handle undefined — the request still goes out, the server
27
+ // rejects with csrf_token_missing and the UI surfaces an auth-expired
28
+ // toast. That's the right failure mode: silently skipping the header
29
+ // would hide a broken auth state.
30
+ export function readCsrfToken(cookieSource?: string): string | undefined {
31
+ const raw = cookieSource ?? readDocumentCookie();
32
+ if (!raw) return undefined;
33
+ // cookie format: "a=1; b=2; kumiko_csrf=<uuid>; c=3"
34
+ // Parse by splitting on "; " — cookie values never contain that
35
+ // delimiter literally (they're percent-encoded if needed).
36
+ const pairs = raw.split(/;\s*/);
37
+ for (const pair of pairs) {
38
+ const eq = pair.indexOf("=");
39
+ if (eq < 0) continue;
40
+ const name = pair.slice(0, eq);
41
+ if (name === CSRF_COOKIE_NAME) {
42
+ const value = pair.slice(eq + 1);
43
+ return value.length > 0 ? decodeURIComponent(value) : undefined;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function readDocumentCookie(): string | undefined {
50
+ // Guard for non-browser environments. Avoid `typeof document` on the
51
+ // left so a bundler that const-folds to "document is defined" still
52
+ // compiles — the actual runtime check is what matters.
53
+ const g = globalThis as { document?: { cookie?: string } };
54
+ return g.document?.cookie;
55
+ }
@@ -0,0 +1,269 @@
1
+ import {
2
+ type BatchResult,
3
+ type Command,
4
+ createStore,
5
+ type Dispatcher,
6
+ type DispatcherError,
7
+ type DispatcherStatus,
8
+ type PendingFile,
9
+ type PendingWrite,
10
+ type QueryOpts,
11
+ type QueryResult,
12
+ type WriteOpts,
13
+ type WriteResult,
14
+ } from "@cosmicdrift/kumiko-headless";
15
+ import { CSRF_HEADER_NAME, readCsrfToken } from "./csrf";
16
+ import { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping";
17
+
18
+ // HTTP-only dispatcher. Maps Kumiko's client-side Dispatcher contract to
19
+ // `POST /api/{write,query,batch}`. No local store, no queue, no retry —
20
+ // a failed call surfaces immediately. This is the right fit for:
21
+ // - web admin/cockpit apps (always-online context)
22
+ // - mobile "managed" apps where offline means "locked"
23
+ //
24
+ // For local-first UIs (mobile-field apps, PWA offline mode) use
25
+ // @cosmicdrift/kumiko-dispatcher-savable (M7) — same contract, different semantics.
26
+ //
27
+ // Wiring — app entrypoint:
28
+ // const dispatcher = createLiveDispatcher(); // defaults are fine for same-origin
29
+ // <DispatcherProvider dispatcher={dispatcher}>...
30
+ //
31
+ // Split-deploy (API on a different origin):
32
+ // const dispatcher = createLiveDispatcher({ baseUrl: "https://api.example.com" });
33
+ // // Requires CORS + matching cookie SameSite=None; Secure.
34
+
35
+ export type LiveDispatcherOptions = {
36
+ // Base URL for API calls. Default "" → relative paths (same-origin via
37
+ // Vite-proxy in dev, reverse-proxy in prod). Set to a full origin only
38
+ // for split-deploy with CORS.
39
+ readonly baseUrl?: string;
40
+ // Override fetch — used by tests to inject a spy/mock. Defaults to
41
+ // globalThis.fetch; in environments without it (very old Node,
42
+ // non-browser runtimes missing a polyfill) the dispatcher throws at
43
+ // first call instead of construction (no reason to fail boot in an
44
+ // SSR pre-pass where fetch may be wired in later).
45
+ readonly fetch?: typeof fetch;
46
+ // Source of the CSRF token. Defaults to `document.cookie`. Tests
47
+ // inject a string; React-Native apps could inject a token fetched
48
+ // from a /session endpoint when they don't have same-origin cookies.
49
+ readonly readCsrf?: () => string | undefined;
50
+ };
51
+
52
+ // Paths — matched against the server routes (packages/framework/src/api/routes.ts).
53
+ // Kept as constants so refactors elsewhere (api-constants.ts bumped on the
54
+ // server) flag a mismatch here in a code review instead of at runtime.
55
+ const PATH_WRITE = "/api/write";
56
+ const PATH_QUERY = "/api/query";
57
+ const PATH_BATCH = "/api/batch";
58
+
59
+ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispatcher {
60
+ const baseUrl = options.baseUrl ?? "";
61
+ const readCsrf = options.readCsrf ?? (() => readCsrfToken());
62
+
63
+ // Status state — transitions between "online" and "offline" driven
64
+ // purely by call outcomes. "syncing" never fires here (live has
65
+ // nothing to catch up on). Initial "online": optimism — we haven't
66
+ // proven the server is unreachable, and a down-state on boot would
67
+ // show an offline-toast before the user has even clicked anything.
68
+ const statusStore = createStore<DispatcherStatus>("online");
69
+
70
+ // A status-flip drives network-error → "offline" and any subsequent
71
+ // success → "online". Typed server failures (400, 403, ...) don't flip
72
+ // status — the network reached the server, the server answered, we're
73
+ // online in every operational sense.
74
+ function observeNetworkOutcome(ok: boolean): void {
75
+ statusStore.setState(ok ? "online" : "offline");
76
+ }
77
+
78
+ type CallResult =
79
+ | { readonly ok: true; readonly body: unknown; readonly status: number }
80
+ | { readonly ok: false; readonly networkFailure: DispatcherError };
81
+
82
+ async function callJson(
83
+ path: string,
84
+ body: unknown,
85
+ signal: AbortSignal | undefined,
86
+ ): Promise<CallResult> {
87
+ const f = options.fetch ?? globalThis.fetch;
88
+ if (!f) {
89
+ return {
90
+ ok: false,
91
+ networkFailure: buildNetworkError(
92
+ "fetch is not available in this runtime — inject via LiveDispatcherOptions.fetch",
93
+ ),
94
+ };
95
+ }
96
+
97
+ const headers: Record<string, string> = {
98
+ "Content-Type": "application/json",
99
+ Accept: "application/json",
100
+ };
101
+ const csrf = readCsrf();
102
+ if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
103
+ // If no CSRF token, still send the request — public / pre-login
104
+ // routes like /auth/login don't require CSRF, and POST /write/query/
105
+ // batch against a logged-out client returns 401 via auth-middleware
106
+ // which is a cleaner error than a csrf-mismatch.
107
+
108
+ let response: Response;
109
+ try {
110
+ response = await f(`${baseUrl}${path}`, {
111
+ method: "POST",
112
+ credentials: "include",
113
+ headers,
114
+ body: JSON.stringify(body),
115
+ signal,
116
+ });
117
+ } catch (e) {
118
+ // Abort surfaces as a DOMException with name="AbortError" in the
119
+ // standard fetch contract. Handle it distinctly so the caller
120
+ // doesn't see the user-cancellation as a network drop.
121
+ if (isAbortError(e)) {
122
+ return { ok: false, networkFailure: buildAbortError() };
123
+ }
124
+ observeNetworkOutcome(false);
125
+ return { ok: false, networkFailure: buildNetworkError(e) };
126
+ }
127
+
128
+ observeNetworkOutcome(true);
129
+
130
+ // JSON body parse. A server that returned a non-JSON body for any
131
+ // reason (HTML error page from a reverse-proxy, empty body on a
132
+ // weird 502) maps to network-error — structurally the same as a
133
+ // fetch-throw from the UI's perspective.
134
+ let parsed: unknown;
135
+ try {
136
+ parsed = await response.json();
137
+ } catch (e) {
138
+ return {
139
+ ok: false,
140
+ networkFailure: buildNetworkError(
141
+ `invalid JSON response (${response.status}): ${
142
+ e instanceof Error ? e.message : String(e)
143
+ }`,
144
+ ),
145
+ };
146
+ }
147
+ return { ok: true, body: parsed, status: response.status };
148
+ }
149
+
150
+ return {
151
+ async write<TData = unknown>(
152
+ type: string,
153
+ payload: unknown,
154
+ opts?: WriteOpts,
155
+ ): Promise<WriteResult<TData>> {
156
+ const body: Record<string, unknown> = { type, payload };
157
+ if (opts?.requestId) body["requestId"] = opts.requestId;
158
+ const call = await callJson(PATH_WRITE, body, opts?.signal);
159
+ return normalizeWriteResult<TData>(call);
160
+ },
161
+
162
+ async query<TData = unknown>(
163
+ type: string,
164
+ payload: unknown,
165
+ opts?: QueryOpts,
166
+ ): Promise<QueryResult<TData>> {
167
+ const body = { type, payload };
168
+ const call = await callJson(PATH_QUERY, body, opts?.signal);
169
+ return normalizeQueryResponse<TData>(call);
170
+ },
171
+
172
+ async batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult> {
173
+ const body: Record<string, unknown> = { commands };
174
+ if (opts?.requestId) body["requestId"] = opts.requestId;
175
+ const call = await callJson(PATH_BATCH, body, opts?.signal);
176
+ return normalizeBatchResponse(call);
177
+ },
178
+
179
+ statusStore,
180
+ // Live dispatcher has no queue. Returning a constant empty array
181
+ // keeps the contract uniform with savable; UI code that renders
182
+ // pending-badges draws nothing instead of branching on dispatcher
183
+ // type.
184
+ pendingWrites: (): readonly PendingWrite[] => EMPTY_PENDING_WRITES,
185
+ pendingFiles: (): readonly PendingFile[] => EMPTY_PENDING_FILES,
186
+ };
187
+ }
188
+
189
+ const EMPTY_PENDING_WRITES: readonly PendingWrite[] = Object.freeze([]);
190
+ const EMPTY_PENDING_FILES: readonly PendingFile[] = Object.freeze([]);
191
+
192
+ type CallOutcome =
193
+ | { readonly ok: true; readonly body: unknown; readonly status: number }
194
+ | { readonly ok: false; readonly networkFailure: DispatcherError };
195
+
196
+ // Write / Batch share the same envelope: `{ isSuccess: true, data }` on
197
+ // success, `{ isSuccess: false, error: ServerErrorInfo, ... }` on failure.
198
+ // Query uses a different envelope (see normalizeQueryResponse).
199
+ function normalizeWriteResult<TData>(call: CallOutcome): WriteResult<TData> {
200
+ if (!call.ok) return { isSuccess: false, error: call.networkFailure };
201
+ const body = call.body as
202
+ | { isSuccess: true; data: TData }
203
+ | { isSuccess: false; error: Parameters<typeof mapServerError>[0] };
204
+ if (body.isSuccess) return body;
205
+ return { isSuccess: false, error: mapServerError(body.error) };
206
+ }
207
+
208
+ function normalizeBatchResponse(call: CallOutcome): BatchResult {
209
+ if (!call.ok) {
210
+ return { isSuccess: false, error: call.networkFailure, failedIndex: -1, results: [] };
211
+ }
212
+ const body = call.body as
213
+ | BatchResult
214
+ | {
215
+ isSuccess: false;
216
+ error: Parameters<typeof mapServerError>[0];
217
+ failedIndex: number;
218
+ results: readonly WriteResult[];
219
+ };
220
+ if (body.isSuccess) return body;
221
+ return {
222
+ isSuccess: false,
223
+ error: mapServerError(body.error),
224
+ failedIndex: body.failedIndex,
225
+ results: body.results,
226
+ };
227
+ }
228
+
229
+ // Query envelope: Kumiko's /api/query returns `{ data: ... }` on success
230
+ // (no isSuccess flag) and `{ error: { code, i18nKey, message, ... } }`
231
+ // on failure. Source of truth: packages/framework/src/api/routes.ts:85
232
+ // (the query route handler that emits `c.json({ data: result })`).
233
+ // The HTTP status carries the failure-status; the error body itself
234
+ // doesn't repeat it (serializeError drops httpStatus to keep the wire
235
+ // payload lean). We have to reinject httpStatus from the Response.status
236
+ // here.
237
+ function normalizeQueryResponse<TData>(call: CallOutcome): QueryResult<TData> {
238
+ if (!call.ok) return { isSuccess: false, error: call.networkFailure };
239
+ const body = call.body as { data?: unknown; error?: ServerErrorLike };
240
+ if (body && "error" in body && body.error) {
241
+ const errorWithStatus: Parameters<typeof mapServerError>[0] = {
242
+ ...body.error,
243
+ httpStatus: body.error.httpStatus ?? call.status,
244
+ };
245
+ return { isSuccess: false, error: mapServerError(errorWithStatus) };
246
+ }
247
+ return { isSuccess: true, data: body?.data as TData };
248
+ }
249
+
250
+ // Minimal shape check — server's serialized error has code + i18nKey +
251
+ // message at minimum; we fill httpStatus from the Response if missing.
252
+ type ServerErrorLike = {
253
+ readonly code: string;
254
+ readonly httpStatus?: number;
255
+ readonly i18nKey: string;
256
+ readonly message: string;
257
+ readonly details?: unknown;
258
+ readonly i18nParams?: Readonly<Record<string, unknown>>;
259
+ readonly requestId?: string;
260
+ };
261
+
262
+ function isAbortError(e: unknown): boolean {
263
+ return (
264
+ !!e &&
265
+ typeof e === "object" &&
266
+ "name" in (e as Record<string, unknown>) && // @cast-boundary error-details
267
+ (e as { name?: unknown }).name === "AbortError"
268
+ );
269
+ }
@@ -0,0 +1,113 @@
1
+ import type { DispatcherError, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
+
3
+ // The server returns failure envelopes whose shape is nearly — but not
4
+ // exactly — DispatcherError. Kumiko's error-contract (see
5
+ // packages/framework/src/errors/) ships `code`, `httpStatus`, `i18nKey`,
6
+ // `message`, optional `i18nParams`, `details`, `requestId`, `timestamp`.
7
+ // DispatcherError is the client's trimmed view: timestamp is irrelevant
8
+ // for rendering, the rest maps 1:1. This module keeps the mapping in one
9
+ // place so a server-contract change (new field, renamed key) surfaces
10
+ // here with a typed error.
11
+
12
+ // Server's failure envelope, as serialized over JSON.
13
+ type ServerErrorInfo = {
14
+ readonly code: string;
15
+ readonly httpStatus: number;
16
+ readonly i18nKey: string;
17
+ readonly i18nParams?: Readonly<Record<string, unknown>>;
18
+ readonly message: string;
19
+ readonly details?: unknown;
20
+ readonly docsUrl?: string;
21
+ readonly requestId?: string;
22
+ readonly timestamp?: string;
23
+ };
24
+
25
+ // Narrow cast into the DispatcherError shape. Pass-through for everything
26
+ // except `details.fields`, which we normalize: the server uses
27
+ // ValidationFieldIssue (path/code/i18nKey/params), which is structurally
28
+ // identical to FieldIssue — but we re-build it so a future field-addition
29
+ // on either side forces a compile error here, the right place to update.
30
+ export function mapServerError(serverError: ServerErrorInfo): DispatcherError {
31
+ const normalizedDetails = normalizeDetails(serverError.details);
32
+ return {
33
+ code: serverError.code,
34
+ httpStatus: serverError.httpStatus,
35
+ i18nKey: serverError.i18nKey,
36
+ message: serverError.message,
37
+ ...(serverError.i18nParams && { i18nParams: serverError.i18nParams }),
38
+ ...(normalizedDetails && { details: normalizedDetails }),
39
+ ...(serverError.docsUrl && { docsUrl: serverError.docsUrl }),
40
+ ...(serverError.requestId && { requestId: serverError.requestId }),
41
+ };
42
+ }
43
+
44
+ function normalizeDetails(details: unknown): DispatcherError["details"] {
45
+ if (!details || typeof details !== "object") return undefined;
46
+ const d = details as Record<string, unknown>; // @cast-boundary error-details — generic über alle DispatcherError-shapes
47
+ const fields = d["fields"];
48
+ if (!Array.isArray(fields)) {
49
+ // Details without fields still pass through — non-validation errors
50
+ // (rate-limit, version-conflict, ...) carry their own structured
51
+ // payload here.
52
+ return d as DispatcherError["details"];
53
+ }
54
+ const mappedFields: FieldIssue[] = [];
55
+ for (const f of fields) {
56
+ if (!f || typeof f !== "object") {
57
+ // Server sent a non-object entry in details.fields — contract
58
+ // breach (ValidationFieldIssue shape is required). Warn so the
59
+ // skip doesn't hide a broken server build.
60
+ // biome-ignore lint/suspicious/noConsole: ops-visible warning when the server breaks the validation-error contract
61
+ console.warn("[dispatcher-live] dropping malformed field issue (not an object):", f);
62
+ continue;
63
+ }
64
+ const r = f as Record<string, unknown>; // @cast-boundary error-details
65
+ if (
66
+ typeof r["path"] !== "string" ||
67
+ typeof r["code"] !== "string" ||
68
+ typeof r["i18nKey"] !== "string"
69
+ ) {
70
+ // Entry is an object but missing required keys — same reasoning.
71
+ // biome-ignore lint/suspicious/noConsole: ops-visible warning when the server breaks the validation-error contract
72
+ console.warn("[dispatcher-live] dropping malformed field issue (missing keys):", r);
73
+ continue;
74
+ }
75
+ mappedFields.push({
76
+ path: r["path"],
77
+ code: r["code"],
78
+ i18nKey: r["i18nKey"],
79
+ ...(r["params"] !== undefined && {
80
+ params: r["params"] as Readonly<Record<string, unknown>>,
81
+ }),
82
+ });
83
+ }
84
+ return { ...d, fields: mappedFields };
85
+ }
86
+
87
+ // Builds a DispatcherError for a failure that never reached the server —
88
+ // network dropped, DNS error, CORS block, fetch() threw. The UI should
89
+ // surface this differently (offline indicator, retry button) from a
90
+ // typed server-rejection; callers key off `code === "network_error"`.
91
+ export function buildNetworkError(cause: unknown): DispatcherError {
92
+ const message = cause instanceof Error ? cause.message : String(cause ?? "network error");
93
+ return {
94
+ code: "network_error",
95
+ // 0 is the JS fetch-failure convention (xhr.status is 0 on abort/fail
96
+ // too). Distinguishes network from typed 5xx server errors.
97
+ httpStatus: 0,
98
+ i18nKey: "dispatcher.errors.network",
99
+ message,
100
+ };
101
+ }
102
+
103
+ // Abort-specific error — used when the caller canceled via AbortSignal.
104
+ // Distinct code so a form submit that was cancelled because the user
105
+ // closed the modal doesn't toast "network error" (confusing).
106
+ export function buildAbortError(): DispatcherError {
107
+ return {
108
+ code: "aborted",
109
+ httpStatus: 0,
110
+ i18nKey: "dispatcher.errors.aborted",
111
+ message: "request was aborted",
112
+ };
113
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, readCsrfToken } from "./csrf";
2
+ export type { LiveDispatcherOptions } from "./dispatcher-live";
3
+ export { createLiveDispatcher } from "./dispatcher-live";
4
+ export { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping";