@cosmicdrift/kumiko-dev-server 0.387.0 → 0.389.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 (50) 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 +102 -0
  17. package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
  18. package/dist/create-kumiko-server-stylesheet.js +135 -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/framework-version.js +2 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.js +3 -1
  28. package/dist/managed-files/app-jobs.js +1 -1
  29. package/dist/managed-files/check.js +1 -1
  30. package/dist/managed-files/registry.js +5 -1
  31. package/dist/run-dev-app-auth.d.ts +38 -0
  32. package/dist/run-dev-app-auth.js +156 -0
  33. package/dist/run-dev-app-config.d.ts +26 -0
  34. package/dist/run-dev-app-config.js +185 -0
  35. package/dist/run-dev-app-server-options.d.ts +13 -0
  36. package/dist/run-dev-app-server-options.js +49 -0
  37. package/dist/run-dev-app.d.ts +11 -7
  38. package/dist/run-dev-app.js +90 -463
  39. package/dist/scaffold-app.d.ts +2 -1
  40. package/dist/scaffold-app.js +29 -2
  41. package/dist/schema-apply.d.ts +1 -1
  42. package/dist/schema-apply.js +4 -4
  43. package/dist/setup-test-stack-from-features.d.ts +1 -0
  44. package/dist/setup-test-stack-from-features.js +6 -2
  45. package/dist/write-server-lockfile.js +23 -16
  46. package/package.json +15 -10
  47. package/src/changes.json +61 -0
  48. package/templates/deploy/Dockerfile.template +3 -2
  49. package/templates/managed/ci-public.yml.template +6 -2
  50. package/templates/managed/kumiko-schema.ts.template +1 -0
@@ -1,7 +1,7 @@
1
1
  export type BuildServerBundleOptions = {
2
2
  /** App-Root. Default: process.cwd(). */
3
3
  readonly cwd?: string;
4
- /** Output-Folder relativ zu cwd. Default: "dist-server". */
4
+ /** Output folder relative to cwd. Default: "dist-server". */
5
5
  readonly outDir?: string;
6
6
  /** Additional packages that must be installed at runtime
7
7
  * (beyond RUNTIME_EXTERNALS). App-specific; defaults to empty. */
@@ -125,12 +125,12 @@ export async function buildServerBundle(options = {}) {
125
125
  const outDir = resolve(cwd, options.outDir ?? "dist-server");
126
126
  const serverEntry = discoverServerEntry(cwd);
127
127
  if (!serverEntry) {
128
- throw new Error(`[buildServerBundle] Kein bin/main.ts in ${cwd}.\n Convention: bin/main.ts ist der Server-Bootstrap.`);
128
+ throw new Error(`[buildServerBundle] No bin/main.ts in ${cwd}.\n Convention: bin/main.ts is the server bootstrap.`);
129
129
  }
130
130
  const repoRoot = findRepoRoot(cwd);
131
131
  const kumikoCli = repoRoot ? join(repoRoot, "bin/kumiko.ts") : undefined;
132
132
  if (kumikoCli && !existsSync(kumikoCli)) {
133
- throw new Error(`[buildServerBundle] Repo-Root erkannt (${repoRoot}), aber bin/kumiko.ts fehlt — kann Migrate-CLI nicht bündeln.`);
133
+ throw new Error(`[buildServerBundle] Found the repo root (${repoRoot}), but bin/kumiko.ts is missing, so the migrate CLI cannot be bundled.`);
134
134
  }
135
135
  const extraEntryPaths = resolveExtraEntries(cwd, options.extraEntries ?? []);
136
136
  const externals = [
@@ -0,0 +1,21 @@
1
+ /// <reference types="bun-types" preserve="true" />
2
+ import type { CreateKumikoServerOptions } from "./create-kumiko-server.ts";
3
+ export declare const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
4
+ export type ClientBundle = {
5
+ readonly js: string;
6
+ readonly map: string;
7
+ readonly chunks?: ReadonlyMap<string, string>;
8
+ };
9
+ export type ClientBundleBuilder = (sourceFile: string) => Promise<ClientBundle>;
10
+ export declare function buildClient(entry: string): Promise<ClientBundle>;
11
+ /** @internal — normalized client-entry form shared by single-entry
12
+ * (`clientEntry`) and multi-entry (`clientEntries`) modes. */
13
+ export type NormalizedEntry = {
14
+ readonly name: string;
15
+ readonly sourceFile: string;
16
+ readonly htmlPath: string | undefined;
17
+ };
18
+ export declare function normalizeEntries(options: CreateKumikoServerOptions): readonly NormalizedEntry[];
19
+ export declare function indexBundlesByAssetPath(entries: readonly NormalizedEntry[]): ReadonlyMap<string, string>;
20
+ export declare function buildClientBundles(entries: readonly NormalizedEntry[], buildBundle: ClientBundleBuilder): Promise<Map<string, ClientBundle>>;
21
+ export declare function rebuildClientBundles(entries: readonly NormalizedEntry[], buildBundle: ClientBundleBuilder, clientBundles: Map<string, ClientBundle>): Promise<void>;
@@ -0,0 +1,97 @@
1
+ /// <reference types="bun-types" preserve="true" />
2
+ import { resolve } from "node:path";
3
+ import { hasBun, logError, logInfo } from "./dev-server-runtime.js";
4
+ // Duplicated in packages/testing/src/e2e/constants.ts: the Playwright config
5
+ // runs under Node and cannot import this module.
6
+ export const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
7
+ export async function buildClient(entry) {
8
+ if (!hasBun) {
9
+ throw new Error("[kumiko-server] clientEntry is only supported under Bun — Bun.build is unavailable in this runtime.");
10
+ }
11
+ const unminified = process.env["KUMIKO_DEV_UNMINIFIED"] === "1";
12
+ // Same splitting/sourcemap/NODE_ENV as build-prod-bundle.ts, opt-in via
13
+ // env so `bun dev` stays unchanged.
14
+ const prodShaped = process.env[PROD_BUNDLES_ENV] === "1";
15
+ const built = await Bun.build({
16
+ entrypoints: [entry],
17
+ target: "browser",
18
+ minify: !unminified,
19
+ sourcemap: prodShaped ? "none" : "linked",
20
+ splitting: prodShaped,
21
+ ...(prodShaped ? { define: { "process.env.NODE_ENV": JSON.stringify("production") } } : {}),
22
+ });
23
+ if (!built.success) {
24
+ logError("[kumiko-server] client bundle failed:");
25
+ for (const log of built.logs)
26
+ logError(log);
27
+ throw new Error("client bundle failed");
28
+ }
29
+ const jsOutput = built.outputs.find((o) => o.kind === "entry-point");
30
+ const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
31
+ if (!jsOutput)
32
+ throw new Error("[kumiko-server] client bundle produced no .js output");
33
+ const chunks = new Map();
34
+ for (const output of built.outputs) {
35
+ if (output.kind !== "chunk")
36
+ continue;
37
+ const chunkPath = `/${output.path.replace(/^\.\//, "")}`;
38
+ chunks.set(chunkPath, await output.text());
39
+ }
40
+ return {
41
+ js: await jsOutput.text(),
42
+ map: mapOutput ? await mapOutput.text() : "",
43
+ chunks,
44
+ };
45
+ }
46
+ /** URL path used to serve an entry: `"client"` → `/client.js`
47
+ * (the single-entry default), otherwise `/client-<name>.js`. Keeping
48
+ * this in one place ensures routing and logging use the same convention. */
49
+ function assetPathFor(entryName) {
50
+ return entryName === "client" ? "/client.js" : `/client-${entryName}.js`;
51
+ }
52
+ export function normalizeEntries(options) {
53
+ if (options.clientEntries !== undefined && options.clientEntry !== undefined) {
54
+ throw new Error("[kumiko-server] clientEntry und clientEntries sind mutually exclusive — wähle eins");
55
+ }
56
+ if (options.clientEntries !== undefined && options.clientEntries.length > 0) {
57
+ if (options.hostDispatch === undefined) {
58
+ throw new Error("[kumiko-server] clientEntries braucht hostDispatch — sonst weiß der Server nicht welches Template er liefern soll");
59
+ }
60
+ return options.clientEntries.map((e) => ({
61
+ name: e.name,
62
+ sourceFile: resolve(e.sourceFile),
63
+ htmlPath: e.htmlPath,
64
+ }));
65
+ }
66
+ if (options.clientEntry !== undefined) {
67
+ return [{ name: "client", sourceFile: resolve(options.clientEntry), htmlPath: undefined }];
68
+ }
69
+ return [];
70
+ }
71
+ // Each entry is served at GET /client[-name].js and its sourcemap at
72
+ // GET /client[-name].js.map.
73
+ export function indexBundlesByAssetPath(entries) {
74
+ const bundleByAssetPath = new Map();
75
+ for (const e of entries)
76
+ bundleByAssetPath.set(assetPathFor(e.name), e.name);
77
+ return bundleByAssetPath;
78
+ }
79
+ export async function buildClientBundles(entries, buildBundle) {
80
+ const clientBundles = new Map();
81
+ for (const e of entries) {
82
+ const bundle = await buildBundle(e.sourceFile);
83
+ clientBundles.set(e.name, bundle);
84
+ logInfo(`[kumiko-server] client bundle ${e.name}: ${bundle.js.length.toLocaleString()} bytes` +
85
+ (bundle.map ? ` (+ ${bundle.map.length.toLocaleString()} bytes sourcemap)` : ""));
86
+ }
87
+ return clientBundles;
88
+ }
89
+ // Rebuilds every entry even when only one file changed, because we do not
90
+ // know which entries import it. A failing build throws and leaves the
91
+ // bundles replaced so far in place.
92
+ export async function rebuildClientBundles(entries, buildBundle, clientBundles) {
93
+ for (const e of entries) {
94
+ const rebuilt = await buildBundle(e.sourceFile);
95
+ clientBundles.set(e.name, rebuilt);
96
+ }
97
+ }
@@ -0,0 +1,21 @@
1
+ import { type PageHeadResolver, type PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
2
+ import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";
3
+ import type { NormalizedEntry } from "./create-kumiko-server-client-bundles.ts";
4
+ export declare const DEFAULT_HTML = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,interactive-widget=resizes-content\" />\n <title>Kumiko</title>\n </head>\n <body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/client.js\"></script>\n </body>\n</html>\n";
5
+ export type DevHtmlTemplates = {
6
+ readonly defaultTemplate: string;
7
+ readonly byEntry: ReadonlyMap<string, string>;
8
+ };
9
+ export declare function loadHtmlTemplates(entries: readonly NormalizedEntry[], htmlPath: string | undefined): Promise<DevHtmlTemplates>;
10
+ export type DevSystemQueryBuilder = (req: Request, socketAddress?: string) => PageHeadSystemQuery;
11
+ export declare function createDevSystemQueryBuilder(dispatcher: TestStack["dispatcher"], trustedProxyHops: number | undefined): DevSystemQueryBuilder;
12
+ export type DevHtmlDeps = {
13
+ readonly templates: DevHtmlTemplates;
14
+ readonly jwt: TestStack["jwt"];
15
+ readonly autoMintJwt: boolean;
16
+ readonly stylesheetPath: string | undefined;
17
+ readonly resolvePageHead: PageHeadResolver | undefined;
18
+ readonly buildSystemQuery: DevSystemQueryBuilder;
19
+ };
20
+ export declare function respondWithHtmlShell(deps: DevHtmlDeps, entryName: string, req: Request, socketAddress?: string, status?: number): Promise<Response>;
21
+ export declare function respondWithStaticHtmlFile(file: string, status: number): Promise<Response>;
@@ -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;