@cosmicdrift/kumiko-dev-server 0.329.0 → 0.330.2

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