@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/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +273 -18
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +41 -8
- package/guides/authorization.md +2 -2
- package/guides/backend-answered-pages.md +74 -3
- package/guides/bun.md +76 -0
- package/guides/coming-from-next.md +18 -3
- package/guides/deployment.md +10 -0
- package/guides/emails.md +13 -1
- package/guides/feature-flags.md +63 -0
- package/guides/fonts.md +25 -0
- package/guides/forms.md +103 -0
- package/guides/index.json +15 -0
- package/guides/installation.md +38 -13
- package/guides/laravel.md +27 -2
- package/guides/mcp.md +11 -5
- package/guides/offline.md +9 -4
- package/guides/openapi.md +99 -0
- package/guides/redirects.md +12 -1
- package/guides/server-actions.md +51 -7
- package/guides/testing.md +25 -2
- package/guides/typed-routes.md +16 -9
- package/guides/url-validation.md +8 -2
- package/guides/where-it-runs.md +87 -9
- package/guides/your-own-backend.md +28 -7
- package/package.json +1 -1
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
|
|
69
|
-
|
|
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
|
-
|
|
|
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`
|
package/guides/redirects.md
CHANGED
|
@@ -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
|
|
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'
|
package/guides/server-actions.md
CHANGED
|
@@ -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
|
|
273
|
-
result
|
|
274
|
-
result
|
|
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
|
|
456
|
+
| Response | What the client does | What you do |
|
|
413
457
|
| --- | --- | --- |
|
|
414
|
-
| `X-RSC-Redirect` header | `
|
|
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 `
|
|
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
|
package/guides/typed-routes.md
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
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
|
package/guides/where-it-runs.md
CHANGED
|
@@ -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
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
loses nothing but the saving: the renderer reads its "no
|
|
139
|
-
as "no batches here" and sends single calls from then
|
|
140
|
-
visitors; every call in one carried the same forwarded
|
|
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