@rsc-kit/mcp 0.18.1 → 0.20.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.
@@ -84,6 +84,12 @@ server with it.
84
84
  Files travel this way too, without any encoding of your own — see
85
85
  [File uploads](/guides/file-uploads).
86
86
 
87
+ The id is a hash of the module and the export in a build —
88
+ `9396f92f746f#createOrder` — and nothing about the file is in it. In
89
+ development it is the module's path as Vite serves it
90
+ (`/@fs/Users/…/actions.ts#createOrder`), which is what a
91
+ `$ACTION_ID_…` hidden input in a form shows there; a build never carries it.
92
+
87
93
  ## Returning UI
88
94
 
89
95
  Step 5 is the same serialiser a page goes through, so an action can answer
@@ -269,11 +275,18 @@ forgotten `next()` would otherwise look exactly like a check that passed.
269
275
  ```ts
270
276
  const result = await createPost({ title: 'x', body: 'y' });
271
277
 
272
- result.data // what the handler returned
273
- result.validationErrors // { title: ['Too short'] }
274
- result.serverError // 'Something went wrong.'
278
+ result?.data // what the handler returned
279
+ result?.validationErrors // { title: ['Too short'] }
280
+ result?.serverError // 'Something went wrong.'
275
281
  ```
276
282
 
283
+ The `?.` is the type's, not decoration: an action that `redirect()`s
284
+ resolves with `undefined` on the client — the page is on its way elsewhere —
285
+ and `result.serverError` on that is a `TypeError` inside a transition, which
286
+ unmounts the root. The type is `Promise<ActionResult<Data> | undefined>`,
287
+ so the unasked read does not compile. In a test, where nothing redirects,
288
+ `result!` is fine.
289
+
277
290
  Returned rather than thrown, and that is not a style choice: React serialises a
278
291
  rejected server action opaquely — production strips the message and leaves a
279
292
  digest — so a thrown validation error reaches the browser as "an error
@@ -357,6 +370,37 @@ one can be wired in without this package having an opinion about which. See
357
370
  [Writing to the response](/guides/authorization#writing-to-the-response) for
358
371
  where else it works, and where it does not.
359
372
 
373
+ ## Work the visitor should not wait for
374
+
375
+ An audit row, a welcome email, a cache warm: the action's answer should not
376
+ wait for it, and it must still finish. `after()` queues work to run once the
377
+ answer is on its way:
378
+
379
+ ```ts
380
+ 'use server';
381
+
382
+ import { after } from '@rsc-kit/core/request';
383
+
384
+ export async function signup(formData: FormData) {
385
+ const user = await createUser(formData);
386
+
387
+ after(() => sendWelcomeEmail(user));
388
+ after(() => audit('signup', user.id));
389
+
390
+ redirect('/welcome');
391
+ }
392
+ ```
393
+
394
+ Why not a detached promise: on a long-lived process it happens to run to
395
+ completion; on a Worker the isolate is torn down when the response ends
396
+ unless the work was handed to the platform's `waitUntil`, so a promise nobody
397
+ awaited dies silently — some of the time, which is the worst way. `after()`
398
+ is the same call on every host: handed to `waitUntil` where one exists, kept
399
+ by the process elsewhere. A rejection is reported to the log and never
400
+ reaches the response, which has already gone. It works from a component, a
401
+ `middleware.ts`, an api route and an action alike; outside a request — a
402
+ build — the work simply runs.
403
+
360
404
  ## Two at once
361
405
 
362
406
  Nothing queues them. Each call is an ordinary `fetch`, so two submits fired
@@ -409,11 +453,11 @@ double-click sends two requests unless you disable the button.
409
453
  An action that fails does not answer with a Flight stream, so the client turns
410
454
  the response into an error before the decoder ever sees it:
411
455
 
412
- | Response | What the client throws | What you do |
456
+ | Response | What the client does | What you do |
413
457
  | --- | --- | --- |
414
- | `X-RSC-Redirect` header | `ServerRedirectError` | Nothing — the browser is sent to the location. |
415
- | `422` | `ServerValidationError`, carrying `errors` | Show the messages. `<Form>` does it for you. |
416
- | Any other failure | `Error`, naming the status | Whatever the app needs. |
458
+ | `X-RSC-Redirect` header | Starts the navigation and **resolves** with `{ redirected }` | Nothing — the page is on its way to the location, and there is nothing to catch. |
459
+ | `422` | Throws `ServerValidationError`, carrying `errors` | Show the messages. `<Form>` does it for you. |
460
+ | Any other failure | Throws `Error`, naming the status | Whatever the app needs. |
417
461
 
418
462
  ```tsx
419
463
  "use client";
package/guides/testing.md CHANGED
@@ -54,6 +54,24 @@ there is the round trip. Here the function *is* the interesting part — the
54
54
  validation, the middleware, the authorisation — and it is a unit test.
55
55
  :::
56
56
 
57
+ One line a port brings needs a stub: `import 'server-only'`. Keep it — the
58
+ build honours it, and a client file that imports the module fails to build
59
+ rather than shipping a secret. But under Vite the package resolves to an
60
+ empty module on the server, and under `bun test` it is the real package,
61
+ which throws on import; an action file that carries the line is not callable
62
+ in a unit test until a preload stubs it. The scaffold ships the two files:
63
+
64
+ ```toml title="bunfig.toml"
65
+ [test]
66
+ preload = ["./tests/preload.ts"]
67
+ ```
68
+
69
+ ```ts title="tests/preload.ts"
70
+ import { mock } from 'bun:test'
71
+
72
+ mock.module('server-only', () => ({}))
73
+ ```
74
+
57
75
  ### Reading the request
58
76
 
59
77
  `cookies()`, `headers()` and the rest read from a scope the host opens per
@@ -120,7 +138,10 @@ that never ran, a stored page that should not have been, a `404` that came back
120
138
  otherwise — the first run pays for it, the rest do not, and an edit is picked
121
139
  up. It runs your own `build` script — `bun run build` under Bun, `npm run
122
140
  build` under Node — so what is tested is what ships, on the runtime it ships
123
- on. Pass
141
+ on. Files the build wrote to `.output/public` — the hashed assets, `sw.js`,
142
+ `manifest.webmanifest`, the icons — are answered too, the way Nitro's static
143
+ layer answers them in production, so a test can prove a page's links resolve
144
+ and not only that the page renders. Pass
124
145
  `{ build: false }` in a ci step that already built.
125
146
 
126
147
  One build and one loaded module per test run, shared across files. That is both
@@ -55,7 +55,7 @@ export const searchParams = z.object({
55
55
  A key the page requires is required on the link — a `q: z.string()` with no
56
56
  default makes `search` itself required, so the page's error boundary is not
57
57
  where a missing `q` is found. A page with no schema takes any scalars, and so
58
- does an href that is not one route (`path as Href`), because there is nothing
58
+ does an href that is not one route (`path as Route`), because there is nothing
59
59
  to check it against.
60
60
 
61
61
  Values are typed by what the page will **see**, not what the schema accepts:
@@ -88,7 +88,7 @@ so `/posts/a/b` type-checks even though it does not match at runtime.
88
88
  const nav = [
89
89
  { href: '/', label: 'Home' },
90
90
  { href: '/about', label: 'About' },
91
- ] satisfies { href: Href; label: string }[]
91
+ ] satisfies { href: Route; label: string }[]
92
92
  ```
93
93
 
94
94
  Without `satisfies`, TypeScript infers `string` for `href` and you lose the
@@ -117,8 +117,17 @@ middleware — so typing it would make the common case a cast.
117
117
 
118
118
  ## Api routes
119
119
 
120
- Every build writes the `route.ts` files it found as well, in their own union —
121
- so a `fetch` to an endpoint that no longer exists stops compiling:
120
+ A `route.ts` is a `Route` too `<Link href="/logout">`, `visit('/agent-account')`
121
+ and `redirect('/files/export.csv')` all typecheck, as they do in Next. What
122
+ differs is what the browser does with one: a route answers with a `Response`,
123
+ not a page, so the client treats a link to it as the anchor it is — never
124
+ prefetched (a hover must not sign someone out) and a full navigation rather
125
+ than a payload fetch. The build hands the client every route's pattern for
126
+ that.
127
+
128
+ Every build also writes the `route.ts` files in their own narrower union,
129
+ `ApiRoute`, so a `fetch` to an endpoint that no longer exists stops
130
+ compiling:
122
131
 
123
132
  ```ts
124
133
  import { apiUrl } from '@rsc-kit/core/routes'
@@ -131,11 +140,8 @@ await fetch(apiUrl('/api/ordrs')) // does not compile
131
140
  `string`, so without somewhere to put the type there is nothing to check
132
141
  against — the function is the place.
133
142
 
134
- **Pages and api routes are separate unions on purpose.** `<Link href="/api/health">`
135
- does not compile, because linking to an api route navigates the browser away to
136
- a json document; and `apiUrl('/orders')` does not compile either, because
137
- fetching a page gets html where json was expected. Each refuses the other's
138
- urls, which is the pair of mistakes worth catching.
143
+ `apiUrl('/orders')` does not compile, because fetching a page gets html where
144
+ json was expected the one mistake the narrower union is for.
139
145
 
140
146
  :::note[Paths, not response types]
141
147
  This checks the **url**. It does not infer what the endpoint returns — that
@@ -62,13 +62,21 @@ npm run deploy # nitro deploy --prebuilt
62
62
  ## Compiling to a single binary
63
63
 
64
64
  Bun only, and the whole application ends up inside one file — engine, route
65
- tree and assets:
65
+ tree, frozen pages and assets, the precompressed variants included:
66
66
 
67
67
  ```bash
68
- npm run compile # builds, then bun build --compile
68
+ npm run compile # builds, then bun build --compile .output/server/compile.mjs
69
69
  ./dist/app
70
70
  ```
71
71
 
72
+ `compile.mjs` is written by the build beside the server. It is what puts the
73
+ frozen pages inside the binary: the server reads them through a computed
74
+ import, which a compile cannot see, and this entry imports them by name and
75
+ hands them over before starting the server. Compile `index.mjs` instead and
76
+ the binary still works — it renders those pages live. Nothing else has to
77
+ travel with the binary: not `.output/public`, not `.output/server`. A
78
+ `Dockerfile` copies `dist/app` and runs it.
79
+
72
80
  It builds first on purpose. Compiling whatever `.output` happens to hold means
73
81
  a binary one version behind the source with nothing to say so — and on a
74
82
  project that has never been built, an `ENOENT` naming a path the app did not
@@ -84,13 +92,6 @@ write.
84
92
  worth knowing on a site with hundreds of frozen pages.
85
93
  </Aside>
86
94
 
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
95
  <Aside type="caution" title="serveStatic: 'inline' is what makes this work">
95
96
  Without it the binary compiles, starts, serves pages, and 404s every asset.
96
97
  Inside a compiled binary the static path resolves into Bun's virtual
@@ -113,6 +114,48 @@ the list:
113
114
  rscKit({ serverExternalPackages: ['@acme/native-thing'] })
114
115
  ```
115
116
 
117
+ ## A polyfill runs first
118
+
119
+ A dependency that checks for a polyfill at module evaluation — `tsyringe`
120
+ wants `Reflect.getMetadata`, under `@peculiar/x509`, under
121
+ `@simplewebauthn/server` — depends on `import 'reflect-metadata'` running
122
+ before it, and the source has it there. A bundler does not keep that place:
123
+ it emits a chunk's imports before its external ones, whatever the source
124
+ said, so the check ran first — survivable by luck as a directory, fatal
125
+ compiled into a binary. When the project's graph has `reflect-metadata`
126
+ anywhere in it, the build loads it in a Nitro plugin, which the server's
127
+ entry evaluates before any of the app is imported; a project without it
128
+ has nothing that checks. Nothing to add to `instrumentation.ts`.
129
+
130
+ ## A dependency's `"use client"` is read
131
+
132
+ The other direction. A dependency is bundled into the server graphs — and
133
+ so has its `"use client"` directives read — when it declares `react` as a
134
+ peer dependency, which is what a React library does. One that imports React
135
+ and never says so (a generated component wrapper, a workspace package with
136
+ `react` under `dependencies`, a package whose author forgot) would be left
137
+ external, and its directive is then a string nobody reads: the server loads
138
+ it as a server module and the first `useState` in it fails at render, with an
139
+ error that names React and not the package.
140
+
141
+ So the build reads the direct dependencies once, and bundles any that carry
142
+ a `"use client"` file and plugin-rsc would not have. It says so:
143
+
144
+ ```text
145
+ [rsc-kit] bundling @acme/chat-widget: it has "use client" files but does not
146
+ declare react as a peer dependency, so its components would otherwise run
147
+ on the server.
148
+ ```
149
+
150
+ Nothing to configure; the line is there so the package's author hears about
151
+ the missing peer. The React-using dependencies under such a package — the
152
+ runtime a generated wrapper calls into — are bundled with it, so there is
153
+ one React for all of them: left external, that runtime would load React
154
+ through Node while everything else got it through Vite, and the hooks
155
+ dispatcher is null in one of the two. Only direct dependencies are read for
156
+ the directive itself — a directive two levels down is the concern of the
157
+ package between, which declared it.
158
+
116
159
  ## Offline
117
160
 
118
161
  `rscKit({ offline: true })` writes a service worker into `.output/public`
@@ -147,6 +190,28 @@ Assets live in `.output/public` and are served by the same process that serves
147
190
  your pages. If you want nginx or a CDN serving them instead, point it at
148
191
  `.output/public` — that directory is the deployment.
149
192
 
193
+ ## Nothing is sent raw
194
+
195
+ A bun or node server answering the internet by itself compresses what it
196
+ sends, because nothing in front of it will. The build writes a `.br` and a
197
+ `.gz` beside every public asset larger than a kilobyte, and Nitro serves
198
+ whichever the request accepts — a 147 kB stylesheet is 18 kB on the wire.
199
+ The host gzips what it answers itself — documents, streamed pages, RSC
200
+ payloads, stored pages, api routes — for a request that accepts it, flushing
201
+ every chunk so a streamed shell still reaches the browser before the holes
202
+ fill; a stored page is compressed once and kept.
203
+
204
+ A port measured what this is worth: with every byte raw, first paint on a
205
+ throttled phone was 3.8 s where 1.2 s was the baseline. That was the whole
206
+ regression.
207
+
208
+ On a Worker the platform compresses and the host does nothing. Behind a
209
+ CDN or nginx that compresses, the proxy sees an already-encoded answer and
210
+ passes it through. A deployment that would rather its proxy did all of it
211
+ turns the host's half off with `compress: false` on the handler, and Nitro's
212
+ with `compressPublicAssets: false` in its config; `Cache-Control:
213
+ no-transform` on an answer leaves that answer alone.
214
+
150
215
  ---
151
216
 
152
217
  Next: [Deploying →](/hosts/deployment)
@@ -125,19 +125,40 @@ rather than one each:
125
125
  { "calls": [ { "function": "Orders.recent", "args": [5] }, { "function": "Me.profile", "args": [] } ] }
126
126
  ```
127
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:
128
+ Answer **as each call finishes**: `Content-Type: application/x-ndjson`, one
129
+ JSON line per call, carrying its `index` in the batch, the `status` it would
130
+ have had alone, and the reply — in whatever order the calls complete, flushed
131
+ as they do:
132
+
133
+ ```text
134
+ { "index": 1, "status": 401, "unauthenticated": true }
135
+ { "index": 0, "status": 200, "result": [ … ] }
136
+ ```
137
+
138
+ That is what keeps a page's boundaries streaming independently after their
139
+ reads travelled together: the renderer resolves each call the moment its
140
+ line lands, so a component waiting on a fast read paints while a slow
141
+ sibling's is still running. The Go module runs the calls concurrently and
142
+ writes each as it returns; Laravel runs them in order and flushes after each.
143
+ Set `X-Accel-Buffering: no` so a proxy in front does not hold the lines back.
144
+
145
+ A backend that would rather answer the whole batch at once may: one JSON
146
+ object of `replies`, one per call in order, each with its `status`:
130
147
 
131
148
  ```json
132
149
  { "replies": [ { "status": 200, "result": [ … ] }, { "status": 401, "unauthenticated": true } ] }
133
150
  ```
134
151
 
135
- Run every call, in order, and answer every one a refusal in the second is
152
+ The renderer reads either. The saving of the batch is the same; with the
153
+ whole-batch form every call in it waits for the slowest.
154
+
155
+ Either way, run every call and answer every one — a refusal in the second is
136
156
  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.
157
+ `revalidate` stays with that call. A backend that has not implemented
158
+ batches at all loses nothing but the saving: the renderer reads its "no
159
+ function name" answer as "no batches here" and sends single calls from then
160
+ on. Batches never mix visitors; every call in one carried the same forwarded
161
+ headers.
141
162
 
142
163
  ## Route middleware
143
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rsc-kit/mcp",
3
- "version": "0.18.1",
3
+ "version": "0.20.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",