6f0104d6d3fddocs/public/_headers+13 -0docs/react-server.wrangler.toml+40 -1docs/src/pages/(agent-discovery).middleware.mjs+51 -13docs/src/pages/en/(pages)/deploy/cloudflare.mdx+39 -06f0104d6d3fddocs/public/_headers+13 -0docs/react-server.wrangler.toml+40 -1docs/src/pages/(agent-discovery).middleware.mjs+51 -13docs/src/pages/en/(pages)/deploy/cloudflare.mdx+39 -0| 1 | + | # RFC 8288 discovery Link header for agent readiness (isitagentready.com, | |
| 2 | + | # SEP-1649 scanners, etc.). Cloudflare applies these to the pre-rendered | |
| 3 | + | # HTML pages served by the static-assets binding. Worker-generated responses | |
| 4 | + | # (the SSR / `Accept: text/markdown` branch) get the same Link header from | |
| 5 | + | # `(agent-discovery).middleware.mjs`. | |
| 6 | + | | |
| 7 | + | /* | |
| 8 | + | Link: </.well-known/api-catalog>; rel="api-catalog"; type="application/linkset+json" | |
| 9 | + | Link: </.well-known/mcp-server-card>; rel="service-meta"; type="application/json" | |
| 10 | + | Link: </mcp>; rel="service-meta"; type="application/json" | |
| 11 | + | Link: </llms.txt>; rel="describedby"; type="text/plain" | |
| 12 | + | Link: </sitemap.xml>; rel="sitemap"; type="application/xml" | |
| 13 | + | Link: </.well-known/agent-skills/index.json>; rel="https://agent-skills.dev/rel/index"; type="application/json" |
| 1 | 1 | [[routes]] | |
| 2 | 2 | pattern = "react-server.dev" | |
| 3 | - | custom_domain = true | |
| 3 | + | custom_domain = true | |
| 4 | + | | |
| 5 | + | # The docs site needs the worker to run before assets on HTML routes so the | |
| 6 | + | # content-negotiation middleware can serve the pre-rendered `.md` sibling | |
| 7 | + | # when an agent sends `Accept: text/markdown`. Without this, Cloudflare's | |
| 8 | + | # default assets-first router would short-circuit pre-rendered HTML before | |
| 9 | + | # the middleware ever runs. | |
| 10 | + | # | |
| 11 | + | # Pattern form (vs the simpler `true`) excludes paths the worker has nothing | |
| 12 | + | # to do for: bundled assets, client modules, and any path with a static-file | |
| 13 | + | # extension. Those bypass the worker entirely and Cloudflare's `_headers` | |
| 14 | + | # rules still apply directly. URLs without an extension (`/`, | |
| 15 | + | # `/router/file-router`, `/.well-known/*`, `/mcp`, …) fall through to the | |
| 16 | + | # worker so content negotiation, the agent-discovery middleware, and SSR | |
| 17 | + | # routes keep working. | |
| 18 | + | [assets] | |
| 19 | + | run_worker_first = [ | |
| 20 | + | "/*", | |
| 21 | + | "!/assets/*", | |
| 22 | + | "!/client/*", | |
| 23 | + | "!/*.svg", | |
| 24 | + | "!/*.png", | |
| 25 | + | "!/*.jpg", | |
| 26 | + | "!/*.jpeg", | |
| 27 | + | "!/*.gif", | |
| 28 | + | "!/*.webp", | |
| 29 | + | "!/*.ico", | |
| 30 | + | "!/*.css", | |
| 31 | + | "!/*.js", | |
| 32 | + | "!/*.mjs", | |
| 33 | + | "!/*.json", | |
| 34 | + | "!/*.xml", | |
| 35 | + | "!/*.txt", | |
| 36 | + | "!/*.woff", | |
| 37 | + | "!/*.woff2", | |
| 38 | + | "!/*.ttf", | |
| 39 | + | "!/*.eot", | |
| 40 | + | "!/*.html", | |
| 41 | + | "!/*.md", | |
| 42 | + | ] |
| 36 | 36 | ], | |
| 37 | 37 | describedby: [ | |
| 38 | 38 | { | |
| 39 | - | href: `${SITE}/.well-known/mcp/server-card.json`, | |
| 39 | + | href: `${SITE}/.well-known/mcp-server-card`, | |
| 40 | 40 | type: "application/json", | |
| 41 | 41 | }, | |
| 42 | 42 | ], |
| 69 | 69 | ], | |
| 70 | 70 | }; | |
| 71 | 71 | | |
| 72 | + | // MCP Server Card per SEP-1649 / PR #2127. | |
| 73 | + | // https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127 | |
| 74 | + | // | |
| 75 | + | // `name` MUST be reverse-DNS with exactly one `/` separating namespace and | |
| 76 | + | // server. `remotes[]` is the spec field for HTTP transports. We additionally | |
| 77 | + | // emit the legacy `serverInfo` / `transport` / `capabilities` keys that | |
| 78 | + | // pre-SEP scanners (e.g. isitagentready.com) still look for, so the card | |
| 79 | + | // validates against both readers without forcing either to upgrade. | |
| 72 | 80 | const mcpServerCard = { | |
| 73 | 81 | $schema: | |
| 74 | - | "https://modelcontextprotocol.io/schemas/draft/2025-09-29/server-card.json", | |
| 75 | - | name: "react-server-docs", | |
| 76 | - | title: "@lazarv/react-server Documentation", | |
| 77 | - | description: | |
| 78 | - | "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.", | |
| 82 | + | "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json", | |
| 83 | + | name: "dev.react-server/docs", | |
| 79 | 84 | version, | |
| 80 | - | homepage: SITE, | |
| 81 | - | documentation: `${SITE}/features/mcp`, | |
| 82 | - | endpoints: { | |
| 83 | - | streamable_http: `${SITE}/mcp`, | |
| 85 | + | description: | |
| 86 | + | "Search and read the @lazarv/react-server documentation as Model Context Protocol resources and tools. Provides search_docs and read_doc tools, exposes every documentation page as a markdown resource, and offers an explain-topic prompt.", | |
| 87 | + | title: "@lazarv/react-server Documentation", | |
| 88 | + | websiteUrl: SITE, | |
| 89 | + | repository: { | |
| 90 | + | url: "https://github.com/lazarv/react-server", | |
| 91 | + | source: "github", | |
| 92 | + | }, | |
| 93 | + | remotes: [ | |
| 94 | + | { | |
| 95 | + | type: "streamable-http", | |
| 96 | + | url: `${SITE}/mcp`, | |
| 97 | + | supportedProtocolVersions: ["2025-06-18", "2025-03-12", "2024-11-05"], | |
| 98 | + | }, | |
| 99 | + | ], | |
| 100 | + | // Legacy fields — kept for compatibility with pre-SEP readers that look for | |
| 101 | + | // `serverInfo.name`/`transport.type`/`capabilities` rather than the SEP-1649 | |
| 102 | + | // shape above. | |
| 103 | + | serverInfo: { | |
| 104 | + | name: "react-server-docs", | |
| 105 | + | version, | |
| 106 | + | }, | |
| 107 | + | transport: { | |
| 108 | + | type: "streamable-http", | |
| 109 | + | url: `${SITE}/mcp`, | |
| 84 | 110 | }, | |
| 85 | 111 | capabilities: { | |
| 86 | 112 | tools: { listChanged: false }, | |
| 87 | 113 | resources: { listChanged: false, subscribe: false }, | |
| 88 | 114 | prompts: { listChanged: false }, | |
| 89 | 115 | }, | |
| 90 | - | contact: { | |
| 91 | - | repository: "https://github.com/lazarv/react-server", | |
| 92 | - | }, | |
| 116 | + | documentation: `${SITE}/features/mcp`, | |
| 117 | + | }; | |
| 118 | + | | |
| 119 | + | // Discovery endpoints MUST be CORS-readable (RFC 8615 / SEP-1649 §CORS). | |
| 120 | + | const CORS_HEADERS = { | |
| 121 | + | "Access-Control-Allow-Origin": "*", | |
| 122 | + | "Access-Control-Allow-Methods": "GET", | |
| 123 | + | "Access-Control-Allow-Headers": "Content-Type", | |
| 93 | 124 | }; | |
| 94 | 125 | | |
| 95 | 126 | const wellKnown = { |
| 104 | 135 | headers: { | |
| 105 | 136 | "Content-Type": "text/markdown; charset=utf-8", | |
| 106 | 137 | "Cache-Control": "public, max-age=3600", | |
| 138 | + | ...CORS_HEADERS, | |
| 107 | 139 | }, | |
| 108 | 140 | }), | |
| 141 | + | // SEP-1649 canonical path. | |
| 142 | + | "/.well-known/mcp-server-card": () => json(mcpServerCard), | |
| 143 | + | // Legacy path that some early scanners (incl. isitagentready.com) still | |
| 144 | + | // probe — alias to keep both readers happy until the spec is final. | |
| 109 | 145 | "/.well-known/mcp/server-card.json": () => json(mcpServerCard), | |
| 110 | 146 | }; | |
| 111 | 147 | |
| 114 | 150 | headers: { | |
| 115 | 151 | "Content-Type": contentType, | |
| 116 | 152 | "Cache-Control": "public, max-age=3600", | |
| 153 | + | ...CORS_HEADERS, | |
| 117 | 154 | }, | |
| 118 | 155 | }); | |
| 119 | 156 | } |
| 128 | 165 | | |
| 129 | 166 | const linkHeader = [ | |
| 130 | 167 | '</.well-known/api-catalog>; rel="api-catalog"; type="application/linkset+json"', | |
| 168 | + | '</.well-known/mcp-server-card>; rel="service-meta"; type="application/json"', | |
| 131 | 169 | '</mcp>; rel="service-meta"; type="application/json"', | |
| 132 | 170 | '</llms.txt>; rel="describedby"; type="text/plain"', | |
| 133 | 171 | '</sitemap.xml>; rel="sitemap"; type="application/xml"', |
| 68 | 68 | - `serverlessFunctions`: Enable/disable worker deployment (default: true). Set to `false` for static-only deployment. | |
| 69 | 69 | - `wrangler`: Additional wrangler.toml configuration as an object (merged with adapter defaults). | |
| 70 | 70 | | |
| 71 | + | <Link name="response-headers"> | |
| 72 | + | ## Response headers | |
| 73 | + | </Link> | |
| 74 | + | | |
| 75 | + | To attach response headers to pre-rendered (ASSETS-served) pages — RFC 8288 `Link` headers for agent discovery, security headers, custom cache directives — drop a [`_headers`](https://developers.cloudflare.com/workers/static-assets/headers/) file into your `public/` directory. The runtime copies it to the static asset bundle at build time and Cloudflare applies it natively: | |
| 76 | + | | |
| 77 | + | ```text filename="public/_headers" | |
| 78 | + | /* | |
| 79 | + | Link: </.well-known/api-catalog>; rel="api-catalog"; type="application/linkset+json" | |
| 80 | + | | |
| 81 | + | /secure/* | |
| 82 | + | X-Frame-Options: DENY | |
| 83 | + | Referrer-Policy: no-referrer | |
| 84 | + | ``` | |
| 85 | + | | |
| 86 | + | This works even when [`run_worker_first`](#run-worker-first) is enabled, as long as the worker proxies the request via `env.ASSETS.fetch()` (which the built-in adapter does) — the response is generated by the static-assets binding, not by the worker, so `_headers` rules are preserved end-to-end. | |
| 87 | + | | |
| 88 | + | For headers that should also appear on **worker-generated** responses (SSR pages, content-negotiation responses, server functions, …) set them inside your middleware with `setHeader` from `@lazarv/react-server`. The `_headers` file does not apply to worker-generated responses. | |
| 89 | + | | |
| 90 | + | <Link name="run-worker-first"> | |
| 91 | + | ## Worker-first request handling | |
| 92 | + | </Link> | |
| 93 | + | | |
| 94 | + | By default Cloudflare serves static assets directly and only invokes your worker when no asset matches. If you need the worker to run **before** static assets — for example to do content negotiation between an HTML page and its `.md` sibling, set custom redirects, or apply auth in front of pre-rendered pages — enable `run_worker_first` in `react-server.wrangler.toml`: | |
| 95 | + | | |
| 96 | + | ```toml filename="react-server.wrangler.toml" | |
| 97 | + | [assets] | |
| 98 | + | run_worker_first = true | |
| 99 | + | ``` | |
| 100 | + | | |
| 101 | + | The setting accepts either a boolean or an array of glob patterns (with `!` for exclusion) to scope worker-first handling to specific routes: | |
| 102 | + | | |
| 103 | + | ```toml filename="react-server.wrangler.toml" | |
| 104 | + | [assets] | |
| 105 | + | run_worker_first = ["/*", "!/assets/*", "!/client/*"] | |
| 106 | + | ``` | |
| 107 | + | | |
| 108 | + | The built-in worker proxies non-deferred requests through `env.ASSETS.fetch()`, so [`_headers`](#response-headers) continues to apply to those responses even with `run_worker_first` enabled. Worker-generated responses (SSR / content-negotiated alternatives) need to set their own headers from middleware. | |
| 109 | + | | |
| 71 | 110 | <Link name="extending-wrangler"> | |
| 72 | 111 | ## Extending Wrangler configuration | |
| 73 | 112 | </Link> |
packages/react-server/adapters/cloudflare/index.mjs+0 -8| 87 | 87 | assets: { | |
| 88 | 88 | directory: ".cloudflare/static", | |
| 89 | 89 | binding: "ASSETS", | |
| 90 | - | // Make the worker the first thing every request hits. The worker | |
| 91 | - | // then calls `env.ASSETS.fetch()` itself, which lets it skip the | |
| 92 | - | // assets binding for HTML routes whose client clearly prefers a | |
| 93 | - | // non-HTML media type (e.g. agents asking for `Accept: text/markdown`) | |
| 94 | - | // so content-negotiation middleware can serve the matching variant. | |
| 95 | - | // Without this, Cloudflare's default "assets-first" mode would | |
| 96 | - | // short-circuit pre-rendered HTML before the worker ever runs. | |
| 97 | - | run_worker_first: true, | |
| 98 | 90 | ...adapterOptions?.assets, | |
| 99 | 91 | }, | |
| 100 | 92 | ...(options.sourcemap ? { upload_source_maps: true } : {}), |
packages/react-server/adapters/cloudflare/worker/edge.mjs+4 -0| 31 | 31 | // returns the 404 page with a text/html body, breaking every CSS, | |
| 32 | 32 | // image, and JS module the moment the browser starts revalidating. | |
| 33 | 33 | if (assetResponse.status !== 404) { | |
| 34 | + | // Note: response headers set by Cloudflare's `_headers` file are | |
| 35 | + | // preserved here — this is a worker-proxied response, not a | |
| 36 | + | // worker-generated one (the Cloudflare docs warning about | |
| 37 | + | // `_headers` being bypassed only applies to the latter). | |
| 34 | 38 | return assetResponse; | |
| 35 | 39 | } | |
| 36 | 40 | } catch { |