feat: agent ready docs#410
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
react-server-docs | 8391d19 | Apr 28 2026, 11:10 PM |
⚡ Flight Protocol BenchmarkCommit: Serialization (
|
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 264.3K | 45.8K | 🟢 +477.6% |
| react: shallow wide (1000) | 2.7K | 447 | 🟢 +506.5% |
| react: deep nested (100) | 21.8K | 7.9K | 🟢 +176.9% |
| react: product list (50) | 8.4K | 2.6K | 🟢 +227.7% |
| react: large table (500x10) | 341 | 106 | 🟢 +222.5% |
| data: primitives | 227.9K | 49.6K | 🟢 +359.9% |
| data: large string (100KB) | 9.6K | 8.8K | 🟢 +9.6% |
| data: nested objects (20) | 79.1K | 35.3K | 🟢 +123.9% |
| data: large array (10K) | 151 | 158 | 🔴 -4.6% |
| data: Map & Set | 14.7K | 7.6K | 🟢 +92.9% |
| data: Date/BigInt/Symbol | 212.1K | 46.9K | 🟢 +351.9% |
| data: typed arrays | 46.5K | 16.7K | 🟢 +178.4% |
| data: mixed payload | 11.9K | 5.2K | 🟢 +130.4% |
Prerender (prerender)
| Scenario | @lazarv/rsc ops/s | mean |
|---|---|---|
| react: minimal element | 310.0K | 3.2 µs |
| react: shallow wide (1000) | 2.6K | 381.3 µs |
| react: deep nested (100) | 20.1K | 49.9 µs |
| react: product list (50) | 7.8K | 127.8 µs |
| react: large table (500x10) | 337 | 2.97 ms |
| data: primitives | 241.9K | 4.1 µs |
| data: large string (100KB) | 845 | 1.18 ms |
| data: nested objects (20) | 78.3K | 12.8 µs |
| data: large array (10K) | 156 | 6.41 ms |
| data: Map & Set | 15.4K | 64.9 µs |
| data: Date/BigInt/Symbol | 234.8K | 4.3 µs |
| data: typed arrays | 851 | 1.17 ms |
| data: mixed payload | 10.8K | 92.9 µs |
Deserialization (createFromReadableStream)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 220.6K | 194.9K | 🟢 +13.2% |
| react: shallow wide (1000) | 24.1K | 2.1K | 🟢 +1070.5% |
| react: deep nested (100) | 119.8K | 25.5K | 🟢 +369.9% |
| react: product list (50) | 61.3K | 16.4K | 🟢 +273.4% |
| react: large table (500x10) | 4.8K | 2.8K | 🟢 +73.1% |
| data: primitives | 174.4K | 169.9K | 🟢 +2.7% |
| data: large string (100KB) | 46.5K | 46.9K | ⚪ -0.9% |
| data: nested objects (20) | 104.6K | 91.8K | 🟢 +13.9% |
| data: large array (10K) | 363 | 320 | 🟢 +13.5% |
| data: Map & Set | 21.4K | 19.2K | 🟢 +11.1% |
| data: Date/BigInt/Symbol | 177.8K | 155.7K | 🟢 +14.2% |
| data: typed arrays | 74.3K | 52.9K | 🟢 +40.4% |
| data: mixed payload | 28.8K | 19.4K | 🟢 +48.9% |
Roundtrip (serialize + deserialize)
| Scenario | @lazarv/rsc | webpack | vs webpack |
|---|---|---|---|
| react: minimal element | 157.8K | 34.4K | 🟢 +358.5% |
| react: shallow wide (1000) | 2.4K | 365 | 🟢 +550.7% |
| react: deep nested (100) | 19.2K | 5.8K | 🟢 +233.7% |
| react: product list (50) | 7.4K | 2.2K | 🟢 +238.4% |
| react: large table (500x10) | 334 | 101 | 🟢 +230.8% |
| data: primitives | 122.1K | 47.7K | 🟢 +156.1% |
| data: large string (100KB) | 8.5K | 8.9K | 🔴 -4.6% |
| data: nested objects (20) | 49.6K | 29.1K | 🟢 +70.3% |
| data: large array (10K) | 110 | 109 | ⚪ +1.0% |
| data: Map & Set | 9.0K | 5.7K | 🟢 +56.7% |
| data: Date/BigInt/Symbol | 119.9K | 44.9K | 🟢 +167.2% |
| data: typed arrays | 36.8K | 14.7K | 🟢 +150.4% |
| data: mixed payload | 8.7K | 4.3K | 🟢 +100.0% |
Legend & methodology
Indicators: 🟢 > 1% faster | 🔴 > 1% slower | ⚪ within noise margin
vs webpack: compares @lazarv/rsc against react-server-dom-webpack within the same run.
vs baseline: compares @lazarv/rsc against the previous main branch run.
Values shown are operations/second (higher is better). Each scenario runs for at least 100 iterations with warmup.
Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple scenarios are more meaningful than any single number.
❌ 1 Tests Failed:
View the top 3 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
⚡ Benchmark Results
Legend🟢 > 1% improvement | 🔴 > 1% regression | ⚪ within noise margin Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple routes are more meaningful than any single number. |
Summary
Brings the docs site to the public agent-readiness bar (level 0 → level 5 on
isitagentready.com) and ships the runtime support that other react-server applications need to do the same. Closes the gap that any pre-rendered react-server site has today: there is no way for an agent asking forAccept: text/markdownon the canonical URL to receive markdown, because the static layer always wins.The work splits cleanly into three buckets. The docs site grows a set of agent-facing surfaces (well-known endpoints, link headers, content negotiation, an MCP server). The runtime grows the primitives those surfaces depend on (
Accept-aware static deferral across every adapter, header propagation across short-circuiting middlewares, a generic fix for rolldown's CJS-imports-JSON wrapping bug). The same primitives let any other application following this PR's patterns reach the same readiness level with a few hundred lines of glue.Runtime changes
Accept-aware static deferral
Every adapter in
@lazarv/react-serverpreviously gave static files unconditional priority over the worker. That makes browsers fast — and silently breaks content negotiation, because middleware never runs for paths whose pre-rendered HTML happens to match. This PR adds a sharedshouldDeferToServer(request)/isHtmlRoute(url)pair underadapters/shared/accept.mjsand applies them at every static-first site so that browser navigation and asset traffic keep the fast path while agent traffic withAccept: text/markdown(or any concrete non-HTML media type) flows to the worker.The Cloudflare adapter checks both predicates before calling
env.ASSETS.fetch. The Node-mode static handler inlib/handlers/static.mjsnow defers when it would have servedtext/htmland the client clearly prefers something else — covering Bun, Deno, Docker, Azure Functions, AWS Lambda, Firebase, and the singlefile adapter at once. The Vercel adapter gains ahas-conditioned route that runs before{ handle: "filesystem" }and routes bare paths whoseAcceptheader omitstext/htmlto the function. The Netlify adapter drops the unconditionalpreferStatic: truefrom the function config so the in-process static handler can do the per-request decision; users who don't need content negotiation can opt back into the fast path viaadapterOptions.functions.config.preferStatic = true. AWS Lambda'stryServeStaticand Docker's bespoke static-first server gain the sameisHtmlRoute && shouldDeferToServerguard.The deferral predicate is deliberately narrow: it requires the URL to look like an HTML route (no extension, or
.html/.htm) and the client to explicitly prefer a concrete non-HTML media type at higher q-value thantext/htmland*/*. Browsers always listtext/htmland never trigger it; image/CSS/JSON requests are excluded by the URL-extension filter;curlwith the default*/*is treated as "anything is fine" and gets the static reply.Header propagation across short-circuiting middlewares
react-server now supports running multiple middlewares in sequence — for example, an
agent-discoverymiddleware that sets aLinkheader followed by acontent-negotiationmiddleware that returns aResponsedirectly. The old behaviour silently dropped headers set on the HTTP context whenever a later middleware short-circuited. A new shared helper atlib/http/middleware-response.mjs#mergeContextHeadersis now invoked from bothlib/start/ssr-handler.mjsandlib/dev/ssr-handler.mjsand mergessetHeader/appendHeader/headers()output onto the returnedResponse(theResponse's own headers win on conflict, so middlewares that explicitly set Content-Type or Cache-Control retain authority).Generic fix for the rolldown CJS-imports-JSON wrapping bug
@lazarv/react-server/mcppulls in@modelcontextprotocol/sdk, which transitively pulls in Express's CJS dependency tree (statuses,mime-types,finalhandler,http-errors). Several of those packages dovar data = require('./codes.json')and then iterateObject.keys(data).forEach(k => data[k].toLowerCase()). The bundler's CJS-from-ESM interop wraps imported JSON as{ __esModule: true, default: <getter> }and never unwraps it back, so the iteration crashes on the boolean__esModulewithr.toLowerCase is not a function. The bug reproduces inexamples/mcponmainand is the reason any react-server project trying to host an MCP server has been broken in production.Docs site changes
Discovery surface
/robots.txtis now a proper file with aUser-agent: *block, aContent-Signal: search=yes, ai-input=yes, ai-train=yesdirective, and the sitemap. As an open-source documentation site,react-server.devwants to be indexed and used by AI tools — the signal is intentionally permissive.A new
(agent-discovery).middleware.mjsserves four well-known endpoints with the right content types:/.well-known/api-catalog(RFC 9727 linkset format),/.well-known/agent-skills/index.json(Agent Skills v0.2 index),/.well-known/agent-skills/react-server/SKILL.md(the canonical skill body, imported via?rawfrom the monorepo'sskills/react-server/SKILL.md), and/.well-known/mcp/server-card.json. The same middleware sets RFC 8288Linkheaders on every documentation page advertising the api-catalog, the MCP endpoint, llms.txt, the sitemap, and the agent-skills index.A new
(content-negotiation).middleware.mjshandlesAccept: text/markdownby rewriting requests to the existing/md/[...slug]route. The homepage has no/md/slug entry, so it returns the canonicalllms.txtsummary astext/markdowninstead — that is the right "what is this site" answer for an agent landing at/.Vary: Acceptis set on the response so HTML and markdown caches don't poison each other.The original
(i18n).middleware.mjswas reduced back to pure locale resolution. With three middlewares now living side by side (alphabetically ordered: agent-discovery, content-negotiation, i18n), each file does one thing.MCP server and version source-of-truth
The docs site now hosts a live MCP server at
/mcp, dogfooding@lazarv/react-server/mcp. It exposes asearch_docstool (free-text query against the page index), aread_doctool (fetches any docs page as markdown via the public URL — works on every runtime including Cloudflare Workers without filesystem access), a templateddocs-pageresource that lists every page, and anexplain-topicprompt that orchestrates the two tools.A new
docs/src/version.mjsstrips thereact-server/prefix off the package's namespaced version export, giving callers a clean semver. The/mcpserver, the MCP server card, and the agent-skills index now all read from this single source — there is no longer a hardcoded1.0.0to drift out of sync with the package.WebMCP browser tools
A
"use client"WebMCP.jsxcomponent mounts once in the root layout and registerssearch_docsandget_docs_pagevianavigator.modelContext.registerTool. Any in-browser agent (Claude, Cursor, ChatGPT Atlas, Cloudflare Browser-Use) interacting with a docs page can now search the docs and fetch any page as markdown without scraping HTML.