@cosmicdrift/kumiko-dev-server 0.386.0 → 0.388.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 (56) hide show
  1. package/LICENSE +21 -57
  2. package/dist/build-server-bundle.d.ts +7 -7
  3. package/dist/build-server-bundle.js +53 -53
  4. package/dist/build.js +3 -3
  5. package/dist/compose-stacks.d.ts +2 -2
  6. package/dist/compose-stacks.js +1 -2
  7. package/dist/crash-tracker.d.ts +3 -7
  8. package/dist/crash-tracker.js +4 -13
  9. package/dist/create-kumiko-server-client-bundles.d.ts +21 -0
  10. package/dist/create-kumiko-server-client-bundles.js +97 -0
  11. package/dist/create-kumiko-server-html.d.ts +21 -0
  12. package/dist/create-kumiko-server-html.js +132 -0
  13. package/dist/create-kumiko-server-public-files.d.ts +6 -0
  14. package/dist/create-kumiko-server-public-files.js +96 -0
  15. package/dist/create-kumiko-server-reload.d.ts +14 -0
  16. package/dist/create-kumiko-server-reload.js +73 -0
  17. package/dist/create-kumiko-server-routes.d.ts +16 -0
  18. package/dist/create-kumiko-server-routes.js +206 -0
  19. package/dist/create-kumiko-server-shutdown.d.ts +14 -0
  20. package/dist/create-kumiko-server-shutdown.js +72 -0
  21. package/dist/create-kumiko-server-stack.d.ts +12 -0
  22. package/dist/create-kumiko-server-stack.js +101 -0
  23. package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
  24. package/dist/create-kumiko-server-stylesheet.js +137 -0
  25. package/dist/create-kumiko-server-watcher.d.ts +22 -0
  26. package/dist/create-kumiko-server-watcher.js +155 -0
  27. package/dist/create-kumiko-server.d.ts +53 -71
  28. package/dist/create-kumiko-server.js +59 -1076
  29. package/dist/dev-server-runtime.d.ts +5 -0
  30. package/dist/dev-server-runtime.js +11 -0
  31. package/dist/env-schema.d.ts +1 -1
  32. package/dist/env-schema.js +4 -4
  33. package/dist/index.d.ts +3 -1
  34. package/dist/index.js +17 -15
  35. package/dist/managed-files/app-jobs.js +1 -1
  36. package/dist/managed-files/check.js +1 -1
  37. package/dist/run-dev-app-auth.d.ts +38 -0
  38. package/dist/run-dev-app-auth.js +155 -0
  39. package/dist/run-dev-app-config.d.ts +26 -0
  40. package/dist/run-dev-app-config.js +185 -0
  41. package/dist/run-dev-app-server-options.d.ts +13 -0
  42. package/dist/run-dev-app-server-options.js +49 -0
  43. package/dist/run-dev-app.d.ts +52 -50
  44. package/dist/run-dev-app.js +100 -471
  45. package/dist/scaffold-app-feature.js +5 -4
  46. package/dist/scaffold-app.js +5 -7
  47. package/dist/schema-apply.d.ts +3 -3
  48. package/dist/schema-apply.js +16 -16
  49. package/dist/schema-tables-auth-mode.js +16 -15
  50. package/dist/schema-tables-minimal.js +7 -7
  51. package/dist/setup-test-stack-from-features.js +1 -1
  52. package/dist/welcome-banner.js +2 -2
  53. package/dist/write-server-lockfile.js +23 -16
  54. package/package.json +10 -10
  55. package/src/changes.json +30 -0
  56. package/templates/managed/ci-public.yml.template +4 -0
@@ -14,1100 +14,83 @@
14
14
  //
15
15
  // The companion prod entry will land at `@cosmicdrift/kumiko-framework/server`
16
16
  // with a different options shape (clientDist, auth config, db url).
17
- import { spawn } from "node:child_process";
18
- import { existsSync, mkdtempSync, realpathSync, statSync } from "node:fs";
19
- import { readFile, realpath, watch } from "node:fs/promises";
20
- import { tmpdir } from "node:os";
21
- import { join, resolve, sep } from "node:path";
22
- import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
23
- import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, createClientIpResolver, generateToken, sessionCookieName, } from "@cosmicdrift/kumiko-framework/api";
24
17
  import { buildAppSchema } from "@cosmicdrift/kumiko-framework/engine";
25
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
26
- import { rankPreferredLocales } from "@cosmicdrift/kumiko-framework/i18n";
27
- import { createMetricsForFactory } from "@cosmicdrift/kumiko-framework/observability";
28
- import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
29
- import { resolveAndInjectPageHead, } from "@cosmicdrift/kumiko-renderer/headless/apex";
30
- import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
31
18
  import { buildBunServeOptions, resolveDerivedMaxRequestBodySize, } from "@cosmicdrift/kumiko-server-runtime/bun-serve-options";
32
- import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
33
- import { RENDERER_WEB_FONT_FILE_PATTERN, RENDERER_WEB_FONTS_URL_PREFIX, resolveRendererWebFontsDir, } from "@cosmicdrift/kumiko-server-runtime/renderer-web-fonts";
34
- import { buildRequestBoundSystemQuery } from "@cosmicdrift/kumiko-server-runtime/request-bound-system-query";
35
- import { canResolveTailwindStylesheet, resolveTailwindCli, } from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli";
36
- import { stripNoRouteMatchHeader, tryHonoFirst, } from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
37
- import { pushEntityProjectionTables, setupTestStack, } from "@cosmicdrift/kumiko-testing/stack";
38
- import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
39
- import { isPersistentDevDb } from "./is-persistent-dev-db.js";
40
- // Runtime-detection. The dev-server is meant to run under Bun (Kumiko's
41
- // target runtime), but the test-suite runs under vitest on Node — we
42
- // gate every Bun.* call so the module at least LOADS under Node, and
43
- // tests drive the fetch-handler directly instead of going through
44
- // Bun.serve + real sockets.
45
- const hasBun = typeof Bun !== "undefined";
46
- // @wrapper-known semantic-alias
47
- // biome-ignore lint/suspicious/noConsole: dev-server status logging
48
- export const logInfo = (msg) => console.log(msg);
49
- // @wrapper-known semantic-alias
50
- // biome-ignore lint/suspicious/noConsole: dev-server error logging
51
- const logError = (...args) => console.error(...args);
52
- // The dev server speaks plain http, so its minted cookies are never Secure and keep the unprefixed names.
53
- const CSRF_COOKIE = sessionCookieName(CSRF_COOKIE_BASE, { secure: false });
54
- const AUTH_COOKIE = sessionCookieName(AUTH_COOKIE_BASE, { secure: false });
55
- // Duplicated in packages/testing/src/e2e/constants.ts: the Playwright config
56
- // runs under Node and cannot import this module.
57
- export const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
58
- // Duplicated in packages/testing/src/e2e/constants.ts: the Playwright config
59
- // runs under Node and cannot import this module.
60
- export const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
61
- // Reload snippet injected into every page-load so the browser
62
- // subscribes to /_reload without the HTML needing to hard-code it.
63
- //
64
- // Zwei reload-Trigger:
65
- // - explizites `reload`-Event vom Server beim hot-reload (rebuild + send)
66
- // - implizites: jede SSE-Connection bekommt beim Connect ein `boot`-Event
67
- // mit der bootId des aktuellen Server-Process. Das Snippet merkt sich
68
- // die erste bootId; wenn nach einem Reconnect (Server-Restart!) eine
69
- // ANDERE bootId kommt, refresh — sonst bleibt der Browser ewig auf
70
- // dem alten Bundle hängen wenn der Watcher classifyChange="restart"
71
- // gewählt hat oder der User Ctrl-C/bun dev gemacht hat.
72
- const RELOAD_SNIPPET = `
73
- <script>
74
- (() => {
75
- const es = new EventSource("/_reload");
76
- let firstBootId = null;
77
- es.addEventListener("boot", (e) => {
78
- if (firstBootId === null) {
79
- firstBootId = e.data;
80
- } else if (firstBootId !== e.data) {
81
- location.reload();
82
- }
83
- });
84
- es.addEventListener("reload", () => location.reload());
85
- })();
86
- </script>
87
- `;
88
- // Minimal HTML when the caller didn't hand one in. `#root` is the
89
- // default mount target for `createKumikoApp`, so the one-line client
90
- // can attach without the sample having to ship its own template.
91
- //
92
- // `type="module"` is load-bearing: a classic script turns the bundle's
93
- // top-level declarations into window properties, so a dependency exporting
94
- // `function history()` (prosemirror-history, pulled in by tiptap) silently
95
- // replaces `window.history` and every pushState afterwards throws. Prod
96
- // (build-prod-bundle) has always emitted the module form.
97
- //
98
- // `interactive-widget=resizes-content` (fw#1918): without it, a mobile
99
- // keyboard shrinks only the visual viewport, not the layout viewport that
100
- // `position: fixed` anchors to — a fixed bottom action bar would stay
101
- // pinned behind the keyboard instead of above it. iOS Safari ignores the
102
- // key; renderer/web's useKeyboardInset covers it via window.visualViewport.
103
- export const DEFAULT_HTML = `<!doctype html>
104
- <html lang="en">
105
- <head>
106
- <meta charset="utf-8" />
107
- <meta name="viewport" content="width=device-width,initial-scale=1,interactive-widget=resizes-content" />
108
- <title>Kumiko</title>
109
- </head>
110
- <body>
111
- <div id="root"></div>
112
- <script type="module" src="/client.js"></script>
113
- </body>
114
- </html>
115
- `;
116
- async function buildClient(entry) {
117
- if (!hasBun) {
118
- throw new Error("[kumiko-server] clientEntry is only supported under Bun — Bun.build is unavailable in this runtime.");
119
- }
120
- const unminified = process.env["KUMIKO_DEV_UNMINIFIED"] === "1";
121
- // Same splitting/sourcemap/NODE_ENV as build-prod-bundle.ts, opt-in via
122
- // env so `bun dev` stays unchanged.
123
- const prodShaped = process.env[PROD_BUNDLES_ENV] === "1";
124
- const built = await Bun.build({
125
- entrypoints: [entry],
126
- target: "browser",
127
- minify: !unminified,
128
- sourcemap: prodShaped ? "none" : "linked",
129
- splitting: prodShaped,
130
- ...(prodShaped ? { define: { "process.env.NODE_ENV": JSON.stringify("production") } } : {}),
131
- });
132
- if (!built.success) {
133
- logError("[kumiko-server] client bundle failed:");
134
- for (const log of built.logs)
135
- logError(log);
136
- throw new Error("client bundle failed");
137
- }
138
- const jsOutput = built.outputs.find((o) => o.kind === "entry-point");
139
- const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
140
- if (!jsOutput)
141
- throw new Error("[kumiko-server] client bundle produced no .js output");
142
- const chunks = new Map();
143
- for (const output of built.outputs) {
144
- if (output.kind !== "chunk")
145
- continue;
146
- const chunkPath = `/${output.path.replace(/^\.\//, "")}`;
147
- chunks.set(chunkPath, await output.text());
148
- }
149
- return {
150
- js: await jsOutput.text(),
151
- map: mapOutput ? await mapOutput.text() : "",
152
- chunks,
153
- };
154
- }
155
- function injectReload(html) {
156
- if (html.includes("/_reload"))
157
- return html;
158
- return html.includes("</body>")
159
- ? html.replace("</body>", `${RELOAD_SNIPPET}</body>`)
160
- : html + RELOAD_SNIPPET;
161
- }
162
- // Injiziert <link rel="stylesheet" href="/styles.css"> in den <head>,
163
- // wenn es noch nicht da ist. Wird nur aufgerufen wenn die App das
164
- // stylesheet-Option genutzt hat — andernfalls kommt keine CSS-Route.
165
- function injectStylesheet(html) {
166
- if (html.includes('href="/styles.css"'))
167
- return html;
168
- const link = '<link rel="stylesheet" href="/styles.css" />';
169
- return html.includes("</head>")
170
- ? html.replace("</head>", ` ${link}\n</head>`)
171
- : `${link}${html}`;
172
- }
173
- // GET/HEAD to a non-API, non-SSE path — the dispatch-worthy set shared by
174
- // the SPA catch-all and the public/-file lookup below (they split on
175
- // whether the path has a dot).
176
- function isRoutableGetOrHead(req, pathname) {
177
- return ((req.method === "GET" || req.method === "HEAD") &&
178
- !pathname.startsWith("/api/") &&
179
- !pathname.startsWith("/sse"));
180
- }
181
- // Resolves a request pathname to a file inside publicDir, or undefined if
182
- // it isn't one. Two traversal vectors, both must be blocked:
183
- // - literal ".." segments (new URL() already collapses these in
184
- // `pathname`, but resolve()+containment is checked regardless — no
185
- // path-safety may depend on caller behavior upstream).
186
- // - percent-encoded slashes (e.g. "%2e%2e%2f"): WHATWG URL parsing only
187
- // normalizes dot-segments that are delimited by a literal "/", so an
188
- // encoded slash survives into `pathname` untouched. decodeURIComponent
189
- // resolves it to "../" before the containment check runs.
190
- function resolvePublicFilePath(pathname, publicDir) {
191
- let decoded;
192
- try {
193
- decoded = decodeURIComponent(pathname);
194
- }
195
- catch {
196
- return undefined;
197
- }
198
- // A NUL byte makes readFile throw ERR_INVALID_ARG_VALUE (not ENOENT), which
199
- // would surface as a 500 instead of a router miss.
200
- if (decoded.includes("\0"))
201
- return undefined;
202
- const resolved = resolve(publicDir, `.${decoded}`);
203
- return resolved === publicDir || resolved.startsWith(publicDir + sep) ? resolved : undefined;
204
- }
205
- // Same extension → content-type mapping as prod's
206
- // run-prod-app-static-files.ts#mimeTypeFor — that function isn't part of
207
- // server-runtime's public exports (its own package.json "exports" map),
208
- // so this is a small self-contained copy rather than a new cross-package
209
- // export for one call site.
210
- const PUBLIC_FILE_MIME_TYPES = new Map([
211
- ["html", "text/html; charset=utf-8"],
212
- ["js", "text/javascript; charset=utf-8"],
213
- ["mjs", "text/javascript; charset=utf-8"],
214
- ["css", "text/css; charset=utf-8"],
215
- ["json", "application/json; charset=utf-8"],
216
- ["svg", "image/svg+xml"],
217
- ["png", "image/png"],
218
- ["jpg", "image/jpeg"],
219
- ["jpeg", "image/jpeg"],
220
- ["ico", "image/x-icon"],
221
- ["txt", "text/plain; charset=utf-8"],
222
- ["xml", "application/xml; charset=utf-8"],
223
- ["webmanifest", "application/manifest+json"],
224
- ["woff2", "font/woff2"],
225
- ]);
226
- function publicFileMimeType(filePath) {
227
- const ext = filePath.toLowerCase().split(".").pop() ?? "";
228
- return PUBLIC_FILE_MIME_TYPES.get(ext) ?? "application/octet-stream";
229
- }
230
- // Reads a file under publicDir for the dev-server's static-asset fallback
231
- // (dev-parity with prod's disk lookup in buildStaticFallback). undefined
232
- // means "not a servable file here" — caller treats that as a router miss,
233
- // same as ENOENT/EISDIR/ENOTDIR further down.
234
- async function servePublicFile(pathname, publicDir) {
235
- const filePath = resolvePublicFilePath(pathname, publicDir);
236
- if (filePath === undefined)
237
- return undefined;
238
- try {
239
- // Lexical containment (above) only catches "..", not a symlink inside
240
- // publicDir that points outside it on disk — realpath resolves the
241
- // actual target and the same containment check runs against it. Read
242
- // from the resolved path so the checked target is the one read.
243
- const real = await realpath(filePath);
244
- if (real !== publicDir && !real.startsWith(publicDir + sep))
245
- return undefined;
246
- const bytes = await readFile(real);
247
- return { bytes, mime: publicFileMimeType(filePath) };
248
- }
249
- catch (err) {
250
- const code = errorCodeOf(err);
251
- if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR" || code === "ENAMETOOLONG") {
252
- return undefined;
253
- }
254
- throw err;
255
- }
256
- }
257
- // Static assets under public/ — prod serves these via buildStaticFallback's
258
- // disk lookup (run-prod-app-static-files.ts), dev previously had no
259
- // equivalent: a dotted path (e.g. /marketing/hero.png) fell straight
260
- // through to the API stack and 404ed. Hono still goes first (an
261
- // r.httpRoute could itself own a dotted path), then the file on disk,
262
- // then the router-miss 404 — mirrors handleFetch's SPA branch. undefined
263
- // means "not a static-asset request", caller falls through to the next
264
- // route.
265
- async function tryServePublicAsset(req, pathname, app, publicDir, socketAddress) {
266
- if (!isRoutableGetOrHead(req, pathname) || !pathname.includes("."))
267
- return undefined;
268
- const honoTry = await tryHonoFirst(app, req, socketAddress);
269
- if (honoTry.matched) {
270
- return honoTry.response;
271
- }
272
- const file = await servePublicFile(pathname, publicDir);
273
- if (file !== undefined) {
274
- // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types
275
- // just doesn't say so — same cast run-prod-app-static-files.ts uses.
276
- return new Response(file.bytes, {
277
- headers: { "Content-Type": file.mime },
278
- });
279
- }
280
- return honoTry.response;
281
- }
282
- async function watchDir(dir, onChange, signal, watchEvents) {
283
- // AbortSignal wird vom Server-stop() ausgelöst: ohne den Abort liefe
284
- // die for-await-Schleife bis zum Process-Exit weiter. Im Test-Setup
285
- // (afterEach räumt tmpdir mit rmSync auf) sähe der Watcher dann das
286
- // rmSync, klassifizierte's als "restart" und riefe process.exit(75) —
287
- // bubbles als unhandled error in vitest hoch.
288
- const watcher = watchEvents(dir, { recursive: true, signal });
289
- try {
290
- for await (const ev of watcher) {
291
- if (ev.filename)
292
- onChange(ev.filename);
293
- }
294
- }
295
- catch (err) {
296
- // signal.abort() wirft AbortError aus dem async-iterator; das ist
297
- // gewollt und kein Fehler. Andere Errors weiterreichen.
298
- // skip: AbortSignal fired the abort, this is expected teardown not a real error
299
- if (typeof err === "object" && err !== null && "name" in err && err.name === "AbortError")
300
- return;
301
- throw err;
302
- }
303
- }
304
- // Klassifiziert eine geänderte Datei: `hot-reload` für Client-Side
305
- // (Browser-Bundle rebuild + reload), `restart` für Server-Side (Bun-
306
- // Module-Cache zwingt einen Process-Restart durch), `ignore` für
307
- // alles was den Server nicht beeinflusst (Tests, .css, .json…).
308
- //
309
- // Heuristik:
310
- // - Tests (`__tests__/` oder `*.test.ts(x)`) → ignore
311
- // - `.ts` / `.tsx` außer Tests:
312
- // - Client-side-Dirs (`/web/`, `/admin/`, `/public/`, `/client/`)
313
- // oder die client-entry-Datei selbst → hot-reload
314
- // - sonst → restart (könnte Schema/Feature-Definition sein)
315
- // - andere Dateitypen → ignore (kein TS rebuild nötig)
316
- //
317
- // Warum mehrere Dirs für client-side: in Kumiko-Samples gibt's keine
318
- // Convention. publicstatus splittet `/admin/` (Admin-Bundle) und
319
- // `/public/` (Anonymous-Bundle); beammycar nutzt `/web/` für seine
320
- // Feature-Web-Code; ältere Samples haben einfach `/client.tsx` neben
321
- // dem Server. Der Watcher muss alle drei verstehen, sonst löst ein
322
- // Edit der Bridge-Component einen kompletten Server-Restart aus —
323
- // kostet 2-3s, droppt die Test-DB im ephemeral-Modus, reseed läuft
324
- // erneut. Ineffektiv und für der User verwirrend.
325
- //
326
- // Exportiert für Tests; intern wird's von der Watcher-Loop gerufen.
327
- export function classifyChange(filename) {
328
- if (!filename.endsWith(".ts") && !filename.endsWith(".tsx"))
329
- return "ignore";
330
- if (filename.includes("__tests__"))
331
- return "ignore";
332
- if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx"))
333
- return "ignore";
334
- if (filename.endsWith(".integration.ts") ||
335
- filename.endsWith(".integration.test.ts") ||
336
- filename.endsWith(".e2e.ts")) {
337
- return "ignore";
338
- }
339
- // Plattformpfad-agnostisch: prüfen auf POSIX und Windows-Trenner.
340
- // Wir matchen sowohl `<sep><dir><sep>` als auch trailing-`<sep><dir>`
341
- // (für Watcher-Filenames die als relativer Pfad ankommen).
342
- const clientSubdirs = ["web", "admin", "public", "client"];
343
- for (const dir of clientSubdirs) {
344
- if (filename.includes(`/${dir}/`) ||
345
- filename.includes(`\\${dir}\\`) ||
346
- filename.startsWith(`${dir}/`) ||
347
- filename.startsWith(`${dir}\\`)) {
348
- return "hot-reload";
349
- }
350
- }
351
- if (filename.endsWith("/client.tsx") || filename.endsWith("/client.ts")) {
352
- return "hot-reload";
353
- }
354
- return "restart";
355
- }
356
- // The watcher reports paths relative to the watched directory, so an entry arrives as a bare
357
- // "client.tsx" (or a custom name like "client-admin.tsx") that classifyChange alone reads as a
358
- // server file and answers with a restart.
359
- export function classifyWatchedChange(watchedDir, filename, entrySourceFiles) {
360
- return entrySourceFiles.has(resolve(watchedDir, filename))
361
- ? "hot-reload"
362
- : classifyChange(filename);
363
- }
364
- // Expandiert watchDirs-Patterns auf konkrete Verzeichnisse. Ein Eintrag
365
- // ohne `*` wird als gewöhnlicher Pfad resolved; mit `*` wird er per
366
- // glob expanded und alle Treffer die Verzeichnisse sind übernommen.
367
- // Erlaubt z.B. `"../../../packages/*/src"` statt vier hart-kodierte
368
- // Pfade. Glob ist sync — wird einmal beim Boot ausgewertet, nicht
369
- // während der Watcher läuft.
370
- function expandWatchPatterns(patterns) {
371
- const out = [];
372
- for (const p of patterns) {
373
- if (!p.includes("*")) {
374
- out.push(resolve(p));
375
- continue;
376
- }
377
- // Only reachable under Bun (createKumikoServer is Bun-only); without Bun the
378
- // call stack fails fast in Bun.serve anyway.
379
- if (typeof Bun === "undefined")
380
- throw new Error("expandWatchPatterns requires Bun.Glob");
381
- const glob = new Bun.Glob(p);
382
- const matches = Array.from(glob.scanSync({ onlyFiles: false, cwd: process.cwd() }));
383
- for (const m of matches) {
384
- const abs = resolve(m);
385
- try {
386
- if (statSync(abs).isDirectory())
387
- out.push(abs);
388
- }
389
- catch {
390
- // ignore unreadable matches — typisch defekte Symlinks
391
- }
392
- }
393
- }
394
- return out;
395
- }
396
- // Resolves the path to the Tailwind entry CSS:
397
- // - explicit string → the resolved absolute path
398
- // - false → CSS pipeline off (undefined)
399
- // - undefined + client entries:
400
- // 1. the app's own src/styles.css (theme override) if present
401
- // 2. else the default `@cosmicdrift/kumiko-renderer/web/styles.css` via package exports
402
- // - undefined + no client entry: undefined (no CSS needed)
403
- //
404
- // The src/styles.css auto-detection mirrors build-prod-bundle:resolveStylesheetEntry
405
- // so dev and prod resolve identically. Without it every app would have to set
406
- // `stylesheet: "./src/styles.css"`, or dev would fall back to the default and
407
- // ignore the brand tokens.
408
- //
409
- // @internal — exported for unit tests only, not re-exported from the package index.
410
- export function resolveStylesheet(options) {
411
- if (options.stylesheet === false)
412
- return undefined;
413
- if (typeof options.stylesheet === "string")
414
- return resolve(options.stylesheet);
415
- const hasAnyEntry = options.clientEntry !== undefined ||
416
- (options.clientEntries !== undefined && options.clientEntries.length > 0);
417
- if (!hasAnyEntry)
19
+ import { stripNoRouteMatchHeader } from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
20
+ import { buildClient, buildClientBundles, normalizeEntries, } from "./create-kumiko-server-client-bundles.js";
21
+ import { loadHtmlTemplates } from "./create-kumiko-server-html.js";
22
+ import { createReloadHub } from "./create-kumiko-server-reload.js";
23
+ import { createDevFetchHandler } from "./create-kumiko-server-routes.js";
24
+ import { createDevServerStop, installDevSignalHandlers } from "./create-kumiko-server-shutdown.js";
25
+ import { bootDevStack, resolveDevDbMode, startDevBackgroundRunners, } from "./create-kumiko-server-stack.js";
26
+ import { startDevStylesheet } from "./create-kumiko-server-stylesheet.js";
27
+ import { startDevFileWatchers } from "./create-kumiko-server-watcher.js";
28
+ import { hasBun, logInfo } from "./dev-server-runtime.js";
29
+ // Under Node the handle's .fetch() is the test surface. Bun.serve options come
30
+ // from buildBunServeOptions (run-prod-app.ts) so development and production
31
+ // share the same SSE-relevant defaults (idleTimeout: 0);
32
+ // run-prod-app-spec.test.ts pins that.
33
+ function startDevHttpServer(port, handleFetch, stack) {
34
+ if (!hasBun)
418
35
  return undefined;
419
- // The app's own src/styles.css beats the renderer/web default, same as
420
- // kumiko-build, so local and prod builds match.
421
- const local = resolve(process.cwd(), "src/styles.css");
422
- if (existsSync(local))
423
- return local;
424
- // Bun.resolveSync folgt Package-Exports — "./styles.css" in renderer/web's
425
- // package.json. Das Monorepo auflöst direkt auf den Workspace-File, eine
426
- // installierte Fremd-App auf den node_modules-File. Kein `fileURLToPath`
427
- // nötig, Bun gibt schon einen absoluten FS-Pfad zurück.
428
- if (!hasBun) {
429
- // Unit-Tests unter vitest/Node landen hier. Ohne Bun können wir die
430
- // Package-Export-Resolution nicht machen — und im Test-Kontext gibt's
431
- // keine echte Tailwind-Pipeline. Skip still, keine Fehlermeldung nötig.
432
- return undefined;
433
- }
434
- try {
435
- const resolved = Bun.resolveSync("@cosmicdrift/kumiko-renderer/web/styles.css", process.cwd());
436
- if (!canResolveTailwindStylesheet(resolved, { bun: Bun, cwd: process.cwd() })) {
437
- return undefined;
438
- }
439
- return resolved;
440
- }
441
- catch (err) {
442
- logError("[kumiko-server] couldn't auto-resolve @cosmicdrift/kumiko-renderer/web/styles.css — " +
443
- "pass `stylesheet: <path>` or `stylesheet: false` explicitly.", err);
444
- return undefined;
445
- }
446
- }
447
- // The E2E template can only reach an app's own server entry through its env,
448
- // so the env has to count whenever the option is left undefined.
449
- //
450
- // @internal — exported for unit tests only, not re-exported from the index.
451
- export function resolveStylesheetWatch(options, env = process.env) {
452
- if (options.stylesheetWatch !== undefined)
453
- return options.stylesheetWatch;
454
- return env[STYLESHEET_WATCH_ENV] !== "0";
455
- }
456
- // Non-fatal by design (returns undefined): if the CLI can't be resolved or
457
- // the initial build fails (flaky network, missing dependency), the server
458
- // boots without CSS instead of dying.
459
- async function startTailwindPipeline(entryCss, { watch }) {
460
- const bunResolver = hasBun ? Bun : undefined;
461
- const cliPath = resolveTailwindCli({ bun: bunResolver, cwd: process.cwd() });
462
- if (cliPath === undefined) {
463
- logError("[kumiko-server] @tailwindcss/cli nicht auflösbar — booting ohne CSS-Pipeline. " +
464
- "`bun install` und Restart, um Styles zu aktivieren.");
465
- return undefined;
466
- }
467
- const outDir = mkdtempSync(join(tmpdir(), "kumiko-tw-"));
468
- const outPath = join(outDir, "styles.css");
469
- logInfo(watch
470
- ? `[kumiko-server] tailwind watcher → ${outPath}`
471
- : `[kumiko-server] tailwind one-shot build → ${outPath}`);
472
- const bunPath = process.argv[0] ?? "bun";
473
- // Initial-Build blockend, damit der erste /styles.css-Request kein
474
- // 404 bekommt. Dann den watcher im Hintergrund mit unref() — sonst
475
- // hing er beim Parent-Crash als orphan-process.
476
- try {
477
- await new Promise((resolvePromise, rejectPromise) => {
478
- const child = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath], {
479
- stdio: "inherit",
480
- });
481
- child.on("exit", (code) => {
482
- if (code === 0)
483
- resolvePromise();
484
- else
485
- rejectPromise(new Error(`tailwind one-shot-build exit ${code}`));
486
- });
487
- child.on("error", rejectPromise);
488
- });
489
- }
490
- catch (err) {
491
- logError("[kumiko-server] tailwind one-shot-build fehlgeschlagen — booting ohne CSS:", err);
492
- return undefined;
493
- }
494
- if (!watch) {
495
- return { outPath, kill: () => { } };
496
- }
497
- const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], {
498
- stdio: "inherit",
499
- });
500
- watcher.unref();
501
- return {
502
- outPath,
503
- kill: () => {
504
- try {
505
- watcher.kill("SIGTERM");
506
- }
507
- catch {
508
- // schon tot oder nie gestartet — nicht weiter relevant
509
- }
510
- },
511
- };
512
- }
513
- /** URL-Pfad unter dem ein Entry ausgeliefert wird. "client" → /client.js
514
- * (Single-Mode-Default), sonst "/client-<name>.js". Single-Source-of-Truth
515
- * damit Routing + Logging dieselbe Konvention nutzen. */
516
- function assetPathFor(entryName) {
517
- return entryName === "client" ? "/client.js" : `/client-${entryName}.js`;
518
- }
519
- function normalizeEntries(options) {
520
- if (options.clientEntries !== undefined && options.clientEntry !== undefined) {
521
- throw new Error("[kumiko-server] clientEntry und clientEntries sind mutually exclusive — wähle eins");
522
- }
523
- if (options.clientEntries !== undefined && options.clientEntries.length > 0) {
524
- if (options.hostDispatch === undefined) {
525
- throw new Error("[kumiko-server] clientEntries braucht hostDispatch — sonst weiß der Server nicht welches Template er liefern soll");
526
- }
527
- return options.clientEntries.map((e) => ({
528
- name: e.name,
529
- sourceFile: resolve(e.sourceFile),
530
- htmlPath: e.htmlPath,
531
- }));
532
- }
533
- if (options.clientEntry !== undefined) {
534
- return [{ name: "client", sourceFile: resolve(options.clientEntry), htmlPath: undefined }];
535
- }
536
- return [];
36
+ return Bun.serve(buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry), {
37
+ upgradeFetch: async (req, env) => stripNoRouteMatchHeader(await stack.app.fetch(req, env)),
38
+ }));
537
39
  }
538
40
  export async function createKumikoServer(options) {
539
41
  const port = options.port ?? Number(process.env["PORT"] ?? 4173);
540
- // --- client bundles (single-entry oder multi-entry über dieselbe Map) ---
541
42
  const entries = normalizeEntries(options);
542
43
  const buildBundle = options._buildBundle ?? buildClient;
543
- const clientBundles = new Map();
544
- for (const e of entries) {
545
- const bundle = await buildBundle(e.sourceFile);
546
- clientBundles.set(e.name, bundle);
547
- logInfo(`[kumiko-server] client bundle ${e.name}: ${bundle.js.length.toLocaleString()} bytes` +
548
- (bundle.map ? ` (+ ${bundle.map.length.toLocaleString()} bytes sourcemap)` : ""));
549
- }
550
- // --- Tailwind stylesheet (optional) ---
551
- // The Tailwind CLI writes to a temp file that every /styles.css request
552
- // reads fresh: no in-memory signalling needed, and the browser only
553
- // reloads after a bundle rebuild anyway.
554
- //
555
- // Without an explicit `stylesheet`, the default resolves through the
556
- // package exports of `@cosmicdrift/kumiko-renderer/web`, which works both
557
- // for a workspace link and for an installed app (node_modules).
558
- let stylesheetPath;
559
- let killTailwind;
560
- const resolvedStylesheet = resolveStylesheet(options);
561
- if (resolvedStylesheet !== undefined) {
562
- const handle = await startTailwindPipeline(resolvedStylesheet, {
563
- watch: resolveStylesheetWatch(options),
564
- });
565
- if (handle !== undefined) {
566
- stylesheetPath = handle.outPath;
567
- killTailwind = handle.kill;
568
- }
569
- }
570
- // --- HTML templates ---
571
- // Single-Entry: ein Template (htmlPath oder DEFAULT_HTML) für alles.
572
- // Multi-Entry: pro Entry ein Template (entry.htmlPath ?? options.htmlPath
573
- // ?? DEFAULT_HTML). Der hostDispatch wählt zur Request-Zeit.
574
- const defaultTemplate = options.htmlPath !== undefined
575
- ? await readFile(resolve(options.htmlPath), "utf-8")
576
- : DEFAULT_HTML;
577
- const htmlTemplates = new Map();
578
- for (const e of entries) {
579
- htmlTemplates.set(e.name, e.htmlPath !== undefined ? await readFile(resolve(e.htmlPath), "utf-8") : defaultTemplate);
580
- }
581
- // --- Kumiko stack ---
582
- // KUMIKO_DEV_DB_NAME switches the underlying testDb from ephemeral
583
- // (fresh kumiko_test_<random>, dropped on cleanup) to persistent
584
- // (reuses the named DB across restarts). The var is framework-scoped
585
- // on purpose — every dev-server pattern benefits from the same
586
- // toggle, not just one sample.
587
- const devDbName = process.env["KUMIKO_DEV_DB_NAME"];
588
- const persistentDb = isPersistentDevDb(process.env);
589
- // Redis is shared across boots AND across apps (unlike the per-boot test
590
- // DB, which is ephemeral or persistent per KUMIKO_DEV_DB_NAME), so every
591
- // boot needs its own queue-name prefix — otherwise two dev/e2e servers
592
- // running in parallel drain each other's queues ("Unknown job: ..."). A
593
- // persistent DB implies a stable prefix so jobs survive a restart; an
594
- // ephemeral DB gets a fresh prefix per boot, matching the fresh DB.
595
- const jobQueueNamePrefix = persistentDb
596
- ? `kumiko-dev-${devDbName}`
597
- : `kumiko-dev-${crypto.randomUUID()}`;
598
- logInfo(`[kumiko-server] booting Kumiko stack${persistentDb ? ` — persistent DB "${devDbName}"` : " — ephemeral test DB"}…`);
599
- const stack = await setupTestStack({
600
- features: options.features,
601
- ...(persistentDb && { dbName: devDbName, persistentDb: true }),
602
- ...(options.auth !== undefined && { authConfig: options.auth }),
603
- ...(options.extraContext !== undefined && { extraContext: options.extraContext }),
604
- ...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
605
- enrichAnonymousAccess: (base, deps) => resolveAnonymousAccessFromRegistry(base, deps),
606
- ...(options.files !== undefined && { files: options.files }),
607
- ...(options.effectiveFeatures !== undefined && {
608
- effectiveFeatures: options.effectiveFeatures,
609
- }),
610
- ...(options.extraRoutes !== undefined && { extraRoutes: options.extraRoutes }),
611
- ...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
612
- ...(options.errorDocs && { errorDocs: options.errorDocs }),
613
- // jobs.consumerLane unset = enqueuer-only; startDevJobRunners below is
614
- // the sole consumer/cron-scheduler per lane, so runOnBoot/cron jobs
615
- // don't double-fire. queueNamePrefix keeps this boot's queues isolated
616
- // from every other dev/e2e server sharing the same Redis.
617
- jobs: { queueNamePrefix: jobQueueNamePrefix },
618
- });
619
- await createEventsTable(stack.db);
620
- await pushEntityProjectionTables(stack, stack.registry);
621
- // `extraRoutes` is already mounted — setupTestStack's buildServer() call
622
- // wired it above, before this function ever runs. `wire` is the
623
- // remaining escape-hatch for app-wired co-running components that need
624
- // the system-write dispatcher but don't declare a route.
625
- if (options.wire !== undefined) {
626
- await options.wire({
627
- db: stack.db,
628
- // Der nackte ioredis-Client (nicht der TestRedis-Wrapper) —
629
- // Parität mit runProdApp, App-Code soll in dev+prod dasselbe sehen.
630
- redis: stack.redis.redis,
631
- registry: stack.registry,
632
- metricsFor: createMetricsForFactory(stack.observability.meter),
633
- dispatchSystemWrite: makeDispatchSystemWrite(stack.dispatcher),
634
- dispatchSystemQuery: makeDispatchSystemQuery(stack.dispatcher),
635
- });
636
- }
637
- // Hook für Caller-spezifische Tables + Seed. Läuft nach den Entity-
638
- // Tabellen damit das Sample auf `stack.db` / `stack.dispatcher`
639
- // zugreifen kann, und VOR dem Server-Start damit der erste HTTP-Request
640
- // bereits gegen einen gefüllten State läuft. Idempotenz ist Caller-
641
- // Verantwortung (persistent-DB-Modus läuft es bei jedem Boot).
642
- if (options.onAfterSetup !== undefined) {
643
- await options.onAfterSetup(stack);
644
- }
645
- // setupTestStack konfiguriert den eventDispatcher, startet ihn aber
646
- // NICHT — Integration-Tests drain'en deterministisch via runOnce().
647
- // Ein Dev-Server will das laufende Polling, damit SSE-Broadcasts
648
- // (system-hook sse, Priorität 1001) von selbst an connected Clients
649
- // fließen. Ohne start() bleiben alle Events in der events-Tabelle
650
- // liegen und die Tabs sehen nichts.
651
- if (stack.eventDispatcher) {
652
- await stack.eventDispatcher.start();
653
- }
654
- const redisUrl = `redis://${stack.redis.redis.options.host}:${stack.redis.redis.options.port}/${stack.redis.redis.options.db}`;
655
- const devJobRunners = await startDevJobRunners({
656
- registry: stack.registry,
657
- db: stack.db,
658
- // The stack's own AppContext, not a `{ db, registry }` literal: these
659
- // runners consume the lanes, and a job reaching for ctx.files /
660
- // ctx.notify / ctx.config would otherwise die here while the very same
661
- // handler works on the request path (kumiko-framework#1232).
662
- context: stack.context,
663
- redisUrl,
664
- // Same dispatcher the request path writes through — without this,
665
- // ctx.write/ctx.writeAs/ctx.queryAs inside a dev-run job throw on their
666
- // first call (kumiko-framework#2553).
667
- dispatcher: stack.dispatcher,
668
- // Same isolation reasoning as setupTestStack's jobs.queueNamePrefix
669
- // above — both must use the same prefix so enqueuer and consumer agree
670
- // on queue names.
671
- queueNamePrefix: jobQueueNamePrefix,
672
- });
673
- // Dev user = TestUsers.admin. Demo features are openToAll but the
674
- // auth-middleware still needs a valid JWT to let the request past.
675
- // Nicht genutzt wenn `options.auth` gesetzt ist — dann macht der Client
676
- // selbst den Login.
677
- const autoMintJwt = options.auth === undefined;
678
- const devUser = TestUsers.admin;
679
- // Build once at boot purely to surface authoring warnings (settings-hub
680
- // placement etc.) at the same point prod would — clients fetch the actual
681
- // schema themselves from GET /api/schema, so the result itself is
682
- // discarded here.
44
+ const clientBundles = await buildClientBundles(entries, buildBundle);
45
+ const stylesheet = await startDevStylesheet(options);
46
+ const templates = await loadHtmlTemplates(entries, options.htmlPath);
47
+ const dbMode = resolveDevDbMode(process.env);
48
+ const stack = await bootDevStack(options, dbMode);
49
+ const devJobRunners = await startDevBackgroundRunners(stack, dbMode.jobQueueNamePrefix);
50
+ // Built once purely to surface authoring warnings (settings-hub placement
51
+ // etc.) at the same point prod would; clients fetch the schema themselves
52
+ // from GET /api/schema, so the result is discarded.
683
53
  buildAppSchema(stack.registry, {
684
54
  authoringWarnings: true,
685
55
  searchAdapterMissing: !stack.context.searchAdapter,
686
56
  });
687
- // --- SSE reload ---
688
- // bootId identifiziert diese spezifische Server-Process-Instanz. Wird
689
- // beim Connect an jeden Browser geschickt; Browser merkt sich den
690
- // ersten Wert und refresht wenn beim Reconnect ein anderer kommt
691
- // (= Server wurde restartet, alter JS-Bundle ist stale). Siehe
692
- // RELOAD_SNIPPET oben.
693
- const bootId = String(Date.now());
694
- const reloadClients = new Set();
695
- // JSON-encoded so a filename can never break the SSE framing.
696
- const broadcastReload = (changedFile) => {
697
- const payload = `event: reload\ndata: ${JSON.stringify(changedFile)}\n\n`;
698
- for (const client of reloadClients) {
699
- if (client.closed)
700
- continue;
701
- try {
702
- client.controller.enqueue(client.encoder.encode(payload));
703
- }
704
- catch {
705
- client.closed = true;
706
- }
707
- }
708
- };
709
- // Build a fresh HTML response. In auto-mint mode (no auth config) we
710
- // pack a valid JWT + CSRF cookie right in — deep-links work immediately
711
- // without login (and that auth cookie is what a client-side fetch to
712
- // GET /api/schema authenticates with). In auth mode we serve the bare
713
- // HTML; the client then goes through /auth/login and gets its cookies
714
- // from there.
715
- //
716
- // entryName is decided by the caller (handleFetch) after evaluating
717
- // hostDispatch. Without hostDispatch it's always "client" (single-entry
718
- // default).
719
- //
720
- // Anonymous-role systemQuery, shared by resolvePageHead and hostDispatch —
721
- // mirrors runProdApp's HostDispatchFn deps so dev/prod stay symmetric.
722
- // Own resolver mirroring the same effective hops as setupTestStack's
723
- // internal buildServer — this path runs for
724
- // page-head/hostDispatch resolution, ahead of requestIdMiddleware's
725
- // /api/* mount, so it can't reuse that instance. Two independent
726
- // warn-once flags in dev is an accepted, low-risk gap (dev normally
727
- // runs unproxied — see CreateKumikoServerOptions.trustedProxyHops).
728
- const pageHeadClientIpResolver = createClientIpResolver(options.trustedProxyHops ?? 0, "createKumikoServer(pageHead)");
729
- const buildDevSystemQuery = (req, socketAddress) => buildRequestBoundSystemQuery({
730
- req,
731
- dispatcher: stack.dispatcher,
732
- resolver: pageHeadClientIpResolver,
733
- socketAddress,
57
+ const reloadHub = createReloadHub();
58
+ const handleFetch = createDevFetchHandler({
59
+ options,
60
+ stack,
61
+ entries,
62
+ clientBundles,
63
+ templates,
64
+ stylesheetPath: stylesheet?.outPath,
65
+ reloadHub,
734
66
  });
735
- // resolvePageHead goes through the same headless resolveAndInjectPageHead
736
- // that runProdApp uses, so dev and e2e exercise the one timeout and
737
- // fallback path instead of a second copy of it (#3026).
738
- const htmlResponse = async (entryName, req, socketAddress, status = 200) => {
739
- const template = htmlTemplates.get(entryName) ?? defaultTemplate;
740
- const headers = new Headers();
741
- headers.set("Content-Type", "text/html; charset=utf-8");
742
- if (autoMintJwt) {
743
- const jwt = await stack.jwt.sign(devUser);
744
- const csrf = generateToken();
745
- headers.append("Set-Cookie", `${AUTH_COOKIE}=${jwt}; Path=/; HttpOnly; SameSite=Lax`);
746
- headers.append("Set-Cookie", `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax`);
747
- }
748
- let html = injectReload(template);
749
- if (stylesheetPath !== undefined)
750
- html = injectStylesheet(html);
751
- if (options.resolvePageHead !== undefined) {
752
- const url = new URL(req.url);
753
- const host = req.headers.get("host") ?? url.host;
754
- html = await resolveAndInjectPageHead(html, options.resolvePageHead, {
755
- path: url.pathname,
756
- host,
757
- preferredLocales: rankPreferredLocales({
758
- queryLang: url.searchParams.get("lang"),
759
- acceptLanguage: req.headers.get("accept-language"),
760
- }),
761
- systemQuery: buildDevSystemQuery(req, socketAddress),
762
- requestSignal: req.signal,
763
- });
764
- }
765
- return new Response(html, { status, headers });
766
- };
767
- // Raw file serve, no bundle injection — mirrors prod's `{ kind: "html", file }`.
768
- const staticHtmlResponse = async (file, status) => {
769
- let content;
770
- try {
771
- content = await readFile(file, "utf-8");
772
- }
773
- catch (error) {
774
- if (errorCodeOf(error) !== "ENOENT")
775
- throw error;
776
- return new Response(`hostDispatch: file not found: ${file}`, { status: 500 });
777
- }
778
- return new Response(content, {
779
- status,
780
- headers: { "Content-Type": "text/html; charset=utf-8" },
781
- });
782
- };
783
- // --- Fetch handler (runtime-neutral) ---
784
- // Bundle-Pfad-Lookup: für jede Entry serven wir
785
- // GET /client[-name].js → JS-Bundle
786
- // GET /client[-name].js.map → Sourcemap
787
- // assetPathFor() ist die Single-Source-of-Truth für die URL-Form.
788
- const bundleByAssetPath = new Map();
789
- for (const e of entries)
790
- bundleByAssetPath.set(assetPathFor(e.name), e.name);
791
- // App-root convention (same as expandWatchPatterns/resolveStylesheet above):
792
- // process.cwd() is the app workspace, so public/ is its static asset dir.
793
- // Symlink-resolved once: servePublicFile compares realpath()s against it, so
794
- // a symlinked public/ would otherwise 404 every file.
795
- const lexicalPublicDir = resolve(process.cwd(), "public");
796
- const publicDir = existsSync(lexicalPublicDir)
797
- ? realpathSync(lexicalPublicDir)
798
- : lexicalPublicDir;
799
- // The app's cwd first, this package's own location as fallback (the cwd may
800
- // not have renderer/web installed, e.g. a fixture dir).
801
- const rendererWebFontsDir = resolveRendererWebFontsDir([process.cwd(), import.meta.dir]);
802
- const routeFetch = async (req, socketAddress) => {
803
- const url = new URL(req.url);
804
- // Specific routes first — assets, reload-SSE, API.
805
- if (req.method === "GET") {
806
- const bundleName = bundleByAssetPath.get(url.pathname);
807
- if (bundleName !== undefined) {
808
- const bundle = clientBundles.get(bundleName);
809
- if (bundle === undefined)
810
- return new Response("no bundle", { status: 404 });
811
- return new Response(bundle.js, {
812
- headers: { "Content-Type": "application/javascript; charset=utf-8" },
813
- });
814
- }
815
- // Looked up in the live bundles instead of a separate index, which a
816
- // hot-reload rebuild would leave stale.
817
- for (const bundle of clientBundles.values()) {
818
- const chunk = bundle.chunks?.get(url.pathname);
819
- if (chunk !== undefined) {
820
- return new Response(chunk, {
821
- headers: { "Content-Type": "application/javascript; charset=utf-8" },
822
- });
823
- }
824
- }
825
- // .js.map-Variante: gleicher Lookup mit /.map abgeschnitten.
826
- if (url.pathname.endsWith(".js.map")) {
827
- const jsPath = url.pathname.slice(0, -".map".length);
828
- const mapName = bundleByAssetPath.get(jsPath);
829
- if (mapName !== undefined) {
830
- const bundle = clientBundles.get(mapName);
831
- if (bundle === undefined || !bundle.map) {
832
- return new Response("no map", { status: 404 });
833
- }
834
- return new Response(bundle.map, {
835
- headers: { "Content-Type": "application/json; charset=utf-8" },
836
- });
837
- }
838
- }
839
- }
840
- if (url.pathname.startsWith(RENDERER_WEB_FONTS_URL_PREFIX) && req.method === "GET") {
841
- const fontFile = url.pathname.slice(RENDERER_WEB_FONTS_URL_PREFIX.length);
842
- // The allowlist pattern excludes "/", "." segments and encoded traversal.
843
- if (rendererWebFontsDir === undefined || !RENDERER_WEB_FONT_FILE_PATTERN.test(fontFile)) {
844
- return new Response("not found", { status: 404 });
845
- }
846
- try {
847
- const bytes = await readFile(join(rendererWebFontsDir, fontFile));
848
- // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types just doesn't say so.
849
- return new Response(bytes, {
850
- headers: { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=86400" },
851
- });
852
- }
853
- catch (err) {
854
- if (errorCodeOf(err) === "ENOENT") {
855
- return new Response("not found", { status: 404 });
856
- }
857
- throw err;
858
- }
859
- }
860
- if (url.pathname === "/styles.css" && req.method === "GET") {
861
- if (stylesheetPath === undefined)
862
- return new Response("no stylesheet", { status: 404 });
863
- const css = await readFile(stylesheetPath, "utf-8");
864
- return new Response(css, {
865
- headers: { "Content-Type": "text/css; charset=utf-8" },
866
- });
867
- }
868
- if (url.pathname === "/_reload" && req.method === "GET") {
869
- const encoder = new TextEncoder();
870
- const stream = new ReadableStream({
871
- start(controller) {
872
- const entry = { controller, encoder, closed: false };
873
- reloadClients.add(entry);
874
- controller.enqueue(encoder.encode(": connected\n\n"));
875
- // boot-Event: Browser-Snippet vergleicht das mit der ersten
876
- // bootId. Verschiedener Wert nach Reconnect = Server wurde
877
- // restartet → location.reload().
878
- controller.enqueue(encoder.encode(`event: boot\ndata: ${bootId}\n\n`));
879
- },
880
- cancel() {
881
- for (const c of reloadClients) {
882
- if (c.closed)
883
- reloadClients.delete(c);
884
- }
885
- },
886
- });
887
- return new Response(stream, {
888
- headers: {
889
- "Content-Type": "text/event-stream",
890
- "Cache-Control": "no-cache",
891
- Connection: "keep-alive",
892
- },
893
- });
894
- }
895
- // SPA catch-all: any GET to a non-API, non-asset path returns the
896
- // HTML shell. The client-side router then reads location.pathname
897
- // and mounts the right screen. The "no dot" filter skips
898
- // /favicon.ico etc. (let the stack's 404 handler respond).
899
- //
900
- // Backend routes that live outside /api (currently just /sse) have
901
- // to be excluded explicitly, otherwise the catch-all would shadow
902
- // the real Hono route with HTML and EventSource would never
903
- // connect.
904
- //
905
- // Plus: r.httpRoute-deklarierte Feature-Routes (z.B. /legal/*) liegen
906
- // ebenfalls außerhalb /api und matchen sonst diesen catch-all. Wir
907
- // probieren daher ZUERST stack.app.fetch — wenn Hono eine matchende
908
- // Route hat, gewinnt sie. 404 vom Hono-stack → SPA-fallback wie
909
- // bisher. Das spiegelt runProdApp's doc-intent ("Hono matched VOR
910
- // staticDir-fallback") und macht r.httpRoute mit non-/api paths im
911
- // dev-server symmetrisch zu prod.
912
- if (
913
- // HEAD mitnehmen — prod (runProdApp) fällt für GET UND HEAD auf die
914
- // SPA zurück; ohne das liefert dev 404 wo prod 200 liefert.
915
- isRoutableGetOrHead(req, url.pathname) &&
916
- !url.pathname.includes(".")) {
917
- const honoTry = await tryHonoFirst(stack.app, req, socketAddress);
918
- if (honoTry.matched) {
919
- return honoTry.response;
920
- }
921
- // Discriminated-Dispatch — symmetric zu prod. Ohne hostDispatch
922
- // landet das im Single-Entry-Default ("client" + Schema-Inject).
923
- if (options.hostDispatch !== undefined) {
924
- const dispatch = await options.hostDispatch(req, {
925
- systemQuery: buildDevSystemQuery(req, socketAddress),
926
- cspScriptHashes: [],
927
- });
928
- if (dispatch.kind === "redirect") {
929
- return new Response(null, {
930
- status: dispatch.status ?? 302,
931
- headers: { Location: dispatch.to },
932
- });
933
- }
934
- if (dispatch.kind === "not-found") {
935
- if (dispatch.file !== undefined)
936
- return staticHtmlResponse(dispatch.file, 404);
937
- if (dispatch.entryName !== undefined) {
938
- return htmlResponse(dispatch.entryName, req, socketAddress, 404);
939
- }
940
- return new Response("Not Found", { status: 404 });
941
- }
942
- if (dispatch.kind === "static-html") {
943
- return staticHtmlResponse(dispatch.file, 200);
944
- }
945
- return htmlResponse(dispatch.entryName, req, socketAddress);
946
- }
947
- return htmlResponse("client", req, socketAddress);
948
- }
949
- // Static assets under public/ — see tryServePublicAsset's own comment
950
- // for the Hono → file → router-miss ordering.
951
- const staticAsset = await tryServePublicAsset(req, url.pathname, stack.app, publicDir, socketAddress);
952
- if (staticAsset !== undefined)
953
- return staticAsset;
954
- // Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the
955
- // router-miss marker must be stripped here too — otherwise an unmatched
956
- // path would leak it straight to the client (see try-hono-first.ts's
957
- // header-hygiene note).
958
- return stripNoRouteMatchHeader(await stack.app.fetch(req, socketAddress));
959
- };
960
- const handleFetch = options.wrapFetch ? options.wrapFetch(routeFetch) : routeFetch;
961
- // --- HTTP server (Bun only) ---
962
- // Under Node/vitest we skip Bun.serve entirely — the handle's
963
- // .fetch() is the test surface. Real dev runs under Bun, where
964
- // Bun.serve wires the listener.
965
- // Bun.serve-Options kommen aus buildBunServeOptions (run-prod-app.ts)
966
- // damit Dev und Prod genau dieselben SSE-relevanten Defaults nutzen
967
- // (idleTimeout: 0). Spec-Test in run-prod-app-spec.test.ts pinst das.
968
- const server = hasBun
969
- ? Bun.serve(buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry), {
970
- upgradeFetch: async (req, env) => stripNoRouteMatchHeader(await stack.app.fetch(req, env)),
971
- }))
972
- : undefined;
973
- // --- file watcher → rebundle + reload, oder process-restart bei Schema-Änderungen ---
974
- // Heuristik: alles in `web/` oder `__tests__/` ist client-side oder
975
- // test-only — Hot-Reload reicht (rebuild + broadcast reload). Alles
976
- // andere ist server-side; Bun cached die Module-Imports, also würde ein
977
- // Schema-Change in feature.ts nicht durchschlagen ohne process-restart.
978
- // Wir exiten dann mit Code 75 (EX_TEMPFAIL) — `kumiko-dev` Wrapper
979
- // detected das und respawnt.
980
- //
981
- // watcherAbort wird beim stop() ausgelöst → fs.watch beendet die
982
- // async-iteration → kein Watcher überlebt einen Test-Teardown und
983
- // klassifiziert ein rmSync(tmpdir) als "restart needed".
67
+ const server = startDevHttpServer(port, handleFetch, stack);
984
68
  const watcherAbort = new AbortController();
985
- if (entries.length > 0) {
986
- // Watch-Dirs: alle entry-Verzeichnisse (deduped) plus die explizit
987
- // angegebenen watchDirs. In Multi-Entry-Setups liegen die Entries
988
- // oft im selben src/-Verzeichnis (`src/client-admin.tsx` +
989
- // `src/client-public.tsx`) — der Set kollabiert das auf einen
990
- // Watcher pro Verzeichnis.
991
- const entryDirs = new Set();
992
- for (const e of entries)
993
- entryDirs.add(resolve(e.sourceFile, ".."));
994
- const entrySourceFiles = new Set(entries.map((e) => resolve(e.sourceFile)));
995
- const dirs = [...entryDirs, ...expandWatchPatterns(options.watchDirs ?? [])];
996
- for (const dir of dirs) {
997
- void watchDir(dir, async (filename) => {
998
- const action = classifyWatchedChange(dir, filename, entrySourceFiles);
999
- // skip: file change classified as ignore (test/css/json), nothing to rebuild
1000
- if (action === "ignore")
1001
- return;
1002
- if (action === "restart") {
1003
- logInfo(`[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht). ` +
1004
- "The kumiko-dev wrapper (package.json's `dev` script) respawns automatically " +
1005
- "and re-syncs new r.entity() columns on the new boot; running this entry " +
1006
- "directly (not via kumiko-dev) exits here without restarting.");
1007
- await stop();
1008
- process.exit(75);
1009
- }
1010
- try {
1011
- // Alle Entries rebuilden — auch wenn nur eine Datei sich
1012
- // ändert, wir wissen nicht welche Entries sie importieren.
1013
- // Bei zwei Entries mit shared Code triggert ein Edit der
1014
- // gemeinsamen Datei beide Bundles neu, das ist gewollt.
1015
- for (const e of entries) {
1016
- const rebuilt = await buildBundle(e.sourceFile);
1017
- clientBundles.set(e.name, rebuilt);
1018
- }
1019
- logInfo(`[kumiko-server] rebuilt on ${filename}, broadcasting reload`);
1020
- broadcastReload(filename);
1021
- }
1022
- catch {
1023
- // buildClient already logged the failure; keep serving the
1024
- // last good bundle until the next successful rebuild.
1025
- }
1026
- }, watcherAbort.signal, options._watchDirectory ?? watch);
1027
- }
1028
- }
1029
- // The per-boot prefix is never reused, so its BullMQ keys (queues,
1030
- // repeatables, delayed jobs) would pile up in the shared Redis forever.
1031
- // A prefix-less duplicate: SCAN returns full key names, which the stack
1032
- // client's keyPrefix would otherwise prepend a second time.
1033
- // Takes the connection as a parameter: it must be opened before
1034
- // stack.cleanup() closes the stack client, yet used only after it, because
1035
- // closing the stack's enqueuer queues re-writes their `meta` keys.
1036
- const deleteEphemeralJobQueues = async (raw) => {
1037
- try {
1038
- const keys = [];
1039
- for await (const batch of raw.scanStream({
1040
- match: `bull:${jobQueueNamePrefix}-*`,
1041
- count: 500,
1042
- })) {
1043
- keys.push(...batch);
1044
- }
1045
- if (keys.length > 0)
1046
- await raw.del(...keys);
1047
- }
1048
- finally {
1049
- raw.disconnect();
1050
- }
1051
- };
1052
- const stop = async () => {
1053
- // Watcher zuerst stoppen damit kein onChange während des Teardowns
1054
- // mehr feuert (sonst können tmpdir-rmSync ein process.exit(75)
1055
- // auslösen).
1056
- watcherAbort.abort();
1057
- if (killTailwind)
1058
- killTailwind();
1059
- if (server !== undefined) {
1060
- server.stop(true);
1061
- }
1062
- if (stack.eventDispatcher) {
1063
- await stack.eventDispatcher.stop();
1064
- }
1065
- await devJobRunners.stop();
1066
- const rawRedis = persistentDb ? undefined : stack.redis.redis.duplicate({ keyPrefix: "" });
1067
- await stack.cleanup();
1068
- if (rawRedis)
1069
- await deleteEphemeralJobQueues(rawRedis);
1070
- };
1071
- // --- graceful shutdown ---
1072
- // Signal handlers fire on Ctrl-C / kill. Without them, repeated dev
1073
- // restarts leak Postgres pools, lassen Tailwind-Watcher als orphan
1074
- // hängen und (in persistent mode) hinterlassen temp Clients.
1075
- // uncaughtException + unhandledRejection: Crashes hatten den Tailwind-
1076
- // Watcher nicht gekillt, der lief munter weiter im Hintergrund. Jetzt
1077
- // räumen wir auch im Fehlerfall auf bevor wir mit non-zero exit'n.
1078
- const installHandlers = options.installSignalHandlers ?? true;
1079
- if (installHandlers) {
1080
- for (const sig of ["SIGINT", "SIGTERM"]) {
1081
- process.on(sig, async () => {
1082
- logInfo(`[kumiko-server] ${sig} — cleaning up…`);
1083
- await stop();
1084
- process.exit(0);
1085
- });
1086
- }
1087
- process.on("uncaughtException", async (err) => {
1088
- logError("[kumiko-server] uncaughtException — cleaning up…", err);
1089
- try {
1090
- await stop();
1091
- }
1092
- finally {
1093
- process.exit(1);
1094
- }
1095
- });
1096
- process.on("unhandledRejection", async (err) => {
1097
- logError("[kumiko-server] unhandledRejection — cleaning up…", err);
1098
- try {
1099
- await stop();
1100
- }
1101
- finally {
1102
- process.exit(1);
1103
- }
1104
- });
1105
- }
69
+ const stop = createDevServerStop({
70
+ watcherAbort,
71
+ killTailwind: stylesheet?.kill,
72
+ server,
73
+ stack,
74
+ devJobRunners,
75
+ dbMode,
76
+ });
77
+ startDevFileWatchers({
78
+ entries,
79
+ watchDirs: options.watchDirs,
80
+ watchDirectory: options._watchDirectory,
81
+ signal: watcherAbort.signal,
82
+ buildBundle,
83
+ clientBundles,
84
+ reloadHub,
85
+ stop,
86
+ });
87
+ if (options.installSignalHandlers ?? true)
88
+ installDevSignalHandlers(stop);
1106
89
  if (server !== undefined) {
1107
90
  logInfo(`[kumiko-server] listening on http://localhost:${port}` +
1108
91
  (entries.length > 0
1109
92
  ? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
1110
93
  : ""));
1111
94
  }
1112
- return { fetch: handleFetch, server, stack, stop, jobQueueNamePrefix };
95
+ return { fetch: handleFetch, server, stack, stop, jobQueueNamePrefix: dbMode.jobQueueNamePrefix };
1113
96
  }