@rsc-kit/mcp 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +319 -14
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +106 -6
- package/guides/authorization.md +39 -0
- package/guides/backend-answered-pages.md +163 -0
- package/guides/coming-from-next.md +3 -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 +40 -0
- package/guides/installation.md +23 -4
- package/guides/instrumentation.md +91 -0
- package/guides/introduction.md +4 -0
- package/guides/laravel.md +406 -0
- 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/typed-routes.md +17 -0
- package/guides/where-it-runs.md +155 -0
- package/guides/your-own-backend.md +238 -0
- package/package.json +2 -2
package/guides/queries.md
CHANGED
|
@@ -35,7 +35,7 @@ id from an action id — only `fetchQuery` sends a GET.
|
|
|
35
35
|
|
|
36
36
|
```tsx
|
|
37
37
|
getListings(kind) // POST, even though it is a query
|
|
38
|
-
fetchQuery(getListings, [kind]) // GET
|
|
38
|
+
fetchQuery(getListings, [kind]) // GET, and [kind] is typed from getListings
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Development warns from the server when a query arrives at the action endpoint,
|
|
@@ -110,11 +110,43 @@ response, with no request from the browser. Nothing in this package is involved;
|
|
|
110
110
|
`use()` is React's. Reach for this first.
|
|
111
111
|
|
|
112
112
|
**When the browser decides what to read** — a filter, another page, a refresh —
|
|
113
|
-
|
|
113
|
+
call it. `fetchQuery` is an async function that goes to the server and gives
|
|
114
|
+
back the typed answer; nothing else is required:
|
|
114
115
|
|
|
115
116
|
```tsx
|
|
116
|
-
|
|
117
|
+
'use client'
|
|
117
118
|
|
|
119
|
+
import { useState, useTransition } from 'react'
|
|
120
|
+
import { fetchQuery } from '@rsc-kit/core/queryClient'
|
|
121
|
+
import { getListings } from '@/queries'
|
|
122
|
+
|
|
123
|
+
export function Listings({ initial }: { initial: Listing[] }) {
|
|
124
|
+
const [listings, setListings] = useState(initial) // the server-rendered page one
|
|
125
|
+
const [pending, start] = useTransition()
|
|
126
|
+
|
|
127
|
+
const show = (kind: string) =>
|
|
128
|
+
start(async () => setListings(await fetchQuery(getListings, [kind])))
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<>
|
|
132
|
+
<button onClick={() => show('stay')} disabled={pending}>Stays</button>
|
|
133
|
+
<button onClick={() => show('rent')} disabled={pending}>Rentals</button>
|
|
134
|
+
<List listings={listings} />
|
|
135
|
+
</>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
That is the whole pattern: an event handler, `await fetchQuery(...)`, a
|
|
141
|
+
`setState`. `useTransition` keeps the old list on screen while the new one
|
|
142
|
+
loads and gives you `pending` for the button. The same call works in a
|
|
143
|
+
`useEffect`, in an `onSubmit`, anywhere in the browser.
|
|
144
|
+
|
|
145
|
+
**When you want caching**, hand the same call to the library that will hold
|
|
146
|
+
the answer. `fetchQuery` goes to the server every time; staleness,
|
|
147
|
+
revalidation and deduplication belong to the library, not to the fetcher:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
118
150
|
// TanStack Query
|
|
119
151
|
useQuery({
|
|
120
152
|
queryKey: ["listings", kind],
|
|
@@ -125,10 +157,6 @@ useQuery({
|
|
|
125
157
|
useSWR(["listings", kind], () => fetchQuery(getListings, [kind]))
|
|
126
158
|
```
|
|
127
159
|
|
|
128
|
-
`fetchQuery` goes to the server every time. That is what a fetcher needs:
|
|
129
|
-
staleness, revalidation and deduplication belong to the library holding the
|
|
130
|
-
answer, not to the thing that fetches it.
|
|
131
|
-
|
|
132
160
|
:::caution[Keep the arrow]
|
|
133
161
|
TanStack calls a bare `queryFn` with its own context — `{ client, queryKey,
|
|
134
162
|
meta, signal }` — and a server function serialises whatever it is handed, so
|
|
@@ -152,7 +180,7 @@ useInfiniteQuery({
|
|
|
152
180
|
})
|
|
153
181
|
|
|
154
182
|
// Data that changes while you watch. No live connection needed.
|
|
155
|
-
useQuery({ queryKey: ["seats"], queryFn: () => fetchQuery(getSeats
|
|
183
|
+
useQuery({ queryKey: ["seats"], queryFn: () => fetchQuery(getSeats), refetchInterval: 2_000 })
|
|
156
184
|
```
|
|
157
185
|
|
|
158
186
|
Page one comes from a server component and costs no request; later pages are
|
|
@@ -166,6 +194,95 @@ trip you seeded to avoid happens anyway. SWR's `fallbackData` has the same
|
|
|
166
194
|
shape of caveat, and `refetchInterval` pauses while the tab is hidden.
|
|
167
195
|
:::
|
|
168
196
|
|
|
197
|
+
### Live data
|
|
198
|
+
|
|
199
|
+
A value that keeps changing while someone watches is not what `query()` is —
|
|
200
|
+
a query answers once and is cacheable — but a query is the natural thing to
|
|
201
|
+
read *again*:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
import { usePolling } from '@rsc-kit/core/usePolling'
|
|
205
|
+
|
|
206
|
+
const { data: seats } = usePolling(() => fetchQuery(getSeats), { every: 2_000 })
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
It reuses what you already wrote, needs no special route, and goes through the
|
|
210
|
+
query's `Cache-Control` — a thousand tabs polling the same seat count become
|
|
211
|
+
one origin request per interval at the CDN. It pauses while the tab is hidden
|
|
212
|
+
and never overlaps two reads.
|
|
213
|
+
|
|
214
|
+
**Until it settles.** A job that is queued, then running, then done wants
|
|
215
|
+
polling that stops on its own. `until` says when a read is the last one, and
|
|
216
|
+
`onSettled` fires once, on that read. The result is the data, and what to do
|
|
217
|
+
when it settles is the page's to decide — so the same primitive serves two
|
|
218
|
+
pages that want different things:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
// A list of jobs, server-rendered, some still running: poll those until they
|
|
222
|
+
// settle, then re-render the card through the server path that built it.
|
|
223
|
+
usePolling(() => fetchQuery(jobStatus, [id]), {
|
|
224
|
+
every: 2_000,
|
|
225
|
+
enabled: !isTerminal(job),
|
|
226
|
+
until: isTerminal,
|
|
227
|
+
onSettled: () => refresh('page'),
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// The page that started the job and owns its state machine: the value in
|
|
231
|
+
// hand, and the terminal state driving the next step.
|
|
232
|
+
const { data, status } = usePolling(() => fetchQuery(jobStatus, [id]), {
|
|
233
|
+
every: 1_500,
|
|
234
|
+
until: isTerminal,
|
|
235
|
+
onSettled: (final) => dispatch({ type: final.status }),
|
|
236
|
+
})
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Settled means stopped: nothing is read again until `refresh()`, which starts
|
|
240
|
+
it over, or the inputs change. A page that has to survive a reload keeps the
|
|
241
|
+
job's id where it likes and passes it back in; the hook starts polling the
|
|
242
|
+
moment it mounts with one.
|
|
243
|
+
|
|
244
|
+
**No library needed.** `data` is state; a component that only wants to show
|
|
245
|
+
the value reads it. When the value already lives somewhere, `onData` hands
|
|
246
|
+
every answer to it — a `useState`, a reducer, or a cache library — so that
|
|
247
|
+
stays the source of truth:
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
const [seats, setSeats] = useState(initial)
|
|
251
|
+
usePolling(() => fetchQuery(getSeats), { every: 2_000, onData: setSeats }) // useState
|
|
252
|
+
|
|
253
|
+
usePolling(() => fetchQuery(getSeats), {
|
|
254
|
+
every: 2_000,
|
|
255
|
+
onData: (s) => queryClient.setQueryData(['seats'], s), // TanStack
|
|
256
|
+
})
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Neither hook needs a cache library; TanStack and SWR are shown because a page
|
|
260
|
+
that already uses one should keep it as the one place the value lives.
|
|
261
|
+
|
|
262
|
+
A read that fails does not stop the polling — the next interval reads again —
|
|
263
|
+
and it reaches you two ways: `error` is the state, and `onError` fires per
|
|
264
|
+
failed read, for a toast or a log, so a page that keeps its own state does not
|
|
265
|
+
have to watch `error` in an effect:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
usePolling(() => fetchQuery(getSeats), {
|
|
269
|
+
every: 2_000,
|
|
270
|
+
onData: setSeats,
|
|
271
|
+
onError: (e, { failures }) => {
|
|
272
|
+
if (failures === 3) toast.error('Could not refresh seats')
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`failures` counts the failed reads in a row and a success resets it, so the
|
|
278
|
+
third failure can be a toast where the first was a blip, without the page
|
|
279
|
+
keeping a counter of its own.
|
|
280
|
+
|
|
281
|
+
**When something can push**, the server sends events instead and the browser
|
|
282
|
+
holds one connection per tab. That is a route, not a query:
|
|
283
|
+
[a route that streams](/guides/api-routes/#a-route-that-streams). The trade-off
|
|
284
|
+
is stated there.
|
|
285
|
+
|
|
169
286
|
### Where it will not work
|
|
170
287
|
|
|
171
288
|
`fetchQuery` only works in the browser. React refuses a server-function call
|
|
@@ -310,6 +427,19 @@ and development warns when it happens.
|
|
|
310
427
|
|
|
311
428
|
## Caching
|
|
312
429
|
|
|
430
|
+
There is no cache in `fetchQuery`, and there will not be one: a client cache
|
|
431
|
+
is where "small and ours" goes wrong — a `Map` first, then staleness, then
|
|
432
|
+
invalidation, then dedupe across components, and it ends as a worse TanStack
|
|
433
|
+
Query that every guide has to teach. What this package owns is the part a
|
|
434
|
+
library cannot: whether the answer is cacheable *at all*, which is an HTTP
|
|
435
|
+
question. So the choice is a ladder, and most reads stop on the first rung:
|
|
436
|
+
|
|
437
|
+
| you need | use |
|
|
438
|
+
| --- | --- |
|
|
439
|
+
| the value, now | `fetchQuery` in a handler and `setState` — every call goes to the server |
|
|
440
|
+
| the answer to survive a reload, or a CDN to serve it | `cache` on the query, below — the browser and the CDN hold it, with no code in the page |
|
|
441
|
+
| staleness, background refresh, optimistic updates, one value shared across components | TanStack Query or SWR, with `fetchQuery` as the fetcher |
|
|
442
|
+
|
|
313
443
|
Answers default to `private, no-store`. A query may read the session, and a
|
|
314
444
|
cacheable answer to a personal read is how one visitor is served another's data.
|
|
315
445
|
|
package/guides/quick-start.md
CHANGED
|
@@ -14,6 +14,37 @@ That is it. You get a page, a layout, a client component and a Vite config,
|
|
|
14
14
|
wired together and running. There is no server file — Nitro builds one from the
|
|
15
15
|
preset in that config when you build.
|
|
16
16
|
|
|
17
|
+
It asks a few questions, each with a flag for a script: where it runs, the
|
|
18
|
+
React Compiler, Tailwind, oxlint, and which **validation library** you want —
|
|
19
|
+
Zod, Valibot or ArkType. That one is asked once because everything schema-shaped
|
|
20
|
+
hangs off it: a form's schema, `action.input()`, a page's `searchParams`, and
|
|
21
|
+
**typed environment variables**, which it offers next. Say yes and you get
|
|
22
|
+
`src/env.ts`:
|
|
23
|
+
|
|
24
|
+
```ts title="src/env.ts"
|
|
25
|
+
export const env = createEnv({
|
|
26
|
+
server: {
|
|
27
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
28
|
+
// DATABASE_URL: z.url(),
|
|
29
|
+
},
|
|
30
|
+
clientPrefix: 'PUBLIC_',
|
|
31
|
+
client: {},
|
|
32
|
+
runtimeEnv: { ...process.env, ...import.meta.env },
|
|
33
|
+
emptyStringAsUndefined: true,
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A missing or malformed variable is refused at startup with its name, not as an
|
|
38
|
+
`undefined` three calls later; `env.DATABASE_URL` is typed everywhere it is
|
|
39
|
+
read; and a server variable can never reach the browser — a browser-readable
|
|
40
|
+
one has to start with `PUBLIC_`. That is [`@t3-oss/env-core`](https://env.t3.gg),
|
|
41
|
+
with the schema in whichever library you chose. `.env.example` is written
|
|
42
|
+
beside it and is the one that gets committed.
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
bun create rsc-kit@latest my-app --validation=valibot --env # scripted
|
|
46
|
+
```
|
|
47
|
+
|
|
17
48
|
When you are ready to ship:
|
|
18
49
|
|
|
19
50
|
```sh
|
package/guides/redirects.md
CHANGED
|
@@ -26,6 +26,20 @@ the layouts you are inside stay mounted, and only the part below them changes.
|
|
|
26
26
|
The url that redirected replaces its history entry rather than adding one, so
|
|
27
27
|
Back does not land on it and redirect you again.
|
|
28
28
|
|
|
29
|
+
## With a query string
|
|
30
|
+
|
|
31
|
+
`search` is typed to the destination page's own `searchParams` schema, the
|
|
32
|
+
same check `Link` puts on its `search` prop — a key the page never reads, or
|
|
33
|
+
a number written as text, does not compile:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
redirect('/', { search: { auth: true } }); // → /?auth=true
|
|
37
|
+
redirect('/search', { search: { q, page: 2 } }); // checked against /search's schema
|
|
38
|
+
redirect('/old', { status: 308 }); // a permanent one
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A bare second argument is still the status: `redirect('/old', 308)`.
|
|
42
|
+
|
|
29
43
|
## Where you call it matters
|
|
30
44
|
|
|
31
45
|
This is the part worth understanding, because it is also the security-relevant
|
|
@@ -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/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.
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
## Offline
|
|
104
|
+
|
|
105
|
+
`rscKit({ offline: true })` writes a service worker into `.output/public`
|
|
106
|
+
alongside the assets, so a page someone has visited survives a reload with no
|
|
107
|
+
network. Off by default, and covered in [Offline](/guides/offline#surviving-without-one).
|
|
108
|
+
|
|
109
|
+
## Why `nitro` is pinned
|
|
110
|
+
|
|
111
|
+
The generated `package.json` pins an exact version rather than a range:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
"nitro": "3.0.260903-beta"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Nitro's own `latest` tag is a dated prerelease, and it sorts **above** the plain
|
|
118
|
+
`3.0.0` on npm. So `^3.0.0` resolves to the older release, which builds without
|
|
119
|
+
complaint and then answers 404 to every route. TanStack Start pins a dated beta
|
|
120
|
+
for the same reason.
|
|
121
|
+
|
|
122
|
+
## Assets are Nitro's
|
|
123
|
+
|
|
124
|
+
The build writes browser assets to `.output/public`, and Nitro serves them from
|
|
125
|
+
its own root. There is no `assetsDir` or `assetsUrl` to set: both were removed,
|
|
126
|
+
and `rscKit()` refuses a config that still passes them rather than reading a
|
|
127
|
+
prefix and ignoring it.
|
|
128
|
+
|
|
129
|
+
The alternative was the silent version — the markup asks for the app's prefix,
|
|
130
|
+
Nitro answers at its own, and the page arrives unstyled and never hydrates with
|
|
131
|
+
nothing logged anywhere.
|
|
132
|
+
|
|
133
|
+
Assets live in `.output/public` and are served by the same process that serves
|
|
134
|
+
your pages. If you want nginx or a CDN serving them instead, point it at
|
|
135
|
+
`.output/public` — that directory is the deployment.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
Next: [Deploying →](/hosts/deployment)
|
|
140
|
+
|
|
141
|
+
## Which entry point is whose
|
|
142
|
+
|
|
143
|
+
`@rsc-kit/core/host` is the engine's front door for a **host adapter** — the
|
|
144
|
+
handler, and beside it the few functions an adapter needs when it embeds the
|
|
145
|
+
engine. Several of those are re-exports of app-facing modules, so an editor's
|
|
146
|
+
auto-import may offer `redirect` or `revalidate` from `host`. They are the
|
|
147
|
+
same functions; an app imports them from their own entry points:
|
|
148
|
+
|
|
149
|
+
| in an app | not |
|
|
150
|
+
| --- | --- |
|
|
151
|
+
| `@rsc-kit/core/redirect` | `@rsc-kit/core/host` |
|
|
152
|
+
| `@rsc-kit/core/revalidate` | `@rsc-kit/core/host` |
|
|
153
|
+
|
|
154
|
+
Only an adapter imports `host`. The re-exports there are marked internal, so
|
|
155
|
+
they sort below the app entry in a completion list.
|