@cosmicdrift/kumiko-server-runtime 0.288.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.288.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.288.0",
84
- "@cosmicdrift/kumiko-framework": "0.288.0",
85
- "@cosmicdrift/kumiko-headless": "0.288.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": {
@@ -34,6 +34,7 @@ export function makeProbeFeature(opts: {
34
34
  name: "ping",
35
35
  schema: z.object({}),
36
36
  access: { roles: ["anonymous"] },
37
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
37
38
  handler: async () => ({ pong: true }),
38
39
  });
39
40
  });
@@ -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,6 +7,9 @@ export type {
7
7
  AccountUnlockSetup,
8
8
  EmailVerificationSetup,
9
9
  InviteSetup,
10
+ PageHeadMeta,
11
+ PageHeadResolver,
12
+ PageHeadSystemQuery,
10
13
  PasswordResetSetup,
11
14
  ProdAppHandle,
12
15
  ProdSeedFn,
@@ -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 { type ApexHead, renderApexHeadTags } from "@cosmicdrift/kumiko-headless/apex";
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 { injectPageHead } from "./render-head-tags";
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
- const meta = await resolvePageHeadMeta(req);
258
- if (!meta) return html;
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 = injectPageHead(text, renderApexHeadTags(toApexHead(meta)));
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 {
@@ -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
- /** 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>;
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
- });
@@ -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
- }