From 8391d193aea0d3364bfb0c6ed1cd2ee1b9392894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Wed, 29 Apr 2026 01:06:55 +0200 Subject: [PATCH] feat: agent ready docs --- docs/package.json | 3 +- docs/public/robots.txt | 15 +- docs/react-server.config.mjs | 7 +- docs/src/components/WebMCP.jsx | 124 +++++++++++ .../pages/(agent-discovery).middleware.mjs | 161 ++++++++++++++ .../(content-negotiation).middleware.mjs | 100 +++++++++ docs/src/pages/(i18n).middleware.mjs | 25 +-- docs/src/pages/(root).layout.jsx | 2 + docs/src/pages/mcp.server.mjs | 205 ++++++++++++++++++ docs/src/version.mjs | 10 + docs/vite.config.mjs | 7 + .../adapters/aws/functions/handler.mjs | 11 +- .../adapters/cloudflare/worker/edge.mjs | 19 +- .../adapters/docker/server/index.mjs | 12 +- .../adapters/netlify/functions/node.mjs | 7 +- .../react-server/adapters/netlify/index.mjs | 7 +- .../react-server/adapters/shared/accept.mjs | 106 +++++++++ .../react-server/adapters/vercel/index.mjs | 24 ++ packages/react-server/lib/build/edge.mjs | 8 + packages/react-server/lib/build/server.mjs | 2 + packages/react-server/lib/dev/ssr-handler.mjs | 13 +- packages/react-server/lib/handlers/static.mjs | 14 ++ .../lib/http/middleware-response.mjs | 32 +++ .../lib/plugins/inline-cjs-json.mjs | 72 ++++++ .../react-server/lib/start/ssr-handler.mjs | 13 +- pnpm-lock.yaml | 3 + 26 files changed, 964 insertions(+), 38 deletions(-) create mode 100644 docs/src/components/WebMCP.jsx create mode 100644 docs/src/pages/(agent-discovery).middleware.mjs create mode 100644 docs/src/pages/(content-negotiation).middleware.mjs create mode 100644 docs/src/pages/mcp.server.mjs create mode 100644 docs/src/version.mjs create mode 100644 packages/react-server/adapters/shared/accept.mjs create mode 100644 packages/react-server/lib/http/middleware-response.mjs create mode 100644 packages/react-server/lib/plugins/inline-cjs-json.mjs diff --git a/docs/package.json b/docs/package.json index 0cd6a5c6..01cb70d9 100644 --- a/docs/package.json +++ b/docs/package.json @@ -33,7 +33,8 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "three": "^0.183.1", - "vite-plugin-svgr": "^4.5.0" + "vite-plugin-svgr": "^4.5.0", + "zod": "^3.23.8" }, "devDependencies": { "@inlang/paraglide-js": "1.11.8", diff --git a/docs/public/robots.txt b/docs/public/robots.txt index 69610840..5e60dd14 100644 --- a/docs/public/robots.txt +++ b/docs/public/robots.txt @@ -1 +1,14 @@ -Sitemap: https://react-server.dev/sitemap.xml \ No newline at end of file +# @lazarv/react-server is an open-source React Server Components runtime. +# We welcome AI agents — this site is documentation, and we want LLMs to +# index it, cite it, and answer user questions about it accurately. +# +# Content-Signal (https://blog.cloudflare.com/content-signals/): +# search — build a search index and link in search results +# ai-input — use content as live input for AI answers (RAG, in-context) +# ai-train — train or fine-tune AI models + +User-agent: * +Content-Signal: search=yes, ai-input=yes, ai-train=yes +Allow: / + +Sitemap: https://react-server.dev/sitemap.xml diff --git a/docs/react-server.config.mjs b/docs/react-server.config.mjs index 37e1ced2..0d41902c 100644 --- a/docs/react-server.config.mjs +++ b/docs/react-server.config.mjs @@ -7,12 +7,7 @@ import remarkMath from "remark-math"; export default { root: "src/pages", public: "public", - adapter: [ - "cloudflare", - { - serverlessFunctions: false, - }, - ], + adapter: "cloudflare", mdx: { remarkPlugins: [remarkGfm, remarkMath], rehypePlugins: [ diff --git a/docs/src/components/WebMCP.jsx b/docs/src/components/WebMCP.jsx new file mode 100644 index 00000000..89954aba --- /dev/null +++ b/docs/src/components/WebMCP.jsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Register WebMCP tools so any browser-based agent (Claude, Cursor, ChatGPT + * Atlas, Cloudflare Browser-Use) interacting with the docs page through + * `navigator.modelContext` can search the docs and fetch any page as + * markdown without scraping HTML. + * + * https://webmcp.org + */ +export default function WebMCP() { + useEffect(() => { + const nav = typeof navigator !== "undefined" ? navigator : null; + if (!nav?.modelContext?.registerTool) return; + + const registrations = []; + + registrations.push( + nav.modelContext.registerTool({ + name: "search_docs", + description: + "Search the @lazarv/react-server documentation for a query and return matching page paths with titles. Use this when the user asks how to do something with @lazarv/react-server.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: + "Free-text search query (e.g. 'file system router', 'use cache', 'cloudflare deploy').", + }, + }, + required: ["query"], + }, + annotations: { readOnlyHint: true, idempotentHint: true }, + async execute({ query }) { + if (typeof query !== "string" || !query.trim()) { + return { error: "Missing query" }; + } + // Use the sitemap as a lightweight, cache-friendly index. + const res = await fetch("/sitemap.xml", { + headers: { Accept: "application/xml" }, + }); + if (!res.ok) return { error: `sitemap fetch failed: ${res.status}` }; + const xml = await res.text(); + const locs = [...xml.matchAll(/([^<]+)<\/loc>/g)].map( + (m) => m[1] + ); + const q = query.toLowerCase(); + const matches = locs + .filter((u) => u.toLowerCase().includes(q)) + .slice(0, 20) + .map((u) => ({ + url: u, + markdown_url: `${u.replace(/\/$/, "")}.md`, + })); + return { matches, total: matches.length }; + }, + }) + ); + + registrations.push( + nav.modelContext.registerTool({ + name: "get_docs_page", + description: + "Fetch a documentation page as markdown. Pass either a full URL on https://react-server.dev or a path like '/router/file-router'. Returns the page content as text/markdown.", + inputSchema: { + type: "object", + properties: { + path: { + type: "string", + description: + "Page path (e.g. '/router/file-router') or full URL. The .md suffix is added automatically.", + }, + }, + required: ["path"], + }, + annotations: { readOnlyHint: true, idempotentHint: true }, + async execute({ path }) { + if (typeof path !== "string" || !path) { + return { error: "Missing path" }; + } + let target = path; + try { + const u = new URL(path, location.origin); + target = u.pathname; + } catch { + // not a URL, treat as path + } + if (!target.startsWith("/")) target = `/${target}`; + target = target.replace(/\/$/, ""); + if (!target.endsWith(".md")) target = `${target}.md`; + const res = await fetch(target, { + headers: { Accept: "text/markdown" }, + }); + if (!res.ok) return { error: `fetch failed: ${res.status}` }; + const markdown = await res.text(); + return { path: target, markdown }; + }, + }) + ); + + return () => { + for (const r of registrations) { + if (typeof r === "function") { + try { + r(); + } catch { + /* ignore */ + } + } else if (r && typeof r.unregister === "function") { + try { + r.unregister(); + } catch { + /* ignore */ + } + } + } + }; + }, []); + + return null; +} diff --git a/docs/src/pages/(agent-discovery).middleware.mjs b/docs/src/pages/(agent-discovery).middleware.mjs new file mode 100644 index 00000000..dcde5352 --- /dev/null +++ b/docs/src/pages/(agent-discovery).middleware.mjs @@ -0,0 +1,161 @@ +import { setHeader } from "@lazarv/react-server"; +import { useUrl } from "@lazarv/react-server"; + +import skillContent from "../../../skills/react-server/SKILL.md?raw"; +import { version } from "../version.mjs"; + +// --------------------------------------------------------------------------- +// Agent-readiness payloads +// +// Implements the contracts checked by https://isitagentready.com: +// - RFC 9727 API Catalog → /.well-known/api-catalog +// - Agent Skills v0.2 index → /.well-known/agent-skills/index.json +// - Agent Skill body → /.well-known/agent-skills/react-server/SKILL.md +// - MCP Server Card → /.well-known/mcp/server-card.json +// - RFC 8288 Link headers → on every documentation page +// --------------------------------------------------------------------------- + +const SITE = "https://react-server.dev"; + +const apiCatalog = { + linkset: [ + { + anchor: `${SITE}/.well-known/api-catalog`, + item: [ + { href: `${SITE}/mcp` }, + { href: `${SITE}/llms.txt` }, + { href: `${SITE}/sitemap.xml` }, + { href: `${SITE}/schema.json` }, + ], + }, + { + anchor: `${SITE}/mcp`, + "service-doc": [ + { href: `${SITE}/features/mcp`, type: "text/html" }, + { href: `${SITE}/features/mcp.md`, type: "text/markdown" }, + ], + describedby: [ + { + href: `${SITE}/.well-known/mcp/server-card.json`, + type: "application/json", + }, + ], + }, + { + anchor: `${SITE}/llms.txt`, + describedby: [{ href: `${SITE}/llms.txt`, type: "text/plain" }], + }, + { + anchor: `${SITE}/schema.json`, + describedby: [ + { href: `${SITE}/schema.json`, type: "application/schema+json" }, + ], + }, + ], +}; + +const agentSkillsIndex = { + $schema: "https://agent-skills.dev/schema/v0.2.0.json", + skills: [ + { + name: "react-server", + description: + "Build applications with @lazarv/react-server — a React Server Components runtime built on Vite. Covers use directives, file-system router, HTTP hooks, caching, live components, workers, MCP, deployment, and all core APIs.", + version, + skill_url: `${SITE}/.well-known/agent-skills/react-server/SKILL.md`, + homepage: SITE, + license: "MIT", + }, + ], +}; + +const mcpServerCard = { + $schema: + "https://modelcontextprotocol.io/schemas/draft/2025-09-29/server-card.json", + name: "react-server-docs", + title: "@lazarv/react-server Documentation", + description: + "Search and read the @lazarv/react-server documentation as Model Context Protocol resources and tools. Provides a search_docs tool and exposes every documentation page as a markdown resource.", + version, + homepage: SITE, + documentation: `${SITE}/features/mcp`, + endpoints: { + streamable_http: `${SITE}/mcp`, + }, + capabilities: { + tools: { listChanged: false }, + resources: { listChanged: false, subscribe: false }, + prompts: { listChanged: false }, + }, + contact: { + repository: "https://github.com/lazarv/react-server", + }, +}; + +const wellKnown = { + "/.well-known/api-catalog": () => + json( + apiCatalog, + 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"' + ), + "/.well-known/agent-skills/index.json": () => json(agentSkillsIndex), + "/.well-known/agent-skills/react-server/SKILL.md": () => + new Response(skillContent, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }), + "/.well-known/mcp/server-card.json": () => json(mcpServerCard), +}; + +function json(body, contentType = "application/json; charset=utf-8") { + return new Response(JSON.stringify(body, null, 2), { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=3600", + }, + }); +} + +// --------------------------------------------------------------------------- +// Discovery Link headers (RFC 8288 / RFC 9727) +// +// Advertised on every documentation page so any HTTP client (including agents +// that only do HEAD or GET on `/`) can discover the API catalog, MCP entry, +// and human-/machine-readable documentation without crawling the whole site. +// --------------------------------------------------------------------------- + +const linkHeader = [ + '; rel="api-catalog"; type="application/linkset+json"', + '; rel="service-meta"; type="application/json"', + '; rel="describedby"; type="text/plain"', + '; rel="sitemap"; type="application/xml"', + '; rel="https://agent-skills.dev/rel/index"; type="application/json"', +].join(", "); + +// Pathnames that should never receive the discovery Link header — they're +// machine-only endpoints with their own headers/cache semantics. +const SKIP_LINK_HEADER = (pathname) => + pathname === "/sitemap.xml" || + pathname === "/schema.json" || + pathname === "/mcp" || + pathname.startsWith("/mcp/") || + pathname.startsWith("/.well-known/") || + pathname.startsWith("/md/") || + pathname.endsWith(".md"); + +export default function AgentDiscovery() { + const { pathname } = useUrl(); + + // 1. Static well-known endpoints — short-circuit with the right content type. + const wellKnownHandler = wellKnown[pathname]; + if (wellKnownHandler) { + return wellKnownHandler(); + } + + // 2. Set discovery Link header on documentation pages. + if (!SKIP_LINK_HEADER(pathname)) { + setHeader("Link", linkHeader); + } +} diff --git a/docs/src/pages/(content-negotiation).middleware.mjs b/docs/src/pages/(content-negotiation).middleware.mjs new file mode 100644 index 00000000..e3dcbf8b --- /dev/null +++ b/docs/src/pages/(content-negotiation).middleware.mjs @@ -0,0 +1,100 @@ +import { rewrite, setHeader, useRequest, useUrl } from "@lazarv/react-server"; + +import llmsTxt from "../../public/llms.txt?raw"; +import { languages } from "../const.mjs"; + +// --------------------------------------------------------------------------- +// Markdown content negotiation +// +// Implements the Cloudflare "Markdown for Agents" pattern: +// GET /router/file-router Accept: text/markdown → pre-rendered .md +// GET / Accept: text/markdown → llms.txt summary +// +// The same URL serves HTML or Markdown based on the client's preferred type. +// `Vary: Accept` is required so HTML caches don't poison Markdown responses. +// Browsers don't list `text/markdown` in their Accept header, so they never +// hit this branch and continue to receive HTML. +// --------------------------------------------------------------------------- + +export default function ContentNegotiation() { + const { pathname } = useUrl(); + + // Skip URLs that already target the markdown route or have a `.md` suffix — + // those go through the existing /md/ handler. + if (pathname.startsWith("/md/") || pathname.endsWith(".md")) { + return; + } + + // Skip machine-only endpoints — they have their own response shape. + if ( + pathname === "/sitemap.xml" || + pathname === "/schema.json" || + pathname === "/mcp" || + pathname.startsWith("/mcp/") || + pathname.startsWith("/.well-known/") + ) { + return; + } + + if (!prefersMarkdown(useRequest().headers.get("accept") ?? "")) { + return; + } + + // Strip any leading language prefix; the /md/ route is language-agnostic + // and always serves the canonical English markdown. + const stripped = pathname.replace( + new RegExp(`^/(${languages.join("|")})(?=/|$)`), + "" + ); + const mdPath = stripped === "" || stripped === "/" ? "" : stripped; + + // Always advertise the response varies on Accept so caches don't poison + // each other across HTML/markdown. + setHeader("Vary", "Accept"); + + // Homepage has no `/md/` entry — return the canonical llms.txt summary as + // markdown (the right "what is this site" answer for an agent). + if (mdPath === "") { + return new Response(llmsTxt, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); + } + + rewrite(`/md${mdPath}`); +} + +/** + * Parse the `Accept` header and return true when `text/markdown` is preferred + * over `text/html`. Honors `q=` quality values per RFC 9110. + */ +function prefersMarkdown(accept) { + if (!accept) return false; + + let mdQ = -1; + let htmlQ = -1; + + for (const part of accept.split(",")) { + const [type, ...params] = part.trim().split(";"); + const q = parseQ(params); + const mediaType = type.trim().toLowerCase(); + if (mediaType === "text/markdown") mdQ = Math.max(mdQ, q); + else if (mediaType === "text/html") htmlQ = Math.max(htmlQ, q); + } + + if (mdQ < 0) return false; + // Markdown wins on explicit preference (no HTML listed, or markdown listed + // first / at equal-or-higher q). Browsers don't list text/markdown so they + // never enter this branch. + return mdQ >= htmlQ; +} + +function parseQ(params) { + for (const p of params) { + const m = p.trim().match(/^q=([0-9.]+)$/i); + if (m) return parseFloat(m[1]); + } + return 1; +} diff --git a/docs/src/pages/(i18n).middleware.mjs b/docs/src/pages/(i18n).middleware.mjs index dae9f1f5..f4d29a59 100644 --- a/docs/src/pages/(i18n).middleware.mjs +++ b/docs/src/pages/(i18n).middleware.mjs @@ -3,22 +3,21 @@ import { useMatch } from "@lazarv/react-server/router"; import { defaultLanguage, languages } from "../const.mjs"; +// Pathnames that bypass locale resolution — they are language-agnostic +// resources or endpoints handled by other middlewares / API routes. +const NON_LOCALIZED = (pathname) => + pathname === "/sitemap.xml" || + pathname === "/schema.json" || + pathname === "/mcp" || + pathname.startsWith("/mcp/") || + pathname.startsWith("/.well-known/") || + pathname.startsWith("/md/") || + pathname.endsWith(".md"); + export default function I18n() { const { pathname } = useUrl(); - if (pathname === "/sitemap.xml" || pathname === "/schema.json") { - return; - } - - // Rewrite .md requests to the markdown API route - if (pathname.endsWith(".md")) { - const mdPath = pathname.replace(/\.md$/, ""); - rewrite(`/md${mdPath}`); - return; - } - - // Skip /md/ API route paths from i18n handling - if (pathname.startsWith("/md/")) { + if (NON_LOCALIZED(pathname)) { return; } diff --git a/docs/src/pages/(root).layout.jsx b/docs/src/pages/(root).layout.jsx index f89c91ad..9ba7ccc8 100644 --- a/docs/src/pages/(root).layout.jsx +++ b/docs/src/pages/(root).layout.jsx @@ -9,6 +9,7 @@ import { useMatch } from "@lazarv/react-server/router"; import EditPage from "../components/EditPage.jsx"; import PageMeta from "../components/PageMeta.jsx"; import ViewMarkdown from "../components/ViewMarkdown.jsx"; +import WebMCP from "../components/WebMCP.jsx"; import { useLanguage, m } from "../i18n.mjs"; import { defaultLanguage, defaultLanguageRE, languages } from "../const.mjs"; import { categories, getPageFrontmatter } from "../pages.mjs"; @@ -107,6 +108,7 @@ export default function Layout({ /> + {header}
{sidebar} diff --git a/docs/src/pages/mcp.server.mjs b/docs/src/pages/mcp.server.mjs new file mode 100644 index 00000000..1c418f66 --- /dev/null +++ b/docs/src/pages/mcp.server.mjs @@ -0,0 +1,205 @@ +"use server"; + +import { + createPrompt, + createResource, + createServer, + createTool, +} from "@lazarv/react-server/mcp"; +import { z } from "zod"; + +import { getPages } from "../pages.mjs"; +import { version } from "../version.mjs"; + +// --------------------------------------------------------------------------- +// Pre-computed page index (used for search). Built once per worker init. +// --------------------------------------------------------------------------- + +const SITE = "https://react-server.dev"; + +const pageIndex = (() => { + try { + return getPages("/", "en").flatMap(({ category, pages }) => + pages.map((p) => ({ + category, + title: p.frontmatter?.title ?? p.langHref ?? "", + description: p.frontmatter?.description ?? "", + href: (p.langHref ?? p.href ?? "").replace(/^\/en/, ""), + })) + ); + } catch { + return []; + } +})(); + +async function readDocsMarkdown(slug) { + // The docs site exports a `.md` form for every page at build time + // (see `react-server.config.mjs`). Fetch it back at runtime; this works + // identically on Node, Bun, Deno, and Cloudflare Workers without + // requiring filesystem access from the worker bundle. + const cleaned = slug.replace(/^\/+|\/+$/g, ""); + if (!cleaned) return null; + const url = `${SITE}/${cleaned}.md`; + const res = await fetch(url, { + headers: { Accept: "text/markdown" }, + }); + if (!res.ok) return null; + return await res.text(); +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +const searchDocs = createTool({ + id: "search_docs", + title: "Search react-server docs", + description: + "Search the @lazarv/react-server documentation by free-text query. Returns matching pages with their titles and markdown URLs (suitable for fetching with the read_doc tool).", + inputSchema: { + query: z + .string() + .min(1) + .describe( + "Free-text query, e.g. 'file system router', 'use cache', 'cloudflare deploy'." + ), + limit: z + .number() + .int() + .min(1) + .max(50) + .default(10) + .describe("Maximum results to return (1-50)."), + }, + async handler({ query, limit = 10 }) { + const q = query.toLowerCase(); + const matches = pageIndex + .filter((p) => { + const haystack = + `${p.title} ${p.description} ${p.href} ${p.category}`.toLowerCase(); + return q.split(/\s+/).every((tok) => haystack.includes(tok)); + }) + .slice(0, limit) + .map((p) => ({ + title: p.title, + category: p.category, + description: p.description || undefined, + url: `${SITE}${p.href}`, + markdown_url: `${SITE}${p.href}.md`, + })); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { query, total: matches.length, results: matches }, + null, + 2 + ), + }, + ], + }; + }, +}); + +const readDoc = createTool({ + id: "read_doc", + title: "Read a docs page as markdown", + description: + "Fetch a specific @lazarv/react-server documentation page as markdown. Pass a path like '/router/file-router' or a full URL on https://react-server.dev.", + inputSchema: { + path: z + .string() + .min(1) + .describe( + "Page path ('/router/file-router') or full URL. The .md form is fetched automatically." + ), + }, + async handler({ path }) { + let target = path; + try { + const u = new URL(path); + if (u.host.endsWith("react-server.dev")) target = u.pathname; + } catch { + // not a URL + } + if (!target.startsWith("/")) target = `/${target}`; + target = target.replace(/\.md$/, "").replace(/\/+$/, ""); + const md = await readDocsMarkdown(target); + if (!md) { + return { + isError: true, + content: [{ type: "text", text: `No docs page found at "${target}"` }], + }; + } + return { + content: [{ type: "text", text: md }], + }; + }, +}); + +// --------------------------------------------------------------------------- +// Resources — every docs page is exposed as a markdown resource +// --------------------------------------------------------------------------- + +const docsPageResource = createResource({ + id: "docs-page", + template: `${SITE}/{slug}.md`, + name: "@lazarv/react-server docs page", + description: + "Any @lazarv/react-server documentation page rendered as markdown.", + mimeType: "text/markdown", + list: async () => + pageIndex.slice(0, 200).map((p) => ({ + uri: `${SITE}${p.href}.md`, + name: p.title, + mimeType: "text/markdown", + })), + async handler({ slug }) { + const md = await readDocsMarkdown(slug); + return md ?? `# Not Found\n\nNo page at /${slug}`; + }, +}); + +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- + +const explainTopic = createPrompt({ + id: "explain-topic", + title: "Explain a react-server topic", + description: + "Generate a structured explanation of a @lazarv/react-server topic, grounded in the official documentation.", + argsSchema: { + topic: z + .string() + .min(1) + .describe("The topic to explain — e.g. 'use cache', 'live components'."), + }, + async handler({ topic }) { + return { + messages: [ + { + role: "user", + content: { + type: "text", + text: `Explain "${topic}" in @lazarv/react-server. First call \`search_docs\` to find the canonical pages, then \`read_doc\` for the most relevant one, then synthesize an answer that cites the URLs you used.`, + }, + }, + ], + }; + }, +}); + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +export default createServer({ + name: "react-server-docs", + title: "@lazarv/react-server Documentation", + version, + tools: [searchDocs, readDoc], + resources: [docsPageResource], + prompts: [explainTopic], +}); diff --git a/docs/src/version.mjs b/docs/src/version.mjs new file mode 100644 index 00000000..5b20d897 --- /dev/null +++ b/docs/src/version.mjs @@ -0,0 +1,10 @@ +import { version as fullVersion } from "@lazarv/react-server"; + +/** + * The plain semver of the @lazarv/react-server package the docs site is + * built against. The package's own `version` export is namespaced as + * `react-server/` (handy for log lines), but every consumer that + * advertises an MCP server / Agent Skill / API catalog wants just the + * semver so external tools display it cleanly. + */ +export const version = fullVersion.split("/").pop() ?? fullVersion; diff --git a/docs/vite.config.mjs b/docs/vite.config.mjs index 2b529126..3c68f88e 100644 --- a/docs/vite.config.mjs +++ b/docs/vite.config.mjs @@ -9,4 +9,11 @@ export default { outdir: "./src/paraglide", }), ], + // Allow reading the canonical SKILL.md from the monorepo's `skills/` + // directory. The agent-skills well-known endpoint serves it verbatim. + server: { + fs: { + allow: ["..", "../skills"], + }, + }, }; diff --git a/packages/react-server/adapters/aws/functions/handler.mjs b/packages/react-server/adapters/aws/functions/handler.mjs index 95c61c43..6da6b351 100644 --- a/packages/react-server/adapters/aws/functions/handler.mjs +++ b/packages/react-server/adapters/aws/functions/handler.mjs @@ -2,6 +2,7 @@ import { readFileSync, statSync } from "node:fs"; import { join, extname } from "node:path"; import { reactServer } from "@lazarv/react-server/edge"; import { createContext } from "@lazarv/react-server/http"; +import { isHtmlRoute, shouldDeferToServer } from "../../shared/accept.mjs"; import { finalizeResponse } from "../../shared/edge-handler.mjs"; let serverPromise = null; @@ -174,8 +175,14 @@ async function handleRequest(event, context) { // ---- Static files (GET only) ---- if (request.method === "GET") { const url = new URL(request.url); - const staticResponse = tryServeStatic(url.pathname); - if (staticResponse) return staticResponse; + // For HTML routes, defer to SSR when the client clearly prefers a + // non-HTML media type (e.g. agents sending `Accept: text/markdown`) + // so content-negotiation middleware can serve the matching variant. + const deferToSsr = isHtmlRoute(url) && shouldDeferToServer(request); + if (!deferToSsr) { + const staticResponse = tryServeStatic(url.pathname); + if (staticResponse) return staticResponse; + } } // ---- SSR via react-server ---- diff --git a/packages/react-server/adapters/cloudflare/worker/edge.mjs b/packages/react-server/adapters/cloudflare/worker/edge.mjs index a2287c95..f79fab9e 100644 --- a/packages/react-server/adapters/cloudflare/worker/edge.mjs +++ b/packages/react-server/adapters/cloudflare/worker/edge.mjs @@ -1,3 +1,4 @@ +import { isHtmlRoute, shouldDeferToServer } from "../../shared/accept.mjs"; import { createEdgeHandler } from "../../shared/edge-handler.mjs"; const handle = createEdgeHandler({ @@ -11,16 +12,24 @@ const handle = createEdgeHandler({ export default { async fetch(request, env, ctx) { - try { - // Try static assets first - if (env.ASSETS) { + // Defer to the worker when the request is for an HTML route AND the + // client clearly prefers a non-HTML media type (e.g. agents sending + // `Accept: text/markdown`). The asset binding would otherwise serve + // the pre-rendered `index.html` and bypass content-negotiation + // middleware. Static files with explicit non-HTML extensions (CSS, + // images, JSON, …) are unaffected — `isHtmlRoute()` returns false. + const url = new URL(request.url); + const deferToWorker = isHtmlRoute(url) && shouldDeferToServer(request); + + if (env.ASSETS && !deferToWorker) { + try { const assetResponse = await env.ASSETS.fetch(request); if (assetResponse.ok) { return assetResponse; } + } catch { + // Fall through to SSR handler } - } catch { - // Fall through to SSR handler } return handle(request, env, ctx); diff --git a/packages/react-server/adapters/docker/server/index.mjs b/packages/react-server/adapters/docker/server/index.mjs index e59b078b..9be30889 100644 --- a/packages/react-server/adapters/docker/server/index.mjs +++ b/packages/react-server/adapters/docker/server/index.mjs @@ -4,6 +4,8 @@ import { readFileSync, existsSync } from "node:fs"; import { reactServer } from "@lazarv/react-server/node"; +import { isHtmlRoute, shouldDeferToServer } from "../../shared/accept.mjs"; + const port = parseInt(process.env.PORT, 10) || 3000; const host = process.env.HOST || "0.0.0.0"; @@ -88,8 +90,14 @@ const { middlewares } = await reactServer({ }); const server = createServer((req, res) => { - // Try static files first, then fall through to react-server - if (tryServeStatic(req, res)) return; + // Try static files first, then fall through to react-server. For HTML + // routes the client may legitimately prefer a non-HTML media type + // (e.g. agents sending `Accept: text/markdown`) — in that case skip + // static and let content-negotiation middleware serve the matching + // variant from the same canonical URL. + const url = new URL(req.url, `http://${req.headers.host}`); + const deferToServer = isHtmlRoute(url) && shouldDeferToServer(req); + if (!deferToServer && tryServeStatic(req, res)) return; middlewares(req, res); }); diff --git a/packages/react-server/adapters/netlify/functions/node.mjs b/packages/react-server/adapters/netlify/functions/node.mjs index 3a1b17d7..5853f30d 100644 --- a/packages/react-server/adapters/netlify/functions/node.mjs +++ b/packages/react-server/adapters/netlify/functions/node.mjs @@ -11,7 +11,12 @@ export default createEdgeHandler({ onError: (e) => console.error(e), }); +// Netlify's `preferStatic: true` would short-circuit any URL with a matching +// static file — bypassing the function before it can run content-negotiation +// middleware (e.g. an agent asking for `Accept: text/markdown` on a +// pre-rendered HTML route would always receive HTML). The framework's +// in-process static handler defers to SSR per-request, so we always route +// through the function and let it decide. export const config = { path: "/*", - preferStatic: true, }; diff --git a/packages/react-server/adapters/netlify/index.mjs b/packages/react-server/adapters/netlify/index.mjs index b0b6c987..717b60f5 100644 --- a/packages/react-server/adapters/netlify/index.mjs +++ b/packages/react-server/adapters/netlify/index.mjs @@ -129,6 +129,12 @@ export const config = { message("creating", "server function"); // Create index.mjs that re-exports from the bundled edge.mjs + // Note: `preferStatic` is intentionally omitted (Netlify default + // `false`) so the function runs for every request and can do + // Accept-aware content negotiation. The framework's in-process + // static handler still serves matching files directly without SSR + // overhead. Users who don't need content negotiation can opt back + // in via `adapterOptions.functions.config.preferStatic = true`. const entryFile = join(outServerDir, "index.mjs"); writeFileSync( entryFile, @@ -136,7 +142,6 @@ export const config = { export const config = { path: "/*", - preferStatic: true, ...${JSON.stringify(adapterOptions?.functions?.config ?? {})}, }; ` diff --git a/packages/react-server/adapters/shared/accept.mjs b/packages/react-server/adapters/shared/accept.mjs new file mode 100644 index 00000000..d5da2f36 --- /dev/null +++ b/packages/react-server/adapters/shared/accept.mjs @@ -0,0 +1,106 @@ +/** + * Utilities for HTTP `Accept`-header content negotiation. + * + * The framework pre-renders pages as both `.html` and `.md` static files at + * build time. By default the static layer (Cloudflare Workers Assets, the + * Node static handler, etc.) serves `index.html` for `/` regardless of what + * the client asked for — which means an agent sending + * `Accept: text/markdown` on the canonical URL receives HTML. + * + * `shouldDeferToServer(request)` lets adapters and the static handler ask: + * "would a static HTML reply satisfy this client?" When the client clearly + * prefers a non-HTML media type, the static layer should defer to SSR so the + * content-negotiation middleware can rewrite to the matching `.md` (or other) + * resource. + * + * Returns `true` only when the client *explicitly* prefers a non-HTML media + * type over `text/html` and `* /*`. Browsers (which list `text/html` and + * `* /*` and never `text/markdown`) always return `false`. + * + * Accepts: + * - a WHATWG Request (`request.headers.get("accept")`) + * - a Node IncomingMessage (`request.headers.accept`) + * - a plain string (the raw Accept header value) + */ +export function shouldDeferToServer(request) { + const accept = readAccept(request); + if (!accept) return false; + + let htmlQ = -1; + let anyQ = -1; + let bestNonHtmlQ = -1; + + for (const part of accept.split(",")) { + const semi = part.indexOf(";"); + const type = (semi === -1 ? part : part.slice(0, semi)) + .trim() + .toLowerCase(); + const params = semi === -1 ? "" : part.slice(semi + 1); + const q = parseQ(params); + if (q <= 0) continue; + if (type === "text/html" || type === "application/xhtml+xml") { + htmlQ = Math.max(htmlQ, q); + } else if (type === "*/*" || type === "text/*") { + anyQ = Math.max(anyQ, q); + } else if (type && type.includes("/")) { + bestNonHtmlQ = Math.max(bestNonHtmlQ, q); + } + } + + // Defer only when a concrete non-HTML type is preferred *strictly above* + // both HTML and the catch-all `*/*`. Equality goes to the static layer + // (HTML), since that's the conventional default for tied weights. + if (bestNonHtmlQ < 0) return false; + return bestNonHtmlQ > htmlQ && bestNonHtmlQ > anyQ; +} + +/** + * Heuristic check: does this URL look like it maps to an HTML route? + * + * Adapters use this to bound the `shouldDeferToServer` deferral so that + * browser image / CSS / JSON requests (which legitimately prefer non-HTML + * media types) still hit the static layer. Only routes without an extension + * or with `.html`/`.htm` are considered HTML. + * + * Accepts a URL string, URL instance, or any object with a `.pathname`. + */ +export function isHtmlRoute(urlOrPath) { + const path = + typeof urlOrPath === "string" ? urlOrPath : (urlOrPath?.pathname ?? ""); + // Strip query/hash if a raw string was passed. + const clean = path.split("?")[0].split("#")[0]; + // No file extension on the last segment → treat as a route. + const lastSlash = clean.lastIndexOf("/"); + const last = lastSlash === -1 ? clean : clean.slice(lastSlash + 1); + const dot = last.lastIndexOf("."); + if (dot === -1) return true; + const ext = last.slice(dot + 1).toLowerCase(); + return ext === "html" || ext === "htm"; +} + +function readAccept(request) { + if (!request) return ""; + if (typeof request === "string") return request; + // WHATWG Headers (Request, Headers instance) + if (typeof request.headers?.get === "function") { + return request.headers.get("accept") ?? ""; + } + // Node IncomingMessage / object headers + if (request.headers && typeof request.headers === "object") { + const v = request.headers.accept ?? request.headers.Accept; + return Array.isArray(v) ? v.join(", ") : (v ?? ""); + } + return ""; +} + +function parseQ(params) { + if (!params) return 1; + for (const p of params.split(";")) { + const trimmed = p.trim(); + if (trimmed.toLowerCase().startsWith("q=")) { + const n = parseFloat(trimmed.slice(2)); + return Number.isFinite(n) ? n : 1; + } + } + return 1; +} diff --git a/packages/react-server/adapters/vercel/index.mjs b/packages/react-server/adapters/vercel/index.mjs index d1d2ad6e..8ebb1641 100644 --- a/packages/react-server/adapters/vercel/index.mjs +++ b/packages/react-server/adapters/vercel/index.mjs @@ -75,6 +75,30 @@ export const adapter = createAdapter({ "Content-Type": "text/x-component; charset=utf-8", }, }, + // Bare paths (no file extension) where the client did *not* list + // `text/html` in its `Accept` header are routed to the function + // before the filesystem so content-negotiation middleware can + // respond with the matching variant for the same canonical URL. + // Browsers always include `text/html`/`application/xhtml+xml`, so + // their navigation hits the static layer untouched. Static asset + // requests (paths with extensions like `.css`, `.png`, `.json`) + // are excluded by the `src` pattern and continue to be served + // directly by Vercel's CDN. + ...(adapterOptions?.serverlessFunctions !== false + ? [ + { + src: "^/[^.]*$", + has: [ + { + type: "header", + key: "accept", + value: "^(?!.*text/html).+", + }, + ], + dest: "/", + }, + ] + : []), { handle: "filesystem" }, ...(adapterOptions?.routes ?? []), adapterOptions?.routes?.find((route) => route.status === 404) ?? { diff --git a/packages/react-server/lib/build/edge.mjs b/packages/react-server/lib/build/edge.mjs index 62c1c0de..a39fff7a 100644 --- a/packages/react-server/lib/build/edge.mjs +++ b/packages/react-server/lib/build/edge.mjs @@ -8,6 +8,7 @@ import { build as viteBuild } from "vite"; import { forRoot } from "../../config/index.mjs"; import { resolveTelemetryConfig } from "../../server/telemetry.mjs"; +import inlineCjsJson from "../plugins/inline-cjs-json.mjs"; import optionalDeps from "../plugins/optional-deps.mjs"; import * as sys from "../sys.mjs"; import customLogger from "./custom-logger.mjs"; @@ -44,7 +45,13 @@ export default async function edgeBuild(root, options) { logLevel: options.silent ? "silent" : undefined, define: config.define, json: { + // Default to named exports so individual JSON keys can be tree-shaken + // when imported as ESM. Allow user override (e.g. when bundling CJS + // dependencies that `require('foo.json')` and iterate `Object.keys` — + // rolldown's CJS interop adds `__esModule` to the exports under + // `namedExports: true`, breaking iteration in those consumers). namedExports: true, + ...config.json, }, envDir: config.envDir, envPrefix: @@ -183,6 +190,7 @@ export default async function edgeBuild(root, options) { return false; }, plugins: [ + inlineCjsJson(), optionalDeps([/^@opentelemetry\//], { forceEmpty: otelForceEmpty }), replace({ preventAssignment: true, diff --git a/packages/react-server/lib/build/server.mjs b/packages/react-server/lib/build/server.mjs index 7ea26e8c..3b9b0180 100644 --- a/packages/react-server/lib/build/server.mjs +++ b/packages/react-server/lib/build/server.mjs @@ -15,6 +15,7 @@ import resourcesPlugin from "../plugins/resources.mjs"; import optionalDeps from "../plugins/optional-deps.mjs"; import fixEsbuildOptionsPlugin from "../plugins/fix-esbuildoptions.mjs"; import importRemotePlugin from "../plugins/import-remote.mjs"; +import inlineCjsJson from "../plugins/inline-cjs-json.mjs"; import reactServerEval from "../plugins/react-server-eval.mjs"; import reactServerRuntime from "../plugins/react-server-runtime.mjs"; @@ -851,6 +852,7 @@ export default async function serverBuild(root, options, clientManifestBus) { fileListingReporterPlugin("RSC"), manifestRegistry(), manifestGenerator(clientManifest, serverManifest), + inlineCjsJson(), jsonNamedExports(), resourcesPlugin(), !root || root === "@lazarv/react-server/file-router" diff --git a/packages/react-server/lib/dev/ssr-handler.mjs b/packages/react-server/lib/dev/ssr-handler.mjs index 0d9bf160..e5c33ce2 100644 --- a/packages/react-server/lib/dev/ssr-handler.mjs +++ b/packages/react-server/lib/dev/ssr-handler.mjs @@ -38,6 +38,7 @@ import { SERVER_CONTEXT, STYLES_CONTEXT, } from "../../server/symbols.mjs"; +import { mergeContextHeaders } from "../http/middleware-response.mjs"; import * as sys from "../sys.mjs"; import errorHandler from "../handlers/error.mjs"; import getModules from "./modules.mjs"; @@ -258,9 +259,15 @@ export default async function ssrHandler(root) { for (const middleware of middlewares) { const response = await middleware(httpContext); if (response) { - return typeof response === "function" - ? await response(httpContext) - : response; + const final = + typeof response === "function" + ? await response(httpContext) + : response; + // Merge headers set by earlier middlewares (via + // setHeader / appendHeader / headers()) into the + // short-circuit Response — see ssrHandler in + // start/ssr-handler.mjs for the rationale. + return mergeContextHeaders(final); } } } diff --git a/packages/react-server/lib/handlers/static.mjs b/packages/react-server/lib/handlers/static.mjs index f40f0220..3645f120 100644 --- a/packages/react-server/lib/handlers/static.mjs +++ b/packages/react-server/lib/handlers/static.mjs @@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url"; import mime from "mime"; +import { shouldDeferToServer } from "../../adapters/shared/accept.mjs"; import { prerender$ } from "../../server/prerender-storage.mjs"; import { POSTPONE_STATE, @@ -121,6 +122,19 @@ export default async function staticHandler(dir, options = {}) { } } + // Defer to the SSR handler when the client clearly prefers a non-HTML + // media type and the static reply would be HTML. This lets + // content-negotiation middleware rewrite to the matching `.md` (or other) + // pre-rendered variant for the same canonical URL. Browsers, which + // always list `text/html` / `*/*`, still get the static HTML. + const candidate = files.get(basename); + if ( + candidate?.mime === "text/html" && + shouldDeferToServer(context.request) + ) { + return; + } + let prelude = null; r = exists(`${basename}.postponed.json`); if (settled(r) ?? (await r)) { diff --git a/packages/react-server/lib/http/middleware-response.mjs b/packages/react-server/lib/http/middleware-response.mjs new file mode 100644 index 00000000..ccd22760 --- /dev/null +++ b/packages/react-server/lib/http/middleware-response.mjs @@ -0,0 +1,32 @@ +import { getContext } from "../../server/context.mjs"; +import { HTTP_HEADERS } from "../../server/symbols.mjs"; + +/** + * Merge headers set via `setHeader` / `appendHeader` / `headers()` (stored on + * the HTTP_HEADERS context) into a `Response` returned by a short-circuiting + * middleware. + * + * Without this, headers set by an earlier middleware in the chain would be + * silently dropped when a later middleware returned a Response directly — + * e.g. an `agent-discovery` middleware that sets `Link` would lose its work + * if a `content-negotiation` middleware later returned a `Response` for the + * same request. + * + * The Response's own headers win on conflict — middlewares that explicitly + * set Content-Type, Cache-Control, etc. on their Response keep authority + * over those values. + */ +export function mergeContextHeaders(response) { + const httpHeaders = getContext(HTTP_HEADERS); + if (!httpHeaders || !response || !(response instanceof Response)) { + return response; + } + const merged = new Headers(); + for (const [k, v] of httpHeaders) merged.set(k, v); + for (const [k, v] of response.headers) merged.set(k, v); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: merged, + }); +} diff --git a/packages/react-server/lib/plugins/inline-cjs-json.mjs b/packages/react-server/lib/plugins/inline-cjs-json.mjs new file mode 100644 index 00000000..fddd8247 --- /dev/null +++ b/packages/react-server/lib/plugins/inline-cjs-json.mjs @@ -0,0 +1,72 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve as resolvePath } from "node:path"; + +/** + * Vite plugin: inline static `require('./*.json')` calls in CJS source + * files with the literal JSON content. + * + * The bundler's CJS-from-ESM interop wraps imported JSON as + * `{ __esModule: true, default: }` and never unwraps it back to + * the raw object. CJS consumers that do + * + * var data = require('./foo.json'); + * Object.keys(data).forEach(k => data[k].toLowerCase()); + * + * (`statuses`, `mime-types`, `finalhandler`, `http-errors`, …) then iterate + * the wrapper and crash on the boolean `__esModule`. + * + * Pre-inlining the JSON sidesteps the wrapper entirely — by the time + * rolldown sees the module, the `require('./foo.json')` has been replaced + * by the JSON expression itself, so there's no JSON module to wrap. + * + * Scope: + * - Only static `require('./literal/path.json')` calls. + * - Only `.js` / `.cjs` / `.cts` files (skips ESM, TS-as-ESM, virtual modules). + * - Dynamic / computed requires, package imports, and JSON files imported + * via ESM `import` are untouched (Vite's JSON plugin handles those). + */ +export default function inlineCjsJson() { + // `require('./X.json')` or `require("../X.json")`. Path must start with `./` + // or `../` (relative) and end in `.json`. Conservative on purpose — anything + // unusual falls through to the bundler's normal handling. + const REQUIRE_JSON = /\brequire\s*\(\s*(['"])(\.\.?\/[^'"`]+\.json)\1\s*\)/g; + + return { + name: "react-server:inline-cjs-json", + enforce: "pre", + transform(code, id) { + // Skip virtual / query-suffixed / non-JS modules early. + if (!id || id.includes("?") || id.includes("\0")) return null; + if (!/\.(c?js|cts)$/.test(id)) return null; + // Cheap fast-path before running the regex globally. + if (!code.includes("require(")) return null; + if (!code.includes(".json")) return null; + + REQUIRE_JSON.lastIndex = 0; + if (!REQUIRE_JSON.test(code)) return null; + REQUIRE_JSON.lastIndex = 0; + + const dir = dirname(id); + let mutated = false; + const out = code.replace(REQUIRE_JSON, (match, _q, relPath) => { + try { + const jsonPath = resolvePath(dir, relPath); + const raw = readFileSync(jsonPath, "utf-8"); + // Validate; bail on invalid JSON rather than corrupting the source. + JSON.parse(raw); + mutated = true; + // Wrap in parens so it parses as an expression in any context + // (assignment RHS, argument position, …). + return `(${raw})`; + } catch { + return match; + } + }); + + if (!mutated) return null; + // Returning a null sourcemap is fine — these CJS dependencies are + // already minified/transpiled and we don't ship sourcemaps for them. + return { code: out, map: null }; + }, + }; +} diff --git a/packages/react-server/lib/start/ssr-handler.mjs b/packages/react-server/lib/start/ssr-handler.mjs index d5a54c27..367d3087 100644 --- a/packages/react-server/lib/start/ssr-handler.mjs +++ b/packages/react-server/lib/start/ssr-handler.mjs @@ -55,6 +55,7 @@ import { STYLES_CONTEXT, WORKER_THREAD, } from "../../server/symbols.mjs"; +import { mergeContextHeaders } from "../http/middleware-response.mjs"; import * as sys from "../sys.mjs"; globalThis.AsyncLocalStorage = AsyncLocalStorage; @@ -424,11 +425,17 @@ export default async function ssrHandler(root, options = {}) { for (const middleware of middlewares) { const response = await middleware(httpContext); if (response) { - return resolve( + const final = typeof response === "function" ? await response(httpContext) - : response - ); + : response; + // Merge headers set by earlier middlewares (via + // setHeader / appendHeader / headers()) into the + // short-circuit Response. Without this, common + // discovery headers like `Link` set by an earlier + // middleware would be silently dropped when a later + // middleware returned a Response directly. + return resolve(mergeContextHeaders(final)); } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ab3c446..68d4a07a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: vite-plugin-svgr: specifier: ^4.5.0 version: 4.5.0(rollup@4.53.2)(typescript@5.9.3)(vite@8.0.10(@types/node@24.9.2)(jiti@2.6.1)(less@4.2.0)(sass@1.86.0)(stylus@0.62.0)(terser@5.37.0)(yaml@2.5.0)) + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@inlang/paraglide-js': specifier: 1.11.8