@cosmicdrift/kumiko-headless 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.287.0",
3
+ "version": "0.289.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.287.0",
39
+ "@cosmicdrift/kumiko-framework": "0.289.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { injectPageHead, resolveAndInjectPageHead } from "../index";
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
+ });
28
+
29
+ describe("resolveAndInjectPageHead", () => {
30
+ const HTML = "<!doctype html><html><head><title>Shell</title></head><body>shell</body></html>";
31
+ const input = { path: "/", host: "t", systemQuery: async () => ({}) };
32
+
33
+ test("resolved meta is injected as head tags", async () => {
34
+ const out = await resolveAndInjectPageHead(
35
+ HTML,
36
+ async () => ({ title: "Vehicle X", description: "A car", ogImage: "https://x/i.png" }),
37
+ input,
38
+ );
39
+ expect(out).toContain("<title>Vehicle X</title>");
40
+ expect(out).toContain('<meta property="og:image" content="https://x/i.png" />');
41
+ });
42
+
43
+ test("resolver throws → unchanged html, not a rejection", async () => {
44
+ const out = await resolveAndInjectPageHead(
45
+ HTML,
46
+ async () => {
47
+ throw new Error("boom");
48
+ },
49
+ input,
50
+ );
51
+ expect(out).toBe(HTML);
52
+ });
53
+
54
+ test("resolver returns null → unchanged html", async () => {
55
+ const out = await resolveAndInjectPageHead(HTML, async () => null, input);
56
+ expect(out).toBe(HTML);
57
+ });
58
+
59
+ test("resolver never resolves → unchanged html after the shared timeout", async () => {
60
+ const out = await resolveAndInjectPageHead(HTML, () => new Promise(() => {}), input);
61
+ expect(out).toBe(HTML);
62
+ }, 2000);
63
+ });
package/src/apex/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // brand tokens (brand.tokensCss) and content. Server-side, zero React, one
5
5
  // cacheable HTTP response. See APEX_STRUCTURAL_CSS for the CSS contract.
6
6
 
7
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
8
  import { escapeHtml } from "../format";
8
9
  import { APEX_STRUCTURAL_CSS } from "./css";
9
10
  import { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox";
@@ -550,3 +551,117 @@ export function renderApexPage(page: ApexPage): string {
550
551
  </body>
551
552
  </html>`;
552
553
  }
554
+
555
+ /** Per-request head metadata (Open-Graph/title/description) for a
556
+ * server-rendered HTML shell. Only `title` is required; everything else
557
+ * falls back to no tag rather than a placeholder. */
558
+ export type PageHeadMeta = {
559
+ readonly title: string;
560
+ readonly description?: string;
561
+ readonly ogImage?: string;
562
+ readonly canonicalUrl?: string;
563
+ readonly siteName?: string;
564
+ readonly locale?: string;
565
+ };
566
+
567
+ // Matches bundled-features' shared/system-query.ts SystemQueryFn shape
568
+ // (non-generic, Promise<unknown>) rather than a generic <T> signature —
569
+ // that's the convention every other systemQuery caller in the framework
570
+ // already follows (r.httpRoute handlers, seo/managed-pages features).
571
+ export type PageHeadSystemQuery = (
572
+ type: string,
573
+ payload: unknown,
574
+ tenantId: TenantId,
575
+ ) => Promise<unknown>;
576
+
577
+ export type PageHeadResolver = (input: {
578
+ readonly path: string;
579
+ readonly host: string;
580
+ readonly systemQuery: PageHeadSystemQuery;
581
+ }) => Promise<PageHeadMeta | null>;
582
+
583
+ const HEAD_TAGS_MARKER = "<!-- kumiko-page-head -->";
584
+ const TITLE_TAG_RE = /<title\b[^>]*>[\s\S]*?<\/title>/i;
585
+
586
+ // Idempotent by marker (repeated calls, e.g. hostDispatch + default path
587
+ // both hitting the same request, never double-inject) and safe on a
588
+ // head-less document (nothing to splice into). `tagsHtml` is pre-rendered
589
+ // HTML from a caller (renderApexHeadTags) — this function only owns
590
+ // placement + the original <title> removal, not escaping.
591
+ export function injectPageHead(html: string, tagsHtml: string): string {
592
+ if (html.includes(HEAD_TAGS_MARKER)) return html;
593
+ if (!html.includes("</head>")) return html;
594
+ const withoutTitle = html.replace(TITLE_TAG_RE, "");
595
+ return withoutTitle.replace("</head>", () => `${HEAD_TAGS_MARKER}\n${tagsHtml}\n</head>`);
596
+ }
597
+
598
+ // Resolver runs alongside the request, never gates it: a slow, throwing, or
599
+ // null-returning resolver must never turn a 200 shell into a 500 or a
600
+ // stall. One constant shared by runProdApp and runDevApp so both time out
601
+ // on the same value (kumiko-framework#3026).
602
+ const PAGE_HEAD_TIMEOUT_MS = 300;
603
+
604
+ async function resolvePageHeadWithTimeout(
605
+ resolvePageHead: PageHeadResolver,
606
+ input: {
607
+ readonly path: string;
608
+ readonly host: string;
609
+ readonly systemQuery: PageHeadSystemQuery;
610
+ },
611
+ ): Promise<PageHeadMeta | null> {
612
+ let timer: ReturnType<typeof setTimeout> | undefined;
613
+ try {
614
+ const timedOut = new Promise<null>((resolve) => {
615
+ timer = setTimeout(() => resolve(null), PAGE_HEAD_TIMEOUT_MS);
616
+ });
617
+ // .catch on the resolver's own promise (not just the outer try/catch)
618
+ // so a rejection arriving AFTER the timeout already won the race
619
+ // doesn't surface as an unhandled rejection.
620
+ const resolved = resolvePageHead(input).catch(() => null);
621
+ return await Promise.race([resolved, timedOut]);
622
+ } catch {
623
+ return null;
624
+ } finally {
625
+ if (timer !== undefined) clearTimeout(timer);
626
+ }
627
+ }
628
+
629
+ // ApexHead.lang is required (html lang="..."); PageHeadMeta only carries
630
+ // og:locale-shaped strings ("de_DE", "en-US") since that's all og:locale
631
+ // needs. Take the language subtag before the region separator; default to
632
+ // "en" when there's no locale to derive one from.
633
+ function apexLangFromLocale(locale: string | undefined): string {
634
+ return locale?.split(/[_-]/)[0]?.toLowerCase() || "en";
635
+ }
636
+
637
+ function toApexHead(meta: PageHeadMeta): ApexHead {
638
+ return {
639
+ lang: apexLangFromLocale(meta.locale),
640
+ title: meta.title,
641
+ description: meta.description ?? "",
642
+ ...(meta.canonicalUrl !== undefined ? { canonicalUrl: meta.canonicalUrl } : {}),
643
+ ...(meta.ogImage !== undefined ? { ogImage: meta.ogImage } : {}),
644
+ ...(meta.siteName !== undefined ? { siteName: meta.siteName } : {}),
645
+ ...(meta.locale !== undefined ? { locale: meta.locale } : {}),
646
+ };
647
+ }
648
+
649
+ /** Resolves per-request head metadata and splices it into `html`, capped at
650
+ * the shared timeout. Never throws and never changes `html` on a resolver
651
+ * error, a `null` resolve, or a timeout — the caller's shell always ships
652
+ * unchanged. runProdApp and runDevApp both call this single function, so a
653
+ * future change to timeout or fallback semantics has one place to land
654
+ * (kumiko-framework#3026). */
655
+ export async function resolveAndInjectPageHead(
656
+ html: string,
657
+ resolvePageHead: PageHeadResolver,
658
+ input: {
659
+ readonly path: string;
660
+ readonly host: string;
661
+ readonly systemQuery: PageHeadSystemQuery;
662
+ },
663
+ ): Promise<string> {
664
+ const meta = await resolvePageHeadWithTimeout(resolvePageHead, input);
665
+ if (!meta) return html;
666
+ return injectPageHead(html, renderApexHeadTags(toApexHead(meta)));
667
+ }
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.289.0",
4
+ "type": "improvement",
5
+ "title": "headless gains resolveAndInjectPageHead/injectPageHead for per-request head injection",
6
+ "detail": "New apex exports: PageHeadMeta/PageHeadResolver/PageHeadSystemQuery types, injectPageHead (idempotent marker-based </head> splice), and resolveAndInjectPageHead (resolves a PageHeadResolver with a shared 300ms timeout, converts to ApexHead, renders via renderApexHeadTags, injects). Moved out of @cosmicdrift/kumiko-server-runtime (kumiko-framework#3026) so runDevApp and runProdApp share one resolve+inject call instead of two copies of the timeout/fallback logic; server-runtime re-exports the three types unchanged for existing consumers."
7
+ }
8
+ ]