@cosmicdrift/kumiko-dev-server 0.387.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 (42) hide show
  1. package/dist/build-server-bundle.d.ts +1 -1
  2. package/dist/build-server-bundle.js +2 -2
  3. package/dist/create-kumiko-server-client-bundles.d.ts +21 -0
  4. package/dist/create-kumiko-server-client-bundles.js +97 -0
  5. package/dist/create-kumiko-server-html.d.ts +21 -0
  6. package/dist/create-kumiko-server-html.js +132 -0
  7. package/dist/create-kumiko-server-public-files.d.ts +6 -0
  8. package/dist/create-kumiko-server-public-files.js +96 -0
  9. package/dist/create-kumiko-server-reload.d.ts +14 -0
  10. package/dist/create-kumiko-server-reload.js +73 -0
  11. package/dist/create-kumiko-server-routes.d.ts +16 -0
  12. package/dist/create-kumiko-server-routes.js +206 -0
  13. package/dist/create-kumiko-server-shutdown.d.ts +14 -0
  14. package/dist/create-kumiko-server-shutdown.js +72 -0
  15. package/dist/create-kumiko-server-stack.d.ts +12 -0
  16. package/dist/create-kumiko-server-stack.js +101 -0
  17. package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
  18. package/dist/create-kumiko-server-stylesheet.js +137 -0
  19. package/dist/create-kumiko-server-watcher.d.ts +22 -0
  20. package/dist/create-kumiko-server-watcher.js +155 -0
  21. package/dist/create-kumiko-server.d.ts +6 -20
  22. package/dist/create-kumiko-server.js +59 -1046
  23. package/dist/dev-server-runtime.d.ts +5 -0
  24. package/dist/dev-server-runtime.js +11 -0
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.js +3 -1
  27. package/dist/managed-files/app-jobs.js +1 -1
  28. package/dist/managed-files/check.js +1 -1
  29. package/dist/run-dev-app-auth.d.ts +38 -0
  30. package/dist/run-dev-app-auth.js +155 -0
  31. package/dist/run-dev-app-config.d.ts +26 -0
  32. package/dist/run-dev-app-config.js +185 -0
  33. package/dist/run-dev-app-server-options.d.ts +13 -0
  34. package/dist/run-dev-app-server-options.js +49 -0
  35. package/dist/run-dev-app.d.ts +4 -4
  36. package/dist/run-dev-app.js +86 -459
  37. package/dist/schema-apply.d.ts +1 -1
  38. package/dist/schema-apply.js +4 -4
  39. package/dist/write-server-lockfile.js +23 -16
  40. package/package.json +9 -9
  41. package/src/changes.json +30 -0
  42. package/templates/managed/ci-public.yml.template +4 -0
@@ -14,1070 +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
- // Two reload triggers:
65
- // - explicit `reload` event from the server after hot reload (rebuild + send)
66
- // - implicit `boot` event on every SSE connection, containing the current
67
- // server process's bootId. The snippet remembers the first bootId; if a
68
- // reconnect receives a different one, it refreshes. Otherwise the
69
- // browser could remain on a stale bundle after a server restart.
70
- const RELOAD_SNIPPET = `
71
- <script>
72
- (() => {
73
- const es = new EventSource("/_reload");
74
- let firstBootId = null;
75
- es.addEventListener("boot", (e) => {
76
- if (firstBootId === null) {
77
- firstBootId = e.data;
78
- } else if (firstBootId !== e.data) {
79
- location.reload();
80
- }
81
- });
82
- es.addEventListener("reload", () => location.reload());
83
- })();
84
- </script>
85
- `;
86
- // Minimal HTML when the caller didn't hand one in. `#root` is the
87
- // default mount target for `createKumikoApp`, so the one-line client
88
- // can attach without the sample having to ship its own template.
89
- //
90
- // `type="module"` is load-bearing: a classic script turns the bundle's
91
- // top-level declarations into window properties, so a dependency exporting
92
- // `function history()` (prosemirror-history, pulled in by tiptap) silently
93
- // replaces `window.history` and every pushState afterwards throws. Prod
94
- // (build-prod-bundle) has always emitted the module form.
95
- //
96
- // `interactive-widget=resizes-content`: without it, a mobile
97
- // keyboard shrinks only the visual viewport, not the layout viewport that
98
- // `position: fixed` anchors to — a fixed bottom action bar would stay
99
- // pinned behind the keyboard instead of above it. iOS Safari ignores the
100
- // key; renderer/web's useKeyboardInset covers it via window.visualViewport.
101
- export const DEFAULT_HTML = `<!doctype html>
102
- <html lang="en">
103
- <head>
104
- <meta charset="utf-8" />
105
- <meta name="viewport" content="width=device-width,initial-scale=1,interactive-widget=resizes-content" />
106
- <title>Kumiko</title>
107
- </head>
108
- <body>
109
- <div id="root"></div>
110
- <script type="module" src="/client.js"></script>
111
- </body>
112
- </html>
113
- `;
114
- async function buildClient(entry) {
115
- if (!hasBun) {
116
- throw new Error("[kumiko-server] clientEntry is only supported under Bun — Bun.build is unavailable in this runtime.");
117
- }
118
- const unminified = process.env["KUMIKO_DEV_UNMINIFIED"] === "1";
119
- // Same splitting/sourcemap/NODE_ENV as build-prod-bundle.ts, opt-in via
120
- // env so `bun dev` stays unchanged.
121
- const prodShaped = process.env[PROD_BUNDLES_ENV] === "1";
122
- const built = await Bun.build({
123
- entrypoints: [entry],
124
- target: "browser",
125
- minify: !unminified,
126
- sourcemap: prodShaped ? "none" : "linked",
127
- splitting: prodShaped,
128
- ...(prodShaped ? { define: { "process.env.NODE_ENV": JSON.stringify("production") } } : {}),
129
- });
130
- if (!built.success) {
131
- logError("[kumiko-server] client bundle failed:");
132
- for (const log of built.logs)
133
- logError(log);
134
- throw new Error("client bundle failed");
135
- }
136
- const jsOutput = built.outputs.find((o) => o.kind === "entry-point");
137
- const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
138
- if (!jsOutput)
139
- throw new Error("[kumiko-server] client bundle produced no .js output");
140
- const chunks = new Map();
141
- for (const output of built.outputs) {
142
- if (output.kind !== "chunk")
143
- continue;
144
- const chunkPath = `/${output.path.replace(/^\.\//, "")}`;
145
- chunks.set(chunkPath, await output.text());
146
- }
147
- return {
148
- js: await jsOutput.text(),
149
- map: mapOutput ? await mapOutput.text() : "",
150
- chunks,
151
- };
152
- }
153
- function injectReload(html) {
154
- if (html.includes("/_reload"))
155
- return html;
156
- return html.includes("</body>")
157
- ? html.replace("</body>", `${RELOAD_SNIPPET}</body>`)
158
- : html + RELOAD_SNIPPET;
159
- }
160
- // Injects <link rel="stylesheet" href="/styles.css"> into <head> if it
161
- // is not already present. Called only when the stylesheet option is used;
162
- // otherwise no CSS route is provided.
163
- function injectStylesheet(html) {
164
- if (html.includes('href="/styles.css"'))
165
- return html;
166
- const link = '<link rel="stylesheet" href="/styles.css" />';
167
- return html.includes("</head>")
168
- ? html.replace("</head>", ` ${link}\n</head>`)
169
- : `${link}${html}`;
170
- }
171
- // GET/HEAD to a non-API, non-SSE path — the dispatch-worthy set shared by
172
- // the SPA catch-all and the public/-file lookup below (they split on
173
- // whether the path has a dot).
174
- function isRoutableGetOrHead(req, pathname) {
175
- return ((req.method === "GET" || req.method === "HEAD") &&
176
- !pathname.startsWith("/api/") &&
177
- !pathname.startsWith("/sse"));
178
- }
179
- // Resolves a request pathname to a file inside publicDir, or undefined if
180
- // it isn't one. Two traversal vectors, both must be blocked:
181
- // - literal ".." segments (new URL() already collapses these in
182
- // `pathname`, but resolve()+containment is checked regardless — no
183
- // path-safety may depend on caller behavior upstream).
184
- // - percent-encoded slashes (e.g. "%2e%2e%2f"): WHATWG URL parsing only
185
- // normalizes dot-segments that are delimited by a literal "/", so an
186
- // encoded slash survives into `pathname` untouched. decodeURIComponent
187
- // resolves it to "../" before the containment check runs.
188
- function resolvePublicFilePath(pathname, publicDir) {
189
- let decoded;
190
- try {
191
- decoded = decodeURIComponent(pathname);
192
- }
193
- catch {
194
- return undefined;
195
- }
196
- // A NUL byte makes readFile throw ERR_INVALID_ARG_VALUE (not ENOENT), which
197
- // would surface as a 500 instead of a router miss.
198
- if (decoded.includes("\0"))
199
- return undefined;
200
- const resolved = resolve(publicDir, `.${decoded}`);
201
- return resolved === publicDir || resolved.startsWith(publicDir + sep) ? resolved : undefined;
202
- }
203
- // Same extension → content-type mapping as prod's
204
- // run-prod-app-static-files.ts#mimeTypeFor — that function isn't part of
205
- // server-runtime's public exports (its own package.json "exports" map),
206
- // so this is a small self-contained copy rather than a new cross-package
207
- // export for one call site.
208
- const PUBLIC_FILE_MIME_TYPES = new Map([
209
- ["html", "text/html; charset=utf-8"],
210
- ["js", "text/javascript; charset=utf-8"],
211
- ["mjs", "text/javascript; charset=utf-8"],
212
- ["css", "text/css; charset=utf-8"],
213
- ["json", "application/json; charset=utf-8"],
214
- ["svg", "image/svg+xml"],
215
- ["png", "image/png"],
216
- ["jpg", "image/jpeg"],
217
- ["jpeg", "image/jpeg"],
218
- ["ico", "image/x-icon"],
219
- ["txt", "text/plain; charset=utf-8"],
220
- ["xml", "application/xml; charset=utf-8"],
221
- ["webmanifest", "application/manifest+json"],
222
- ["woff2", "font/woff2"],
223
- ]);
224
- function publicFileMimeType(filePath) {
225
- const ext = filePath.toLowerCase().split(".").pop() ?? "";
226
- return PUBLIC_FILE_MIME_TYPES.get(ext) ?? "application/octet-stream";
227
- }
228
- // Reads a file under publicDir for the dev-server's static-asset fallback
229
- // (dev-parity with prod's disk lookup in buildStaticFallback). undefined
230
- // means "not a servable file here" — caller treats that as a router miss,
231
- // same as ENOENT/EISDIR/ENOTDIR further down.
232
- async function servePublicFile(pathname, publicDir) {
233
- const filePath = resolvePublicFilePath(pathname, publicDir);
234
- if (filePath === undefined)
235
- return undefined;
236
- try {
237
- // Lexical containment (above) only catches "..", not a symlink inside
238
- // publicDir that points outside it on disk — realpath resolves the
239
- // actual target and the same containment check runs against it. Read
240
- // from the resolved path so the checked target is the one read.
241
- const real = await realpath(filePath);
242
- if (real !== publicDir && !real.startsWith(publicDir + sep))
243
- return undefined;
244
- const bytes = await readFile(real);
245
- return { bytes, mime: publicFileMimeType(filePath) };
246
- }
247
- catch (err) {
248
- const code = errorCodeOf(err);
249
- if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR" || code === "ENAMETOOLONG") {
250
- return undefined;
251
- }
252
- throw err;
253
- }
254
- }
255
- // Static assets under public/ — prod serves these via buildStaticFallback's
256
- // disk lookup (run-prod-app-static-files.ts), dev previously had no
257
- // equivalent: a dotted path (e.g. /marketing/hero.png) fell straight
258
- // through to the API stack and 404ed. Hono still goes first (an
259
- // r.httpRoute could itself own a dotted path), then the file on disk,
260
- // then the router-miss 404 — mirrors handleFetch's SPA branch. undefined
261
- // means "not a static-asset request", caller falls through to the next
262
- // route.
263
- async function tryServePublicAsset(req, pathname, app, publicDir, socketAddress) {
264
- if (!isRoutableGetOrHead(req, pathname) || !pathname.includes("."))
265
- return undefined;
266
- const honoTry = await tryHonoFirst(app, req, socketAddress);
267
- if (honoTry.matched) {
268
- return honoTry.response;
269
- }
270
- const file = await servePublicFile(pathname, publicDir);
271
- if (file !== undefined) {
272
- // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types
273
- // just doesn't say so — same cast run-prod-app-static-files.ts uses.
274
- return new Response(file.bytes, {
275
- headers: { "Content-Type": file.mime },
276
- });
277
- }
278
- return honoTry.response;
279
- }
280
- async function watchDir(dir, onChange, signal, watchEvents) {
281
- // The server's stop() aborts the signal; without it, the for-await loop
282
- // would continue until process exit. During test teardown, removing the
283
- // temporary directory could then be classified as a restart and call
284
- // process.exit(75), surfacing as an unhandled Vitest error.
285
- const watcher = watchEvents(dir, { recursive: true, signal });
286
- try {
287
- for await (const ev of watcher) {
288
- if (ev.filename)
289
- onChange(ev.filename);
290
- }
291
- }
292
- catch (err) {
293
- // signal.abort() causes the async iterator to throw AbortError during
294
- // expected teardown; other errors must propagate.
295
- // skip: AbortSignal fired the abort, this is expected teardown not a real error
296
- if (typeof err === "object" && err !== null && "name" in err && err.name === "AbortError")
297
- return;
298
- throw err;
299
- }
300
- }
301
- // Classifies a changed file as `hot-reload` for client-side changes,
302
- // `restart` for server-side changes whose cached Bun modules must be
303
- // reloaded, or `ignore` when the server is unaffected.
304
- //
305
- // Tests and non-TypeScript files are ignored. TypeScript files under
306
- // `/web/`, `/admin/`, `/public/`, or `/client/`, as well as the client
307
- // entry itself, trigger hot reload; other TypeScript files trigger a
308
- // restart because they may contain schema or feature definitions.
309
- //
310
- // The multiple client directories are intentional: samples use different
311
- // layouts. Restarting for a client-only edit would be slower, drop the
312
- // ephemeral test database, and reseed it unnecessarily.
313
- //
314
- // Exported for tests; internally called by the watcher loop.
315
- export function classifyChange(filename) {
316
- if (!filename.endsWith(".ts") && !filename.endsWith(".tsx"))
317
- return "ignore";
318
- if (filename.includes("__tests__"))
319
- return "ignore";
320
- if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx"))
321
- return "ignore";
322
- if (filename.endsWith(".integration.ts") ||
323
- filename.endsWith(".integration.test.ts") ||
324
- filename.endsWith(".e2e.ts")) {
325
- return "ignore";
326
- }
327
- // Platform-independent: checks both POSIX and Windows separators.
328
- // Matches `<sep><dir><sep>` and trailing-`<sep><dir>` for watcher
329
- // filenames supplied as relative paths.
330
- const clientSubdirs = ["web", "admin", "public", "client"];
331
- for (const dir of clientSubdirs) {
332
- if (filename.includes(`/${dir}/`) ||
333
- filename.includes(`\\${dir}\\`) ||
334
- filename.startsWith(`${dir}/`) ||
335
- filename.startsWith(`${dir}\\`)) {
336
- return "hot-reload";
337
- }
338
- }
339
- if (filename.endsWith("/client.tsx") || filename.endsWith("/client.ts")) {
340
- return "hot-reload";
341
- }
342
- return "restart";
343
- }
344
- // The watcher reports paths relative to the watched directory, so an entry arrives as a bare
345
- // "client.tsx" (or a custom name like "client-admin.tsx") that classifyChange alone reads as a
346
- // server file and answers with a restart.
347
- export function classifyWatchedChange(watchedDir, filename, entrySourceFiles) {
348
- return entrySourceFiles.has(resolve(watchedDir, filename))
349
- ? "hot-reload"
350
- : classifyChange(filename);
351
- }
352
- // Expands watchDirs patterns into concrete directories. Entries without
353
- // `*` are resolved as ordinary paths; entries with `*` are glob-expanded
354
- // and only directory matches are retained, so `"../../../packages/*/src"`
355
- // replaces a list of hard-coded paths. Expansion runs once at boot,
356
- // not while the watcher is running.
357
- function expandWatchPatterns(patterns) {
358
- const out = [];
359
- for (const p of patterns) {
360
- if (!p.includes("*")) {
361
- out.push(resolve(p));
362
- continue;
363
- }
364
- // Only reachable under Bun (createKumikoServer is Bun-only); without Bun the
365
- // call stack fails fast in Bun.serve anyway.
366
- if (typeof Bun === "undefined")
367
- throw new Error("expandWatchPatterns requires Bun.Glob");
368
- const glob = new Bun.Glob(p);
369
- const matches = Array.from(glob.scanSync({ onlyFiles: false, cwd: process.cwd() }));
370
- for (const m of matches) {
371
- const abs = resolve(m);
372
- try {
373
- if (statSync(abs).isDirectory())
374
- out.push(abs);
375
- }
376
- catch {
377
- // ignore unreadable matches — typisch defekte Symlinks
378
- }
379
- }
380
- }
381
- return out;
382
- }
383
- // Resolves the path to the Tailwind entry CSS:
384
- // - explicit string → the resolved absolute path
385
- // - false → CSS pipeline off (undefined)
386
- // - undefined + client entries:
387
- // 1. the app's own src/styles.css (theme override) if present
388
- // 2. else the default `@cosmicdrift/kumiko-renderer/web/styles.css` via package exports
389
- // - undefined + no client entry: undefined (no CSS needed)
390
- //
391
- // The src/styles.css auto-detection mirrors build-prod-bundle:resolveStylesheetEntry
392
- // so dev and prod resolve identically. Without it every app would have to set
393
- // `stylesheet: "./src/styles.css"`, or dev would fall back to the default and
394
- // ignore the brand tokens.
395
- //
396
- // @internal — exported for unit tests only, not re-exported from the package index.
397
- export function resolveStylesheet(options) {
398
- if (options.stylesheet === false)
399
- return undefined;
400
- if (typeof options.stylesheet === "string")
401
- return resolve(options.stylesheet);
402
- const hasAnyEntry = options.clientEntry !== undefined ||
403
- (options.clientEntries !== undefined && options.clientEntries.length > 0);
404
- 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)
405
35
  return undefined;
406
- // The app's own src/styles.css beats the renderer/web default, same as
407
- // kumiko-build, so local and prod builds match.
408
- const local = resolve(process.cwd(), "src/styles.css");
409
- if (existsSync(local))
410
- return local;
411
- // Bun.resolveSync follows package exports: `"./styles.css"` in
412
- // renderer/web's package.json resolves to the workspace file in the
413
- // monorepo or the node_modules file in an installed app. Bun already
414
- // returns an absolute filesystem path, so `fileURLToPath` is unnecessary.
415
- if (!hasBun) {
416
- // Node-based unit tests reach this branch. Without Bun, package-export
417
- // resolution and a real Tailwind pipeline are unavailable, so skip
418
- // silently rather than reporting an error.
419
- return undefined;
420
- }
421
- try {
422
- const resolved = Bun.resolveSync("@cosmicdrift/kumiko-renderer/web/styles.css", process.cwd());
423
- if (!canResolveTailwindStylesheet(resolved, { bun: Bun, cwd: process.cwd() })) {
424
- return undefined;
425
- }
426
- return resolved;
427
- }
428
- catch (err) {
429
- logError("[kumiko-server] couldn't auto-resolve @cosmicdrift/kumiko-renderer/web/styles.css — " +
430
- "pass `stylesheet: <path>` or `stylesheet: false` explicitly.", err);
431
- return undefined;
432
- }
433
- }
434
- // The E2E template can only reach an app's own server entry through its env,
435
- // so the env has to count whenever the option is left undefined.
436
- //
437
- // @internal — exported for unit tests only, not re-exported from the index.
438
- export function resolveStylesheetWatch(options, env = process.env) {
439
- if (options.stylesheetWatch !== undefined)
440
- return options.stylesheetWatch;
441
- return env[STYLESHEET_WATCH_ENV] !== "0";
442
- }
443
- // Non-fatal by design (returns undefined): if the CLI can't be resolved or
444
- // the initial build fails (flaky network, missing dependency), the server
445
- // boots without CSS instead of dying.
446
- async function startTailwindPipeline(entryCss, { watch }) {
447
- const bunResolver = hasBun ? Bun : 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
- // Wait for the initial build so the first /styles.css request does not
461
- // return 404. Then run the watcher in the background with unref();
462
- // otherwise it could remain as an orphan after the parent crashes.
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
- // Already stopped or never started; no further action is needed.
496
- }
497
- },
498
- };
499
- }
500
- /** URL path used to serve an entry: `"client"` → `/client.js`
501
- * (the single-entry default), otherwise `/client-<name>.js`. Keeping
502
- * this in one place ensures routing and logging use the same convention. */
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 [];
36
+ return Bun.serve(buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry), {
37
+ upgradeFetch: async (req, env) => stripNoRouteMatchHeader(await stack.app.fetch(req, env)),
38
+ }));
524
39
  }
525
40
  export async function createKumikoServer(options) {
526
41
  const port = options.port ?? Number(process.env["PORT"] ?? 4173);
527
- // --- client bundles (single-entry or multi-entry through the same map) ---
528
42
  const entries = normalizeEntries(options);
529
43
  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 uses one template (`htmlPath` or DEFAULT_HTML).
559
- // Multi-entry uses one per entry (`entry.htmlPath ?? options.htmlPath
560
- // ?? DEFAULT_HTML`); hostDispatch selects it per request.
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 = isPersistentDevDb(process.env);
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
- ...(options.errorDocs && { errorDocs: options.errorDocs }),
600
- // jobs.consumerLane unset = enqueuer-only; startDevJobRunners below is
601
- // the sole consumer/cron-scheduler per lane, so runOnBoot/cron jobs
602
- // don't double-fire. queueNamePrefix keeps this boot's queues isolated
603
- // from every other dev/e2e server sharing the same Redis.
604
- jobs: { queueNamePrefix: jobQueueNamePrefix },
605
- });
606
- await createEventsTable(stack.db);
607
- await pushEntityProjectionTables(stack, stack.registry);
608
- // `extraRoutes` is already mounted — setupTestStack's buildServer() call
609
- // wired it above, before this function ever runs. `wire` is the
610
- // remaining escape-hatch for app-wired co-running components that need
611
- // the system-write dispatcher but don't declare a route.
612
- if (options.wire !== undefined) {
613
- await options.wire({
614
- db: stack.db,
615
- // The raw ioredis client, not the TestRedis wrapper, matching runProdApp
616
- // so application code sees the same interface in development and production.
617
- redis: stack.redis.redis,
618
- registry: stack.registry,
619
- metricsFor: createMetricsForFactory(stack.observability.meter),
620
- dispatchSystemWrite: makeDispatchSystemWrite(stack.dispatcher),
621
- dispatchSystemQuery: makeDispatchSystemQuery(stack.dispatcher),
622
- });
623
- }
624
- // Hook for caller-specific tables and seeding. It runs after entity tables
625
- // are created, so the hook can use `stack.db` / `stack.dispatcher`, and
626
- // before the server starts, so the first request sees initialized state. Idempotence is the caller's responsibility because
627
- // persistent-DB mode runs the hook on every boot.
628
- if (options.onAfterSetup !== undefined) {
629
- await options.onAfterSetup(stack);
630
- }
631
- // setupTestStack configures but does not start the event dispatcher so
632
- // integration tests can drain it deterministically with runOnce(). The
633
- // dev server starts polling so SSE broadcasts reach connected clients;
634
- // otherwise events remain in the events table.
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.
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.
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 requires a valid JWT. When `options.auth` is set,
659
- // the client performs login instead.
660
- const autoMintJwt = options.auth === undefined;
661
- const devUser = TestUsers.admin;
662
- // Build once at boot purely to surface authoring warnings (settings-hub
663
- // placement etc.) at the same point prod would — clients fetch the actual
664
- // schema themselves from GET /api/schema, so the result itself is
665
- // 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.
666
53
  buildAppSchema(stack.registry, {
667
54
  authoringWarnings: true,
668
55
  searchAdapterMissing: !stack.context.searchAdapter,
669
56
  });
670
- // --- SSE reload ---
671
- // bootId identifies this server process instance. It is sent on connect;
672
- // the browser remembers the first value and refreshes when a reconnect
673
- // receives a different one, indicating that the server restarted and
674
- // the old bundle is stale. See RELOAD_SNIPPET above.
675
- const bootId = String(Date.now());
676
- const reloadClients = new Set();
677
- // JSON-encoded so a filename can never break the SSE framing.
678
- const broadcastReload = (changedFile) => {
679
- const payload = `event: reload\ndata: ${JSON.stringify(changedFile)}\n\n`;
680
- for (const client of reloadClients) {
681
- if (client.closed)
682
- continue;
683
- try {
684
- client.controller.enqueue(client.encoder.encode(payload));
685
- }
686
- catch {
687
- client.closed = true;
688
- }
689
- }
690
- };
691
- // Build a fresh HTML response. In auto-mint mode (no auth config) we
692
- // pack a valid JWT + CSRF cookie right in — deep-links work immediately
693
- // without login (and that auth cookie is what a client-side fetch to
694
- // GET /api/schema authenticates with). In auth mode we serve the bare
695
- // HTML; the client then goes through /auth/login and gets its cookies
696
- // from there.
697
- //
698
- // entryName is decided by the caller (handleFetch) after evaluating
699
- // hostDispatch. Without hostDispatch it's always "client" (single-entry
700
- // default).
701
- //
702
- // Anonymous-role systemQuery, shared by resolvePageHead and hostDispatch —
703
- // mirrors runProdApp's HostDispatchFn deps so dev/prod stay symmetric.
704
- // Own resolver mirroring the same effective hops as setupTestStack's
705
- // internal buildServer — this path runs for
706
- // page-head/hostDispatch resolution, ahead of requestIdMiddleware's
707
- // /api/* mount, so it can't reuse that instance. Two independent
708
- // warn-once flags in dev is an accepted, low-risk gap (dev normally
709
- // runs unproxied — see CreateKumikoServerOptions.trustedProxyHops).
710
- const pageHeadClientIpResolver = createClientIpResolver(options.trustedProxyHops ?? 0, "createKumikoServer(pageHead)");
711
- const buildDevSystemQuery = (req, socketAddress) => buildRequestBoundSystemQuery({
712
- req,
713
- dispatcher: stack.dispatcher,
714
- resolver: pageHeadClientIpResolver,
715
- 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,
716
66
  });
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.
720
- const htmlResponse = async (entryName, req, socketAddress, status = 200) => {
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
- preferredLocales: rankPreferredLocales({
740
- queryLang: url.searchParams.get("lang"),
741
- acceptLanguage: req.headers.get("accept-language"),
742
- }),
743
- systemQuery: buildDevSystemQuery(req, socketAddress),
744
- requestSignal: req.signal,
745
- });
746
- }
747
- return new Response(html, { status, headers });
748
- };
749
- // Raw file serve, no bundle injection — mirrors prod's `{ kind: "html", file }`.
750
- const staticHtmlResponse = async (file, status) => {
751
- let content;
752
- try {
753
- content = await readFile(file, "utf-8");
754
- }
755
- catch (error) {
756
- if (errorCodeOf(error) !== "ENOENT")
757
- throw error;
758
- return new Response(`hostDispatch: file not found: ${file}`, { status: 500 });
759
- }
760
- return new Response(content, {
761
- status,
762
- headers: { "Content-Type": "text/html; charset=utf-8" },
763
- });
764
- };
765
- // --- Fetch handler (runtime-neutral) ---
766
- // Bundle path lookup: each entry is served at
767
- // GET /client[-name].js → JS bundle
768
- // GET /client[-name].js.map → sourcemap
769
- // assetPathFor() is the single source of truth for the URL form.
770
- const bundleByAssetPath = new Map();
771
- for (const e of entries)
772
- bundleByAssetPath.set(assetPathFor(e.name), e.name);
773
- // App-root convention (same as expandWatchPatterns/resolveStylesheet above):
774
- // process.cwd() is the app workspace, so public/ is its static asset dir.
775
- // Symlink-resolved once: servePublicFile compares realpath()s against it, so
776
- // a symlinked public/ would otherwise 404 every file.
777
- const lexicalPublicDir = resolve(process.cwd(), "public");
778
- const publicDir = existsSync(lexicalPublicDir)
779
- ? realpathSync(lexicalPublicDir)
780
- : lexicalPublicDir;
781
- // The app's cwd first, this package's own location as fallback (the cwd may
782
- // not have renderer/web installed, e.g. a fixture dir).
783
- const rendererWebFontsDir = resolveRendererWebFontsDir([process.cwd(), import.meta.dir]);
784
- const routeFetch = async (req, socketAddress) => {
785
- const url = new URL(req.url);
786
- // Specific routes first — assets, reload-SSE, API.
787
- if (req.method === "GET") {
788
- const bundleName = bundleByAssetPath.get(url.pathname);
789
- if (bundleName !== undefined) {
790
- const bundle = clientBundles.get(bundleName);
791
- if (bundle === undefined)
792
- return new Response("no bundle", { status: 404 });
793
- return new Response(bundle.js, {
794
- headers: { "Content-Type": "application/javascript; charset=utf-8" },
795
- });
796
- }
797
- // Looked up in the live bundles instead of a separate index, which a
798
- // hot-reload rebuild would leave stale.
799
- for (const bundle of clientBundles.values()) {
800
- const chunk = bundle.chunks?.get(url.pathname);
801
- if (chunk !== undefined) {
802
- return new Response(chunk, {
803
- headers: { "Content-Type": "application/javascript; charset=utf-8" },
804
- });
805
- }
806
- }
807
- // .js.map variant: perform the same lookup after removing /.map.
808
- if (url.pathname.endsWith(".js.map")) {
809
- const jsPath = url.pathname.slice(0, -".map".length);
810
- const mapName = bundleByAssetPath.get(jsPath);
811
- if (mapName !== undefined) {
812
- const bundle = clientBundles.get(mapName);
813
- if (bundle === undefined || !bundle.map) {
814
- return new Response("no map", { status: 404 });
815
- }
816
- return new Response(bundle.map, {
817
- headers: { "Content-Type": "application/json; charset=utf-8" },
818
- });
819
- }
820
- }
821
- }
822
- if (url.pathname.startsWith(RENDERER_WEB_FONTS_URL_PREFIX) && req.method === "GET") {
823
- const fontFile = url.pathname.slice(RENDERER_WEB_FONTS_URL_PREFIX.length);
824
- // The allowlist pattern excludes "/", "." segments and encoded traversal.
825
- if (rendererWebFontsDir === undefined || !RENDERER_WEB_FONT_FILE_PATTERN.test(fontFile)) {
826
- return new Response("not found", { status: 404 });
827
- }
828
- try {
829
- const bytes = await readFile(join(rendererWebFontsDir, fontFile));
830
- // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types just doesn't say so.
831
- return new Response(bytes, {
832
- headers: { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=86400" },
833
- });
834
- }
835
- catch (err) {
836
- if (errorCodeOf(err) === "ENOENT") {
837
- return new Response("not found", { status: 404 });
838
- }
839
- throw err;
840
- }
841
- }
842
- if (url.pathname === "/styles.css" && req.method === "GET") {
843
- if (stylesheetPath === undefined)
844
- return new Response("no stylesheet", { status: 404 });
845
- const css = await readFile(stylesheetPath, "utf-8");
846
- return new Response(css, {
847
- headers: { "Content-Type": "text/css; charset=utf-8" },
848
- });
849
- }
850
- if (url.pathname === "/_reload" && req.method === "GET") {
851
- const encoder = new TextEncoder();
852
- const stream = new ReadableStream({
853
- start(controller) {
854
- const entry = { controller, encoder, closed: false };
855
- reloadClients.add(entry);
856
- controller.enqueue(encoder.encode(": connected\n\n"));
857
- // boot event: the browser snippet compares this with the first
858
- // bootId. A different value after reconnect means the server
859
- // restarted, so location.reload() is called.
860
- controller.enqueue(encoder.encode(`event: boot\ndata: ${bootId}\n\n`));
861
- },
862
- cancel() {
863
- for (const c of reloadClients) {
864
- if (c.closed)
865
- reloadClients.delete(c);
866
- }
867
- },
868
- });
869
- return new Response(stream, {
870
- headers: {
871
- "Content-Type": "text/event-stream",
872
- "Cache-Control": "no-cache",
873
- Connection: "keep-alive",
874
- },
875
- });
876
- }
877
- // SPA catch-all: GET or HEAD requests to non-API, non-asset paths return
878
- // the HTML shell so the client-side router can mount the screen for
879
- // location.pathname. Paths containing a dot are left to the stack's
880
- // 404 handling.
881
- //
882
- // Backend routes outside /api, currently including /sse, must be
883
- // excluded so the catch-all does not shadow Hono routes.
884
- //
885
- // Feature routes declared with r.httpRoute can also be outside /api.
886
- // Try stack.app.fetch first so a matching Hono route wins; only a Hono
887
- // 404 proceeds to the SPA fallback, matching production behavior.
888
- if (
889
- // Include HEAD: runProdApp falls back to the SPA for both GET and HEAD;
890
- // omitting it would make development return 404 where production returns 200.
891
- isRoutableGetOrHead(req, url.pathname) &&
892
- !url.pathname.includes(".")) {
893
- const honoTry = await tryHonoFirst(stack.app, req, socketAddress);
894
- if (honoTry.matched) {
895
- return honoTry.response;
896
- }
897
- // Discriminated dispatch mirrors production. Without hostDispatch, use
898
- // the single-entry default ("client").
899
- if (options.hostDispatch !== undefined) {
900
- const dispatch = await options.hostDispatch(req, {
901
- systemQuery: buildDevSystemQuery(req, socketAddress),
902
- cspScriptHashes: [],
903
- });
904
- if (dispatch.kind === "redirect") {
905
- return new Response(null, {
906
- status: dispatch.status ?? 302,
907
- headers: { Location: dispatch.to },
908
- });
909
- }
910
- if (dispatch.kind === "not-found") {
911
- if (dispatch.file !== undefined)
912
- return staticHtmlResponse(dispatch.file, 404);
913
- if (dispatch.entryName !== undefined) {
914
- return htmlResponse(dispatch.entryName, req, socketAddress, 404);
915
- }
916
- return new Response("Not Found", { status: 404 });
917
- }
918
- if (dispatch.kind === "static-html") {
919
- return staticHtmlResponse(dispatch.file, 200);
920
- }
921
- return htmlResponse(dispatch.entryName, req, socketAddress);
922
- }
923
- return htmlResponse("client", req, socketAddress);
924
- }
925
- // Static assets under public/ — see tryServePublicAsset's own comment
926
- // for the Hono → file → router-miss ordering.
927
- const staticAsset = await tryServePublicAsset(req, url.pathname, stack.app, publicDir, socketAddress);
928
- if (staticAsset !== undefined)
929
- return staticAsset;
930
- // Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the
931
- // router-miss marker must be stripped here too — otherwise an unmatched
932
- // path would leak it straight to the client (see try-hono-first.ts's
933
- // header-hygiene note).
934
- return stripNoRouteMatchHeader(await stack.app.fetch(req, socketAddress));
935
- };
936
- const handleFetch = options.wrapFetch ? options.wrapFetch(routeFetch) : routeFetch;
937
- // --- HTTP server (Bun only) ---
938
- // Under Node/vitest, skip Bun.serve; the handle's .fetch() is the test
939
- // surface. Under Bun, Bun.serve wires the listener.
940
- // Bun.serve options come from buildBunServeOptions (run-prod-app.ts) so
941
- // development and production share the same SSE-relevant defaults
942
- // (idleTimeout: 0); run-prod-app-spec.test.ts pins that.
943
- const server = hasBun
944
- ? Bun.serve(buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry), {
945
- upgradeFetch: async (req, env) => stripNoRouteMatchHeader(await stack.app.fetch(req, env)),
946
- }))
947
- : undefined;
948
- // --- file watcher → rebundle + reload, or process restart for schema changes ---
949
- // Heuristic: files in `web/` or `__tests__/` are client-side or test-only,
950
- // so hot reload is sufficient. Other files are server-side; because Bun
951
- // caches module imports, a schema change in feature.ts requires a process
952
- // restart. Exit with code 75 (EX_TEMPFAIL) so the `kumiko-dev` wrapper
953
- // can respawn the process.
954
- //
955
- // stop() aborts watcherAbort, which ends fs.watch's async iteration. This
956
- // prevents a watcher from surviving test teardown and classifying an
957
- // rmSync(tmpdir) as "restart needed".
67
+ const server = startDevHttpServer(port, handleFetch, stack);
958
68
  const watcherAbort = new AbortController();
959
- if (entries.length > 0) {
960
- // Watch directories: all entry directories (deduplicated) plus the explicit
961
- // watchDirs. In multi-entry setups, entries often share a src/ directory
962
- // (`src/client-admin.tsx` + `src/client-public.tsx`), so the Set keeps
963
- // one watcher per directory.
964
- const entryDirs = new Set();
965
- for (const e of entries)
966
- entryDirs.add(resolve(e.sourceFile, ".."));
967
- const entrySourceFiles = new Set(entries.map((e) => resolve(e.sourceFile)));
968
- const dirs = [...entryDirs, ...expandWatchPatterns(options.watchDirs ?? [])];
969
- for (const dir of dirs) {
970
- void watchDir(dir, async (filename) => {
971
- const action = classifyWatchedChange(dir, filename, entrySourceFiles);
972
- // skip: file change classified as ignore (test/css/json), nothing to rebuild
973
- if (action === "ignore")
974
- return;
975
- if (action === "restart") {
976
- logInfo(`[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht). ` +
977
- "The kumiko-dev wrapper (package.json's `dev` script) respawns automatically " +
978
- "and re-syncs new r.entity() columns on the new boot; running this entry " +
979
- "directly (not via kumiko-dev) exits here without restarting.");
980
- await stop();
981
- process.exit(75);
982
- }
983
- try {
984
- // Rebuild all entries—even when only one file changes, because we do not
985
- // know which entries import it. With shared code, an edit triggers
986
- // both bundles; this is intentional.
987
- for (const e of entries) {
988
- const rebuilt = await buildBundle(e.sourceFile);
989
- clientBundles.set(e.name, rebuilt);
990
- }
991
- logInfo(`[kumiko-server] rebuilt on ${filename}, broadcasting reload`);
992
- broadcastReload(filename);
993
- }
994
- catch {
995
- // buildClient already logged the failure; keep serving the
996
- // last good bundle until the next successful rebuild.
997
- }
998
- }, watcherAbort.signal, options._watchDirectory ?? watch);
999
- }
1000
- }
1001
- // The per-boot prefix is never reused, so its BullMQ keys (queues,
1002
- // repeatables, delayed jobs) would pile up in the shared Redis forever.
1003
- // A prefix-less duplicate: SCAN returns full key names, which the stack
1004
- // client's keyPrefix would otherwise prepend a second time.
1005
- // Takes the connection as a parameter: it must be opened before
1006
- // stack.cleanup() closes the stack client, yet used only after it, because
1007
- // closing the stack's enqueuer queues re-writes their `meta` keys.
1008
- const deleteEphemeralJobQueues = async (raw) => {
1009
- try {
1010
- const keys = [];
1011
- for await (const batch of raw.scanStream({
1012
- match: `bull:${jobQueueNamePrefix}-*`,
1013
- count: 500,
1014
- })) {
1015
- keys.push(...batch);
1016
- }
1017
- if (keys.length > 0)
1018
- await raw.del(...keys);
1019
- }
1020
- finally {
1021
- raw.disconnect();
1022
- }
1023
- };
1024
- const stop = async () => {
1025
- // Stop the watcher first so no onChange callback runs during teardown;
1026
- // otherwise removing the temporary directory could trigger process.exit(75).
1027
- watcherAbort.abort();
1028
- if (killTailwind)
1029
- killTailwind();
1030
- if (server !== undefined) {
1031
- server.stop(true);
1032
- }
1033
- if (stack.eventDispatcher) {
1034
- await stack.eventDispatcher.stop();
1035
- }
1036
- await devJobRunners.stop();
1037
- const rawRedis = persistentDb ? undefined : stack.redis.redis.duplicate({ keyPrefix: "" });
1038
- await stack.cleanup();
1039
- if (rawRedis)
1040
- await deleteEphemeralJobQueues(rawRedis);
1041
- };
1042
- // --- graceful shutdown ---
1043
- // Signal handlers run on Ctrl-C / kill. Without them, repeated dev
1044
- // restarts leak Postgres pools, leave the Tailwind watcher as an orphan,
1045
- // and leave temporary clients behind in persistent mode.
1046
- // uncaughtException and unhandledRejection previously left the Tailwind
1047
- // watcher running in the background; clean it up before exiting nonzero.
1048
- const installHandlers = options.installSignalHandlers ?? true;
1049
- if (installHandlers) {
1050
- for (const sig of ["SIGINT", "SIGTERM"]) {
1051
- process.on(sig, async () => {
1052
- logInfo(`[kumiko-server] ${sig} — cleaning up…`);
1053
- await stop();
1054
- process.exit(0);
1055
- });
1056
- }
1057
- process.on("uncaughtException", async (err) => {
1058
- logError("[kumiko-server] uncaughtException — cleaning up…", err);
1059
- try {
1060
- await stop();
1061
- }
1062
- finally {
1063
- process.exit(1);
1064
- }
1065
- });
1066
- process.on("unhandledRejection", async (err) => {
1067
- logError("[kumiko-server] unhandledRejection — cleaning up…", err);
1068
- try {
1069
- await stop();
1070
- }
1071
- finally {
1072
- process.exit(1);
1073
- }
1074
- });
1075
- }
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);
1076
89
  if (server !== undefined) {
1077
90
  logInfo(`[kumiko-server] listening on http://localhost:${port}` +
1078
91
  (entries.length > 0
1079
92
  ? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
1080
93
  : ""));
1081
94
  }
1082
- return { fetch: handleFetch, server, stack, stop, jobQueueNamePrefix };
95
+ return { fetch: handleFetch, server, stack, stop, jobQueueNamePrefix: dbMode.jobQueueNamePrefix };
1083
96
  }