@cosmicdrift/kumiko-server-runtime 0.326.1 → 0.328.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.326.1",
3
+ "version": "0.328.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -62,6 +62,10 @@
62
62
  "types": "./src/extra-routes-deps.ts",
63
63
  "default": "./src/extra-routes-deps.ts"
64
64
  },
65
+ "./renderer-web-fonts": {
66
+ "types": "./src/renderer-web-fonts.ts",
67
+ "default": "./src/renderer-web-fonts.ts"
68
+ },
65
69
  "./resolve-tailwind-cli": {
66
70
  "types": "./src/resolve-tailwind-cli.ts",
67
71
  "default": "./src/resolve-tailwind-cli.ts"
@@ -76,9 +80,9 @@
76
80
  }
77
81
  },
78
82
  "dependencies": {
79
- "@cosmicdrift/kumiko-bundled-features": "0.326.1",
80
- "@cosmicdrift/kumiko-framework": "0.326.1",
81
- "@cosmicdrift/kumiko-headless": "0.326.1",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.328.0",
84
+ "@cosmicdrift/kumiko-framework": "0.328.0",
85
+ "@cosmicdrift/kumiko-headless": "0.328.0",
82
86
  "temporal-polyfill": "^0.3.2"
83
87
  },
84
88
  "publishConfig": {
@@ -118,6 +118,46 @@ describe("buildProdBundle in-process (Bun.build)", () => {
118
118
  }
119
119
  });
120
120
 
121
+ test("renderer-web stylesheet → woff2 files land where the CSS points, no external font URL", async () => {
122
+ const dir = await mkdtemp(join(REPO_ROOT, ".inproc-fonts-"));
123
+ const cwd = join(dir, "app");
124
+ try {
125
+ await mkdir(join(cwd, "src"), { recursive: true });
126
+ await writeFile(
127
+ join(cwd, "src/client.ts"),
128
+ `const root = document.getElementById("root"); if (root) root.textContent = "hi";`,
129
+ );
130
+ await writeFile(
131
+ join(cwd, "src/styles.css"),
132
+ `@import "@cosmicdrift/kumiko-renderer-web/styles.css";\n`,
133
+ );
134
+ await mkdir(join(cwd, "public"), { recursive: true });
135
+ await writeFile(
136
+ join(cwd, "public/index.html"),
137
+ `<!doctype html><html><head><link rel="stylesheet" href="/styles.css" /></head><body><div id="root"></div><script type="module" src="/client.js"></script></body></html>`,
138
+ );
139
+ await writeFile(join(cwd, "package.json"), `{"name":"inproc-fonts","private":true}`);
140
+
141
+ const result = await buildProdBundle({ cwd, stylesheet: "src/styles.css" });
142
+
143
+ const css = await readFile(join(cwd, "dist", result.manifest["styles.css"] ?? ""), "utf8");
144
+ const referenced = [
145
+ ...css.matchAll(/url\("?(\/assets\/kumiko\/fonts\/[a-z0-9-]+\.woff2)"?\)/g),
146
+ ].map((m) => m[1] ?? "");
147
+ expect(referenced.length).toBeGreaterThanOrEqual(10);
148
+ for (const url of referenced) {
149
+ const file = join(cwd, "dist", url);
150
+ expect(existsSync(file)).toBe(true);
151
+ // woff2 magic number
152
+ expect((await readFile(file)).subarray(0, 4).toString("latin1")).toBe("wOF2");
153
+ }
154
+ expect(css).not.toMatch(/url\(\s*["']?(https?:)?\/\//);
155
+ expect(css).not.toContain("data:font");
156
+ } finally {
157
+ await rm(dir, { recursive: true, force: true });
158
+ }
159
+ });
160
+
121
161
  test("missing stylesheet override → tailwind rejects", async () => {
122
162
  const dir = await mkdtemp(join(REPO_ROOT, ".inproc-styles-miss-"));
123
163
  const cwd = join(dir, "app");
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  import { runTailwindOnce } from "../build-prod-bundle";
12
12
 
13
13
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
14
- const FRAMEWORK_LIGHT_PRIMARY = "--color-primary:#171717";
14
+ const FRAMEWORK_LIGHT_PRIMARY = "--color-primary:#1a1c1e";
15
15
 
16
16
  function bunAvailable(): boolean {
17
17
  try {
@@ -5,7 +5,14 @@
5
5
  // Tweaks.
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
- import { SSE_HEARTBEAT_INTERVAL_MS } from "@cosmicdrift/kumiko-framework/api";
8
+ import {
9
+ type KumikoServeEnv,
10
+ type KumikoWebSocketData,
11
+ SSE_HEARTBEAT_INTERVAL_MS,
12
+ WEBSOCKET_BACKPRESSURE_LIMIT_BYTES,
13
+ WEBSOCKET_HEARTBEAT_INTERVAL_MS,
14
+ WEBSOCKET_MAX_PAYLOAD_BYTES,
15
+ } from "@cosmicdrift/kumiko-framework/api";
9
16
  import {
10
17
  createEntity,
11
18
  createFileField,
@@ -30,6 +37,56 @@ describe("Bun.serve options for production", () => {
30
37
  expect(opts.idleTimeout).toBe(0);
31
38
  });
32
39
 
40
+ test("websocket payload cap is 1 MiB and the ping cadence stays under nginx's 60 s", () => {
41
+ // ingress-nginx's default proxy-read-timeout is 60 s: a quiet socket needs a server ping below it.
42
+ const opts = buildBunServeOptions(0, () => new Response("ok"));
43
+ expect(opts.websocket.maxPayloadLength).toBe(1024 * 1024);
44
+ expect(WEBSOCKET_MAX_PAYLOAD_BYTES).toBe(1024 * 1024);
45
+ expect(WEBSOCKET_HEARTBEAT_INTERVAL_MS).toBeLessThan(60_000);
46
+ });
47
+
48
+ test("a stalled reader is cut off: 4 MiB backpressure limit, close on limit", () => {
49
+ const opts = buildBunServeOptions(0, () => new Response("ok"));
50
+ expect(opts.websocket.backpressureLimit).toBe(4 * 1024 * 1024);
51
+ expect(WEBSOCKET_BACKPRESSURE_LIMIT_BYTES).toBe(4 * 1024 * 1024);
52
+ expect(opts.websocket.closeOnBackpressureLimit).toBe(true);
53
+ });
54
+
55
+ test("upgrade requests go to webSocketUpgradeFetch with the server handle, others to fetchHandler", async () => {
56
+ const plain: string[] = [];
57
+ const upgraded: KumikoServeEnv[] = [];
58
+ const opts = buildBunServeOptions(
59
+ 0,
60
+ (_req, socketAddress) => {
61
+ plain.push(socketAddress ?? "none");
62
+ return new Response("plain");
63
+ },
64
+ undefined,
65
+ {
66
+ upgradeFetch: (_req, serveEnv) => {
67
+ upgraded.push(serveEnv);
68
+ return new Response("upgrade");
69
+ },
70
+ },
71
+ );
72
+ const fakeServer = { requestIP: () => ({ address: "203.0.113.9", port: 1, family: "IPv4" }) };
73
+ const server = fakeServer as unknown as Bun.Server<KumikoWebSocketData>;
74
+ await opts.fetch(new Request("http://localhost/api/x"), server);
75
+ await opts.fetch(
76
+ new Request("http://localhost/api/ws/x", { headers: { Upgrade: "WebSocket" } }),
77
+ server,
78
+ );
79
+ // An upgrade outside /api/ws/ (dev HMR etc.) stays on the normal fetch path.
80
+ await opts.fetch(
81
+ new Request("http://localhost/__hmr", { headers: { Upgrade: "websocket" } }),
82
+ server,
83
+ );
84
+ expect(plain).toEqual(["203.0.113.9", "203.0.113.9"]);
85
+ expect(upgraded).toHaveLength(1);
86
+ expect(upgraded[0]?.socketAddress).toBe("203.0.113.9");
87
+ expect(upgraded[0]?.server).toBe(server);
88
+ });
89
+
33
90
  test("port wird 1:1 durchgereicht, fetch reicht req + Socket-Adresse an den Handler weiter", async () => {
34
91
  let received: [Request, string | undefined] | undefined;
35
92
  const fetchHandler = (req: Request, socketAddress?: string) => {
@@ -1097,6 +1097,35 @@ describe("runProdApp — real Bun.serve socket resolves the client IP (fw#3260 a
1097
1097
  });
1098
1098
  });
1099
1099
 
1100
+ describe("runProdApp — rejected WebSocket upgrades carry the security headers", () => {
1101
+ const wsProbeFeature = defineFeature("ws-probe", (r) => {
1102
+ r.webSocketRoute({ path: "/api/ws/probe", connect: () => ({}) });
1103
+ });
1104
+
1105
+ test("a real upgrade request refused by the route (no session) still gets nosniff", async () => {
1106
+ const handle = await boot(undefined, {
1107
+ features: [widgetFeature, wsProbeFeature],
1108
+ autoListen: true,
1109
+ anonymousAccess: { defaultTenantId: TENANT_ID },
1110
+ });
1111
+ const port = handle.server?.port;
1112
+ if (port === undefined) throw new Error("expected handle.server to be listening");
1113
+
1114
+ const res = await fetch(`http://127.0.0.1:${port}/api/ws/probe`, {
1115
+ headers: {
1116
+ Connection: "Upgrade",
1117
+ Upgrade: "websocket",
1118
+ "Sec-WebSocket-Version": "13",
1119
+ "Sec-WebSocket-Key": Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString(
1120
+ "base64",
1121
+ ),
1122
+ },
1123
+ });
1124
+ expect(res.status).toBe(401);
1125
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
1126
+ });
1127
+ });
1128
+
1100
1129
  describe("runProdApp: lokaler Event-Dispatcher (MSP-Anwendung im Single-Container)", () => {
1101
1130
  // Regression für den 2026-06-11-Incident: runProdApp baute den
1102
1131
  // Event-Dispatcher nie ({disabled:true} im API-Entrypoint) — jede
@@ -0,0 +1,580 @@
1
+ // r.webSocketRoute over a real Bun.serve socket: upgrade guards (origin,
2
+ // session, upgrade header), connect-time rejection, and the live message
3
+ // path (text/binary echo, size cap, handler failure). anonymousAccess is wired
4
+ // on purpose (see http-route-entry.integration.test.ts): without it a route
5
+ // mounted behind the wrong guard would 401 on a missing token too, masking the
6
+ // bug the "no session" test exists to catch.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { AUTH_COOKIE_NAME, type KumikoWebSocketData } from "@cosmicdrift/kumiko-framework/api";
10
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
12
+ import { buildBunServeOptions } from "../bun-serve-options";
13
+
14
+ const APP_ORIGIN = "https://app.example";
15
+ const EVIL_ORIGIN = "https://evil.example";
16
+ const MAX_MESSAGE_BYTES = 1024;
17
+ const membershipQuery = "tenant:query:memberships";
18
+
19
+ type Deferred = { readonly promise: Promise<void>; readonly resolve: () => void };
20
+
21
+ function deferred(): Deferred {
22
+ let resolve: () => void = () => {};
23
+ const promise = new Promise<void>((done) => {
24
+ resolve = done;
25
+ });
26
+ return { promise, resolve };
27
+ }
28
+
29
+ // Per-test gates the routes below wait on, so ordering and slot release are
30
+ // driven by explicit signals instead of timers.
31
+ let orderedGate = deferred();
32
+ let hangingClosed = deferred();
33
+ let hangingSignal: AbortSignal | undefined;
34
+ const hangingStarted: string[] = [];
35
+ // A function read keeps TS from narrowing the module-level `let` to its last assignment.
36
+ const heldSignal = (): AbortSignal | undefined => hangingSignal;
37
+ let limitedClosed = deferred();
38
+ let limitedConnectCalls = 0;
39
+ let gatedRejects = true;
40
+
41
+ const wsFeature = defineFeature("ws-test", (r) => {
42
+ r.webSocketRoute({
43
+ path: "/api/ws/ordered",
44
+ connect: () => ({
45
+ onMessage: async (data, connection) => {
46
+ if (data === "slow") await orderedGate.promise;
47
+ connection.send(data);
48
+ },
49
+ }),
50
+ });
51
+ r.webSocketRoute({
52
+ path: "/api/ws/hanging",
53
+ connect: () => ({
54
+ onMessage: (data, connection) => {
55
+ hangingStarted.push(String(data));
56
+ hangingSignal = connection.signal;
57
+ return new Promise<void>(() => {});
58
+ },
59
+ onClose: (_code, _reason, connection) => {
60
+ hangingSignal = connection.signal;
61
+ hangingClosed.resolve();
62
+ },
63
+ }),
64
+ });
65
+ r.webSocketRoute({
66
+ path: "/api/ws/limited",
67
+ maxConnectionsPerUser: 2,
68
+ connect: () => {
69
+ limitedConnectCalls += 1;
70
+ return { onClose: () => limitedClosed.resolve() };
71
+ },
72
+ });
73
+ r.webSocketRoute({
74
+ path: "/api/ws/gated",
75
+ maxConnectionsPerUser: 1,
76
+ connect: () => (gatedRejects ? new Response(null, { status: 409 }) : {}),
77
+ });
78
+ r.webSocketRoute({
79
+ path: "/api/ws/echo",
80
+ maxMessageBytes: MAX_MESSAGE_BYTES,
81
+ connect: (_c, { user }) => ({
82
+ onOpen: (connection) => connection.send(JSON.stringify({ userId: user.id })),
83
+ onMessage: (data, connection) => connection.send(data),
84
+ }),
85
+ });
86
+ r.webSocketRoute({
87
+ path: "/api/ws/rejected",
88
+ connect: () => new Response(null, { status: 409 }),
89
+ });
90
+ r.webSocketRoute({
91
+ path: "/api/ws/explosive",
92
+ connect: () => ({
93
+ onMessage: () => {
94
+ throw new Error("handler failure");
95
+ },
96
+ }),
97
+ });
98
+ });
99
+
100
+ function upgradeHeaders(extra: Record<string, string>): Record<string, string> {
101
+ return {
102
+ Connection: "Upgrade",
103
+ Upgrade: "websocket",
104
+ "Sec-WebSocket-Version": "13",
105
+ "Sec-WebSocket-Key": Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64"),
106
+ ...extra,
107
+ };
108
+ }
109
+
110
+ async function errorCode(res: Response): Promise<string | undefined> {
111
+ const body: unknown = await res.json();
112
+ if (typeof body !== "object" || body === null || !("error" in body)) return undefined;
113
+ const { error } = body;
114
+ if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
115
+ return typeof error.code === "string" ? error.code : undefined;
116
+ }
117
+
118
+ // Bun's WebSocket accepts request headers (needed for Cookie/Origin), which lib.dom's typing omits.
119
+ const BunWebSocket = WebSocket as unknown as new (
120
+ url: string,
121
+ options: { headers: Record<string, string> },
122
+ ) => WebSocket;
123
+
124
+ type ClientSocket = {
125
+ readonly opened: Promise<void>;
126
+ readonly closed: Promise<CloseEvent>;
127
+ readonly next: () => Promise<unknown>;
128
+ readonly send: (data: string | Uint8Array<ArrayBuffer>) => void;
129
+ readonly close: () => void;
130
+ };
131
+
132
+ // Listeners attach at construction so a message sent during the upgrade
133
+ // (onOpen) is never lost between "open" and the test's first await.
134
+ function connectSocket(url: string, headers: Record<string, string>): ClientSocket {
135
+ const ws = new BunWebSocket(url, { headers });
136
+ ws.binaryType = "arraybuffer";
137
+ const inbox: unknown[] = [];
138
+ const waiters: ((value: unknown) => void)[] = [];
139
+ ws.addEventListener("message", (event) => {
140
+ const waiter = waiters.shift();
141
+ if (waiter) waiter(event.data);
142
+ else inbox.push(event.data);
143
+ });
144
+ return {
145
+ opened: new Promise<void>((resolve, reject) => {
146
+ ws.addEventListener("open", () => resolve());
147
+ ws.addEventListener("error", () => reject(new Error(`WebSocket to ${url} failed`)));
148
+ }),
149
+ closed: new Promise<CloseEvent>((resolve) => ws.addEventListener("close", resolve)),
150
+ next: () =>
151
+ inbox.length > 0
152
+ ? Promise.resolve(inbox.shift())
153
+ : new Promise((resolve) => waiters.push(resolve)),
154
+ send: (data) => ws.send(data),
155
+ close: () => ws.close(),
156
+ };
157
+ }
158
+
159
+ type Harness = {
160
+ readonly stack: TestStack;
161
+ readonly server: Bun.Server<KumikoWebSocketData>;
162
+ readonly httpOrigin: string;
163
+ readonly url: (path: string) => string;
164
+ readonly cookie: (sid?: string) => Promise<string>;
165
+ /** Call before sending: resolves once Bun's websocket message callback has run `count` more times. */
166
+ readonly nextMessages: (count: number) => Promise<void>;
167
+ };
168
+
169
+ async function startHarness(
170
+ authConfig: Parameters<typeof setupTestStack>[0]["authConfig"],
171
+ heartbeatIntervalMs?: number,
172
+ ): Promise<Harness> {
173
+ const stack = await setupTestStack({
174
+ features: [wsFeature],
175
+ anonymousAccess: { defaultTenantId: TestUsers.user.tenantId },
176
+ ...(authConfig ? { authConfig } : {}),
177
+ });
178
+ const options = buildBunServeOptions(
179
+ 0,
180
+ (req, socketAddress) => stack.app.fetch(req, socketAddress),
181
+ undefined,
182
+ {
183
+ upgradeFetch: (req, serveEnv) => stack.app.fetch(req, serveEnv),
184
+ ...(heartbeatIntervalMs !== undefined ? { heartbeatIntervalMs } : {}),
185
+ },
186
+ );
187
+ let received = 0;
188
+ const receivedWaiters: { readonly count: number; readonly resolve: () => void }[] = [];
189
+ const server = Bun.serve<KumikoWebSocketData>({
190
+ ...options,
191
+ websocket: {
192
+ ...options.websocket,
193
+ message: (ws, message) => {
194
+ options.websocket.message?.(ws, message);
195
+ received += 1;
196
+ for (const waiter of receivedWaiters) if (received >= waiter.count) waiter.resolve();
197
+ },
198
+ },
199
+ });
200
+ return {
201
+ nextMessages: (count) =>
202
+ new Promise<void>((resolve) => receivedWaiters.push({ count: received + count, resolve })),
203
+ stack,
204
+ server,
205
+ httpOrigin: `http://localhost:${server.port}`,
206
+ url: (path) => `ws://localhost:${server.port}${path}`,
207
+ cookie: async (sid) =>
208
+ `${AUTH_COOKIE_NAME}=${await stack.jwt.sign(sid ? { ...TestUsers.user, sid } : TestUsers.user)}`,
209
+ };
210
+ }
211
+
212
+ async function stopHarness(harness: Harness): Promise<void> {
213
+ await harness.server.stop(true);
214
+ await harness.stack.cleanup();
215
+ }
216
+
217
+ async function upgradeAttempt(
218
+ harness: Harness,
219
+ path: string,
220
+ headers: Record<string, string>,
221
+ ): Promise<Response> {
222
+ return fetch(`${harness.httpOrigin}${path}`, { headers: upgradeHeaders(headers) });
223
+ }
224
+
225
+ describe("r.webSocketRoute (integration) — same-host origin, no allowlist", () => {
226
+ let harness: Harness;
227
+ let cookie: string;
228
+
229
+ beforeAll(async () => {
230
+ harness = await startHarness({ membershipQuery });
231
+ cookie = await harness.cookie();
232
+ });
233
+ afterAll(() => stopHarness(harness));
234
+
235
+ test("no session → 401", async () => {
236
+ const res = await upgradeAttempt(harness, "/api/ws/echo", { Origin: harness.httpOrigin });
237
+ expect(res.status).toBe(401);
238
+ expect(await errorCode(res)).toBe("unauthenticated");
239
+ });
240
+
241
+ test("valid cookie + foreign Origin → 403 origin_not_allowed", async () => {
242
+ const res = await upgradeAttempt(harness, "/api/ws/echo", {
243
+ Cookie: cookie,
244
+ Origin: EVIL_ORIGIN,
245
+ });
246
+ expect(res.status).toBe(403);
247
+ expect(await errorCode(res)).toBe("origin_not_allowed");
248
+ });
249
+
250
+ test("valid cookie + no Origin → 403 origin_not_allowed", async () => {
251
+ const res = await upgradeAttempt(harness, "/api/ws/echo", { Cookie: cookie });
252
+ expect(res.status).toBe(403);
253
+ expect(await errorCode(res)).toBe("origin_not_allowed");
254
+ });
255
+
256
+ test("plain GET (no upgrade headers) → 426", async () => {
257
+ const res = await fetch(`${harness.httpOrigin}/api/ws/echo`, {
258
+ headers: { Cookie: cookie, Origin: harness.httpOrigin },
259
+ });
260
+ expect(res.status).toBe(426);
261
+ expect(await errorCode(res)).toBe("websocket_upgrade_required");
262
+ });
263
+
264
+ test("upgrade request on a server without the upgrade wiring → 501 websocket_upgrade_not_wired", async () => {
265
+ const res = await harness.stack.app.request(`${harness.httpOrigin}/api/ws/echo`, {
266
+ headers: upgradeHeaders({
267
+ Cookie: cookie,
268
+ Origin: harness.httpOrigin,
269
+ Host: new URL(harness.httpOrigin).host,
270
+ }),
271
+ });
272
+ expect(res.status).toBe(501);
273
+ expect(await errorCode(res)).toBe("websocket_upgrade_not_wired");
274
+ });
275
+
276
+ test("connect() returning a Response rejects the upgrade with it", async () => {
277
+ const res = await upgradeAttempt(harness, "/api/ws/rejected", {
278
+ Cookie: cookie,
279
+ Origin: harness.httpOrigin,
280
+ });
281
+ expect(res.status).toBe(409);
282
+ });
283
+
284
+ test("same-host Origin + valid cookie opens and greets with the user id", async () => {
285
+ const socket = connectSocket(harness.url("/api/ws/echo"), {
286
+ Cookie: cookie,
287
+ Origin: harness.httpOrigin,
288
+ });
289
+ await socket.opened;
290
+ expect(JSON.parse(String(await socket.next()))).toEqual({ userId: TestUsers.user.id });
291
+ socket.close();
292
+ await socket.closed;
293
+ });
294
+
295
+ test("echoes text and binary messages unchanged", async () => {
296
+ const socket = connectSocket(harness.url("/api/ws/echo"), {
297
+ Cookie: cookie,
298
+ Origin: harness.httpOrigin,
299
+ });
300
+ await socket.opened;
301
+ await socket.next();
302
+
303
+ socket.send("hello ä");
304
+ expect(await socket.next()).toBe("hello ä");
305
+
306
+ const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
307
+ socket.send(bytes);
308
+ const echoed = await socket.next();
309
+ if (!(echoed instanceof ArrayBuffer)) throw new Error("expected binary echo");
310
+ expect(Array.from(new Uint8Array(echoed))).toEqual(Array.from(bytes));
311
+
312
+ socket.close();
313
+ await socket.closed;
314
+ });
315
+
316
+ test("async handlers run in arrival order even when an earlier one is slower", async () => {
317
+ orderedGate = deferred();
318
+ const socket = connectSocket(harness.url("/api/ws/ordered"), {
319
+ Cookie: cookie,
320
+ Origin: harness.httpOrigin,
321
+ });
322
+ await socket.opened;
323
+ const bothReceived = harness.nextMessages(2);
324
+ socket.send("slow");
325
+ socket.send("fast");
326
+ // Both frames reached the server; unserialized handlers would now answer "fast" first.
327
+ await bothReceived;
328
+ orderedGate.resolve();
329
+ expect(await socket.next()).toBe("slow");
330
+ expect(await socket.next()).toBe("fast");
331
+ socket.close();
332
+ await socket.closed;
333
+ });
334
+
335
+ test("onClose runs and the signal aborts while onMessage hangs; queued messages never start", async () => {
336
+ hangingClosed = deferred();
337
+ hangingStarted.length = 0;
338
+ hangingSignal = undefined;
339
+ const socket = connectSocket(harness.url("/api/ws/hanging"), {
340
+ Cookie: cookie,
341
+ Origin: harness.httpOrigin,
342
+ });
343
+ await socket.opened;
344
+ const bothReceived = harness.nextMessages(2);
345
+ socket.send("hangs");
346
+ socket.send("queued");
347
+ await bothReceived;
348
+ socket.close();
349
+ await socket.closed;
350
+ await hangingClosed.promise;
351
+ expect(heldSignal()?.aborted).toBe(true);
352
+ expect(hangingStarted).toEqual(["hangs"]);
353
+ });
354
+
355
+ test("a message above maxMessageBytes closes the socket with 1009", async () => {
356
+ const socket = connectSocket(harness.url("/api/ws/echo"), {
357
+ Cookie: cookie,
358
+ Origin: harness.httpOrigin,
359
+ });
360
+ await socket.opened;
361
+ await socket.next();
362
+
363
+ socket.send("x".repeat(MAX_MESSAGE_BYTES * 2));
364
+ expect((await socket.closed).code).toBe(1009);
365
+ });
366
+
367
+ test("a throwing handler closes the socket with 1011", async () => {
368
+ const socket = connectSocket(harness.url("/api/ws/explosive"), {
369
+ Cookie: cookie,
370
+ Origin: harness.httpOrigin,
371
+ });
372
+ await socket.opened;
373
+ socket.send("trigger");
374
+ expect((await socket.closed).code).toBe(1011);
375
+ });
376
+ });
377
+
378
+ describe("r.webSocketRoute (integration) — origin allowlist and session checker", () => {
379
+ const revokedSessions = new Set<string>();
380
+ let harness: Harness;
381
+
382
+ beforeAll(async () => {
383
+ harness = await startHarness({
384
+ membershipQuery,
385
+ allowedOrigins: [APP_ORIGIN],
386
+ sessionChecker: async (sid) => (revokedSessions.has(sid) ? "revoked" : "live"),
387
+ });
388
+ });
389
+ afterAll(() => stopHarness(harness));
390
+
391
+ test("an allowlisted Origin opens", async () => {
392
+ const socket = connectSocket(harness.url("/api/ws/echo"), {
393
+ Cookie: await harness.cookie("sid-live"),
394
+ Origin: APP_ORIGIN,
395
+ });
396
+ await socket.opened;
397
+ expect(JSON.parse(String(await socket.next()))).toEqual({ userId: TestUsers.user.id });
398
+ socket.close();
399
+ await socket.closed;
400
+ });
401
+
402
+ test("the request's own host is NOT enough once an allowlist exists → 403", async () => {
403
+ const res = await upgradeAttempt(harness, "/api/ws/echo", {
404
+ Cookie: await harness.cookie("sid-live"),
405
+ Origin: harness.httpOrigin,
406
+ });
407
+ expect(res.status).toBe(403);
408
+ expect(await errorCode(res)).toBe("origin_not_allowed");
409
+ });
410
+
411
+ test("a revoked session is rejected at the upgrade with 401", async () => {
412
+ revokedSessions.add("sid-revoked");
413
+ const res = await upgradeAttempt(harness, "/api/ws/echo", {
414
+ Cookie: await harness.cookie("sid-revoked"),
415
+ Origin: APP_ORIGIN,
416
+ });
417
+ expect(res.status).toBe(401);
418
+ });
419
+ });
420
+
421
+ describe("r.webSocketRoute (integration) — tenant lifecycle", () => {
422
+ const lifecycle = { status: "active" };
423
+ let harness: Harness;
424
+
425
+ beforeAll(async () => {
426
+ harness = await startHarness({
427
+ membershipQuery,
428
+ resolveTenantLifecycleStatus: async () => ({ status: lifecycle.status }),
429
+ });
430
+ });
431
+ afterAll(() => stopHarness(harness));
432
+
433
+ test("a serving tenant opens", async () => {
434
+ lifecycle.status = "active";
435
+ const socket = connectSocket(harness.url("/api/ws/echo"), {
436
+ Cookie: await harness.cookie(),
437
+ Origin: harness.httpOrigin,
438
+ });
439
+ await socket.opened;
440
+ expect(JSON.parse(String(await socket.next()))).toEqual({ userId: TestUsers.user.id });
441
+ socket.close();
442
+ await socket.closed;
443
+ });
444
+
445
+ test("a tenant in teardown is rejected at the upgrade with 410, like HTTP", async () => {
446
+ lifecycle.status = "destroying";
447
+ const res = await upgradeAttempt(harness, "/api/ws/echo", {
448
+ Cookie: await harness.cookie(),
449
+ Origin: harness.httpOrigin,
450
+ });
451
+ expect(res.status).toBe(410);
452
+ expect(await errorCode(res)).toBe("tenant_unavailable");
453
+ });
454
+ });
455
+
456
+ describe("r.webSocketRoute (integration) — per-user connection cap", () => {
457
+ let harness: Harness;
458
+ let cookie: string;
459
+
460
+ beforeAll(async () => {
461
+ harness = await startHarness({ membershipQuery });
462
+ cookie = await harness.cookie();
463
+ });
464
+ afterAll(() => stopHarness(harness));
465
+
466
+ test("the third socket gets 429; closing one frees a slot", async () => {
467
+ limitedClosed = deferred();
468
+ const headers = { Cookie: cookie, Origin: harness.httpOrigin };
469
+ const first = connectSocket(harness.url("/api/ws/limited"), headers);
470
+ const second = connectSocket(harness.url("/api/ws/limited"), headers);
471
+ await first.opened;
472
+ await second.opened;
473
+
474
+ limitedConnectCalls = 0;
475
+ const rejected = await upgradeAttempt(harness, "/api/ws/limited", headers);
476
+ expect(rejected.status).toBe(429);
477
+ expect(await errorCode(rejected)).toBe("websocket_connection_limit");
478
+ // The slot is taken before connect, so a refused attempt never runs it.
479
+ expect(limitedConnectCalls).toBe(0);
480
+
481
+ first.close();
482
+ // The route's onClose runs after the slot is released.
483
+ await limitedClosed.promise;
484
+ const third = connectSocket(harness.url("/api/ws/limited"), headers);
485
+ await third.opened;
486
+
487
+ second.close();
488
+ third.close();
489
+ await Promise.all([second.closed, third.closed]);
490
+ });
491
+ });
492
+
493
+ describe("r.webSocketRoute (integration) — connect rejection frees the slot", () => {
494
+ let harness: Harness;
495
+ let cookie: string;
496
+
497
+ beforeAll(async () => {
498
+ harness = await startHarness({ membershipQuery });
499
+ cookie = await harness.cookie();
500
+ });
501
+ afterAll(() => stopHarness(harness));
502
+
503
+ test("a connect() Response does not leak the single slot", async () => {
504
+ const headers = { Cookie: cookie, Origin: harness.httpOrigin };
505
+ gatedRejects = true;
506
+ // With a cap of 1, a leaked slot would turn the second refusal into a 429.
507
+ expect((await upgradeAttempt(harness, "/api/ws/gated", headers)).status).toBe(409);
508
+ expect((await upgradeAttempt(harness, "/api/ws/gated", headers)).status).toBe(409);
509
+
510
+ gatedRejects = false;
511
+ const socket = connectSocket(harness.url("/api/ws/gated"), headers);
512
+ await socket.opened;
513
+ socket.close();
514
+ await socket.closed;
515
+ });
516
+ });
517
+
518
+ describe("r.webSocketRoute (integration) — revalidation on an open socket", () => {
519
+ const HEARTBEAT_MS = 20;
520
+ const revokedSessions = new Set<string>();
521
+ const hangingSessions = new Set<string>();
522
+ let currentRoles: readonly string[] = TestUsers.user.roles;
523
+ let harness: Harness;
524
+
525
+ beforeAll(async () => {
526
+ harness = await startHarness(
527
+ {
528
+ membershipQuery,
529
+ // In-memory checker: only a session listed in hangingSessions ever stalls.
530
+ sessionChecker: (sid) =>
531
+ hangingSessions.has(sid)
532
+ ? new Promise(() => {})
533
+ : Promise.resolve(
534
+ revokedSessions.has(sid) ? "revoked" : { status: "live", roles: currentRoles },
535
+ ),
536
+ },
537
+ HEARTBEAT_MS,
538
+ );
539
+ });
540
+ afterAll(() => stopHarness(harness));
541
+
542
+ test("a session revoked while the socket is open closes it with 1008", async () => {
543
+ const socket = connectSocket(harness.url("/api/ws/echo"), {
544
+ Cookie: await harness.cookie("sid-revoked-live"),
545
+ Origin: harness.httpOrigin,
546
+ });
547
+ await socket.opened;
548
+ await socket.next();
549
+ revokedSessions.add("sid-revoked-live");
550
+ const closed = await socket.closed;
551
+ expect(closed.code).toBe(1008);
552
+ expect(closed.reason).toBe("session changed");
553
+ });
554
+
555
+ test("changed roles close an open socket with 1008", async () => {
556
+ currentRoles = TestUsers.user.roles;
557
+ const socket = connectSocket(harness.url("/api/ws/echo"), {
558
+ Cookie: await harness.cookie("sid-roles"),
559
+ Origin: harness.httpOrigin,
560
+ });
561
+ await socket.opened;
562
+ await socket.next();
563
+ currentRoles = [...TestUsers.user.roles, "ExtraRole"];
564
+ expect((await socket.closed).code).toBe(1008);
565
+ currentRoles = TestUsers.user.roles;
566
+ });
567
+
568
+ test("a session store that stops answering closes the socket with 1013", async () => {
569
+ const socket = connectSocket(harness.url("/api/ws/echo"), {
570
+ Cookie: await harness.cookie("sid-hang"),
571
+ Origin: harness.httpOrigin,
572
+ });
573
+ await socket.opened;
574
+ await socket.next();
575
+ hangingSessions.add("sid-hang");
576
+ const closed = await socket.closed;
577
+ expect(closed.code).toBe(1013);
578
+ expect(closed.reason).toBe("try again later");
579
+ });
580
+ });
@@ -0,0 +1,43 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
+ import { runPiiEventBackfill } from "@cosmicdrift/kumiko-framework/migrations";
5
+
6
+ export const SKIP_PII_BACKFILL_ENV = "KUMIKO_SKIP_PII_BACKFILL";
7
+
8
+ type ShutdownRegistry = {
9
+ registerShutdownHook(name: string, fn: () => Promise<void>): void;
10
+ };
11
+
12
+ // Runs in the background instead of blocking boot: the first run after a bump
13
+ // scans the whole store and would delay readiness in proportion to its size.
14
+ // Every batch is its own transaction, so an abort at any point is safe.
15
+ export function startPiiEventBackfillOnBoot(opts: {
16
+ readonly db: DbConnection;
17
+ readonly registry: Registry;
18
+ readonly lifecycle: ShutdownRegistry;
19
+ readonly envSource: Readonly<Record<string, string | undefined>>;
20
+ }): void {
21
+ if (opts.envSource[SKIP_PII_BACKFILL_ENV] === "1") {
22
+ // skip: operator opted out via KUMIKO_SKIP_PII_BACKFILL
23
+ return;
24
+ }
25
+
26
+ const log = createFallbackLogger("pii-event-backfill");
27
+ const abortController = new AbortController();
28
+ const run = runPiiEventBackfill(opts.db, opts.registry, {
29
+ signal: abortController.signal,
30
+ }).then(
31
+ () => undefined,
32
+ (e: unknown) => {
33
+ log.error("PII event backfill crashed; it will be retried on the next boot", {
34
+ error: e instanceof Error ? e.message : String(e),
35
+ });
36
+ },
37
+ );
38
+
39
+ opts.lifecycle.registerShutdownHook("piiEventBackfill", async () => {
40
+ abortController.abort();
41
+ await run;
42
+ });
43
+ }
@@ -53,6 +53,11 @@ import { tmpdir } from "node:os";
53
53
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
54
54
  import { isPlainObject, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
55
55
  import { Temporal } from "temporal-polyfill";
56
+ import {
57
+ RENDERER_WEB_FONT_FILE_PATTERN,
58
+ RENDERER_WEB_FONTS_DIST_DIR,
59
+ resolveRendererWebFontsDir,
60
+ } from "./renderer-web-fonts";
56
61
  import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli";
57
62
 
58
63
  // Bun-Runtime-Check als module-level Konstante: alle Build-Schritte
@@ -186,6 +191,7 @@ export async function buildProdBundle(options: BuildProdBundleOptions = {}): Pro
186
191
  const filename = `styles-${hash}.css`;
187
192
  await writeFile(join(assetsDir, filename), css);
188
193
  manifest["styles.css"] = `/${ASSETS_DIR}/${filename}`;
194
+ await copyRendererWebFonts(cwd, outDir);
189
195
  }
190
196
 
191
197
  // 4. Bun.build pro Entry (multi-entry produces N bundles + shared chunks).
@@ -641,6 +647,22 @@ export function assertRendererWebShellPresent(css: string, stylesheet: ResolvedS
641
647
  );
642
648
  }
643
649
 
650
+ // The compiled CSS points at absolute /assets/kumiko/fonts/<file>.woff2 URLs
651
+ // (see renderer-web-fonts.ts), so the files have to sit at exactly that path in
652
+ // dist. Skipped when renderer-web isn't resolvable — then no such CSS exists.
653
+ async function copyRendererWebFonts(cwd: string, outDir: string): Promise<void> {
654
+ const fontsDir = resolveRendererWebFontsDir([cwd]);
655
+ // skip: renderer-web not resolvable, so no font-referencing CSS exists.
656
+ if (fontsDir === undefined || !existsSync(fontsDir)) return;
657
+ const target = join(outDir, RENDERER_WEB_FONTS_DIST_DIR);
658
+ await mkdir(target, { recursive: true });
659
+ for (const file of readdirSync(fontsDir)) {
660
+ if (RENDERER_WEB_FONT_FILE_PATTERN.test(file)) {
661
+ await cp(join(fontsDir, file), join(target, file));
662
+ }
663
+ }
664
+ }
665
+
644
666
  // @internal — exported nur für Unit-Tests.
645
667
  export function discoverHtmlTemplate(cwd: string): string | undefined {
646
668
  for (const candidate of ["index.html", "public/index.html"]) {
@@ -1,4 +1,13 @@
1
- import { DEFAULT_MAX_REQUEST_BYTES } from "@cosmicdrift/kumiko-framework/api";
1
+ import {
2
+ createKumikoWebSocketHandler,
3
+ DEFAULT_MAX_REQUEST_BYTES,
4
+ isWebSocketUpgradeRequest,
5
+ type KumikoServeEnv,
6
+ type KumikoWebSocketData,
7
+ WEBSOCKET_BACKPRESSURE_LIMIT_BYTES,
8
+ WEBSOCKET_MAX_PAYLOAD_BYTES,
9
+ WEBSOCKET_ROUTE_PATH_PREFIX,
10
+ } from "@cosmicdrift/kumiko-framework/api";
2
11
  import type { Registry } from "@cosmicdrift/kumiko-framework/engine/types";
3
12
  import {
4
13
  readFilesRouteOptions,
@@ -26,35 +35,76 @@ export function resolveDerivedMaxRequestBodySize(registry: Registry): number {
26
35
  );
27
36
  }
28
37
 
38
+ // Other upgrades (dev HMR, ...) keep going through fetchHandler.
39
+ function isKumikoWebSocketUpgrade(req: Request): boolean {
40
+ return (
41
+ isWebSocketUpgradeRequest(req) &&
42
+ new URL(req.url).pathname.startsWith(WEBSOCKET_ROUTE_PATH_PREFIX)
43
+ );
44
+ }
45
+
29
46
  /**
30
- * Bun.serve-Options für Production.
47
+ * Bun.serve options for production.
48
+ *
49
+ * idleTimeout: 0 (disabled): SSE streams stay alive through their own
50
+ * heartbeat (SSE_HEARTBEAT_INTERVAL_MS in framework/api/sse-route.ts). With
51
+ * Bun's 10 s default, every heartbeat gap killed the connection with a half
52
+ * HTTP/2 RST_STREAM, which browsers report as ERR_HTTP2_PROTOCOL_ERROR.
31
53
  *
32
- * Spec: idleTimeout: 0 (= disabled). SSE-Streams werden via Heartbeat
33
- * lebend gehalten (siehe SSE_HEARTBEAT_INTERVAL_MS in framework/api/
34
- * sse-route.ts), kein Bun-side Idle-Cleanup nötig. Mit dem Default
35
- * von 10 s killt Bun nach jedem Heartbeat-Gap die Connection mit
36
- * halbem HTTP/2-RST_STREAM → Browser ERR_HTTP2_PROTOCOL_ERROR.
54
+ * __tests__/run-prod-app-spec.test.ts pins the 0 against "looks like a
55
+ * leak" reverts.
37
56
  *
38
- * Spec-Test in __tests__/run-prod-app-spec.test.ts pinst die 0 gegen
39
- * "looks like a leak"-Reverts.
57
+ * WebSocket: upgrade requests are branched off here, before `fetchHandler`
58
+ * (whose static/SPA layers clone the request, which `server.upgrade` can't
59
+ * use), into `webSocketOptions.upgradeFetch` with the original Request.
60
+ * `heartbeatIntervalMs` overrides the 25 s ping/revalidation cadence.
40
61
  */
62
+ export type WebSocketServeOptions = {
63
+ readonly upgradeFetch: (req: Request, env: KumikoServeEnv) => Response | Promise<Response>;
64
+ readonly heartbeatIntervalMs?: number;
65
+ };
66
+
41
67
  export function buildBunServeOptions(
42
68
  port: number,
43
69
  fetchHandler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
44
70
  maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_SIZE_BYTES,
71
+ webSocketOptions?: WebSocketServeOptions,
45
72
  ): {
46
73
  readonly port: number;
47
- readonly fetch: (req: Request, server: Bun.Server<unknown>) => Response | Promise<Response>;
74
+ readonly fetch: (
75
+ req: Request,
76
+ server: Bun.Server<KumikoWebSocketData>,
77
+ ) => Response | Promise<Response>;
48
78
  readonly idleTimeout: number;
49
79
  readonly maxRequestBodySize: number;
80
+ readonly websocket: Bun.WebSocketHandler<KumikoWebSocketData>;
50
81
  } {
51
82
  // `server.requestIP(req)` only resolves for the exact Request instance
52
83
  // Bun created — extracted here, once, before any downstream req.clone()
53
84
  // (tryHonoFirst et al.) can invalidate it.
54
85
  return {
55
86
  port,
56
- fetch: (req, server) => fetchHandler(req, server.requestIP(req)?.address),
87
+ fetch: (req, server) => {
88
+ const socketAddress = server.requestIP(req)?.address;
89
+ if (webSocketOptions && isKumikoWebSocketUpgrade(req)) {
90
+ return webSocketOptions.upgradeFetch(req, {
91
+ ...(socketAddress !== undefined ? { socketAddress } : {}),
92
+ server,
93
+ });
94
+ }
95
+ return fetchHandler(req, socketAddress);
96
+ },
57
97
  idleTimeout: 0,
58
98
  maxRequestBodySize,
99
+ websocket: {
100
+ ...createKumikoWebSocketHandler(
101
+ webSocketOptions?.heartbeatIntervalMs !== undefined
102
+ ? { heartbeatIntervalMs: webSocketOptions.heartbeatIntervalMs }
103
+ : {},
104
+ ),
105
+ maxPayloadLength: WEBSOCKET_MAX_PAYLOAD_BYTES,
106
+ backpressureLimit: WEBSOCKET_BACKPRESSURE_LIMIT_BYTES,
107
+ closeOnBackpressureLimit: true,
108
+ },
59
109
  };
60
110
  }
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.328.0",
4
+ "type": "improvement",
5
+ "title": "Production build copies the renderer-web fonts",
6
+ "detail": "`buildProdBundle` copies the IBM Plex woff2 files to `dist/assets/kumiko/fonts/`, where the compiled CSS expects them. The new export `@cosmicdrift/kumiko-server-runtime/renderer-web-fonts` holds the URL prefix, the file name allowlist and the resolver, and the woff2 MIME type is known to the static file server."
7
+ },
2
8
  {
3
9
  "version": "0.323.0",
4
10
  "type": "breaking",
@@ -0,0 +1,33 @@
1
+ // The renderer-web stylesheet references its IBM Plex woff2 files by absolute
2
+ // same-origin URL. Relative url() would 404: the CSS is compiled into a temp
3
+ // dir (dev) or hashed into dist/assets (prod), away from the source fonts.
4
+ // data: URIs are out because apps run with `font-src 'self'`.
5
+
6
+ import { dirname, join } from "node:path";
7
+
8
+ export const RENDERER_WEB_FONTS_URL_PREFIX = "/assets/kumiko/fonts/";
9
+
10
+ /** dist-relative folder the prod build copies the fonts into. */
11
+ export const RENDERER_WEB_FONTS_DIST_DIR = "assets/kumiko/fonts";
12
+
13
+ /** Allowlist for servable font file names; also rules out path segments. */
14
+ export const RENDERER_WEB_FONT_FILE_PATTERN = /^[a-z0-9-]+\.woff2$/;
15
+
16
+ const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
17
+
18
+ /** Directory of the woff2 files next to renderer-web's styles.css, or
19
+ * undefined when the package can't be resolved from any `searchFrom` dir
20
+ * (or outside Bun, which does the package-exports resolution). */
21
+ export function resolveRendererWebFontsDir(searchFrom: readonly string[]): string | undefined {
22
+ if (!hasBun) return undefined;
23
+ const bun = (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }).Bun;
24
+ for (const from of searchFrom) {
25
+ try {
26
+ const stylesheet = bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", from);
27
+ return join(dirname(stylesheet), "fonts");
28
+ } catch {
29
+ // try the next search root
30
+ }
31
+ }
32
+ return undefined;
33
+ }
@@ -110,6 +110,8 @@ export function mimeTypeFor(filePath: string): string {
110
110
  return "application/xml; charset=utf-8";
111
111
  case "webmanifest":
112
112
  return "application/manifest+json";
113
+ case "woff2":
114
+ return "font/woff2";
113
115
  default:
114
116
  return "application/octet-stream";
115
117
  }
@@ -74,6 +74,7 @@ import {
74
74
  createDefaultSseBroker,
75
75
  createRedisLoginRateLimiter,
76
76
  type ExtraRouteDefinition,
77
+ type KumikoServeEnv,
77
78
  type LoginRateLimiter,
78
79
  loadJwtSecretOrKeyring,
79
80
  type PostAuthLandingResolver,
@@ -148,6 +149,7 @@ import Redis from "ioredis";
148
149
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
149
150
  import { resolveBootCrypto } from "./boot/boot-crypto";
150
151
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
152
+ import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot";
151
153
  import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options";
152
154
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
153
155
  import { makeDispatchSystemWrite, type SystemWireDeps } from "./extra-routes-deps";
@@ -246,6 +248,7 @@ function makeDryRunHandle(): ProdAppHandle {
246
248
  // entrypoint is never reached by callers in dry-run.
247
249
  entrypoint: undefined as unknown as ApiEntrypoint,
248
250
  fetch: () => new Response("dry-run", { status: 503 }),
251
+ webSocketUpgradeFetch: async () => new Response("dry-run", { status: 503 }),
249
252
  listen: noop,
250
253
  stop: noop,
251
254
  };
@@ -711,6 +714,11 @@ export type ProdAppHandle = {
711
714
  * outermost Bun fetch callback — `requestIP` only resolves for Bun's
712
715
  * original Request instance, never a cloned/rebuilt one. */
713
716
  readonly fetch: (req: Request, socketAddress?: string) => Promise<Response> | Response;
717
+ /** Serves WebSocket-upgrade requests (r.webSocketRoute) with Bun's original
718
+ * Request. Apps with `autoListen: false` that run their own `Bun.serve`
719
+ * must pass it as `{ upgradeFetch }` in the 4th argument of `buildBunServeOptions` to get
720
+ * WebSockets; `listen()` already wires it. */
721
+ readonly webSocketUpgradeFetch: (req: Request, env: KumikoServeEnv) => Promise<Response>;
714
722
  /** Active Bun-server (only set when listen() was called — tests skip
715
723
  * listen() because Bun.serve isn't available under vitest/node). */
716
724
  server?: ReturnType<typeof Bun.serve>;
@@ -1294,6 +1302,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1294
1302
  }
1295
1303
 
1296
1304
  await entrypoint.start();
1305
+ startPiiEventBackfillOnBoot({
1306
+ db,
1307
+ registry,
1308
+ lifecycle: entrypoint.lifecycle,
1309
+ envSource,
1310
+ });
1297
1311
 
1298
1312
  // 11. Build the fetch-handler. Static-fallback for non-/api/ paths
1299
1313
  // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
@@ -1324,9 +1338,18 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1324
1338
  // 11. Mark lifecycle ready — health/ready flips to 200 after this.
1325
1339
  entrypoint.lifecycle.markReady();
1326
1340
 
1341
+ // Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
1342
+ const securedUpgradeFetch = withSecurityHeaders<KumikoServeEnv>(
1343
+ async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)),
1344
+ options.securityHeaders,
1345
+ );
1346
+ const webSocketUpgradeFetch = async (req: Request, env: KumikoServeEnv): Promise<Response> =>
1347
+ securedUpgradeFetch(req, env);
1348
+
1327
1349
  const handle: ProdAppHandle = {
1328
1350
  entrypoint,
1329
1351
  fetch: fetchHandler,
1352
+ webSocketUpgradeFetch,
1330
1353
  listen: async (listenPort = port) => {
1331
1354
  // Bun.serve is the production HTTP. Tests don't call listen()
1332
1355
  // because vitest runs under Node where Bun.serve doesn't exist.
@@ -1347,6 +1370,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1347
1370
  listenPort,
1348
1371
  fetchHandler,
1349
1372
  options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(registry),
1373
+ { upgradeFetch: webSocketUpgradeFetch },
1350
1374
  ),
1351
1375
  );
1352
1376
 
@@ -60,6 +60,7 @@ import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
60
60
  import Redis from "ioredis";
61
61
  import { resolveBootCrypto } from "./boot/boot-crypto";
62
62
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
63
+ import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot";
63
64
  import { composeFeatures } from "./compose-features";
64
65
  import { makeDispatchSystemWrite, type SystemWireDeps } from "./extra-routes-deps";
65
66
  import { assertPiiBootInvariants } from "./pii-boot-gate";
@@ -320,6 +321,12 @@ export async function runWorkerApp(options: RunWorkerAppOptions): Promise<Worker
320
321
  };
321
322
 
322
323
  await entrypoint.start();
324
+ startPiiEventBackfillOnBoot({
325
+ db,
326
+ registry,
327
+ lifecycle: entrypoint.lifecycle,
328
+ envSource,
329
+ });
323
330
 
324
331
  if (options.wireComponents) {
325
332
  await options.wireComponents({
@@ -42,14 +42,14 @@ export function resolveSecurityHeaders(
42
42
 
43
43
  // Sets each header only when absent so per-response values (e.g. the
44
44
  // per-host CSP from hostDispatch) always win over the runtime default.
45
- export function withSecurityHeaders(
46
- handler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
45
+ export function withSecurityHeaders<TServeArg = string>(
46
+ handler: (req: Request, serveArg?: TServeArg) => Response | Promise<Response>,
47
47
  option: SecurityHeadersOption | undefined,
48
- ): (req: Request, socketAddress?: string) => Response | Promise<Response> {
48
+ ): (req: Request, serveArg?: TServeArg) => Response | Promise<Response> {
49
49
  const defaults = resolveSecurityHeaders(option);
50
50
  if (defaults.length === 0) return handler;
51
- return async (req: Request, socketAddress?: string): Promise<Response> => {
52
- const res = await handler(req, socketAddress);
51
+ return async (req: Request, serveArg?: TServeArg): Promise<Response> => {
52
+ const res = await handler(req, serveArg);
53
53
  try {
54
54
  for (const [name, value] of defaults) {
55
55
  if (!res.headers.has(name)) res.headers.set(name, value);