@cosmicdrift/kumiko-server-runtime 0.337.1 → 0.339.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.
@@ -33,9 +33,9 @@ export type BuildResult = {
33
33
  * undefined bei vanilla public-only-Builds (kein Bundle). */
34
34
  readonly buildInfo?: BuildInfo;
35
35
  };
36
- /** In index.html gebacken (`window.__KUMIKO_BUILD__`) UND als
37
- * dist/build-info.json geschrieben. Der UpdateChecker pollt build-info.json
38
- * und vergleicht `id` gegen den geladenen Stand → Reload-Banner bei Drift. */
36
+ /** Baked into index.html (`<meta name="kumiko-build">`) AND written as
37
+ * dist/build-info.json. The UpdateChecker polls build-info.json and compares
38
+ * `id` with the loaded build, showing a reload banner on drift. */
39
39
  export type BuildInfo = {
40
40
  /** Hash über die sortierten (content-gehashten) Asset-URLs. Ändert sich
41
41
  * gdw. sich ein Asset ändert — selbsttragend, kein Env-Var/Dockerfile. */
@@ -51,6 +51,7 @@ import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
51
51
  import { tmpdir } from "node:os";
52
52
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
53
53
  import { isPlainObject, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
54
+ import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
54
55
  import { Temporal } from "temporal-polyfill";
55
56
  import { RENDERER_WEB_FONT_FILE_PATTERN, RENDERER_WEB_FONTS_DIST_DIR, resolveRendererWebFontsDir, } from "./renderer-web-fonts.js";
56
57
  import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli.js";
@@ -654,6 +655,7 @@ export function buildMissingTemplateError(manifest, entry) {
654
655
  `\n` +
655
656
  `Der Build ersetzt /styles.css und /${entry.manifestKey} durch die gehashten URLs.`);
656
657
  }
658
+ const BUILD_META_NAME = "kumiko-build";
657
659
  // @internal — exported nur für Unit-Tests.
658
660
  //
659
661
  // Convention: das HTML-Template MUSS Placeholder-Tags für jedes Asset
@@ -668,10 +670,10 @@ export function buildMissingTemplateError(manifest, entry) {
668
670
  // Diff zwischen Dev- und Prod-HTML unsichtbar macht.
669
671
  export function injectAssetTags(html, manifest, entry, buildInfo) {
670
672
  let result = html;
671
- // Build-Stand vor </head> backen. Ohne </head> (z.B. Fragment) still skip.
673
+ // Meta tag instead of an inline script: strict CSPs (script-src 'self') block inline scripts.
674
+ // Without </head> (e.g. a fragment) skip silently.
672
675
  if (buildInfo && result.includes("</head>")) {
673
- // html-ok: id/builtAt sind Hex bzw. ISO — kein `<` möglich, kein Breakout.
674
- const tag = `<script>window.__KUMIKO_BUILD__=${JSON.stringify(buildInfo)};</script>`;
676
+ const tag = `<meta name="${BUILD_META_NAME}" content="${escapeHtmlAttr(buildInfo.id)}" data-built-at="${escapeHtmlAttr(buildInfo.builtAt)}" />`;
675
677
  result = result.replace("</head>", ` ${tag}\n </head>`);
676
678
  }
677
679
  const cssUrl = manifest["styles.css"];
@@ -117,8 +117,17 @@ trustedProxyHops = 0) {
117
117
  mtimeMs: file.mtimeMs,
118
118
  };
119
119
  }
120
- function serveHtmlFile(req, pathname, html, extraHeaders) {
120
+ function serveHtmlFile(req, pathname, html, extraHeaders, status) {
121
+ // A not-found page carries no validator: a matching If-None-Match must
122
+ // never turn a 404 into a bodyless 304.
123
+ if (status === 404) {
124
+ return new Response(req.method === "HEAD" ? null : html.bytes, {
125
+ status,
126
+ headers: { "content-type": html.mime, "cache-control": "no-store", ...extraHeaders },
127
+ });
128
+ }
121
129
  return cachedResponse(req, {
130
+ status,
122
131
  body: html.bytes,
123
132
  etag: html.etag,
124
133
  cache: staticCachePolicy(pathname),
@@ -177,30 +186,33 @@ trustedProxyHops = 0) {
177
186
  buildRequestContextDataFromRequest(req, { resolver: clientIpResolver, socketAddress }), () => hostDispatchDispatcher.query(type, payload, createAnonymousUser(tenantId)));
178
187
  };
179
188
  const result = await hostDispatch({ host, path: url.pathname, search: url.search }, { systemQuery });
180
- if (result.kind === "not-found") {
181
- return new Response("Not Found", { status: 404 });
182
- }
183
189
  if (result.kind === "redirect") {
184
190
  return new Response(null, {
185
191
  status: result.status ?? 302,
186
192
  headers: { Location: result.to },
187
193
  });
188
194
  }
189
- // result.kind === "html"
190
- const filePath = `${staticDir}/${result.file}`;
191
- const html = await readHtmlFile(filePath);
195
+ if (result.kind === "not-found") {
196
+ if (result.file === undefined)
197
+ return new Response("Not Found", { status: 404 });
198
+ return serveHostDispatchFile(req, result.file, result.csp, socketAddress, 404);
199
+ }
200
+ return serveHostDispatchFile(req, result.file, result.csp, socketAddress, 200);
201
+ }
202
+ async function serveHostDispatchFile(req, file, csp, socketAddress, status) {
203
+ const html = await readHtmlFile(`${staticDir}/${file}`);
192
204
  if (!html) {
193
205
  // Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
194
206
  // Liefer 500 statt silent-404 damit der Bug schnell auffällt.
195
- return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
207
+ return new Response(`hostDispatch: file not found: ${file}`, { status: 500 });
196
208
  }
197
209
  // Per-host body (hostDispatch picks the file by host) → Vary: Host,
198
210
  // otherwise a shared cache could serve Host A's HTML to Host B.
199
211
  const extraHeaders = { vary: "Host" };
200
- if (result.csp)
201
- extraHeaders["content-security-policy"] = result.csp;
212
+ if (csp)
213
+ extraHeaders["content-security-policy"] = csp;
202
214
  const withHead = await applyPageHead(req, html, socketAddress);
203
- return serveHtmlFile(req, "/index.html", withHead, extraHeaders);
215
+ return serveHtmlFile(req, "/index.html", withHead, extraHeaders, status);
204
216
  }
205
217
  return async (req, socketAddress) => {
206
218
  const url = new URL(req.url);
@@ -179,7 +179,8 @@ export type ExtraContextOption = Record<string, unknown> | ((deps: RunProdAppDep
179
179
  * itself from the authenticated GET /api/schema, after the
180
180
  * clientFeature gates (auth) let it through.
181
181
  * - "redirect": 301/302 to the given location.
182
- * - "not-found": reject outright (e.g. unknown subdomain).
182
+ * - "not-found": reject (e.g. unknown subdomain) with a plain 404, or —
183
+ * with `file` (+ optional `csp`) — serve that page with status 404.
183
184
  *
184
185
  * Only consulted when the path would otherwise fall through to the HTML
185
186
  * fallback — i.e. for "/", "/index.html", or SPA routes that neither Hono
@@ -198,6 +199,8 @@ export type HostDispatchResult = {
198
199
  readonly status?: 301 | 302;
199
200
  } | {
200
201
  readonly kind: "not-found";
202
+ readonly file?: string;
203
+ readonly csp?: string;
201
204
  };
202
205
  export type HostDispatchFn = (req: {
203
206
  readonly host: string;
@@ -242,6 +242,13 @@ export async function runProdApp(options) {
242
242
  const trustedProxyHops = options.trustedProxyHops ??
243
243
  options.auth?.trustedProxyHops ??
244
244
  parseTrustedProxyHopsEnv(readEnv(TRUSTED_PROXY_HOPS_ENV, envSource), "runProdApp");
245
+ if (trustedProxyHops === undefined) {
246
+ // With 0 hops every request appears to come from the proxy pod, so all IP-keyed
247
+ // rate limiters collapse into one shared bucket behind a reverse proxy.
248
+ // biome-ignore lint/suspicious/noConsole: boot-time warn, no logger configured this early
249
+ console.warn(`[runProdApp] trustedProxyHops / ${TRUSTED_PROXY_HOPS_ENV} is not set; client IP falls back to the socket address. ` +
250
+ "Behind a reverse proxy all clients share one rate-limit bucket.");
251
+ }
245
252
  const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
246
253
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
247
254
  console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.337.1",
3
+ "version": "0.339.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -80,10 +80,10 @@
80
80
  }
81
81
  },
82
82
  "dependencies": {
83
- "@cosmicdrift/kumiko-bundled-features": "0.337.1",
84
- "@cosmicdrift/kumiko-framework": "0.337.1",
85
- "@cosmicdrift/kumiko-headless": "0.337.1",
86
- "@cosmicdrift/kumiko-renderer-web": "0.337.1",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.339.0",
84
+ "@cosmicdrift/kumiko-framework": "0.339.0",
85
+ "@cosmicdrift/kumiko-headless": "0.339.0",
86
+ "@cosmicdrift/kumiko-renderer-web": "0.339.0",
87
87
  "bun-types": "^1.3.13",
88
88
  "ioredis": "^5.10.1",
89
89
  "temporal-polyfill": "^0.3.2"
package/src/changes.json CHANGED
@@ -1,4 +1,25 @@
1
1
  [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "fix",
5
+ "title": "Prod builds no longer inject an inline script for the build info, so strict CSPs without unsafe-inline work without hashes or nonces",
6
+ "detail": "Build info is baked into index.html as a meta tag instead of an inline script",
7
+ "migration": "window.__KUMIKO_BUILD__ no longer exists. Code that read it (for example a version footer) now gets undefined and silently renders nothing. Replace the global with readLoadedBuild() from @cosmicdrift/kumiko-renderer-web, which returns { id, builtAt } from the new <meta name=\"kumiko-build\"> tag, and drop the Window augmentation for __KUMIKO_BUILD__. The UpdateChecker needs no change."
8
+ },
9
+ {
10
+ "version": "0.338.0",
11
+ "type": "improvement",
12
+ "title": "hostDispatch not-found can serve a custom 404 page",
13
+ "detail": "`HostDispatchResult` `{ kind: \"not-found\" }` accepts `file` (relative to `staticDir`) and `csp`. The page goes through the same delivery as `kind: \"html\"` (page head, `Vary: Host`, CSP) with status 404. A missing file answers 500 with the same message as `html`.",
14
+ "migration": "No code change needed."
15
+ },
16
+ {
17
+ "version": "0.338.0",
18
+ "type": "improvement",
19
+ "title": "boot warning when trustedProxyHops is unset",
20
+ "detail": "runProdApp warns at boot when neither `trustedProxyHops` nor `KUMIKO_TRUSTED_PROXY_HOPS` is set. Behind a reverse proxy all clients then share one IP-keyed rate-limit bucket.",
21
+ "migration": "Set `KUMIKO_TRUSTED_PROXY_HOPS` (for example 1 behind one ingress) or pass `trustedProxyHops` to runProdApp."
22
+ },
2
23
  {
3
24
  "version": "0.335.0",
4
25
  "type": "fix",