// Dev server bootstrap. Wires the real Kumiko stack behind a Bun.serve // shell that also bundles the client, serves it at /client.js, mints // a JWT for a dev-admin on GET /, and broadcasts SSE reloads when // source files change. One import + one call is enough for any // sample's server.ts — the 150-line boilerplate of pre-dev-server // days lives here now. // // Not for production: // - auto-mints a JWT for TestUsers.admin on every GET / (anyone // hitting the server ends up as admin) // - bundles the client in-process (prod uses a prebuilt dist) // - no rate-limit, no helmet, no secure-cookie flags // // The companion prod entry will land at `@cosmicdrift/kumiko-framework/server` // with a different options shape (clientDist, auth config, db url). import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, statSync } from "node:fs"; import { readFile, realpath, watch } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve, sep } from "node:path"; import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-features/auth-foundation"; import { type AuthRoutesConfig, buildRequestContextDataFromRequest, generateToken, requestContext, } from "@cosmicdrift/kumiko-framework/api"; import { buildAppSchema, createAnonymousUser, type FeatureDefinition, } from "@cosmicdrift/kumiko-framework/engine"; import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store"; import { pushEntityProjectionTables, setupTestStack, type TestStack, type TestStackOptions, TestUsers, } from "@cosmicdrift/kumiko-framework/stack"; import { type PageHeadResolver, type PageHeadSystemQuery, resolveAndInjectPageHead, } from "@cosmicdrift/kumiko-headless/apex"; import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger"; import { buildBunServeOptions } from "@cosmicdrift/kumiko-server-runtime/bun-serve-options"; import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite, } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps"; import { injectSchema } from "@cosmicdrift/kumiko-server-runtime/inject-schema"; import { canResolveTailwindStylesheet, resolveTailwindCli, } from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli"; import { type HonoLikeApp, stripNoRouteMatchHeader, tryHonoFirst, } from "@cosmicdrift/kumiko-server-runtime/try-hono-first"; // Runtime-detection. The dev-server is meant to run under Bun (Kumiko's // target runtime), but the test-suite runs under vitest on Node — we // gate every Bun.* call so the module at least LOADS under Node, and // tests drive the fetch-handler directly instead of going through // Bun.serve + real sockets. const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; // Bun.serve returns a parametrised Server; we don't // touch WebSockets here, so the narrow `unknown` binding is plenty. // `Bun` isn't declared in Node types, so we fall back to `unknown` // and only resolve the type when Bun is actually around. type BunServer = typeof Bun extends undefined ? unknown : ReturnType; // @wrapper-known semantic-alias // biome-ignore lint/suspicious/noConsole: dev-server status logging const logInfo = (msg: string): void => console.log(msg); // @wrapper-known semantic-alias // biome-ignore lint/suspicious/noConsole: dev-server error logging const logError = (...args: unknown[]): void => console.error(...args); /** Multi-Entry-Mode für Apps die mehrere getrennte Bundles ausliefern * (z.B. publicstatus: `admin.` lädt Admin-UI, sonst Public-Page). * * Spiegelt die Convention von kumiko-build (`src/client-.tsx`) und * serviert `/client-.js` per HTTP. Multi-Entry ist mutually * exclusive mit `clientEntry`. Wer Multi-Entry nutzt MUSS auch * `hostDispatch` setzen — sonst weiß der Server nicht welches HTML * er rausgeben soll. */ export type DevClientEntry = { /** Logical Name. Frei wählbar; Convention: gleicher Suffix wie * `src/client-.tsx` damit der Build identische Asset-URLs * liefert (`/client-.js`). */ readonly name: string; /** Absoluter Pfad zur Browser-Entry-Datei. */ readonly sourceFile: string; /** Optional eigenes HTML-Template für diesen Entry. Wenn nicht gesetzt, * wird `htmlPath` (das default-Template) für alle Entries genutzt. */ readonly htmlPath?: string; }; /** Discriminated-Union, identisch zur Form von `runProdApp.hostDispatch`. * Damit kann Dev/Prod-Routing 1:1 gespiegelt werden — ein Apex-404 in * Prod ist ein Apex-404 in Dev (mit `/etc/hosts`-Eintrag für die * betroffene Domain). Schema-Inject ist pro Response steuerbar — ein * Public-Bundle leakt das Admin-Schema nicht, auch nicht in Dev. */ export type DevHostDispatchResult = | { readonly kind: "html"; readonly entryName: string; /** Default: true. Setze `false` für Public-Routes — analog zu * prod-`injectSchema:false` für Anonymous-Visitors. */ readonly injectSchema?: boolean; } | { /** Static-HTML: liefert eine Datei wortwörtlich, kein Bundle-Inject, * kein Schema-Inject. Pendant zu prod's `{ kind: "html", file: ..., * injectSchema: false }` für Marketing-/Apex-Pages die kein React * brauchen. Pfad relativ zum Server-CWD. */ readonly kind: "static-html"; readonly file: string; } | { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 } | { readonly kind: "not-found" }; /** Picks an entry by inspecting the incoming request. Wird von * Multi-Entry-Apps gesetzt; im Single-Entry-Mode irrelevant. */ export type DevHostDispatch = (req: Request) => DevHostDispatchResult; export type CreateKumikoServerOptions = { /** Features whose entities, handlers, and screens get wired into the * dev stack. Pass every feature the app is supposed to run. */ readonly features: readonly FeatureDefinition[]; /** Absolute path to the browser entry module. The dev-server runs * `Bun.build` on it and serves the output at `/client.js`. Omit to * run a headless API-only dev-stack (rare — every sample has one). * Mutually exclusive mit `clientEntries`. */ readonly clientEntry?: string; /** Multi-Entry-Mode: mehrere getrennte Bundles, jeweils unter * `/client-.js`. Mutually exclusive mit `clientEntry`. Setze * `hostDispatch` mit, sonst bleibt unklar welches Template zurück- * geht. */ readonly clientEntries?: readonly DevClientEntry[]; /** Multi-Entry-Mode: Routing pro Request. Inspiziert `Host` (oder * was auch immer) und liefert eine Discriminated-Union zurück * (html → entry-bundle, redirect → 30x, not-found → 404). * Symmetric zu `runProdApp.hostDispatch` damit dev/prod-Drift * beim Routing unmöglich ist. */ readonly hostDispatch?: DevHostDispatch; /** @internal — ersetzt `Bun.build` für Tests. Default ruft die echte * Bun-Toolchain. Tests unter Node injizieren einen Stub damit der * Routing-Pfad treibbar bleibt ohne Bun.build aufzurufen. * KEIN Public-API-Surface — präfixiert mit `_` damit Konsumenten * wissen dass das ein Test-Seam ist. */ readonly _buildBundle?: (sourceFile: string) => Promise<{ readonly js: string; readonly map: string; }>; /** Absolute path to the CSS entry (typischerweise styles.css mit * @import "tailwindcss"). Der dev-server startet dann den * Tailwind-CLI als watcher und servt das kompilierte CSS unter * /styles.css. * * Wenn `undefined` UND `clientEntry` gesetzt: resolve die * `@cosmicdrift/kumiko-renderer-web/styles.css`-Default via Package-Exports. * So muss kein Sample mehr den monorepo-relativen Pfad * ../../packages/renderer-web/src/styles.css hardcoden. * * `stylesheet: false` → CSS-Pipeline explizit deaktivieren. */ readonly stylesheet?: string | false; /** Optional HTML template served at `GET /`. The dev-server injects * a ` `; // Minimal HTML when the caller didn't hand one in. `#root` is the // default mount target for `createKumikoApp`, so the one-line client // can attach without the sample having to ship its own template. // // `type="module"` is load-bearing: a classic script turns the bundle's // top-level declarations into window properties, so a dependency exporting // `function history()` (prosemirror-history, pulled in by tiptap) silently // replaces `window.history` and every pushState afterwards throws. Prod // (build-prod-bundle) has always emitted the module form. // // `interactive-widget=resizes-content` (fw#1918): without it, a mobile // keyboard shrinks only the visual viewport, not the layout viewport that // `position: fixed` anchors to — a fixed bottom action bar would stay // pinned behind the keyboard instead of above it. const DEFAULT_HTML = ` Kumiko
`; type ClientBundle = { readonly js: string; readonly map: string }; async function buildClient(entry: string): Promise { if (!hasBun) { throw new Error( "[kumiko-server] clientEntry is only supported under Bun — Bun.build is unavailable in this runtime.", ); } const unminified = process.env["KUMIKO_DEV_UNMINIFIED"] === "1"; const built = await Bun.build({ entrypoints: [entry], target: "browser", minify: !unminified, sourcemap: "linked", }); if (!built.success) { logError("[kumiko-server] client bundle failed:"); for (const log of built.logs) logError(log); throw new Error("client bundle failed"); } const jsOutput = built.outputs.find((o) => o.path.endsWith(".js")); const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map")); if (!jsOutput) throw new Error("[kumiko-server] client bundle produced no .js output"); return { js: await jsOutput.text(), map: mapOutput ? await mapOutput.text() : "", }; } type ReloadClient = { readonly controller: ReadableStreamDefaultController; readonly encoder: TextEncoder; closed: boolean; }; function injectReload(html: string): string { if (html.includes("/_reload")) return html; return html.includes("") ? html.replace("", `${RELOAD_SNIPPET}`) : html + RELOAD_SNIPPET; } // Injiziert in den , // wenn es noch nicht da ist. Wird nur aufgerufen wenn die App das // stylesheet-Option genutzt hat — andernfalls kommt keine CSS-Route. function injectStylesheet(html: string): string { if (html.includes('href="/styles.css"')) return html; const link = ''; return html.includes("") ? html.replace("", ` ${link}\n`) : `${link}${html}`; } // GET/HEAD to a non-API, non-SSE path — the dispatch-worthy set shared by // the SPA catch-all and the public/-file lookup below (they split on // whether the path has a dot). function isRoutableGetOrHead(req: Request, pathname: string): boolean { return ( (req.method === "GET" || req.method === "HEAD") && !pathname.startsWith("/api/") && !pathname.startsWith("/sse") ); } // Resolves a request pathname to a file inside publicDir, or undefined if // it isn't one. Two traversal vectors, both must be blocked: // - literal ".." segments (new URL() already collapses these in // `pathname`, but resolve()+containment is checked regardless — no // path-safety may depend on caller behavior upstream). // - percent-encoded slashes (e.g. "%2e%2e%2f"): WHATWG URL parsing only // normalizes dot-segments that are delimited by a literal "/", so an // encoded slash survives into `pathname` untouched. decodeURIComponent // resolves it to "../" before the containment check runs. function resolvePublicFilePath(pathname: string, publicDir: string): string | undefined { let decoded: string; try { decoded = decodeURIComponent(pathname); } catch { return undefined; } const resolved = resolve(publicDir, `.${decoded}`); return resolved === publicDir || resolved.startsWith(publicDir + sep) ? resolved : undefined; } // Same extension → content-type mapping as prod's // run-prod-app-static-files.ts#mimeTypeFor — that function isn't part of // server-runtime's public exports (its own package.json "exports" map), // so this is a small self-contained copy rather than a new cross-package // export for one call site. const PUBLIC_FILE_MIME_TYPES = new Map([ ["html", "text/html; charset=utf-8"], ["js", "text/javascript; charset=utf-8"], ["mjs", "text/javascript; charset=utf-8"], ["css", "text/css; charset=utf-8"], ["json", "application/json; charset=utf-8"], ["svg", "image/svg+xml"], ["png", "image/png"], ["jpg", "image/jpeg"], ["jpeg", "image/jpeg"], ["ico", "image/x-icon"], ["txt", "text/plain; charset=utf-8"], ["xml", "application/xml; charset=utf-8"], ["webmanifest", "application/manifest+json"], ]); function publicFileMimeType(filePath: string): string { const ext = filePath.toLowerCase().split(".").pop() ?? ""; return PUBLIC_FILE_MIME_TYPES.get(ext) ?? "application/octet-stream"; } // Reads a file under publicDir for the dev-server's static-asset fallback // (dev-parity with prod's disk lookup in buildStaticFallback). undefined // means "not a servable file here" — caller treats that as a router miss, // same as ENOENT/EISDIR/ENOTDIR further down. async function servePublicFile( pathname: string, publicDir: string, ): Promise<{ readonly bytes: Uint8Array; readonly mime: string } | undefined> { const filePath = resolvePublicFilePath(pathname, publicDir); if (filePath === undefined) return undefined; try { const bytes = await readFile(filePath); // Lexical containment (above) only catches "..", not a symlink inside // publicDir that points outside it on disk — realpath resolves the // actual target and the same containment check runs against it. const real = await realpath(filePath); if (real !== publicDir && !real.startsWith(publicDir + sep)) return undefined; return { bytes, mime: publicFileMimeType(filePath) }; } catch (err) { const code = (err as { code?: string }).code; if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR") return undefined; throw err; } } // Static assets under public/ — prod serves these via buildStaticFallback's // disk lookup (run-prod-app-static-files.ts), dev previously had no // equivalent: a dotted path (e.g. /marketing/hero.png) fell straight // through to the API stack and 404ed. Hono still goes first (an // r.httpRoute could itself own a dotted path), then the file on disk, // then the router-miss 404 — mirrors handleFetch's SPA branch. undefined // means "not a static-asset request", caller falls through to the next // route. async function tryServePublicAsset( req: Request, pathname: string, app: HonoLikeApp, publicDir: string, ): Promise { if (!isRoutableGetOrHead(req, pathname) || !pathname.includes(".")) return undefined; const honoTry = await tryHonoFirst(app, req); if (honoTry.matched) { return honoTry.response; } const file = await servePublicFile(pathname, publicDir); if (file !== undefined) { // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types // just doesn't say so — same cast run-prod-app-static-files.ts uses. return new Response(file.bytes as unknown as BodyInit, { headers: { "Content-Type": file.mime }, }); } return honoTry.response; } // injectSchema lebt in `./inject-schema.ts` damit dev-server + prod- // server denselben Inject-Pfad nutzen. async function watchDir( dir: string, onChange: (filename: string) => void, signal: AbortSignal, ): Promise { // AbortSignal wird vom Server-stop() ausgelöst: ohne den Abort liefe // die for-await-Schleife bis zum Process-Exit weiter. Im Test-Setup // (afterEach räumt tmpdir mit rmSync auf) sähe der Watcher dann das // rmSync, klassifizierte's als "restart" und riefe process.exit(75) — // bubbles als unhandled error in vitest hoch. const watcher = watch(dir, { recursive: true, signal }); try { for await (const ev of watcher) { if (ev.filename) onChange(ev.filename); } } catch (err) { // signal.abort() wirft AbortError aus dem async-iterator; das ist // gewollt und kein Fehler. Andere Errors weiterreichen. // skip: AbortSignal fired the abort, this is expected teardown not a real error if ((err as { name?: string }).name === "AbortError") return; throw err; } } // Klassifiziert eine geänderte Datei: `hot-reload` für Client-Side // (Browser-Bundle rebuild + reload), `restart` für Server-Side (Bun- // Module-Cache zwingt einen Process-Restart durch), `ignore` für // alles was den Server nicht beeinflusst (Tests, .css, .json…). // // Heuristik: // - Tests (`__tests__/` oder `*.test.ts(x)`) → ignore // - `.ts` / `.tsx` außer Tests: // - Client-side-Dirs (`/web/`, `/admin/`, `/public/`, `/client/`) // oder die client-entry-Datei selbst → hot-reload // - sonst → restart (könnte Schema/Feature-Definition sein) // - andere Dateitypen → ignore (kein TS rebuild nötig) // // Warum mehrere Dirs für client-side: in Kumiko-Samples gibt's keine // Convention. publicstatus splittet `/admin/` (Admin-Bundle) und // `/public/` (Anonymous-Bundle); beammycar nutzt `/web/` für seine // Feature-Web-Code; ältere Samples haben einfach `/client.tsx` neben // dem Server. Der Watcher muss alle drei verstehen, sonst löst ein // Edit der Bridge-Component einen kompletten Server-Restart aus — // kostet 2-3s, droppt die Test-DB im ephemeral-Modus, reseed läuft // erneut. Ineffektiv und für der User verwirrend. // // Exportiert für Tests; intern wird's von der Watcher-Loop gerufen. export function classifyChange(filename: string): "restart" | "hot-reload" | "ignore" { if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return "ignore"; if (filename.includes("__tests__")) return "ignore"; if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx")) return "ignore"; if ( filename.endsWith(".integration.ts") || filename.endsWith(".integration.test.ts") || filename.endsWith(".e2e.ts") ) { return "ignore"; } // Plattformpfad-agnostisch: prüfen auf POSIX und Windows-Trenner. // Wir matchen sowohl `` als auch trailing-`` // (für Watcher-Filenames die als relativer Pfad ankommen). const clientSubdirs = ["web", "admin", "public", "client"]; for (const dir of clientSubdirs) { if ( filename.includes(`/${dir}/`) || filename.includes(`\\${dir}\\`) || filename.startsWith(`${dir}/`) || filename.startsWith(`${dir}\\`) ) { return "hot-reload"; } } if (filename.endsWith("/client.tsx") || filename.endsWith("/client.ts")) { return "hot-reload"; } return "restart"; } // Expandiert watchDirs-Patterns auf konkrete Verzeichnisse. Ein Eintrag // ohne `*` wird als gewöhnlicher Pfad resolved; mit `*` wird er per // glob expanded und alle Treffer die Verzeichnisse sind übernommen. // Erlaubt z.B. `"../../../packages/*/src"` statt vier hart-kodierte // Pfade. Glob ist sync — wird einmal beim Boot ausgewertet, nicht // während der Watcher läuft. function expandWatchPatterns(patterns: readonly string[]): string[] { const out: string[] = []; for (const p of patterns) { if (!p.includes("*")) { out.push(resolve(p)); continue; } // expandWatchPatterns wird nur unter Bun aufgerufen (createKumikoServer // ist Bun-only); der ?.! -dance ist nötig weil TS Bun nicht im // globalThis-default sieht. Wenn Bun fehlt, ist der Aufrufstapel eh // schon fail-fast unten in Bun.serve. const BunRef = ( globalThis as { Bun?: { Glob: new ( p: string, ) => { scanSync: (opts: { onlyFiles: false; cwd: string }) => Iterable }; }; } ).Bun; if (!BunRef) throw new Error("expandWatchPatterns requires Bun.Glob"); const glob = new BunRef.Glob(p); const matches = Array.from(glob.scanSync({ onlyFiles: false, cwd: process.cwd() })); for (const m of matches) { const abs = resolve(m); try { if (statSync(abs).isDirectory()) out.push(abs); } catch { // ignore unreadable matches — typisch defekte Symlinks } } } return out; } // Resolve den Pfad zur Tailwind-Entry-CSS. Mehrere Fälle: // - Explicit string → den resolved'en absoluten Pfad verwenden // - false → CSS-Pipeline aus (undefined zurück) // - undefined + client(s): // 1. App-eigenes src/styles.css (App-Theme-Override) wenn vorhanden // 2. Sonst Default `@cosmicdrift/kumiko-renderer-web/styles.css` über Package-Exports // - undefined + kein clientEntry/clientEntries: undefined (keine CSS nötig) // // Auto-Detection von src/styles.css spiegelt die Logik aus // build-prod-bundle:resolveStylesheetEntry — damit dev und prod identisch // resolven. Ohne diesen Check müsste jede App `stylesheet: "./src/styles.css"` // setzen, sonst greift in dev der renderer-web-Default und Brand-Tokens // werden ignoriert (DX-Falle). // // @internal — exportiert nur für Unit-Tests, nicht aus dem Package-Index // re-exportiert. Konsumenten gehen ausschließlich über die `stylesheet`- // Option der createKumikoServer-Aufrufstelle. export function resolveStylesheet(options: CreateKumikoServerOptions): string | undefined { if (options.stylesheet === false) return undefined; if (typeof options.stylesheet === "string") return resolve(options.stylesheet); const hasAnyEntry = options.clientEntry !== undefined || (options.clientEntries !== undefined && options.clientEntries.length > 0); if (!hasAnyEntry) return undefined; // App-eigenes src/styles.css schlägt den renderer-web-Default — gleiche // Logik wie kumiko-build, damit lokal/prod identisch bauen. const local = resolve(process.cwd(), "src/styles.css"); if (existsSync(local)) return local; // Bun.resolveSync folgt Package-Exports — "./styles.css" in renderer-web's // package.json. Das Monorepo auflöst direkt auf den Workspace-File, eine // installierte Fremd-App auf den node_modules-File. Kein `fileURLToPath` // nötig, Bun gibt schon einen absoluten FS-Pfad zurück. if (!hasBun) { // Unit-Tests unter vitest/Node landen hier. Ohne Bun können wir die // Package-Export-Resolution nicht machen — und im Test-Kontext gibt's // keine echte Tailwind-Pipeline. Skip still, keine Fehlermeldung nötig. return undefined; } try { const resolved = ( globalThis as { Bun: { resolveSync: (id: string, from: string) => string } } ).Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", process.cwd()); const bun = (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }).Bun; if (!canResolveTailwindStylesheet(resolved, { bun, cwd: process.cwd() })) { return undefined; } return resolved; } catch (err) { logError( "[kumiko-server] couldn't auto-resolve @cosmicdrift/kumiko-renderer-web/styles.css — " + "pass `stylesheet: ` or `stylesheet: false` explicitly.", err, ); return undefined; } } // Startet den Tailwind-CLI als watch-Prozess. Failure-Mode ist // non-fatal (return undefined): kann der CLI nicht resolved werden // oder failt der initial-Build (z.B. flakiges Netz, fehlende // Dependency), bootet der Server ohne CSS statt zu sterben. async function startTailwindWatcher( entryCss: string, ): Promise<{ outPath: string; kill: () => void } | undefined> { const bunResolver = hasBun ? (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }).Bun : undefined; const cliPath = resolveTailwindCli({ bun: bunResolver, cwd: process.cwd() }); if (cliPath === undefined) { logError( "[kumiko-server] @tailwindcss/cli nicht auflösbar — booting ohne CSS-Pipeline. " + "`bun install` und Restart, um Styles zu aktivieren.", ); return undefined; } const outDir = mkdtempSync(join(tmpdir(), "kumiko-tw-")); const outPath = join(outDir, "styles.css"); logInfo(`[kumiko-server] tailwind watcher → ${outPath}`); const bunPath = process.argv[0] ?? "bun"; // Initial-Build blockend, damit der erste /styles.css-Request kein // 404 bekommt. Dann den watcher im Hintergrund mit unref() — sonst // hing er beim Parent-Crash als orphan-process. try { await new Promise((resolvePromise, rejectPromise) => { const child = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath], { stdio: "inherit", }); child.on("exit", (code) => { if (code === 0) resolvePromise(); else rejectPromise(new Error(`tailwind one-shot-build exit ${code}`)); }); child.on("error", rejectPromise); }); } catch (err) { logError("[kumiko-server] tailwind one-shot-build fehlgeschlagen — booting ohne CSS:", err); return undefined; } const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], { stdio: "inherit", }); watcher.unref(); return { outPath, kill: () => { try { watcher.kill("SIGTERM"); } catch { // schon tot oder nie gestartet — nicht weiter relevant } }, }; } /** @internal — normalisierte Client-Entry-Form, einheitlich über * Single-Mode (`clientEntry`) und Multi-Mode (`clientEntries`). */ type NormalizedEntry = { readonly name: string; readonly sourceFile: string; readonly htmlPath: string | undefined; }; /** URL-Pfad unter dem ein Entry ausgeliefert wird. "client" → /client.js * (Single-Mode-Default), sonst "/client-.js". Single-Source-of-Truth * damit Routing + Logging dieselbe Konvention nutzen. */ function assetPathFor(entryName: string): string { return entryName === "client" ? "/client.js" : `/client-${entryName}.js`; } function normalizeEntries(options: CreateKumikoServerOptions): readonly NormalizedEntry[] { if (options.clientEntries !== undefined && options.clientEntry !== undefined) { throw new Error( "[kumiko-server] clientEntry und clientEntries sind mutually exclusive — wähle eins", ); } if (options.clientEntries !== undefined && options.clientEntries.length > 0) { if (options.hostDispatch === undefined) { throw new Error( "[kumiko-server] clientEntries braucht hostDispatch — sonst weiß der Server nicht welches Template er liefern soll", ); } return options.clientEntries.map((e) => ({ name: e.name, sourceFile: resolve(e.sourceFile), htmlPath: e.htmlPath, })); } if (options.clientEntry !== undefined) { return [{ name: "client", sourceFile: resolve(options.clientEntry), htmlPath: undefined }]; } return []; } export async function createKumikoServer( options: CreateKumikoServerOptions, ): Promise { const port = options.port ?? Number(process.env["PORT"] ?? 4173); // --- client bundles (single-entry oder multi-entry über dieselbe Map) --- const entries = normalizeEntries(options); const buildBundle = options._buildBundle ?? buildClient; const clientBundles = new Map(); for (const e of entries) { const bundle = await buildBundle(e.sourceFile); clientBundles.set(e.name, bundle); logInfo( `[kumiko-server] client bundle ${e.name}: ${bundle.js.length.toLocaleString()} bytes` + (bundle.map ? ` (+ ${bundle.map.length.toLocaleString()} bytes sourcemap)` : ""), ); } // --- Tailwind stylesheet (optional) --- // Tailwind-CLI läuft im watch-mode, schreibt in ein temp-file, der // dev-server liest es bei jedem /styles.css-Request frisch. Nicht // Super-Performant, aber keine in-memory-Signal-Gymnastik nötig // und der Browser-Reload kommt eh nur nach Bundle-Rebuild. // // Default-Resolution: wenn kein `stylesheet` übergeben und ein // `clientEntry` existiert, resolve die styles.css aus // `@cosmicdrift/kumiko-renderer-web` via Package-Exports. Bun.resolveSync liefert // einen absoluten Pfad — funktioniert sowohl im Monorepo (Workspace- // Link) als auch in einer installierten Fremd-App (node_modules). let stylesheetPath: string | undefined; let killTailwind: (() => void) | undefined; const resolvedStylesheet = resolveStylesheet(options); if (resolvedStylesheet !== undefined) { const handle = await startTailwindWatcher(resolvedStylesheet); if (handle !== undefined) { stylesheetPath = handle.outPath; killTailwind = handle.kill; } } // --- HTML templates --- // Single-Entry: ein Template (htmlPath oder DEFAULT_HTML) für alles. // Multi-Entry: pro Entry ein Template (entry.htmlPath ?? options.htmlPath // ?? DEFAULT_HTML). Der hostDispatch wählt zur Request-Zeit. const defaultTemplate = options.htmlPath !== undefined ? await readFile(resolve(options.htmlPath), "utf-8") : DEFAULT_HTML; const htmlTemplates = new Map(); for (const e of entries) { htmlTemplates.set( e.name, e.htmlPath !== undefined ? await readFile(resolve(e.htmlPath), "utf-8") : defaultTemplate, ); } // --- Kumiko stack --- // KUMIKO_DEV_DB_NAME switches the underlying testDb from ephemeral // (fresh kumiko_test_, dropped on cleanup) to persistent // (reuses the named DB across restarts). The var is framework-scoped // on purpose — every dev-server pattern benefits from the same // toggle, not just one sample. const devDbName = process.env["KUMIKO_DEV_DB_NAME"]; const persistentDb = devDbName !== undefined && devDbName !== ""; logInfo( `[kumiko-server] booting Kumiko stack${ persistentDb ? ` — persistent DB "${devDbName}"` : " — ephemeral test DB" }…`, ); const stack = await setupTestStack({ features: options.features, ...(persistentDb && { dbName: devDbName, persistentDb: true }), ...(options.auth !== undefined && { authConfig: options.auth }), ...(options.extraContext !== undefined && { extraContext: options.extraContext }), ...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }), enrichAnonymousAccess: (base, deps) => resolveAnonymousAccessFromRegistry(base, deps), ...(options.files !== undefined && { files: options.files }), ...(options.effectiveFeatures !== undefined && { effectiveFeatures: options.effectiveFeatures, }), // jobs: {} = enqueuer-only; startDevJobRunners below is the sole // consumer/cron-scheduler per lane, so runOnBoot/cron jobs don't double-fire. jobs: {}, }); await createEventsTable(stack.db); await pushEntityProjectionTables(stack, stack.registry); // App-eigene HTTP-Routes ans Hono-app hängen — symmetrisch zur // gleichnamigen Option in runProdApp. Wird vor dem dev-fallback // (HTML/JS/CSS-Serving via handleFetch unten) registriert, damit // explizite Routen wie /feed.xml den Asset-Pfad schlagen. // // Muss VOR dem Seed laufen: ein Seed, der über stack.http dispatcht, // baut Honos Matcher — danach wirft jedes weitere app.get() mit // "Can not add a route since the matcher is already built". if (options.extraRoutes !== undefined) { options.extraRoutes(stack.app, { db: stack.db, // Der nackte ioredis-Client (nicht der TestRedis-Wrapper) — // Parität mit runProdApp, App-Code soll in dev+prod dasselbe sehen. redis: stack.redis.redis, registry: stack.registry, dispatchSystemWrite: makeDispatchSystemWrite(stack.dispatcher), }); } // Hook für Caller-spezifische Tables + Seed. Läuft nach den Entity- // Tabellen damit das Sample auf `stack.db` / `stack.dispatcher` // zugreifen kann, und VOR dem Server-Start damit der erste HTTP-Request // bereits gegen einen gefüllten State läuft. Idempotenz ist Caller- // Verantwortung (persistent-DB-Modus läuft es bei jedem Boot). // Dispatching through stack.http below builds Hono's matcher — no route // may be registered after this point (see extraRoutes above). if (options.onAfterSetup !== undefined) { await options.onAfterSetup(stack); } // setupTestStack konfiguriert den eventDispatcher, startet ihn aber // NICHT — Integration-Tests drain'en deterministisch via runOnce(). // Ein Dev-Server will das laufende Polling, damit SSE-Broadcasts // (system-hook sse, Priorität 1001) von selbst an connected Clients // fließen. Ohne start() bleiben alle Events in der events-Tabelle // liegen und die Tabs sehen nichts. if (stack.eventDispatcher) { await stack.eventDispatcher.start(); } const redisUrl = `redis://${stack.redis.redis.options.host}:${stack.redis.redis.options.port}/${stack.redis.redis.options.db}`; const devJobRunners = await startDevJobRunners({ registry: stack.registry, db: stack.db, // The stack's own AppContext, not a `{ db, registry }` literal: these // runners consume the lanes, and a job reaching for ctx.files / // ctx.notify / ctx.config would otherwise die here while the very same // handler works on the request path (kumiko-framework#1232). context: stack.context, redisUrl, // Same dispatcher the request path writes through — without this, // ctx.write/ctx.writeAs/ctx.queryAs inside a dev-run job throw on their // first call (kumiko-framework#2553). dispatcher: stack.dispatcher, }); // Dev user = TestUsers.admin. Demo features are openToAll but the // auth-middleware still needs a valid JWT to let the request past. // Nicht genutzt wenn `options.auth` gesetzt ist — dann macht der Client // selbst den Login. const autoMintJwt = options.auth === undefined; const devUser = TestUsers.admin; // AppSchema einmal beim Boot bauen. Sample-clients ohne explizites // schema-Argument lesen das via window.__KUMIKO_SCHEMA__ aus — der // dev-server injiziert das in jede HTML-Response. Re-build NICHT // bei Hot-Reload weil sich Feature-Defs nur über einen restart // ändern. const appSchemaJson = JSON.stringify( buildAppSchema(stack.registry, { authoringWarnings: true, searchAdapterMissing: !stack.context.searchAdapter, }), ); // --- SSE reload --- // bootId identifiziert diese spezifische Server-Process-Instanz. Wird // beim Connect an jeden Browser geschickt; Browser merkt sich den // ersten Wert und refresht wenn beim Reconnect ein anderer kommt // (= Server wurde restartet, alter JS-Bundle ist stale). Siehe // RELOAD_SNIPPET oben. const bootId = String(Date.now()); const reloadClients = new Set(); const broadcastReload = (): void => { const payload = "event: reload\ndata: now\n\n"; for (const client of reloadClients) { if (client.closed) continue; try { client.controller.enqueue(client.encoder.encode(payload)); } catch { client.closed = true; } } }; // Build a fresh HTML response. Im Auto-Mint-Modus (keine auth-Config) // packen wir direkt ein gültiges JWT + CSRF-Cookie rein — Deep-Links // funktionieren sofort ohne Login. Im Auth-Modus serven wir nur die // nackte HTML; der Client geht dann durch /auth/login und bekommt die // Cookies von dort. // // entryName + injectSchemaForEntry werden vom Caller (handleFetch) // bestimmt nachdem er hostDispatch evaluiert hat. Ohne hostDispatch // ist es immer "client" mit Schema-Inject true (Single-Entry-Default // damit der Client TypeScript-Schemas findet). // // resolvePageHead goes through the same headless resolveAndInjectPageHead // that runProdApp uses, so dev and e2e exercise the one timeout and // fallback path instead of a second copy of it (#3026). const htmlResponse = async ( entryName: string, doInjectSchema: boolean, req: Request, ): Promise => { const template = htmlTemplates.get(entryName) ?? defaultTemplate; const headers = new Headers(); headers.set("Content-Type", "text/html; charset=utf-8"); if (autoMintJwt) { const jwt = await stack.jwt.sign(devUser); const csrf = generateToken(); headers.append("Set-Cookie", `${AUTH_COOKIE}=${jwt}; Path=/; HttpOnly; SameSite=Lax`); headers.append("Set-Cookie", `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax`); } let html = injectReload(template); if (stylesheetPath !== undefined) html = injectStylesheet(html); if (doInjectSchema) html = injectSchema(html, appSchemaJson); if (options.resolvePageHead !== undefined) { const url = new URL(req.url); const host = req.headers.get("host") ?? url.host; const systemQuery: PageHeadSystemQuery = (type, payload, tenantId) => requestContext.run(requestContext.get() ?? buildRequestContextDataFromRequest(req), () => stack.dispatcher.query(type, payload, createAnonymousUser(tenantId)), ); html = await resolveAndInjectPageHead(html, options.resolvePageHead, { path: url.pathname, host, systemQuery, }); } return new Response(html, { headers }); }; // --- Fetch handler (runtime-neutral) --- // Bundle-Pfad-Lookup: für jede Entry serven wir // GET /client[-name].js → JS-Bundle // GET /client[-name].js.map → Sourcemap // assetPathFor() ist die Single-Source-of-Truth für die URL-Form. const bundleByAssetPath = new Map(); for (const e of entries) bundleByAssetPath.set(assetPathFor(e.name), e.name); // App-root convention (same as expandWatchPatterns/resolveStylesheet above): // process.cwd() is the app workspace, so public/ is its static asset dir. const publicDir = resolve(process.cwd(), "public"); const handleFetch = async (req: Request): Promise => { const url = new URL(req.url); // Specific routes first — assets, reload-SSE, API. if (req.method === "GET") { const bundleName = bundleByAssetPath.get(url.pathname); if (bundleName !== undefined) { const bundle = clientBundles.get(bundleName); if (bundle === undefined) return new Response("no bundle", { status: 404 }); return new Response(bundle.js, { headers: { "Content-Type": "application/javascript; charset=utf-8" }, }); } // .js.map-Variante: gleicher Lookup mit /.map abgeschnitten. if (url.pathname.endsWith(".js.map")) { const jsPath = url.pathname.slice(0, -".map".length); const mapName = bundleByAssetPath.get(jsPath); if (mapName !== undefined) { const bundle = clientBundles.get(mapName); if (bundle === undefined || !bundle.map) { return new Response("no map", { status: 404 }); } return new Response(bundle.map, { headers: { "Content-Type": "application/json; charset=utf-8" }, }); } } } if (url.pathname === "/styles.css" && req.method === "GET") { if (stylesheetPath === undefined) return new Response("no stylesheet", { status: 404 }); const css = await readFile(stylesheetPath, "utf-8"); return new Response(css, { headers: { "Content-Type": "text/css; charset=utf-8" }, }); } if (url.pathname === "/_reload" && req.method === "GET") { const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { const entry: ReloadClient = { controller, encoder, closed: false }; reloadClients.add(entry); controller.enqueue(encoder.encode(": connected\n\n")); // boot-Event: Browser-Snippet vergleicht das mit der ersten // bootId. Verschiedener Wert nach Reconnect = Server wurde // restartet → location.reload(). controller.enqueue(encoder.encode(`event: boot\ndata: ${bootId}\n\n`)); }, cancel() { for (const c of reloadClients) { if (c.closed) reloadClients.delete(c); } }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); } // SPA catch-all: any GET to a non-API, non-asset path returns the // HTML shell. The client-side router then reads location.pathname // and mounts the right screen. The "no dot" filter skips // /favicon.ico etc. (let the stack's 404 handler respond). // // Backend routes that live outside /api (currently just /sse) have // to be excluded explicitly, otherwise the catch-all would shadow // the real Hono route with HTML and EventSource would never // connect. // // Plus: r.httpRoute-deklarierte Feature-Routes (z.B. /legal/*) liegen // ebenfalls außerhalb /api und matchen sonst diesen catch-all. Wir // probieren daher ZUERST stack.app.fetch — wenn Hono eine matchende // Route hat, gewinnt sie. 404 vom Hono-stack → SPA-fallback wie // bisher. Das spiegelt runProdApp's doc-intent ("Hono matched VOR // staticDir-fallback") und macht r.httpRoute mit non-/api paths im // dev-server symmetrisch zu prod. if ( // HEAD mitnehmen — prod (runProdApp) fällt für GET UND HEAD auf die // SPA zurück; ohne das liefert dev 404 wo prod 200 liefert. isRoutableGetOrHead(req, url.pathname) && !url.pathname.includes(".") ) { const honoTry = await tryHonoFirst(stack.app, req); if (honoTry.matched) { return honoTry.response; } // Discriminated-Dispatch — symmetric zu prod. Ohne hostDispatch // landet das im Single-Entry-Default ("client" + Schema-Inject). if (options.hostDispatch !== undefined) { const dispatch = options.hostDispatch(req); if (dispatch.kind === "redirect") { return new Response(null, { status: dispatch.status ?? 302, headers: { Location: dispatch.to }, }); } if (dispatch.kind === "not-found") { return new Response("Not Found", { status: 404 }); } if (dispatch.kind === "static-html") { // Raw-File-Serve, kein Bundle-Inject, kein Schema-Inject. // Pendant zu prod's `{ kind: "html", file: ..., injectSchema: false }`. const file = await readFile(dispatch.file, "utf-8"); return new Response(file, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } return htmlResponse(dispatch.entryName, dispatch.injectSchema ?? true, req); } return htmlResponse("client", true, req); } // Static assets under public/ — see tryServePublicAsset's own comment // for the Hono → file → router-miss ordering. const staticAsset = await tryServePublicAsset(req, url.pathname, stack.app, publicDir); if (staticAsset !== undefined) return staticAsset; // Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the // router-miss marker must be stripped here too — otherwise an unmatched // path would leak it straight to the client (see try-hono-first.ts's // header-hygiene note). return stripNoRouteMatchHeader(await stack.app.fetch(req)); }; // --- HTTP server (Bun only) --- // Under Node/vitest we skip Bun.serve entirely — the handle's // .fetch() is the test surface. Real dev runs under Bun, where // Bun.serve wires the listener. // Bun.serve-Options kommen aus buildBunServeOptions (run-prod-app.ts) // damit Dev und Prod genau dieselben SSE-relevanten Defaults nutzen // (idleTimeout: 0). Spec-Test in run-prod-app-spec.test.ts pinst das. const server = hasBun ? (globalThis as { Bun: { serve: (opts: unknown) => BunServer } }).Bun.serve( buildBunServeOptions(port, handleFetch), ) : undefined; // --- file watcher → rebundle + reload, oder process-restart bei Schema-Änderungen --- // Heuristik: alles in `web/` oder `__tests__/` ist client-side oder // test-only — Hot-Reload reicht (rebuild + broadcast reload). Alles // andere ist server-side; Bun cached die Module-Imports, also würde ein // Schema-Change in feature.ts nicht durchschlagen ohne process-restart. // Wir exiten dann mit Code 75 (EX_TEMPFAIL) — `kumiko-dev` Wrapper // detected das und respawnt. // // watcherAbort wird beim stop() ausgelöst → fs.watch beendet die // async-iteration → kein Watcher überlebt einen Test-Teardown und // klassifiziert ein rmSync(tmpdir) als "restart needed". const watcherAbort = new AbortController(); if (entries.length > 0) { // Watch-Dirs: alle entry-Verzeichnisse (deduped) plus die explizit // angegebenen watchDirs. In Multi-Entry-Setups liegen die Entries // oft im selben src/-Verzeichnis (`src/client-admin.tsx` + // `src/client-public.tsx`) — der Set kollabiert das auf einen // Watcher pro Verzeichnis. const entryDirs = new Set(); for (const e of entries) entryDirs.add(resolve(e.sourceFile, "..")); const dirs = [...entryDirs, ...expandWatchPatterns(options.watchDirs ?? [])]; for (const dir of dirs) { void watchDir( dir, async (filename) => { const action = classifyChange(filename); // skip: file change classified as ignore (test/css/json), nothing to rebuild if (action === "ignore") return; if (action === "restart") { logInfo( `[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht)`, ); await stop(); process.exit(75); } try { // Alle Entries rebuilden — auch wenn nur eine Datei sich // ändert, wir wissen nicht welche Entries sie importieren. // Bei zwei Entries mit shared Code triggert ein Edit der // gemeinsamen Datei beide Bundles neu, das ist gewollt. for (const e of entries) { const rebuilt = await buildBundle(e.sourceFile); clientBundles.set(e.name, rebuilt); } logInfo(`[kumiko-server] rebuilt on ${filename}, broadcasting reload`); broadcastReload(); } catch { // buildClient already logged the failure; keep serving the // last good bundle until the next successful rebuild. } }, watcherAbort.signal, ); } } const stop = async (): Promise => { // Watcher zuerst stoppen damit kein onChange während des Teardowns // mehr feuert (sonst können tmpdir-rmSync ein process.exit(75) // auslösen). watcherAbort.abort(); if (killTailwind) killTailwind(); if (server !== undefined) { (server as { stop: (closeActive?: boolean) => void }).stop(true); } if (stack.eventDispatcher) { await stack.eventDispatcher.stop(); } await devJobRunners.stop(); await stack.cleanup(); }; // --- graceful shutdown --- // Signal handlers fire on Ctrl-C / kill. Without them, repeated dev // restarts leak Postgres pools, lassen Tailwind-Watcher als orphan // hängen und (in persistent mode) hinterlassen temp Clients. // uncaughtException + unhandledRejection: Crashes hatten den Tailwind- // Watcher nicht gekillt, der lief munter weiter im Hintergrund. Jetzt // räumen wir auch im Fehlerfall auf bevor wir mit non-zero exit'n. const installHandlers = options.installSignalHandlers ?? true; if (installHandlers) { for (const sig of ["SIGINT", "SIGTERM"] as const) { process.on(sig, async () => { logInfo(`[kumiko-server] ${sig} — cleaning up…`); await stop(); process.exit(0); }); } process.on("uncaughtException", async (err) => { logError("[kumiko-server] uncaughtException — cleaning up…", err); try { await stop(); } finally { process.exit(1); } }); process.on("unhandledRejection", async (err) => { logError("[kumiko-server] unhandledRejection — cleaning up…", err); try { await stop(); } finally { process.exit(1); } }); } if (server !== undefined) { logInfo( `[kumiko-server] listening on http://localhost:${port}` + (entries.length > 0 ? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})` : ""), ); } return { fetch: handleFetch, server, stack, stop }; }