@cosmicdrift/kumiko-server-runtime 0.285.1 → 0.286.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.285.1",
3
+ "version": "0.286.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,8 +80,9 @@
80
80
  }
81
81
  },
82
82
  "dependencies": {
83
- "@cosmicdrift/kumiko-bundled-features": "0.285.1",
84
- "@cosmicdrift/kumiko-framework": "0.285.1",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.286.0",
84
+ "@cosmicdrift/kumiko-framework": "0.286.0",
85
+ "@cosmicdrift/kumiko-headless": "0.286.0",
85
86
  "temporal-polyfill": "^0.3.2"
86
87
  },
87
88
  "publishConfig": {
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { injectPageHead } from "../render-head-tags";
3
+
4
+ describe("injectPageHead", () => {
5
+ const TAGS = '<title>New Title</title>\n<meta name="description" content="d">';
6
+
7
+ test("replaces an existing <title> instead of duplicating it", () => {
8
+ const html = "<html><head><title>Old</title></head><body></body></html>";
9
+ const out = injectPageHead(html, TAGS);
10
+ expect(out).not.toContain("<title>Old</title>");
11
+ expect((out.match(/<title>/g) ?? []).length).toBe(1);
12
+ expect(out).toContain("<title>New Title</title>");
13
+ });
14
+
15
+ test("applying twice does not duplicate the head-tags block", () => {
16
+ const html = "<html><head><title>Old</title></head><body></body></html>";
17
+ const once = injectPageHead(html, TAGS);
18
+ const twice = injectPageHead(once, TAGS);
19
+ expect(twice).toBe(once);
20
+ expect((twice.match(/kumiko-page-head/g) ?? []).length).toBe(1);
21
+ });
22
+
23
+ test("HTML without </head> is returned unchanged", () => {
24
+ const html = "<html><body><title>Old</title></body></html>";
25
+ expect(injectPageHead(html, TAGS)).toBe(html);
26
+ });
27
+ });
@@ -203,3 +203,110 @@ describe("buildStaticFallback hostDispatch", () => {
203
203
  expect(await res.text()).toContain("spa-shell");
204
204
  });
205
205
  });
206
+
207
+ describe("buildStaticFallback resolvePageHead", () => {
208
+ let tmp = "";
209
+
210
+ const HTML = "<!doctype html><html><head><title>Offlot</title></head><body>shell</body></html>";
211
+
212
+ function stubDispatcher(): { query: () => Promise<unknown> } {
213
+ return { query: async () => ({}) };
214
+ }
215
+
216
+ beforeEach(async () => {
217
+ tmp = await mkdtemp(join(tmpdir(), "kumiko-pagehead-"));
218
+ await writeFile(join(tmp, "tenant.html"), HTML);
219
+ await writeFile(join(tmp, "index.html"), HTML);
220
+ });
221
+
222
+ afterEach(async () => {
223
+ await rm(tmp, { recursive: true, force: true });
224
+ });
225
+
226
+ // Exercised through hostDispatch's "html" branch — that's the path
227
+ // offlot actually uses in production (createOfflotHostDispatch returns
228
+ // {kind:"html", ...} for every host), not the default single-app path.
229
+ test("resolver meta → og-tags appear in the hostDispatch HTML response", async () => {
230
+ const handler = buildStaticFallback(
231
+ () => noRouteMatchedResponse(),
232
+ tmp,
233
+ "{}",
234
+ () => ({ kind: "html", file: "tenant.html" }),
235
+ {
236
+ resolvePageHead: async () => ({
237
+ title: "Vehicle X",
238
+ description: "A car",
239
+ ogImage: "https://x/i.png",
240
+ }),
241
+ dispatcher: stubDispatcher(),
242
+ },
243
+ );
244
+ const res = await handler(new Request("http://t/"));
245
+ expect(res.status).toBe(200);
246
+ const text = await res.text();
247
+ expect(text).toContain("<title>Vehicle X</title>");
248
+ expect(text).toContain('<meta property="og:image" content="https://x/i.png" />');
249
+ });
250
+
251
+ test("resolver throws → 200 with the unchanged shell", async () => {
252
+ const handler = buildStaticFallback(
253
+ () => noRouteMatchedResponse(),
254
+ tmp,
255
+ "{}",
256
+ () => ({ kind: "html", file: "tenant.html" }),
257
+ {
258
+ resolvePageHead: async () => {
259
+ throw new Error("boom");
260
+ },
261
+ dispatcher: stubDispatcher(),
262
+ },
263
+ );
264
+ const res = await handler(new Request("http://t/"));
265
+ expect(res.status).toBe(200);
266
+ expect(await res.text()).toBe(HTML);
267
+ });
268
+
269
+ test("resolver never resolves → 200 with the unchanged shell after the timeout", async () => {
270
+ const handler = buildStaticFallback(
271
+ () => noRouteMatchedResponse(),
272
+ tmp,
273
+ "{}",
274
+ () => ({ kind: "html", file: "tenant.html" }),
275
+ {
276
+ resolvePageHead: () => new Promise(() => {}),
277
+ dispatcher: stubDispatcher(),
278
+ },
279
+ );
280
+ const res = await handler(new Request("http://t/"));
281
+ expect(res.status).toBe(200);
282
+ expect(await res.text()).toBe(HTML);
283
+ }, 2000);
284
+
285
+ test("two paths with different resolved titles get different ETags", async () => {
286
+ const handler = buildStaticFallback(
287
+ () => noRouteMatchedResponse(),
288
+ tmp,
289
+ "{}",
290
+ () => ({ kind: "html", file: "tenant.html" }),
291
+ {
292
+ resolvePageHead: async ({ path }) => ({ title: `Title for ${path}` }),
293
+ dispatcher: stubDispatcher(),
294
+ },
295
+ );
296
+ const resA = await handler(new Request("http://t/a"));
297
+ const resB = await handler(new Request("http://t/b"));
298
+ expect(resA.headers.get("etag")).not.toBe(resB.headers.get("etag"));
299
+ });
300
+
301
+ test("no resolvePageHead configured → response is byte-identical to the no-resolver call", async () => {
302
+ const withoutPageHead = buildStaticFallback(
303
+ () => noRouteMatchedResponse(),
304
+ tmp,
305
+ "{}",
306
+ () => ({ kind: "html", file: "tenant.html" }),
307
+ );
308
+ const res = await withoutPageHead(new Request("http://t/"));
309
+ expect(res.status).toBe(200);
310
+ expect(await res.text()).toBe(HTML);
311
+ });
312
+ });
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.286.0",
4
+ "type": "improvement",
5
+ "title": "runProdApp gains resolvePageHead for per-request Open-Graph/title/description metadata",
6
+ "detail": "New PageHeadResolver/PageHeadMeta types and a resolvePageHead option on RunProdAppOptions. buildStaticFallback's static-files module resolves the metadata on both HTML-serving paths (hostDispatch and the default index.html), timing the resolver out at 300ms via Promise.race and always falling back to the unchanged 200 shell on error/null/timeout. Head tags render via renderApexHeadTags from the new @cosmicdrift/kumiko-headless dependency (ApexHead built from PageHeadMeta, lang derived from meta.locale's language subtag, default \"en\"; og:type is hardcoded \"website\" by the renderer, so PageHeadMeta has no ogType field), injected idempotently via a marker comment in render-head-tags.ts's injectPageHead, same pattern as inject-schema.ts. A strong ETag is recomputed over the final bytes whenever head tags are actually injected, so two requests with different resolved metadata never collide on one ETag. systemQuery inside the resolver dispatches as createAnonymousUser (not createSystemUser) wrapped in requestContext.run, using the new buildRequestContextDataFromRequest(req: Request) extracted from @cosmicdrift/kumiko-framework/api's existing buildRequestContextData(c: Context) — needed because this resolver runs outside Hono's router and only has a raw Request. No behavior change for apps that don't set resolvePageHead."
7
+ }
8
+ ]
@@ -0,0 +1,14 @@
1
+ const HEAD_TAGS_MARKER = "<!-- kumiko-page-head -->";
2
+ const TITLE_TAG_RE = /<title\b[^>]*>[\s\S]*?<\/title>/i;
3
+
4
+ // Idempotent by marker (repeated calls, e.g. hostDispatch + default path
5
+ // both hitting the same request, never double-inject) and safe on a
6
+ // head-less document (nothing to splice into). `tagsHtml` is pre-rendered
7
+ // HTML from a caller (renderApexHeadTags) — this function only owns
8
+ // placement + the original <title> removal, not escaping.
9
+ export function injectPageHead(html: string, tagsHtml: string): string {
10
+ if (html.includes(HEAD_TAGS_MARKER)) return html;
11
+ if (!html.includes("</head>")) return html;
12
+ const withoutTitle = html.replace(TITLE_TAG_RE, "");
13
+ return withoutTitle.replace("</head>", () => `${HEAD_TAGS_MARKER}\n${tagsHtml}\n</head>`);
14
+ }
@@ -1,12 +1,22 @@
1
1
  import {
2
+ buildRequestContextDataFromRequest,
2
3
  type CachePolicy,
3
4
  cachedResponse,
4
5
  computeStrongEtag,
5
6
  computeWeakEtag,
7
+ requestContext,
6
8
  } from "@cosmicdrift/kumiko-framework/api";
9
+ import { createAnonymousUser, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { type ApexHead, renderApexHeadTags } from "@cosmicdrift/kumiko-headless/apex";
7
11
  import { ASSETS_DIR } from "./build-prod-bundle";
8
12
  import { injectSchema } from "./inject-schema";
9
- import type { HostDispatchFn } from "./run-prod-app";
13
+ import { injectPageHead } from "./render-head-tags";
14
+ import type {
15
+ HostDispatchFn,
16
+ PageHeadMeta,
17
+ PageHeadResolver,
18
+ PageHeadSystemQuery,
19
+ } from "./run-prod-app";
10
20
  import { stripNoRouteMatchHeader, tryHonoFirst } from "./try-hono-first";
11
21
 
12
22
  // Static-asset + SPA-fallback serving for runProdApp's HTTP handler. Split
@@ -110,11 +120,28 @@ export function mimeTypeFor(filePath: string): string {
110
120
  }
111
121
  }
112
122
 
123
+ // Minimal structural shape of the dispatcher buildStaticFallback needs —
124
+ // not the full Dispatcher type, so this file doesn't have to import the
125
+ // pipeline package just to type one param.
126
+ type QueryDispatcher = {
127
+ readonly query: (type: string, payload: unknown, user: SessionUser) => Promise<unknown>;
128
+ };
129
+
130
+ export type PageHeadOptions = {
131
+ readonly resolvePageHead: PageHeadResolver;
132
+ readonly dispatcher: QueryDispatcher;
133
+ };
134
+
135
+ // Resolver runs alongside the request, never gates it: a slow, throwing, or
136
+ // null-returning resolver must never turn a 200 shell into a 500 or a stall.
137
+ const PAGE_HEAD_TIMEOUT_MS = 300;
138
+
113
139
  export function buildStaticFallback(
114
140
  apiHandler: (req: Request) => Response | Promise<Response>,
115
141
  staticDir: string,
116
142
  appSchemaJson: string,
117
143
  hostDispatch?: HostDispatchFn,
144
+ pageHead?: PageHeadOptions,
118
145
  ): (req: Request) => Promise<Response> {
119
146
  const indexHtml = `${staticDir}/index.html`;
120
147
 
@@ -166,6 +193,81 @@ export function buildStaticFallback(
166
193
  });
167
194
  }
168
195
 
196
+ // ApexHead.lang is required (html lang="..."); PageHeadMeta only carries
197
+ // og:locale-shaped strings ("de_DE", "en-US") since that's all og:locale
198
+ // needs. Take the language subtag before the region separator; default to
199
+ // "en" when there's no locale to derive one from.
200
+ function apexLangFromLocale(locale: string | undefined): string {
201
+ return locale?.split(/[_-]/)[0]?.toLowerCase() || "en";
202
+ }
203
+
204
+ function toApexHead(meta: PageHeadMeta): ApexHead {
205
+ return {
206
+ lang: apexLangFromLocale(meta.locale),
207
+ title: meta.title,
208
+ description: meta.description ?? "",
209
+ ...(meta.canonicalUrl !== undefined ? { canonicalUrl: meta.canonicalUrl } : {}),
210
+ ...(meta.ogImage !== undefined ? { ogImage: meta.ogImage } : {}),
211
+ ...(meta.siteName !== undefined ? { siteName: meta.siteName } : {}),
212
+ ...(meta.locale !== undefined ? { locale: meta.locale } : {}),
213
+ };
214
+ }
215
+
216
+ // Resolves per-request head metadata, capped at PAGE_HEAD_TIMEOUT_MS.
217
+ // Never throws — a failing/slow/absent resolver just means "no head
218
+ // metadata this request", not a broken response.
219
+ async function resolvePageHeadMeta(req: Request): Promise<PageHeadMeta | null> {
220
+ if (!pageHead) return null;
221
+ const url = new URL(req.url);
222
+ const host = req.headers.get("host") ?? url.host;
223
+ const systemQuery: PageHeadSystemQuery = (type, payload, tenantId) =>
224
+ requestContext.run(requestContext.get() ?? buildRequestContextDataFromRequest(req), () =>
225
+ pageHead.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
226
+ );
227
+ let timer: ReturnType<typeof setTimeout> | undefined;
228
+ try {
229
+ const timedOut = new Promise<null>((resolve) => {
230
+ timer = setTimeout(() => resolve(null), PAGE_HEAD_TIMEOUT_MS);
231
+ });
232
+ // .catch on the resolver's own promise (not just the outer try/catch)
233
+ // so a rejection arriving AFTER the timeout already won the race
234
+ // doesn't surface as an unhandled rejection.
235
+ const resolved = pageHead
236
+ .resolvePageHead({ path: url.pathname, host, systemQuery })
237
+ .catch(() => null);
238
+ return await Promise.race([resolved, timedOut]);
239
+ } catch {
240
+ return null;
241
+ } finally {
242
+ if (timer !== undefined) clearTimeout(timer);
243
+ }
244
+ }
245
+
246
+ // Injects resolved head metadata into an already-read HTML payload, right
247
+ // before it's served. Recomputes a strong etag over the final bytes so
248
+ // two requests with different head metadata never collide on one etag
249
+ // (see computeStrongEtag usage in readHtmlFile above for the same
250
+ // requirement on schema-injection). No resolvePageHead configured, no
251
+ // meta resolved, or nothing actually changed (e.g. no `</head>` to
252
+ // inject into) → the original html object is returned untouched.
253
+ async function applyPageHead(
254
+ req: Request,
255
+ html: { bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number },
256
+ ): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number }> {
257
+ const meta = await resolvePageHeadMeta(req);
258
+ if (!meta) return html;
259
+ const text = new TextDecoder().decode(html.bytes);
260
+ const injected = injectPageHead(text, renderApexHeadTags(toApexHead(meta)));
261
+ if (injected === text) return html;
262
+ const encoded = new TextEncoder().encode(injected);
263
+ return {
264
+ bytes: encoded.buffer as ArrayBuffer,
265
+ mime: html.mime,
266
+ etag: computeStrongEtag(encoded),
267
+ mtimeMs: html.mtimeMs,
268
+ };
269
+ }
270
+
169
271
  // hostDispatch konsultieren wenn gesetzt UND der Request auf den
170
272
  // HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
171
273
  // resolved Response (redirect/404/html) oder null wenn der Default-
@@ -196,7 +298,8 @@ export function buildStaticFallback(
196
298
  // sonst darf ein Shared-Cache Tenant-As Schema an Tenant B liefern.
197
299
  const extraHeaders: Record<string, string> = { vary: "Host" };
198
300
  if (result.csp) extraHeaders["content-security-policy"] = result.csp;
199
- return serveHtmlFile(req, "/index.html", html, extraHeaders);
301
+ const withHead = await applyPageHead(req, html);
302
+ return serveHtmlFile(req, "/index.html", withHead, extraHeaders);
200
303
  }
201
304
 
202
305
  return async (req: Request): Promise<Response> => {
@@ -249,7 +352,8 @@ export function buildStaticFallback(
249
352
  // Default Single-App-Pfad: index.html, schema injected.
250
353
  const index = await readHtmlFile(indexHtml, true);
251
354
  if (index) {
252
- return serveHtmlFile(req, "/index.html", index);
355
+ const withHead = await applyPageHead(req, index);
356
+ return serveHtmlFile(req, "/index.html", withHead);
253
357
  }
254
358
 
255
359
  // Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
@@ -98,6 +98,7 @@ import {
98
98
  type EffectiveFeaturesResolver,
99
99
  type FeatureDefinition,
100
100
  findTierResolverUsage,
101
+ type TenantId,
101
102
  type TierResolverPlugin,
102
103
  validateAppCustomScreenWriteQns,
103
104
  validateBoot,
@@ -430,6 +431,34 @@ export type HostDispatchFn = (req: {
430
431
  readonly search: string;
431
432
  }) => HostDispatchResult;
432
433
 
434
+ /** Per-request head metadata (Open-Graph/title/description) for the
435
+ * static-fallback HTML shell. Only `title` is required; everything else
436
+ * falls back to no tag rather than a placeholder. */
437
+ export type PageHeadMeta = {
438
+ readonly title: string;
439
+ readonly description?: string;
440
+ readonly ogImage?: string;
441
+ readonly canonicalUrl?: string;
442
+ readonly siteName?: string;
443
+ readonly locale?: string;
444
+ };
445
+
446
+ // Matches bundled-features' shared/system-query.ts SystemQueryFn shape
447
+ // (non-generic, Promise<unknown>) rather than a generic <T> signature —
448
+ // that's the convention every other systemQuery caller in the framework
449
+ // already follows (r.httpRoute handlers, seo/managed-pages features).
450
+ export type PageHeadSystemQuery = (
451
+ type: string,
452
+ payload: unknown,
453
+ tenantId: TenantId,
454
+ ) => Promise<unknown>;
455
+
456
+ export type PageHeadResolver = (input: {
457
+ readonly path: string;
458
+ readonly host: string;
459
+ readonly systemQuery: PageHeadSystemQuery;
460
+ }) => Promise<PageHeadMeta | null>;
461
+
433
462
  export type RunProdAppOptions = {
434
463
  /** App-specific features. config/user/tenant/auth-email-password are
435
464
  * auto-mixed when `auth:` is set — don't add them yourself. */
@@ -475,6 +504,16 @@ export type RunProdAppOptions = {
475
504
  * werden. CSP-Header pro Host können zusätzlich Asset-Pfade
476
505
  * einschränken. */
477
506
  readonly hostDispatch?: HostDispatchFn;
507
+ /** Per-request head-metadata resolver (Open-Graph/title/description) for
508
+ * the static-fallback HTML shell — see `PageHeadResolver`. Consulted on
509
+ * the same two HTML-serving paths as `hostDispatch` (host-dispatched
510
+ * HTML + the default single-app index.html), right before the response
511
+ * is sent. On error, `null`, or a resolve time over 300ms, the shell
512
+ * ships unchanged with status 200 — this must never turn into a 500 or
513
+ * a blank page. `systemQuery` inside the resolver runs as the
514
+ * anonymous role (not system) — the resolver is reachable by any
515
+ * public visitor, same access gate as a real anonymous request. */
516
+ readonly resolvePageHead?: PageHeadResolver;
478
517
  /** Pfad zu kumiko/migrations für den Boot-Gate. Default "./kumiko/
479
518
  * migrations" relativ zum process-cwd (wo die App gestartet wird —
480
519
  * bei Container-Deploys typischerweise der App-Workspace-Root, weil
@@ -1271,6 +1310,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1271
1310
  options.staticDir,
1272
1311
  appSchemaJson,
1273
1312
  options.hostDispatch,
1313
+ options.resolvePageHead
1314
+ ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
1315
+ : undefined,
1274
1316
  )
1275
1317
  : // No staticDir (split-deploy / API-only container) → app.fetch's
1276
1318
  // response goes straight to the client, bypassing buildStaticFallback