@cosmicdrift/kumiko-server-runtime 0.287.0 → 0.289.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 +4 -4
- package/src/__tests__/boot-probe-fixture.ts +1 -0
- package/src/__tests__/run-prod-app.integration.test.ts +2 -0
- package/src/changes.json +6 -0
- package/src/index.ts +3 -0
- package/src/run-prod-app-static-files.ts +19 -65
- package/src/run-prod-app.ts +12 -28
- package/src/__tests__/render-head-tags.test.ts +0 -27
- package/src/render-head-tags.ts +0 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.289.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,9 +80,9 @@
|
|
|
80
80
|
}
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
85
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
83
|
+
"@cosmicdrift/kumiko-bundled-features": "0.289.0",
|
|
84
|
+
"@cosmicdrift/kumiko-framework": "0.289.0",
|
|
85
|
+
"@cosmicdrift/kumiko-headless": "0.289.0",
|
|
86
86
|
"temporal-polyfill": "^0.3.2"
|
|
87
87
|
},
|
|
88
88
|
"publishConfig": {
|
|
@@ -87,12 +87,14 @@ const widgetFeature = defineFeature("prod-probe", (r) => {
|
|
|
87
87
|
name: "ping",
|
|
88
88
|
schema: z.object({}),
|
|
89
89
|
access: { roles: ["anonymous"] },
|
|
90
|
+
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
90
91
|
handler: async () => ({ pong: true }),
|
|
91
92
|
});
|
|
92
93
|
r.queryHandler({
|
|
93
94
|
name: "kms-probe",
|
|
94
95
|
schema: z.object({}),
|
|
95
96
|
access: { roles: ["anonymous"] },
|
|
97
|
+
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
96
98
|
handler: async (_event, ctx) => ({ hasKms: ctx.kms !== undefined }),
|
|
97
99
|
});
|
|
98
100
|
// SystemAdmin-gated write — Ziel des extraRoutes.dispatchSystemWrite-
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.289.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "PageHeadMeta/PageHeadResolver/PageHeadSystemQuery now importable from the server-runtime barrel",
|
|
6
|
+
"detail": "The three resolvePageHead types moved to @cosmicdrift/kumiko-headless/apex (kumiko-framework#3026) and are re-exported unchanged from run-prod-app.ts, so the existing @cosmicdrift/kumiko-server-runtime/run-prod-app subpath import keeps working. New: the main @cosmicdrift/kumiko-server-runtime barrel now also exports all three, closing the gap where a consumer had to use the subpath just to type a resolver. render-head-tags.ts (internal-only, not a public subpath) is removed; injectPageHead now lives in headless."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.286.0",
|
|
4
10
|
"type": "improvement",
|
package/src/index.ts
CHANGED
|
@@ -7,16 +7,10 @@ import {
|
|
|
7
7
|
requestContext,
|
|
8
8
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
9
9
|
import { createAnonymousUser, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
-
import {
|
|
10
|
+
import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-headless/apex";
|
|
11
11
|
import { ASSETS_DIR } from "./build-prod-bundle";
|
|
12
12
|
import { injectSchema } from "./inject-schema";
|
|
13
|
-
import {
|
|
14
|
-
import type {
|
|
15
|
-
HostDispatchFn,
|
|
16
|
-
PageHeadMeta,
|
|
17
|
-
PageHeadResolver,
|
|
18
|
-
PageHeadSystemQuery,
|
|
19
|
-
} from "./run-prod-app";
|
|
13
|
+
import type { HostDispatchFn, PageHeadResolver, PageHeadSystemQuery } from "./run-prod-app";
|
|
20
14
|
import { stripNoRouteMatchHeader, tryHonoFirst } from "./try-hono-first";
|
|
21
15
|
|
|
22
16
|
// Static-asset + SPA-fallback serving for runProdApp's HTTP handler. Split
|
|
@@ -132,10 +126,6 @@ export type PageHeadOptions = {
|
|
|
132
126
|
readonly dispatcher: QueryDispatcher;
|
|
133
127
|
};
|
|
134
128
|
|
|
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
|
-
|
|
139
129
|
export function buildStaticFallback(
|
|
140
130
|
apiHandler: (req: Request) => Response | Promise<Response>,
|
|
141
131
|
staticDir: string,
|
|
@@ -193,56 +183,6 @@ export function buildStaticFallback(
|
|
|
193
183
|
});
|
|
194
184
|
}
|
|
195
185
|
|
|
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
186
|
// Injects resolved head metadata into an already-read HTML payload, right
|
|
247
187
|
// before it's served. Recomputes a strong etag over the final bytes so
|
|
248
188
|
// two requests with different head metadata never collide on one etag
|
|
@@ -250,14 +190,28 @@ export function buildStaticFallback(
|
|
|
250
190
|
// requirement on schema-injection). No resolvePageHead configured, no
|
|
251
191
|
// meta resolved, or nothing actually changed (e.g. no `</head>` to
|
|
252
192
|
// inject into) → the original html object is returned untouched.
|
|
193
|
+
//
|
|
194
|
+
// The resolve+timeout+inject itself runs through headless's
|
|
195
|
+
// resolveAndInjectPageHead — the single call site runDevApp also uses
|
|
196
|
+
// (kumiko-framework#3026), so only the systemQuery/dispatcher wiring
|
|
197
|
+
// below (Request-bound, prod-specific) and the etag recompute are local.
|
|
253
198
|
async function applyPageHead(
|
|
254
199
|
req: Request,
|
|
255
200
|
html: { bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number },
|
|
256
201
|
): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number }> {
|
|
257
|
-
|
|
258
|
-
|
|
202
|
+
if (!pageHead) return html;
|
|
203
|
+
const url = new URL(req.url);
|
|
204
|
+
const host = req.headers.get("host") ?? url.host;
|
|
205
|
+
const systemQuery: PageHeadSystemQuery = (type, payload, tenantId) =>
|
|
206
|
+
requestContext.run(requestContext.get() ?? buildRequestContextDataFromRequest(req), () =>
|
|
207
|
+
pageHead.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
|
|
208
|
+
);
|
|
259
209
|
const text = new TextDecoder().decode(html.bytes);
|
|
260
|
-
const injected =
|
|
210
|
+
const injected = await resolveAndInjectPageHead(text, pageHead.resolvePageHead, {
|
|
211
|
+
path: url.pathname,
|
|
212
|
+
host,
|
|
213
|
+
systemQuery,
|
|
214
|
+
});
|
|
261
215
|
if (injected === text) return html;
|
|
262
216
|
const encoded = new TextEncoder().encode(injected);
|
|
263
217
|
return {
|
package/src/run-prod-app.ts
CHANGED
|
@@ -98,7 +98,6 @@ import {
|
|
|
98
98
|
type EffectiveFeaturesResolver,
|
|
99
99
|
type FeatureDefinition,
|
|
100
100
|
findTierResolverUsage,
|
|
101
|
-
type TenantId,
|
|
102
101
|
type TierResolverPlugin,
|
|
103
102
|
validateAppCustomScreenWriteQns,
|
|
104
103
|
validateBoot,
|
|
@@ -137,6 +136,11 @@ import {
|
|
|
137
136
|
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
138
137
|
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
139
138
|
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
139
|
+
import type {
|
|
140
|
+
PageHeadMeta,
|
|
141
|
+
PageHeadResolver,
|
|
142
|
+
PageHeadSystemQuery,
|
|
143
|
+
} from "@cosmicdrift/kumiko-headless/apex";
|
|
140
144
|
import Redis from "ioredis";
|
|
141
145
|
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
142
146
|
import { resolveBootCrypto } from "./boot/boot-crypto";
|
|
@@ -431,33 +435,13 @@ export type HostDispatchFn = (req: {
|
|
|
431
435
|
readonly search: string;
|
|
432
436
|
}) => HostDispatchResult;
|
|
433
437
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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>;
|
|
438
|
+
// PageHeadMeta/PageHeadResolver/PageHeadSystemQuery moved to
|
|
439
|
+
// @cosmicdrift/kumiko-headless/apex (kumiko-framework#3026) — runDevApp needs
|
|
440
|
+
// the same types and the same resolve+inject function runProdApp uses, and
|
|
441
|
+
// headless is where renderApexHeadTags already lives. Re-exported here so
|
|
442
|
+
// existing subpath-imports (`@cosmicdrift/kumiko-server-runtime/run-prod-app`)
|
|
443
|
+
// keep working unchanged.
|
|
444
|
+
export type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery };
|
|
461
445
|
|
|
462
446
|
export type RunProdAppOptions = {
|
|
463
447
|
/** App-specific features. config/user/tenant/auth-email-password are
|
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
});
|
package/src/render-head-tags.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
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
|
-
}
|