@cosmicdrift/kumiko-server-runtime 0.329.0 → 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 -695
  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,996 +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
- // Client entries (fw#2320 — explicit, no filename inference):
7
- //
8
- // package.json "kumiko.clientEntries": [{ name, sourceFile, htmlPath? }]
9
- // → one Bun.build bundle per declared entry
10
- // package.json "kumiko.clientEntry": "./src/…"
11
- // → single bundle, name "client"
12
- // (neither declared) → convention: src/client.tsx | src/client.ts
13
- //
14
- // Convention for everything else (all optional, missing → skipped):
15
- //
16
- // src/styles.css → Tailwind one-shot
17
- // (oder fallback auf @cosmicdrift/kumiko-renderer-web/styles.css
18
- // wenn nur clientEntry da ist und kein eigenes CSS)
19
- // public/ → rsync 1:1 (kein Hash — User-bewusste URLs)
20
- // public/index.html | index.html → Template, Placeholder-Tags ersetzt:
21
- // <script type="module" src="/client.js"> → /assets/client-<hash>.js
22
- // <link href="/styles.css"> → /assets/styles-<hash>.css
23
- // (kein HTML, vanilla) → Default-HTML ohne Asset-Tags
24
- //
25
- // Fehler-Modus: hat client.tsx oder Tailwind etwas produziert, aber das HTML
26
- // hat keinen passenden Placeholder, wirft der Build mit dem exakten Snippet
27
- // zum Reinkopieren. Keine silent-injection — das HTML soll lesen wie's
28
- // auch im Dev-Server liefert.
29
- //
30
- // Output:
31
- //
32
- // dist/
33
- // index.html ← Tags mit gehashten URLs
34
- // assets/
35
- // client-<hash>.js ← entry
36
- // <chunk>-<hash>.js ← split chunks
37
- // styles-<hash>.css ← Tailwind output
38
- // <asset>-<hash>.<ext> ← imported file-loader assets
39
- // manifest.json ← logical → hashed-URL mapping
40
- // <public/* 1:1> ← favicon.ico, robots.txt, og-image.png, …
41
- //
42
- // Cache-Header (von runProdApp gesetzt, nicht hier):
43
- //
44
- // /assets/* → public, max-age=31536000, immutable
45
- // /index.html, /sw.js → no-cache, must-revalidate
46
- // /manifest.json → no-cache
47
- // alles andere (public/) → default (auto-cache)
48
-
49
- import { createHash } from "node:crypto";
50
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
51
- import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
52
- import { tmpdir } from "node:os";
53
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
54
- import { isPlainObject, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
55
- import { Temporal } from "temporal-polyfill";
56
- import {
57
- RENDERER_WEB_FONT_FILE_PATTERN,
58
- RENDERER_WEB_FONTS_DIST_DIR,
59
- resolveRendererWebFontsDir,
60
- } from "./renderer-web-fonts";
61
- import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli";
62
-
63
- // Bun-Runtime-Check als module-level Konstante: alle Build-Schritte
64
- // (Tailwind via Bun.spawn, Client-Bundle via Bun.build, Stylesheet-
65
- // Resolution via Bun.resolveSync) sind Bun-only. Pro-Funktions-Inline-
66
- // Checks driften sonst — eine Konstante hier hält das konsistent.
67
- const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
68
-
69
- export type BuildProdBundleOptions = {
70
- /** App-Root. Default: process.cwd(). */
71
- readonly cwd?: string;
72
- /** Output-Folder relativ zu cwd. Default: "dist". */
73
- readonly outDir?: string;
74
- /** Stylesheet-Override. Default: erst src/styles.css, dann
75
- * @cosmicdrift/kumiko-renderer-web/styles.css wenn clientEntry da ist.
76
- * `false` deaktiviert die CSS-Pipeline explizit. */
77
- readonly stylesheet?: string | false;
78
- } & ClientEntriesConfig;
79
-
80
- /** One declared client entry, read from package.json `kumiko.clientEntries`.
81
- * `name` becomes the output filename (`client-<name>.js` / `<name>.html`
82
- * unless `htmlPath` overrides it). */
83
- export type ClientEntryDeclaration = {
84
- readonly name: string;
85
- readonly sourceFile: string;
86
- readonly htmlPath?: string;
87
- };
88
-
89
- /** The two mutually exclusive shapes read from package.json `kumiko.*`.
90
- * Neither set → convention mode (src/client.tsx | src/client.ts). */
91
- export type ClientEntriesConfig = {
92
- readonly clientEntry?: string;
93
- readonly clientEntries?: readonly ClientEntryDeclaration[];
94
- };
95
-
96
- export type BuildManifest = Readonly<Record<string, string>>;
97
-
98
- export type BuildResult = {
99
- readonly outDir: string;
100
- /** Logical → hashed-URL mapping. Beispiel:
101
- * { "client.js": "/assets/client-a3f2.js",
102
- * "styles.css": "/assets/styles-9b4c.css" } */
103
- readonly manifest: BuildManifest;
104
- /** Build-Identität (Hash über die Asset-URLs) + lesbarer Zeitstempel.
105
- * undefined bei vanilla public-only-Builds (kein Bundle). */
106
- readonly buildInfo?: BuildInfo;
107
- };
108
-
109
- /** In index.html gebacken (`window.__KUMIKO_BUILD__`) UND als
110
- * dist/build-info.json geschrieben. Der UpdateChecker pollt build-info.json
111
- * und vergleicht `id` gegen den geladenen Stand → Reload-Banner bei Drift. */
112
- export type BuildInfo = {
113
- /** Hash über die sortierten (content-gehashten) Asset-URLs. Ändert sich
114
- * gdw. sich ein Asset ändert — selbsttragend, kein Env-Var/Dockerfile. */
115
- readonly id: string;
116
- /** ISO-Zeitstempel des Builds, lesbare Anzeige-Version (ersetzt den rohen sha). */
117
- readonly builtAt: string;
118
- };
119
-
120
- // Default-HTML wird nur genutzt wenn der App-Author KEIN index.html liefert.
121
- // Hat keine Asset-Placeholder, weil der Default-Pfad für vanilla apps
122
- // (nur public/) gedacht ist — wer JS/CSS will, schreibt ein eigenes
123
- // index.html mit den richtigen Placeholder-Tags.
124
- // interactive-widget=resizes-content (fw#1918): keeps `position: fixed`
125
- // bottom bars anchored above a mobile keyboard instead of behind it.
126
- const DEFAULT_HTML = `<!doctype html>
127
- <html lang="en">
128
- <head>
129
- <meta charset="utf-8" />
130
- <meta name="viewport" content="width=device-width,initial-scale=1,interactive-widget=resizes-content" />
131
- <title>Kumiko</title>
132
- </head>
133
- <body>
134
- <div id="root"></div>
135
- </body>
136
- </html>
137
- `;
138
-
139
- /** Folder name relative to dist/ for hashed assets (JS, CSS, file-loader
140
- * outputs). Exported damit runProdApp dieselbe Konvention für Cache-
141
- * Header nutzt — Drift verhindern. */
142
- export const ASSETS_DIR = "assets";
143
-
144
- const ASSET_LOADERS = {
145
- ".png": "file",
146
- ".jpg": "file",
147
- ".jpeg": "file",
148
- ".gif": "file",
149
- ".svg": "file",
150
- ".webp": "file",
151
- ".ico": "file",
152
- ".woff": "file",
153
- ".woff2": "file",
154
- ".ttf": "file",
155
- ".otf": "file",
156
- } as const;
157
-
158
- export async function buildProdBundle(options: BuildProdBundleOptions = {}): Promise<BuildResult> {
159
- const cwd = resolve(options.cwd ?? process.cwd());
160
- const outDir = resolve(cwd, options.outDir ?? "dist");
161
- const assetsDir = join(outDir, ASSETS_DIR);
162
-
163
- // 1. Discovery: was ist da?
164
- const clientEntries = resolveClientEntries(cwd, options);
165
- const firstClientSource = clientEntries[0]?.sourceFile;
166
- const stylesheet = resolveStylesheetEntry(cwd, firstClientSource, options.stylesheet);
167
- const publicDir = resolve(cwd, "public");
168
- const hasPublicDir = existsSync(publicDir);
169
-
170
- if (clientEntries.length === 0 && !hasPublicDir) {
171
- throw new Error(
172
- `[kumiko build] nothing to build in ${cwd} — expected one of: ` +
173
- `package.json "kumiko.clientEntry"/"kumiko.clientEntries", src/client.tsx, public/`,
174
- );
175
- }
176
-
177
- // 2. Clean + scaffold
178
- await rm(outDir, { recursive: true, force: true });
179
- await mkdir(outDir, { recursive: true });
180
- await mkdir(assetsDir, { recursive: true });
181
-
182
- const manifest: Record<string, string> = {};
183
-
184
- // 3. Tailwind one-shot (vor JS, weil JS' loader auf .css trifft falls
185
- // der client.tsx ein "import './foo.css'" macht — den Fall lassen
186
- // wir hier raus, Tailwind ist die einzige CSS-Quelle).
187
- if (stylesheet) {
188
- const css = await runTailwindOnce(stylesheet.path, cwd);
189
- assertRendererWebShellPresent(css, stylesheet);
190
- const hash = shortHash(css);
191
- const filename = `styles-${hash}.css`;
192
- await writeFile(join(assetsDir, filename), css);
193
- manifest["styles.css"] = `/${ASSETS_DIR}/${filename}`;
194
- await copyRendererWebFonts(cwd, outDir);
195
- }
196
-
197
- // 4. Bun.build pro Entry (multi-entry produces N bundles + shared chunks).
198
- // Ein einzelner Bun.build-Call mit allen entrypoints würde shared
199
- // chunks deduplizieren, hashes deterministisch halten — passt zu
200
- // dem split-tree-Pattern von publicstatus (admin + public teilen
201
- // sich den renderer-web-core).
202
- if (clientEntries.length > 0) {
203
- const built = await buildClientBundles(clientEntries, assetsDir);
204
- for (const [manifestKey, filename] of Object.entries(built)) {
205
- manifest[manifestKey] = `/${ASSETS_DIR}/${filename}`;
206
- }
207
- }
208
-
209
- // 5. Public-Folder rsync (ohne index.html / *.html-templates — werden
210
- // separat gerendert). Filter-list = template-basenames der entries.
211
- const templateBasenames = new Set<string>(clientEntries.map((e) => basenameOf(e.htmlPath)));
212
- if (hasPublicDir) {
213
- await copyPublicFolder(publicDir, outDir, templateBasenames);
214
- }
215
-
216
- // 5b. Build-Identität: Hash über die content-gehashten Asset-URLs +
217
- // lesbarer Zeitstempel. Wird in index.html gebacken und als
218
- // build-info.json geschrieben (Update-Awareness, default an).
219
- // ponytail: id hängt nur an Assets — ein reiner Server-Deploy ohne
220
- // Client-Bundle-Change behält die id → kein Banner (die UI ist dann
221
- // nicht stale). Bei Bedarf an Server-Versionierung koppeln.
222
- const buildInfo: BuildInfo | undefined =
223
- Object.keys(manifest).length > 0
224
- ? { id: computeBuildId(manifest), builtAt: Temporal.Now.instant().toString() }
225
- : undefined;
226
-
227
- // 6. HTML pro Entry rendern. Convention: ein HTML-File pro Client-Entry,
228
- // jede mit ihrem eigenen Script-Tag. Server (runProdApp.hostDispatch)
229
- // serviert je nach Host das passende File.
230
- if (clientEntries.length === 0) {
231
- // Vanilla-public-only-app: keine HTML-Files zu rendern, public-Folder
232
- // wurde schon kopiert.
233
- } else {
234
- for (const entry of clientEntries) {
235
- const templatePath = resolve(cwd, entry.htmlPath);
236
- const templateExists = existsSync(templatePath);
237
- const html = await renderHtml(
238
- templateExists ? templatePath : undefined,
239
- manifest,
240
- entry,
241
- buildInfo,
242
- );
243
- const outFile = basenameOf(entry.htmlPath);
244
- await writeFile(join(outDir, outFile), html);
245
- }
246
- }
247
-
248
- // 7. Manifest + Build-Info.
249
- await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
250
- if (buildInfo) {
251
- await writeFile(join(outDir, "build-info.json"), `${JSON.stringify(buildInfo)}\n`);
252
- }
253
-
254
- return { outDir, manifest, ...(buildInfo && { buildInfo }) };
255
- }
256
-
257
- // ---------------------------------------------------------------------------
258
- // Discovery
259
- // ---------------------------------------------------------------------------
260
-
261
- // Single client-entry shape — one bundle, one html-template.
262
- export type ClientEntry = {
263
- /** Logical name. "client" for single-entry mode (package.json
264
- * `kumiko.clientEntry` or the src/client.tsx convention); otherwise the
265
- * `name` declared in package.json `kumiko.clientEntries[]`. */
266
- readonly name: string;
267
- /** Absolute path to the TypeScript entry module. */
268
- readonly sourceFile: string;
269
- /** Manifest key & logical asset path. "client.js" for single-entry mode,
270
- * otherwise "client-<name>.js". */
271
- readonly manifestKey: string;
272
- /** HTML template path — absolute when declared or discovered on disk,
273
- * otherwise a bare default filename ("index.html", "<name>.html")
274
- * resolved against cwd by the caller. Naming is deliberately symmetric
275
- * to `runDevApp.clientEntries[].htmlPath` so build and dev server agree
276
- * on the same convention. */
277
- readonly htmlPath: string;
278
- };
279
-
280
- const ENTRY_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
281
-
282
- // The extension guard and both basename derivations (collision check, Bun.build
283
- // output mapping) must agree, otherwise an accepted entry maps to no bundle.
284
- const SOURCE_EXTENSION_PATTERN = /\.(?:tsx?|jsx?)$/;
285
-
286
- /** Resolves the client entries to build. Public — also called from
287
- * `kumiko-build`/`kumiko build` via `@cosmicdrift/kumiko-dev-server/build`,
288
- * not just from unit tests.
289
- *
290
- * Precedence (fw#2320 — explicit declaration, no filename inference):
291
- * 1. `declared.clientEntry` and `declared.clientEntries` both set → throw.
292
- * 2. `declared.clientEntries` (non-empty) → one entry per declaration.
293
- * 3. `declared.clientEntry` → single entry, name "client".
294
- * 4. Neither → convention: src/client.tsx | src/client.ts (name "client").
295
- * If that convention finds nothing but src/ still has legacy
296
- * `client-<suffix>.tsx` files, throws a migration error instead of
297
- * silently shipping a build without their bundles. */
298
- export function resolveClientEntries(
299
- cwd: string,
300
- declared: ClientEntriesConfig,
301
- ): readonly ClientEntry[] {
302
- const hasClientEntry = declared.clientEntry !== undefined;
303
- const hasClientEntries = declared.clientEntries !== undefined;
304
- if (hasClientEntry && hasClientEntries) {
305
- throw new Error(
306
- '[kumiko build] package.json "kumiko.clientEntry" and "kumiko.clientEntries" ' +
307
- "are mutually exclusive — declare only one.",
308
- );
309
- }
310
- if (declared.clientEntries !== undefined) {
311
- return resolveDeclaredMultiEntries(cwd, declared.clientEntries);
312
- }
313
- if (declared.clientEntry !== undefined) {
314
- return [resolveDeclaredSingleEntry(cwd, declared.clientEntry)];
315
- }
316
- return resolveConventionEntries(cwd);
317
- }
318
-
319
- function resolveDeclaredMultiEntries(
320
- cwd: string,
321
- declarations: readonly ClientEntryDeclaration[],
322
- ): readonly ClientEntry[] {
323
- if (declarations.length === 0) {
324
- throw new Error(
325
- '[kumiko build] package.json "kumiko.clientEntries" is empty — omit the key ' +
326
- "entirely to use the src/client.tsx convention.",
327
- );
328
- }
329
- const entries = declarations.map((decl) => resolveDeclaredEntry(cwd, decl));
330
-
331
- const seenNames = new Set<string>();
332
- for (const entry of entries) {
333
- if (seenNames.has(entry.name)) {
334
- throw new Error(
335
- `[kumiko build] duplicate "kumiko.clientEntries[].name" "${entry.name}" — names must be unique.`,
336
- );
337
- }
338
- seenNames.add(entry.name);
339
- }
340
-
341
- const seenHtmlBasenames = new Set<string>();
342
- for (const entry of entries) {
343
- const base = basenameOf(entry.htmlPath);
344
- if (seenHtmlBasenames.has(base)) {
345
- throw new Error(
346
- `[kumiko build] two "kumiko.clientEntries" entries resolve to the same HTML ` +
347
- `output file "${base}" — set an explicit "htmlPath" on one of them.`,
348
- );
349
- }
350
- seenHtmlBasenames.add(base);
351
- }
352
-
353
- // Bun.build's output → ClientEntry mapping (buildClientBundles) matches on
354
- // the source basename without extension. Two entries sharing it would
355
- // silently map to the same bundle.
356
- const seenSourceBasenames = new Set<string>();
357
- for (const entry of entries) {
358
- const base = basenameOf(entry.sourceFile).replace(SOURCE_EXTENSION_PATTERN, "");
359
- if (seenSourceBasenames.has(base)) {
360
- throw new Error(
361
- `[kumiko build] two "kumiko.clientEntries" entries share the source basename ` +
362
- `"${base}" ("${entry.sourceFile}") — rename one of the source files.`,
363
- );
364
- }
365
- seenSourceBasenames.add(base);
366
- }
367
-
368
- return [...entries].sort((a, b) => a.name.localeCompare(b.name));
369
- }
370
-
371
- function resolveDeclaredEntry(cwd: string, decl: ClientEntryDeclaration): ClientEntry {
372
- if (!ENTRY_NAME_PATTERN.test(decl.name)) {
373
- throw new Error(
374
- `[kumiko build] invalid "kumiko.clientEntries[].name" "${decl.name}" — must match ` +
375
- `^[a-z][a-z0-9-]*$ (it becomes the output filename client-${decl.name}.js).`,
376
- );
377
- }
378
- const sourceFile = resolveWithinCwd(
379
- cwd,
380
- decl.sourceFile,
381
- `kumiko.clientEntries["${decl.name}"].sourceFile`,
382
- );
383
- assertValidSourceExtension(decl.sourceFile, `kumiko.clientEntries["${decl.name}"].sourceFile`);
384
- if (!isExistingFile(sourceFile)) {
385
- throw new Error(
386
- `[kumiko build] kumiko.clientEntries["${decl.name}"].sourceFile "${decl.sourceFile}" ` +
387
- `does not exist (resolved: ${sourceFile}).`,
388
- );
389
- }
390
- const htmlPath =
391
- decl.htmlPath !== undefined
392
- ? resolveDeclaredHtmlPath(cwd, decl.name, decl.htmlPath)
393
- : decl.name === "public"
394
- ? (discoverHtmlTemplateFor(cwd, "index") ?? "index.html")
395
- : (discoverHtmlTemplateFor(cwd, decl.name) ?? `${decl.name}.html`);
396
- return { name: decl.name, sourceFile, manifestKey: `client-${decl.name}.js`, htmlPath };
397
- }
398
-
399
- function resolveDeclaredHtmlPath(cwd: string, entryName: string, htmlPath: string): string {
400
- const label = `kumiko.clientEntries["${entryName}"].htmlPath`;
401
- const resolved = resolveWithinCwd(cwd, htmlPath, label);
402
- assertValidHtmlExtension(htmlPath, label);
403
- return resolved;
404
- }
405
-
406
- function resolveDeclaredSingleEntry(cwd: string, clientEntry: string): ClientEntry {
407
- const sourceFile = resolveWithinCwd(cwd, clientEntry, "kumiko.clientEntry");
408
- assertValidSourceExtension(clientEntry, "kumiko.clientEntry");
409
- if (!isExistingFile(sourceFile)) {
410
- throw new Error(
411
- `[kumiko build] kumiko.clientEntry "${clientEntry}" does not exist (resolved: ${sourceFile}).`,
412
- );
413
- }
414
- return {
415
- name: "client",
416
- sourceFile,
417
- manifestKey: "client.js",
418
- htmlPath: discoverHtmlTemplateFor(cwd, "index") ?? "index.html",
419
- };
420
- }
421
-
422
- function resolveConventionEntries(cwd: string): readonly ClientEntry[] {
423
- for (const candidate of ["src/client.tsx", "src/client.ts"]) {
424
- const sourceFile = resolve(cwd, candidate);
425
- if (existsSync(sourceFile)) {
426
- return [
427
- {
428
- name: "client",
429
- sourceFile,
430
- manifestKey: "client.js",
431
- htmlPath: discoverHtmlTemplateFor(cwd, "index") ?? "index.html",
432
- },
433
- ];
434
- }
435
- }
436
- const legacyEntries = findLegacyClientFiles(cwd);
437
- if (legacyEntries.length > 0) {
438
- throw new Error(buildLegacyMultiEntryMigrationError(legacyEntries));
439
- }
440
- return [];
441
- }
442
-
443
- type LegacyClientFile = { readonly name: string; readonly file: string };
444
-
445
- // Pre-fw#2320 multi-entry convention: any src/client-<suffix>.tsx(x) became
446
- // its own bundle. Detecting these without a declaration means an unmigrated
447
- // app would silently ship without their bundles — throw instead.
448
- function findLegacyClientFiles(cwd: string): readonly LegacyClientFile[] {
449
- const srcDir = resolve(cwd, "src");
450
- if (!existsSync(srcDir)) return [];
451
- let files: readonly string[];
452
- try {
453
- files = readdirSync(srcDir);
454
- } catch {
455
- return [];
456
- }
457
- const found: LegacyClientFile[] = [];
458
- for (const file of files) {
459
- const match = /^client-([a-z][a-z0-9-]*)\.tsx?$/.exec(file);
460
- if (match?.[1]) found.push({ name: match[1], file });
461
- }
462
- return found.sort((a, b) => a.name.localeCompare(b.name));
463
- }
464
-
465
- function buildLegacyMultiEntryMigrationError(entries: readonly LegacyClientFile[]): string {
466
- const fileNames = entries.map((e) => `src/${e.file}`).join(", ");
467
- const snippet = entries
468
- .map((e) => ` { "name": "${e.name}", "sourceFile": "./src/${e.file}" }`)
469
- .join(",\n");
470
- return (
471
- `[kumiko build] found ${fileNames} but no "kumiko.clientEntries" declaration in ` +
472
- `package.json — production builds no longer infer entries from filenames (fw#2320). Add:\n\n` +
473
- ` "kumiko": {\n` +
474
- ` "clientEntries": [\n` +
475
- `${snippet}\n` +
476
- ` ]\n` +
477
- ` }\n`
478
- );
479
- }
480
-
481
- function resolveWithinCwd(cwd: string, value: string, label: string): string {
482
- const resolved = resolve(cwd, value);
483
- const rel = relative(cwd, resolved);
484
- if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
485
- throw new Error(`[kumiko build] ${label} "${value}" resolves outside the app root ${cwd}.`);
486
- }
487
- return resolved;
488
- }
489
-
490
- function isExistingFile(path: string): boolean {
491
- return existsSync(path) && statSync(path).isFile();
492
- }
493
-
494
- // The basename of htmlPath becomes a dist output filename (e.g. "manifest.json"
495
- // would collide with a file the build writes itself) — restrict it to .html.
496
- function assertValidHtmlExtension(value: string, label: string): void {
497
- if (!value.endsWith(".html")) {
498
- throw new Error(`[kumiko build] ${label} "${value}" must point to an .html file.`);
499
- }
500
- }
501
-
502
- function assertValidSourceExtension(value: string, label: string): void {
503
- if (!SOURCE_EXTENSION_PATTERN.test(value)) {
504
- throw new Error(
505
- `[kumiko build] ${label} "${value}" must point to a .ts, .tsx, .js or .jsx file.`,
506
- );
507
- }
508
- }
509
-
510
- /** Reads package.json → `kumiko.clientEntry` / `kumiko.clientEntries`.
511
- * Missing file or missing keys → `{}` (falls back to convention mode). A
512
- * malformed value under either key throws — a typo here must fail the
513
- * build loudly instead of silently dropping declared entries. */
514
- export function readClientEntriesConfig(cwd: string): ClientEntriesConfig {
515
- const pkgJsonPath = resolve(cwd, "package.json");
516
- if (!existsSync(pkgJsonPath)) return {};
517
- const raw = readFileSync(pkgJsonPath, "utf8");
518
- const parsed = parseJsonOrThrow<unknown>(raw, pkgJsonPath);
519
- if (!isPlainObject(parsed)) return {};
520
- const kumiko = parsed["kumiko"];
521
- if (!isPlainObject(kumiko)) return {};
522
-
523
- const result: { clientEntry?: string; clientEntries?: readonly ClientEntryDeclaration[] } = {};
524
-
525
- if ("clientEntry" in kumiko) {
526
- const value = kumiko["clientEntry"];
527
- if (typeof value !== "string") {
528
- throw new Error(
529
- `[kumiko build] package.json "kumiko.clientEntry" must be a string, got ${typeof value}.`,
530
- );
531
- }
532
- result.clientEntry = value;
533
- }
534
-
535
- if ("clientEntries" in kumiko) {
536
- const value = kumiko["clientEntries"];
537
- if (!Array.isArray(value)) {
538
- throw new Error(
539
- `[kumiko build] package.json "kumiko.clientEntries" must be an array, got ${typeof value}.`,
540
- );
541
- }
542
- result.clientEntries = value.map((item, index) => parseClientEntryDeclaration(item, index));
543
- }
544
-
545
- return result;
546
- }
547
-
548
- function parseClientEntryDeclaration(item: unknown, index: number): ClientEntryDeclaration {
549
- if (!isPlainObject(item)) {
550
- throw new Error(
551
- `[kumiko build] package.json "kumiko.clientEntries[${index}]" must be an object.`,
552
- );
553
- }
554
- const name = item["name"];
555
- const sourceFile = item["sourceFile"];
556
- const htmlPath = item["htmlPath"];
557
- if (typeof name !== "string" || name.length === 0) {
558
- throw new Error(
559
- `[kumiko build] package.json "kumiko.clientEntries[${index}].name" must be a non-empty string.`,
560
- );
561
- }
562
- if (typeof sourceFile !== "string" || sourceFile.length === 0) {
563
- throw new Error(
564
- `[kumiko build] package.json "kumiko.clientEntries[${index}].sourceFile" must be a non-empty string.`,
565
- );
566
- }
567
- if (htmlPath !== undefined && typeof htmlPath !== "string") {
568
- throw new Error(
569
- `[kumiko build] package.json "kumiko.clientEntries[${index}].htmlPath" must be a string when set.`,
570
- );
571
- }
572
- return { name, sourceFile, ...(htmlPath !== undefined && { htmlPath }) };
573
- }
574
-
575
- function discoverHtmlTemplateFor(cwd: string, basename: string): string | undefined {
576
- for (const candidate of [`${basename}.html`, `public/${basename}.html`]) {
577
- const path = resolve(cwd, candidate);
578
- if (existsSync(path)) return path;
579
- }
580
- return undefined;
581
- }
582
-
583
- type ResolvedStylesheet = {
584
- readonly path: string;
585
- /** True nur wenn wir auf das gepackte renderer-web-styles.css zurückfallen
586
- * (App hat clientEntry + renderer-web-Dep, aber kein eigenes src/styles.css).
587
- * Steuert den Shell-Klassen-Sentinel im Build. */
588
- readonly isRendererWebFallback: boolean;
589
- };
590
-
591
- // Äußerster Wrapper von DefaultAppShell/WorkspaceShell trägt diese Utility
592
- // (app-layout.tsx: "flex min-h-screen …"). Echtes Tailwind-Utility (landet
593
- // also im kompilierten Output) ohne CSS-Escaping → stabiler Substring-Sentinel.
594
- const RENDERER_WEB_SHELL_SENTINEL = "min-h-screen";
595
-
596
- function resolveStylesheetEntry(
597
- cwd: string,
598
- clientEntry: string | undefined,
599
- override: BuildProdBundleOptions["stylesheet"],
600
- ): ResolvedStylesheet | undefined {
601
- if (override === false) return undefined;
602
- if (typeof override === "string") {
603
- return { path: resolve(cwd, override), isRendererWebFallback: false };
604
- }
605
-
606
- // App-eigenes styles.css schlägt den Default.
607
- const local = resolve(cwd, "src/styles.css");
608
- if (existsSync(local)) return { path: local, isRendererWebFallback: false };
609
-
610
- // Sonst: nur wenn ein client da ist, fallback auf renderer-web/styles.css.
611
- // Sample-Apps und Showcases nutzen das alle — gleiche Logik wie der dev-
612
- // server, damit lokal/prod identisch bauen.
613
- if (!clientEntry) return undefined;
614
-
615
- if (!hasBun) return undefined;
616
- try {
617
- const resolved = (
618
- globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }
619
- ).Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", cwd);
620
- const bun = (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }).Bun;
621
- if (!canResolveTailwindStylesheet(resolved, { bun, cwd })) {
622
- return undefined;
623
- }
624
- return { path: resolved, isRendererWebFallback: true };
625
- } catch {
626
- return undefined;
627
- }
628
- }
629
-
630
- // Build-Zeit-Guard gegen unstyled prod (#359): fällt der Build auf renderer-
631
- // web/styles.css zurück und fehlt im kompilierten CSS die Shell-Sentinel-
632
- // Klasse, wurden die DefaultAppShell-Styles nicht gescannt (renderer-web
633
- // nicht dort installiert wo Tailwind scannt, oder ein @source-Regress).
634
- // Laut failen statt ein 15KB-Image zu liefern, das in prod nackt rendert.
635
- // @internal — exportiert nur für Unit-Tests.
636
- export function assertRendererWebShellPresent(css: string, stylesheet: ResolvedStylesheet): void {
637
- // skip: not the renderer-web fallback stylesheet, invariant doesn't apply
638
- if (!stylesheet.isRendererWebFallback) return;
639
- // skip: shell sentinel class present, styles were scanned correctly
640
- if (css.includes(RENDERER_WEB_SHELL_SENTINEL)) return;
641
- throw new Error(
642
- `[kumiko build] renderer-web-Fallback-CSS ohne Shell-Klasse "${RENDERER_WEB_SHELL_SENTINEL}" — ` +
643
- "die DefaultAppShell/WorkspaceShell-Styles fehlen, prod würde unstyled rendern. Meist ist " +
644
- "@cosmicdrift/kumiko-renderer-web nicht dort installiert wo Tailwind seine Source scannt. " +
645
- 'Fix: src/styles.css anlegen mit `@import "@cosmicdrift/kumiko-renderer-web/styles.css";` ' +
646
- '(+ `@source "./**/*.{ts,tsx}";` um auch eigene Komponenten zu scannen).',
647
- );
648
- }
649
-
650
- // The compiled CSS points at absolute /assets/kumiko/fonts/<file>.woff2 URLs
651
- // (see renderer-web-fonts.ts), so the files have to sit at exactly that path in
652
- // dist. Skipped when renderer-web isn't resolvable — then no such CSS exists.
653
- async function copyRendererWebFonts(cwd: string, outDir: string): Promise<void> {
654
- const fontsDir = resolveRendererWebFontsDir([cwd]);
655
- // skip: renderer-web not resolvable, so no font-referencing CSS exists.
656
- if (fontsDir === undefined || !existsSync(fontsDir)) return;
657
- const target = join(outDir, RENDERER_WEB_FONTS_DIST_DIR);
658
- await mkdir(target, { recursive: true });
659
- for (const file of readdirSync(fontsDir)) {
660
- if (RENDERER_WEB_FONT_FILE_PATTERN.test(file)) {
661
- await cp(join(fontsDir, file), join(target, file));
662
- }
663
- }
664
- }
665
-
666
- // @internal — exported nur für Unit-Tests.
667
- export function discoverHtmlTemplate(cwd: string): string | undefined {
668
- for (const candidate of ["index.html", "public/index.html"]) {
669
- const path = resolve(cwd, candidate);
670
- if (existsSync(path)) return path;
671
- }
672
- return undefined;
673
- }
674
-
675
- // ---------------------------------------------------------------------------
676
- // Build steps
677
- // ---------------------------------------------------------------------------
678
-
679
- // @internal — exportiert nur für Unit-Tests (Relocation-Test des @source-Layouts).
680
- export async function runTailwindOnce(entry: string, cwd: string): Promise<string> {
681
- if (!hasBun) {
682
- throw new Error(
683
- "[kumiko build] Tailwind one-shot requires Bun (Bun.spawn) — run via `bun run …` or `bun kumiko build`.",
684
- );
685
- }
686
- const bunResolver = (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } })
687
- .Bun;
688
- const cliPath = resolveTailwindCli({ bun: bunResolver, cwd });
689
- if (cliPath === undefined) {
690
- throw new Error(
691
- "[kumiko build] @tailwindcss/cli nicht auflösbar — `bun install` im App-Root ausführen.",
692
- );
693
- }
694
- if (!canResolveTailwindStylesheet(entry, { bun: bunResolver, cwd })) {
695
- throw new Error(
696
- `[kumiko build] tailwindcss nicht auflösbar für ${entry} — peer dependency fehlt am Stylesheet-Ort.`,
697
- );
698
- }
699
- const tmpDir = await mkdtemp(join(tmpdir(), "kumiko-build-tw-"));
700
- const outPath = join(tmpDir, "styles.css");
701
- const bunPath = process.argv[0] ?? "bun";
702
- const proc = Bun.spawn([bunPath, "run", cliPath, "-i", entry, "-o", outPath, "--minify"], {
703
- cwd,
704
- stdout: "inherit",
705
- stderr: "inherit",
706
- });
707
- const code = await proc.exited;
708
- if (code !== 0) {
709
- throw new Error(`[kumiko build] tailwind exit ${code}`);
710
- }
711
- const css = await readFile(outPath, "utf8");
712
- await rm(tmpDir, { recursive: true, force: true });
713
- return css;
714
- }
715
-
716
- /** Multi-entry-build: ein Bun.build-Call mit allen entrypoints — shared
717
- * chunks werden dedupliziert, hashes deterministisch. Returns map
718
- * manifestKey → hashed-filename (basename, ohne /assets/-Prefix). */
719
- async function buildClientBundles(
720
- entries: readonly ClientEntry[],
721
- outDir: string,
722
- ): Promise<Record<string, string>> {
723
- if (!hasBun) {
724
- throw new Error("[kumiko build] requires Bun — run via `bun run …` or `bun kumiko build`.");
725
- }
726
- const built = await Bun.build({
727
- entrypoints: entries.map((e) => e.sourceFile),
728
- outdir: outDir,
729
- target: "browser",
730
- splitting: true,
731
- minify: true,
732
- // Keine Source-Maps in Prod: 1.6 MB+ Müll im Container, plus
733
- // exposed Source-Code reverse-engineerable. Dev hat seine eigenen
734
- // sourcemaps via create-kumiko-server.ts.
735
- sourcemap: "none",
736
- naming: {
737
- entry: "[name]-[hash].[ext]",
738
- chunk: "[name]-[hash].[ext]",
739
- asset: "[name]-[hash].[ext]",
740
- },
741
- loader: ASSET_LOADERS,
742
- define: {
743
- "process.env.NODE_ENV": JSON.stringify("production"),
744
- },
745
- });
746
- if (!built.success) {
747
- const errs = built.logs.map((log) => String(log)).join("\n");
748
- throw new Error(`[kumiko build] Bun.build failed:\n${errs}`);
749
- }
750
- const entryOutputs = built.outputs.filter((o) => o.kind === "entry-point");
751
- if (entryOutputs.length !== entries.length) {
752
- throw new Error(
753
- `[kumiko build] expected ${entries.length} entry-point outputs, got ${entryOutputs.length}`,
754
- );
755
- }
756
- // Bun.build benennt entry-files nach Source-Basename (ohne extension):
757
- // `src/client-admin.tsx` → `client-admin-<hash>.js`. Wir mappen jedes
758
- // entry-output zurück auf seinen ClientEntry via Basename-match.
759
- const result: Record<string, string> = {};
760
- for (const entry of entries) {
761
- const baseName = (entry.sourceFile.split("/").pop() ?? "").replace(
762
- SOURCE_EXTENSION_PATTERN,
763
- "",
764
- );
765
- const match = entryOutputs.find((o) => {
766
- const outName = o.path.split("/").pop() ?? "";
767
- return outName.startsWith(`${baseName}-`);
768
- });
769
- if (!match) {
770
- throw new Error(
771
- `[kumiko build] no entry-point output for "${entry.sourceFile}" (looked for "${baseName}-*.js")`,
772
- );
773
- }
774
- result[entry.manifestKey] = match.path.split("/").pop() ?? match.path;
775
- }
776
- return result;
777
- }
778
-
779
- function basenameOf(p: string): string {
780
- return p.split("/").pop() ?? p;
781
- }
782
-
783
- async function copyPublicFolder(
784
- src: string,
785
- dst: string,
786
- templateBasenames: ReadonlySet<string>,
787
- ): Promise<void> {
788
- // HTML-templates werden separat gerendert — nicht blind kopieren, sonst
789
- // überschreibt das die injizierte Version. (z.B. index.html, admin.html
790
- // bei multi-entry).
791
- await cp(src, dst, {
792
- recursive: true,
793
- filter: (source) => {
794
- const normalized = source.replace(/\\/g, "/");
795
- const srcNormalized = src.replace(/\\/g, "/");
796
- const base = normalized.startsWith(`${srcNormalized}/`)
797
- ? normalized.slice(srcNormalized.length + 1)
798
- : "";
799
- return !templateBasenames.has(base);
800
- },
801
- });
802
- }
803
-
804
- // ---------------------------------------------------------------------------
805
- // HTML render
806
- // ---------------------------------------------------------------------------
807
-
808
- async function renderHtml(
809
- templatePath: string | undefined,
810
- manifest: BuildManifest,
811
- entry: ClientEntry,
812
- buildInfo: BuildInfo | undefined,
813
- ): Promise<string> {
814
- // Edge-Case: kein eigenes HTML-Template + Bun.build oder Tailwind hat
815
- // Output produziert. DEFAULT_HTML hat keine Placeholder (vanilla
816
- // template), also würde injectAssetTags eh fehlschlagen. Klarer Fehler
817
- // mit Vorschlag-Snippet zum Reinkopieren.
818
- if (!templatePath && Object.keys(manifest).length > 0) {
819
- throw new Error(buildMissingTemplateError(manifest, entry));
820
- }
821
- const template = templatePath ? await readFile(templatePath, "utf8") : DEFAULT_HTML;
822
- return injectAssetTags(template, manifest, entry, buildInfo);
823
- }
824
-
825
- // @internal — exported for unit tests only. See #2305: the message must
826
- // name the source file and, for a declared multi-entry, which package.json
827
- // "kumiko.clientEntries" entry it came from.
828
- export function buildMissingTemplateError(manifest: BuildManifest, entry: ClientEntry): string {
829
- const cssLine = manifest["styles.css"]
830
- ? ` <link rel="stylesheet" href="/styles.css" />\n`
831
- : "";
832
- const jsLine = manifest[entry.manifestKey]
833
- ? // html-ok: Error-Hilfetext (Tag-Snippet als Text), wird nie gerendert.
834
- ` <script type="module" src="/${entry.manifestKey}"></script>\n`
835
- : "";
836
- const sourceBasename = basenameOf(entry.sourceFile);
837
- // Single-mode entries always carry manifestKey "client.js"; any other
838
- // value came from a declared package.json "kumiko.clientEntries" entry.
839
- const isMultiEntry = entry.manifestKey !== "client.js";
840
- const discoveryHint = isMultiEntry
841
- ? `Entry "${entry.name}" comes from package.json "kumiko.clientEntries" ` +
842
- `(sourceFile: "src/${sourceBasename}").\n\n`
843
- : "";
844
- return (
845
- `[kumiko build] kein ${entry.htmlPath} gefunden für Entry "src/${sourceBasename}", ` +
846
- `aber es gibt JS/CSS-Output.\n` +
847
- discoveryHint +
848
- `Leg ein public/${basenameOf(entry.htmlPath)} oder ${basenameOf(entry.htmlPath)} im App-Root an, z. B.:\n` +
849
- `\n` +
850
- `<!doctype html>\n` +
851
- `<html>\n` +
852
- ` <head>\n` +
853
- ` <meta charset="utf-8" />\n` +
854
- ` <title>Meine App</title>\n` +
855
- cssLine +
856
- ` </head>\n` +
857
- ` <body>\n` +
858
- ` <div id="root"></div>\n` +
859
- jsLine +
860
- ` </body>\n` +
861
- `</html>\n` +
862
- `\n` +
863
- `Der Build ersetzt /styles.css und /${entry.manifestKey} durch die gehashten URLs.`
864
- );
865
- }
866
-
867
- // @internal — exported nur für Unit-Tests.
868
- //
869
- // Convention: das HTML-Template MUSS Placeholder-Tags für jedes Asset
870
- // dieses Entries enthalten:
871
- // - `<script type="module" src="/client.js">` für single-mode entry "client"
872
- // - `<script type="module" src="/client-<name>.js">` für multi-mode entry "<name>"
873
- // - `<link href="/styles.css">` für styles (gemeinsam über alle entries)
874
- // Der Build ersetzt sie durch die gehashten URLs.
875
- //
876
- // Fehlt ein erwarteter Tag, wirft der Build einen Fehler mit dem exakten
877
- // Snippet zum Reinkopieren — kein silent injection mehr, weil das den
878
- // Diff zwischen Dev- und Prod-HTML unsichtbar macht.
879
- export function injectAssetTags(
880
- html: string,
881
- manifest: BuildManifest,
882
- entry: ClientEntry,
883
- buildInfo?: BuildInfo,
884
- ): string {
885
- let result = html;
886
-
887
- // Build-Stand vor </head> backen. Ohne </head> (z.B. Fragment) still skip.
888
- if (buildInfo && result.includes("</head>")) {
889
- // html-ok: id/builtAt sind Hex bzw. ISO — kein `<` möglich, kein Breakout.
890
- const tag = `<script>window.__KUMIKO_BUILD__=${JSON.stringify(buildInfo)};</script>`;
891
- result = result.replace("</head>", ` ${tag}\n </head>`);
892
- }
893
-
894
- const cssUrl = manifest["styles.css"];
895
- if (cssUrl && !result.includes(cssUrl)) {
896
- const placeholder = /<link\s+rel="stylesheet"\s+href="\/styles\.css"\s*\/?>/.exec(result);
897
- if (!placeholder) {
898
- throw new Error(
899
- buildMissingTagError({
900
- htmlPath: entry.htmlPath,
901
- assetKey: "styles.css",
902
- tagSnippet: `<link rel="stylesheet" href="/styles.css" />`,
903
- insertHint: "ins <head>",
904
- hashedAssetHint: "/assets/styles-<hash>.css",
905
- }),
906
- );
907
- }
908
- // html-ok: cssUrl ist ein selbst-generierter Manifest-Asset-Pfad (Hash-Name).
909
- result = result.replace(placeholder[0], `<link rel="stylesheet" href="${cssUrl}" />`);
910
- }
911
-
912
- const jsUrl = manifest[entry.manifestKey];
913
- if (jsUrl && !result.includes(jsUrl)) {
914
- // Placeholder-pattern: src="/client.js" oder src="/client-<name>.js"
915
- const placeholderRx = new RegExp(
916
- `<script\\b[^>]*src="\\/${entry.manifestKey.replace(/\./g, "\\.")}"[^>]*><\\/script>`,
917
- );
918
- const placeholder = placeholderRx.exec(result);
919
- if (!placeholder) {
920
- const baseAssetName = entry.manifestKey.replace(/\.js$/, "");
921
- throw new Error(
922
- buildMissingTagError({
923
- htmlPath: entry.htmlPath,
924
- assetKey: entry.manifestKey,
925
- tagSnippet: `<script type="module" src="/${entry.manifestKey}"></script>`,
926
- insertHint: "vor </body>",
927
- hashedAssetHint: `/assets/${baseAssetName}-<hash>.js`,
928
- }),
929
- );
930
- }
931
- // html-ok: jsUrl ist ein selbst-generierter Manifest-Asset-Pfad (Hash-Name).
932
- result = result.replace(placeholder[0], `<script type="module" src="${jsUrl}"></script>`);
933
- }
934
-
935
- return result;
936
- }
937
-
938
- /** Einheitliche Error-Form für fehlende Asset-Tags im HTML-Template
939
- * (script-tag fürs JS-Bundle ODER stylesheet-link für Tailwind). */
940
- function buildMissingTagError(args: {
941
- readonly htmlPath: string;
942
- readonly assetKey: string;
943
- readonly tagSnippet: string;
944
- readonly insertHint: string;
945
- readonly hashedAssetHint: string;
946
- }): string {
947
- const tpl = basenameOf(args.htmlPath);
948
- return (
949
- `[kumiko build] ${tpl} hat keinen Entry-Tag für /${args.assetKey} — füg ${args.insertHint} ein:\n` +
950
- `\n` +
951
- ` ${args.tagSnippet}\n` +
952
- `\n` +
953
- `Der Build ersetzt das durch ${args.hashedAssetHint}. Im Dev-Server liefert er die Datei direkt.`
954
- );
955
- }
956
-
957
- // ---------------------------------------------------------------------------
958
- // Hash helpers
959
- // ---------------------------------------------------------------------------
960
-
961
- function shortHash(content: string | Uint8Array): string {
962
- return createHash("sha256").update(content).digest("hex").slice(0, 8);
963
- }
964
-
965
- // Build-Identität aus den content-gehashten Asset-URLs. Sortiert →
966
- // deterministisch (Manifest-Key-Reihenfolge egal). Identischer Source →
967
- // gleiche id, geändertes Asset → andere id.
968
- // @internal — exported nur für Unit-Tests.
969
- export function computeBuildId(manifest: BuildManifest): string {
970
- return createHash("sha256")
971
- .update(Object.values(manifest).sort().join("\n"))
972
- .digest("hex")
973
- .slice(0, 12);
974
- }
975
-
976
- // ---------------------------------------------------------------------------
977
- // CLI output
978
- // ---------------------------------------------------------------------------
979
-
980
- /** Formatiert ein BuildResult als CLI-freundliche Mehrzeilen-Zusammenfassung
981
- * mit ANSI-Farben. Wird sowohl von `kumiko build` als auch von dem
982
- * hoisted `kumiko-build`-Bin verwendet, damit das Output konsistent ist. */
983
- export function formatBuildResult(result: BuildResult, durationMs: number): string {
984
- const dim = "\x1b[2m";
985
- const green = "\x1b[32m";
986
- const reset = "\x1b[0m";
987
- const lines: string[] = [
988
- "",
989
- ` ${green}✓${reset} built ${result.outDir} ${dim}(${durationMs}ms)${reset}`,
990
- ];
991
- for (const [logical, hashed] of Object.entries(result.manifest)) {
992
- lines.push(` ${dim}${logical.padEnd(14)}${reset} ${hashed}`);
993
- }
994
- lines.push("");
995
- return lines.join("\n");
996
- }