@rsc-kit/mcp 0.16.3 → 0.18.0

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.
@@ -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.16.3",
3
+ "version": "0.18.0",
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"