@rsc-kit/mcp 0.17.0 → 0.18.1

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.
@@ -87,3 +87,25 @@ jar.set('name', 'value', {
87
87
 
88
88
  Names are validated as cookie tokens and `sameSite` / `expires` are checked, so
89
89
  a typo is an error rather than a header the browser quietly ignores.
90
+
91
+ ## What a response says about itself
92
+
93
+ Two things, and they are not the same kind:
94
+
95
+ **How it was served**, always: `X-RSC-Kit: stored`, `rendered` or `shell` on
96
+ every response — a page from a file the build wrote, a page rendered for this
97
+ visitor, or a stored shell with its holes rendered now. It is the header a
98
+ developer reads in the Network tab when a page is slower than expected, the
99
+ way `X-Nextjs-Cache` is, and a CDN rule or a health check can key on it. It
100
+ names no product, so there is nothing to strip.
101
+
102
+ **What built it**, by default: `X-Powered-By: rsc-kit` on every response and
103
+ `<meta name="generator" content="rsc-kit">` in every document — what
104
+ BuiltWith and Wappalyzer read. The name only, never the version: a version in
105
+ every response is what a vulnerability scanner filters on. A policy that
106
+ strips every framework identifier turns both off, which matters for the tag,
107
+ since a proxy can strip a header but not a line inside the HTML:
108
+
109
+ ```ts title="vite.config.ts"
110
+ rscKit({ identify: false })
111
+ ```
@@ -62,6 +62,51 @@ A relative url in any of them is made absolute with the root layout's
62
62
  `metadataBase`; without one, a relative url is a build error that says so.
63
63
  Any of the three may return a string instead, served as written.
64
64
 
65
+ ## A sitemap you do not write
66
+
67
+ With no `sitemap.ts`, the build writes `/sitemap.xml` itself, from what it
68
+ already knows: every page it stored and every url `generateStaticParams`
69
+ listed, each with the build as `lastModified`. Left out: anything under a
70
+ `middleware.ts` (a guard means not for everyone), a page the build could not
71
+ render, and the not-found page. The build's table says so:
72
+
73
+ ```
74
+ ○ /sitemap.xml
75
+ written by the build: 5 urls; a sitemap.ts beside the root layout replaces it
76
+ ```
77
+
78
+ It needs the root layout's `metadataBase` for the host; without one the line
79
+ says it was not written. A `sitemap.ts` replaces it entirely — the moment you
80
+ want a page the build cannot see (a post per database row without
81
+ `generateStaticParams`) or a `changeFrequency`, write one.
82
+
83
+ ## How fresh
84
+
85
+ Three choices, and the function decides — the same rule every route follows:
86
+
87
+ | you write | served | fresh |
88
+ | --- | --- | --- |
89
+ | nothing | the build's own sitemap, stored | every deploy |
90
+ | a `sitemap.ts` that reads the database | stored at build | every deploy |
91
+ | a `sitemap.ts` that awaits `connection()` | rendered per request | every crawl |
92
+
93
+ ```ts title="src/app/sitemap.ts"
94
+ import { connection } from '@rsc-kit/core/request';
95
+
96
+ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
97
+ await connection(); // per request — the same mark a page uses
98
+
99
+ const posts = await db.post.findMany({ select: { slug: true, updatedAt: true } });
100
+
101
+ return posts.map((post) => ({ url: `/blog/${post.slug}`, lastModified: post.updatedAt }));
102
+ }
103
+ ```
104
+
105
+ Without the `connection()` line the same function runs once at build and the
106
+ answer is stored; the build's table shows `○` for a stored one and `ƒ` for
107
+ one that runs per request. Reading the database at build is fine — it is the
108
+ request that makes a route dynamic, not the data.
109
+
65
110
  ## How they are served
66
111
 
67
112
  Each file becomes an api route, and that decides the rest. A `sitemap.ts`
package/guides/testing.md CHANGED
@@ -41,7 +41,7 @@ import { GET } from '../src/app/api/greet/[name]/route'
41
41
 
42
42
  test('greets by name', async () => {
43
43
  const res = await GET(new Request('https://app.test/api/greet/ada'), {
44
- params: Promise.resolve({ name: 'ada' }), // a promise, as the engine gives it
44
+ params: Promise.resolve({ name: 'ada' }), // a promise, as the engine gives it - RouteContext types it so
45
45
  })
46
46
 
47
47
  expect(await res.json()).toEqual({ greeting: 'Hello, ada' })
@@ -118,7 +118,9 @@ that never ran, a stored page that should not have been, a `404` that came back
118
118
 
119
119
  `createTestApp()` builds when your source is newer than the last build, and not
120
120
  otherwise — the first run pays for it, the rest do not, and an edit is picked
121
- up. It runs your own `vite build`, so what is tested is what ships. Pass
121
+ up. It runs your own `build` script `bun run build` under Bun, `npm run
122
+ build` under Node — so what is tested is what ships, on the runtime it ships
123
+ on. Pass
122
124
  `{ build: false }` in a ci step that already built.
123
125
 
124
126
  One build and one loaded module per test run, shared across files. That is both
@@ -146,3 +146,20 @@ For end-to-end types without a fetch at all, a [server action or
146
146
  query](/guides/queries/) is already typed across the boundary: the return type
147
147
  is the function's, because it is the same function.
148
148
  :::
149
+
150
+ ## Regions
151
+
152
+ `revalidate()` in an action and `refresh()` in the browser take a region by
153
+ name, and the names are typed to the ones the build found: every
154
+ `section('orders', …)` and every `@slot` directory, plus `'page'` and
155
+ `'all'`. A typo, or a section renamed since, stops compiling instead of being
156
+ refused by the renderer at runtime:
157
+
158
+ ```ts
159
+ revalidate('orders') // a section the build found
160
+ revalidate('order') // does not compile
161
+ ```
162
+
163
+ A name computed at runtime is cast, as an href is: `revalidate(name as
164
+ RevalidateTarget)`. A name that reaches the renderer unknown anyway is still
165
+ refused with the names it does know.
@@ -86,12 +86,16 @@ Plus the body, which is the one that matters for a `POST`:
86
86
 
87
87
  ```ts title="src/app/api/posts/[id]/route.ts"
88
88
  import { z } from 'zod'
89
+ import type { RouteContext } from '@rsc-kit/core/route-schema'
89
90
 
90
91
  export const params = z.object({ id: z.coerce.number().int() })
91
92
  export const searchParams = z.object({ fields: z.string().optional() })
92
93
  export const body = z.object({ title: z.string().min(1), draft: z.boolean().default(false) })
93
94
 
94
- export async function POST(request: Request, { params, body }) {
95
+ export async function POST(
96
+ request: Request,
97
+ { params, body }: RouteContext<typeof params, typeof searchParams, typeof body>,
98
+ ) {
95
99
  const { id } = await params // number
96
100
  const { title } = await body // non-empty string
97
101
 
@@ -99,7 +103,9 @@ export async function POST(request: Request, { params, body }) {
99
103
  }
100
104
  ```
101
105
 
102
- Awaited, the same way a page awaits its props. That is not only symmetry: a
106
+ `RouteContext` is `PageProps` for a route each schema's output behind a
107
+ promise, and with none, `RouteContext<'/api/posts/[id]'>` types `params` from
108
+ the segments. Awaited, the same way a page awaits its props. That is not only symmetry: a
103
109
  route that never awaits `searchParams` provably does not vary by it, so the
104
110
  build can store one answer and serve it for `?utm_source=anything`. Resolved
105
111
  eagerly, that fact is unknowable and every tracking link misses the stored
@@ -0,0 +1,168 @@
1
+ # Where it runs
2
+
3
+ > A host is a Nitro preset, not a server you write.
4
+
5
+ There used to be a page here for each runtime, and a server file generated to
6
+ match. There is no server file now, and no choice about it either:
7
+ [Nitro](https://nitro.build) builds the server around the route tree, and
8
+ choosing where an app runs is choosing a preset.
9
+
10
+ ```ts title="vite.config.ts"
11
+ import { defineConfig } from 'vite';
12
+ import { nitro } from 'nitro/vite';
13
+ import react from '@vitejs/plugin-react';
14
+ import { rscKit } from '@rsc-kit/core/vite';
15
+
16
+ export default defineConfig({
17
+ plugins: [
18
+ nitro({ preset: 'bun', serveStatic: 'inline' }),
19
+ rscKit(),
20
+ react(),
21
+ ],
22
+ });
23
+ ```
24
+
25
+ `bun create rsc-kit@latest my-app` writes that for you. Changing where it deploys is
26
+ changing the one string.
27
+
28
+ ## The presets
29
+
30
+ `rsc-kit` asks about three, because those are the ones it can check for you.
31
+ Nitro carries [many more](https://nitro.build/deploy) and they need nothing from
32
+ this package — a preset is a string, not an integration.
33
+
34
+ | `--host` | preset | what you get |
35
+ | --- | --- | --- |
36
+ | `bun` | `bun` | `.output/server/index.mjs`, and `bun run compile` for a single binary |
37
+ | `node` | `node` | `.output/server/index.mjs`, run with `node` |
38
+ | `worker` | `cloudflare_module` | a Worker, plus the `wrangler.json` and `_headers` Nitro generates |
39
+
40
+ Everything else — Vercel, Netlify, Azure, Deno, AWS Amplify — is the same
41
+ change:
42
+
43
+ ```ts
44
+ nitro({ preset: 'vercel', serveStatic: 'inline' })
45
+ ```
46
+
47
+ ## Running it
48
+
49
+ ```bash
50
+ npm run dev # vite is the renderer; nothing is prebuilt
51
+ npm run build # writes .output/
52
+ npm run start # runs .output/server/index.mjs
53
+ ```
54
+
55
+ A Worker has no `start`, because it is deployed rather than started:
56
+
57
+ ```bash
58
+ npm run preview # wrangler dev, on workerd
59
+ npm run deploy # nitro deploy --prebuilt
60
+ ```
61
+
62
+ ## Compiling to a single binary
63
+
64
+ Bun only, and the whole application ends up inside one file — engine, route
65
+ tree and assets:
66
+
67
+ ```bash
68
+ npm run compile # builds, then bun build --compile
69
+ ./dist/app
70
+ ```
71
+
72
+ It builds first on purpose. Compiling whatever `.output` happens to hold means
73
+ a binary one version behind the source with nothing to say so — and on a
74
+ project that has never been built, an `ENOENT` naming a path the app did not
75
+ write.
76
+
77
+ <Aside type="note" title="A Worker serves the frozen pages from a module">
78
+ A Worker has no filesystem, so the directory the build writes is not there
79
+ at request time. The build also writes the same pages as one module beside
80
+ the bundle, `rsc-static-inline.mjs`, which wrangler uploads with the rest
81
+ and the server imports when the directory is missing. Nothing to configure;
82
+ it is why `○` on a Worker means the stored page and not a live render that
83
+ happens to be the same. It counts toward the Worker's script size, which is
84
+ worth knowing on a site with hundreds of frozen pages.
85
+ </Aside>
86
+
87
+ <Aside type="note" title="Frozen pages stay outside the binary">
88
+ The build freezes pages into `.output/server/rsc-static` and the server reads
89
+ them from there. A compiled binary has no filesystem to read — the directory
90
+ is not embedded — so it renders those pages live instead. Everything still
91
+ answers; what you lose is the stored render, not the page.
92
+ </Aside>
93
+
94
+ <Aside type="caution" title="serveStatic: 'inline' is what makes this work">
95
+ Without it the binary compiles, starts, serves pages, and 404s every asset.
96
+ Inside a compiled binary the static path resolves into Bun's virtual
97
+ filesystem — where the files on disk are not — and the failure is
98
+ `ENOENT: /$bunfs/public/assets/…` with the pages themselves looking fine.
99
+
100
+ The generated config sets it. If you write your own, set it too.
101
+ </Aside>
102
+
103
+ ## Native dependencies stay outside the bundle
104
+
105
+ A package with a native binary — `sharp`, `bcrypt`, `better-sqlite3`,
106
+ `@prisma/client` — cannot be rolled into a server bundle: the build succeeds
107
+ and the server cannot load its own binary. The usual ones are left external
108
+ by default, and Nitro traces each into `.output/server/node_modules` with its
109
+ binaries, so the deployment is still one directory. For one that is not on
110
+ the list:
111
+
112
+ ```ts
113
+ rscKit({ serverExternalPackages: ['@acme/native-thing'] })
114
+ ```
115
+
116
+ ## Offline
117
+
118
+ `rscKit({ offline: true })` writes a service worker into `.output/public`
119
+ alongside the assets, so a page someone has visited survives a reload with no
120
+ network. Off by default, and covered in [Offline](/guides/offline#surviving-without-one).
121
+
122
+ ## Why `nitro` is pinned
123
+
124
+ The generated `package.json` pins an exact version rather than a range:
125
+
126
+ ```json
127
+ "nitro": "3.0.260903-beta"
128
+ ```
129
+
130
+ Nitro's own `latest` tag is a dated prerelease, and it sorts **above** the plain
131
+ `3.0.0` on npm. So `^3.0.0` resolves to the older release, which builds without
132
+ complaint and then answers 404 to every route. TanStack Start pins a dated beta
133
+ for the same reason.
134
+
135
+ ## Assets are Nitro's
136
+
137
+ The build writes browser assets to `.output/public`, and Nitro serves them from
138
+ its own root. There is no `assetsDir` or `assetsUrl` to set: both were removed,
139
+ and `rscKit()` refuses a config that still passes them rather than reading a
140
+ prefix and ignoring it.
141
+
142
+ The alternative was the silent version — the markup asks for the app's prefix,
143
+ Nitro answers at its own, and the page arrives unstyled and never hydrates with
144
+ nothing logged anywhere.
145
+
146
+ Assets live in `.output/public` and are served by the same process that serves
147
+ your pages. If you want nginx or a CDN serving them instead, point it at
148
+ `.output/public` — that directory is the deployment.
149
+
150
+ ---
151
+
152
+ Next: [Deploying →](/hosts/deployment)
153
+
154
+ ## Which entry point is whose
155
+
156
+ `@rsc-kit/core/host` is the engine's front door for a **host adapter** — the
157
+ handler, and beside it the few functions an adapter needs when it embeds the
158
+ engine. Several of those are re-exports of app-facing modules, so an editor's
159
+ auto-import may offer `redirect` or `revalidate` from `host`. They are the
160
+ same functions; an app imports them from their own entry points:
161
+
162
+ | in an app | not |
163
+ | --- | --- |
164
+ | `@rsc-kit/core/redirect` | `@rsc-kit/core/host` |
165
+ | `@rsc-kit/core/revalidate` | `@rsc-kit/core/host` |
166
+
167
+ Only an adapter imports `host`. The re-exports there are marked internal, so
168
+ they sort below the app entry in a completion list.
@@ -0,0 +1,238 @@
1
+ # Your own backend
2
+
3
+ > The one endpoint a backend in any language answers.
4
+
5
+ The model is a [BAP — Backend-Answered Pages](/hosts/backend-answered-pages);
6
+ this page is the wire.
7
+
8
+ There is no adapter to write on the JavaScript side. The renderer is what
9
+ Nitro builds, on every host, and it already knows how to ask a backend for
10
+ things: a server component calls `rpc()`, and the call leaves the process as
11
+ an ordinary POST. What a backend implements is that one endpoint. Laravel's is
12
+ [about 400 lines of PHP](/hosts/laravel); [Go's](/hosts/go) is about the same.
13
+
14
+ This page is the contract, so a Rails, Django, .NET or Elixir application can
15
+ answer it the same way.
16
+
17
+ ## Telling the renderer where you are
18
+
19
+ Two variables, and the renderer wires itself. Both in development — `vite`
20
+ reads the project's `.env` — and in production, where the built server reads
21
+ its process environment:
22
+
23
+ ```ini title=".env"
24
+ RSC_BACKEND=http://127.0.0.1:8080
25
+ RSC_HOST_CALL_SECRET=a-long-random-string
26
+ ```
27
+
28
+ `APP_URL` is read where `RSC_BACKEND` is absent, which is what makes a Laravel
29
+ app need nothing extra. Optional beside them: `RSC_HOST_CALL_PATH` (default
30
+ `/__rsc/host-call`) and `RSC_HOST_GLOBAL` (default `rpc`, the name server
31
+ components call). Both, or neither: a secret without a backend has nowhere to
32
+ go, and a backend without a secret is refused at the door — the renderer does
33
+ not gate on one of the pair alone.
34
+
35
+ Both are read when the first request arrives, not when the module loads, so
36
+ they work wherever the built server runs: a process with an environment, or a
37
+ Worker, where Nitro maps the bindings in `wrangler.json` — a `var` for the
38
+ address, a `secret` for the secret — onto `process.env` per request. On a
39
+ Worker the backend has to be reachable from Cloudflare's network, so it is an
40
+ `https` origin behind the secret rather than a loopback address.
41
+
42
+ Then the plugin's `hostCall` option overrides any of it for a setup that
43
+ would rather not use the environment:
44
+
45
+ ```ts
46
+ rscKit({ hostCall: { endpoint: 'http://127.0.0.1:8080', secret, path: '/__rsc/host-call' } })
47
+ ```
48
+
49
+ ## The request
50
+
51
+ ```http
52
+ POST /__rsc/host-call
53
+ Content-Type: application/json
54
+ X-Rsc-Host-Secret: a-long-random-string
55
+ Cookie: <the visitor's, forwarded unchanged>
56
+ Authorization: <likewise, if the page request had one>
57
+
58
+ { "function": "Orders.recent", "args": [5] }
59
+ ```
60
+
61
+ `function` is whatever the component passed to `rpc()`; the naming scheme is
62
+ yours. `args` is positional, exactly as passed. `Cookie` and `Authorization`
63
+ are the only headers copied from the page request — everything else either
64
+ describes this POST or is meaningless to you — and they are what let the call
65
+ run *as the visitor*: your session middleware reads the cookie and finds the
66
+ same person the page is being rendered for. During a build there is no
67
+ visitor and the headers are absent.
68
+
69
+ **Check the secret first, in constant time, and refuse before dispatch.** This
70
+ endpoint runs functions by name with none of your routing in front of it;
71
+ `hash_equals('', '')` is true in PHP and its equivalents elsewhere, so an
72
+ unconfigured secret has to be rejected before the comparison rather than
73
+ trusted to it. Better still, do not register the endpoint at all when no
74
+ secret is configured — absent, not open.
75
+
76
+ ## The reply
77
+
78
+ JSON, and the fields keep the outcomes apart so the renderer never reads a
79
+ message to tell an invalid form from a broken server:
80
+
81
+ ```json
82
+ { "result": [ … ], "revalidate": ["orders"] }
83
+ ```
84
+
85
+ | you want to say | status | fields |
86
+ | --- | --- | --- |
87
+ | here is the answer | 200 | `result` |
88
+ | …and the action made these regions stale | 200 | `result`, `revalidate: ["orders", "page"]` |
89
+ | the input is invalid | 422 | `validationErrors: { "email": ["…"], "address.city": ["…"] }`, `error` |
90
+ | there is no session | 401 | `unauthenticated: true`, `error` |
91
+ | there is one, and still no | 403 | `unauthorized: true`, `error` |
92
+ | go somewhere else | **200** | `redirect: "/login"`, optionally `redirectStatus` (default 307) |
93
+ | a guard refused with its own status | that status | `error`, `refusalStatus: 429` |
94
+ | the function failed | 500 | `error` |
95
+
96
+ What each becomes on the other side: `validationErrors` reaches the form
97
+ that submitted, each message under its input, dot-joined for a nested field
98
+ and under `""` for a message about the form itself. `unauthenticated` and
99
+ `unauthorized` become the engine's own `ServerAuthenticationError` and
100
+ `ServerAuthorizationError`, so a page answers 401 or 403 the way it would
101
+ have if a JavaScript guard had thrown them. `redirect` travels the path every
102
+ other redirect travels — a real 3xx above a Suspense boundary, a digest below
103
+ one. `revalidate` names [sections](/guides/sections) or `page`, and the answer
104
+ to the action carries the re-rendered region with it rather than the browser
105
+ being told to ask again.
106
+
107
+ Two things that are easy to get wrong:
108
+
109
+ **A redirect is a 200.** An HTTP client follows a 3xx transparently, so a real
110
+ one here would send the host call itself to the destination and hand whatever
111
+ it found back to the render as the function's result.
112
+
113
+ **Refusing is not failing.** A form filled in wrongly is the ordinary case, and
114
+ `validationErrors` is checked before `error` — a reply carrying both is read as
115
+ a refusal with fields, not a failure with none. Reserve `error` alone, with a
116
+ 500, for the thing the visitor did not cause.
117
+
118
+ ## Batches
119
+
120
+ Calls issued in the same tick of a render — sibling components each awaiting
121
+ `rpc()` — arrive as one POST, so a page's parallel reads cost you one request
122
+ rather than one each:
123
+
124
+ ```json
125
+ { "calls": [ { "function": "Orders.recent", "args": [5] }, { "function": "Me.profile", "args": [] } ] }
126
+ ```
127
+
128
+ Answer with `replies`, one per call in order, each the reply it would have had
129
+ alone plus the `status` it would have carried:
130
+
131
+ ```json
132
+ { "replies": [ { "status": 200, "result": [ … ] }, { "status": 401, "unauthenticated": true } ] }
133
+ ```
134
+
135
+ Run every call, in order, and answer every one — a refusal in the second is
136
+ that call's answer, not a reason to leave the third out. Each call's
137
+ `revalidate` stays with that call. A backend that has not implemented this
138
+ loses nothing but the saving: the renderer reads its "no function name" answer
139
+ as "no batches here" and sends single calls from then on. Batches never mix
140
+ visitors; every call in one carried the same forwarded headers.
141
+
142
+ ## Route middleware
143
+
144
+ A `middleware.ts` beside or above a page may name guards in your vocabulary:
145
+
146
+ ```ts title="app/admin/middleware.ts"
147
+ export const middleware = ['auth', 'can:manage-users']
148
+ ```
149
+
150
+ Only the names, no default export: the engine's own guards are a
151
+ `middleware.ts` *default export*, a function it runs itself, and a file may
152
+ carry either or both. (`route.ts` may carry the names too, from before
153
+ `middleware.ts` could.)
154
+
155
+ The renderer does not know what those mean. Before anything at or below that
156
+ directory renders — including a page frozen at build time, before the file is
157
+ served — it calls the reserved function with the list:
158
+
159
+ ```json
160
+ { "function": "__rsc.middleware", "args": [["auth", "can:manage-users"]] }
161
+ ```
162
+
163
+ Answer `{ "result": true }` to let the render go ahead. **Anything else is a
164
+ refusal**: `false`, `null`, a string, an object, a 4xx, a connection error.
165
+ The engine reads the literal `true` and nothing else, so a guard that aborts,
166
+ redirects or simply throws keeps the page from rendering rather than being
167
+ read as silence. Refuse with the fields above — `unauthenticated` when there
168
+ is no session, `redirect` to send them to sign in, `refusalStatus` for a
169
+ throttle's 429 — and the page answers accordingly.
170
+
171
+ Run them in order, outermost first, and stop at the first refusal: an outer
172
+ guard saying no means the inner one should never have been asked.
173
+
174
+ ## Server actions
175
+
176
+ A `"use server"` function the browser can call is a name the renderer forwards
177
+ to you. Write the map to `rsc-host-actions.json` at the project root before
178
+ each build — the JavaScript name to whatever your side dispatches on:
179
+
180
+ ```json title="rsc-host-actions.json"
181
+ { "ordersCancel": "Orders.cancel", "profileUpdate": "Profile.update" }
182
+ ```
183
+
184
+ The build writes `server-actions.generated.ts` in the source directory
185
+ exporting each as an action, so a client component imports `ordersCancel`
186
+ and calls it. Regenerate the file as part of `build` rather than by hand: a
187
+ stale map names a method that has since been renamed, and nothing fails until
188
+ the browser calls it. Laravel's `rsc:action-manifest` is this step; a Go
189
+ registry writes the same file from the names it holds.
190
+
191
+ ## Urls you own
192
+
193
+ The renderer forwards any url the route tree does not own — `/login`, a
194
+ webhook, an uploaded file — to `RSC_BACKEND`, with `X-Forwarded-Host` and
195
+ `X-Forwarded-Proto` set and the header `x-rsc-renderer-fallback: 1`. Trust
196
+ the renderer as a proxy so your absolute urls come out against the public
197
+ origin.
198
+
199
+ If your application also proxies to the renderer — sitting in front of it,
200
+ the way Laravel does with `RSC_RENDERER_URL` — two things keep a url neither
201
+ side owns from bouncing between you forever:
202
+
203
+ - Set `x-rsc-proxied-by-backend: 1` on what you forward. The renderer answers
204
+ 404 itself instead of handing it back.
205
+ - When a request arrives carrying `x-rsc-renderer-fallback`, answer 404 for
206
+ anything you do not route. It has already been through the renderer's table.
207
+
208
+ Whichever process faces the internet, the host-call endpoint must not:
209
+ restrict it at the web server, bind the listener to loopback, or serve it on
210
+ a unix socket. The secret is the layer the protocol guarantees; the network
211
+ is the one it cannot.
212
+
213
+ ## What the renderer expects of you
214
+
215
+ - **Answer within 30 seconds.** A render blocked on a host that never answers
216
+ is a hung request, and the renderer gives up at 30s (`timeoutMs` on
217
+ `httpHostCalls`, for a host that embeds the engine itself).
218
+ - **One process is not enough if you proxy.** A server that proxies a page
219
+ to the renderer holds a worker for the whole render, and the render calls
220
+ back to that same server for its data. With one worker, nobody is left to
221
+ answer. Laravel refuses `php artisan serve` for exactly this.
222
+ - **A panic is one failed call.** Recover it into a 500 with `error`; the
223
+ other renders in flight should survive it.
224
+ - **Serialise what you return as JSON.** It is decoded on the other side as
225
+ whatever `rpc<T>()` was told it is; there is no schema between you.
226
+
227
+ ## Testing it without the renderer
228
+
229
+ The contract is plain HTTP, so a backend's own test suite can cover it
230
+ without a JavaScript process: POST the request shape, assert the reply
231
+ shape. Laravel's Pest suite does this; the Go adapter's `go test` does the
232
+ same. The end-to-end proof — a real page rendered with data from your
233
+ process — lives with the engine, which is where a rendering regression can be
234
+ caught.
235
+
236
+ ---
237
+
238
+ Reference implementations: [Laravel](/hosts/laravel) and [Go](/hosts/go).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rsc-kit/mcp",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  "guides"
28
28
  ],
29
29
  "scripts": {
30
- "test": "bun test tests",
30
+ "test": "tsc --noEmit && bun test tests",
31
31
  "typecheck": "tsc --noEmit",
32
32
  "build": "rm -rf dist guides && tsc -p tsconfig.build.json && node scripts/bundle-guides.mjs",
33
33
  "prepack": "bun run build"