@cosmicdrift/kumiko-server-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +57 -0
  2. package/package.json +87 -0
  3. package/src/__tests__/boot-extra-context.test.ts +140 -0
  4. package/src/__tests__/build-prod-bundle.test.ts +278 -0
  5. package/src/__tests__/cache-headers.test.ts +83 -0
  6. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +308 -0
  7. package/src/__tests__/compose-features-wiring.integration.test.ts +382 -0
  8. package/src/__tests__/compose-features.test.ts +128 -0
  9. package/src/__tests__/config-seed-boot.integration.test.ts +158 -0
  10. package/src/__tests__/inject-schema.test.ts +62 -0
  11. package/src/__tests__/pii-boot-gate.test.ts +68 -0
  12. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +85 -0
  13. package/src/__tests__/renderer-web-shell-sentinel.test.ts +35 -0
  14. package/src/__tests__/require-env.test.ts +29 -0
  15. package/src/__tests__/resolve-auth-mail.test.ts +69 -0
  16. package/src/__tests__/resolve-tailwind-cli.test.ts +81 -0
  17. package/src/__tests__/run-prod-app-env-source.test.ts +157 -0
  18. package/src/__tests__/run-prod-app-spec.test.ts +57 -0
  19. package/src/__tests__/run-prod-app.integration.test.ts +915 -0
  20. package/src/__tests__/session-wiring.test.ts +51 -0
  21. package/src/__tests__/try-hono-first.test.ts +63 -0
  22. package/src/boot/__tests__/job-run-logger.test.ts +26 -0
  23. package/src/boot/apply-boot-seeds.ts +19 -0
  24. package/src/boot/boot-crypto.ts +82 -0
  25. package/src/boot/job-run-logger.ts +51 -0
  26. package/src/build-prod-bundle.ts +692 -0
  27. package/src/bun-serve-options.ts +22 -0
  28. package/src/compose-features.ts +164 -0
  29. package/src/extra-routes-deps.ts +47 -0
  30. package/src/index.ts +16 -0
  31. package/src/inject-schema.ts +30 -0
  32. package/src/pii-boot-gate.ts +62 -0
  33. package/src/resolve-tailwind-cli.ts +45 -0
  34. package/src/run-prod-app-boot-context.ts +241 -0
  35. package/src/run-prod-app-static-files.ts +273 -0
  36. package/src/run-prod-app.ts +1137 -0
  37. package/src/session-wiring.ts +29 -0
  38. package/src/try-hono-first.ts +46 -0
@@ -0,0 +1,273 @@
1
+ import {
2
+ type CachePolicy,
3
+ cachedResponse,
4
+ computeStrongEtag,
5
+ computeWeakEtag,
6
+ } from "@cosmicdrift/kumiko-framework/api";
7
+ import { ASSETS_DIR } from "./build-prod-bundle";
8
+ import { injectSchema } from "./inject-schema";
9
+ import type { HostDispatchFn } from "./run-prod-app";
10
+ import { tryHonoFirst } from "./try-hono-first";
11
+
12
+ // Static-asset + SPA-fallback serving for runProdApp's HTTP handler. Split
13
+ // out of run-prod-app.ts (#1005, Welle 2) — mechanical relocation, these
14
+ // functions are self-contained (no closure over runProdApp's local boot
15
+ // state), only params.
16
+
17
+ // Static-fallback: try the Hono app first, fall back to a file in
18
+ // staticDir if Hono returns 404. Keeps /api/* on the dispatcher and
19
+ // everything else (HTML, JS, CSS, images) on the disk.
20
+ //
21
+ // Cache-Header-Strategie:
22
+ // /assets/* → public, max-age=31536000, immutable
23
+ // (gehashte Filenames vom Build, sicher cachebar)
24
+ // /index.html → no-cache, must-revalidate
25
+ // (HTML-Shell, must reload on deploy)
26
+ // /manifest.json, /sw.js → no-cache
27
+ // (Update-Detection-Mechanismen, müssen frisch sein)
28
+ // alles andere → kein expliziter Header
29
+ // (Browser-Default, public/-Files wie favicon)
30
+ // File-reader für den static-fallback. Nutzt node:fs/promises statt
31
+ // Bun.file damit der Pfad in vitest+node integration-tests laufen kann
32
+ // (Bun.file ist Bun-only). Performance-cost ist marginal: die Disk-
33
+ // Files in einem prod-staticDir sind 1-200 KB, full-buffer-Read ist
34
+ // ein paar Mikrosekunden. Streaming via Bun.file wäre nur relevant ab
35
+ // ~1 MB.
36
+ export async function readStaticFile(
37
+ filePath: string,
38
+ ): Promise<
39
+ { readonly bytes: Uint8Array; readonly mime: string; readonly mtimeMs: number } | undefined
40
+ > {
41
+ try {
42
+ const { readFile, stat } = await import("node:fs/promises");
43
+ const [bytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
44
+ return { bytes, mime: mimeTypeFor(filePath), mtimeMs: fileStat.mtimeMs };
45
+ } catch (err) {
46
+ if ((err as { code?: string }).code === "ENOENT") return undefined;
47
+ throw err;
48
+ }
49
+ }
50
+
51
+ export function serveDiskFile(
52
+ req: Request,
53
+ pathname: string,
54
+ file: {
55
+ readonly bytes: Uint8Array;
56
+ readonly mime: string;
57
+ readonly mtimeMs: number;
58
+ },
59
+ ): Response {
60
+ return cachedResponse(req, {
61
+ // @cast-boundary bun-types — Response BodyInit narrowing
62
+ body: file.bytes as unknown as BodyInit,
63
+ etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
64
+ cache: staticCachePolicy(pathname),
65
+ headers: { "content-type": file.mime },
66
+ lastModified: new Date(file.mtimeMs),
67
+ });
68
+ }
69
+
70
+ // Minimal-Mime-Map — deckt die Files ab die kumiko-build und typische
71
+ // public/-Inhalte produzieren. Bun.file leitet das aus dem Suffix ab,
72
+ // im node-Pfad müssen wir es selbst tun. Default: octet-stream (Browser
73
+ // fragt bei unbekanntem MIME nach).
74
+ export function mimeTypeFor(filePath: string): string {
75
+ const ext = filePath.toLowerCase().split(".").pop() ?? "";
76
+ switch (ext) {
77
+ case "html":
78
+ return "text/html; charset=utf-8";
79
+ case "js":
80
+ case "mjs":
81
+ return "text/javascript; charset=utf-8";
82
+ case "css":
83
+ return "text/css; charset=utf-8";
84
+ case "json":
85
+ return "application/json; charset=utf-8";
86
+ case "svg":
87
+ return "image/svg+xml";
88
+ case "png":
89
+ return "image/png";
90
+ case "jpg":
91
+ case "jpeg":
92
+ return "image/jpeg";
93
+ case "ico":
94
+ return "image/x-icon";
95
+ case "txt":
96
+ return "text/plain; charset=utf-8";
97
+ case "xml":
98
+ return "application/xml; charset=utf-8";
99
+ case "webmanifest":
100
+ return "application/manifest+json";
101
+ default:
102
+ return "application/octet-stream";
103
+ }
104
+ }
105
+
106
+ export function buildStaticFallback(
107
+ apiHandler: (req: Request) => Response | Promise<Response>,
108
+ staticDir: string,
109
+ appSchemaJson: string,
110
+ hostDispatch?: HostDispatchFn,
111
+ ): (req: Request) => Promise<Response> {
112
+ const indexHtml = `${staticDir}/index.html`;
113
+
114
+ // Helper: liest eine HTML-Datei von der Disk + (optional) injiziert
115
+ // das pre-serialized AppSchema vor dem client.js-Tag. Schema-Injection
116
+ // ist explicit-opt-in damit Public-Domain-Antworten die Admin-UI-
117
+ // Topologie nicht leaken. injectSchema ist idempotent, doppelte Calls
118
+ // produzieren keinen doppelten Tag.
119
+ async function readHtmlFile(
120
+ path: string,
121
+ injectSchemaInto: boolean,
122
+ ): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number } | null> {
123
+ const file = await readStaticFile(path);
124
+ if (!file) return null;
125
+ if (!injectSchemaInto) {
126
+ return {
127
+ bytes: file.bytes.buffer.slice(
128
+ file.bytes.byteOffset,
129
+ file.bytes.byteOffset + file.bytes.byteLength,
130
+ ) as ArrayBuffer,
131
+ mime: file.mime,
132
+ etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
133
+ mtimeMs: file.mtimeMs,
134
+ };
135
+ }
136
+ const text = new TextDecoder().decode(file.bytes);
137
+ const injected = injectSchema(text, appSchemaJson);
138
+ const bytes = new TextEncoder().encode(injected).buffer as ArrayBuffer;
139
+ return {
140
+ bytes,
141
+ mime: file.mime,
142
+ etag: computeStrongEtag(new Uint8Array(bytes)),
143
+ mtimeMs: file.mtimeMs,
144
+ };
145
+ }
146
+
147
+ function serveHtmlFile(
148
+ req: Request,
149
+ pathname: string,
150
+ html: { bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number },
151
+ extraHeaders?: Record<string, string>,
152
+ ): Response {
153
+ return cachedResponse(req, {
154
+ body: html.bytes,
155
+ etag: html.etag,
156
+ cache: staticCachePolicy(pathname),
157
+ headers: { "content-type": html.mime, ...extraHeaders },
158
+ lastModified: new Date(html.mtimeMs),
159
+ });
160
+ }
161
+
162
+ // hostDispatch konsultieren wenn gesetzt UND der Request auf den
163
+ // HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
164
+ // resolved Response (redirect/404/html) oder null wenn der Default-
165
+ // Pfad weiterlaufen soll.
166
+ async function tryHostDispatch(req: Request): Promise<Response | null> {
167
+ if (!hostDispatch) return null;
168
+ const url = new URL(req.url);
169
+ const host = req.headers.get("host") ?? url.host;
170
+ const result = hostDispatch({ host, path: url.pathname, search: url.search });
171
+ if (result.kind === "not-found") {
172
+ return new Response("Not Found", { status: 404 });
173
+ }
174
+ if (result.kind === "redirect") {
175
+ return new Response(null, {
176
+ status: result.status ?? 302,
177
+ headers: { Location: result.to },
178
+ });
179
+ }
180
+ // result.kind === "html"
181
+ const filePath = `${staticDir}/${result.file}`;
182
+ const html = await readHtmlFile(filePath, result.injectSchema === true);
183
+ if (!html) {
184
+ // Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
185
+ // Liefer 500 statt silent-404 damit der Bug schnell auffällt.
186
+ return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
187
+ }
188
+ // Per-Host-Body (hostDispatch wählt die Datei nach Host) → Vary: Host,
189
+ // sonst darf ein Shared-Cache Tenant-As Schema an Tenant B liefern.
190
+ const extraHeaders: Record<string, string> = { vary: "Host" };
191
+ if (result.csp) extraHeaders["content-security-policy"] = result.csp;
192
+ return serveHtmlFile(req, "/index.html", html, extraHeaders);
193
+ }
194
+
195
+ return async (req: Request): Promise<Response> => {
196
+ const url = new URL(req.url);
197
+ // /api/* and /health → always Hono (Dispatcher + Health-Probe).
198
+ if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
199
+ return apiHandler(req);
200
+ }
201
+
202
+ // Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
203
+ // /sitemap.xml) UND r.httpRoute-Features (z.B. /legal/*) müssen vor
204
+ // dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
205
+ // unbekannte Pfade als index.html. Shared mit dev-server's
206
+ // createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
207
+ const honoTry = await tryHonoFirst({ fetch: apiHandler }, req);
208
+ if (honoTry.matched) {
209
+ return honoTry.response;
210
+ }
211
+ const honoRes = honoTry.response;
212
+
213
+ // Disk-/SPA-Fallback ist GET/HEAD-only. Ein non-GET ohne Hono-Match
214
+ // (z.B. POST auf einen falsch konfigurierten Webhook-Pfad) muss den
215
+ // Hono-404 durchreichen — 200 index.html würde dem Provider
216
+ // "delivered" signalisieren und Events gingen still verloren (#259).
217
+ if (req.method !== "GET" && req.method !== "HEAD") {
218
+ return honoRes;
219
+ }
220
+
221
+ // Disk-Datei (Asset oder konkrete File). Asset-Pfade laufen
222
+ // host-unabhängig — die Bundles in /assets/* werden vom client
223
+ // aktiv geladen, kein Server-side Routing nötig.
224
+ const isIndexRequest = url.pathname === "/" || url.pathname === "/index.html";
225
+ if (!isIndexRequest) {
226
+ const relPath = url.pathname.slice(1);
227
+ const filePath = `${staticDir}/${relPath}`;
228
+ const file = await readStaticFile(filePath);
229
+ if (file) {
230
+ return serveDiskFile(req, url.pathname, file);
231
+ }
232
+ }
233
+
234
+ // Root oder SPA-Route — hier greift hostDispatch wenn gesetzt.
235
+ // Ohne hostDispatch: alter Single-App-Pfad (index.html mit Schema).
236
+ const dispatched = await tryHostDispatch(req);
237
+ if (dispatched) return dispatched;
238
+
239
+ // Default Single-App-Pfad: index.html, schema injected.
240
+ const index = await readHtmlFile(indexHtml, true);
241
+ if (index) {
242
+ return serveHtmlFile(req, "/index.html", index);
243
+ }
244
+
245
+ // Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
246
+ // ursprünglichen 404 von Hono durch (statt einen neuen Roundtrip).
247
+ return honoRes;
248
+ };
249
+ }
250
+
251
+ // Map URL-Pfad → Cache-Policy. Hashed-Asset-Pfade (/assets/*) sind
252
+ // unveränderlich, der Rest bleibt revalidate/no-cache damit Updates ohne
253
+ // Hard-Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
254
+ // runProdApp.
255
+ export function staticCachePolicy(pathname: string): CachePolicy {
256
+ if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
257
+ return { kind: "immutable" };
258
+ }
259
+ if (pathname === "/" || pathname === "/index.html") {
260
+ return { kind: "revalidate" };
261
+ }
262
+ if (
263
+ pathname === "/manifest.json" ||
264
+ pathname === "/sw.js" ||
265
+ // ponytail: build-info.json ist statisch — kein /api/version-Endpoint
266
+ // nötig, der Disk-Fallback serviert sie. no-cache, sonst pollt der
267
+ // UpdateChecker eine veraltete id.
268
+ pathname === "/build-info.json"
269
+ ) {
270
+ return { kind: "no-cache" };
271
+ }
272
+ return { kind: "none" };
273
+ }