UNPKG

@rsc-kit/mcp

Version:

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.

444 lines (357 loc) • 21.9 kB
# Your own backend > The one endpoint a backend in any language answers. The model is a [BAP — Backend-Answered Pages](/hosts/backend-answered-pages); this page is the wire. There is no adapter to write on the JavaScript side. The renderer is what Nitro builds, on every host, and it already knows how to ask a backend for things: a server component calls `rpc()`, and the call leaves the process as an ordinary POST. What a backend implements is that one endpoint. Laravel's is [about 400 lines of PHP](/hosts/laravel); [Go's](/hosts/go) is about the same. This page is the contract, so a Rails, Django, .NET or Elixir application can answer it the same way. ## Telling the renderer where you are Two variables, and the renderer wires itself. Both in development — `vite` reads the project's `.env` — and in production, where the built server reads its process environment: ```ini title=".env" RSC_BACKEND=http://127.0.0.1:8080 RSC_HOST_CALL_SECRET=… # openssl rand -base64 32 - see Secrets under Deployment ``` `APP_URL` is read where `RSC_BACKEND` is absent, which is what makes a Laravel app need nothing extra. Optional beside them: `RSC_HOST_CALL_PATH` (default `/__rsc/host-call`) and `RSC_HOST_GLOBAL` (default `rpc`, the name server components call). Both, or neither: a secret without a backend has nowhere to go, and a backend without a secret is refused at the door — the renderer does not gate on one of the pair alone. Both are read when the first request arrives, not when the module loads, so they work wherever the built server runs: a process with an environment, or a Worker, where Nitro maps the bindings in `wrangler.json` — a `var` for the address, a `secret` for the secret — onto `process.env` per request. On a Worker the backend has to be reachable from Cloudflare's network, so it is an `https` origin behind the secret rather than a loopback address. Then the plugin's `hostCall` option overrides any of it for a setup that would rather not use the environment: ```ts rscKit({ hostCall: { endpoint: 'http://127.0.0.1:8080', secret, path: '/__rsc/host-call' } }) ``` ## The request ```http POST /__rsc/host-call Content-Type: application/json X-Rsc-Host-Secret: a-long-random-string Cookie: <the visitor's, forwarded unchanged> Authorization: <likewise, if the page request had one> { "function": "Orders.recent", "args": [5] } ``` `function` is whatever the component passed to `rpc()`; the naming scheme is yours. `args` is positional, exactly as passed. `Cookie` and `Authorization` are the only headers copied from the page request — everything else either describes this POST or is meaningless to you — and they are what let the call run *as the visitor*: your session middleware reads the cookie and finds the same person the page is being rendered for. During a build there is no visitor and the headers are absent. **Arguments that do not fit the function are a failure, not a refusal.** Too few, or a value your language cannot decode into the parameter's type: answer 500 with `error`, as Go and Laravel do. The renderer's calls are typed from your own manifest, so a mismatch is a bug in a caller - most often a manifest older than the code - and a 500 is what makes it show up in your logs. Extra arguments may be ignored. Values that decode but break your rules - an email that is not one - are the input being invalid: a 422 with `validationErrors`, below. **Check the secret first, in constant time, and refuse before dispatch.** This endpoint runs functions by name with none of your routing in front of it; `hash_equals('', '')` is true in PHP and its equivalents elsewhere, so an unconfigured secret has to be rejected before the comparison rather than trusted to it. Better still, do not register the endpoint at all when no secret is configured — absent, not open. ## The reply JSON, and the fields keep the outcomes apart so the renderer never reads a message to tell an invalid form from a broken server: ```json { "result": [ … ], "revalidate": ["orders"] } ``` | you want to say | status | fields | | --- | --- | --- | | here is the answer | 200 | `result` | | …and the action made these regions stale | 200 | `result`, `revalidate: ["orders", "page"]` | | the input is invalid | 422 | `validationErrors: { "email": ["…"], "address.city": ["…"] }`, `error` | | there is no session | 401 | `unauthenticated: true`, `error` | | there is one, and still no | 403 | `unauthorized: true`, `error` | | go somewhere else | **200** | `redirect: "/login"`, and `redirectStatus` when you mean something other than 307 | | refused, with its own status | that status | `error` (shown to the visitor), `refusalStatus: 429` (any 4xx or 5xx: a 503 while a dependency is down) | | refused, with something to act on | that status, 409 by default | `error` (shown to the visitor), `refusalStatus`, `refusalData: { "blockers": [ … ] }` | | the function failed | 500 | `error`, and in development `debug` | What each becomes on the other side: a `redirect` resolves the browser's call with `{ redirected }` after the navigation starts, which is why every generated stub is typed `Promise<T | Redirected>`. `validationErrors` reaches the form that submitted, each message under its input, dot-joined for a nested field and under `""` for a message about the form itself. `unauthenticated` and `unauthorized` become the engine's own `ServerAuthenticationError` and `ServerAuthorizationError`, so a page answers 401 or 403 the way it would have if a JavaScript guard had thrown them; in an action, each is the result's `serverError` with your `error` as written. A `refusalStatus` makes `error` a message for the visitor, with data or without: the page answers that status, an action shows it, and a 404 is the not-found page. Leave it out of a failure, whose message stays in your logs. `redirect` travels the path every other redirect travels — a real 3xx above a Suspense boundary, a digest below one. `revalidate` names [sections](/guides/live-data) or `page`, and the answer to the action carries the re-rendered region with it rather than the browser being told to ask again. Two things that are easy to get wrong: **A redirect is a 200.** An HTTP client follows a 3xx transparently, so a real one here would send the host call itself to the destination and hand whatever it found back to the render as the function's result. The redirect's own status goes in `redirectStatus`; without it the renderer uses 307, which keeps the method. Send it whenever your framework says something else - Laravel's redirects are 302, so the Laravel package always sends it. **Refusing is not failing.** A form filled in wrongly is the ordinary case, and `validationErrors` is checked before `error` — a reply carrying both is read as a refusal with fields, not a failure with none. Reserve `error` alone, with a 500, for the thing the visitor did not cause. **Say where it failed, in development only.** A 500 may carry `debug`: the thrown type, its message, and frames newest first. ```json { "error": "boom", "debug": { "type": "RuntimeException", "message": "boom", "trace": ["app/Rsc/Orders.php:42 App\\Rsc\\Orders->recent()", "…"] } } ``` The renderer puts it on the error the render sees, as its `cause` and under its stack, so a failed `rpc()` points at the line of your backend that failed, not only at the JavaScript that called it. Send it only when the app is in development - Laravel's `app.debug`, Go's `CallbackHandler.Debug` - never in production, where it would name your files to whoever caused the error, and never with a refusal, which is an answer and not a bug. ## Batches Calls issued in the same tick of a render — sibling components each awaiting `rpc()` — arrive as one POST, so a page's parallel reads cost you one request rather than one each: ```json { "calls": [ { "function": "Orders.recent", "args": [5] }, { "function": "Me.profile", "args": [] } ] } ``` Answer **as each call finishes**: `Content-Type: application/x-ndjson`, one JSON line per call, carrying its `index` in the batch, the `status` it would have had alone, and the reply — in whatever order the calls complete, flushed as they do: ```text { "index": 1, "status": 401, "unauthenticated": true } { "index": 0, "status": 200, "result": [ … ] } ``` That is what keeps a page's boundaries streaming independently after their reads travelled together: the renderer resolves each call the moment its line lands, so a component waiting on a fast read paints while a slow sibling's is still running. The Go module runs the calls concurrently and writes each as it returns; Laravel runs them in order and flushes after each. Set `X-Accel-Buffering: no` so a proxy in front does not hold the lines back. A backend that would rather answer the whole batch at once may: one JSON object of `replies`, one per call in order, each with its `status`: ```json { "replies": [ { "status": 200, "result": [ … ] }, { "status": 401, "unauthenticated": true } ] } ``` The renderer reads either. The saving of the batch is the same; with the whole-batch form every call in it waits for the slowest. Either way, run every call and answer every one — a refusal in the second is that call's answer, not a reason to leave the third out. Each call's `revalidate` stays with that call. Batches never mix visitors; every call in one carried the same forwarded headers. **At most 50 calls.** The renderer never sends more in one batch; a page with more is split. Refusing a larger one - 413, before running anything - is a safe limit, and Laravel's. **Batches are not optional.** The renderer always sends calls from the same tick together, and an answer that is neither form - whatever its status - fails every call in the batch with what your backend said. None is sent again one by one: they may have run, and an action run twice is worse than one reported as failed. The conformance suite checks batches, so a backend that passes it takes them. ## Route middleware A `middleware.ts` beside or above a page may name guards in your vocabulary: ```ts title="app/admin/middleware.ts" export const middleware = ['auth', 'can:manage-users'] ``` Only the names, no default export: the engine's own guards are a `middleware.ts` *default export*, a function it runs itself, and a file may carry either or both. (`route.ts` may carry the names too, from before `middleware.ts` could.) The renderer does not know what those mean. Before anything at or below that directory renders — including a page frozen at build time, before the file is served — it calls the reserved function with the list: ```json { "function": "__rsc.middleware", "args": [["auth", "can:manage-users"]] } ``` Answer `{ "result": true }` to let the render go ahead. **Anything else is a refusal**: `false`, `null`, a string, an object, a 4xx, a connection error. The engine reads the literal `true` and nothing else, so a guard that aborts, redirects or simply throws keeps the page from rendering rather than being read as silence. Refuse with the fields above — `unauthenticated` when there is no session, `redirect` to send them to sign in, `refusalStatus` for a throttle's 429 or a 503 during maintenance — and the page answers accordingly, with the status and the `error` message. Only a deliberate refusal carries a `refusalStatus`: a crash leaves it out and is answered 500. Run them in order, outermost first, and stop at the first refusal: an outer guard saying no means the inner one should never have been asked. An empty list passes - `{ "result": true }`: it is what the renderer's health check asks, to prove the backend answers without running anything. ## Saying data changed A section names the data it shows: ```tsx export default section('repos', Repos, { refreshOn: ({ params }) => [`team:${params.team}:repos`] }) ``` and your backend says when that changed, from a webhook or a job as much as an action. Every open tab showing it refreshes, with nothing polling. Two things to provide: **A way to say a name changed**, callable from anywhere in your backend. It moves the name's version to the larger of one past where it was and the current time in milliseconds - so a version never comes round again, and an old name can be deleted at any time - kept somewhere every instance of your backend shares - a cache, a table - since a webhook lands on whichever instance the balancer picked. A name never changed is at 0. **The reserved function `__rsc.changed`**, asked with what a watcher holds and how long it would wait: ```json { "function": "__rsc.changed", "args": [{ "since": { "team:1:repos": 3, "team:1:members": 0 }, "wait": 5000 }] } ``` Answer `{ "result": { "versions": { ... } } }` with every name in `since` whose version differs now, and nothing else - an empty object when none does. If you can, hold the call up to `wait` milliseconds for one to move and answer the moment it does: that is what makes the refresh immediate. The renderer asks with 5 seconds; hold no longer than `wait`, and never past 30 seconds, where the renderer gives up on any call. A name you have never seen is at 0, compared like any other: held while the asker also holds 0, answered the moment it is changed. If you cannot - PHP-FPM, a worker that must answer and go - answer at once; the renderer asks again on its interval, a couple of seconds apart. Both are conformant, and the page is the same. The renderer asks once per process for every open tab it holds, never once per tab, so the cost to you is one small call per interval per renderer. A tab may only watch names its page was rendered with: the renderer signs them, so no authorization is needed here beyond the secret. ## Server actions A `"use server"` function the browser can call is a name the renderer forwards to you. Write `rsc-host.json` at the project root, beside `vite.config.ts`: `actions` maps the JavaScript name to whatever your side dispatches on, and `functions` lists every name `rpc()` may call. ```json title="rsc-host.json" { "actions": { "ordersCancel": "Orders.cancel", "profileUpdate": "Profile.update" }, "functions": ["Orders.cancel", "Orders.recent", "Profile.update"] } ``` A function can also carry its types, so the app's `rpc()` and the stubs are typed: `types` maps a name to its positional `params`, how many trailing ones are `optional`, a variadic `rest`, and its `result`, each a JSON Schema; `defs` holds the named object types they refer to by `"$ref": "#/defs/Name"`. ```json "types": { "Orders.recent": { "params": [{ "type": "integer" }], "result": { "type": "array", "items": { "$ref": "#/defs/Order" } } } }, "defs": { "Order": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] } } ``` The build writes `server-actions.generated.ts` in the source directory exporting each action, so a client component imports `ordersCancel` and calls it, and types `rpc()`'s first argument with the functions. Have Vite run the command that writes it, `rscKit({ hostManifest: { command } })`, so it cannot go stale: a stale one names a method since renamed, and nothing fails until the browser calls it. Laravel's `rsc:host-manifest` is this step; a Go registry's `WriteManifest` writes the same file. ## Urls you own The renderer forwards any url the route tree does not own — `/login`, a webhook, an uploaded file — to `RSC_BACKEND`, with `X-Forwarded-Host` and `X-Forwarded-Proto` set and the header `x-rsc-renderer-fallback: 1`. Trust the renderer as a proxy so your absolute urls come out against the public origin. If your application also proxies to the renderer — sitting in front of it, the way Laravel does with `RSC_RENDERER_URL` — two things keep a url neither side owns from bouncing between you forever: - Set `x-rsc-proxied-by-backend: 1` on what you forward. The renderer answers 404 itself instead of handing it back. - When a request arrives carrying `x-rsc-renderer-fallback`, answer 404 for anything you do not route. It has already been through the renderer's table. Whichever process faces the internet, the host-call endpoint must not: restrict it at the web server, bind the listener to loopback, or serve it on a unix socket. The secret is the layer the protocol guarantees; the network is the one it cannot. ## What the renderer expects of you - **Answer within 30 seconds.** A render blocked on a host that never answers is a hung request, and the renderer gives up at 30s (`timeoutMs` on `httpHostCalls`, for a host that embeds the engine itself). - **One process is not enough if you proxy.** A server that proxies a page to the renderer holds a worker for the whole render, and the render calls back to that same server for its data. With one worker, nobody is left to answer. Laravel refuses `php artisan serve` for exactly this. - **A panic is one failed call.** Recover it into a 500 with `error`; the other renders in flight should survive it. - **Serialise what you return as JSON.** Where your manifest gives a function a type, what you send must fit it: an empty list is `[]`, never `null`; a time is an ISO 8601 string. ## Before you ship: the conformance suite An adapter is ready when it passes rsc-kit's conformance suite - all of it. Every bug found at this boundary so far was a difference between how a backend and the engine read the wire - a nil list sent as `null` where the type said a list, a timestamp typed `unknown`, a refusal that did not arrive as the error a page checks for - and each is a case in the suite. The Laravel and Go adapters run it in CI on every change, against rsc-kit's main. Register these functions in your backend, written with your adapter's ordinary API, the way an app would write them: | Function | Must | | --- | --- | | `Conformance.echo` | return its one argument unchanged | | `Conformance.emptyList` | return an empty list, the way your language says "no rows" | | `Conformance.time` | return 2026-01-02T03:04:05Z as your language's time value | | `Conformance.noTime` | return an absent time | | `Conformance.unauthenticated` | refuse as not signed in | | `Conformance.unauthorized` | refuse as not allowed | | `Conformance.notFound` | refuse as not found (404) | | `Conformance.refuse` | refuse with 429 and "Slow down." | | `Conformance.invalid` | refuse the input, on the field `name` | | `Conformance.invalidNested` | refuse the input, on `address.city` and on the form itself, under `""` | | `Conformance.redirect` | send the visitor to `/login` | | `Conformance.revalidate` | mark `orders` stale and return `"ok"` | | `Conformance.fail` | fail with an ordinary error | | `Conformance.double` | take one integer and return it doubled; anything else fails it | | `Conformance.authorization` | return the forwarded `Authorization` header | | `Conformance.cookie` | return the forwarded `Cookie` header | | `Conformance.login` | set a cookie named `conformance_login` on the response, and return `"ok"` | | `Conformance.change` | say `conformance:changed` changed, as your webhook would, and return `"ok"` | And three guards: `conformance-allow`, which passes, `conformance-deny`, which refuses, and `conformance-redirect`, which sends the visitor to `/conformance-login`. Then serve them and run: ```sh npx -y -p @rsc-kit/core rsc-kit-conformance \ --endpoint http://127.0.0.1:8123/__rsc/host-call --secret test --manifest rsc-host.json ``` It checks the whole contract on this page: - **values** - every JSON shape unchanged, `[]` for no rows, ISO 8601 times, arguments decoded to their types, and every value against the type your manifest declares; - **outcomes** - each refusal as the engine's own error, field errors with dotted paths and the form's under `""`, redirects, revalidation staying with its call, a crash staying a failure, and arguments that do not fit failing rather than refusing; - **the request** - `Cookie` and `Authorization` forwarded, a cookie set by a call reaching the page, no secret or the wrong one refused with 403, an unknown function 404, a malformed body 400; - **batches** - every call answered, a refusal only its own, and more than 50 refused with 413 before any runs; - **guards** - in the order named, stopping at the first that does not pass, a redirect from one, and a name nobody registered refusing; - **live data** - a name never changed at 0, a change moving it to a time in milliseconds that never comes round again, only the names that differ answered, and a held call bounded by `wait`. It exits non-zero on any failure, so it can gate your CI. The Go adapter's `cmd/conformance` and Laravel's `tests/Conformance` are working fixtures to copy. ## Proposing an adapter rsc-kit standardises on adapters: a backend in a language rsc-kit has none for gets one, rather than a second way to reach it. To propose one, open a discussion or a pull request on [rsc-kit/rsc-kit](https://github.com/rsc-kit/rsc-kit). To be adopted, an adapter: - passes the whole conformance suite, in its own CI, against rsc-kit's main on every change - not a subset, and not with cases skipped; - registers the fixtures with its ordinary API, the way an app would; - covers everything on this page, live data included - `__rsc.changed`, a way to say a name changed, and a store every instance shares. ## Testing it without the renderer The contract is plain HTTP, so a backend's own test suite can cover it without a JavaScript process: POST the request shape, assert the reply shape. Laravel's Pest suite does this; the Go adapter's `go test` does the same. The end-to-end proof — a real page rendered with data from your process — lives with the engine, which is where a rendering regression can be caught. --- Reference implementations: [Laravel](/hosts/laravel) and [Go](/hosts/go).