@rsc-kit/mcp 0.18.0 → 0.19.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.
package/guides/offline.md CHANGED
@@ -65,8 +65,10 @@ rscKit({ offline: true })
65
65
  ```
66
66
 
67
67
  The build writes `sw.js` beside the assets and the generated entry registers
68
- it. With it on, a page you have visited survives a full reload with no network
69
- at all not just a navigation, a reload and comes back interactive.
68
+ it once the page has loaded, or at once if it already has by the time the
69
+ runtime boots. With it on, a page you have visited survives a full reload
70
+ with no network at all — not just a navigation, a reload — and comes back
71
+ interactive.
70
72
 
71
73
  Everything else lives in one page's memory — the pages a boundary keeps
72
74
  mounted, the prefetch cache. Reload with no network and the browser shows its
@@ -86,7 +88,8 @@ on every navigation, so it happens on the first load.
86
88
 
87
89
  | | |
88
90
  | --- | --- |
89
- | hashed assets | at install, and answered from the cache forever after the name changes when the bytes do |
91
+ | what boots the app | at install: the scripts, stylesheets, fonts, manifest and icons, plus `/` and the offline page with the payloads they boot from. Not an image, a wasm module or the share card those are cached the first time they are used, so an install costs what a first page costs and not a megabyte more |
92
+ | any other hashed asset | the first time it is asked for, and from the cache forever after — the name changes when the bytes do |
90
93
  | a page you loaded | its document, and the payload it boots from |
91
94
  | a page you reached by link | its payload, and its document fetched once to go with it |
92
95
  | a page you never visited | nothing |
@@ -178,7 +181,9 @@ export default function Offline() {
178
181
  Nothing in the file makes it special. It is an ordinary route, and what makes it
179
182
  the fallback is that the build stored it and the worker precached it. It is also
180
183
  the one page that can honestly stand in for another, because it is about being
181
- offline rather than about the url it appears under.
184
+ offline rather than about the url it appears under. It stands in for every
185
+ navigation nothing can answer — a page the build stored but this browser
186
+ never visited included.
182
187
 
183
188
  **It has to be static.** A fallback that renders per request cannot be served
184
189
  when there is no request to be made, so the build checks and says when it will
@@ -0,0 +1,99 @@
1
+ # OpenAPI
2
+
3
+ > A document derived from your route.ts files, and Scalar's page over it.
4
+
5
+ Every `route.ts` already says what an OpenAPI operation needs: the methods it
6
+ exports, and the `params`, `searchParams` and `body` schemas beside them. So
7
+ the document is derived, not written — the way Elysia derives its from the
8
+ schemas on its routes — and it cannot go stale.
9
+
10
+ ```ts title="vite.config.ts"
11
+ rscKit({
12
+ openapi: {
13
+ info: { title: 'Shop API', version: '1.0.0' },
14
+ servers: [{ url: 'https://api.shop.example' }],
15
+ security: [{ bearerAuth: [] }],
16
+ components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } } },
17
+ },
18
+ })
19
+ ```
20
+
21
+ `openapi: true` is the same with defaults. The document answers at
22
+ `/openapi.json` (`path` moves it), as an api route the build stores, without
23
+ middleware — a document exists to be read.
24
+
25
+ ## What a route contributes
26
+
27
+ ```ts title="src/app/api/orders/[id]/route.ts"
28
+ import { z } from 'zod'
29
+ import type { RouteContext } from '@rsc-kit/core/route-schema'
30
+
31
+ export const params = z.object({ id: z.coerce.number().int() })
32
+ export const searchParams = z.object({ expand: z.boolean().optional() })
33
+ export const body = z.object({ title: z.string().min(1) })
34
+
35
+ export const openapi = {
36
+ tags: ['Orders'],
37
+ PATCH: { summary: 'Rename an order' },
38
+ responses: { 200: { description: 'The order' } },
39
+ }
40
+
41
+ export async function GET(request: Request, { params }: RouteContext<typeof params>) { … }
42
+ export async function PATCH(request: Request, { params, body }: RouteContext<typeof params, never, typeof body>) { … }
43
+ ```
44
+
45
+ | from | into the document |
46
+ | --- | --- |
47
+ | the directory | the path: `/api/orders/[id]` → `/api/orders/{id}` |
48
+ | each method export | an operation |
49
+ | `params` | the path parameters' types; a segment with no schema is a string |
50
+ | `searchParams` | one query parameter per property, required where the schema requires it |
51
+ | `body` | the request body of `POST`, `PUT`, `PATCH`, `DELETE`, and a documented `422` |
52
+ | a `middleware.ts` above | a `session` security requirement, and `401`/`403` |
53
+ | `export const openapi` | anything else an operation may say — `summary`, `description`, `tags`, `responses` — shared, or per method under `GET`/`POST`/… |
54
+
55
+ A schema contributes by describing itself as JSON Schema (Standard JSON
56
+ Schema): Zod 4 and ArkType do; Valibot needs its own converter and
57
+ contributes nothing yet. Response bodies are what a route declares in
58
+ `openapi.responses` — a handler returns `Response`, so nothing else knows the
59
+ shape — until a typed response helper carries it.
60
+
61
+ `export const openapi = false` leaves a route out: the page that renders the
62
+ document, a webhook meant for one caller. `{ DELETE: false }` leaves one
63
+ method out. An app whose routes are mostly webhooks turns the default
64
+ around with `rscKit({ openapi: { include: 'declared' } })`: only a route
65
+ that exports `openapi` is documented, and a callback needs no line. `HEAD` and `OPTIONS` are never documented — the engine answers
66
+ them for every route, and a file exporting `OPTIONS` for a CORS preflight is
67
+ not describing an operation.
68
+
69
+ ## The page
70
+
71
+ Scalar's API Reference is a route handler in this shape already, so the
72
+ page is one file and none of it is ours:
73
+
74
+ ```sh
75
+ bun add @scalar/nextjs-api-reference
76
+ ```
77
+
78
+ ```ts title="src/app/reference/route.ts"
79
+ import { ApiReference } from '@scalar/nextjs-api-reference'
80
+
81
+ export const GET = ApiReference({ url: '/openapi.json' })
82
+ export const openapi = false
83
+ ```
84
+
85
+ Stored at build like any route that reads nothing per request. Scalar's
86
+ package is the app's dependency and the app's version; the engine ships no
87
+ UI and no dependency for it, whether or not the document is on.
88
+
89
+ ## Coming from a hand-written spec
90
+
91
+ A spec kept in a file has three parts, and two of them move:
92
+
93
+ - the `paths` — delete them; they are the routes and their schemas now, and
94
+ the same `body` schema validates the request at runtime, which the
95
+ hand-written spec never did
96
+ - `info`, `servers`, `security`, `components.securitySchemes` — into
97
+ `rscKit({ openapi })`
98
+ - a route's `summary`, `tags` and response shapes — into its
99
+ `export const openapi`
@@ -123,7 +123,18 @@ window, and nothing warns.
123
123
  ## From a server action
124
124
 
125
125
  An action is not a render, so there is no shell to be on either side of. Throw
126
- from the action and the client follows it:
126
+ from the action a plain `"use server"` function or one built from
127
+ `createActionClient()` — and the client follows it as a navigation: the
128
+ layouts stay mounted and the history entry the form was on is replaced, so
129
+ Back does not return to the submitted form. A `<Form>` shows nothing for it;
130
+ signing in and arriving is the success.
131
+
132
+ To the caller, the action **resolves** — with `{ redirected: '/where' }` —
133
+ once the navigation is under way. It does not throw: a `startTransition(async
134
+ () => { await logOut() })` with no `catch` around it would otherwise reject
135
+ into React, which unmounts the root, and a logout ended on a white page.
136
+ There is nothing to catch; a component that awaited the action is on its
137
+ way off the screen, and the object it got is safe to read any field of.
127
138
 
128
139
  ```ts title="src/actions.ts"
129
140
  'use server'
@@ -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
@@ -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' })
@@ -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
@@ -118,7 +136,12 @@ that never ran, a stored page that should not have been, a `404` that came back
118
136
 
119
137
  `createTestApp()` builds when your source is newer than the last build, and not
120
138
  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
139
+ up. It runs your own `build` script `bun run build` under Bun, `npm run
140
+ build` under Node — so what is tested is what ships, on the runtime it ships
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
122
145
  `{ build: false }` in a ci step that already built.
123
146
 
124
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,9 @@ 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. `Href` and
145
+ `ApiHref` are the same two types under their older names.
139
146
 
140
147
  :::note[Paths, not response types]
141
148
  This checks the **url**. It does not infer what the endpoint returns — that
@@ -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
@@ -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
@@ -100,6 +101,61 @@ write.
100
101
  The generated config sets it. If you write your own, set it too.
101
102
  </Aside>
102
103
 
104
+ ## Native dependencies stay outside the bundle
105
+
106
+ A package with a native binary — `sharp`, `bcrypt`, `better-sqlite3`,
107
+ `@prisma/client` — cannot be rolled into a server bundle: the build succeeds
108
+ and the server cannot load its own binary. The usual ones are left external
109
+ by default, and Nitro traces each into `.output/server/node_modules` with its
110
+ binaries, so the deployment is still one directory. For one that is not on
111
+ the list:
112
+
113
+ ```ts
114
+ rscKit({ serverExternalPackages: ['@acme/native-thing'] })
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
+
103
159
  ## Offline
104
160
 
105
161
  `rscKit({ offline: true })` writes a service worker into `.output/public`
@@ -134,6 +190,28 @@ Assets live in `.output/public` and are served by the same process that serves
134
190
  your pages. If you want nginx or a CDN serving them instead, point it at
135
191
  `.output/public` — that directory is the deployment.
136
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
+
137
215
  ---
138
216
 
139
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.0",
3
+ "version": "0.19.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",