@cosmicdrift/kumiko-dev-server 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.
- package/bin/kumiko-build.ts +85 -0
- package/bin/kumiko-dev.ts +90 -0
- package/package.json +45 -0
- package/src/__tests__/build-prod-bundle.integration.ts +265 -0
- package/src/__tests__/build-prod-bundle.test.ts +262 -0
- package/src/__tests__/cache-headers.test.ts +70 -0
- package/src/__tests__/classify-change.test.ts +87 -0
- package/src/__tests__/compose-features-wiring.integration.ts +352 -0
- package/src/__tests__/compose-features.test.ts +81 -0
- package/src/__tests__/crash-tracker.test.ts +89 -0
- package/src/__tests__/create-kumiko-server.integration.ts +286 -0
- package/src/__tests__/few-shot-corpus.test.ts +311 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/resolve-stylesheet.test.ts +90 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.ts +535 -0
- package/src/__tests__/scaffold-feature.test.ts +143 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/build-prod-bundle.ts +587 -0
- package/src/build-server-bundle.ts +308 -0
- package/src/build.ts +28 -0
- package/src/codegen/__tests__/run-codegen.test.ts +494 -0
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
- package/src/codegen/__tests__/watch.test.ts +186 -0
- package/src/codegen/index.ts +17 -0
- package/src/codegen/render.ts +225 -0
- package/src/codegen/run-codegen.ts +157 -0
- package/src/codegen/scan-events.ts +574 -0
- package/src/codegen/watch.ts +127 -0
- package/src/compose-features.ts +128 -0
- package/src/crash-tracker.ts +56 -0
- package/src/create-kumiko-server.ts +1010 -0
- package/src/drizzle-config.ts +44 -0
- package/src/drizzle-tables-auth-mode.ts +32 -0
- package/src/drizzle-tables-minimal.ts +22 -0
- package/src/few-shot-corpus.ts +369 -0
- package/src/index.ts +57 -0
- package/src/inject-schema.ts +24 -0
- package/src/resolve-tailwind-cli.ts +28 -0
- package/src/run-dev-app.ts +290 -0
- package/src/run-prod-app.ts +892 -0
- package/src/scaffold-feature.ts +226 -0
- package/src/try-hono-first.ts +46 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Pure-function pin für tryHonoFirst. Trivial aber load-bearing:
|
|
2
|
+
// Drift zwischen dev (createKumikoServer) und prod (runProdApp) hat
|
|
3
|
+
// schon einen Bug verursacht (legal-pages funktionierten in prod aber
|
|
4
|
+
// nicht in dev). Beide nutzen jetzt diesen helper — wenn die Semantik
|
|
5
|
+
// sich ändert (z.B. "matched" auch für 4xx anders als 404), MÜSSEN
|
|
6
|
+
// beide Pfade synchron updaten.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { type HonoLikeApp, tryHonoFirst } from "../try-hono-first";
|
|
10
|
+
|
|
11
|
+
function makeApp(response: Response): HonoLikeApp {
|
|
12
|
+
return { fetch: () => response };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeAsyncApp(response: Response): HonoLikeApp {
|
|
16
|
+
return { fetch: async () => response };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("tryHonoFirst", () => {
|
|
20
|
+
test("matched=true bei 200 (Hono-route greift)", async () => {
|
|
21
|
+
const app = makeApp(new Response("ok", { status: 200 }));
|
|
22
|
+
const res = await tryHonoFirst(app, new Request("http://test/foo"));
|
|
23
|
+
expect(res.matched).toBe(true);
|
|
24
|
+
expect(res.response.status).toBe(200);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("matched=false bei 404 (keine Route — caller fällt auf SPA-fallback)", async () => {
|
|
28
|
+
const app = makeApp(new Response("not found", { status: 404 }));
|
|
29
|
+
const res = await tryHonoFirst(app, new Request("http://test/unknown"));
|
|
30
|
+
expect(res.matched).toBe(false);
|
|
31
|
+
// response wird trotzdem zurückgegeben — caller kann den 404 als
|
|
32
|
+
// letztes Sicherheitsnetz nutzen wenn auch SPA-fallback nichts liefert.
|
|
33
|
+
expect(res.response.status).toBe(404);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("matched=true bei 401/403/500 (Hono hat geantwortet — kein SPA-fallback)", async () => {
|
|
37
|
+
// Bug-Pin: matched darf NUR bei status=404 false sein. Wenn Hono
|
|
38
|
+
// 401 (auth required) returnt, war die Route klar gefunden + hat
|
|
39
|
+
// bewusst rejected — SPA-fallback würde das überschreiben und den
|
|
40
|
+
// User auf eine SPA leiten statt die 401-message zu zeigen.
|
|
41
|
+
for (const status of [401, 403, 422, 500] as const) {
|
|
42
|
+
const app = makeApp(new Response(null, { status }));
|
|
43
|
+
const res = await tryHonoFirst(app, new Request("http://test/x"));
|
|
44
|
+
expect(res.matched, `status ${status} should be matched`).toBe(true);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("akzeptiert sowohl sync als auch async fetch (Hono-Variation)", async () => {
|
|
49
|
+
// Hono.app.fetch returnt Response | Promise<Response> abhängig vom
|
|
50
|
+
// handler-mix. createApiEntrypoint's apiHandler dasselbe. Helper
|
|
51
|
+
// muss beide schluckable.
|
|
52
|
+
const sync = await tryHonoFirst(
|
|
53
|
+
makeApp(new Response("s", { status: 200 })),
|
|
54
|
+
new Request("http://t/"),
|
|
55
|
+
);
|
|
56
|
+
const asyncRes = await tryHonoFirst(
|
|
57
|
+
makeAsyncApp(new Response("a", { status: 200 })),
|
|
58
|
+
new Request("http://t/"),
|
|
59
|
+
);
|
|
60
|
+
expect(sync.matched).toBe(true);
|
|
61
|
+
expect(asyncRes.matched).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,587 @@
|
|
|
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
|
+
|
|
48
|
+
// Bun-Runtime-Check als module-level Konstante: alle Build-Schritte
|
|
49
|
+
// (Tailwind via Bun.spawn, Client-Bundle via Bun.build, Stylesheet-
|
|
50
|
+
// Resolution via Bun.resolveSync) sind Bun-only. Pro-Funktions-Inline-
|
|
51
|
+
// Checks driften sonst — eine Konstante hier hält das konsistent.
|
|
52
|
+
const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
|
53
|
+
|
|
54
|
+
export type BuildProdBundleOptions = {
|
|
55
|
+
/** App-Root. Default: process.cwd(). */
|
|
56
|
+
readonly cwd?: string;
|
|
57
|
+
/** Output-Folder relativ zu cwd. Default: "dist". */
|
|
58
|
+
readonly outDir?: string;
|
|
59
|
+
/** Stylesheet-Override. Default: erst src/styles.css, dann
|
|
60
|
+
* @cosmicdrift/kumiko-renderer-web/styles.css wenn clientEntry da ist.
|
|
61
|
+
* `false` deaktiviert die CSS-Pipeline explizit. */
|
|
62
|
+
readonly stylesheet?: string | false;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type BuildManifest = Readonly<Record<string, string>>;
|
|
66
|
+
|
|
67
|
+
export type BuildResult = {
|
|
68
|
+
readonly outDir: string;
|
|
69
|
+
/** Logical → hashed-URL mapping. Beispiel:
|
|
70
|
+
* { "client.js": "/assets/client-a3f2.js",
|
|
71
|
+
* "styles.css": "/assets/styles-9b4c.css" } */
|
|
72
|
+
readonly manifest: BuildManifest;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Default-HTML wird nur genutzt wenn der App-Author KEIN index.html liefert.
|
|
76
|
+
// Hat keine Asset-Placeholder, weil der Default-Pfad für vanilla apps
|
|
77
|
+
// (nur public/) gedacht ist — wer JS/CSS will, schreibt ein eigenes
|
|
78
|
+
// index.html mit den richtigen Placeholder-Tags.
|
|
79
|
+
const DEFAULT_HTML = `<!doctype html>
|
|
80
|
+
<html lang="en">
|
|
81
|
+
<head>
|
|
82
|
+
<meta charset="utf-8" />
|
|
83
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
84
|
+
<title>Kumiko</title>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
87
|
+
<div id="root"></div>
|
|
88
|
+
</body>
|
|
89
|
+
</html>
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
/** Folder name relative to dist/ for hashed assets (JS, CSS, file-loader
|
|
93
|
+
* outputs). Exported damit runProdApp dieselbe Konvention für Cache-
|
|
94
|
+
* Header nutzt — Drift verhindern. */
|
|
95
|
+
export const ASSETS_DIR = "assets";
|
|
96
|
+
|
|
97
|
+
const ASSET_LOADERS = {
|
|
98
|
+
".png": "file",
|
|
99
|
+
".jpg": "file",
|
|
100
|
+
".jpeg": "file",
|
|
101
|
+
".gif": "file",
|
|
102
|
+
".svg": "file",
|
|
103
|
+
".webp": "file",
|
|
104
|
+
".ico": "file",
|
|
105
|
+
".woff": "file",
|
|
106
|
+
".woff2": "file",
|
|
107
|
+
".ttf": "file",
|
|
108
|
+
".otf": "file",
|
|
109
|
+
} as const;
|
|
110
|
+
|
|
111
|
+
export async function buildProdBundle(options: BuildProdBundleOptions = {}): Promise<BuildResult> {
|
|
112
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
113
|
+
const outDir = resolve(cwd, options.outDir ?? "dist");
|
|
114
|
+
const assetsDir = join(outDir, ASSETS_DIR);
|
|
115
|
+
|
|
116
|
+
// 1. Discovery: was ist da?
|
|
117
|
+
const clientEntries = discoverClientEntries(cwd);
|
|
118
|
+
const firstClientSource = clientEntries[0]?.sourceFile;
|
|
119
|
+
const stylesheet = resolveStylesheetEntry(cwd, firstClientSource, options.stylesheet);
|
|
120
|
+
const publicDir = resolve(cwd, "public");
|
|
121
|
+
const hasPublicDir = existsSync(publicDir);
|
|
122
|
+
|
|
123
|
+
if (clientEntries.length === 0 && !hasPublicDir) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`[kumiko build] nothing to build in ${cwd} — expected at least one of: ` +
|
|
126
|
+
`src/client.tsx, src/client-*.tsx, public/`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 2. Clean + scaffold
|
|
131
|
+
await rm(outDir, { recursive: true, force: true });
|
|
132
|
+
await mkdir(outDir, { recursive: true });
|
|
133
|
+
await mkdir(assetsDir, { recursive: true });
|
|
134
|
+
|
|
135
|
+
const manifest: Record<string, string> = {};
|
|
136
|
+
|
|
137
|
+
// 3. Tailwind one-shot (vor JS, weil JS' loader auf .css trifft falls
|
|
138
|
+
// der client.tsx ein "import './foo.css'" macht — den Fall lassen
|
|
139
|
+
// wir hier raus, Tailwind ist die einzige CSS-Quelle).
|
|
140
|
+
if (stylesheet) {
|
|
141
|
+
const css = await runTailwindOnce(stylesheet);
|
|
142
|
+
const hash = shortHash(css);
|
|
143
|
+
const filename = `styles-${hash}.css`;
|
|
144
|
+
await writeFile(join(assetsDir, filename), css);
|
|
145
|
+
manifest["styles.css"] = `/${ASSETS_DIR}/${filename}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 4. Bun.build pro Entry (multi-entry produces N bundles + shared chunks).
|
|
149
|
+
// Ein einzelner Bun.build-Call mit allen entrypoints würde shared
|
|
150
|
+
// chunks deduplizieren, hashes deterministisch halten — passt zu
|
|
151
|
+
// dem split-tree-Pattern von publicstatus (admin + public teilen
|
|
152
|
+
// sich den renderer-web-core).
|
|
153
|
+
if (clientEntries.length > 0) {
|
|
154
|
+
const built = await buildClientBundles(clientEntries, assetsDir);
|
|
155
|
+
for (const [manifestKey, filename] of Object.entries(built)) {
|
|
156
|
+
manifest[manifestKey] = `/${ASSETS_DIR}/${filename}`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 5. Public-Folder rsync (ohne index.html / *.html-templates — werden
|
|
161
|
+
// separat gerendert). Filter-list = template-basenames der entries.
|
|
162
|
+
const templateBasenames = new Set<string>(clientEntries.map((e) => basenameOf(e.htmlPath)));
|
|
163
|
+
if (hasPublicDir) {
|
|
164
|
+
await copyPublicFolder(publicDir, outDir, templateBasenames);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 6. HTML pro Entry rendern. Convention: ein HTML-File pro Client-Entry,
|
|
168
|
+
// jede mit ihrem eigenen Script-Tag. Server (runProdApp.hostDispatch)
|
|
169
|
+
// serviert je nach Host das passende File.
|
|
170
|
+
if (clientEntries.length === 0) {
|
|
171
|
+
// Vanilla-public-only-app: keine HTML-Files zu rendern, public-Folder
|
|
172
|
+
// wurde schon kopiert.
|
|
173
|
+
} else {
|
|
174
|
+
for (const entry of clientEntries) {
|
|
175
|
+
const templatePath = resolve(cwd, entry.htmlPath);
|
|
176
|
+
const templateExists = existsSync(templatePath);
|
|
177
|
+
const html = await renderHtml(templateExists ? templatePath : undefined, manifest, entry);
|
|
178
|
+
const outFile = basenameOf(entry.htmlPath);
|
|
179
|
+
await writeFile(join(outDir, outFile), html);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 7. Manifest.
|
|
184
|
+
await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
185
|
+
|
|
186
|
+
return { outDir, manifest };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Discovery
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
// Single client-entry shape — one bundle, one html-template.
|
|
194
|
+
export type ClientEntry = {
|
|
195
|
+
/** Logical name. "client" für single-mode; sonst der Suffix von
|
|
196
|
+
* src/client-<suffix>.tsx (z.B. "public", "admin"). */
|
|
197
|
+
readonly name: string;
|
|
198
|
+
/** TypeScript-Source. */
|
|
199
|
+
readonly sourceFile: string;
|
|
200
|
+
/** Manifest-key & logical-asset-path. "client.js" für single, sonst
|
|
201
|
+
* "client-<name>.js". */
|
|
202
|
+
readonly manifestKey: string;
|
|
203
|
+
/** HTML-template-Pfad relativ zum cwd. "index.html" für single oder
|
|
204
|
+
* "public"-entry; sonst "<name>.html". Naming bewusst symmetrisch
|
|
205
|
+
* zu `runDevApp.clientEntries[].htmlPath` damit Build und Dev-Server
|
|
206
|
+
* dieselbe Konvention verwenden. */
|
|
207
|
+
readonly htmlPath: string;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// @internal — exported nur für Unit-Tests. Konsumenten gehen über
|
|
211
|
+
// buildProdBundle.
|
|
212
|
+
//
|
|
213
|
+
// Discovery-Pattern:
|
|
214
|
+
// - Falls `src/client-<suffix>.tsx` files existieren → multi-entry-mode,
|
|
215
|
+
// ein Bundle pro Datei. "public" mapped auf index.html (default),
|
|
216
|
+
// andere Suffixe auf "<suffix>.html".
|
|
217
|
+
// - Sonst falls `src/client.tsx` oder `src/client.ts` existiert →
|
|
218
|
+
// single-entry-mode mit name "client" + index.html.
|
|
219
|
+
// - Sonst leeres Array (keine Client-Bundles).
|
|
220
|
+
export function discoverClientEntries(cwd: string): readonly ClientEntry[] {
|
|
221
|
+
const multi = discoverMultiClientEntries(cwd);
|
|
222
|
+
if (multi.length > 0) return multi;
|
|
223
|
+
|
|
224
|
+
for (const candidate of ["src/client.tsx", "src/client.ts"]) {
|
|
225
|
+
const sourceFile = resolve(cwd, candidate);
|
|
226
|
+
if (existsSync(sourceFile)) {
|
|
227
|
+
return [
|
|
228
|
+
{
|
|
229
|
+
name: "client",
|
|
230
|
+
sourceFile,
|
|
231
|
+
manifestKey: "client.js",
|
|
232
|
+
htmlPath: discoverHtmlTemplateFor(cwd, "index") ?? "index.html",
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function discoverMultiClientEntries(cwd: string): readonly ClientEntry[] {
|
|
241
|
+
const srcDir = resolve(cwd, "src");
|
|
242
|
+
if (!existsSync(srcDir)) return [];
|
|
243
|
+
let files: readonly string[];
|
|
244
|
+
try {
|
|
245
|
+
files = readdirSync(srcDir);
|
|
246
|
+
} catch {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
const out: ClientEntry[] = [];
|
|
250
|
+
for (const file of files) {
|
|
251
|
+
const match = /^client-([a-z][a-z0-9-]*)\.tsx?$/.exec(file);
|
|
252
|
+
const suffix = match?.[1];
|
|
253
|
+
if (!suffix) continue;
|
|
254
|
+
const sourceFile = resolve(srcDir, file);
|
|
255
|
+
out.push({
|
|
256
|
+
name: suffix,
|
|
257
|
+
sourceFile,
|
|
258
|
+
manifestKey: `client-${suffix}.js`,
|
|
259
|
+
// "public"-entry serviert die default-page (index.html), sonst pro
|
|
260
|
+
// Suffix ein eigenes Template.
|
|
261
|
+
htmlPath:
|
|
262
|
+
suffix === "public"
|
|
263
|
+
? (discoverHtmlTemplateFor(cwd, "index") ?? "index.html")
|
|
264
|
+
: (discoverHtmlTemplateFor(cwd, suffix) ?? `${suffix}.html`),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function discoverHtmlTemplateFor(cwd: string, basename: string): string | undefined {
|
|
271
|
+
for (const candidate of [`${basename}.html`, `public/${basename}.html`]) {
|
|
272
|
+
const path = resolve(cwd, candidate);
|
|
273
|
+
if (existsSync(path)) return path;
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** @deprecated single-entry-Variante. Nutze discoverClientEntries. */
|
|
279
|
+
export function discoverClientEntry(cwd: string): string | undefined {
|
|
280
|
+
const entries = discoverClientEntries(cwd);
|
|
281
|
+
if (entries.length !== 1) return undefined;
|
|
282
|
+
const only = entries[0];
|
|
283
|
+
return only?.name === "client" ? only.sourceFile : undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function resolveStylesheetEntry(
|
|
287
|
+
cwd: string,
|
|
288
|
+
clientEntry: string | undefined,
|
|
289
|
+
override: BuildProdBundleOptions["stylesheet"],
|
|
290
|
+
): string | undefined {
|
|
291
|
+
if (override === false) return undefined;
|
|
292
|
+
if (typeof override === "string") return resolve(cwd, override);
|
|
293
|
+
|
|
294
|
+
// App-eigenes styles.css schlägt den Default.
|
|
295
|
+
const local = resolve(cwd, "src/styles.css");
|
|
296
|
+
if (existsSync(local)) return local;
|
|
297
|
+
|
|
298
|
+
// Sonst: nur wenn ein client da ist, fallback auf renderer-web/styles.css.
|
|
299
|
+
// Sample-Apps und Showcases nutzen das alle — gleiche Logik wie der dev-
|
|
300
|
+
// server, damit lokal/prod identisch bauen.
|
|
301
|
+
if (!clientEntry) return undefined;
|
|
302
|
+
|
|
303
|
+
if (!hasBun) return undefined;
|
|
304
|
+
try {
|
|
305
|
+
return (
|
|
306
|
+
globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }
|
|
307
|
+
).Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", cwd);
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// @internal — exported nur für Unit-Tests.
|
|
314
|
+
export function discoverHtmlTemplate(cwd: string): string | undefined {
|
|
315
|
+
for (const candidate of ["index.html", "public/index.html"]) {
|
|
316
|
+
const path = resolve(cwd, candidate);
|
|
317
|
+
if (existsSync(path)) return path;
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Build steps
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
async function runTailwindOnce(entry: string): Promise<string> {
|
|
327
|
+
if (!hasBun) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
"[kumiko build] Tailwind one-shot requires Bun (Bun.spawn) — run via `bun run …` or `yarn kumiko build`.",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "kumiko-build-tw-"));
|
|
333
|
+
const outPath = join(tmpDir, "styles.css");
|
|
334
|
+
// --minify: Tailwind-CLI default ist NICHT minified. Symmetric zum
|
|
335
|
+
// Bun.build minify-Flag — sonst ist das CSS in dist/ ~30 % größer als
|
|
336
|
+
// nötig (Whitespace, Kommentare, Newlines).
|
|
337
|
+
const proc = Bun.spawn(["bunx", "@tailwindcss/cli", "-i", entry, "-o", outPath, "--minify"], {
|
|
338
|
+
stdout: "inherit",
|
|
339
|
+
stderr: "inherit",
|
|
340
|
+
});
|
|
341
|
+
const code = await proc.exited;
|
|
342
|
+
if (code !== 0) {
|
|
343
|
+
throw new Error(`[kumiko build] tailwind exit ${code}`);
|
|
344
|
+
}
|
|
345
|
+
const css = await readFile(outPath, "utf8");
|
|
346
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
347
|
+
return css;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Multi-entry-build: ein Bun.build-Call mit allen entrypoints — shared
|
|
351
|
+
* chunks werden dedupliziert, hashes deterministisch. Returns map
|
|
352
|
+
* manifestKey → hashed-filename (basename, ohne /assets/-Prefix). */
|
|
353
|
+
async function buildClientBundles(
|
|
354
|
+
entries: readonly ClientEntry[],
|
|
355
|
+
outDir: string,
|
|
356
|
+
): Promise<Record<string, string>> {
|
|
357
|
+
if (!hasBun) {
|
|
358
|
+
throw new Error("[kumiko build] requires Bun — run via `bun run …` or `yarn kumiko build`.");
|
|
359
|
+
}
|
|
360
|
+
const built = await Bun.build({
|
|
361
|
+
entrypoints: entries.map((e) => e.sourceFile),
|
|
362
|
+
outdir: outDir,
|
|
363
|
+
target: "browser",
|
|
364
|
+
splitting: true,
|
|
365
|
+
minify: true,
|
|
366
|
+
// Keine Source-Maps in Prod: 1.6 MB+ Müll im Container, plus
|
|
367
|
+
// exposed Source-Code reverse-engineerable. Dev hat seine eigenen
|
|
368
|
+
// sourcemaps via create-kumiko-server.ts.
|
|
369
|
+
sourcemap: "none",
|
|
370
|
+
naming: {
|
|
371
|
+
entry: "[name]-[hash].[ext]",
|
|
372
|
+
chunk: "[name]-[hash].[ext]",
|
|
373
|
+
asset: "[name]-[hash].[ext]",
|
|
374
|
+
},
|
|
375
|
+
loader: ASSET_LOADERS,
|
|
376
|
+
define: {
|
|
377
|
+
"process.env.NODE_ENV": JSON.stringify("production"),
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
if (!built.success) {
|
|
381
|
+
const errs = built.logs.map((log) => String(log)).join("\n");
|
|
382
|
+
throw new Error(`[kumiko build] Bun.build failed:\n${errs}`);
|
|
383
|
+
}
|
|
384
|
+
const entryOutputs = built.outputs.filter((o) => o.kind === "entry-point");
|
|
385
|
+
if (entryOutputs.length !== entries.length) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`[kumiko build] expected ${entries.length} entry-point outputs, got ${entryOutputs.length}`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
// Bun.build benennt entry-files nach Source-Basename (ohne extension):
|
|
391
|
+
// `src/client-admin.tsx` → `client-admin-<hash>.js`. Wir mappen jedes
|
|
392
|
+
// entry-output zurück auf seinen ClientEntry via Basename-match.
|
|
393
|
+
const result: Record<string, string> = {};
|
|
394
|
+
for (const entry of entries) {
|
|
395
|
+
const baseName = (entry.sourceFile.split("/").pop() ?? "").replace(/\.tsx?$/, "");
|
|
396
|
+
const match = entryOutputs.find((o) => {
|
|
397
|
+
const outName = o.path.split("/").pop() ?? "";
|
|
398
|
+
return outName.startsWith(`${baseName}-`);
|
|
399
|
+
});
|
|
400
|
+
if (!match) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`[kumiko build] no entry-point output for "${entry.sourceFile}" (looked for "${baseName}-*.js")`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
result[entry.manifestKey] = match.path.split("/").pop() ?? match.path;
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function basenameOf(p: string): string {
|
|
411
|
+
return p.split("/").pop() ?? p;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function copyPublicFolder(
|
|
415
|
+
src: string,
|
|
416
|
+
dst: string,
|
|
417
|
+
templateBasenames: ReadonlySet<string>,
|
|
418
|
+
): Promise<void> {
|
|
419
|
+
// HTML-templates werden separat gerendert — nicht blind kopieren, sonst
|
|
420
|
+
// überschreibt das die injizierte Version. (z.B. index.html, admin.html
|
|
421
|
+
// bei multi-entry).
|
|
422
|
+
await cp(src, dst, {
|
|
423
|
+
recursive: true,
|
|
424
|
+
filter: (source) => {
|
|
425
|
+
const normalized = source.replace(/\\/g, "/");
|
|
426
|
+
const srcNormalized = src.replace(/\\/g, "/");
|
|
427
|
+
const base = normalized.startsWith(`${srcNormalized}/`)
|
|
428
|
+
? normalized.slice(srcNormalized.length + 1)
|
|
429
|
+
: "";
|
|
430
|
+
return !templateBasenames.has(base);
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// HTML render
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
async function renderHtml(
|
|
440
|
+
templatePath: string | undefined,
|
|
441
|
+
manifest: BuildManifest,
|
|
442
|
+
entry: ClientEntry,
|
|
443
|
+
): Promise<string> {
|
|
444
|
+
// Edge-Case: kein eigenes HTML-Template + Bun.build oder Tailwind hat
|
|
445
|
+
// Output produziert. DEFAULT_HTML hat keine Placeholder (vanilla
|
|
446
|
+
// template), also würde injectAssetTags eh fehlschlagen. Klarer Fehler
|
|
447
|
+
// mit Vorschlag-Snippet zum Reinkopieren.
|
|
448
|
+
if (!templatePath && Object.keys(manifest).length > 0) {
|
|
449
|
+
throw new Error(buildMissingTemplateError(manifest, entry));
|
|
450
|
+
}
|
|
451
|
+
const template = templatePath ? await readFile(templatePath, "utf8") : DEFAULT_HTML;
|
|
452
|
+
return injectAssetTags(template, manifest, entry);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function buildMissingTemplateError(manifest: BuildManifest, entry: ClientEntry): string {
|
|
456
|
+
const cssLine = manifest["styles.css"]
|
|
457
|
+
? ` <link rel="stylesheet" href="/styles.css" />\n`
|
|
458
|
+
: "";
|
|
459
|
+
const jsLine = manifest[entry.manifestKey]
|
|
460
|
+
? ` <script type="module" src="/${entry.manifestKey}"></script>\n`
|
|
461
|
+
: "";
|
|
462
|
+
return (
|
|
463
|
+
`[kumiko build] kein ${entry.htmlPath} gefunden, aber es gibt JS/CSS-Output.\n` +
|
|
464
|
+
`Leg ein public/${basenameOf(entry.htmlPath)} oder ${basenameOf(entry.htmlPath)} im App-Root an, z. B.:\n` +
|
|
465
|
+
`\n` +
|
|
466
|
+
`<!doctype html>\n` +
|
|
467
|
+
`<html>\n` +
|
|
468
|
+
` <head>\n` +
|
|
469
|
+
` <meta charset="utf-8" />\n` +
|
|
470
|
+
` <title>Meine App</title>\n` +
|
|
471
|
+
cssLine +
|
|
472
|
+
` </head>\n` +
|
|
473
|
+
` <body>\n` +
|
|
474
|
+
` <div id="root"></div>\n` +
|
|
475
|
+
jsLine +
|
|
476
|
+
` </body>\n` +
|
|
477
|
+
`</html>\n` +
|
|
478
|
+
`\n` +
|
|
479
|
+
`Der Build ersetzt /styles.css und /${entry.manifestKey} durch die gehashten URLs.`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// @internal — exported nur für Unit-Tests.
|
|
484
|
+
//
|
|
485
|
+
// Convention: das HTML-Template MUSS Placeholder-Tags für jedes Asset
|
|
486
|
+
// dieses Entries enthalten:
|
|
487
|
+
// - `<script src="/client.js">` für single-mode entry "client"
|
|
488
|
+
// - `<script src="/client-<name>.js">` für multi-mode entry "<name>"
|
|
489
|
+
// - `<link href="/styles.css">` für styles (gemeinsam über alle entries)
|
|
490
|
+
// Der Build ersetzt sie durch die gehashten URLs.
|
|
491
|
+
//
|
|
492
|
+
// Fehlt ein erwarteter Tag, wirft der Build einen Fehler mit dem exakten
|
|
493
|
+
// Snippet zum Reinkopieren — kein silent injection mehr, weil das den
|
|
494
|
+
// Diff zwischen Dev- und Prod-HTML unsichtbar macht.
|
|
495
|
+
export function injectAssetTags(html: string, manifest: BuildManifest, entry: ClientEntry): string {
|
|
496
|
+
let result = html;
|
|
497
|
+
|
|
498
|
+
const cssUrl = manifest["styles.css"];
|
|
499
|
+
if (cssUrl && !result.includes(cssUrl)) {
|
|
500
|
+
const placeholder = /<link\s+rel="stylesheet"\s+href="\/styles\.css"\s*\/?>/.exec(result);
|
|
501
|
+
if (!placeholder) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
buildMissingTagError({
|
|
504
|
+
htmlPath: entry.htmlPath,
|
|
505
|
+
assetKey: "styles.css",
|
|
506
|
+
tagSnippet: `<link rel="stylesheet" href="/styles.css" />`,
|
|
507
|
+
insertHint: "ins <head>",
|
|
508
|
+
hashedAssetHint: "/assets/styles-<hash>.css",
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
result = result.replace(placeholder[0], `<link rel="stylesheet" href="${cssUrl}" />`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const jsUrl = manifest[entry.manifestKey];
|
|
516
|
+
if (jsUrl && !result.includes(jsUrl)) {
|
|
517
|
+
// Placeholder-pattern: src="/client.js" oder src="/client-<name>.js"
|
|
518
|
+
const placeholderRx = new RegExp(
|
|
519
|
+
`<script\\b[^>]*src="\\/${entry.manifestKey.replace(/\./g, "\\.")}"[^>]*><\\/script>`,
|
|
520
|
+
);
|
|
521
|
+
const placeholder = placeholderRx.exec(result);
|
|
522
|
+
if (!placeholder) {
|
|
523
|
+
const baseAssetName = entry.manifestKey.replace(/\.js$/, "");
|
|
524
|
+
throw new Error(
|
|
525
|
+
buildMissingTagError({
|
|
526
|
+
htmlPath: entry.htmlPath,
|
|
527
|
+
assetKey: entry.manifestKey,
|
|
528
|
+
tagSnippet: `<script type="module" src="/${entry.manifestKey}"></script>`,
|
|
529
|
+
insertHint: "vor </body>",
|
|
530
|
+
hashedAssetHint: `/assets/${baseAssetName}-<hash>.js`,
|
|
531
|
+
}),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
result = result.replace(placeholder[0], `<script type="module" src="${jsUrl}"></script>`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return result;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Einheitliche Error-Form für fehlende Asset-Tags im HTML-Template
|
|
541
|
+
* (script-tag fürs JS-Bundle ODER stylesheet-link für Tailwind). */
|
|
542
|
+
function buildMissingTagError(args: {
|
|
543
|
+
readonly htmlPath: string;
|
|
544
|
+
readonly assetKey: string;
|
|
545
|
+
readonly tagSnippet: string;
|
|
546
|
+
readonly insertHint: string;
|
|
547
|
+
readonly hashedAssetHint: string;
|
|
548
|
+
}): string {
|
|
549
|
+
const tpl = basenameOf(args.htmlPath);
|
|
550
|
+
return (
|
|
551
|
+
`[kumiko build] ${tpl} hat keinen Entry-Tag für /${args.assetKey} — füg ${args.insertHint} ein:\n` +
|
|
552
|
+
`\n` +
|
|
553
|
+
` ${args.tagSnippet}\n` +
|
|
554
|
+
`\n` +
|
|
555
|
+
`Der Build ersetzt das durch ${args.hashedAssetHint}. Im Dev-Server liefert er die Datei direkt.`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
// Hash helpers
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
function shortHash(content: string | Uint8Array): string {
|
|
564
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 8);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// CLI output
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
/** Formatiert ein BuildResult als CLI-freundliche Mehrzeilen-Zusammenfassung
|
|
572
|
+
* mit ANSI-Farben. Wird sowohl von `kumiko build` als auch von dem
|
|
573
|
+
* hoisted `kumiko-build`-Bin verwendet, damit das Output konsistent ist. */
|
|
574
|
+
export function formatBuildResult(result: BuildResult, durationMs: number): string {
|
|
575
|
+
const dim = "\x1b[2m";
|
|
576
|
+
const green = "\x1b[32m";
|
|
577
|
+
const reset = "\x1b[0m";
|
|
578
|
+
const lines: string[] = [
|
|
579
|
+
"",
|
|
580
|
+
` ${green}✓${reset} built ${result.outDir} ${dim}(${durationMs}ms)${reset}`,
|
|
581
|
+
];
|
|
582
|
+
for (const [logical, hashed] of Object.entries(result.manifest)) {
|
|
583
|
+
lines.push(` ${dim}${logical.padEnd(14)}${reset} ${hashed}`);
|
|
584
|
+
}
|
|
585
|
+
lines.push("");
|
|
586
|
+
return lines.join("\n");
|
|
587
|
+
}
|