@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.
- package/dist/index.js +14 -2
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +367 -17
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +125 -9
- package/guides/authorization.md +39 -0
- package/guides/backend-answered-pages.md +210 -0
- package/guides/bun.md +76 -0
- package/guides/coming-from-next.md +4 -0
- package/guides/deployment.md +129 -0
- package/guides/domains.md +129 -0
- package/guides/errors.md +11 -1
- package/guides/go.md +194 -0
- package/guides/index.json +45 -0
- package/guides/installation.md +47 -9
- package/guides/instrumentation.md +91 -0
- package/guides/introduction.md +4 -0
- package/guides/laravel.md +406 -0
- package/guides/mcp.md +11 -5
- package/guides/queries.md +138 -8
- package/guides/quick-start.md +31 -0
- package/guides/redirects.md +14 -0
- package/guides/response-headers.md +22 -0
- package/guides/seo-files.md +45 -0
- package/guides/testing.md +4 -2
- package/guides/typed-routes.md +17 -0
- package/guides/url-validation.md +8 -2
- package/guides/where-it-runs.md +168 -0
- package/guides/your-own-backend.md +238 -0
- package/package.json +2 -2
|
@@ -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
|
+
```
|
package/guides/seo-files.md
CHANGED
|
@@ -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 `
|
|
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
|
package/guides/typed-routes.md
CHANGED
|
@@ -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.
|
package/guides/url-validation.md
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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.
|
|
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"
|