@mandujs/core 0.34.1 → 0.35.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": "@mandujs/core",
3
- "version": "0.34.1",
3
+ "version": "0.35.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Issue #208 — Minimal inline SPA-navigation helper.
3
3
  * Issue #220 — body-swap observability + fallback + script re-execution.
4
+ * Issue #222 — hash-anchor preservation across SPA swaps.
4
5
  *
5
6
  * Self-contained IIFE injected into the SSR `<head>` that upgrades plain
6
7
  * full-page navigations into client-side `history.pushState` +
@@ -64,6 +65,16 @@
64
65
  * `detail: { url, durationMs, container }` so islands and
65
66
  * integrations can re-hydrate if needed.
66
67
  *
68
+ * 6b. **Hash anchor preservation** (issue #222): after a successful
69
+ * swap, if the target URL carries a `#hash`, the helper resolves
70
+ * `document.getElementById(hash)` (with a fallback to
71
+ * `[name="<hash>"]`) and calls `scrollIntoView({block:"start"})`
72
+ * so `<a href="/docs#intro">` lands on `<h1 id="intro">` instead
73
+ * of the top of the page. Missing targets fall back to `scrollTo(0,0)`.
74
+ * Same-page hash navigation (identical pathname + search) skips the
75
+ * body swap entirely — only pushState + scrollIntoView fire.
76
+ * `CSS.escape` is used when available to handle punctuation in ids.
77
+ *
67
78
  * 7. **Inline, not external**: same rationale as #192's prefetch
68
79
  * helper — inline removes the extra round-trip on every SSR
69
80
  * response, keeps the CSP posture simple, and sidesteps the
@@ -86,7 +97,7 @@
86
97
  * flow is documented in this file's JSDoc; anyone editing this string
87
98
  * MUST update the exclusion-matrix test and the body-swap test to match.
88
99
  */
89
- export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;var TAG="[mandu-spa-nav]";function warn(m,d){try{console.warn(TAG+" "+m,d==null?"":d);}catch(_){}}function info(m,d){try{console.debug(TAG+" "+m,d==null?"":d);}catch(_){}}function hardNav(u,why){warn("falling back to full navigation: "+why,u);try{L.href=u;}catch(_){}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h||h.charAt(0)==="#")return null;var u;try{u=new URL(h,L.origin);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;return u;}function pickContainer(doc){var main=doc.querySelector("main");if(main)return{src:main,dst:document.querySelector("main"),kind:"main"};var root=doc.getElementById&&doc.getElementById("root");if(root){var dstR=document.getElementById?document.getElementById("root"):null;if(dstR)return{src:root,dst:dstR,kind:"#root"};}if(doc.body)return{src:doc.body,dst:document.body,kind:"body"};return null;}function mergeHead(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(!nh||!ch)return;var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}catch(e){warn("head merge failed",e&&e.message||e);}}function runScripts(container){try{var scripts=container.querySelectorAll("script");for(var i=0;i<scripts.length;i++){var old=scripts[i];var s=document.createElement("script");for(var j=0;j<old.attributes.length;j++){var a=old.attributes[j];try{s.setAttribute(a.name,a.value);}catch(_){}}if(!old.src)s.text=old.textContent||"";old.parentNode&&old.parentNode.removeChild(old);(document.head||document.body||document.documentElement).appendChild(s);}}catch(e){warn("script re-exec failed",e&&e.message||e);}}function doSwap(doc,url,startedAt){var perr=doc.querySelector&&doc.querySelector("parsererror");if(perr){hardNav(url,"DOMParser returned parsererror");return false;}var pick=pickContainer(doc);if(!pick||!pick.dst){hardNav(url,"no swap container matched (main/#root/body)");return false;}info("swap target container: "+pick.kind);try{pick.dst.innerHTML=pick.src.innerHTML;}catch(e){hardNav(url,"innerHTML assignment threw: "+(e&&e.message||e));return false;}mergeHead(doc);runScripts(pick.dst);try{window.scrollTo(0,0);}catch(_){}var dur=0;try{dur=Math.round((performance&&performance.now?performance.now():Date.now())-startedAt);}catch(_){}info("swapped to "+url+" in "+dur+"ms (container="+pick.kind+")");try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:dur,container:pick.kind}}));}catch(_){}try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}return true;}function nav(url,push){var startedAt=0;try{startedAt=performance&&performance.now?performance.now():Date.now();}catch(_){startedAt=Date.now();}fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok){hardNav(url,"fetch responded "+r.status);return null;}var ct=r.headers.get("content-type");if(!ct||ct.indexOf("text/html")<0){hardNav(url,"non-HTML response ("+(ct||"no content-type")+")");return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url,"DOMParser unavailable");return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(e){hardNav(url,"DOMParser threw: "+(e&&e.message||e));return;}if(push){try{H.pushState({mandu:1},"",url);}catch(e){hardNav(url,"pushState threw: "+(e&&e.message||e));return;}}var run=function(){doSwap(doc,url,startedAt);};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(e){warn("startViewTransition threw, running swap directly",e&&e.message||e);run();}}else{run();}}).catch(function(e){hardNav(url,"fetch rejected: "+(e&&e.message||e));});}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
100
+ export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;var TAG="[mandu-spa-nav]";function warn(m,d){try{console.warn(TAG+" "+m,d==null?"":d);}catch(_){}}function info(m,d){try{console.debug(TAG+" "+m,d==null?"":d);}catch(_){}}function hardNav(u,why){warn("falling back to full navigation: "+why,u);try{L.href=u;}catch(_){}}function esc(h){try{if(typeof CSS!=="undefined"&&CSS&&typeof CSS.escape==="function")return CSS.escape(h);}catch(_){}return String(h).replace(/([^a-zA-Z0-9_-])/g,"\\\\$1");}function extractHash(u){var i=u.indexOf("#");return i>=0?u.slice(i+1):"";}function scrollToHash(hash,url){if(!hash){try{window.scrollTo(0,0);}catch(_){}return;}var el=null;try{el=document.getElementById?document.getElementById(hash):null;}catch(_){}if(!el){try{el=document.querySelector?document.querySelector('[name="'+esc(hash)+'"]'):null;}catch(_){}}if(el&&typeof el.scrollIntoView==="function"){try{el.scrollIntoView({behavior:"instant",block:"start"});}catch(e1){try{el.scrollIntoView();}catch(_){}}try{if(L.hash!=="#"+hash)L.hash="#"+hash;}catch(_){}info("scrolled to #"+hash,url==null?"":url);}else{info("hash target #"+hash+" not found, scrolling to top",url==null?"":url);try{window.scrollTo(0,0);}catch(_){}}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h)return null;var u;try{u=new URL(h,L.href);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;if(u.pathname===L.pathname&&u.search===L.search&&!u.hash)return null;return u;}function pickContainer(doc){var main=doc.querySelector("main");if(main)return{src:main,dst:document.querySelector("main"),kind:"main"};var root=doc.getElementById&&doc.getElementById("root");if(root){var dstR=document.getElementById?document.getElementById("root"):null;if(dstR)return{src:root,dst:dstR,kind:"#root"};}if(doc.body)return{src:doc.body,dst:document.body,kind:"body"};return null;}function mergeHead(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(!nh||!ch)return;var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}catch(e){warn("head merge failed",e&&e.message||e);}}function runScripts(container){try{var scripts=container.querySelectorAll("script");for(var i=0;i<scripts.length;i++){var old=scripts[i];var s=document.createElement("script");for(var j=0;j<old.attributes.length;j++){var a=old.attributes[j];try{s.setAttribute(a.name,a.value);}catch(_){}}if(!old.src)s.text=old.textContent||"";old.parentNode&&old.parentNode.removeChild(old);(document.head||document.body||document.documentElement).appendChild(s);}}catch(e){warn("script re-exec failed",e&&e.message||e);}}function doSwap(doc,url,startedAt){var perr=doc.querySelector&&doc.querySelector("parsererror");if(perr){hardNav(url,"DOMParser returned parsererror");return false;}var pick=pickContainer(doc);if(!pick||!pick.dst){hardNav(url,"no swap container matched (main/#root/body)");return false;}info("swap target container: "+pick.kind);try{pick.dst.innerHTML=pick.src.innerHTML;}catch(e){hardNav(url,"innerHTML assignment threw: "+(e&&e.message||e));return false;}mergeHead(doc);runScripts(pick.dst);scrollToHash(extractHash(url),url);var dur=0;try{dur=Math.round((performance&&performance.now?performance.now():Date.now())-startedAt);}catch(_){}info("swapped to "+url+" in "+dur+"ms (container="+pick.kind+")");try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:dur,container:pick.kind}}));}catch(_){}try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}return true;}function nav(url,push){var startedAt=0;try{startedAt=performance&&performance.now?performance.now():Date.now();}catch(_){startedAt=Date.now();}fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok){hardNav(url,"fetch responded "+r.status);return null;}var ct=r.headers.get("content-type");if(!ct||ct.indexOf("text/html")<0){hardNav(url,"non-HTML response ("+(ct||"no content-type")+")");return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url,"DOMParser unavailable");return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(e){hardNav(url,"DOMParser threw: "+(e&&e.message||e));return;}if(push){try{H.pushState({mandu:1},"",url);}catch(e){hardNav(url,"pushState threw: "+(e&&e.message||e));return;}}var run=function(){doSwap(doc,url,startedAt);};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(e){warn("startViewTransition threw, running swap directly",e&&e.message||e);run();}}else{run();}}).catch(function(e){hardNav(url,"fetch rejected: "+(e&&e.message||e));});}function samePageHashNav(u,push){var url=u.pathname+u.search+u.hash;if(push){try{H.pushState({mandu:1},"",url);}catch(e){warn("pushState threw on same-page hash nav",e&&e.message||e);}}info("same-page hash nav "+url);scrollToHash(u.hash?u.hash.slice(1):"",url);try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:0,container:"hash"}}));}catch(_){}}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();if(url.pathname===L.pathname&&url.search===L.search&&url.hash){samePageHashNav(url,true);return;}nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
90
101
 
91
102
  /** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
92
103
  export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
@@ -525,6 +525,34 @@ export interface ManduConfig {
525
525
  jobs?: CronDef[];
526
526
  disabled?: boolean;
527
527
  };
528
+ /**
529
+ * Production-grade OpenAPI endpoint.
530
+ *
531
+ * When enabled, the runtime serves the contracts-derived OpenAPI 3.0.3
532
+ * document at `<path>.json` / `<path>.yaml` (default base path
533
+ * `/__mandu/openapi`). The spec is materialized from `.mandu/openapi.json`
534
+ * (emitted by `mandu build`) on first request and cached for the
535
+ * lifetime of the server instance.
536
+ *
537
+ * - `enabled` — default `false`. Do NOT leak the API surface on
538
+ * every internet-facing deployment. Operators must opt in
539
+ * explicitly, or set `MANDU_OPENAPI_ENABLED=1` in the environment
540
+ * for a one-off probe without editing config.
541
+ * - `path` — base URL path (with or without `.json` suffix). The
542
+ * runtime appends `.json` / `.yaml` to serve each variant.
543
+ * Default `/__mandu/openapi`.
544
+ *
545
+ * The response stamps `Cache-Control: public, max-age=0,
546
+ * must-revalidate` plus a SHA-256 ETag over the JSON body so CDNs and
547
+ * browsers revalidate cheaply after every deploy without serving
548
+ * stale specs.
549
+ *
550
+ * @see `docs/runtime/openapi.md`
551
+ */
552
+ openapi?: {
553
+ enabled?: boolean;
554
+ path?: string;
555
+ };
528
556
  /**
529
557
  * Phase 18.μ — first-class internationalization.
530
558
  *
@@ -384,6 +384,28 @@ const ObservabilityConfigSchema = z
384
384
  })
385
385
  .strict();
386
386
 
387
+ /**
388
+ * Production OpenAPI endpoint config (strict).
389
+ *
390
+ * Default `enabled: false` — we validate permissive so `openapi: {}`
391
+ * still loads (equivalent to disabled). Operators explicitly set
392
+ * `enabled: true` OR export `MANDU_OPENAPI_ENABLED=1` to turn the
393
+ * endpoint on.
394
+ */
395
+ const OpenApiConfigSchema = z
396
+ .object({
397
+ enabled: z.boolean().optional(),
398
+ path: z
399
+ .string()
400
+ .min(1)
401
+ .refine(
402
+ (value) => value.startsWith("/"),
403
+ { message: "openapi.path must start with '/'" }
404
+ )
405
+ .optional(),
406
+ })
407
+ .strict();
408
+
387
409
  const AdapterConfigSchema = z.custom<ManduAdapter | undefined>(
388
410
  (value) =>
389
411
  value === undefined ||
@@ -591,6 +613,11 @@ export const ManduConfigSchema = z
591
613
  seo: SeoConfigSchema.default({}),
592
614
  test: TestConfigSchema.default({}),
593
615
  observability: ObservabilityConfigSchema.default({}),
616
+ /**
617
+ * Production OpenAPI endpoint (default disabled). See
618
+ * {@link OpenApiConfigSchema} + `docs/runtime/openapi.md`.
619
+ */
620
+ openapi: OpenApiConfigSchema.optional(),
594
621
  /** Phase 18.ζ — ISR / tag-based cache invalidation. Optional. */
595
622
  cache: CacheConfigSchema.optional(),
596
623
  plugins: z.array(ManduPluginSchema).optional(),
@@ -542,16 +542,150 @@ export async function generateOpenAPIDocument(
542
542
  };
543
543
  }
544
544
 
545
+ // ============================================
546
+ // YAML Serialization
547
+ // ============================================
548
+
549
+ /**
550
+ * Quote a string value for YAML if required by the spec.
551
+ *
552
+ * Follows a conservative subset of YAML 1.2 rules: any scalar that
553
+ * could be confused for a YAML reserved word, a number, a boolean, a
554
+ * null, or that contains indicator characters must be double-quoted.
555
+ * Everything else is emitted as a plain scalar for maximum
556
+ * readability.
557
+ */
558
+ function yamlScalar(value: unknown): string {
559
+ if (value === null) return "null";
560
+ if (typeof value === "boolean") return value ? "true" : "false";
561
+ if (typeof value === "number") {
562
+ if (!Number.isFinite(value)) return ".nan";
563
+ return String(value);
564
+ }
565
+ if (typeof value === "string") {
566
+ // Quote strings that a YAML 1.2 parser would otherwise interpret
567
+ // as a non-string scalar (null/bool/number) or that contain indicator
568
+ // characters at positions where they are load-bearing.
569
+ //
570
+ // Conservative heuristic:
571
+ // - empty string
572
+ // - leading/trailing whitespace (would be silently trimmed)
573
+ // - reserved scalars: null / true / false / yes / no / on / off (+ ~)
574
+ // - parses cleanly as a YAML number (integer or float)
575
+ // - contains a flow indicator (`[ ]`, `{ }`, `,`) or a comment start `#`
576
+ // - contains `: ` (key separator) anywhere, or ends with `:`
577
+ // - starts with an indicator char that would collide with block syntax:
578
+ // `-` (sequence), `?` (mapping key), `&`/`*` (anchor/alias),
579
+ // `!` (tag), `|`/`>` (block scalar), `'`/`"` / backtick / `%`/`@`
580
+ // - embedded newline
581
+ const numericLike = /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(value);
582
+ const reservedScalar = /^(~|null|true|false|yes|no|on|off)$/i.test(value);
583
+ const leadingIndicator = /^[-?&*!|>'"%@`]/.test(value);
584
+ const hasFlowOrComment = /[\[\]{},#]/.test(value);
585
+ const hasKeyLike = /:\s/.test(value) || /:$/.test(value);
586
+ const needsQuote =
587
+ value === "" ||
588
+ /^[\s]|[\s]$/.test(value) ||
589
+ reservedScalar ||
590
+ numericLike ||
591
+ leadingIndicator ||
592
+ hasFlowOrComment ||
593
+ hasKeyLike ||
594
+ value.includes("\n");
595
+ if (!needsQuote) return value;
596
+ // Use double quotes with JSON-style escaping (valid YAML subset).
597
+ return JSON.stringify(value);
598
+ }
599
+ // Fallback: JSON-stringify anything exotic.
600
+ return JSON.stringify(value);
601
+ }
602
+
603
+ function emitYAML(value: unknown, indent: number, lines: string[]): void {
604
+ const pad = " ".repeat(indent);
605
+
606
+ if (value === null || typeof value !== "object") {
607
+ lines.push(`${pad}${yamlScalar(value)}`);
608
+ return;
609
+ }
610
+
611
+ if (Array.isArray(value)) {
612
+ if (value.length === 0) {
613
+ lines.push(`${pad}[]`);
614
+ return;
615
+ }
616
+ for (const item of value) {
617
+ if (item !== null && typeof item === "object" && !Array.isArray(item)) {
618
+ const entries = Object.entries(item).filter(([, v]) => v !== undefined);
619
+ if (entries.length === 0) {
620
+ lines.push(`${pad}- {}`);
621
+ continue;
622
+ }
623
+ const [firstKey, firstVal] = entries[0];
624
+ lines.push(`${pad}- ${yamlKeyValue(firstKey, firstVal, indent + 1)}`);
625
+ for (let i = 1; i < entries.length; i++) {
626
+ const [k, v] = entries[i];
627
+ lines.push(`${pad} ${yamlKeyValue(k, v, indent + 1)}`);
628
+ }
629
+ } else {
630
+ lines.push(`${pad}- ${yamlScalar(item).trimStart()}`);
631
+ }
632
+ }
633
+ return;
634
+ }
635
+
636
+ // Plain object.
637
+ const entries = Object.entries(value as Record<string, unknown>).filter(
638
+ ([, v]) => v !== undefined
639
+ );
640
+ if (entries.length === 0) {
641
+ lines.push(`${pad}{}`);
642
+ return;
643
+ }
644
+ for (const [k, v] of entries) {
645
+ if (v !== null && typeof v === "object") {
646
+ const isEmptyArray = Array.isArray(v) && v.length === 0;
647
+ const isEmptyObject = !Array.isArray(v) && Object.keys(v).length === 0;
648
+ if (isEmptyArray) {
649
+ lines.push(`${pad}${k}: []`);
650
+ continue;
651
+ }
652
+ if (isEmptyObject) {
653
+ lines.push(`${pad}${k}: {}`);
654
+ continue;
655
+ }
656
+ lines.push(`${pad}${k}:`);
657
+ emitYAML(v, indent + 1, lines);
658
+ } else {
659
+ lines.push(`${pad}${k}: ${yamlScalar(v)}`);
660
+ }
661
+ }
662
+ }
663
+
664
+ function yamlKeyValue(key: string, value: unknown, indent: number): string {
665
+ if (value === null || typeof value !== "object") {
666
+ return `${key}: ${yamlScalar(value)}`;
667
+ }
668
+ // Nested object / array: emit header + drop into recursion on subsequent lines.
669
+ // The caller pushes lines after this; we return the header only.
670
+ const tmp: string[] = [];
671
+ emitYAML(value, indent, tmp);
672
+ return `${key}:\n${tmp.join("\n")}`;
673
+ }
674
+
545
675
  /**
546
- * Convert OpenAPI document to YAML string
676
+ * Convert an OpenAPI document to a YAML string.
677
+ *
678
+ * Emits a conservative YAML 1.2 subset — two-space indentation,
679
+ * double-quoted scalars where required, literal `[]` / `{}` for empty
680
+ * collections. Produces output that round-trips cleanly through
681
+ * Swagger UI, openapi-generator, and `yq`.
547
682
  */
548
683
  export function openAPIToYAML(doc: OpenAPIDocument): string {
549
- // Simple YAML conversion (for production, use a proper YAML library)
550
- return JSON.stringify(doc, null, 2)
551
- .replace(/"/g, "")
552
- .replace(/,$/gm, "")
553
- .replace(/\[/g, "\n - ")
554
- .replace(/\]/g, "");
684
+ const lines: string[] = [];
685
+ emitYAML(doc, 0, lines);
686
+ // Terminate with a newline per POSIX text-file convention so `cat`
687
+ // doesn't elide the final byte.
688
+ return lines.join("\n") + "\n";
555
689
  }
556
690
 
557
691
  /**
@@ -560,3 +694,126 @@ export function openAPIToYAML(doc: OpenAPIDocument): string {
560
694
  export function openAPIToJSON(doc: OpenAPIDocument): string {
561
695
  return JSON.stringify(doc, null, 2);
562
696
  }
697
+
698
+ // ============================================
699
+ // Content hashing (for ETag)
700
+ // ============================================
701
+
702
+ /**
703
+ * Compute a SHA-256 hex digest of the serialized OpenAPI document.
704
+ *
705
+ * Uses `Bun.CryptoHasher` when available (zero allocations beyond the
706
+ * input buffer) and falls back to the WebCrypto API so the helper works
707
+ * under edge runtimes (Cloudflare Workers, Deno Deploy).
708
+ */
709
+ export async function hashOpenAPIJSON(json: string): Promise<string> {
710
+ const bunGlobal = (globalThis as { Bun?: { CryptoHasher?: new (algo: string) => { update(input: string | Uint8Array): void; digest(encoding: "hex"): string } } }).Bun;
711
+ if (bunGlobal?.CryptoHasher) {
712
+ const hasher = new bunGlobal.CryptoHasher("sha256");
713
+ hasher.update(json);
714
+ return hasher.digest("hex");
715
+ }
716
+ // WebCrypto fallback (browser / edge runtimes).
717
+ const subtle = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto?.subtle;
718
+ if (subtle) {
719
+ const data = new TextEncoder().encode(json);
720
+ const buf = await subtle.digest("SHA-256", data);
721
+ return Array.from(new Uint8Array(buf))
722
+ .map((b) => b.toString(16).padStart(2, "0"))
723
+ .join("");
724
+ }
725
+ // Last-resort Node fallback.
726
+ const nodeCrypto = await import("node:crypto");
727
+ return nodeCrypto.createHash("sha256").update(json).digest("hex");
728
+ }
729
+
730
+ // ============================================
731
+ // Build-time artifact emission
732
+ // ============================================
733
+
734
+ export interface OpenAPIArtifactPaths {
735
+ json: string;
736
+ yaml: string;
737
+ }
738
+
739
+ export interface OpenAPIArtifactResult {
740
+ /** OpenAPI 3.0.3 document serialized as JSON (two-space indented). */
741
+ json: string;
742
+ /** OpenAPI 3.0.3 document serialized as YAML. */
743
+ yaml: string;
744
+ /** SHA-256 hex digest of `json` — used as the runtime ETag. */
745
+ hash: string;
746
+ /** Absolute paths of the emitted artifacts (matches the dir argument). */
747
+ paths: OpenAPIArtifactPaths;
748
+ /** Number of route paths captured in the document. */
749
+ pathCount: number;
750
+ }
751
+
752
+ /**
753
+ * Generate and write both `openapi.json` and `openapi.yaml` under the
754
+ * given output directory. Returns the serialized bodies and the SHA-256
755
+ * hash so callers (e.g., `mandu build`) can log a stable artifact id
756
+ * and the runtime endpoint can skip re-hashing on boot.
757
+ *
758
+ * Safe to call repeatedly — the directory is created if absent and the
759
+ * existing files are overwritten atomically by `Bun.write`.
760
+ */
761
+ export async function writeOpenAPIArtifacts(
762
+ manifest: RoutesManifest,
763
+ rootDir: string,
764
+ outDir: string,
765
+ options: Parameters<typeof generateOpenAPIDocument>[2] = {}
766
+ ): Promise<OpenAPIArtifactResult> {
767
+ const { mkdir } = await import("node:fs/promises");
768
+ const pathMod = await import("node:path");
769
+
770
+ const doc = await generateOpenAPIDocument(manifest, rootDir, options);
771
+ const json = openAPIToJSON(doc);
772
+ const yaml = openAPIToYAML(doc);
773
+ const hash = await hashOpenAPIJSON(json);
774
+
775
+ const absoluteDir = pathMod.isAbsolute(outDir)
776
+ ? outDir
777
+ : pathMod.join(rootDir, outDir);
778
+ await mkdir(absoluteDir, { recursive: true });
779
+
780
+ const jsonPath = pathMod.join(absoluteDir, "openapi.json");
781
+ const yamlPath = pathMod.join(absoluteDir, "openapi.yaml");
782
+ await Bun.write(jsonPath, json);
783
+ await Bun.write(yamlPath, yaml);
784
+
785
+ return {
786
+ json,
787
+ yaml,
788
+ hash,
789
+ paths: { json: jsonPath, yaml: yamlPath },
790
+ pathCount: Object.keys(doc.paths).length,
791
+ };
792
+ }
793
+
794
+ /**
795
+ * Read previously emitted OpenAPI artifacts from disk. Returns
796
+ * `null` when either file is missing so the caller can fall back to
797
+ * on-demand generation without logging a misleading error.
798
+ */
799
+ export async function readOpenAPIArtifacts(
800
+ outDir: string,
801
+ rootDir: string
802
+ ): Promise<{ json: string; yaml: string; hash: string } | null> {
803
+ const pathMod = await import("node:path");
804
+ const absoluteDir = pathMod.isAbsolute(outDir)
805
+ ? outDir
806
+ : pathMod.join(rootDir, outDir);
807
+ const jsonPath = pathMod.join(absoluteDir, "openapi.json");
808
+ const yamlPath = pathMod.join(absoluteDir, "openapi.yaml");
809
+
810
+ const jsonFile = Bun.file(jsonPath);
811
+ const yamlFile = Bun.file(yamlPath);
812
+ if (!(await jsonFile.exists()) || !(await yamlFile.exists())) {
813
+ return null;
814
+ }
815
+ const json = await jsonFile.text();
816
+ const yaml = await yamlFile.text();
817
+ const hash = await hashOpenAPIJSON(json);
818
+ return { json, yaml, hash };
819
+ }
@@ -4,7 +4,19 @@
4
4
 
5
5
  import { describe, test, expect } from "bun:test";
6
6
  import { z } from "zod";
7
- import { zodToOpenAPISchema, openAPIToJSON } from "./generator";
7
+ import {
8
+ generateOpenAPIDocument,
9
+ hashOpenAPIJSON,
10
+ openAPIToJSON,
11
+ openAPIToYAML,
12
+ readOpenAPIArtifacts,
13
+ writeOpenAPIArtifacts,
14
+ zodToOpenAPISchema,
15
+ } from "./generator";
16
+ import type { RoutesManifest } from "../spec/schema";
17
+ import fs from "node:fs/promises";
18
+ import os from "node:os";
19
+ import path from "node:path";
8
20
 
9
21
  describe("zodToOpenAPISchema", () => {
10
22
  describe("primitive types", () => {
@@ -275,3 +287,206 @@ describe("Real-world contract conversion", () => {
275
287
  expect(result.properties!.pagination.properties!.page.minimum).toBe(1);
276
288
  });
277
289
  });
290
+
291
+ // ============================================
292
+ // End-to-end: manifest → OpenAPI → disk artifacts
293
+ // ============================================
294
+
295
+ /**
296
+ * Create a scratch project on disk with one API contract so we can
297
+ * exercise the full build pipeline (generate + write + read) without
298
+ * faking the dynamic import path.
299
+ */
300
+ async function buildFixture(): Promise<{ rootDir: string; manifest: RoutesManifest; cleanup: () => Promise<void> }> {
301
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-"));
302
+ const contractPath = "contracts/users.contract.ts";
303
+ const contractAbs = path.join(rootDir, contractPath);
304
+ await fs.mkdir(path.dirname(contractAbs), { recursive: true });
305
+ await fs.writeFile(
306
+ contractAbs,
307
+ `import { z } from "zod";
308
+ export default {
309
+ name: "users",
310
+ description: "List / create users",
311
+ tags: ["users"],
312
+ request: {
313
+ GET: {
314
+ query: z.object({ limit: z.number().int().min(1).max(100).optional() }),
315
+ },
316
+ POST: {
317
+ body: z.object({
318
+ name: z.string().min(2),
319
+ email: z.string().email(),
320
+ }),
321
+ },
322
+ },
323
+ response: {
324
+ 200: z.object({
325
+ items: z.array(z.object({ id: z.string().uuid(), name: z.string() })),
326
+ }),
327
+ 201: z.object({ id: z.string().uuid() }),
328
+ 400: z.object({ error: z.string() }),
329
+ },
330
+ };
331
+ `,
332
+ "utf-8"
333
+ );
334
+
335
+ const manifest: RoutesManifest = {
336
+ version: 1,
337
+ routes: [
338
+ {
339
+ id: "api/users",
340
+ pattern: "/api/users",
341
+ kind: "api",
342
+ module: contractPath,
343
+ contractModule: contractPath,
344
+ methods: ["GET", "POST"],
345
+ },
346
+ ],
347
+ };
348
+
349
+ return {
350
+ rootDir,
351
+ manifest,
352
+ cleanup: async () => {
353
+ await fs.rm(rootDir, { recursive: true, force: true });
354
+ },
355
+ };
356
+ }
357
+
358
+ describe("generateOpenAPIDocument", () => {
359
+ test("should produce a valid OpenAPI 3.0.3 document with paths + methods + schemas", async () => {
360
+ const { rootDir, manifest, cleanup } = await buildFixture();
361
+ try {
362
+ const doc = await generateOpenAPIDocument(manifest, rootDir, {
363
+ title: "Users API",
364
+ version: "2.1.0",
365
+ });
366
+
367
+ expect(doc.openapi).toBe("3.0.3");
368
+ expect(doc.info.title).toBe("Users API");
369
+ expect(doc.info.version).toBe("2.1.0");
370
+
371
+ // The `/api/users` path should carry both GET and POST operations.
372
+ expect(doc.paths["/api/users"]).toBeDefined();
373
+ expect(doc.paths["/api/users"].get).toBeDefined();
374
+ expect(doc.paths["/api/users"].post).toBeDefined();
375
+
376
+ // GET query param `limit` should be captured.
377
+ const getParams = doc.paths["/api/users"].get!.parameters ?? [];
378
+ const limitParam = getParams.find((p) => p.name === "limit");
379
+ expect(limitParam).toBeDefined();
380
+ expect(limitParam!.in).toBe("query");
381
+
382
+ // POST body should be captured with JSON content.
383
+ const postBody = doc.paths["/api/users"].post!.requestBody;
384
+ expect(postBody).toBeDefined();
385
+ expect(postBody!.content["application/json"]).toBeDefined();
386
+
387
+ // Response schemas should be present.
388
+ const okResponse = doc.paths["/api/users"].get!.responses["200"];
389
+ expect(okResponse).toBeDefined();
390
+ expect(okResponse.content!["application/json"]).toBeDefined();
391
+ } finally {
392
+ await cleanup();
393
+ }
394
+ });
395
+ });
396
+
397
+ describe("hashOpenAPIJSON", () => {
398
+ test("should produce a deterministic 64-char hex digest", async () => {
399
+ const sample = JSON.stringify({ hello: "world" });
400
+ const hashA = await hashOpenAPIJSON(sample);
401
+ const hashB = await hashOpenAPIJSON(sample);
402
+
403
+ expect(hashA).toBe(hashB);
404
+ expect(hashA).toMatch(/^[0-9a-f]{64}$/);
405
+ });
406
+
407
+ test("should change with input", async () => {
408
+ const hashA = await hashOpenAPIJSON(JSON.stringify({ v: 1 }));
409
+ const hashB = await hashOpenAPIJSON(JSON.stringify({ v: 2 }));
410
+ expect(hashA).not.toBe(hashB);
411
+ });
412
+ });
413
+
414
+ describe("openAPIToYAML", () => {
415
+ test("should emit a parseable two-space-indented YAML subset", () => {
416
+ const doc = {
417
+ openapi: "3.0.3" as const,
418
+ info: { title: "Test API", version: "1.0.0" },
419
+ paths: {
420
+ "/api/users": {
421
+ get: {
422
+ summary: "List",
423
+ responses: { "200": { description: "OK" } },
424
+ },
425
+ },
426
+ },
427
+ };
428
+
429
+ const yaml = openAPIToYAML(doc);
430
+
431
+ // Must NOT contain raw curly braces from the old naive converter.
432
+ expect(yaml).toContain("openapi: 3.0.3");
433
+ expect(yaml).toContain("info:");
434
+ expect(yaml).toContain(" title: Test API");
435
+ expect(yaml).toContain(" version: 1.0.0");
436
+ expect(yaml).toContain("paths:");
437
+ // Path key contains `/` and `:` suffix is handled as block mapping,
438
+ // so the OpenAPI path appears as a block-mapping key — expect its
439
+ // presence without assuming quoting.
440
+ expect(yaml).toMatch(/\/api\/users:/);
441
+ expect(yaml).toContain(" summary: List");
442
+ expect(yaml).toMatch(/\n$/);
443
+ });
444
+ });
445
+
446
+ describe("writeOpenAPIArtifacts + readOpenAPIArtifacts", () => {
447
+ test("should write openapi.json and openapi.yaml then read them back with matching hash", async () => {
448
+ const { rootDir, manifest, cleanup } = await buildFixture();
449
+ try {
450
+ const written = await writeOpenAPIArtifacts(manifest, rootDir, ".mandu", {
451
+ title: "Artifact Test",
452
+ version: "1.0.0",
453
+ });
454
+
455
+ expect(written.paths.json.endsWith("openapi.json")).toBe(true);
456
+ expect(written.paths.yaml.endsWith("openapi.yaml")).toBe(true);
457
+ expect(written.hash).toMatch(/^[0-9a-f]{64}$/);
458
+ expect(written.pathCount).toBeGreaterThan(0);
459
+
460
+ // Both files should exist on disk.
461
+ const jsonOnDisk = await fs.readFile(written.paths.json, "utf-8");
462
+ const yamlOnDisk = await fs.readFile(written.paths.yaml, "utf-8");
463
+ expect(jsonOnDisk).toBe(written.json);
464
+ expect(yamlOnDisk).toBe(written.yaml);
465
+
466
+ // JSON should parse.
467
+ const parsed = JSON.parse(jsonOnDisk);
468
+ expect(parsed.openapi).toBe("3.0.3");
469
+ expect(parsed.info.title).toBe("Artifact Test");
470
+
471
+ // readOpenAPIArtifacts should recover the same bodies and recompute
472
+ // the same hash.
473
+ const readBack = await readOpenAPIArtifacts(".mandu", rootDir);
474
+ expect(readBack).not.toBeNull();
475
+ expect(readBack!.hash).toBe(written.hash);
476
+ expect(readBack!.json).toBe(written.json);
477
+ expect(readBack!.yaml).toBe(written.yaml);
478
+ } finally {
479
+ await cleanup();
480
+ }
481
+ });
482
+
483
+ test("readOpenAPIArtifacts returns null when artifacts are missing", async () => {
484
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-openapi-miss-"));
485
+ try {
486
+ const result = await readOpenAPIArtifacts(".mandu", rootDir);
487
+ expect(result).toBeNull();
488
+ } finally {
489
+ await fs.rm(rootDir, { recursive: true, force: true });
490
+ }
491
+ });
492
+ });
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Runtime OpenAPI endpoint.
3
+ *
4
+ * Serves the build-time `.mandu/openapi.json` / `.mandu/openapi.yaml`
5
+ * artifacts at a stable URL (default `/__mandu/openapi.json` and
6
+ * `.yaml`) so API consumers (Postman, codegen, Swagger UI proxies) can
7
+ * fetch a canonical spec without reaching into the framework's dev
8
+ * Kitchen dashboard.
9
+ *
10
+ * Contract:
11
+ * - Disabled by default — the server dispatcher gates this handler
12
+ * behind `ManduConfig.openapi.enabled` or the
13
+ * `MANDU_OPENAPI_ENABLED=1` env var.
14
+ * - Lazy-load artifacts on first request; in-memory cache survives
15
+ * for the lifetime of the server instance. Re-deploy to invalidate.
16
+ * - If artifacts are missing on disk, fall back to live generation
17
+ * from the registered manifest so `mandu dev` users still get a
18
+ * valid response without running `mandu build` first.
19
+ * - ETag = SHA-256 of the JSON body. Supports `If-None-Match` 304
20
+ * short-circuiting so downstream caches (CDN, reverse proxy) behave
21
+ * correctly.
22
+ */
23
+
24
+ import type { RoutesManifest } from "../spec/schema";
25
+ import {
26
+ generateOpenAPIDocument,
27
+ hashOpenAPIJSON,
28
+ openAPIToJSON,
29
+ openAPIToYAML,
30
+ readOpenAPIArtifacts,
31
+ } from "../openapi/generator";
32
+
33
+ export const DEFAULT_OPENAPI_BASE_PATH = "/__mandu/openapi";
34
+ const DEFAULT_ARTIFACT_DIR = ".mandu";
35
+ const CACHE_CONTROL = "public, max-age=0, must-revalidate";
36
+
37
+ /**
38
+ * Runtime-resolved OpenAPI endpoint configuration. Mirrors the shape
39
+ * the server threads through `ServerRegistrySettings` so the hot-path
40
+ * dispatch can stay allocation-free.
41
+ */
42
+ export interface OpenAPIEndpointSettings {
43
+ /** Base path without the trailing `.json`/`.yaml`. Default `/__mandu/openapi`. */
44
+ basePath: string;
45
+ /** Absolute directory containing `openapi.json` / `openapi.yaml` artifacts. */
46
+ artifactDir: string;
47
+ }
48
+
49
+ interface CacheEntry {
50
+ json: string;
51
+ yaml: string;
52
+ hash: string;
53
+ etag: string;
54
+ }
55
+
56
+ /** Module-scoped cache — invalidated by `invalidateOpenAPIEndpointCache()`. */
57
+ let cache: CacheEntry | null = null;
58
+ let pending: Promise<CacheEntry | null> | null = null;
59
+
60
+ /** Test / HMR hook: drop the cached spec so the next request recomputes. */
61
+ export function invalidateOpenAPIEndpointCache(): void {
62
+ cache = null;
63
+ pending = null;
64
+ }
65
+
66
+ /**
67
+ * Resolve the OpenAPI body, either from disk artifacts (preferred) or
68
+ * by generating live from the manifest. Concurrent callers share one
69
+ * in-flight load so we never rebuild the spec twice on a thundering
70
+ * herd.
71
+ */
72
+ async function loadSpec(
73
+ manifest: RoutesManifest,
74
+ rootDir: string,
75
+ settings: OpenAPIEndpointSettings
76
+ ): Promise<CacheEntry | null> {
77
+ if (cache) return cache;
78
+ if (pending) return pending;
79
+
80
+ pending = (async (): Promise<CacheEntry | null> => {
81
+ try {
82
+ // 1. Prefer on-disk artifacts (produced by `mandu build`). Keeps
83
+ // request-time cost at a single file read instead of walking
84
+ // every contract module again.
85
+ const fromDisk = await readOpenAPIArtifacts(settings.artifactDir, rootDir);
86
+ if (fromDisk) {
87
+ const entry: CacheEntry = {
88
+ json: fromDisk.json,
89
+ yaml: fromDisk.yaml,
90
+ hash: fromDisk.hash,
91
+ etag: `"${fromDisk.hash}"`,
92
+ };
93
+ cache = entry;
94
+ return entry;
95
+ }
96
+
97
+ // 2. Fallback: generate live. Useful in `mandu dev` before the
98
+ // user has run `mandu build`, or in test harnesses that boot a
99
+ // server directly from a manifest fixture.
100
+ const doc = await generateOpenAPIDocument(manifest, rootDir);
101
+ const json = openAPIToJSON(doc);
102
+ const yaml = openAPIToYAML(doc);
103
+ const hash = await hashOpenAPIJSON(json);
104
+ const entry: CacheEntry = {
105
+ json,
106
+ yaml,
107
+ hash,
108
+ etag: `"${hash}"`,
109
+ };
110
+ cache = entry;
111
+ return entry;
112
+ } catch {
113
+ // Swallow the error — a 500 here would be worse DX than a 404.
114
+ // Invalidate so the next request retries.
115
+ cache = null;
116
+ return null;
117
+ } finally {
118
+ pending = null;
119
+ }
120
+ })();
121
+
122
+ return pending;
123
+ }
124
+
125
+ /**
126
+ * Handle a GET request for the OpenAPI endpoint.
127
+ *
128
+ * Returns `null` when the pathname does not match — the dispatcher
129
+ * should fall through to the normal route resolution pipeline. Returns
130
+ * a `Response` for both hit (200 + spec) and miss (404 when the spec
131
+ * cannot be materialized). Only `GET` and `HEAD` are accepted; every
132
+ * other method gets a 405 with `Allow: GET, HEAD`.
133
+ */
134
+ export async function handleOpenAPIRequest(
135
+ req: Request,
136
+ pathname: string,
137
+ manifest: RoutesManifest,
138
+ rootDir: string,
139
+ settings: OpenAPIEndpointSettings
140
+ ): Promise<Response | null> {
141
+ const jsonPath = `${settings.basePath}.json`;
142
+ const yamlPath = `${settings.basePath}.yaml`;
143
+
144
+ let variant: "json" | "yaml";
145
+ if (pathname === jsonPath) variant = "json";
146
+ else if (pathname === yamlPath) variant = "yaml";
147
+ else return null;
148
+
149
+ if (req.method !== "GET" && req.method !== "HEAD") {
150
+ return new Response("Method Not Allowed", {
151
+ status: 405,
152
+ headers: {
153
+ Allow: "GET, HEAD",
154
+ "Cache-Control": "no-store",
155
+ },
156
+ });
157
+ }
158
+
159
+ const entry = await loadSpec(manifest, rootDir, settings);
160
+ if (!entry) {
161
+ return new Response("Not Found", { status: 404 });
162
+ }
163
+
164
+ // Conditional-GET: honour `If-None-Match` for CDN / browser caches.
165
+ const ifNoneMatch = req.headers.get("if-none-match");
166
+ if (ifNoneMatch && ifNoneMatch === entry.etag) {
167
+ return new Response(null, {
168
+ status: 304,
169
+ headers: {
170
+ ETag: entry.etag,
171
+ "Cache-Control": CACHE_CONTROL,
172
+ },
173
+ });
174
+ }
175
+
176
+ const body = variant === "json" ? entry.json : entry.yaml;
177
+ const contentType =
178
+ variant === "json"
179
+ ? "application/json; charset=utf-8"
180
+ : "application/yaml; charset=utf-8";
181
+
182
+ // HEAD responses carry the headers but drop the body.
183
+ const responseBody = req.method === "HEAD" ? null : body;
184
+ return new Response(responseBody, {
185
+ status: 200,
186
+ headers: {
187
+ "Content-Type": contentType,
188
+ "Cache-Control": CACHE_CONTROL,
189
+ ETag: entry.etag,
190
+ // Expose ETag to browser JS so API explorer UIs can display the
191
+ // deploy identifier without a round-trip.
192
+ "Access-Control-Expose-Headers": "ETag",
193
+ },
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Resolve the effective endpoint settings for a server boot. Normalizes
199
+ * the `path` option (users may pass with or without leading slash, and
200
+ * with or without the `.json` suffix) and chooses the artifact
201
+ * directory default.
202
+ */
203
+ export function resolveOpenAPIEndpointSettings(
204
+ rootDir: string,
205
+ path?: string
206
+ ): OpenAPIEndpointSettings {
207
+ let basePath = path ?? DEFAULT_OPENAPI_BASE_PATH;
208
+ if (!basePath.startsWith("/")) basePath = `/${basePath}`;
209
+ // Strip trailing `.json` / `.yaml` / trailing slash so the handler can
210
+ // append suffixes uniformly.
211
+ basePath = basePath.replace(/\.(json|yaml|yml)$/i, "").replace(/\/+$/, "");
212
+ if (basePath === "") basePath = DEFAULT_OPENAPI_BASE_PATH;
213
+
214
+ // POSIX-style join — artifact paths are treated as absolute by
215
+ // `readOpenAPIArtifacts`, which itself uses `node:path` for portability.
216
+ const normalizedRoot = rootDir.replace(/[\\/]+$/, "");
217
+ const separator = normalizedRoot.includes("\\") ? "\\" : "/";
218
+ const artifactDir = `${normalizedRoot}${separator}${DEFAULT_ARTIFACT_DIR}`;
219
+ return { basePath, artifactDir };
220
+ }
221
+
222
+ /**
223
+ * Decide whether the OpenAPI endpoint should be active for this
224
+ * server instance. The config flag wins; an explicit `false` still
225
+ * disables the endpoint even when the env var is set (explicit > env).
226
+ * Absent config + truthy env var (`MANDU_OPENAPI_ENABLED=1`) opts in.
227
+ */
228
+ export function isOpenAPIEndpointEnabled(
229
+ enabled: boolean | undefined,
230
+ env: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env
231
+ ): boolean {
232
+ if (enabled === true) return true;
233
+ if (enabled === false) return false;
234
+ const raw = env.MANDU_OPENAPI_ENABLED;
235
+ return raw === "1" || raw === "true";
236
+ }
@@ -62,6 +62,11 @@ import {
62
62
  isObservabilityExposed,
63
63
  recordHttpRequest,
64
64
  } from "../observability/metrics";
65
+ import {
66
+ handleOpenAPIRequest,
67
+ isOpenAPIEndpointEnabled,
68
+ resolveOpenAPIEndpointSettings,
69
+ } from "./openapi-endpoint";
65
70
  // Phase 18.ψ — user-facing perf marks dashboard append. We include a
66
71
  // `perf` block on the `/_mandu/heap` payload when the feature is active
67
72
  // so operators see a unified view (heap + caches + perf histogram).
@@ -499,6 +504,30 @@ export interface ServerOptions {
499
504
  serviceName?: string;
500
505
  };
501
506
  };
507
+ /**
508
+ * Production-grade OpenAPI endpoint.
509
+ *
510
+ * When enabled, the runtime serves the contracts-derived OpenAPI 3.0.3
511
+ * document at `<path>.json` / `<path>.yaml` (default base path
512
+ * `/__mandu/openapi`). The body is loaded once from the build-time
513
+ * artifacts under `.mandu/openapi.{json,yaml}` (emitted by
514
+ * `mandu build`) and cached for the lifetime of the server.
515
+ *
516
+ * - `enabled` — default `false`. Security-conscious: production
517
+ * deployments must explicitly opt in, or set
518
+ * `MANDU_OPENAPI_ENABLED=1` in the environment.
519
+ * - `path` — base URL path (with or without `.json` suffix).
520
+ * Default `/__mandu/openapi`.
521
+ *
522
+ * Wired from `ManduConfig.openapi`. The response carries
523
+ * `Cache-Control: public, max-age=0, must-revalidate` + a SHA-256
524
+ * ETag so CDNs / browsers revalidate on every deploy without holding
525
+ * stale specs.
526
+ */
527
+ openapi?: {
528
+ enabled?: boolean;
529
+ path?: string;
530
+ };
502
531
  /**
503
532
  * Phase 18 — prerendered HTML pass-through (SSG).
504
533
  *
@@ -787,6 +816,14 @@ export interface ServerRegistrySettings {
787
816
  * request opens a root span via `tracer.startSpanFromRequest()`.
788
817
  */
789
818
  tracer?: import("../observability/tracing").Tracer;
819
+ /**
820
+ * Production OpenAPI endpoint — resolved from `ServerOptions.openapi`.
821
+ * `undefined` means the endpoint is disabled (hot path branch-free).
822
+ * When populated, every request whose pathname matches
823
+ * `<basePath>.json` / `<basePath>.yaml` short-circuits before route
824
+ * dispatch to serve the cached spec body.
825
+ */
826
+ openapi?: import("./openapi-endpoint").OpenAPIEndpointSettings;
790
827
  /**
791
828
  * Phase 18 — resolved prerender pass-through state. `undefined`
792
829
  * means the feature is disabled for this server instance.
@@ -3916,6 +3953,26 @@ async function handleRequestInternal(
3916
3953
  }
3917
3954
  }
3918
3955
 
3956
+ // Production OpenAPI endpoint — `/__mandu/openapi.json` + `.yaml`.
3957
+ // Gated behind `ManduConfig.openapi.enabled` (or MANDU_OPENAPI_ENABLED=1).
3958
+ // Every response carries an ETag so CDNs / browsers revalidate cheaply
3959
+ // after a deploy. Falls through to route dispatch if disabled OR the
3960
+ // pathname doesn't match the configured base path.
3961
+ if (settings.openapi) {
3962
+ const openapiManifest: RoutesManifest = {
3963
+ version: 1,
3964
+ routes: router.getRoutes(),
3965
+ };
3966
+ const openapiResponse = await handleOpenAPIRequest(
3967
+ req,
3968
+ pathname,
3969
+ openapiManifest,
3970
+ settings.rootDir,
3971
+ settings.openapi
3972
+ );
3973
+ if (openapiResponse) return ok(openapiResponse);
3974
+ }
3975
+
3919
3976
  // 2. Kitchen dev dashboard (dev mode only)
3920
3977
  if (settings.isDev && pathname.startsWith(KITCHEN_PREFIX) && registry.kitchen) {
3921
3978
  const kitchenResponse = await registry.kitchen.handle(req, pathname);
@@ -4252,6 +4309,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4252
4309
  spa,
4253
4310
  devtools,
4254
4311
  observability: observabilityOption,
4312
+ openapi: openapiOption,
4255
4313
  prerender: prerenderOption,
4256
4314
  middleware: middlewareOption,
4257
4315
  rpc: rpcOption,
@@ -4373,6 +4431,13 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4373
4431
  heapEndpoint: observabilityOption?.heapEndpoint,
4374
4432
  metricsEndpoint: observabilityOption?.metricsEndpoint,
4375
4433
  tracer: tracerInstance.enabled ? tracerInstance : undefined,
4434
+ // Production OpenAPI endpoint — default OFF so an internet-facing
4435
+ // deployment does not leak its API surface without explicit opt-in.
4436
+ // `MANDU_OPENAPI_ENABLED=1` in the environment forces-on without a
4437
+ // config edit; explicit `enabled: false` still wins (explicit > env).
4438
+ openapi: isOpenAPIEndpointEnabled(openapiOption?.enabled)
4439
+ ? resolveOpenAPIEndpointSettings(rootDir, openapiOption?.path)
4440
+ : undefined,
4376
4441
  prerender: prerenderSettings,
4377
4442
  middlewareChain,
4378
4443
  i18n: i18nOption,