@cosmicdrift/kumiko-server-runtime 0.328.1 → 0.330.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.
Files changed (90) hide show
  1. package/README.md +15 -0
  2. package/dist/boot/apply-boot-seeds.d.ts +8 -0
  3. package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
  4. package/dist/boot/boot-crypto.d.ts +9 -0
  5. package/dist/boot/boot-crypto.js +39 -0
  6. package/dist/boot/job-run-logger.d.ts +19 -0
  7. package/dist/boot/job-run-logger.js +44 -0
  8. package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
  9. package/dist/boot/pii-event-backfill-on-boot.js +25 -0
  10. package/dist/build-prod-bundle.d.ts +103 -0
  11. package/dist/build-prod-bundle.js +751 -0
  12. package/dist/bun-serve-options.d.ts +32 -0
  13. package/dist/bun-serve-options.js +49 -0
  14. package/dist/compose-features.d.ts +31 -0
  15. package/dist/compose-features.js +124 -0
  16. package/dist/extra-routes-deps.d.ts +30 -0
  17. package/dist/extra-routes-deps.js +4 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +7 -0
  20. package/dist/pii-boot-gate.d.ts +13 -0
  21. package/dist/pii-boot-gate.js +46 -0
  22. package/dist/renderer-web-fonts.d.ts +9 -0
  23. package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
  24. package/dist/resolve-tailwind-cli.d.ts +14 -0
  25. package/dist/resolve-tailwind-cli.js +36 -0
  26. package/dist/run-prod-app-boot-context.d.ts +43 -0
  27. package/dist/run-prod-app-boot-context.js +198 -0
  28. package/dist/run-prod-app-static-files.d.ts +32 -0
  29. package/dist/run-prod-app-static-files.js +280 -0
  30. package/dist/run-prod-app.d.ts +471 -0
  31. package/dist/run-prod-app.js +741 -0
  32. package/dist/run-worker-app.d.ts +92 -0
  33. package/dist/run-worker-app.js +220 -0
  34. package/dist/security-headers.d.ts +20 -0
  35. package/dist/security-headers.js +49 -0
  36. package/dist/session-boot-gate.d.ts +11 -0
  37. package/dist/session-boot-gate.js +22 -0
  38. package/dist/session-wiring.d.ts +11 -0
  39. package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
  40. package/dist/try-hono-first.d.ts +24 -0
  41. package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
  42. package/package.json +102 -36
  43. package/src/changes.json +6 -0
  44. package/src/__tests__/boot-extra-context.test.ts +0 -163
  45. package/src/__tests__/boot-probe-fixture.ts +0 -64
  46. package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
  47. package/src/__tests__/build-prod-bundle.test.ts +0 -724
  48. package/src/__tests__/cache-headers.test.ts +0 -83
  49. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  50. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -609
  51. package/src/__tests__/compose-features.test.ts +0 -206
  52. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  53. package/src/__tests__/pii-boot-gate.test.ts +0 -112
  54. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  55. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  56. package/src/__tests__/renderer-web-theme-plugin.integration.test.ts +0 -77
  57. package/src/__tests__/require-env.test.ts +0 -34
  58. package/src/__tests__/resolve-auth-mail.test.ts +0 -144
  59. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  60. package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
  61. package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
  62. package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
  63. package/src/__tests__/run-prod-app-spec.test.ts +0 -167
  64. package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
  65. package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
  66. package/src/__tests__/run-worker-app-boot.test.ts +0 -183
  67. package/src/__tests__/run-worker-app.integration.test.ts +0 -218
  68. package/src/__tests__/security-headers.test.ts +0 -88
  69. package/src/__tests__/session-boot-gate.test.ts +0 -70
  70. package/src/__tests__/session-wiring.test.ts +0 -16
  71. package/src/__tests__/try-hono-first.test.ts +0 -108
  72. package/src/__tests__/websocket-route.integration.test.ts +0 -580
  73. package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
  74. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  75. package/src/boot/boot-crypto.ts +0 -67
  76. package/src/boot/job-run-logger.ts +0 -63
  77. package/src/boot/pii-event-backfill-on-boot.ts +0 -43
  78. package/src/build-prod-bundle.ts +0 -996
  79. package/src/bun-serve-options.ts +0 -110
  80. package/src/compose-features.ts +0 -197
  81. package/src/extra-routes-deps.ts +0 -38
  82. package/src/index.ts +0 -27
  83. package/src/pii-boot-gate.ts +0 -80
  84. package/src/resolve-tailwind-cli.ts +0 -45
  85. package/src/run-prod-app-boot-context.ts +0 -319
  86. package/src/run-prod-app-static-files.ts +0 -359
  87. package/src/run-prod-app.ts +0 -1420
  88. package/src/run-worker-app.ts +0 -366
  89. package/src/security-headers.ts +0 -67
  90. package/src/session-boot-gate.ts +0 -34
@@ -1,167 +0,0 @@
1
- // Spec-Tests für die Bun.serve-Options + Heartbeat-Cadence. Diese
2
- // Konstanten/Defaults haben Live-Bugs verursacht und sind 1-Zeile-
3
- // revertierbar — die Tests pinsen Intent + akzeptablen Range gegen
4
- // "looks like a leak"-Reverts oder "bisschen rauf, sollte reichen"-
5
- // Tweaks.
6
-
7
- import { describe, expect, test } from "bun:test";
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";
16
- import {
17
- createEntity,
18
- createFileField,
19
- createRegistry,
20
- defineFeature,
21
- } from "@cosmicdrift/kumiko-framework/engine";
22
- import { createFilesFeature, resolveMaxUploadBodyBytes } from "@cosmicdrift/kumiko-framework/files";
23
- import {
24
- DEFAULT_MAX_REQUEST_BODY_SIZE_BYTES,
25
- resolveDerivedMaxRequestBodySize,
26
- } from "../bun-serve-options";
27
- import { buildBunServeOptions } from "../run-prod-app";
28
-
29
- describe("Bun.serve options for production", () => {
30
- test("idleTimeout is 0 (disabled) — required for SSE long-lived connections", () => {
31
- // Bun.serve default ist 10s — ohne Override killt das SSE-Streams
32
- // mit halbem HTTP/2-RST_STREAM. Spec ist "disabled"; jeder andere
33
- // Wert (auch ein vermeintlich "großzügiges" 60) bricht SSE sobald
34
- // ein Client den Tab im Hintergrund hat (kein Heartbeat-Read auf
35
- // Browser-Seite, Idle-Timer feuert).
36
- const opts = buildBunServeOptions(0, () => new Response("ok"));
37
- expect(opts.idleTimeout).toBe(0);
38
- });
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
-
90
- test("port wird 1:1 durchgereicht, fetch reicht req + Socket-Adresse an den Handler weiter", async () => {
91
- let received: [Request, string | undefined] | undefined;
92
- const fetchHandler = (req: Request, socketAddress?: string) => {
93
- received = [req, socketAddress];
94
- return new Response("test");
95
- };
96
- const opts = buildBunServeOptions(3000, fetchHandler);
97
- expect(opts.port).toBe(3000);
98
- const req = new Request("http://localhost/");
99
- const fakeServer = { requestIP: () => ({ address: "203.0.113.1", port: 1, family: "IPv4" }) };
100
- await opts.fetch(req, fakeServer as unknown as Bun.Server<unknown>);
101
- expect(received?.[0]).toBe(req);
102
- expect(received?.[1]).toBe("203.0.113.1");
103
- });
104
-
105
- test("maxRequestBodySize defaults well below Bun's own 128 MiB", () => {
106
- // Bun.serve's undocumented-in-our-code default (128 MiB) buffers every
107
- // concurrent request up to that size before any app-level body-limit
108
- // middleware runs. Pins the lowered default against a silent revert to
109
- // "just let Bun handle it".
110
- const opts = buildBunServeOptions(0, () => new Response("ok"));
111
- expect(opts.maxRequestBodySize).toBe(DEFAULT_MAX_REQUEST_BODY_SIZE_BYTES);
112
- expect(opts.maxRequestBodySize).toBeLessThan(128 * 1024 * 1024);
113
- });
114
-
115
- test("maxRequestBodySize is overridable for apps with larger maxUploadSize", () => {
116
- const opts = buildBunServeOptions(0, () => new Response("ok"), 64 * 1024 * 1024);
117
- expect(opts.maxRequestBodySize).toBe(64 * 1024 * 1024);
118
- });
119
- });
120
-
121
- describe("resolveDerivedMaxRequestBodySize", () => {
122
- test("stays above the files route's own derived bodyLimit", () => {
123
- // Otherwise Bun's bare rejection would fire before the route's own,
124
- // more specific 413 ever runs.
125
- const registry = createRegistry([createFilesFeature({ maxUploadSize: "50mb" })]);
126
- const routeBodyLimit = resolveMaxUploadBodyBytes({ registry, maxUploadSize: "50mb" });
127
- expect(resolveDerivedMaxRequestBodySize(registry)).toBeGreaterThan(routeBodyLimit);
128
- });
129
-
130
- test("accounts for a field maxSize configured above the global default", () => {
131
- const entity = createEntity({
132
- fields: { attachment: createFileField({ maxSize: "50mb" }) },
133
- });
134
- const feature = defineFeature("attachments", (r) => r.entity("attachment", entity));
135
- const registry = createRegistry([feature, createFilesFeature()]);
136
- expect(resolveDerivedMaxRequestBodySize(registry)).toBeGreaterThanOrEqual(50 * 1024 * 1024);
137
- });
138
- });
139
-
140
- describe("SSE heartbeat cadence", () => {
141
- test("liegt unter dem strengsten Edge-Idle-Timeout (Cloudflare 100s)", () => {
142
- // Bun.serve idleTimeout ist disabled (siehe buildBunServeOptions),
143
- // damit fällt der Bun-default-10s-Layer raus. Die strengsten
144
- // verbleibenden Layer sind CDN/LB-Edges:
145
- // - Cloudflare Edge: 100 s ← strengster realistischer Layer
146
- // - AWS ALB: 60 s
147
- // - Nginx default: keep-alive 60 s, aber nicht für Streams
148
- // Heartbeat muss DEUTLICH darunter liegen damit auch ein verschluckter
149
- // Frame nicht zur Connection-Death führt — Faktor 5 ist konservativ.
150
- expect(SSE_HEARTBEAT_INTERVAL_MS).toBeLessThan(60_000);
151
- });
152
-
153
- test("liegt nicht zu hoch — DefenseInDepth gegen Bun-Default-Drift", () => {
154
- // Wenn jemand idleTimeout: 0 wieder rausnimmt (Audit, Linter,
155
- // Misverständnis), darf der Heartbeat nicht der einzige
156
- // Schutzwall sein der unter dem 10s-Bun-default liegt. ≤30s
157
- // gibt Cushion + bleibt deutlich unter Bun-default-10s × 3.
158
- expect(SSE_HEARTBEAT_INTERVAL_MS).toBeLessThanOrEqual(30_000);
159
- });
160
-
161
- test("ist >= 5 s — Frequency-Wall gegen versehentliches Network-Spam", () => {
162
- // 1000 anonyme Viewer × Heartbeat-Frequency darf den Server nicht
163
- // mit Frames fluten. 5s = 200 frames/s pro 1000 Clients ist OK.
164
- // Unter 5s wäre eher ein "tippfehler" als bewusste Wahl.
165
- expect(SSE_HEARTBEAT_INTERVAL_MS).toBeGreaterThanOrEqual(5_000);
166
- });
167
- });
@@ -1,298 +0,0 @@
1
- // Unit coverage for mimeTypeFor + hostDispatch edge in buildStaticFallback.
2
-
3
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
8
- import {
9
- buildStaticFallback,
10
- mimeTypeFor,
11
- readStaticFile,
12
- serveDiskFile,
13
- } from "../run-prod-app-static-files";
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
-
24
- describe("mimeTypeFor", () => {
25
- const cases: ReadonlyArray<readonly [string, string]> = [
26
- ["x.html", "text/html; charset=utf-8"],
27
- ["x.js", "text/javascript; charset=utf-8"],
28
- ["x.mjs", "text/javascript; charset=utf-8"],
29
- ["x.css", "text/css; charset=utf-8"],
30
- ["x.json", "application/json; charset=utf-8"],
31
- ["x.svg", "image/svg+xml"],
32
- ["x.png", "image/png"],
33
- ["x.jpg", "image/jpeg"],
34
- ["x.jpeg", "image/jpeg"],
35
- ["x.ico", "image/x-icon"],
36
- ["x.txt", "text/plain; charset=utf-8"],
37
- ["x.xml", "application/xml; charset=utf-8"],
38
- ["x.webmanifest", "application/manifest+json"],
39
- ["x.bin", "application/octet-stream"],
40
- ["noext", "application/octet-stream"],
41
- ];
42
-
43
- for (const [path, mime] of cases) {
44
- test(`${path} → ${mime}`, () => {
45
- expect(mimeTypeFor(path)).toBe(mime);
46
- });
47
- }
48
- });
49
-
50
- describe("readStaticFile / serveDiskFile", () => {
51
- let tmp = "";
52
-
53
- beforeEach(async () => {
54
- tmp = await mkdtemp(join(tmpdir(), "kumiko-static-"));
55
- });
56
-
57
- afterEach(async () => {
58
- await rm(tmp, { recursive: true, force: true });
59
- });
60
-
61
- test("readStaticFile returns bytes+mime; ENOENT → undefined", async () => {
62
- const path = join(tmp, "a.css");
63
- await writeFile(path, "body{}");
64
- const file = await readStaticFile(path);
65
- expect(file?.mime).toBe("text/css; charset=utf-8");
66
- expect(new TextDecoder().decode(file!.bytes)).toBe("body{}");
67
- expect(await readStaticFile(join(tmp, "missing.css"))).toBeUndefined();
68
- });
69
-
70
- test("readStaticFile on a directory → undefined (EISDIR), not a throw", async () => {
71
- const dirPath = join(tmp, "sub");
72
- await mkdir(dirPath);
73
- expect(await readStaticFile(dirPath)).toBeUndefined();
74
- });
75
-
76
- test("readStaticFile through a file used as a directory segment → undefined (ENOTDIR), not a throw (#1504)", async () => {
77
- const path = join(tmp, "index.html");
78
- await writeFile(path, "<html/>");
79
- // GET /index.html/x — "index.html" is a file, so treating it as a
80
- // directory segment must fall through to the SPA fallback, not 500.
81
- expect(await readStaticFile(join(path, "x"))).toBeUndefined();
82
- });
83
-
84
- test("serveDiskFile sets content-type from mime", async () => {
85
- const path = join(tmp, "a.svg");
86
- await writeFile(path, "<svg/>");
87
- const file = await readStaticFile(path);
88
- const res = serveDiskFile(new Request("http://t/a.svg"), "/a.svg", file!);
89
- expect(res.headers.get("content-type")).toBe("image/svg+xml");
90
- expect(await res.text()).toBe("<svg/>");
91
- });
92
- });
93
-
94
- describe("buildStaticFallback hostDispatch", () => {
95
- let tmp = "";
96
-
97
- beforeEach(async () => {
98
- tmp = await mkdtemp(join(tmpdir(), "kumiko-fallback-"));
99
- });
100
-
101
- afterEach(async () => {
102
- await rm(tmp, { recursive: true, force: true });
103
- });
104
-
105
- test("hostDispatch html pointing at missing file → 500", async () => {
106
- const handler = buildStaticFallback(
107
- () => noRouteMatchedResponse(),
108
- tmp,
109
- () => ({ kind: "html", file: "gone.html" }),
110
- );
111
- const res = await handler(new Request("http://t/"));
112
- expect(res.status).toBe(500);
113
- expect(await res.text()).toContain("hostDispatch: file not found: gone.html");
114
- });
115
-
116
- test("hostDispatch not-found → 404; redirect → 302", async () => {
117
- const notFound = buildStaticFallback(
118
- () => noRouteMatchedResponse(),
119
- tmp,
120
- () => ({ kind: "not-found" }),
121
- );
122
- expect((await notFound(new Request("http://t/"))).status).toBe(404);
123
-
124
- const redirect = buildStaticFallback(
125
- () => noRouteMatchedResponse(),
126
- tmp,
127
- () => ({ kind: "redirect", to: "https://example.com/", status: 301 }),
128
- );
129
- const res = await redirect(new Request("http://t/"));
130
- expect(res.status).toBe(301);
131
- expect(res.headers.get("location")).toBe("https://example.com/");
132
- });
133
-
134
- test("/api/* always hits apiHandler", async () => {
135
- const handler = buildStaticFallback(() => new Response("from-api", { status: 200 }), tmp);
136
- const res = await handler(new Request("http://t/api/query", { method: "POST" }));
137
- expect(res.status).toBe(200);
138
- expect(await res.text()).toBe("from-api");
139
- });
140
-
141
- test("serves disk asset under staticDir", async () => {
142
- await writeFile(join(tmp, "logo.png"), "PNGDATA");
143
- const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp);
144
- const res = await handler(new Request("http://t/logo.png"));
145
- expect(res.status).toBe(200);
146
- expect(res.headers.get("content-type")).toBe("image/png");
147
- expect(await res.text()).toBe("PNGDATA");
148
- });
149
-
150
- test("a request for a directory copied verbatim from public/ falls back to index.html instead of 500ing", async () => {
151
- await mkdir(join(tmp, "sub"));
152
- await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
153
- const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp);
154
- const res = await handler(new Request("http://t/sub"));
155
- expect(res.status).toBe(200);
156
- expect(await res.text()).toContain("spa-shell");
157
- });
158
-
159
- test("hostDispatch html with CSP + Vary: Host", async () => {
160
- await writeFile(join(tmp, "tenant.html"), "<!doctype html><html><body>ok</body></html>");
161
- const handler = buildStaticFallback(
162
- () => noRouteMatchedResponse(),
163
- tmp,
164
- () => ({
165
- kind: "html",
166
- file: "tenant.html",
167
- csp: "default-src 'self'",
168
- }),
169
- );
170
- const res = await handler(new Request("http://t/", { headers: { host: "a.example" } }));
171
- expect(res.status).toBe(200);
172
- expect(res.headers.get("vary")).toBe("Host");
173
- expect(res.headers.get("content-security-policy")).toBe("default-src 'self'");
174
- expect(await res.text()).toContain("ok");
175
- });
176
-
177
- test("a matched route's own deliberate 404 stays 404, not masked as the SPA shell (#2435)", async () => {
178
- // An index.html exists on disk, so the OLD status-only heuristic would
179
- // have served it with status 200 for ANY 404 — including one from a
180
- // matched route (e.g. file-derivatives' public-variant default-deny)
181
- // that never carries NO_ROUTE_MATCH_HEADER_NAME.
182
- await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
183
- const handler = buildStaticFallback(() => new Response("not found", { status: 404 }), tmp);
184
- const res = await handler(new Request("http://t/files/deadbeef/thumb"));
185
- expect(res.status).toBe(404);
186
- expect(await res.text()).toBe("not found");
187
- });
188
-
189
- test("an unknown SPA route with no matching handler still gets the SPA shell", async () => {
190
- await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
191
- const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp);
192
- const res = await handler(new Request("http://t/some/client-side/route"));
193
- expect(res.status).toBe(200);
194
- expect(await res.text()).toContain("spa-shell");
195
- });
196
- });
197
-
198
- describe("buildStaticFallback resolvePageHead", () => {
199
- let tmp = "";
200
-
201
- const HTML = "<!doctype html><html><head><title>Offlot</title></head><body>shell</body></html>";
202
-
203
- function stubDispatcher(): { query: () => Promise<unknown> } {
204
- return { query: async () => ({}) };
205
- }
206
-
207
- beforeEach(async () => {
208
- tmp = await mkdtemp(join(tmpdir(), "kumiko-pagehead-"));
209
- await writeFile(join(tmp, "tenant.html"), HTML);
210
- await writeFile(join(tmp, "index.html"), HTML);
211
- });
212
-
213
- afterEach(async () => {
214
- await rm(tmp, { recursive: true, force: true });
215
- });
216
-
217
- // Exercised through hostDispatch's "html" branch — that's the path
218
- // offlot actually uses in production (createOfflotHostDispatch returns
219
- // {kind:"html", ...} for every host), not the default single-app path.
220
- test("resolver meta → og-tags appear in the hostDispatch HTML response", async () => {
221
- const handler = buildStaticFallback(
222
- () => noRouteMatchedResponse(),
223
- tmp,
224
- () => ({ kind: "html", file: "tenant.html" }),
225
- {
226
- resolvePageHead: async () => ({
227
- title: "Vehicle X",
228
- description: "A car",
229
- ogImage: "https://x/i.png",
230
- }),
231
- dispatcher: stubDispatcher(),
232
- },
233
- );
234
- const res = await handler(new Request("http://t/"));
235
- expect(res.status).toBe(200);
236
- const text = await res.text();
237
- expect(text).toContain("<title>Vehicle X</title>");
238
- expect(text).toContain('<meta property="og:image" content="https://x/i.png" />');
239
- });
240
-
241
- test("resolver throws → 200 with the unchanged shell", async () => {
242
- const handler = buildStaticFallback(
243
- () => noRouteMatchedResponse(),
244
- tmp,
245
- () => ({ kind: "html", file: "tenant.html" }),
246
- {
247
- resolvePageHead: async () => {
248
- throw new Error("boom");
249
- },
250
- dispatcher: stubDispatcher(),
251
- },
252
- );
253
- const res = await handler(new Request("http://t/"));
254
- expect(res.status).toBe(200);
255
- expect(await res.text()).toBe(HTML);
256
- });
257
-
258
- test("resolver never resolves → 200 with the unchanged shell after the timeout", async () => {
259
- const handler = buildStaticFallback(
260
- () => noRouteMatchedResponse(),
261
- tmp,
262
- () => ({ kind: "html", file: "tenant.html" }),
263
- {
264
- resolvePageHead: () => new Promise(() => {}),
265
- dispatcher: stubDispatcher(),
266
- },
267
- );
268
- const res = await handler(new Request("http://t/"));
269
- expect(res.status).toBe(200);
270
- expect(await res.text()).toBe(HTML);
271
- }, 2000);
272
-
273
- test("two paths with different resolved titles get different ETags", async () => {
274
- const handler = buildStaticFallback(
275
- () => noRouteMatchedResponse(),
276
- tmp,
277
- () => ({ kind: "html", file: "tenant.html" }),
278
- {
279
- resolvePageHead: async ({ path }) => ({ title: `Title for ${path}` }),
280
- dispatcher: stubDispatcher(),
281
- },
282
- );
283
- const resA = await handler(new Request("http://t/a"));
284
- const resB = await handler(new Request("http://t/b"));
285
- expect(resA.headers.get("etag")).not.toBe(resB.headers.get("etag"));
286
- });
287
-
288
- test("no resolvePageHead configured → response is byte-identical to the no-resolver call", async () => {
289
- const withoutPageHead = buildStaticFallback(
290
- () => noRouteMatchedResponse(),
291
- tmp,
292
- () => ({ kind: "html", file: "tenant.html" }),
293
- );
294
- const res = await withoutPageHead(new Request("http://t/"));
295
- expect(res.status).toBe(200);
296
- expect(await res.text()).toBe(HTML);
297
- });
298
- });