@cosmicdrift/kumiko-server-runtime 0.285.2 → 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.
|
|
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.
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
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
|
+
});
|
package/src/changes.json
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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
|