@cosmicdrift/kumiko-server-runtime 0.220.1 → 0.221.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 +3 -3
- package/src/__tests__/run-prod-app-static-files.test.ts +40 -6
- package/src/__tests__/run-prod-app.integration.test.ts +91 -0
- package/src/__tests__/try-hono-first.test.ts +66 -21
- package/src/boot/job-run-logger.ts +2 -2
- package/src/run-prod-app-static-files.ts +6 -3
- package/src/run-prod-app.ts +7 -1
- package/src/try-hono-first.ts +58 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.221.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>",
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
}
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
83
|
+
"@cosmicdrift/kumiko-bundled-features": "0.221.0",
|
|
84
|
+
"@cosmicdrift/kumiko-framework": "0.221.0",
|
|
85
85
|
"temporal-polyfill": "^0.3.2"
|
|
86
86
|
},
|
|
87
87
|
"publishConfig": {
|
|
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
4
4
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
7
8
|
import {
|
|
8
9
|
buildStaticFallback,
|
|
9
10
|
mimeTypeFor,
|
|
@@ -11,6 +12,15 @@ import {
|
|
|
11
12
|
serveDiskFile,
|
|
12
13
|
} from "../run-prod-app-static-files";
|
|
13
14
|
|
|
15
|
+
// Stand-in for a real Hono app's fetch when the test wants "no route
|
|
16
|
+
// matched" — must carry NO_ROUTE_MATCH_HEADER_NAME, same as buildServer's
|
|
17
|
+
// app.notFound() (framework/api/server.ts), otherwise tryHonoFirst treats
|
|
18
|
+
// the fake apiHandler's 404 as a matched route's own deliberate 404 (see
|
|
19
|
+
// kumiko-framework#2435) and never falls through to disk/SPA.
|
|
20
|
+
function noRouteMatchedResponse(): Response {
|
|
21
|
+
return new Response("404", { status: 404, headers: { [NO_ROUTE_MATCH_HEADER_NAME]: "1" } });
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
describe("mimeTypeFor", () => {
|
|
15
25
|
const cases: ReadonlyArray<readonly [string, string]> = [
|
|
16
26
|
["x.html", "text/html; charset=utf-8"],
|
|
@@ -94,7 +104,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
94
104
|
|
|
95
105
|
test("hostDispatch html pointing at missing file → 500", async () => {
|
|
96
106
|
const handler = buildStaticFallback(
|
|
97
|
-
() =>
|
|
107
|
+
() => noRouteMatchedResponse(),
|
|
98
108
|
tmp,
|
|
99
109
|
"{}",
|
|
100
110
|
() => ({ kind: "html", file: "gone.html" }),
|
|
@@ -106,7 +116,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
106
116
|
|
|
107
117
|
test("hostDispatch not-found → 404; redirect → 302", async () => {
|
|
108
118
|
const notFound = buildStaticFallback(
|
|
109
|
-
() =>
|
|
119
|
+
() => noRouteMatchedResponse(),
|
|
110
120
|
tmp,
|
|
111
121
|
"{}",
|
|
112
122
|
() => ({ kind: "not-found" }),
|
|
@@ -114,7 +124,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
114
124
|
expect((await notFound(new Request("http://t/"))).status).toBe(404);
|
|
115
125
|
|
|
116
126
|
const redirect = buildStaticFallback(
|
|
117
|
-
() =>
|
|
127
|
+
() => noRouteMatchedResponse(),
|
|
118
128
|
tmp,
|
|
119
129
|
"{}",
|
|
120
130
|
() => ({ kind: "redirect", to: "https://example.com/", status: 301 }),
|
|
@@ -133,7 +143,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
133
143
|
|
|
134
144
|
test("serves disk asset under staticDir", async () => {
|
|
135
145
|
await writeFile(join(tmp, "logo.png"), "PNGDATA");
|
|
136
|
-
const handler = buildStaticFallback(() =>
|
|
146
|
+
const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp, "{}");
|
|
137
147
|
const res = await handler(new Request("http://t/logo.png"));
|
|
138
148
|
expect(res.status).toBe(200);
|
|
139
149
|
expect(res.headers.get("content-type")).toBe("image/png");
|
|
@@ -143,7 +153,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
143
153
|
test("a request for a directory copied verbatim from public/ falls back to index.html instead of 500ing", async () => {
|
|
144
154
|
await mkdir(join(tmp, "sub"));
|
|
145
155
|
await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
|
|
146
|
-
const handler = buildStaticFallback(() =>
|
|
156
|
+
const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp, "{}");
|
|
147
157
|
const res = await handler(new Request("http://t/sub"));
|
|
148
158
|
expect(res.status).toBe(200);
|
|
149
159
|
expect(await res.text()).toContain("spa-shell");
|
|
@@ -152,7 +162,7 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
152
162
|
test("hostDispatch html with CSP + Vary: Host", async () => {
|
|
153
163
|
await writeFile(join(tmp, "tenant.html"), "<!doctype html><html><body>ok</body></html>");
|
|
154
164
|
const handler = buildStaticFallback(
|
|
155
|
-
() =>
|
|
165
|
+
() => noRouteMatchedResponse(),
|
|
156
166
|
tmp,
|
|
157
167
|
'{"screens":[]}',
|
|
158
168
|
() => ({
|
|
@@ -168,4 +178,28 @@ describe("buildStaticFallback hostDispatch", () => {
|
|
|
168
178
|
expect(res.headers.get("content-security-policy")).toBe("default-src 'self'");
|
|
169
179
|
expect(await res.text()).toContain("ok");
|
|
170
180
|
});
|
|
181
|
+
|
|
182
|
+
test("a matched route's own deliberate 404 stays 404, not masked as the SPA shell (#2435)", async () => {
|
|
183
|
+
// An index.html exists on disk, so the OLD status-only heuristic would
|
|
184
|
+
// have served it with status 200 for ANY 404 — including one from a
|
|
185
|
+
// matched route (e.g. file-derivatives' public-variant default-deny)
|
|
186
|
+
// that never carries NO_ROUTE_MATCH_HEADER_NAME.
|
|
187
|
+
await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
|
|
188
|
+
const handler = buildStaticFallback(
|
|
189
|
+
() => new Response("not found", { status: 404 }),
|
|
190
|
+
tmp,
|
|
191
|
+
"{}",
|
|
192
|
+
);
|
|
193
|
+
const res = await handler(new Request("http://t/files/deadbeef/thumb"));
|
|
194
|
+
expect(res.status).toBe(404);
|
|
195
|
+
expect(await res.text()).toBe("not found");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("an unknown SPA route with no matching handler still gets the SPA shell", async () => {
|
|
199
|
+
await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
|
|
200
|
+
const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp, "{}");
|
|
201
|
+
const res = await handler(new Request("http://t/some/client-side/route"));
|
|
202
|
+
expect(res.status).toBe(200);
|
|
203
|
+
expect(await res.text()).toContain("spa-shell");
|
|
204
|
+
});
|
|
171
205
|
});
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
userSessionEntity,
|
|
21
21
|
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
22
22
|
import { userEntity } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
23
|
+
import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
23
24
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
24
25
|
import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
25
26
|
import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
@@ -412,6 +413,96 @@ describe("runProdApp", () => {
|
|
|
412
413
|
expect(res.headers.get("etag")).toBeTruthy();
|
|
413
414
|
});
|
|
414
415
|
|
|
416
|
+
test("static-fallback: a matched extraRoute's own deliberate 404 stays 404, not masked as the SPA shell (#2435)", async () => {
|
|
417
|
+
// kumiko-framework#2435: tryHonoFirst used to treat ANY 404 as "no
|
|
418
|
+
// route matched" and fall through to index.html with status 200 —
|
|
419
|
+
// masking a matched route's intentional 404 (e.g. default-deny reads
|
|
420
|
+
// like file-derivatives' public-variant route). An index.html exists
|
|
421
|
+
// here specifically to prove the SPA-fallback does NOT win.
|
|
422
|
+
const tmpStaticDir = await createTempStaticDir({
|
|
423
|
+
"index.html": "<html>SPA shell</html>",
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const handle = await boot(undefined, {
|
|
427
|
+
staticDir: tmpStaticDir,
|
|
428
|
+
extraRoutes: (app) => {
|
|
429
|
+
app.get("/probe/:id", (c) => {
|
|
430
|
+
if (c.req.param("id") === "missing") return c.text("not found", 404);
|
|
431
|
+
return c.text(`probe:${c.req.param("id")}`, 200);
|
|
432
|
+
});
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const res = await handle.fetch(new Request("http://test/probe/missing"));
|
|
437
|
+
expect(res.status).toBe(404);
|
|
438
|
+
expect(await res.text()).toBe("not found");
|
|
439
|
+
expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/html/);
|
|
440
|
+
expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("static-fallback: an unknown SPA route still gets the SPA shell alongside a route that 404s (#2435)", async () => {
|
|
444
|
+
const tmpStaticDir = await createTempStaticDir({
|
|
445
|
+
"index.html": "<html>SPA shell</html>",
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const handle = await boot(undefined, {
|
|
449
|
+
staticDir: tmpStaticDir,
|
|
450
|
+
extraRoutes: (app) => {
|
|
451
|
+
app.get("/probe/:id", (c) => c.text("not found", 404));
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const res = await handle.fetch(new Request("http://test/some/client-route"));
|
|
456
|
+
expect(res.status).toBe(200);
|
|
457
|
+
expect(await res.text()).toContain("SPA shell");
|
|
458
|
+
expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("static-fallback: an unmatched /api/* path stays a plain 404, no marker leak (#2435)", async () => {
|
|
462
|
+
// /api/* is a passthrough in buildStaticFallback (always Hono, never
|
|
463
|
+
// the SPA shell) — bypasses tryHonoFirst entirely, so the strip at
|
|
464
|
+
// that passthrough site is what's under test here. anonymousAccess is
|
|
465
|
+
// wired so this anonymous GET clears the auth middleware and actually
|
|
466
|
+
// reaches Hono's router (unauthenticated /api/* requests get a plain
|
|
467
|
+
// 401 from the auth guard before routing is even attempted, which
|
|
468
|
+
// would test the auth guard instead of the router-miss path).
|
|
469
|
+
const tmpStaticDir = await createTempStaticDir({
|
|
470
|
+
"index.html": "<html>SPA shell</html>",
|
|
471
|
+
});
|
|
472
|
+
const handle = await boot(undefined, {
|
|
473
|
+
staticDir: tmpStaticDir,
|
|
474
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const res = await handle.fetch(new Request("http://test/api/totally-unknown-route"));
|
|
478
|
+
expect(res.status).toBe(404);
|
|
479
|
+
expect(await res.text()).not.toContain("SPA shell");
|
|
480
|
+
expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("no staticDir (split-deploy/API-only boot): a router-miss never leaks the internal marker header to the client (#2435)", async () => {
|
|
484
|
+
// Without staticDir, fetchHandler is app.fetch directly — it never goes
|
|
485
|
+
// through buildStaticFallback/tryHonoFirst, so the strip has to happen
|
|
486
|
+
// at this bypass site too (run-prod-app.ts's fetchHandler assembly).
|
|
487
|
+
const handle = await boot(undefined, {
|
|
488
|
+
extraRoutes: (app) => {
|
|
489
|
+
app.get("/probe/:id", (c) => {
|
|
490
|
+
if (c.req.param("id") === "missing") return c.text("not found", 404);
|
|
491
|
+
return c.text(`probe:${c.req.param("id")}`, 200);
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const routerMiss = await handle.fetch(new Request("http://test/totally/unknown/path"));
|
|
497
|
+
expect(routerMiss.status).toBe(404);
|
|
498
|
+
expect(routerMiss.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
499
|
+
|
|
500
|
+
const deliberate404 = await handle.fetch(new Request("http://test/probe/missing"));
|
|
501
|
+
expect(deliberate404.status).toBe(404);
|
|
502
|
+
expect(await deliberate404.text()).toBe("not found");
|
|
503
|
+
expect(deliberate404.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
504
|
+
});
|
|
505
|
+
|
|
415
506
|
test("static-fallback: If-None-Match → 304 on disk file", async () => {
|
|
416
507
|
const tmpStaticDir = await createTempStaticDir({
|
|
417
508
|
"robots.txt": "User-agent: *\nAllow: /",
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
// Pure-function pin
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Pure-function pin for tryHonoFirst. Trivial but load-bearing: drift
|
|
2
|
+
// between dev (createKumikoServer) and prod (runProdApp) has already
|
|
3
|
+
// caused a bug before (legal-pages worked in prod but not in dev). Both
|
|
4
|
+
// now use this helper — if the semantics change (e.g. "matched" treating
|
|
5
|
+
// other 4xx differently than 404), both paths MUST update in sync.
|
|
6
|
+
//
|
|
7
|
+
// kumiko-framework#2435: "matched" used to depend ONLY on the status code
|
|
8
|
+
// (404 = no match) — that masked any matched route's deliberate 404 as the
|
|
9
|
+
// SPA shell with status 200. The contract now: a router-miss is ONLY a 404
|
|
10
|
+
// that ADDITIONALLY carries NO_ROUTE_MATCH_HEADER_NAME (set by buildServer's
|
|
11
|
+
// app.notFound(), see framework/api/server.ts). A plain 404 without the
|
|
12
|
+
// marker counts as matched — the route answered deliberately.
|
|
7
13
|
|
|
8
14
|
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import {
|
|
15
|
+
import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
16
|
+
import { type HonoLikeApp, stripNoRouteMatchHeader, tryHonoFirst } from "../try-hono-first";
|
|
10
17
|
|
|
11
18
|
function makeApp(response: Response): HonoLikeApp {
|
|
12
19
|
return { fetch: () => response };
|
|
@@ -16,28 +23,47 @@ function makeAsyncApp(response: Response): HonoLikeApp {
|
|
|
16
23
|
return { fetch: async () => response };
|
|
17
24
|
}
|
|
18
25
|
|
|
26
|
+
function routerMissResponse(): Response {
|
|
27
|
+
return new Response("Not Found", {
|
|
28
|
+
status: 404,
|
|
29
|
+
headers: { [NO_ROUTE_MATCH_HEADER_NAME]: "1" },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
describe("tryHonoFirst", () => {
|
|
20
|
-
test("matched=true
|
|
34
|
+
test("matched=true on 200 (a Hono route handled it)", async () => {
|
|
21
35
|
const app = makeApp(new Response("ok", { status: 200 }));
|
|
22
36
|
const res = await tryHonoFirst(app, new Request("http://test/foo"));
|
|
23
37
|
expect(res.matched).toBe(true);
|
|
24
38
|
expect(res.response.status).toBe(200);
|
|
25
39
|
});
|
|
26
40
|
|
|
27
|
-
test("matched=false
|
|
28
|
-
const app = makeApp(
|
|
41
|
+
test("matched=false on a router-miss (404 + NO_ROUTE_MATCH_HEADER_NAME — caller falls back to the SPA)", async () => {
|
|
42
|
+
const app = makeApp(routerMissResponse());
|
|
29
43
|
const res = await tryHonoFirst(app, new Request("http://test/unknown"));
|
|
30
44
|
expect(res.matched).toBe(false);
|
|
31
|
-
// response
|
|
32
|
-
//
|
|
45
|
+
// response is still returned — caller can use the 404 as a last-resort
|
|
46
|
+
// safety net if the SPA fallback doesn't deliver anything either.
|
|
47
|
+
expect(res.response.status).toBe(404);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("matched=true for a matched route that deliberately answers 404 (no marker — bug #2435)", async () => {
|
|
51
|
+
// The actual bug: file-derivatives' public-variant route matches and
|
|
52
|
+
// answers `c.text("not found", 404)` for default-deny — NOT a
|
|
53
|
+
// router-miss. Without the marker header this used to be
|
|
54
|
+
// misinterpreted as "no match" and the caller would have served the
|
|
55
|
+
// SPA shell with status 200 instead of passing the real 404 through.
|
|
56
|
+
const app = makeApp(new Response("not found", { status: 404 }));
|
|
57
|
+
const res = await tryHonoFirst(app, new Request("http://test/files/x/thumb"));
|
|
58
|
+
expect(res.matched).toBe(true);
|
|
33
59
|
expect(res.response.status).toBe(404);
|
|
34
60
|
});
|
|
35
61
|
|
|
36
|
-
test("matched=true
|
|
37
|
-
// Bug
|
|
38
|
-
// 401 (auth required)
|
|
39
|
-
//
|
|
40
|
-
//
|
|
62
|
+
test("matched=true on 401/403/500 (Hono answered — no SPA fallback)", async () => {
|
|
63
|
+
// Bug pin: matched may ONLY be false on a router-miss. When Hono
|
|
64
|
+
// returns 401 (auth required), the route was clearly found and
|
|
65
|
+
// deliberately rejected — an SPA fallback would override that and
|
|
66
|
+
// redirect the user into the SPA instead of showing the 401 message.
|
|
41
67
|
for (const status of [401, 403, 422, 500] as const) {
|
|
42
68
|
const app = makeApp(new Response(null, { status }));
|
|
43
69
|
const res = await tryHonoFirst(app, new Request("http://test/x"));
|
|
@@ -45,10 +71,10 @@ describe("tryHonoFirst", () => {
|
|
|
45
71
|
}
|
|
46
72
|
});
|
|
47
73
|
|
|
48
|
-
test("
|
|
49
|
-
// Hono.app.fetch
|
|
50
|
-
// handler
|
|
51
|
-
//
|
|
74
|
+
test("accepts both sync and async fetch (Hono variation)", async () => {
|
|
75
|
+
// Hono.app.fetch returns Response | Promise<Response> depending on the
|
|
76
|
+
// handler mix. createApiEntrypoint's apiHandler does the same. The
|
|
77
|
+
// helper must accept both.
|
|
52
78
|
const sync = await tryHonoFirst(
|
|
53
79
|
makeApp(new Response("s", { status: 200 })),
|
|
54
80
|
new Request("http://t/"),
|
|
@@ -60,4 +86,23 @@ describe("tryHonoFirst", () => {
|
|
|
60
86
|
expect(sync.matched).toBe(true);
|
|
61
87
|
expect(asyncRes.matched).toBe(true);
|
|
62
88
|
});
|
|
89
|
+
|
|
90
|
+
test("strips NO_ROUTE_MATCH_HEADER_NAME from the returned response (never leaks to the client)", async () => {
|
|
91
|
+
const app = makeApp(routerMissResponse());
|
|
92
|
+
const res = await tryHonoFirst(app, new Request("http://test/unknown"));
|
|
93
|
+
expect(res.response.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("stripNoRouteMatchHeader", () => {
|
|
98
|
+
test("removes the header when present", () => {
|
|
99
|
+
const res = stripNoRouteMatchHeader(routerMissResponse());
|
|
100
|
+
expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("is a no-op when the header is absent", () => {
|
|
104
|
+
const res = stripNoRouteMatchHeader(new Response("ok", { status: 200 }));
|
|
105
|
+
expect(res.status).toBe(200);
|
|
106
|
+
expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
107
|
+
});
|
|
63
108
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createJobRunLogger } from "@cosmicdrift/kumiko-bundled-features/jobs";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
-
import type { JobRunIn } from "@cosmicdrift/kumiko-framework/engine/types";
|
|
4
|
+
import type { AppContext, JobRunIn } from "@cosmicdrift/kumiko-framework/engine/types";
|
|
5
5
|
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
6
6
|
|
|
7
7
|
export function jobRunLoggerCallbacks(
|
|
@@ -16,7 +16,7 @@ export function jobRunLoggerCallbacks(
|
|
|
16
16
|
export async function startDevJobRunners(opts: {
|
|
17
17
|
readonly registry: Registry;
|
|
18
18
|
readonly db: DbConnection;
|
|
19
|
-
readonly context:
|
|
19
|
+
readonly context: AppContext;
|
|
20
20
|
readonly redisUrl: string;
|
|
21
21
|
}): Promise<{ readonly runners: readonly JobRunner[]; readonly stop: () => Promise<void> }> {
|
|
22
22
|
const jobs = [...opts.registry.getAllJobs().values()];
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { ASSETS_DIR } from "./build-prod-bundle";
|
|
8
8
|
import { injectSchema } from "./inject-schema";
|
|
9
9
|
import type { HostDispatchFn } from "./run-prod-app";
|
|
10
|
-
import { tryHonoFirst } from "./try-hono-first";
|
|
10
|
+
import { stripNoRouteMatchHeader, tryHonoFirst } from "./try-hono-first";
|
|
11
11
|
|
|
12
12
|
// Static-asset + SPA-fallback serving for runProdApp's HTTP handler. Split
|
|
13
13
|
// out of run-prod-app.ts (#1005, Welle 2) — mechanical relocation, these
|
|
@@ -201,9 +201,12 @@ export function buildStaticFallback(
|
|
|
201
201
|
|
|
202
202
|
return async (req: Request): Promise<Response> => {
|
|
203
203
|
const url = new URL(req.url);
|
|
204
|
-
// /api/* and /health → always Hono (Dispatcher + Health-Probe).
|
|
204
|
+
// /api/* and /health → always Hono (Dispatcher + Health-Probe). Bypasses
|
|
205
|
+
// tryHonoFirst entirely, so the router-miss marker must be stripped
|
|
206
|
+
// here too — otherwise an unmatched /api/* path would leak it straight
|
|
207
|
+
// to the client (see try-hono-first.ts's header-hygiene note).
|
|
205
208
|
if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
|
|
206
|
-
return apiHandler(req);
|
|
209
|
+
return stripNoRouteMatchHeader(await apiHandler(req));
|
|
207
210
|
}
|
|
208
211
|
|
|
209
212
|
// Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
|
package/src/run-prod-app.ts
CHANGED
|
@@ -154,6 +154,7 @@ import { buildStaticFallback } from "./run-prod-app-static-files";
|
|
|
154
154
|
import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
|
|
155
155
|
import { assertSessionBootInvariants } from "./session-boot-gate";
|
|
156
156
|
import { shouldWireProdSessions } from "./session-wiring";
|
|
157
|
+
import { stripNoRouteMatchHeader } from "./try-hono-first";
|
|
157
158
|
|
|
158
159
|
export { buildBunServeOptions } from "./bun-serve-options";
|
|
159
160
|
export {
|
|
@@ -1240,7 +1241,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1240
1241
|
appSchemaJson,
|
|
1241
1242
|
options.hostDispatch,
|
|
1242
1243
|
)
|
|
1243
|
-
:
|
|
1244
|
+
: // No staticDir (split-deploy / API-only container) → app.fetch's
|
|
1245
|
+
// response goes straight to the client, bypassing buildStaticFallback
|
|
1246
|
+
// (and with it tryHonoFirst) entirely. Must strip the router-miss
|
|
1247
|
+
// marker here too, same reason as the /api/* passthrough in
|
|
1248
|
+
// run-prod-app-static-files.ts (see try-hono-first.ts).
|
|
1249
|
+
async (req: Request) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req)),
|
|
1244
1250
|
options.securityHeaders,
|
|
1245
1251
|
);
|
|
1246
1252
|
|
package/src/try-hono-first.ts
CHANGED
|
@@ -1,46 +1,77 @@
|
|
|
1
|
-
// Shared helper
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// legal-pages
|
|
6
|
-
//
|
|
1
|
+
// Shared helper for the "Hono-first, SPA-fallback on 404" strategy. Used by
|
|
2
|
+
// both dev (createKumikoServer.handleFetch) AND prod (runProdApp's fetch
|
|
3
|
+
// handler) — identical semantics, one helper. Without the shared helper the
|
|
4
|
+
// two paths drifted silently before (the same bug class that shadowed
|
|
5
|
+
// legal-pages in the dev server — runProdApp's docs said "Hono matches
|
|
6
|
+
// BEFORE fallback", the dev server didn't).
|
|
7
7
|
//
|
|
8
8
|
// Pattern:
|
|
9
|
-
// 1. Try app.fetch(req) —
|
|
10
|
-
// 2. 404
|
|
11
|
-
//
|
|
9
|
+
// 1. Try app.fetch(req) — if Hono matches a route, it wins.
|
|
10
|
+
// 2. Router-miss (404 WITH NO_ROUTE_MATCH_HEADER_NAME) → matched=false,
|
|
11
|
+
// caller does the SPA fallback.
|
|
12
|
+
// 3. Any other status (200, 401, 500, ...) OR a matched route's own 404
|
|
13
|
+
// WITHOUT the marker → response passes through as-is.
|
|
12
14
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
+
// The status code alone used to be the only signal ("404 = no match") —
|
|
16
|
+
// that masked any matched route's deliberate 404 (e.g. default-deny reads)
|
|
17
|
+
// as the SPA shell with status 200 (kumiko-framework#2435). buildServer
|
|
18
|
+
// (framework/api/server.ts) registers app.notFound() LAST on the app object
|
|
19
|
+
// and marks exactly the "no handler found" case with
|
|
20
|
+
// NO_ROUTE_MATCH_HEADER_NAME — a handler that builds its own
|
|
21
|
+
// `c.text(..., 404)` never routes through this code path. The marker is
|
|
22
|
+
// process-internal only: stripNoRouteMatchHeader removes it from EVERY
|
|
23
|
+
// response before it leaves the process, otherwise a caller could
|
|
24
|
+
// distinguish "unknown path" from "known path, access denied" via the
|
|
25
|
+
// header's presence — exactly what routes like file-derivatives'
|
|
26
|
+
// public-variant deliberately hide behind a single status code.
|
|
27
|
+
//
|
|
28
|
+
// req.clone() because downstream needs to read the request body again
|
|
29
|
+
// (future-proofing for POST/PUT/PATCH — only GET routes today).
|
|
30
|
+
|
|
31
|
+
import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
15
32
|
|
|
16
33
|
export type HonoLikeApp = {
|
|
17
|
-
// Hono.app.fetch
|
|
18
|
-
//
|
|
19
|
-
// apiHandler
|
|
20
|
-
//
|
|
34
|
+
// Hono.app.fetch is `(req) => Response | Promise<Response>` (sync if all
|
|
35
|
+
// handlers are sync, otherwise a Promise). createApiEntrypoint's
|
|
36
|
+
// apiHandler matches the same shape. The union accepts both — we await
|
|
37
|
+
// below, which works for either case.
|
|
21
38
|
readonly fetch: (req: Request) => Response | Promise<Response>;
|
|
22
39
|
};
|
|
23
40
|
|
|
24
41
|
export type HonoFirstResult = {
|
|
25
|
-
/** True
|
|
26
|
-
* Caller
|
|
27
|
-
* False
|
|
28
|
-
*
|
|
29
|
-
*
|
|
42
|
+
/** True when Hono has a matching route (no router-miss).
|
|
43
|
+
* Caller returns the response directly.
|
|
44
|
+
* False when no route matches (router-miss, detected via 404 +
|
|
45
|
+
* NO_ROUTE_MATCH_HEADER_NAME). Caller does the SPA/static fallback;
|
|
46
|
+
* the response still carries the 404 as a final fallback in case the
|
|
47
|
+
* SPA path doesn't deliver anything either. */
|
|
30
48
|
readonly matched: boolean;
|
|
31
49
|
readonly response: Response;
|
|
32
50
|
};
|
|
33
51
|
|
|
52
|
+
// Removes the internal router-miss marker from a response before it leaves
|
|
53
|
+
// the process. Needed at EVERY site that potentially passes app.fetch()'s
|
|
54
|
+
// response through to a real client — including the passthrough paths that
|
|
55
|
+
// never call tryHonoFirst at all (e.g. /api/* in runProdApp, dotted-paths/
|
|
56
|
+
// non-GET in the dev server). No no-op check needed: Headers.delete() on a
|
|
57
|
+
// missing key is harmless.
|
|
58
|
+
export function stripNoRouteMatchHeader(response: Response): Response {
|
|
59
|
+
response.headers.delete(NO_ROUTE_MATCH_HEADER_NAME);
|
|
60
|
+
return response;
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
/**
|
|
35
|
-
* Hono-first try: app.fetch
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
64
|
+
* Hono-first try: app.fetch FIRST. If matched (no router-miss), the caller
|
|
65
|
+
* returns the response directly. If not matched, the caller falls back to
|
|
66
|
+
* its own SPA/static fallback — the response (404) stays available as a
|
|
67
|
+
* last-resort safety net.
|
|
39
68
|
*
|
|
40
|
-
* req.clone()
|
|
41
|
-
* (POST/PUT/PATCH
|
|
69
|
+
* req.clone() because downstream needs to read the request body again
|
|
70
|
+
* (future-proofing for POST/PUT/PATCH — only GET routes today).
|
|
42
71
|
*/
|
|
43
72
|
export async function tryHonoFirst(app: HonoLikeApp, req: Request): Promise<HonoFirstResult> {
|
|
44
73
|
const response = await app.fetch(req.clone());
|
|
45
|
-
|
|
74
|
+
const isRouterMiss = response.status === 404 && response.headers.has(NO_ROUTE_MATCH_HEADER_NAME);
|
|
75
|
+
stripNoRouteMatchHeader(response);
|
|
76
|
+
return { matched: !isRouterMiss, response };
|
|
46
77
|
}
|