@cosmicdrift/kumiko-dev-server 0.151.1 → 0.153.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 (48) hide show
  1. package/package.json +5 -8
  2. package/src/__tests__/build-prod-bundle.integration.test.ts +1 -1
  3. package/src/__tests__/compose-stacks.test.ts +1 -1
  4. package/src/__tests__/discover-format.test.ts +1 -1
  5. package/src/__tests__/env-schema.integration.test.ts +1 -1
  6. package/src/__tests__/walkthrough.integration.test.ts +1 -1
  7. package/src/build.ts +1 -1
  8. package/src/create-kumiko-server.ts +12 -6
  9. package/src/index.ts +4 -12
  10. package/src/run-dev-app.ts +12 -8
  11. package/src/scaffold-app.ts +9 -4
  12. package/src/schema-apply.ts +4 -1
  13. package/src/schema-check-core.ts +1 -1
  14. package/src/setup-test-stack-from-features.ts +4 -1
  15. package/src/__tests__/boot-extra-context.test.ts +0 -140
  16. package/src/__tests__/build-prod-bundle.test.ts +0 -278
  17. package/src/__tests__/cache-headers.test.ts +0 -83
  18. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  19. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -382
  20. package/src/__tests__/compose-features.test.ts +0 -128
  21. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  22. package/src/__tests__/inject-schema.test.ts +0 -62
  23. package/src/__tests__/pii-boot-gate.test.ts +0 -68
  24. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  25. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  26. package/src/__tests__/require-env.test.ts +0 -29
  27. package/src/__tests__/resolve-auth-mail.test.ts +0 -69
  28. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  29. package/src/__tests__/run-prod-app-env-source.test.ts +0 -157
  30. package/src/__tests__/run-prod-app-spec.test.ts +0 -57
  31. package/src/__tests__/run-prod-app.integration.test.ts +0 -915
  32. package/src/__tests__/session-wiring.test.ts +0 -51
  33. package/src/__tests__/try-hono-first.test.ts +0 -63
  34. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  35. package/src/boot/apply-boot-seeds.ts +0 -19
  36. package/src/boot/boot-crypto.ts +0 -82
  37. package/src/boot/job-run-logger.ts +0 -51
  38. package/src/build-prod-bundle.ts +0 -692
  39. package/src/compose-features.ts +0 -164
  40. package/src/extra-routes-deps.ts +0 -47
  41. package/src/inject-schema.ts +0 -30
  42. package/src/pii-boot-gate.ts +0 -62
  43. package/src/resolve-tailwind-cli.ts +0 -45
  44. package/src/run-prod-app-boot-context.ts +0 -241
  45. package/src/run-prod-app-static-files.ts +0 -273
  46. package/src/run-prod-app.ts +0 -1158
  47. package/src/session-wiring.ts +0 -29
  48. package/src/try-hono-first.ts +0 -46
@@ -1,692 +0,0 @@
1
- // buildProdBundle — Production-Build für Kumiko-Apps. Ein generischer
2
- // Build-Step ohne App-spezifisches Wissen: Convention-Discovery liest
3
- // die App-Struktur, Bun.build + Tailwind + Public-Folder-Copy
4
- // produzieren ein deploybares dist/.
5
- //
6
- // Convention (alles optional, fehlt was → übersprungen):
7
- //
8
- // src/client.tsx | src/client.ts → Bun.build (splitting + hash + asset-loader)
9
- // src/styles.css → Tailwind one-shot
10
- // (oder fallback auf @cosmicdrift/kumiko-renderer-web/styles.css
11
- // wenn nur clientEntry da ist und kein eigenes CSS)
12
- // public/ → rsync 1:1 (kein Hash — User-bewusste URLs)
13
- // public/index.html | index.html → Template, Placeholder-Tags ersetzt:
14
- // <script src="/client.js"> → /assets/client-<hash>.js
15
- // <link href="/styles.css"> → /assets/styles-<hash>.css
16
- // (kein HTML, vanilla) → Default-HTML ohne Asset-Tags
17
- //
18
- // Fehler-Modus: hat client.tsx oder Tailwind etwas produziert, aber das HTML
19
- // hat keinen passenden Placeholder, wirft der Build mit dem exakten Snippet
20
- // zum Reinkopieren. Keine silent-injection — das HTML soll lesen wie's
21
- // auch im Dev-Server liefert.
22
- //
23
- // Output:
24
- //
25
- // dist/
26
- // index.html ← Tags mit gehashten URLs
27
- // assets/
28
- // client-<hash>.js ← entry
29
- // <chunk>-<hash>.js ← split chunks
30
- // styles-<hash>.css ← Tailwind output
31
- // <asset>-<hash>.<ext> ← imported file-loader assets
32
- // manifest.json ← logical → hashed-URL mapping
33
- // <public/* 1:1> ← favicon.ico, robots.txt, og-image.png, …
34
- //
35
- // Cache-Header (von runProdApp gesetzt, nicht hier):
36
- //
37
- // /assets/* → public, max-age=31536000, immutable
38
- // /index.html, /sw.js → no-cache, must-revalidate
39
- // /manifest.json → no-cache
40
- // alles andere (public/) → default (auto-cache)
41
-
42
- import { createHash } from "node:crypto";
43
- import { existsSync, readdirSync } from "node:fs";
44
- import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
45
- import { tmpdir } from "node:os";
46
- import { join, resolve } from "node:path";
47
- import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli";
48
-
49
- // Bun-Runtime-Check als module-level Konstante: alle Build-Schritte
50
- // (Tailwind via Bun.spawn, Client-Bundle via Bun.build, Stylesheet-
51
- // Resolution via Bun.resolveSync) sind Bun-only. Pro-Funktions-Inline-
52
- // Checks driften sonst — eine Konstante hier hält das konsistent.
53
- const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
54
-
55
- export type BuildProdBundleOptions = {
56
- /** App-Root. Default: process.cwd(). */
57
- readonly cwd?: string;
58
- /** Output-Folder relativ zu cwd. Default: "dist". */
59
- readonly outDir?: string;
60
- /** Stylesheet-Override. Default: erst src/styles.css, dann
61
- * @cosmicdrift/kumiko-renderer-web/styles.css wenn clientEntry da ist.
62
- * `false` deaktiviert die CSS-Pipeline explizit. */
63
- readonly stylesheet?: string | false;
64
- };
65
-
66
- export type BuildManifest = Readonly<Record<string, string>>;
67
-
68
- export type BuildResult = {
69
- readonly outDir: string;
70
- /** Logical → hashed-URL mapping. Beispiel:
71
- * { "client.js": "/assets/client-a3f2.js",
72
- * "styles.css": "/assets/styles-9b4c.css" } */
73
- readonly manifest: BuildManifest;
74
- /** Build-Identität (Hash über die Asset-URLs) + lesbarer Zeitstempel.
75
- * undefined bei vanilla public-only-Builds (kein Bundle). */
76
- readonly buildInfo?: BuildInfo;
77
- };
78
-
79
- /** In index.html gebacken (`window.__KUMIKO_BUILD__`) UND als
80
- * dist/build-info.json geschrieben. Der UpdateChecker pollt build-info.json
81
- * und vergleicht `id` gegen den geladenen Stand → Reload-Banner bei Drift. */
82
- export type BuildInfo = {
83
- /** Hash über die sortierten (content-gehashten) Asset-URLs. Ändert sich
84
- * gdw. sich ein Asset ändert — selbsttragend, kein Env-Var/Dockerfile. */
85
- readonly id: string;
86
- /** ISO-Zeitstempel des Builds, lesbare Anzeige-Version (ersetzt den rohen sha). */
87
- readonly builtAt: string;
88
- };
89
-
90
- // Default-HTML wird nur genutzt wenn der App-Author KEIN index.html liefert.
91
- // Hat keine Asset-Placeholder, weil der Default-Pfad für vanilla apps
92
- // (nur public/) gedacht ist — wer JS/CSS will, schreibt ein eigenes
93
- // index.html mit den richtigen Placeholder-Tags.
94
- const DEFAULT_HTML = `<!doctype html>
95
- <html lang="en">
96
- <head>
97
- <meta charset="utf-8" />
98
- <meta name="viewport" content="width=device-width,initial-scale=1" />
99
- <title>Kumiko</title>
100
- </head>
101
- <body>
102
- <div id="root"></div>
103
- </body>
104
- </html>
105
- `;
106
-
107
- /** Folder name relative to dist/ for hashed assets (JS, CSS, file-loader
108
- * outputs). Exported damit runProdApp dieselbe Konvention für Cache-
109
- * Header nutzt — Drift verhindern. */
110
- export const ASSETS_DIR = "assets";
111
-
112
- const ASSET_LOADERS = {
113
- ".png": "file",
114
- ".jpg": "file",
115
- ".jpeg": "file",
116
- ".gif": "file",
117
- ".svg": "file",
118
- ".webp": "file",
119
- ".ico": "file",
120
- ".woff": "file",
121
- ".woff2": "file",
122
- ".ttf": "file",
123
- ".otf": "file",
124
- } as const;
125
-
126
- export async function buildProdBundle(options: BuildProdBundleOptions = {}): Promise<BuildResult> {
127
- const cwd = resolve(options.cwd ?? process.cwd());
128
- const outDir = resolve(cwd, options.outDir ?? "dist");
129
- const assetsDir = join(outDir, ASSETS_DIR);
130
-
131
- // 1. Discovery: was ist da?
132
- const clientEntries = discoverClientEntries(cwd);
133
- const firstClientSource = clientEntries[0]?.sourceFile;
134
- const stylesheet = resolveStylesheetEntry(cwd, firstClientSource, options.stylesheet);
135
- const publicDir = resolve(cwd, "public");
136
- const hasPublicDir = existsSync(publicDir);
137
-
138
- if (clientEntries.length === 0 && !hasPublicDir) {
139
- throw new Error(
140
- `[kumiko build] nothing to build in ${cwd} — expected at least one of: ` +
141
- `src/client.tsx, src/client-*.tsx, public/`,
142
- );
143
- }
144
-
145
- // 2. Clean + scaffold
146
- await rm(outDir, { recursive: true, force: true });
147
- await mkdir(outDir, { recursive: true });
148
- await mkdir(assetsDir, { recursive: true });
149
-
150
- const manifest: Record<string, string> = {};
151
-
152
- // 3. Tailwind one-shot (vor JS, weil JS' loader auf .css trifft falls
153
- // der client.tsx ein "import './foo.css'" macht — den Fall lassen
154
- // wir hier raus, Tailwind ist die einzige CSS-Quelle).
155
- if (stylesheet) {
156
- const css = await runTailwindOnce(stylesheet.path, cwd);
157
- assertRendererWebShellPresent(css, stylesheet);
158
- const hash = shortHash(css);
159
- const filename = `styles-${hash}.css`;
160
- await writeFile(join(assetsDir, filename), css);
161
- manifest["styles.css"] = `/${ASSETS_DIR}/${filename}`;
162
- }
163
-
164
- // 4. Bun.build pro Entry (multi-entry produces N bundles + shared chunks).
165
- // Ein einzelner Bun.build-Call mit allen entrypoints würde shared
166
- // chunks deduplizieren, hashes deterministisch halten — passt zu
167
- // dem split-tree-Pattern von publicstatus (admin + public teilen
168
- // sich den renderer-web-core).
169
- if (clientEntries.length > 0) {
170
- const built = await buildClientBundles(clientEntries, assetsDir);
171
- for (const [manifestKey, filename] of Object.entries(built)) {
172
- manifest[manifestKey] = `/${ASSETS_DIR}/${filename}`;
173
- }
174
- }
175
-
176
- // 5. Public-Folder rsync (ohne index.html / *.html-templates — werden
177
- // separat gerendert). Filter-list = template-basenames der entries.
178
- const templateBasenames = new Set<string>(clientEntries.map((e) => basenameOf(e.htmlPath)));
179
- if (hasPublicDir) {
180
- await copyPublicFolder(publicDir, outDir, templateBasenames);
181
- }
182
-
183
- // 5b. Build-Identität: Hash über die content-gehashten Asset-URLs +
184
- // lesbarer Zeitstempel. Wird in index.html gebacken und als
185
- // build-info.json geschrieben (Update-Awareness, default an).
186
- // ponytail: id hängt nur an Assets — ein reiner Server-Deploy ohne
187
- // Client-Bundle-Change behält die id → kein Banner (die UI ist dann
188
- // nicht stale). Bei Bedarf an Server-Versionierung koppeln.
189
- const buildInfo: BuildInfo | undefined =
190
- Object.keys(manifest).length > 0
191
- ? { id: computeBuildId(manifest), builtAt: new Date().toISOString() }
192
- : undefined;
193
-
194
- // 6. HTML pro Entry rendern. Convention: ein HTML-File pro Client-Entry,
195
- // jede mit ihrem eigenen Script-Tag. Server (runProdApp.hostDispatch)
196
- // serviert je nach Host das passende File.
197
- if (clientEntries.length === 0) {
198
- // Vanilla-public-only-app: keine HTML-Files zu rendern, public-Folder
199
- // wurde schon kopiert.
200
- } else {
201
- for (const entry of clientEntries) {
202
- const templatePath = resolve(cwd, entry.htmlPath);
203
- const templateExists = existsSync(templatePath);
204
- const html = await renderHtml(
205
- templateExists ? templatePath : undefined,
206
- manifest,
207
- entry,
208
- buildInfo,
209
- );
210
- const outFile = basenameOf(entry.htmlPath);
211
- await writeFile(join(outDir, outFile), html);
212
- }
213
- }
214
-
215
- // 7. Manifest + Build-Info.
216
- await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
217
- if (buildInfo) {
218
- await writeFile(join(outDir, "build-info.json"), `${JSON.stringify(buildInfo)}\n`);
219
- }
220
-
221
- return { outDir, manifest, ...(buildInfo && { buildInfo }) };
222
- }
223
-
224
- // ---------------------------------------------------------------------------
225
- // Discovery
226
- // ---------------------------------------------------------------------------
227
-
228
- // Single client-entry shape — one bundle, one html-template.
229
- export type ClientEntry = {
230
- /** Logical name. "client" für single-mode; sonst der Suffix von
231
- * src/client-<suffix>.tsx (z.B. "public", "admin"). */
232
- readonly name: string;
233
- /** TypeScript-Source. */
234
- readonly sourceFile: string;
235
- /** Manifest-key & logical-asset-path. "client.js" für single, sonst
236
- * "client-<name>.js". */
237
- readonly manifestKey: string;
238
- /** HTML-template-Pfad relativ zum cwd. "index.html" für single oder
239
- * "public"-entry; sonst "<name>.html". Naming bewusst symmetrisch
240
- * zu `runDevApp.clientEntries[].htmlPath` damit Build und Dev-Server
241
- * dieselbe Konvention verwenden. */
242
- readonly htmlPath: string;
243
- };
244
-
245
- // @internal — exported nur für Unit-Tests. Konsumenten gehen über
246
- // buildProdBundle.
247
- //
248
- // Discovery-Pattern:
249
- // - Falls `src/client-<suffix>.tsx` files existieren → multi-entry-mode,
250
- // ein Bundle pro Datei. "public" mapped auf index.html (default),
251
- // andere Suffixe auf "<suffix>.html".
252
- // - Sonst falls `src/client.tsx` oder `src/client.ts` existiert →
253
- // single-entry-mode mit name "client" + index.html.
254
- // - Sonst leeres Array (keine Client-Bundles).
255
- export function discoverClientEntries(cwd: string): readonly ClientEntry[] {
256
- const multi = discoverMultiClientEntries(cwd);
257
- if (multi.length > 0) return multi;
258
-
259
- for (const candidate of ["src/client.tsx", "src/client.ts"]) {
260
- const sourceFile = resolve(cwd, candidate);
261
- if (existsSync(sourceFile)) {
262
- return [
263
- {
264
- name: "client",
265
- sourceFile,
266
- manifestKey: "client.js",
267
- htmlPath: discoverHtmlTemplateFor(cwd, "index") ?? "index.html",
268
- },
269
- ];
270
- }
271
- }
272
- return [];
273
- }
274
-
275
- function discoverMultiClientEntries(cwd: string): readonly ClientEntry[] {
276
- const srcDir = resolve(cwd, "src");
277
- if (!existsSync(srcDir)) return [];
278
- let files: readonly string[];
279
- try {
280
- files = readdirSync(srcDir);
281
- } catch {
282
- return [];
283
- }
284
- const out: ClientEntry[] = [];
285
- for (const file of files) {
286
- const match = /^client-([a-z][a-z0-9-]*)\.tsx?$/.exec(file);
287
- const suffix = match?.[1];
288
- if (!suffix) continue;
289
- const sourceFile = resolve(srcDir, file);
290
- out.push({
291
- name: suffix,
292
- sourceFile,
293
- manifestKey: `client-${suffix}.js`,
294
- // "public"-entry serviert die default-page (index.html), sonst pro
295
- // Suffix ein eigenes Template.
296
- htmlPath:
297
- suffix === "public"
298
- ? (discoverHtmlTemplateFor(cwd, "index") ?? "index.html")
299
- : (discoverHtmlTemplateFor(cwd, suffix) ?? `${suffix}.html`),
300
- });
301
- }
302
- return out.sort((a, b) => a.name.localeCompare(b.name));
303
- }
304
-
305
- function discoverHtmlTemplateFor(cwd: string, basename: string): string | undefined {
306
- for (const candidate of [`${basename}.html`, `public/${basename}.html`]) {
307
- const path = resolve(cwd, candidate);
308
- if (existsSync(path)) return path;
309
- }
310
- return undefined;
311
- }
312
-
313
- type ResolvedStylesheet = {
314
- readonly path: string;
315
- /** True nur wenn wir auf das gepackte renderer-web-styles.css zurückfallen
316
- * (App hat clientEntry + renderer-web-Dep, aber kein eigenes src/styles.css).
317
- * Steuert den Shell-Klassen-Sentinel im Build. */
318
- readonly isRendererWebFallback: boolean;
319
- };
320
-
321
- // Äußerster Wrapper von DefaultAppShell/WorkspaceShell trägt diese Utility
322
- // (app-layout.tsx: "flex min-h-screen …"). Echtes Tailwind-Utility (landet
323
- // also im kompilierten Output) ohne CSS-Escaping → stabiler Substring-Sentinel.
324
- const RENDERER_WEB_SHELL_SENTINEL = "min-h-screen";
325
-
326
- function resolveStylesheetEntry(
327
- cwd: string,
328
- clientEntry: string | undefined,
329
- override: BuildProdBundleOptions["stylesheet"],
330
- ): ResolvedStylesheet | undefined {
331
- if (override === false) return undefined;
332
- if (typeof override === "string") {
333
- return { path: resolve(cwd, override), isRendererWebFallback: false };
334
- }
335
-
336
- // App-eigenes styles.css schlägt den Default.
337
- const local = resolve(cwd, "src/styles.css");
338
- if (existsSync(local)) return { path: local, isRendererWebFallback: false };
339
-
340
- // Sonst: nur wenn ein client da ist, fallback auf renderer-web/styles.css.
341
- // Sample-Apps und Showcases nutzen das alle — gleiche Logik wie der dev-
342
- // server, damit lokal/prod identisch bauen.
343
- if (!clientEntry) return undefined;
344
-
345
- if (!hasBun) return undefined;
346
- try {
347
- const resolved = (
348
- globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }
349
- ).Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", cwd);
350
- const bun = (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }).Bun;
351
- if (!canResolveTailwindStylesheet(resolved, { bun, cwd })) {
352
- return undefined;
353
- }
354
- return { path: resolved, isRendererWebFallback: true };
355
- } catch {
356
- return undefined;
357
- }
358
- }
359
-
360
- // Build-Zeit-Guard gegen unstyled prod (#359): fällt der Build auf renderer-
361
- // web/styles.css zurück und fehlt im kompilierten CSS die Shell-Sentinel-
362
- // Klasse, wurden die DefaultAppShell-Styles nicht gescannt (renderer-web
363
- // nicht dort installiert wo Tailwind scannt, oder ein @source-Regress).
364
- // Laut failen statt ein 15KB-Image zu liefern, das in prod nackt rendert.
365
- // @internal — exportiert nur für Unit-Tests.
366
- export function assertRendererWebShellPresent(css: string, stylesheet: ResolvedStylesheet): void {
367
- if (!stylesheet.isRendererWebFallback) return;
368
- if (css.includes(RENDERER_WEB_SHELL_SENTINEL)) return;
369
- throw new Error(
370
- `[kumiko build] renderer-web-Fallback-CSS ohne Shell-Klasse "${RENDERER_WEB_SHELL_SENTINEL}" — ` +
371
- "die DefaultAppShell/WorkspaceShell-Styles fehlen, prod würde unstyled rendern. Meist ist " +
372
- "@cosmicdrift/kumiko-renderer-web nicht dort installiert wo Tailwind seine Source scannt. " +
373
- 'Fix: src/styles.css anlegen mit `@import "@cosmicdrift/kumiko-renderer-web/styles.css";` ' +
374
- '(+ `@source "./**/*.{ts,tsx}";` um auch eigene Komponenten zu scannen).',
375
- );
376
- }
377
-
378
- // @internal — exported nur für Unit-Tests.
379
- export function discoverHtmlTemplate(cwd: string): string | undefined {
380
- for (const candidate of ["index.html", "public/index.html"]) {
381
- const path = resolve(cwd, candidate);
382
- if (existsSync(path)) return path;
383
- }
384
- return undefined;
385
- }
386
-
387
- // ---------------------------------------------------------------------------
388
- // Build steps
389
- // ---------------------------------------------------------------------------
390
-
391
- // @internal — exportiert nur für Unit-Tests (Relocation-Test des @source-Layouts).
392
- export async function runTailwindOnce(entry: string, cwd: string): Promise<string> {
393
- if (!hasBun) {
394
- throw new Error(
395
- "[kumiko build] Tailwind one-shot requires Bun (Bun.spawn) — run via `bun run …` or `bun kumiko build`.",
396
- );
397
- }
398
- const bunResolver = (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } })
399
- .Bun;
400
- const cliPath = resolveTailwindCli({ bun: bunResolver, cwd });
401
- if (cliPath === undefined) {
402
- throw new Error(
403
- "[kumiko build] @tailwindcss/cli nicht auflösbar — `bun install` im App-Root ausführen.",
404
- );
405
- }
406
- if (!canResolveTailwindStylesheet(entry, { bun: bunResolver, cwd })) {
407
- throw new Error(
408
- `[kumiko build] tailwindcss nicht auflösbar für ${entry} — peer dependency fehlt am Stylesheet-Ort.`,
409
- );
410
- }
411
- const tmpDir = await mkdtemp(join(tmpdir(), "kumiko-build-tw-"));
412
- const outPath = join(tmpDir, "styles.css");
413
- const bunPath = process.argv[0] ?? "bun";
414
- const proc = Bun.spawn([bunPath, "run", cliPath, "-i", entry, "-o", outPath, "--minify"], {
415
- cwd,
416
- stdout: "inherit",
417
- stderr: "inherit",
418
- });
419
- const code = await proc.exited;
420
- if (code !== 0) {
421
- throw new Error(`[kumiko build] tailwind exit ${code}`);
422
- }
423
- const css = await readFile(outPath, "utf8");
424
- await rm(tmpDir, { recursive: true, force: true });
425
- return css;
426
- }
427
-
428
- /** Multi-entry-build: ein Bun.build-Call mit allen entrypoints — shared
429
- * chunks werden dedupliziert, hashes deterministisch. Returns map
430
- * manifestKey → hashed-filename (basename, ohne /assets/-Prefix). */
431
- async function buildClientBundles(
432
- entries: readonly ClientEntry[],
433
- outDir: string,
434
- ): Promise<Record<string, string>> {
435
- if (!hasBun) {
436
- throw new Error("[kumiko build] requires Bun — run via `bun run …` or `bun kumiko build`.");
437
- }
438
- const built = await Bun.build({
439
- entrypoints: entries.map((e) => e.sourceFile),
440
- outdir: outDir,
441
- target: "browser",
442
- splitting: true,
443
- minify: true,
444
- // Keine Source-Maps in Prod: 1.6 MB+ Müll im Container, plus
445
- // exposed Source-Code reverse-engineerable. Dev hat seine eigenen
446
- // sourcemaps via create-kumiko-server.ts.
447
- sourcemap: "none",
448
- naming: {
449
- entry: "[name]-[hash].[ext]",
450
- chunk: "[name]-[hash].[ext]",
451
- asset: "[name]-[hash].[ext]",
452
- },
453
- loader: ASSET_LOADERS,
454
- define: {
455
- "process.env.NODE_ENV": JSON.stringify("production"),
456
- },
457
- });
458
- if (!built.success) {
459
- const errs = built.logs.map((log) => String(log)).join("\n");
460
- throw new Error(`[kumiko build] Bun.build failed:\n${errs}`);
461
- }
462
- const entryOutputs = built.outputs.filter((o) => o.kind === "entry-point");
463
- if (entryOutputs.length !== entries.length) {
464
- throw new Error(
465
- `[kumiko build] expected ${entries.length} entry-point outputs, got ${entryOutputs.length}`,
466
- );
467
- }
468
- // Bun.build benennt entry-files nach Source-Basename (ohne extension):
469
- // `src/client-admin.tsx` → `client-admin-<hash>.js`. Wir mappen jedes
470
- // entry-output zurück auf seinen ClientEntry via Basename-match.
471
- const result: Record<string, string> = {};
472
- for (const entry of entries) {
473
- const baseName = (entry.sourceFile.split("/").pop() ?? "").replace(/\.tsx?$/, "");
474
- const match = entryOutputs.find((o) => {
475
- const outName = o.path.split("/").pop() ?? "";
476
- return outName.startsWith(`${baseName}-`);
477
- });
478
- if (!match) {
479
- throw new Error(
480
- `[kumiko build] no entry-point output for "${entry.sourceFile}" (looked for "${baseName}-*.js")`,
481
- );
482
- }
483
- result[entry.manifestKey] = match.path.split("/").pop() ?? match.path;
484
- }
485
- return result;
486
- }
487
-
488
- function basenameOf(p: string): string {
489
- return p.split("/").pop() ?? p;
490
- }
491
-
492
- async function copyPublicFolder(
493
- src: string,
494
- dst: string,
495
- templateBasenames: ReadonlySet<string>,
496
- ): Promise<void> {
497
- // HTML-templates werden separat gerendert — nicht blind kopieren, sonst
498
- // überschreibt das die injizierte Version. (z.B. index.html, admin.html
499
- // bei multi-entry).
500
- await cp(src, dst, {
501
- recursive: true,
502
- filter: (source) => {
503
- const normalized = source.replace(/\\/g, "/");
504
- const srcNormalized = src.replace(/\\/g, "/");
505
- const base = normalized.startsWith(`${srcNormalized}/`)
506
- ? normalized.slice(srcNormalized.length + 1)
507
- : "";
508
- return !templateBasenames.has(base);
509
- },
510
- });
511
- }
512
-
513
- // ---------------------------------------------------------------------------
514
- // HTML render
515
- // ---------------------------------------------------------------------------
516
-
517
- async function renderHtml(
518
- templatePath: string | undefined,
519
- manifest: BuildManifest,
520
- entry: ClientEntry,
521
- buildInfo: BuildInfo | undefined,
522
- ): Promise<string> {
523
- // Edge-Case: kein eigenes HTML-Template + Bun.build oder Tailwind hat
524
- // Output produziert. DEFAULT_HTML hat keine Placeholder (vanilla
525
- // template), also würde injectAssetTags eh fehlschlagen. Klarer Fehler
526
- // mit Vorschlag-Snippet zum Reinkopieren.
527
- if (!templatePath && Object.keys(manifest).length > 0) {
528
- throw new Error(buildMissingTemplateError(manifest, entry));
529
- }
530
- const template = templatePath ? await readFile(templatePath, "utf8") : DEFAULT_HTML;
531
- return injectAssetTags(template, manifest, entry, buildInfo);
532
- }
533
-
534
- function buildMissingTemplateError(manifest: BuildManifest, entry: ClientEntry): string {
535
- const cssLine = manifest["styles.css"]
536
- ? ` <link rel="stylesheet" href="/styles.css" />\n`
537
- : "";
538
- const jsLine = manifest[entry.manifestKey]
539
- ? // html-ok: Error-Hilfetext (Tag-Snippet als Text), wird nie gerendert.
540
- ` <script type="module" src="/${entry.manifestKey}"></script>\n`
541
- : "";
542
- return (
543
- `[kumiko build] kein ${entry.htmlPath} gefunden, aber es gibt JS/CSS-Output.\n` +
544
- `Leg ein public/${basenameOf(entry.htmlPath)} oder ${basenameOf(entry.htmlPath)} im App-Root an, z. B.:\n` +
545
- `\n` +
546
- `<!doctype html>\n` +
547
- `<html>\n` +
548
- ` <head>\n` +
549
- ` <meta charset="utf-8" />\n` +
550
- ` <title>Meine App</title>\n` +
551
- cssLine +
552
- ` </head>\n` +
553
- ` <body>\n` +
554
- ` <div id="root"></div>\n` +
555
- jsLine +
556
- ` </body>\n` +
557
- `</html>\n` +
558
- `\n` +
559
- `Der Build ersetzt /styles.css und /${entry.manifestKey} durch die gehashten URLs.`
560
- );
561
- }
562
-
563
- // @internal — exported nur für Unit-Tests.
564
- //
565
- // Convention: das HTML-Template MUSS Placeholder-Tags für jedes Asset
566
- // dieses Entries enthalten:
567
- // - `<script src="/client.js">` für single-mode entry "client"
568
- // - `<script src="/client-<name>.js">` für multi-mode entry "<name>"
569
- // - `<link href="/styles.css">` für styles (gemeinsam über alle entries)
570
- // Der Build ersetzt sie durch die gehashten URLs.
571
- //
572
- // Fehlt ein erwarteter Tag, wirft der Build einen Fehler mit dem exakten
573
- // Snippet zum Reinkopieren — kein silent injection mehr, weil das den
574
- // Diff zwischen Dev- und Prod-HTML unsichtbar macht.
575
- export function injectAssetTags(
576
- html: string,
577
- manifest: BuildManifest,
578
- entry: ClientEntry,
579
- buildInfo?: BuildInfo,
580
- ): string {
581
- let result = html;
582
-
583
- // Build-Stand vor </head> backen. Ohne </head> (z.B. Fragment) still skip.
584
- if (buildInfo && result.includes("</head>")) {
585
- // html-ok: id/builtAt sind Hex bzw. ISO — kein `<` möglich, kein Breakout.
586
- const tag = `<script>window.__KUMIKO_BUILD__=${JSON.stringify(buildInfo)};</script>`;
587
- result = result.replace("</head>", ` ${tag}\n </head>`);
588
- }
589
-
590
- const cssUrl = manifest["styles.css"];
591
- if (cssUrl && !result.includes(cssUrl)) {
592
- const placeholder = /<link\s+rel="stylesheet"\s+href="\/styles\.css"\s*\/?>/.exec(result);
593
- if (!placeholder) {
594
- throw new Error(
595
- buildMissingTagError({
596
- htmlPath: entry.htmlPath,
597
- assetKey: "styles.css",
598
- tagSnippet: `<link rel="stylesheet" href="/styles.css" />`,
599
- insertHint: "ins <head>",
600
- hashedAssetHint: "/assets/styles-<hash>.css",
601
- }),
602
- );
603
- }
604
- // html-ok: cssUrl ist ein selbst-generierter Manifest-Asset-Pfad (Hash-Name).
605
- result = result.replace(placeholder[0], `<link rel="stylesheet" href="${cssUrl}" />`);
606
- }
607
-
608
- const jsUrl = manifest[entry.manifestKey];
609
- if (jsUrl && !result.includes(jsUrl)) {
610
- // Placeholder-pattern: src="/client.js" oder src="/client-<name>.js"
611
- const placeholderRx = new RegExp(
612
- `<script\\b[^>]*src="\\/${entry.manifestKey.replace(/\./g, "\\.")}"[^>]*><\\/script>`,
613
- );
614
- const placeholder = placeholderRx.exec(result);
615
- if (!placeholder) {
616
- const baseAssetName = entry.manifestKey.replace(/\.js$/, "");
617
- throw new Error(
618
- buildMissingTagError({
619
- htmlPath: entry.htmlPath,
620
- assetKey: entry.manifestKey,
621
- tagSnippet: `<script type="module" src="/${entry.manifestKey}"></script>`,
622
- insertHint: "vor </body>",
623
- hashedAssetHint: `/assets/${baseAssetName}-<hash>.js`,
624
- }),
625
- );
626
- }
627
- // html-ok: jsUrl ist ein selbst-generierter Manifest-Asset-Pfad (Hash-Name).
628
- result = result.replace(placeholder[0], `<script type="module" src="${jsUrl}"></script>`);
629
- }
630
-
631
- return result;
632
- }
633
-
634
- /** Einheitliche Error-Form für fehlende Asset-Tags im HTML-Template
635
- * (script-tag fürs JS-Bundle ODER stylesheet-link für Tailwind). */
636
- function buildMissingTagError(args: {
637
- readonly htmlPath: string;
638
- readonly assetKey: string;
639
- readonly tagSnippet: string;
640
- readonly insertHint: string;
641
- readonly hashedAssetHint: string;
642
- }): string {
643
- const tpl = basenameOf(args.htmlPath);
644
- return (
645
- `[kumiko build] ${tpl} hat keinen Entry-Tag für /${args.assetKey} — füg ${args.insertHint} ein:\n` +
646
- `\n` +
647
- ` ${args.tagSnippet}\n` +
648
- `\n` +
649
- `Der Build ersetzt das durch ${args.hashedAssetHint}. Im Dev-Server liefert er die Datei direkt.`
650
- );
651
- }
652
-
653
- // ---------------------------------------------------------------------------
654
- // Hash helpers
655
- // ---------------------------------------------------------------------------
656
-
657
- function shortHash(content: string | Uint8Array): string {
658
- return createHash("sha256").update(content).digest("hex").slice(0, 8);
659
- }
660
-
661
- // Build-Identität aus den content-gehashten Asset-URLs. Sortiert →
662
- // deterministisch (Manifest-Key-Reihenfolge egal). Identischer Source →
663
- // gleiche id, geändertes Asset → andere id.
664
- // @internal — exported nur für Unit-Tests.
665
- export function computeBuildId(manifest: BuildManifest): string {
666
- return createHash("sha256")
667
- .update(Object.values(manifest).sort().join("\n"))
668
- .digest("hex")
669
- .slice(0, 12);
670
- }
671
-
672
- // ---------------------------------------------------------------------------
673
- // CLI output
674
- // ---------------------------------------------------------------------------
675
-
676
- /** Formatiert ein BuildResult als CLI-freundliche Mehrzeilen-Zusammenfassung
677
- * mit ANSI-Farben. Wird sowohl von `kumiko build` als auch von dem
678
- * hoisted `kumiko-build`-Bin verwendet, damit das Output konsistent ist. */
679
- export function formatBuildResult(result: BuildResult, durationMs: number): string {
680
- const dim = "\x1b[2m";
681
- const green = "\x1b[32m";
682
- const reset = "\x1b[0m";
683
- const lines: string[] = [
684
- "",
685
- ` ${green}✓${reset} built ${result.outDir} ${dim}(${durationMs}ms)${reset}`,
686
- ];
687
- for (const [logical, hashed] of Object.entries(result.manifest)) {
688
- lines.push(` ${dim}${logical.padEnd(14)}${reset} ${hashed}`);
689
- }
690
- lines.push("");
691
- return lines.join("\n");
692
- }