@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.
Files changed (44) hide show
  1. package/bin/kumiko-build.ts +85 -0
  2. package/bin/kumiko-dev.ts +90 -0
  3. package/package.json +45 -0
  4. package/src/__tests__/build-prod-bundle.integration.ts +265 -0
  5. package/src/__tests__/build-prod-bundle.test.ts +262 -0
  6. package/src/__tests__/cache-headers.test.ts +70 -0
  7. package/src/__tests__/classify-change.test.ts +87 -0
  8. package/src/__tests__/compose-features-wiring.integration.ts +352 -0
  9. package/src/__tests__/compose-features.test.ts +81 -0
  10. package/src/__tests__/crash-tracker.test.ts +89 -0
  11. package/src/__tests__/create-kumiko-server.integration.ts +286 -0
  12. package/src/__tests__/few-shot-corpus.test.ts +311 -0
  13. package/src/__tests__/inject-schema.test.ts +62 -0
  14. package/src/__tests__/resolve-stylesheet.test.ts +90 -0
  15. package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
  16. package/src/__tests__/run-prod-app-spec.test.ts +57 -0
  17. package/src/__tests__/run-prod-app.integration.ts +535 -0
  18. package/src/__tests__/scaffold-feature.test.ts +143 -0
  19. package/src/__tests__/try-hono-first.test.ts +63 -0
  20. package/src/build-prod-bundle.ts +587 -0
  21. package/src/build-server-bundle.ts +308 -0
  22. package/src/build.ts +28 -0
  23. package/src/codegen/__tests__/run-codegen.test.ts +494 -0
  24. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
  25. package/src/codegen/__tests__/watch.test.ts +186 -0
  26. package/src/codegen/index.ts +17 -0
  27. package/src/codegen/render.ts +225 -0
  28. package/src/codegen/run-codegen.ts +157 -0
  29. package/src/codegen/scan-events.ts +574 -0
  30. package/src/codegen/watch.ts +127 -0
  31. package/src/compose-features.ts +128 -0
  32. package/src/crash-tracker.ts +56 -0
  33. package/src/create-kumiko-server.ts +1010 -0
  34. package/src/drizzle-config.ts +44 -0
  35. package/src/drizzle-tables-auth-mode.ts +32 -0
  36. package/src/drizzle-tables-minimal.ts +22 -0
  37. package/src/few-shot-corpus.ts +369 -0
  38. package/src/index.ts +57 -0
  39. package/src/inject-schema.ts +24 -0
  40. package/src/resolve-tailwind-cli.ts +28 -0
  41. package/src/run-dev-app.ts +290 -0
  42. package/src/run-prod-app.ts +892 -0
  43. package/src/scaffold-feature.ts +226 -0
  44. package/src/try-hono-first.ts +46 -0
@@ -0,0 +1,1010 @@
1
+ // Dev server bootstrap. Wires the real Kumiko stack behind a Bun.serve
2
+ // shell that also bundles the client, serves it at /client.js, mints
3
+ // a JWT for a dev-admin on GET /, and broadcasts SSE reloads when
4
+ // source files change. One import + one call is enough for any
5
+ // sample's server.ts — the 150-line boilerplate of pre-dev-server
6
+ // days lives here now.
7
+ //
8
+ // Not for production:
9
+ // - auto-mints a JWT for TestUsers.admin on every GET / (anyone
10
+ // hitting the server ends up as admin)
11
+ // - bundles the client in-process (prod uses a prebuilt dist)
12
+ // - no rate-limit, no helmet, no secure-cookie flags
13
+ //
14
+ // The companion prod entry will land at `@cosmicdrift/kumiko-framework/server`
15
+ // with a different options shape (clientDist, auth config, db url).
16
+
17
+ import { spawn } from "node:child_process";
18
+ import { existsSync, mkdtempSync, statSync } from "node:fs";
19
+ import { readFile, watch } from "node:fs/promises";
20
+ import { tmpdir } from "node:os";
21
+ import { join, resolve } from "node:path";
22
+ import { type AuthRoutesConfig, generateToken } from "@cosmicdrift/kumiko-framework/api";
23
+ import { buildAppSchema, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
24
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
25
+ import {
26
+ ensureEntityTable,
27
+ setupTestStack,
28
+ type TestStack,
29
+ type TestStackOptions,
30
+ TestUsers,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { injectSchema } from "./inject-schema";
33
+ import { resolveTailwindCli } from "./resolve-tailwind-cli";
34
+ import { buildBunServeOptions } from "./run-prod-app";
35
+ import { tryHonoFirst } from "./try-hono-first";
36
+
37
+ // Runtime-detection. The dev-server is meant to run under Bun (Kumiko's
38
+ // target runtime), but the test-suite runs under vitest on Node — we
39
+ // gate every Bun.* call so the module at least LOADS under Node, and
40
+ // tests drive the fetch-handler directly instead of going through
41
+ // Bun.serve + real sockets.
42
+ const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
43
+
44
+ // Bun.serve returns a parametrised Server<WebSocketData>; we don't
45
+ // touch WebSockets here, so the narrow `unknown` binding is plenty.
46
+ // `Bun` isn't declared in Node types, so we fall back to `unknown`
47
+ // and only resolve the type when Bun is actually around.
48
+ type BunServer = typeof Bun extends undefined ? unknown : ReturnType<typeof Bun.serve>;
49
+
50
+ // biome-ignore lint/suspicious/noConsole: dev-server status logging
51
+ const logInfo = (msg: string): void => console.log(msg);
52
+ // biome-ignore lint/suspicious/noConsole: dev-server error logging
53
+ const logError = (...args: unknown[]): void => console.error(...args);
54
+
55
+ /** Multi-Entry-Mode für Apps die mehrere getrennte Bundles ausliefern
56
+ * (z.B. publicstatus: `admin.<base>` lädt Admin-UI, sonst Public-Page).
57
+ *
58
+ * Spiegelt die Convention von kumiko-build (`src/client-<name>.tsx`) und
59
+ * serviert `/client-<name>.js` per HTTP. Multi-Entry ist mutually
60
+ * exclusive mit `clientEntry`. Wer Multi-Entry nutzt MUSS auch
61
+ * `hostDispatch` setzen — sonst weiß der Server nicht welches HTML
62
+ * er rausgeben soll. */
63
+ export type DevClientEntry = {
64
+ /** Logical Name. Frei wählbar; Convention: gleicher Suffix wie
65
+ * `src/client-<name>.tsx` damit der Build identische Asset-URLs
66
+ * liefert (`/client-<name>.js`). */
67
+ readonly name: string;
68
+ /** Absoluter Pfad zur Browser-Entry-Datei. */
69
+ readonly sourceFile: string;
70
+ /** Optional eigenes HTML-Template für diesen Entry. Wenn nicht gesetzt,
71
+ * wird `htmlPath` (das default-Template) für alle Entries genutzt. */
72
+ readonly htmlPath?: string;
73
+ };
74
+
75
+ /** Discriminated-Union, identisch zur Form von `runProdApp.hostDispatch`.
76
+ * Damit kann Dev/Prod-Routing 1:1 gespiegelt werden — ein Apex-404 in
77
+ * Prod ist ein Apex-404 in Dev (mit `/etc/hosts`-Eintrag für die
78
+ * betroffene Domain). Schema-Inject ist pro Response steuerbar — ein
79
+ * Public-Bundle leakt das Admin-Schema nicht, auch nicht in Dev. */
80
+ export type DevHostDispatchResult =
81
+ | {
82
+ readonly kind: "html";
83
+ readonly entryName: string;
84
+ /** Default: true. Setze `false` für Public-Routes — analog zu
85
+ * prod-`injectSchema:false` für Anonymous-Visitors. */
86
+ readonly injectSchema?: boolean;
87
+ }
88
+ | {
89
+ /** Static-HTML: liefert eine Datei wortwörtlich, kein Bundle-Inject,
90
+ * kein Schema-Inject. Pendant zu prod's `{ kind: "html", file: ...,
91
+ * injectSchema: false }` für Marketing-/Apex-Pages die kein React
92
+ * brauchen. Pfad relativ zum Server-CWD. */
93
+ readonly kind: "static-html";
94
+ readonly file: string;
95
+ }
96
+ | { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 }
97
+ | { readonly kind: "not-found" };
98
+
99
+ /** Picks an entry by inspecting the incoming request. Wird von
100
+ * Multi-Entry-Apps gesetzt; im Single-Entry-Mode irrelevant. */
101
+ export type DevHostDispatch = (req: Request) => DevHostDispatchResult;
102
+
103
+ export type CreateKumikoServerOptions = {
104
+ /** Features whose entities, handlers, and screens get wired into the
105
+ * dev stack. Pass every feature the app is supposed to run. */
106
+ readonly features: readonly FeatureDefinition[];
107
+ /** Absolute path to the browser entry module. The dev-server runs
108
+ * `Bun.build` on it and serves the output at `/client.js`. Omit to
109
+ * run a headless API-only dev-stack (rare — every sample has one).
110
+ * Mutually exclusive mit `clientEntries`. */
111
+ readonly clientEntry?: string;
112
+ /** Multi-Entry-Mode: mehrere getrennte Bundles, jeweils unter
113
+ * `/client-<name>.js`. Mutually exclusive mit `clientEntry`. Setze
114
+ * `hostDispatch` mit, sonst bleibt unklar welches Template zurück-
115
+ * geht. */
116
+ readonly clientEntries?: readonly DevClientEntry[];
117
+ /** Multi-Entry-Mode: Routing pro Request. Inspiziert `Host` (oder
118
+ * was auch immer) und liefert eine Discriminated-Union zurück
119
+ * (html → entry-bundle, redirect → 30x, not-found → 404).
120
+ * Symmetric zu `runProdApp.hostDispatch` damit dev/prod-Drift
121
+ * beim Routing unmöglich ist. */
122
+ readonly hostDispatch?: DevHostDispatch;
123
+ /** @internal — ersetzt `Bun.build` für Tests. Default ruft die echte
124
+ * Bun-Toolchain. Tests unter Node injizieren einen Stub damit der
125
+ * Routing-Pfad treibbar bleibt ohne Bun.build aufzurufen.
126
+ * KEIN Public-API-Surface — präfixiert mit `_` damit Konsumenten
127
+ * wissen dass das ein Test-Seam ist. */
128
+ readonly _buildBundle?: (sourceFile: string) => Promise<{
129
+ readonly js: string;
130
+ readonly map: string;
131
+ }>;
132
+ /** Absolute path to the CSS entry (typischerweise styles.css mit
133
+ * @import "tailwindcss"). Der dev-server startet dann den
134
+ * Tailwind-CLI als watcher und servt das kompilierte CSS unter
135
+ * /styles.css.
136
+ *
137
+ * Wenn `undefined` UND `clientEntry` gesetzt: resolve die
138
+ * `@cosmicdrift/kumiko-renderer-web/styles.css`-Default via Package-Exports.
139
+ * So muss kein Sample mehr den monorepo-relativen Pfad
140
+ * ../../packages/renderer-web/src/styles.css hardcoden.
141
+ *
142
+ * `stylesheet: false` → CSS-Pipeline explizit deaktivieren. */
143
+ readonly stylesheet?: string | false;
144
+ /** Optional HTML template served at `GET /`. The dev-server injects
145
+ * a `<script src="/client.js">` and a reload-listener snippet into
146
+ * `</body>` if those aren't already there. Defaults to a minimal
147
+ * empty-body document — enough to boot the client. */
148
+ readonly htmlPath?: string;
149
+ /** Port to listen on. Default 4173. Overridable via `PORT` env. */
150
+ readonly port?: number;
151
+ /** Extra directories to watch for reload triggers. The entry's
152
+ * directory is watched automatically. */
153
+ readonly watchDirs?: readonly string[];
154
+ /** When false, no SIGINT/SIGTERM handlers are installed. Tests set
155
+ * this so repeated `createKumikoServer` calls don't accumulate
156
+ * listeners on the process. Default true (dev-server behaviour). */
157
+ readonly installSignalHandlers?: boolean;
158
+ /** Auth-Route-Config (login, tenants, switch-tenant, logout). Wenn
159
+ * gesetzt wird die Auto-JWT-Mint auf GET / abgeschaltet — der
160
+ * Client ist dann selbst fürs Login zuständig. Zur echten Wirkung
161
+ * brauchen die dazugehörigen Features (user/tenant/auth-email-
162
+ * password) via `features` drin sein. */
163
+ readonly auth?: AuthRoutesConfig;
164
+ /** Extra-AppContext-Keys (z.B. configResolver für config-feature).
165
+ * Wird an setupTestStack weitergereicht. Siehe TestStackOptions
166
+ * für die erlaubten Shapes (object oder factory-function). */
167
+ readonly extraContext?: TestStackOptions["extraContext"];
168
+ /** Anonymous-Access aktivieren — Requests ohne JWT werden als
169
+ * Pseudo-User mit Rolle `anonymous` durchgelassen, sofern der
170
+ * Handler `roles: ["anonymous"]` deklariert. Tenant-Resolution per
171
+ * Header/Cookie/Default; siehe AnonymousAccessConfig. */
172
+ readonly anonymousAccess?: TestStackOptions["anonymousAccess"];
173
+ /** Wird nach dem Aufsetzen der Entity-Tabellen aufgerufen. Hook für
174
+ * non-entity-tables (pushTables) und Seeding (admin user, initial
175
+ * tenant, …). Muss idempotent sein — im persistent-DB-Modus läuft
176
+ * es bei jedem Boot. */
177
+ readonly onAfterSetup?: (stack: TestStack) => Promise<void>;
178
+ /** Mount-Point für app-eigene HTTP-Routes außerhalb des Dispatcher-
179
+ * Systems — symmetrisch zum runProdApp.extraRoutes. Wird VOR der
180
+ * Static/HTML-Auslieferung aufgerufen, sodass eigene GETs (/feed.xml,
181
+ * /og-image, …) Vorrang vor dem Dev-Asset-Pfad haben. `deps` statt
182
+ * `ctx` weil dies kein HandlerContext ist — kein user/tenant. */
183
+ readonly extraRoutes?: (
184
+ app: import("hono").Hono,
185
+ deps: { db: TestStack["db"]; redis: TestStack["redis"] },
186
+ ) => void;
187
+ };
188
+
189
+ export type KumikoServerHandle = {
190
+ /** The fetch handler that routes a Request through the dev-server
191
+ * layer (HTML, /client.js, /_reload, SSE) and falls back to the
192
+ * underlying Kumiko stack. Tests call this directly to exercise
193
+ * the routing without going through real sockets. */
194
+ readonly fetch: (req: Request) => Promise<Response>;
195
+ /** Bun.serve instance. `undefined` when running outside Bun (e.g.
196
+ * in vitest under Node) — the handle still works via `.fetch`. */
197
+ readonly server: BunServer | undefined;
198
+ readonly stack: TestStack;
199
+ /** Stops the server and tears down the stack (DB + redis). */
200
+ readonly stop: () => Promise<void>;
201
+ };
202
+
203
+ const CSRF_COOKIE = "kumiko_csrf";
204
+ const AUTH_COOKIE = "kumiko_auth";
205
+
206
+ // Reload snippet injected into every page-load so the browser
207
+ // subscribes to /_reload without the HTML needing to hard-code it.
208
+ //
209
+ // Zwei reload-Trigger:
210
+ // - explizites `reload`-Event vom Server beim hot-reload (rebuild + send)
211
+ // - implizites: jede SSE-Connection bekommt beim Connect ein `boot`-Event
212
+ // mit der bootId des aktuellen Server-Process. Das Snippet merkt sich
213
+ // die erste bootId; wenn nach einem Reconnect (Server-Restart!) eine
214
+ // ANDERE bootId kommt, refresh — sonst bleibt der Browser ewig auf
215
+ // dem alten Bundle hängen wenn der Watcher classifyChange="restart"
216
+ // gewählt hat oder der User Ctrl-C/yarn dev gemacht hat.
217
+ const RELOAD_SNIPPET = `
218
+ <script>
219
+ (() => {
220
+ const es = new EventSource("/_reload");
221
+ let firstBootId = null;
222
+ es.addEventListener("boot", (e) => {
223
+ if (firstBootId === null) {
224
+ firstBootId = e.data;
225
+ } else if (firstBootId !== e.data) {
226
+ location.reload();
227
+ }
228
+ });
229
+ es.addEventListener("reload", () => location.reload());
230
+ })();
231
+ </script>
232
+ `;
233
+
234
+ // Minimal HTML when the caller didn't hand one in. `#root` is the
235
+ // default mount target for `createKumikoApp`, so the one-line client
236
+ // can attach without the sample having to ship its own template.
237
+ const DEFAULT_HTML = `<!doctype html>
238
+ <html lang="en">
239
+ <head>
240
+ <meta charset="utf-8" />
241
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
242
+ <title>Kumiko</title>
243
+ </head>
244
+ <body>
245
+ <div id="root"></div>
246
+ <script src="/client.js"></script>
247
+ </body>
248
+ </html>
249
+ `;
250
+
251
+ type ClientBundle = { readonly js: string; readonly map: string };
252
+
253
+ async function buildClient(entry: string): Promise<ClientBundle> {
254
+ if (!hasBun) {
255
+ throw new Error(
256
+ "[kumiko-server] clientEntry is only supported under Bun — Bun.build is unavailable in this runtime.",
257
+ );
258
+ }
259
+ const unminified = process.env["KUMIKO_DEV_UNMINIFIED"] === "1";
260
+ const built = await Bun.build({
261
+ entrypoints: [entry],
262
+ target: "browser",
263
+ minify: !unminified,
264
+ sourcemap: "linked",
265
+ });
266
+ if (!built.success) {
267
+ logError("[kumiko-server] client bundle failed:");
268
+ for (const log of built.logs) logError(log);
269
+ throw new Error("client bundle failed");
270
+ }
271
+ const jsOutput = built.outputs.find((o) => o.path.endsWith(".js"));
272
+ const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
273
+ if (!jsOutput) throw new Error("[kumiko-server] client bundle produced no .js output");
274
+ return {
275
+ js: await jsOutput.text(),
276
+ map: mapOutput ? await mapOutput.text() : "",
277
+ };
278
+ }
279
+
280
+ type ReloadClient = {
281
+ readonly controller: ReadableStreamDefaultController<Uint8Array>;
282
+ readonly encoder: TextEncoder;
283
+ closed: boolean;
284
+ };
285
+
286
+ function injectReload(html: string): string {
287
+ if (html.includes("/_reload")) return html;
288
+ return html.includes("</body>")
289
+ ? html.replace("</body>", `${RELOAD_SNIPPET}</body>`)
290
+ : html + RELOAD_SNIPPET;
291
+ }
292
+
293
+ // Injiziert <link rel="stylesheet" href="/styles.css"> in den <head>,
294
+ // wenn es noch nicht da ist. Wird nur aufgerufen wenn die App das
295
+ // stylesheet-Option genutzt hat — andernfalls kommt keine CSS-Route.
296
+ function injectStylesheet(html: string): string {
297
+ if (html.includes('href="/styles.css"')) return html;
298
+ const link = '<link rel="stylesheet" href="/styles.css" />';
299
+ return html.includes("</head>")
300
+ ? html.replace("</head>", ` ${link}\n</head>`)
301
+ : `${link}${html}`;
302
+ }
303
+
304
+ // injectSchema lebt in `./inject-schema.ts` damit dev-server + prod-
305
+ // server denselben Inject-Pfad nutzen.
306
+
307
+ async function watchDir(
308
+ dir: string,
309
+ onChange: (filename: string) => void,
310
+ signal: AbortSignal,
311
+ ): Promise<void> {
312
+ // AbortSignal wird vom Server-stop() ausgelöst: ohne den Abort liefe
313
+ // die for-await-Schleife bis zum Process-Exit weiter. Im Test-Setup
314
+ // (afterEach räumt tmpdir mit rmSync auf) sähe der Watcher dann das
315
+ // rmSync, klassifizierte's als "restart" und riefe process.exit(75) —
316
+ // bubbles als unhandled error in vitest hoch.
317
+ const watcher = watch(dir, { recursive: true, signal });
318
+ try {
319
+ for await (const ev of watcher) {
320
+ if (ev.filename) onChange(ev.filename);
321
+ }
322
+ } catch (err) {
323
+ // signal.abort() wirft AbortError aus dem async-iterator; das ist
324
+ // gewollt und kein Fehler. Andere Errors weiterreichen.
325
+ if ((err as { name?: string }).name === "AbortError") return;
326
+ throw err;
327
+ }
328
+ }
329
+
330
+ // Klassifiziert eine geänderte Datei: `hot-reload` für Client-Side
331
+ // (Browser-Bundle rebuild + reload), `restart` für Server-Side (Bun-
332
+ // Module-Cache zwingt einen Process-Restart durch), `ignore` für
333
+ // alles was den Server nicht beeinflusst (Tests, .css, .json…).
334
+ //
335
+ // Heuristik:
336
+ // - Tests (`__tests__/` oder `*.test.ts(x)`) → ignore
337
+ // - `.ts` / `.tsx` außer Tests:
338
+ // - Client-side-Dirs (`/web/`, `/admin/`, `/public/`, `/client/`)
339
+ // oder die client-entry-Datei selbst → hot-reload
340
+ // - sonst → restart (könnte Schema/Feature-Definition sein)
341
+ // - andere Dateitypen → ignore (kein TS rebuild nötig)
342
+ //
343
+ // Warum mehrere Dirs für client-side: in Kumiko-Samples gibt's keine
344
+ // Convention. publicstatus splittet `/admin/` (Admin-Bundle) und
345
+ // `/public/` (Anonymous-Bundle); beammycar nutzt `/web/` für seine
346
+ // Feature-Web-Code; ältere Samples haben einfach `/client.tsx` neben
347
+ // dem Server. Der Watcher muss alle drei verstehen, sonst löst ein
348
+ // Edit der Bridge-Component einen kompletten Server-Restart aus —
349
+ // kostet 2-3s, droppt die Test-DB im ephemeral-Modus, reseed läuft
350
+ // erneut. Ineffektiv und für der User verwirrend.
351
+ //
352
+ // Exportiert für Tests; intern wird's von der Watcher-Loop gerufen.
353
+ export function classifyChange(filename: string): "restart" | "hot-reload" | "ignore" {
354
+ if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return "ignore";
355
+ if (filename.includes("__tests__")) return "ignore";
356
+ if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx")) return "ignore";
357
+ if (filename.endsWith(".integration.ts") || filename.endsWith(".e2e.ts")) return "ignore";
358
+ // Plattformpfad-agnostisch: prüfen auf POSIX und Windows-Trenner.
359
+ // Wir matchen sowohl `<sep><dir><sep>` als auch trailing-`<sep><dir>`
360
+ // (für Watcher-Filenames die als relativer Pfad ankommen).
361
+ const clientSubdirs = ["web", "admin", "public", "client"];
362
+ for (const dir of clientSubdirs) {
363
+ if (
364
+ filename.includes(`/${dir}/`) ||
365
+ filename.includes(`\\${dir}\\`) ||
366
+ filename.startsWith(`${dir}/`) ||
367
+ filename.startsWith(`${dir}\\`)
368
+ ) {
369
+ return "hot-reload";
370
+ }
371
+ }
372
+ if (filename.endsWith("/client.tsx") || filename.endsWith("/client.ts")) {
373
+ return "hot-reload";
374
+ }
375
+ return "restart";
376
+ }
377
+
378
+ // Expandiert watchDirs-Patterns auf konkrete Verzeichnisse. Ein Eintrag
379
+ // ohne `*` wird als gewöhnlicher Pfad resolved; mit `*` wird er per
380
+ // glob expanded und alle Treffer die Verzeichnisse sind übernommen.
381
+ // Erlaubt z.B. `"../../../packages/*/src"` statt vier hart-kodierte
382
+ // Pfade. Glob ist sync — wird einmal beim Boot ausgewertet, nicht
383
+ // während der Watcher läuft.
384
+ function expandWatchPatterns(patterns: readonly string[]): string[] {
385
+ const out: string[] = [];
386
+ for (const p of patterns) {
387
+ if (!p.includes("*")) {
388
+ out.push(resolve(p));
389
+ continue;
390
+ }
391
+ // expandWatchPatterns wird nur unter Bun aufgerufen (createKumikoServer
392
+ // ist Bun-only); der ?.! -dance ist nötig weil TS Bun nicht im
393
+ // globalThis-default sieht. Wenn Bun fehlt, ist der Aufrufstapel eh
394
+ // schon fail-fast unten in Bun.serve.
395
+ const BunRef = (
396
+ globalThis as {
397
+ Bun?: {
398
+ Glob: new (
399
+ p: string,
400
+ ) => { scanSync: (opts: { onlyFiles: false; cwd: string }) => Iterable<string> };
401
+ };
402
+ }
403
+ ).Bun;
404
+ if (!BunRef) throw new Error("expandWatchPatterns requires Bun.Glob");
405
+ const glob = new BunRef.Glob(p);
406
+ const matches = Array.from(glob.scanSync({ onlyFiles: false, cwd: process.cwd() }));
407
+ for (const m of matches) {
408
+ const abs = resolve(m);
409
+ try {
410
+ if (statSync(abs).isDirectory()) out.push(abs);
411
+ } catch {
412
+ // ignore unreadable matches — typisch defekte Symlinks
413
+ }
414
+ }
415
+ }
416
+ return out;
417
+ }
418
+
419
+ // Resolve den Pfad zur Tailwind-Entry-CSS. Mehrere Fälle:
420
+ // - Explicit string → den resolved'en absoluten Pfad verwenden
421
+ // - false → CSS-Pipeline aus (undefined zurück)
422
+ // - undefined + client(s):
423
+ // 1. App-eigenes src/styles.css (App-Theme-Override) wenn vorhanden
424
+ // 2. Sonst Default `@cosmicdrift/kumiko-renderer-web/styles.css` über Package-Exports
425
+ // - undefined + kein clientEntry/clientEntries: undefined (keine CSS nötig)
426
+ //
427
+ // Auto-Detection von src/styles.css spiegelt die Logik aus
428
+ // build-prod-bundle:resolveStylesheetEntry — damit dev und prod identisch
429
+ // resolven. Ohne diesen Check müsste jede App `stylesheet: "./src/styles.css"`
430
+ // setzen, sonst greift in dev der renderer-web-Default und Brand-Tokens
431
+ // werden ignoriert (DX-Falle).
432
+ //
433
+ // @internal — exportiert nur für Unit-Tests, nicht aus dem Package-Index
434
+ // re-exportiert. Konsumenten gehen ausschließlich über die `stylesheet`-
435
+ // Option der createKumikoServer-Aufrufstelle.
436
+ export function resolveStylesheet(options: CreateKumikoServerOptions): string | undefined {
437
+ if (options.stylesheet === false) return undefined;
438
+ if (typeof options.stylesheet === "string") return resolve(options.stylesheet);
439
+ const hasAnyEntry =
440
+ options.clientEntry !== undefined ||
441
+ (options.clientEntries !== undefined && options.clientEntries.length > 0);
442
+ if (!hasAnyEntry) return undefined;
443
+
444
+ // App-eigenes src/styles.css schlägt den renderer-web-Default — gleiche
445
+ // Logik wie kumiko-build, damit lokal/prod identisch bauen.
446
+ const local = resolve(process.cwd(), "src/styles.css");
447
+ if (existsSync(local)) return local;
448
+
449
+ // Bun.resolveSync folgt Package-Exports — "./styles.css" in renderer-web's
450
+ // package.json. Das Monorepo auflöst direkt auf den Workspace-File, eine
451
+ // installierte Fremd-App auf den node_modules-File. Kein `fileURLToPath`
452
+ // nötig, Bun gibt schon einen absoluten FS-Pfad zurück.
453
+ if (!hasBun) {
454
+ // Unit-Tests unter vitest/Node landen hier. Ohne Bun können wir die
455
+ // Package-Export-Resolution nicht machen — und im Test-Kontext gibt's
456
+ // keine echte Tailwind-Pipeline. Skip still, keine Fehlermeldung nötig.
457
+ return undefined;
458
+ }
459
+ try {
460
+ return (
461
+ globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }
462
+ ).Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", process.cwd());
463
+ } catch (err) {
464
+ logError(
465
+ "[kumiko-server] couldn't auto-resolve @cosmicdrift/kumiko-renderer-web/styles.css — " +
466
+ "pass `stylesheet: <path>` or `stylesheet: false` explicitly.",
467
+ err,
468
+ );
469
+ return undefined;
470
+ }
471
+ }
472
+
473
+ // Startet den Tailwind-CLI als watch-Prozess. Failure-Mode ist
474
+ // non-fatal (return undefined): kann der CLI nicht resolved werden
475
+ // oder failt der initial-Build (z.B. flakiges Netz, fehlende
476
+ // Dependency), bootet der Server ohne CSS statt zu sterben.
477
+ async function startTailwindWatcher(
478
+ entryCss: string,
479
+ ): Promise<{ outPath: string; kill: () => void } | undefined> {
480
+ const bunResolver = hasBun
481
+ ? (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }).Bun
482
+ : undefined;
483
+ const cliPath = resolveTailwindCli({ bun: bunResolver, cwd: process.cwd() });
484
+ if (cliPath === undefined) {
485
+ logError(
486
+ "[kumiko-server] @tailwindcss/cli nicht auflösbar — booting ohne CSS-Pipeline. " +
487
+ "`bun install` und Restart, um Styles zu aktivieren.",
488
+ );
489
+ return undefined;
490
+ }
491
+ const outDir = mkdtempSync(join(tmpdir(), "kumiko-tw-"));
492
+ const outPath = join(outDir, "styles.css");
493
+ logInfo(`[kumiko-server] tailwind watcher → ${outPath}`);
494
+ const bunPath = process.argv[0] ?? "bun";
495
+ // Initial-Build blockend, damit der erste /styles.css-Request kein
496
+ // 404 bekommt. Dann den watcher im Hintergrund mit unref() — sonst
497
+ // hing er beim Parent-Crash als orphan-process.
498
+ try {
499
+ await new Promise<void>((resolvePromise, rejectPromise) => {
500
+ const child = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath], {
501
+ stdio: "inherit",
502
+ });
503
+ child.on("exit", (code) => {
504
+ if (code === 0) resolvePromise();
505
+ else rejectPromise(new Error(`tailwind one-shot-build exit ${code}`));
506
+ });
507
+ child.on("error", rejectPromise);
508
+ });
509
+ } catch (err) {
510
+ logError("[kumiko-server] tailwind one-shot-build fehlgeschlagen — booting ohne CSS:", err);
511
+ return undefined;
512
+ }
513
+ const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], {
514
+ stdio: "inherit",
515
+ });
516
+ watcher.unref();
517
+ return {
518
+ outPath,
519
+ kill: () => {
520
+ try {
521
+ watcher.kill("SIGTERM");
522
+ } catch {
523
+ // schon tot oder nie gestartet — nicht weiter relevant
524
+ }
525
+ },
526
+ };
527
+ }
528
+
529
+ // Create all entity tables declared by the given features. Uses
530
+ // ensureEntityTable so a persistent DB (KUMIKO_DEV_DB_NAME) can
531
+ // reuse tables from the previous boot without the caller having to
532
+ // check.
533
+ async function createEntityTablesForFeatures(
534
+ stack: TestStack,
535
+ features: readonly FeatureDefinition[],
536
+ ): Promise<void> {
537
+ for (const feature of features) {
538
+ for (const [entityName, entity] of Object.entries(feature.entities)) {
539
+ const created = await ensureEntityTable(stack.db, entity, entityName);
540
+ if (!created) {
541
+ logInfo(
542
+ `[kumiko-server] table ${entity.table ?? entityName} already exists — skipping create`,
543
+ );
544
+ }
545
+ }
546
+ }
547
+ }
548
+
549
+ /** @internal — normalisierte Client-Entry-Form, einheitlich über
550
+ * Single-Mode (`clientEntry`) und Multi-Mode (`clientEntries`). */
551
+ type NormalizedEntry = {
552
+ readonly name: string;
553
+ readonly sourceFile: string;
554
+ readonly htmlPath: string | undefined;
555
+ };
556
+
557
+ /** URL-Pfad unter dem ein Entry ausgeliefert wird. "client" → /client.js
558
+ * (Single-Mode-Default), sonst "/client-<name>.js". Single-Source-of-Truth
559
+ * damit Routing + Logging dieselbe Konvention nutzen. */
560
+ function assetPathFor(entryName: string): string {
561
+ return entryName === "client" ? "/client.js" : `/client-${entryName}.js`;
562
+ }
563
+
564
+ function normalizeEntries(options: CreateKumikoServerOptions): readonly NormalizedEntry[] {
565
+ if (options.clientEntries !== undefined && options.clientEntry !== undefined) {
566
+ throw new Error(
567
+ "[kumiko-server] clientEntry und clientEntries sind mutually exclusive — wähle eins",
568
+ );
569
+ }
570
+ if (options.clientEntries !== undefined && options.clientEntries.length > 0) {
571
+ if (options.hostDispatch === undefined) {
572
+ throw new Error(
573
+ "[kumiko-server] clientEntries braucht hostDispatch — sonst weiß der Server nicht welches Template er liefern soll",
574
+ );
575
+ }
576
+ return options.clientEntries.map((e) => ({
577
+ name: e.name,
578
+ sourceFile: resolve(e.sourceFile),
579
+ htmlPath: e.htmlPath,
580
+ }));
581
+ }
582
+ if (options.clientEntry !== undefined) {
583
+ return [{ name: "client", sourceFile: resolve(options.clientEntry), htmlPath: undefined }];
584
+ }
585
+ return [];
586
+ }
587
+
588
+ export async function createKumikoServer(
589
+ options: CreateKumikoServerOptions,
590
+ ): Promise<KumikoServerHandle> {
591
+ const port = options.port ?? Number(process.env["PORT"] ?? 4173);
592
+
593
+ // --- client bundles (single-entry oder multi-entry über dieselbe Map) ---
594
+ const entries = normalizeEntries(options);
595
+ const buildBundle = options._buildBundle ?? buildClient;
596
+ const clientBundles = new Map<string, ClientBundle>();
597
+ for (const e of entries) {
598
+ const bundle = await buildBundle(e.sourceFile);
599
+ clientBundles.set(e.name, bundle);
600
+ logInfo(
601
+ `[kumiko-server] client bundle ${e.name}: ${bundle.js.length.toLocaleString()} bytes` +
602
+ (bundle.map ? ` (+ ${bundle.map.length.toLocaleString()} bytes sourcemap)` : ""),
603
+ );
604
+ }
605
+
606
+ // --- Tailwind stylesheet (optional) ---
607
+ // Tailwind-CLI läuft im watch-mode, schreibt in ein temp-file, der
608
+ // dev-server liest es bei jedem /styles.css-Request frisch. Nicht
609
+ // Super-Performant, aber keine in-memory-Signal-Gymnastik nötig
610
+ // und der Browser-Reload kommt eh nur nach Bundle-Rebuild.
611
+ //
612
+ // Default-Resolution: wenn kein `stylesheet` übergeben und ein
613
+ // `clientEntry` existiert, resolve die styles.css aus
614
+ // `@cosmicdrift/kumiko-renderer-web` via Package-Exports. Bun.resolveSync liefert
615
+ // einen absoluten Pfad — funktioniert sowohl im Monorepo (Workspace-
616
+ // Link) als auch in einer installierten Fremd-App (node_modules).
617
+ let stylesheetPath: string | undefined;
618
+ let killTailwind: (() => void) | undefined;
619
+ const resolvedStylesheet = resolveStylesheet(options);
620
+ if (resolvedStylesheet !== undefined) {
621
+ const handle = await startTailwindWatcher(resolvedStylesheet);
622
+ if (handle !== undefined) {
623
+ stylesheetPath = handle.outPath;
624
+ killTailwind = handle.kill;
625
+ }
626
+ }
627
+
628
+ // --- HTML templates ---
629
+ // Single-Entry: ein Template (htmlPath oder DEFAULT_HTML) für alles.
630
+ // Multi-Entry: pro Entry ein Template (entry.htmlPath ?? options.htmlPath
631
+ // ?? DEFAULT_HTML). Der hostDispatch wählt zur Request-Zeit.
632
+ const defaultTemplate =
633
+ options.htmlPath !== undefined
634
+ ? await readFile(resolve(options.htmlPath), "utf-8")
635
+ : DEFAULT_HTML;
636
+ const htmlTemplates = new Map<string, string>();
637
+ for (const e of entries) {
638
+ htmlTemplates.set(
639
+ e.name,
640
+ e.htmlPath !== undefined ? await readFile(resolve(e.htmlPath), "utf-8") : defaultTemplate,
641
+ );
642
+ }
643
+
644
+ // --- Kumiko stack ---
645
+ // KUMIKO_DEV_DB_NAME switches the underlying testDb from ephemeral
646
+ // (fresh kumiko_test_<random>, dropped on cleanup) to persistent
647
+ // (reuses the named DB across restarts). The var is framework-scoped
648
+ // on purpose — every dev-server pattern benefits from the same
649
+ // toggle, not just one sample.
650
+ const devDbName = process.env["KUMIKO_DEV_DB_NAME"];
651
+ const persistentDb = devDbName !== undefined && devDbName !== "";
652
+
653
+ logInfo(
654
+ `[kumiko-server] booting Kumiko stack${
655
+ persistentDb ? ` — persistent DB "${devDbName}"` : " — ephemeral test DB"
656
+ }…`,
657
+ );
658
+ const stack = await setupTestStack({
659
+ features: options.features,
660
+ ...(persistentDb && { dbName: devDbName, persistentDb: true }),
661
+ ...(options.auth !== undefined && { authConfig: options.auth }),
662
+ ...(options.extraContext !== undefined && { extraContext: options.extraContext }),
663
+ ...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
664
+ });
665
+ await createEventsTable(stack.db);
666
+ await createEntityTablesForFeatures(stack, options.features);
667
+
668
+ // Hook für Caller-spezifische Tables + Seed. Läuft nach den Entity-
669
+ // Tabellen damit das Sample auf `stack.db` / `stack.dispatcher`
670
+ // zugreifen kann, und VOR dem Server-Start damit der erste HTTP-Request
671
+ // bereits gegen einen gefüllten State läuft. Idempotenz ist Caller-
672
+ // Verantwortung (persistent-DB-Modus läuft es bei jedem Boot).
673
+ if (options.onAfterSetup !== undefined) {
674
+ await options.onAfterSetup(stack);
675
+ }
676
+
677
+ // App-eigene HTTP-Routes ans Hono-app hängen — symmetrisch zur
678
+ // gleichnamigen Option in runProdApp. Wird vor dem dev-fallback
679
+ // (HTML/JS/CSS-Serving via handleFetch unten) registriert, damit
680
+ // explizite Routen wie /feed.xml den Asset-Pfad schlagen.
681
+ if (options.extraRoutes !== undefined) {
682
+ options.extraRoutes(stack.app, { db: stack.db, redis: stack.redis });
683
+ }
684
+
685
+ // setupTestStack konfiguriert den eventDispatcher, startet ihn aber
686
+ // NICHT — Integration-Tests drain'en deterministisch via runOnce().
687
+ // Ein Dev-Server will das laufende Polling, damit SSE-Broadcasts
688
+ // (system-hook sse, Priorität 1001) von selbst an connected Clients
689
+ // fließen. Ohne start() bleiben alle Events in der events-Tabelle
690
+ // liegen und die Tabs sehen nichts.
691
+ if (stack.eventDispatcher) {
692
+ await stack.eventDispatcher.start();
693
+ }
694
+
695
+ // Dev user = TestUsers.admin. Demo features are openToAll but the
696
+ // auth-middleware still needs a valid JWT to let the request past.
697
+ // Nicht genutzt wenn `options.auth` gesetzt ist — dann macht der Client
698
+ // selbst den Login.
699
+ const autoMintJwt = options.auth === undefined;
700
+ const devUser = TestUsers.admin;
701
+
702
+ // AppSchema einmal beim Boot bauen. Sample-clients ohne explizites
703
+ // schema-Argument lesen das via window.__KUMIKO_SCHEMA__ aus — der
704
+ // dev-server injiziert das in jede HTML-Response. Re-build NICHT
705
+ // bei Hot-Reload weil sich Feature-Defs nur über einen restart
706
+ // ändern.
707
+ const appSchemaJson = JSON.stringify(buildAppSchema(stack.registry));
708
+
709
+ // --- SSE reload ---
710
+ // bootId identifiziert diese spezifische Server-Process-Instanz. Wird
711
+ // beim Connect an jeden Browser geschickt; Browser merkt sich den
712
+ // ersten Wert und refresht wenn beim Reconnect ein anderer kommt
713
+ // (= Server wurde restartet, alter JS-Bundle ist stale). Siehe
714
+ // RELOAD_SNIPPET oben.
715
+ const bootId = String(Date.now());
716
+ const reloadClients = new Set<ReloadClient>();
717
+ const broadcastReload = (): void => {
718
+ const payload = "event: reload\ndata: now\n\n";
719
+ for (const client of reloadClients) {
720
+ if (client.closed) continue;
721
+ try {
722
+ client.controller.enqueue(client.encoder.encode(payload));
723
+ } catch {
724
+ client.closed = true;
725
+ }
726
+ }
727
+ };
728
+
729
+ // Build a fresh HTML response. Im Auto-Mint-Modus (keine auth-Config)
730
+ // packen wir direkt ein gültiges JWT + CSRF-Cookie rein — Deep-Links
731
+ // funktionieren sofort ohne Login. Im Auth-Modus serven wir nur die
732
+ // nackte HTML; der Client geht dann durch /auth/login und bekommt die
733
+ // Cookies von dort.
734
+ //
735
+ // entryName + injectSchemaForEntry werden vom Caller (handleFetch)
736
+ // bestimmt nachdem er hostDispatch evaluiert hat. Ohne hostDispatch
737
+ // ist es immer "client" mit Schema-Inject true (Single-Entry-Default
738
+ // damit der Client TypeScript-Schemas findet).
739
+ const htmlResponse = async (entryName: string, doInjectSchema: boolean): Promise<Response> => {
740
+ const template = htmlTemplates.get(entryName) ?? defaultTemplate;
741
+ const headers = new Headers();
742
+ headers.set("Content-Type", "text/html; charset=utf-8");
743
+ if (autoMintJwt) {
744
+ const jwt = await stack.jwt.sign(devUser);
745
+ const csrf = generateToken();
746
+ headers.append("Set-Cookie", `${AUTH_COOKIE}=${jwt}; Path=/; HttpOnly; SameSite=Lax`);
747
+ headers.append("Set-Cookie", `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax`);
748
+ }
749
+ let html = injectReload(template);
750
+ if (stylesheetPath !== undefined) html = injectStylesheet(html);
751
+ if (doInjectSchema) html = injectSchema(html, appSchemaJson);
752
+ return new Response(html, { headers });
753
+ };
754
+
755
+ // --- Fetch handler (runtime-neutral) ---
756
+ // Bundle-Pfad-Lookup: für jede Entry serven wir
757
+ // GET /client[-name].js → JS-Bundle
758
+ // GET /client[-name].js.map → Sourcemap
759
+ // assetPathFor() ist die Single-Source-of-Truth für die URL-Form.
760
+ const bundleByAssetPath = new Map<string, string>();
761
+ for (const e of entries) bundleByAssetPath.set(assetPathFor(e.name), e.name);
762
+
763
+ const handleFetch = async (req: Request): Promise<Response> => {
764
+ const url = new URL(req.url);
765
+
766
+ // Specific routes first — assets, reload-SSE, API.
767
+ if (req.method === "GET") {
768
+ const bundleName = bundleByAssetPath.get(url.pathname);
769
+ if (bundleName !== undefined) {
770
+ const bundle = clientBundles.get(bundleName);
771
+ if (bundle === undefined) return new Response("no bundle", { status: 404 });
772
+ return new Response(bundle.js, {
773
+ headers: { "Content-Type": "application/javascript; charset=utf-8" },
774
+ });
775
+ }
776
+ // .js.map-Variante: gleicher Lookup mit /.map abgeschnitten.
777
+ if (url.pathname.endsWith(".js.map")) {
778
+ const jsPath = url.pathname.slice(0, -".map".length);
779
+ const mapName = bundleByAssetPath.get(jsPath);
780
+ if (mapName !== undefined) {
781
+ const bundle = clientBundles.get(mapName);
782
+ if (bundle === undefined || !bundle.map) {
783
+ return new Response("no map", { status: 404 });
784
+ }
785
+ return new Response(bundle.map, {
786
+ headers: { "Content-Type": "application/json; charset=utf-8" },
787
+ });
788
+ }
789
+ }
790
+ }
791
+
792
+ if (url.pathname === "/styles.css" && req.method === "GET") {
793
+ if (stylesheetPath === undefined) return new Response("no stylesheet", { status: 404 });
794
+ const css = await readFile(stylesheetPath, "utf-8");
795
+ return new Response(css, {
796
+ headers: { "Content-Type": "text/css; charset=utf-8" },
797
+ });
798
+ }
799
+
800
+ if (url.pathname === "/_reload" && req.method === "GET") {
801
+ const encoder = new TextEncoder();
802
+ const stream = new ReadableStream<Uint8Array>({
803
+ start(controller) {
804
+ const entry: ReloadClient = { controller, encoder, closed: false };
805
+ reloadClients.add(entry);
806
+ controller.enqueue(encoder.encode(": connected\n\n"));
807
+ // boot-Event: Browser-Snippet vergleicht das mit der ersten
808
+ // bootId. Verschiedener Wert nach Reconnect = Server wurde
809
+ // restartet → location.reload().
810
+ controller.enqueue(encoder.encode(`event: boot\ndata: ${bootId}\n\n`));
811
+ },
812
+ cancel() {
813
+ for (const c of reloadClients) {
814
+ if (c.closed) reloadClients.delete(c);
815
+ }
816
+ },
817
+ });
818
+ return new Response(stream, {
819
+ headers: {
820
+ "Content-Type": "text/event-stream",
821
+ "Cache-Control": "no-cache",
822
+ Connection: "keep-alive",
823
+ },
824
+ });
825
+ }
826
+
827
+ // SPA catch-all: any GET to a non-API, non-asset path returns the
828
+ // HTML shell. The client-side router then reads location.pathname
829
+ // and mounts the right screen. The "no dot" filter skips
830
+ // /favicon.ico etc. (let the stack's 404 handler respond).
831
+ //
832
+ // Backend routes that live outside /api (currently just /sse) have
833
+ // to be excluded explicitly, otherwise the catch-all would shadow
834
+ // the real Hono route with HTML and EventSource would never
835
+ // connect.
836
+ //
837
+ // Plus: r.httpRoute-deklarierte Feature-Routes (z.B. /legal/*) liegen
838
+ // ebenfalls außerhalb /api und matchen sonst diesen catch-all. Wir
839
+ // probieren daher ZUERST stack.app.fetch — wenn Hono eine matchende
840
+ // Route hat, gewinnt sie. 404 vom Hono-stack → SPA-fallback wie
841
+ // bisher. Das spiegelt runProdApp's doc-intent ("Hono matched VOR
842
+ // staticDir-fallback") und macht r.httpRoute mit non-/api paths im
843
+ // dev-server symmetrisch zu prod.
844
+ if (
845
+ req.method === "GET" &&
846
+ !url.pathname.startsWith("/api/") &&
847
+ !url.pathname.startsWith("/sse") &&
848
+ !url.pathname.includes(".")
849
+ ) {
850
+ const honoTry = await tryHonoFirst(stack.app, req);
851
+ if (honoTry.matched) {
852
+ return honoTry.response;
853
+ }
854
+ // Discriminated-Dispatch — symmetric zu prod. Ohne hostDispatch
855
+ // landet das im Single-Entry-Default ("client" + Schema-Inject).
856
+ if (options.hostDispatch !== undefined) {
857
+ const dispatch = options.hostDispatch(req);
858
+ if (dispatch.kind === "redirect") {
859
+ return new Response(null, {
860
+ status: dispatch.status ?? 302,
861
+ headers: { Location: dispatch.to },
862
+ });
863
+ }
864
+ if (dispatch.kind === "not-found") {
865
+ return new Response("Not Found", { status: 404 });
866
+ }
867
+ if (dispatch.kind === "static-html") {
868
+ // Raw-File-Serve, kein Bundle-Inject, kein Schema-Inject.
869
+ // Pendant zu prod's `{ kind: "html", file: ..., injectSchema: false }`.
870
+ const file = await readFile(dispatch.file, "utf-8");
871
+ return new Response(file, {
872
+ headers: { "Content-Type": "text/html; charset=utf-8" },
873
+ });
874
+ }
875
+ return htmlResponse(dispatch.entryName, dispatch.injectSchema ?? true);
876
+ }
877
+ return htmlResponse("client", true);
878
+ }
879
+
880
+ return stack.app.fetch(req);
881
+ };
882
+
883
+ // --- HTTP server (Bun only) ---
884
+ // Under Node/vitest we skip Bun.serve entirely — the handle's
885
+ // .fetch() is the test surface. Real dev runs under Bun, where
886
+ // Bun.serve wires the listener.
887
+ // Bun.serve-Options kommen aus buildBunServeOptions (run-prod-app.ts)
888
+ // damit Dev und Prod genau dieselben SSE-relevanten Defaults nutzen
889
+ // (idleTimeout: 0). Spec-Test in run-prod-app-spec.test.ts pinst das.
890
+ const server = hasBun
891
+ ? (globalThis as { Bun: { serve: (opts: unknown) => BunServer } }).Bun.serve(
892
+ buildBunServeOptions(port, handleFetch),
893
+ )
894
+ : undefined;
895
+
896
+ // --- file watcher → rebundle + reload, oder process-restart bei Schema-Änderungen ---
897
+ // Heuristik: alles in `web/` oder `__tests__/` ist client-side oder
898
+ // test-only — Hot-Reload reicht (rebuild + broadcast reload). Alles
899
+ // andere ist server-side; Bun cached die Module-Imports, also würde ein
900
+ // Schema-Change in feature.ts nicht durchschlagen ohne process-restart.
901
+ // Wir exiten dann mit Code 75 (EX_TEMPFAIL) — `kumiko-dev` Wrapper
902
+ // detected das und respawnt.
903
+ //
904
+ // watcherAbort wird beim stop() ausgelöst → fs.watch beendet die
905
+ // async-iteration → kein Watcher überlebt einen Test-Teardown und
906
+ // klassifiziert ein rmSync(tmpdir) als "restart needed".
907
+ const watcherAbort = new AbortController();
908
+ if (entries.length > 0) {
909
+ // Watch-Dirs: alle entry-Verzeichnisse (deduped) plus die explizit
910
+ // angegebenen watchDirs. In Multi-Entry-Setups liegen die Entries
911
+ // oft im selben src/-Verzeichnis (`src/client-admin.tsx` +
912
+ // `src/client-public.tsx`) — der Set kollabiert das auf einen
913
+ // Watcher pro Verzeichnis.
914
+ const entryDirs = new Set<string>();
915
+ for (const e of entries) entryDirs.add(resolve(e.sourceFile, ".."));
916
+ const dirs = [...entryDirs, ...expandWatchPatterns(options.watchDirs ?? [])];
917
+ for (const dir of dirs) {
918
+ void watchDir(
919
+ dir,
920
+ async (filename) => {
921
+ const action = classifyChange(filename);
922
+ if (action === "ignore") return;
923
+ if (action === "restart") {
924
+ logInfo(
925
+ `[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht)`,
926
+ );
927
+ await stop();
928
+ process.exit(75);
929
+ }
930
+ try {
931
+ // Alle Entries rebuilden — auch wenn nur eine Datei sich
932
+ // ändert, wir wissen nicht welche Entries sie importieren.
933
+ // Bei zwei Entries mit shared Code triggert ein Edit der
934
+ // gemeinsamen Datei beide Bundles neu, das ist gewollt.
935
+ for (const e of entries) {
936
+ const rebuilt = await buildBundle(e.sourceFile);
937
+ clientBundles.set(e.name, rebuilt);
938
+ }
939
+ logInfo(`[kumiko-server] rebuilt on ${filename}, broadcasting reload`);
940
+ broadcastReload();
941
+ } catch {
942
+ // buildClient already logged the failure; keep serving the
943
+ // last good bundle until the next successful rebuild.
944
+ }
945
+ },
946
+ watcherAbort.signal,
947
+ );
948
+ }
949
+ }
950
+
951
+ const stop = async (): Promise<void> => {
952
+ // Watcher zuerst stoppen damit kein onChange während des Teardowns
953
+ // mehr feuert (sonst können tmpdir-rmSync ein process.exit(75)
954
+ // auslösen).
955
+ watcherAbort.abort();
956
+ if (killTailwind) killTailwind();
957
+ if (server !== undefined) {
958
+ (server as { stop: (closeActive?: boolean) => void }).stop(true);
959
+ }
960
+ if (stack.eventDispatcher) {
961
+ await stack.eventDispatcher.stop();
962
+ }
963
+ await stack.cleanup();
964
+ };
965
+
966
+ // --- graceful shutdown ---
967
+ // Signal handlers fire on Ctrl-C / kill. Without them, repeated dev
968
+ // restarts leak Postgres pools, lassen Tailwind-Watcher als orphan
969
+ // hängen und (in persistent mode) hinterlassen temp Clients.
970
+ // uncaughtException + unhandledRejection: Crashes hatten den Tailwind-
971
+ // Watcher nicht gekillt, der lief munter weiter im Hintergrund. Jetzt
972
+ // räumen wir auch im Fehlerfall auf bevor wir mit non-zero exit'n.
973
+ const installHandlers = options.installSignalHandlers ?? true;
974
+ if (installHandlers) {
975
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
976
+ process.on(sig, async () => {
977
+ logInfo(`[kumiko-server] ${sig} — cleaning up…`);
978
+ await stop();
979
+ process.exit(0);
980
+ });
981
+ }
982
+ process.on("uncaughtException", async (err) => {
983
+ logError("[kumiko-server] uncaughtException — cleaning up…", err);
984
+ try {
985
+ await stop();
986
+ } finally {
987
+ process.exit(1);
988
+ }
989
+ });
990
+ process.on("unhandledRejection", async (err) => {
991
+ logError("[kumiko-server] unhandledRejection — cleaning up…", err);
992
+ try {
993
+ await stop();
994
+ } finally {
995
+ process.exit(1);
996
+ }
997
+ });
998
+ }
999
+
1000
+ if (server !== undefined) {
1001
+ logInfo(
1002
+ `[kumiko-server] listening on http://localhost:${port}` +
1003
+ (entries.length > 0
1004
+ ? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
1005
+ : ""),
1006
+ );
1007
+ }
1008
+
1009
+ return { fetch: handleFetch, server, stack, stop };
1010
+ }