@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/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
- hand `fetchQuery` to your cache library:
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
- import { fetchQuery } from "@rsc-kit/core/queryClient"
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, []), refetchInterval: 2_000 })
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
 
@@ -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
@@ -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
+ ```
@@ -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`
@@ -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.