@cosmicdrift/kumiko-server-runtime 0.288.0 → 0.290.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.290.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.290.0",
84
+ "@cosmicdrift/kumiko-framework": "0.290.0",
85
+ "@cosmicdrift/kumiko-headless": "0.290.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 {
@@ -68,10 +68,6 @@ import {
68
68
  } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
69
69
  import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
70
70
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
71
- import {
72
- resolveTenantLifecycleGate,
73
- TENANT_LIFECYCLE_FEATURE,
74
- } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
75
71
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
76
72
  import {
77
73
  createDefaultSseBroker,
@@ -98,7 +94,6 @@ import {
98
94
  type EffectiveFeaturesResolver,
99
95
  type FeatureDefinition,
100
96
  findTierResolverUsage,
101
- type TenantId,
102
97
  type TierResolverPlugin,
103
98
  validateAppCustomScreenWriteQns,
104
99
  validateBoot,
@@ -137,6 +132,11 @@ import {
137
132
  } from "@cosmicdrift/kumiko-framework/pipeline";
138
133
  import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
139
134
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
135
+ import type {
136
+ PageHeadMeta,
137
+ PageHeadResolver,
138
+ PageHeadSystemQuery,
139
+ } from "@cosmicdrift/kumiko-headless/apex";
140
140
  import Redis from "ioredis";
141
141
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
142
142
  import { resolveBootCrypto } from "./boot/boot-crypto";
@@ -431,33 +431,13 @@ export type HostDispatchFn = (req: {
431
431
  readonly search: string;
432
432
  }) => HostDispatchResult;
433
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>;
434
+ // PageHeadMeta/PageHeadResolver/PageHeadSystemQuery moved to
435
+ // @cosmicdrift/kumiko-headless/apex (kumiko-framework#3026) — runDevApp needs
436
+ // the same types and the same resolve+inject function runProdApp uses, and
437
+ // headless is where renderApexHeadTags already lives. Re-exported here so
438
+ // existing subpath-imports (`@cosmicdrift/kumiko-server-runtime/run-prod-app`)
439
+ // keep working unchanged.
440
+ export type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery };
461
441
 
462
442
  export type RunProdAppOptions = {
463
443
  /** App-specific features. config/user/tenant/auth-email-password are
@@ -1049,16 +1029,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1049
1029
  };
1050
1030
  }
1051
1031
 
1052
- const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
1053
- const tenantLifecycleAuthFragment = tenantLifecycleFeature
1054
- ? {
1055
- resolveTenantLifecycleStatus: async (tenantId: string) => {
1056
- const gate = await resolveTenantLifecycleGate(db, tenantId);
1057
- return gate ? { status: gate.status } : null;
1058
- },
1059
- }
1060
- : undefined;
1061
-
1062
1032
  const baseEntrypointOptions = {
1063
1033
  registry,
1064
1034
  context: {
@@ -1108,7 +1078,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1108
1078
  ...(trustedProxyHops !== undefined && { trustedProxyHops }),
1109
1079
  ...sessionAuthFragment,
1110
1080
  ...patAuthFragment,
1111
- ...tenantLifecycleAuthFragment,
1112
1081
  ...(mfaFeature && {
1113
1082
  mfaVerifyHandler: AuthMfaHandlers.verify,
1114
1083
  mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
@@ -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
- }