@cosmicdrift/kumiko-dev-server 0.386.0 → 0.388.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -57
  2. package/dist/build-server-bundle.d.ts +7 -7
  3. package/dist/build-server-bundle.js +53 -53
  4. package/dist/build.js +3 -3
  5. package/dist/compose-stacks.d.ts +2 -2
  6. package/dist/compose-stacks.js +1 -2
  7. package/dist/crash-tracker.d.ts +3 -7
  8. package/dist/crash-tracker.js +4 -13
  9. package/dist/create-kumiko-server-client-bundles.d.ts +21 -0
  10. package/dist/create-kumiko-server-client-bundles.js +97 -0
  11. package/dist/create-kumiko-server-html.d.ts +21 -0
  12. package/dist/create-kumiko-server-html.js +132 -0
  13. package/dist/create-kumiko-server-public-files.d.ts +6 -0
  14. package/dist/create-kumiko-server-public-files.js +96 -0
  15. package/dist/create-kumiko-server-reload.d.ts +14 -0
  16. package/dist/create-kumiko-server-reload.js +73 -0
  17. package/dist/create-kumiko-server-routes.d.ts +16 -0
  18. package/dist/create-kumiko-server-routes.js +206 -0
  19. package/dist/create-kumiko-server-shutdown.d.ts +14 -0
  20. package/dist/create-kumiko-server-shutdown.js +72 -0
  21. package/dist/create-kumiko-server-stack.d.ts +12 -0
  22. package/dist/create-kumiko-server-stack.js +101 -0
  23. package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
  24. package/dist/create-kumiko-server-stylesheet.js +137 -0
  25. package/dist/create-kumiko-server-watcher.d.ts +22 -0
  26. package/dist/create-kumiko-server-watcher.js +155 -0
  27. package/dist/create-kumiko-server.d.ts +53 -71
  28. package/dist/create-kumiko-server.js +59 -1076
  29. package/dist/dev-server-runtime.d.ts +5 -0
  30. package/dist/dev-server-runtime.js +11 -0
  31. package/dist/env-schema.d.ts +1 -1
  32. package/dist/env-schema.js +4 -4
  33. package/dist/index.d.ts +3 -1
  34. package/dist/index.js +17 -15
  35. package/dist/managed-files/app-jobs.js +1 -1
  36. package/dist/managed-files/check.js +1 -1
  37. package/dist/run-dev-app-auth.d.ts +38 -0
  38. package/dist/run-dev-app-auth.js +155 -0
  39. package/dist/run-dev-app-config.d.ts +26 -0
  40. package/dist/run-dev-app-config.js +185 -0
  41. package/dist/run-dev-app-server-options.d.ts +13 -0
  42. package/dist/run-dev-app-server-options.js +49 -0
  43. package/dist/run-dev-app.d.ts +52 -50
  44. package/dist/run-dev-app.js +100 -471
  45. package/dist/scaffold-app-feature.js +5 -4
  46. package/dist/scaffold-app.js +5 -7
  47. package/dist/schema-apply.d.ts +3 -3
  48. package/dist/schema-apply.js +16 -16
  49. package/dist/schema-tables-auth-mode.js +16 -15
  50. package/dist/schema-tables-minimal.js +7 -7
  51. package/dist/setup-test-stack-from-features.js +1 -1
  52. package/dist/welcome-banner.js +2 -2
  53. package/dist/write-server-lockfile.js +23 -16
  54. package/package.json +10 -10
  55. package/src/changes.json +30 -0
  56. package/templates/managed/ci-public.yml.template +4 -0
@@ -0,0 +1,132 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, createClientIpResolver, generateToken, sessionCookieName, } from "@cosmicdrift/kumiko-framework/api";
4
+ import { rankPreferredLocales } from "@cosmicdrift/kumiko-framework/i18n";
5
+ import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
6
+ import { resolveAndInjectPageHead, } from "@cosmicdrift/kumiko-renderer/headless/apex";
7
+ import { buildRequestBoundSystemQuery } from "@cosmicdrift/kumiko-server-runtime/request-bound-system-query";
8
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
9
+ import { injectReload } from "./create-kumiko-server-reload.js";
10
+ // The dev server speaks plain http, so its minted cookies are never Secure and keep the unprefixed names.
11
+ const CSRF_COOKIE = sessionCookieName(CSRF_COOKIE_BASE, { secure: false });
12
+ const AUTH_COOKIE = sessionCookieName(AUTH_COOKIE_BASE, { secure: false });
13
+ // Minimal HTML when the caller didn't hand one in. `#root` is the
14
+ // default mount target for `createKumikoApp`, so the one-line client
15
+ // can attach without the sample having to ship its own template.
16
+ //
17
+ // `type="module"` is load-bearing: a classic script turns the bundle's
18
+ // top-level declarations into window properties, so a dependency exporting
19
+ // `function history()` (prosemirror-history, pulled in by tiptap) silently
20
+ // replaces `window.history` and every pushState afterwards throws. Prod
21
+ // (build-prod-bundle) has always emitted the module form.
22
+ //
23
+ // `interactive-widget=resizes-content`: without it, a mobile
24
+ // keyboard shrinks only the visual viewport, not the layout viewport that
25
+ // `position: fixed` anchors to — a fixed bottom action bar would stay
26
+ // pinned behind the keyboard instead of above it. iOS Safari ignores the
27
+ // key; renderer/web's useKeyboardInset covers it via window.visualViewport.
28
+ export const DEFAULT_HTML = `<!doctype html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="utf-8" />
32
+ <meta name="viewport" content="width=device-width,initial-scale=1,interactive-widget=resizes-content" />
33
+ <title>Kumiko</title>
34
+ </head>
35
+ <body>
36
+ <div id="root"></div>
37
+ <script type="module" src="/client.js"></script>
38
+ </body>
39
+ </html>
40
+ `;
41
+ // Injects <link rel="stylesheet" href="/styles.css"> into <head> if it
42
+ // is not already present. Called only when the stylesheet option is used;
43
+ // otherwise no CSS route is provided.
44
+ function injectStylesheet(html) {
45
+ if (html.includes('href="/styles.css"'))
46
+ return html;
47
+ const link = '<link rel="stylesheet" href="/styles.css" />';
48
+ return html.includes("</head>")
49
+ ? html.replace("</head>", ` ${link}\n</head>`)
50
+ : `${link}${html}`;
51
+ }
52
+ // Single-entry uses one template (`htmlPath` or DEFAULT_HTML). Multi-entry
53
+ // uses one per entry (`entry.htmlPath ?? options.htmlPath ?? DEFAULT_HTML`);
54
+ // hostDispatch selects it per request.
55
+ export async function loadHtmlTemplates(entries, htmlPath) {
56
+ const defaultTemplate = htmlPath !== undefined ? await readFile(resolve(htmlPath), "utf-8") : DEFAULT_HTML;
57
+ const byEntry = new Map();
58
+ for (const e of entries) {
59
+ byEntry.set(e.name, e.htmlPath !== undefined ? await readFile(resolve(e.htmlPath), "utf-8") : defaultTemplate);
60
+ }
61
+ return { defaultTemplate, byEntry };
62
+ }
63
+ // Anonymous-role systemQuery, shared by resolvePageHead and hostDispatch —
64
+ // mirrors runProdApp's HostDispatchFn deps so dev/prod stay symmetric.
65
+ // Own resolver with the same effective hops as setupTestStack's internal
66
+ // buildServer: this path runs ahead of requestIdMiddleware's /api/* mount,
67
+ // so it can't reuse that instance. Two independent warn-once flags in dev
68
+ // is an accepted, low-risk gap (dev normally runs unproxied).
69
+ export function createDevSystemQueryBuilder(dispatcher, trustedProxyHops) {
70
+ const pageHeadClientIpResolver = createClientIpResolver(trustedProxyHops ?? 0, "createKumikoServer(pageHead)");
71
+ return (req, socketAddress) => buildRequestBoundSystemQuery({
72
+ req,
73
+ dispatcher,
74
+ resolver: pageHeadClientIpResolver,
75
+ socketAddress,
76
+ });
77
+ }
78
+ // Dev user = TestUsers.admin: demo features are openToAll, but the auth
79
+ // middleware still requires a valid JWT.
80
+ async function appendDevSessionCookies(headers, jwt) {
81
+ const token = await jwt.sign(TestUsers.admin);
82
+ const csrf = generateToken();
83
+ headers.append("Set-Cookie", `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax`);
84
+ headers.append("Set-Cookie", `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax`);
85
+ }
86
+ // resolvePageHead goes through the same headless resolveAndInjectPageHead
87
+ // that runProdApp uses, so dev and e2e exercise the one timeout and
88
+ // fallback path instead of a second copy of it.
89
+ async function injectDevPageHead(html, resolvePageHead, req, socketAddress, buildSystemQuery) {
90
+ const url = new URL(req.url);
91
+ const host = req.headers.get("host") ?? url.host;
92
+ return resolveAndInjectPageHead(html, resolvePageHead, {
93
+ path: url.pathname,
94
+ host,
95
+ preferredLocales: rankPreferredLocales({
96
+ queryLang: url.searchParams.get("lang"),
97
+ acceptLanguage: req.headers.get("accept-language"),
98
+ }),
99
+ systemQuery: buildSystemQuery(req, socketAddress),
100
+ requestSignal: req.signal,
101
+ });
102
+ }
103
+ export async function respondWithHtmlShell(deps, entryName, req, socketAddress, status = 200) {
104
+ const template = deps.templates.byEntry.get(entryName) ?? deps.templates.defaultTemplate;
105
+ const headers = new Headers();
106
+ headers.set("Content-Type", "text/html; charset=utf-8");
107
+ if (deps.autoMintJwt)
108
+ await appendDevSessionCookies(headers, deps.jwt);
109
+ let html = injectReload(template);
110
+ if (deps.stylesheetPath !== undefined)
111
+ html = injectStylesheet(html);
112
+ if (deps.resolvePageHead !== undefined) {
113
+ html = await injectDevPageHead(html, deps.resolvePageHead, req, socketAddress, deps.buildSystemQuery);
114
+ }
115
+ return new Response(html, { status, headers });
116
+ }
117
+ // Raw file serve, no bundle injection — mirrors prod's `{ kind: "html", file }`.
118
+ export async function respondWithStaticHtmlFile(file, status) {
119
+ let content;
120
+ try {
121
+ content = await readFile(file, "utf-8");
122
+ }
123
+ catch (error) {
124
+ if (errorCodeOf(error) !== "ENOENT")
125
+ throw error;
126
+ return new Response(`hostDispatch: file not found: ${file}`, { status: 500 });
127
+ }
128
+ return new Response(content, {
129
+ status,
130
+ headers: { "Content-Type": "text/html; charset=utf-8" },
131
+ });
132
+ }
@@ -0,0 +1,6 @@
1
+ export declare function isRoutableGetOrHead(req: Request, pathname: string): boolean;
2
+ export declare function servePublicFile(pathname: string, publicDir: string): Promise<{
3
+ readonly bytes: Uint8Array;
4
+ readonly mime: string;
5
+ } | undefined>;
6
+ export declare function resolveDevPublicDir(): string;
@@ -0,0 +1,96 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import { resolve, sep } from "node:path";
4
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
5
+ // GET/HEAD to a non-API, non-SSE path — the dispatch-worthy set shared by
6
+ // the SPA catch-all and the public/-file lookup (they split on whether the
7
+ // path has a dot).
8
+ export function isRoutableGetOrHead(req, pathname) {
9
+ return ((req.method === "GET" || req.method === "HEAD") &&
10
+ !pathname.startsWith("/api/") &&
11
+ !pathname.startsWith("/sse"));
12
+ }
13
+ // Resolves a request pathname to a file inside publicDir, or undefined if
14
+ // it isn't one. Two traversal vectors, both must be blocked:
15
+ // - literal ".." segments (new URL() already collapses these in
16
+ // `pathname`, but resolve()+containment is checked regardless — no
17
+ // path-safety may depend on caller behavior upstream).
18
+ // - percent-encoded slashes (e.g. "%2e%2e%2f"): WHATWG URL parsing only
19
+ // normalizes dot-segments that are delimited by a literal "/", so an
20
+ // encoded slash survives into `pathname` untouched. decodeURIComponent
21
+ // resolves it to "../" before the containment check runs.
22
+ function resolvePublicFilePath(pathname, publicDir) {
23
+ let decoded;
24
+ try {
25
+ decoded = decodeURIComponent(pathname);
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ // A NUL byte makes readFile throw ERR_INVALID_ARG_VALUE (not ENOENT), which
31
+ // would surface as a 500 instead of a router miss.
32
+ if (decoded.includes("\0"))
33
+ return undefined;
34
+ const resolved = resolve(publicDir, `.${decoded}`);
35
+ return resolved === publicDir || resolved.startsWith(publicDir + sep) ? resolved : undefined;
36
+ }
37
+ // Same extension → content-type mapping as prod's
38
+ // run-prod-app-static-files.ts#mimeTypeFor — that function isn't part of
39
+ // server-runtime's public exports (its own package.json "exports" map),
40
+ // so this is a small self-contained copy rather than a new cross-package
41
+ // export for one call site.
42
+ const PUBLIC_FILE_MIME_TYPES = new Map([
43
+ ["html", "text/html; charset=utf-8"],
44
+ ["js", "text/javascript; charset=utf-8"],
45
+ ["mjs", "text/javascript; charset=utf-8"],
46
+ ["css", "text/css; charset=utf-8"],
47
+ ["json", "application/json; charset=utf-8"],
48
+ ["svg", "image/svg+xml"],
49
+ ["png", "image/png"],
50
+ ["jpg", "image/jpeg"],
51
+ ["jpeg", "image/jpeg"],
52
+ ["ico", "image/x-icon"],
53
+ ["txt", "text/plain; charset=utf-8"],
54
+ ["xml", "application/xml; charset=utf-8"],
55
+ ["webmanifest", "application/manifest+json"],
56
+ ["woff2", "font/woff2"],
57
+ ]);
58
+ function publicFileMimeType(filePath) {
59
+ const ext = filePath.toLowerCase().split(".").pop() ?? "";
60
+ return PUBLIC_FILE_MIME_TYPES.get(ext) ?? "application/octet-stream";
61
+ }
62
+ // Reads a file under publicDir for the dev-server's static-asset fallback
63
+ // (dev-parity with prod's disk lookup in buildStaticFallback). undefined
64
+ // means "not a servable file here" — caller treats that as a router miss,
65
+ // same as ENOENT/EISDIR/ENOTDIR further down.
66
+ export async function servePublicFile(pathname, publicDir) {
67
+ const filePath = resolvePublicFilePath(pathname, publicDir);
68
+ if (filePath === undefined)
69
+ return undefined;
70
+ try {
71
+ // Lexical containment (above) only catches "..", not a symlink inside
72
+ // publicDir that points outside it on disk — realpath resolves the
73
+ // actual target and the same containment check runs against it. Read
74
+ // from the resolved path so the checked target is the one read.
75
+ const real = await realpath(filePath);
76
+ if (real !== publicDir && !real.startsWith(publicDir + sep))
77
+ return undefined;
78
+ const bytes = await readFile(real);
79
+ return { bytes, mime: publicFileMimeType(filePath) };
80
+ }
81
+ catch (err) {
82
+ const code = errorCodeOf(err);
83
+ if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR" || code === "ENAMETOOLONG") {
84
+ return undefined;
85
+ }
86
+ throw err;
87
+ }
88
+ }
89
+ // App-root convention (same as the watchDirs patterns and resolveStylesheet):
90
+ // process.cwd() is the app workspace, so public/ is its static asset dir.
91
+ // Symlink-resolved once: servePublicFile compares realpath()s against it, so
92
+ // a symlinked public/ would otherwise 404 every file.
93
+ export function resolveDevPublicDir() {
94
+ const lexicalPublicDir = resolve(process.cwd(), "public");
95
+ return existsSync(lexicalPublicDir) ? realpathSync(lexicalPublicDir) : lexicalPublicDir;
96
+ }
@@ -0,0 +1,14 @@
1
+ export declare function injectReload(html: string): string;
2
+ type ReloadClient = {
3
+ readonly controller: ReadableStreamDefaultController<Uint8Array>;
4
+ readonly encoder: TextEncoder;
5
+ closed: boolean;
6
+ };
7
+ export type DevReloadHub = {
8
+ readonly bootId: string;
9
+ readonly clients: Set<ReloadClient>;
10
+ };
11
+ export declare function createReloadHub(): DevReloadHub;
12
+ export declare function broadcastReload(hub: DevReloadHub, changedFile: string): void;
13
+ export declare function openReloadStream(hub: DevReloadHub): Response;
14
+ export {};
@@ -0,0 +1,73 @@
1
+ // Reload snippet injected into every page-load so the browser
2
+ // subscribes to /_reload without the HTML needing to hard-code it.
3
+ //
4
+ // Two reload triggers:
5
+ // - explicit `reload` event from the server after hot reload (rebuild + send)
6
+ // - implicit `boot` event on every SSE connection, containing the current
7
+ // server process's bootId. The snippet remembers the first bootId; if a
8
+ // reconnect receives a different one, it refreshes. Otherwise the
9
+ // browser could remain on a stale bundle after a server restart.
10
+ const RELOAD_SNIPPET = `
11
+ <script>
12
+ (() => {
13
+ const es = new EventSource("/_reload");
14
+ let firstBootId = null;
15
+ es.addEventListener("boot", (e) => {
16
+ if (firstBootId === null) {
17
+ firstBootId = e.data;
18
+ } else if (firstBootId !== e.data) {
19
+ location.reload();
20
+ }
21
+ });
22
+ es.addEventListener("reload", () => location.reload());
23
+ })();
24
+ </script>
25
+ `;
26
+ export function injectReload(html) {
27
+ if (html.includes("/_reload"))
28
+ return html;
29
+ return html.includes("</body>")
30
+ ? html.replace("</body>", `${RELOAD_SNIPPET}</body>`)
31
+ : html + RELOAD_SNIPPET;
32
+ }
33
+ export function createReloadHub() {
34
+ return { bootId: String(Date.now()), clients: new Set() };
35
+ }
36
+ // JSON-encoded so a filename can never break the SSE framing.
37
+ export function broadcastReload(hub, changedFile) {
38
+ const payload = `event: reload\ndata: ${JSON.stringify(changedFile)}\n\n`;
39
+ for (const client of hub.clients) {
40
+ if (client.closed)
41
+ continue;
42
+ try {
43
+ client.controller.enqueue(client.encoder.encode(payload));
44
+ }
45
+ catch {
46
+ client.closed = true;
47
+ }
48
+ }
49
+ }
50
+ export function openReloadStream(hub) {
51
+ const encoder = new TextEncoder();
52
+ const stream = new ReadableStream({
53
+ start(controller) {
54
+ const entry = { controller, encoder, closed: false };
55
+ hub.clients.add(entry);
56
+ controller.enqueue(encoder.encode(": connected\n\n"));
57
+ controller.enqueue(encoder.encode(`event: boot\ndata: ${hub.bootId}\n\n`));
58
+ },
59
+ cancel() {
60
+ for (const c of hub.clients) {
61
+ if (c.closed)
62
+ hub.clients.delete(c);
63
+ }
64
+ },
65
+ });
66
+ return new Response(stream, {
67
+ headers: {
68
+ "Content-Type": "text/event-stream",
69
+ "Cache-Control": "no-cache",
70
+ Connection: "keep-alive",
71
+ },
72
+ });
73
+ }
@@ -0,0 +1,16 @@
1
+ /// <reference types="bun-types" preserve="true" />
2
+ import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";
3
+ import type { CreateKumikoServerOptions } from "./create-kumiko-server.ts";
4
+ import { type ClientBundle, type NormalizedEntry } from "./create-kumiko-server-client-bundles.ts";
5
+ import { type DevHtmlTemplates } from "./create-kumiko-server-html.ts";
6
+ import { type DevReloadHub } from "./create-kumiko-server-reload.ts";
7
+ export type DevFetchHandler = (req: Request, socketAddress?: string) => Promise<Response>;
8
+ export declare function createDevFetchHandler(input: {
9
+ readonly options: CreateKumikoServerOptions;
10
+ readonly stack: TestStack;
11
+ readonly entries: readonly NormalizedEntry[];
12
+ readonly clientBundles: ReadonlyMap<string, ClientBundle>;
13
+ readonly templates: DevHtmlTemplates;
14
+ readonly stylesheetPath: string | undefined;
15
+ readonly reloadHub: DevReloadHub;
16
+ }): DevFetchHandler;
@@ -0,0 +1,206 @@
1
+ /// <reference types="bun-types" preserve="true" />
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { RENDERER_WEB_FONT_FILE_PATTERN, RENDERER_WEB_FONTS_URL_PREFIX, resolveRendererWebFontsDir, } from "@cosmicdrift/kumiko-server-runtime/renderer-web-fonts";
5
+ import { stripNoRouteMatchHeader, tryHonoFirst, } from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
6
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
7
+ import { indexBundlesByAssetPath, } from "./create-kumiko-server-client-bundles.js";
8
+ import { createDevSystemQueryBuilder, respondWithHtmlShell, respondWithStaticHtmlFile, } from "./create-kumiko-server-html.js";
9
+ import { isRoutableGetOrHead, resolveDevPublicDir, servePublicFile, } from "./create-kumiko-server-public-files.js";
10
+ import { openReloadStream } from "./create-kumiko-server-reload.js";
11
+ const JAVASCRIPT_HEADERS = { "Content-Type": "application/javascript; charset=utf-8" };
12
+ function serveClientBundle({ req, url }, deps) {
13
+ if (req.method !== "GET")
14
+ return undefined;
15
+ const bundleName = deps.bundleByAssetPath.get(url.pathname);
16
+ if (bundleName === undefined)
17
+ return undefined;
18
+ const bundle = deps.clientBundles.get(bundleName);
19
+ if (bundle === undefined)
20
+ return new Response("no bundle", { status: 404 });
21
+ return new Response(bundle.js, { headers: JAVASCRIPT_HEADERS });
22
+ }
23
+ // Looked up in the live bundles instead of a separate index, which a
24
+ // hot-reload rebuild would leave stale.
25
+ function serveBundleChunk({ req, url }, deps) {
26
+ if (req.method !== "GET")
27
+ return undefined;
28
+ for (const bundle of deps.clientBundles.values()) {
29
+ const chunk = bundle.chunks?.get(url.pathname);
30
+ if (chunk !== undefined)
31
+ return new Response(chunk, { headers: JAVASCRIPT_HEADERS });
32
+ }
33
+ return undefined;
34
+ }
35
+ function serveBundleSourceMap({ req, url }, deps) {
36
+ if (req.method !== "GET" || !url.pathname.endsWith(".js.map"))
37
+ return undefined;
38
+ const mapName = deps.bundleByAssetPath.get(url.pathname.slice(0, -".map".length));
39
+ if (mapName === undefined)
40
+ return undefined;
41
+ const bundle = deps.clientBundles.get(mapName);
42
+ if (bundle === undefined || !bundle.map)
43
+ return new Response("no map", { status: 404 });
44
+ return new Response(bundle.map, {
45
+ headers: { "Content-Type": "application/json; charset=utf-8" },
46
+ });
47
+ }
48
+ async function serveRendererWebFont({ req, url }, deps) {
49
+ if (!url.pathname.startsWith(RENDERER_WEB_FONTS_URL_PREFIX) || req.method !== "GET") {
50
+ return undefined;
51
+ }
52
+ const fontFile = url.pathname.slice(RENDERER_WEB_FONTS_URL_PREFIX.length);
53
+ // The allowlist pattern excludes "/", "." segments and encoded traversal.
54
+ if (deps.rendererWebFontsDir === undefined || !RENDERER_WEB_FONT_FILE_PATTERN.test(fontFile)) {
55
+ return new Response("not found", { status: 404 });
56
+ }
57
+ try {
58
+ const bytes = await readFile(join(deps.rendererWebFontsDir, fontFile));
59
+ // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types just doesn't say so.
60
+ return new Response(bytes, {
61
+ headers: { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=86400" },
62
+ });
63
+ }
64
+ catch (err) {
65
+ if (errorCodeOf(err) === "ENOENT") {
66
+ return new Response("not found", { status: 404 });
67
+ }
68
+ throw err;
69
+ }
70
+ }
71
+ async function serveStylesheet({ req, url }, deps) {
72
+ if (url.pathname !== "/styles.css" || req.method !== "GET")
73
+ return undefined;
74
+ const { stylesheetPath } = deps.html;
75
+ if (stylesheetPath === undefined)
76
+ return new Response("no stylesheet", { status: 404 });
77
+ const css = await readFile(stylesheetPath, "utf-8");
78
+ return new Response(css, { headers: { "Content-Type": "text/css; charset=utf-8" } });
79
+ }
80
+ function serveReloadStream({ req, url }, deps) {
81
+ if (url.pathname !== "/_reload" || req.method !== "GET")
82
+ return undefined;
83
+ return openReloadStream(deps.reloadHub);
84
+ }
85
+ function respondToNotFoundDispatch(dispatch, { req, socketAddress }, html) {
86
+ if (dispatch.file !== undefined)
87
+ return respondWithStaticHtmlFile(dispatch.file, 404);
88
+ if (dispatch.entryName !== undefined) {
89
+ return respondWithHtmlShell(html, dispatch.entryName, req, socketAddress, 404);
90
+ }
91
+ return new Response("Not Found", { status: 404 });
92
+ }
93
+ // Discriminated dispatch mirrors production; anything that is not a
94
+ // redirect, not-found or static-html result serves its entry's shell.
95
+ function respondToHostDispatch(dispatch, request, html) {
96
+ switch (dispatch.kind) {
97
+ case "redirect":
98
+ return new Response(null, {
99
+ status: dispatch.status ?? 302,
100
+ headers: { Location: dispatch.to },
101
+ });
102
+ case "not-found":
103
+ return respondToNotFoundDispatch(dispatch, request, html);
104
+ case "static-html":
105
+ return respondWithStaticHtmlFile(dispatch.file, 200);
106
+ default:
107
+ return respondWithHtmlShell(html, dispatch.entryName, request.req, request.socketAddress);
108
+ }
109
+ }
110
+ // SPA catch-all: GET or HEAD requests to non-API, non-asset paths return
111
+ // the HTML shell so the client-side router can mount the screen for
112
+ // location.pathname. Paths containing a dot are left to the public/ lookup.
113
+ //
114
+ // HEAD is included because runProdApp falls back to the SPA for both GET and
115
+ // HEAD. Backend routes outside /api (currently /sse) are excluded, and an
116
+ // r.httpRoute outside /api is tried first, so only a Hono router miss falls
117
+ // back to the shell, matching production.
118
+ async function serveSpaShell(request, deps) {
119
+ const { req, url, socketAddress } = request;
120
+ if (!isRoutableGetOrHead(req, url.pathname) || url.pathname.includes("."))
121
+ return undefined;
122
+ const honoTry = await tryHonoFirst(deps.app, req, socketAddress);
123
+ if (honoTry.matched) {
124
+ return honoTry.response;
125
+ }
126
+ if (deps.hostDispatch === undefined) {
127
+ return respondWithHtmlShell(deps.html, "client", req, socketAddress);
128
+ }
129
+ const dispatch = await deps.hostDispatch(req, {
130
+ systemQuery: deps.html.buildSystemQuery(req, socketAddress),
131
+ cspScriptHashes: [],
132
+ });
133
+ return respondToHostDispatch(dispatch, request, deps.html);
134
+ }
135
+ // Static assets under public/ — prod serves these via buildStaticFallback's
136
+ // disk lookup (run-prod-app-static-files.ts). Hono still goes first (an
137
+ // r.httpRoute could itself own a dotted path), then the file on disk, then
138
+ // the router-miss 404, mirroring the SPA branch.
139
+ async function servePublicAsset({ req, url, socketAddress }, deps) {
140
+ if (!isRoutableGetOrHead(req, url.pathname) || !url.pathname.includes("."))
141
+ return undefined;
142
+ const honoTry = await tryHonoFirst(deps.app, req, socketAddress);
143
+ if (honoTry.matched) {
144
+ return honoTry.response;
145
+ }
146
+ const file = await servePublicFile(url.pathname, deps.publicDir);
147
+ if (file !== undefined) {
148
+ // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types
149
+ // just doesn't say so — same cast run-prod-app-static-files.ts uses.
150
+ return new Response(file.bytes, {
151
+ headers: { "Content-Type": file.mime },
152
+ });
153
+ }
154
+ return honoTry.response;
155
+ }
156
+ // Specific routes first (assets, reload SSE), then the SPA shell and
157
+ // public/ files; whatever is left goes to the Kumiko stack.
158
+ const DEV_ROUTES = [
159
+ serveClientBundle,
160
+ serveBundleChunk,
161
+ serveBundleSourceMap,
162
+ serveRendererWebFont,
163
+ serveStylesheet,
164
+ serveReloadStream,
165
+ serveSpaShell,
166
+ servePublicAsset,
167
+ ];
168
+ function createDevRouteFetch(deps) {
169
+ return async (req, socketAddress) => {
170
+ const request = { req, url: new URL(req.url), socketAddress };
171
+ for (const route of DEV_ROUTES) {
172
+ const response = await route(request, deps);
173
+ if (response !== undefined)
174
+ return response;
175
+ }
176
+ // Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the
177
+ // router-miss marker must be stripped here too — otherwise an unmatched
178
+ // path would leak it straight to the client (see try-hono-first.ts's
179
+ // header-hygiene note).
180
+ return stripNoRouteMatchHeader(await deps.app.fetch(req, socketAddress));
181
+ };
182
+ }
183
+ export function createDevFetchHandler(input) {
184
+ const { options, stack } = input;
185
+ const buildSystemQuery = createDevSystemQueryBuilder(stack.dispatcher, options.trustedProxyHops);
186
+ const routeFetch = createDevRouteFetch({
187
+ app: stack.app,
188
+ clientBundles: input.clientBundles,
189
+ bundleByAssetPath: indexBundlesByAssetPath(input.entries),
190
+ publicDir: resolveDevPublicDir(),
191
+ // The app's cwd first, this package's own location as fallback (the cwd
192
+ // may not have renderer/web installed, e.g. a fixture dir).
193
+ rendererWebFontsDir: resolveRendererWebFontsDir([process.cwd(), import.meta.dir]),
194
+ reloadHub: input.reloadHub,
195
+ hostDispatch: options.hostDispatch,
196
+ html: {
197
+ templates: input.templates,
198
+ jwt: stack.jwt,
199
+ autoMintJwt: options.auth === undefined,
200
+ stylesheetPath: input.stylesheetPath,
201
+ resolvePageHead: options.resolvePageHead,
202
+ buildSystemQuery,
203
+ },
204
+ });
205
+ return options.wrapFetch ? options.wrapFetch(routeFetch) : routeFetch;
206
+ }
@@ -0,0 +1,14 @@
1
+ /// <reference types="bun-types" preserve="true" />
2
+ import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";
3
+ import type { DevDbMode, DevJobRunners } from "./create-kumiko-server-stack.ts";
4
+ import { type BunServer } from "./dev-server-runtime.ts";
5
+ export type DevServerStopDeps = {
6
+ readonly watcherAbort: AbortController;
7
+ readonly killTailwind: (() => void) | undefined;
8
+ readonly server: BunServer | undefined;
9
+ readonly stack: TestStack;
10
+ readonly devJobRunners: DevJobRunners;
11
+ readonly dbMode: DevDbMode;
12
+ };
13
+ export declare function createDevServerStop(deps: DevServerStopDeps): () => Promise<void>;
14
+ export declare function installDevSignalHandlers(stop: () => Promise<void>): void;
@@ -0,0 +1,72 @@
1
+ import { logError, logInfo } from "./dev-server-runtime.js";
2
+ // The per-boot prefix is never reused, so its BullMQ keys (queues,
3
+ // repeatables, delayed jobs) would pile up in the shared Redis forever.
4
+ // Takes a prefix-less duplicate connection: SCAN returns full key names,
5
+ // which the stack client's keyPrefix would otherwise prepend a second time.
6
+ // The connection must be opened before stack.cleanup() closes the stack
7
+ // client, yet used only after it, because closing the stack's enqueuer
8
+ // queues re-writes their `meta` keys.
9
+ async function deleteEphemeralJobQueues(raw, jobQueueNamePrefix) {
10
+ try {
11
+ const keys = [];
12
+ for await (const batch of raw.scanStream({
13
+ match: `bull:${jobQueueNamePrefix}-*`,
14
+ count: 500,
15
+ })) {
16
+ keys.push(...batch);
17
+ }
18
+ if (keys.length > 0)
19
+ await raw.del(...keys);
20
+ }
21
+ finally {
22
+ raw.disconnect();
23
+ }
24
+ }
25
+ export function createDevServerStop(deps) {
26
+ const { watcherAbort, killTailwind, server, stack, devJobRunners, dbMode } = deps;
27
+ return async () => {
28
+ // Stop the watcher first so no onChange callback runs during teardown;
29
+ // otherwise removing the temporary directory could trigger process.exit(75).
30
+ watcherAbort.abort();
31
+ if (killTailwind)
32
+ killTailwind();
33
+ if (server !== undefined) {
34
+ server.stop(true);
35
+ }
36
+ if (stack.eventDispatcher) {
37
+ await stack.eventDispatcher.stop();
38
+ }
39
+ await devJobRunners.stop();
40
+ const rawRedis = dbMode.persistentDb
41
+ ? undefined
42
+ : stack.redis.redis.duplicate({ keyPrefix: "" });
43
+ await stack.cleanup();
44
+ if (rawRedis)
45
+ await deleteEphemeralJobQueues(rawRedis, dbMode.jobQueueNamePrefix);
46
+ };
47
+ }
48
+ function stopAndExitOnCrash(event, stop) {
49
+ return async (err) => {
50
+ logError(`[kumiko-server] ${event} — cleaning up…`, err);
51
+ try {
52
+ await stop();
53
+ }
54
+ finally {
55
+ process.exit(1);
56
+ }
57
+ };
58
+ }
59
+ // Without these handlers, repeated dev restarts leak Postgres pools, leave
60
+ // the Tailwind watcher as an orphan, and leave temporary clients behind in
61
+ // persistent mode; a crash cleans up the same way before exiting nonzero.
62
+ export function installDevSignalHandlers(stop) {
63
+ for (const sig of ["SIGINT", "SIGTERM"]) {
64
+ process.on(sig, async () => {
65
+ logInfo(`[kumiko-server] ${sig} — cleaning up…`);
66
+ await stop();
67
+ process.exit(0);
68
+ });
69
+ }
70
+ process.on("uncaughtException", stopAndExitOnCrash("uncaughtException", stop));
71
+ process.on("unhandledRejection", stopAndExitOnCrash("unhandledRejection", stop));
72
+ }
@@ -0,0 +1,12 @@
1
+ import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
2
+ import { type TestStack } from "@cosmicdrift/kumiko-testing/stack";
3
+ import type { CreateKumikoServerOptions } from "./create-kumiko-server.ts";
4
+ export type DevDbMode = {
5
+ readonly devDbName: string | undefined;
6
+ readonly persistentDb: boolean;
7
+ readonly jobQueueNamePrefix: string;
8
+ };
9
+ export type DevJobRunners = Awaited<ReturnType<typeof startDevJobRunners>>;
10
+ export declare function resolveDevDbMode(processEnv: Record<string, string | undefined>): DevDbMode;
11
+ export declare function bootDevStack(options: CreateKumikoServerOptions, dbMode: DevDbMode): Promise<TestStack>;
12
+ export declare function startDevBackgroundRunners(stack: TestStack, jobQueueNamePrefix: string): Promise<DevJobRunners>;