@rsc-kit/mcp 0.17.0 → 0.18.1

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.
@@ -0,0 +1,406 @@
1
+ # Laravel
2
+
3
+ > React Server Components in front, a Laravel application behind them.
4
+
5
+ ## The model
6
+
7
+ A [BAP — Backend-Answered Pages](/hosts/backend-answered-pages): the page is
8
+ rendered in front of Laravel rather than by it. The renderer — what `vite dev`
9
+ serves, and what Nitro builds into `.output/server` — is the front door; it
10
+ routes, renders and serves the frozen pages and the assets. Laravel answers
11
+ what only Laravel can — the data, the session, whether a route may render —
12
+ on one private endpoint, and keeps every route of its own: `/login`, a Blade
13
+ page, a webhook, a file under `/storage` are forwarded to it as they are.
14
+
15
+ A page reads from PHP the way a SPA would have fetched, except it is a server
16
+ component, so there is no loading state to write and nothing ships to the
17
+ browser:
18
+
19
+ ```tsx title="resources/js/app/orders/page.tsx"
20
+ export default async function Orders() {
21
+ const orders = await rpc<Order[]>('Orders.recent', 5) // → App\Rsc\Orders::recent(5)
22
+
23
+ return <ul>{orders.map((o) => <li key={o.id}>{o.number}</li>)}</ul>
24
+ }
25
+ ```
26
+
27
+ The call runs as the visitor — their cookie travels with it, so `auth()->user()`
28
+ inside `Orders::recent` is them. Laravel stops being the app that serves pages
29
+ and becomes the app that answers them; what that means for what stays in
30
+ Laravel is on [the BAP page](/hosts/backend-answered-pages#what-the-backend-is-then).
31
+
32
+ ## Install
33
+
34
+ ```sh
35
+ composer require rsc-kit/laravel
36
+ php artisan rsc:install
37
+ ```
38
+
39
+ `rsc:install` does the PHP half — publishes `config/rsc.php`, generates
40
+ `RSC_HOST_CALL_SECRET` into `.env` — and runs `rsc-kit init` for the
41
+ JavaScript half. Nothing you already have is overwritten: where a file exists,
42
+ the exact edit is printed for you to make instead.
43
+
44
+ What lands:
45
+
46
+ | | |
47
+ | --- | --- |
48
+ | `config/rsc.php` | the settings, published |
49
+ | `.env` | `RSC_HOST_CALL_SECRET`, generated once |
50
+ | `vite.config.ts` | the renderer's config, with `rscKit()` in it |
51
+ | `resources/js/app/` | a root layout and a page |
52
+ | `package.json` | `dev`, `build` and `start`, and `laravel-vite-plugin` removed |
53
+
54
+ Requirements: PHP 8.3 and Laravel 13; Bun or Node 24 for the renderer; Vite
55
+ 8, which the plugin needs and a Laravel application does not ship — the
56
+ installer reports the version it found rather than upgrading it for you.
57
+
58
+ ### One Vite config
59
+
60
+ A Laravel application arrives with a `vite.config.js` that
61
+ `laravel-vite-plugin` owns: it sets the base, the public directory, the
62
+ output directory, the input list and the dev server's origin. So does the
63
+ renderer's build, and whichever plugin runs second wins.
64
+
65
+ There used to be a second file for that. There is not now, because once the
66
+ renderer owns the frontend there is nothing left for `laravel-vite-plugin` to
67
+ do — no `@vite` directive, no `public/hot`, no Blade asset pipeline. `init`
68
+ moves the stock config aside as `vite.config.blade.js`, writes its own
69
+ `vite.config.ts`, and drops the plugin from `package.json`. If something of
70
+ yours still needs the Blade pipeline, run it from the moved file:
71
+ `vite --config vite.config.blade.js`.
72
+
73
+ ```ts title="vite.config.ts"
74
+ import { defineConfig } from 'vite'
75
+ import react from '@vitejs/plugin-react'
76
+ import { rscKit } from '@rsc-kit/core/vite'
77
+ import { nitro } from 'nitro/vite'
78
+
79
+ export default defineConfig({
80
+ plugins: [
81
+ nitro({ preset: 'bun', serveStatic: 'inline' }),
82
+ rscKit({
83
+ sourceDir: 'resources/js',
84
+ outDir: 'bootstrap/rsc/vite',
85
+ hotFile: 'public/rsc-hot',
86
+ }),
87
+ react(),
88
+ ],
89
+ })
90
+ ```
91
+
92
+ `outDir` is under `bootstrap/` because that is already where a Laravel
93
+ application keeps generated code. `hotFile` is how Laravel finds a running
94
+ dev server — read below.
95
+
96
+ ### The scripts
97
+
98
+ ```json title="package.json"
99
+ {
100
+ "scripts": {
101
+ "dev": "php artisan rsc:action-manifest && vite",
102
+ "build": "php artisan rsc:action-manifest && vite build",
103
+ "start": "bun .output/server/index.mjs"
104
+ }
105
+ }
106
+ ```
107
+
108
+ `rsc:action-manifest` runs first on purpose. Your server actions are found by
109
+ reflection through Composer's autoloader — the only thing that sees what a
110
+ class inherits from its parents and traits — so PHP writes the map to
111
+ `rsc-host-actions.json` and the build reads it. Part of the command rather
112
+ than a step to remember: a stale map names a method that has since been
113
+ renamed, and nothing fails until the browser calls it.
114
+
115
+ If you had customised `dev` or `build` yourself, `init` leaves them alone and
116
+ puts the renderer's at `rsc:dev` and `rsc:build`, and says so.
117
+
118
+ ## Development
119
+
120
+ ```sh
121
+ npm run dev
122
+ ```
123
+
124
+ Then open the application at its own address — `my-app.test`, whatever you
125
+ already use. Vite **is** the renderer in development. It writes
126
+ `public/rsc-hot` while it runs, Laravel reads that file, and any request
127
+ Laravel does not route is handed through to the address inside it. Stop the
128
+ dev server and the file goes with it, and so does the proxy.
129
+
130
+ The other direction works too. The renderer reads `APP_URL` and
131
+ `RSC_HOST_CALL_SECRET` from the app's own `.env`, and a url the route tree does
132
+ not own — `/login`, a Blade page, a webhook, a file under `/storage` — is
133
+ forwarded to Laravel with `X-Forwarded-Host` set. So the renderer's own origin
134
+ is the whole application as well, not the RSC half of it, and a browser can
135
+ sit on either. The two proxies cannot loop: each marks what it forwards, and a
136
+ url neither side owns is a 404 from whichever saw it second.
137
+
138
+ :::caution[`php artisan serve` needs workers]
139
+ `artisan serve` is `php -S`: one worker by default, which cannot answer a
140
+ second request while it is blocked on the first. When Laravel proxies a page
141
+ it holds that worker for the whole render, and the renderer calls back to the
142
+ same server for the page's data — with nobody left to answer. The call would
143
+ time out after 30 seconds into a page that answers **200** with its data
144
+ missing, because a failed host call is reported inside its Suspense boundary.
145
+ The package refuses that shape up front instead.
146
+
147
+ Give it workers and the refusal stands down:
148
+
149
+ ```ini title=".env"
150
+ PHP_CLI_SERVER_WORKERS=4
151
+ ```
152
+
153
+ ```sh
154
+ php artisan serve --no-reload
155
+ ```
156
+
157
+ `--no-reload` is Laravel's rule, not this package's: without it `serve`
158
+ warns that it cannot respect the variable and starts one worker anyway.
159
+ Herd, Valet, PHP-FPM and Octane run several without being asked. Or skip
160
+ the proxy: open the renderer's origin directly and let Laravel answer host
161
+ calls only, one short request each.
162
+ :::
163
+
164
+ ## Calling PHP
165
+
166
+ A class under `app/Rsc/` is discovered by convention. Its public methods are
167
+ what `rpc()` can reach, named `Class.method`; a class with `__invoke` is
168
+ reached by its class name alone.
169
+
170
+ ```php title="app/Rsc/Orders.php"
171
+ namespace App\Rsc;
172
+
173
+ class Orders
174
+ {
175
+ public function __construct(private OrderRepository $orders) {}
176
+
177
+ public function recent(int $limit = 5): array
178
+ {
179
+ return $this->orders->forUser(auth()->user())->latest()->take($limit)->get()->all();
180
+ }
181
+ }
182
+ ```
183
+
184
+ ```tsx title="resources/js/app/orders/page.tsx"
185
+ export default async function Orders() {
186
+ const orders = await rpc<Order[]>('Orders.recent', 5)
187
+
188
+ return <ul>{orders.map((o) => <li key={o.id}>{o.number}</li>)}</ul>
189
+ }
190
+ ```
191
+
192
+ The class is resolved through the container, so constructor injection works.
193
+ `auth()->user()` is the visitor: the renderer forwards their `Cookie` header on
194
+ every call, `EncryptCookies` and `StartSession` run on the endpoint, and the
195
+ session bound is theirs.
196
+
197
+ `rpc()` is a global the renderer installs, declared for the typechecker in
198
+ `.rsc-kit/rsc-env.d.ts`. It exists in server components during a render and
199
+ nowhere else — a client component reaches PHP through a server action.
200
+
201
+ ### Refusing
202
+
203
+ Attributes on the class or the method, and the refusal travels as itself
204
+ rather than as a broken page:
205
+
206
+ ```php
207
+ use RscKit\Attributes\Authenticated;
208
+ use RscKit\Attributes\Can;
209
+ use Illuminate\Routing\Attributes\Controllers\Middleware;
210
+
211
+ #[Authenticated]
212
+ #[Middleware('throttle:60,1')]
213
+ class Orders
214
+ {
215
+ #[Can('update', Order::class)]
216
+ public function cancel(int $id): void { … }
217
+ }
218
+ ```
219
+
220
+ | thrown in PHP | reaches the render as |
221
+ | --- | --- |
222
+ | `AuthenticationException` | the engine's `ServerAuthenticationError`: the request answers **401** |
223
+ | `AuthorizationException` | its `ServerAuthorizationError`: **403** |
224
+ | `ValidationException` | a `validationErrors` map, field to messages, on the form that submitted |
225
+ | a middleware `abort()` | its own status: throttle's 429 stays a 429 |
226
+ | `RscRedirectException` | a redirect the browser performs |
227
+
228
+ A form request type-hinted on a method is resolved and validated before the
229
+ method runs, which is where `ValidationException` usually comes from.
230
+
231
+ ## Server actions
232
+
233
+ A class under `app/Rsc/Actions/` is a server action. `rsc:action-manifest`
234
+ writes the map, and the build writes a `"use server"` module beside your pages
235
+ exporting one function per method, named `classMethod`:
236
+
237
+ ```php title="app/Rsc/Actions/Orders.php"
238
+ namespace App\Rsc\Actions;
239
+
240
+ use RscKit\Rsc;
241
+
242
+ class Orders
243
+ {
244
+ #[Authenticated]
245
+ public function cancel(CancelOrder $request): void
246
+ {
247
+ $request->order()->cancel();
248
+
249
+ Rsc::revalidate('orders');
250
+ }
251
+ }
252
+ ```
253
+
254
+ ```tsx title="resources/js/app/orders/CancelButton.tsx"
255
+ 'use client'
256
+ import { ordersCancel } from '../../server-actions.generated'
257
+
258
+ export function CancelButton({ id }: { id: number }) {
259
+ return <button onClick={() => ordersCancel(id)}>Cancel</button>
260
+ }
261
+ ```
262
+
263
+ `Rsc::revalidate('orders')` says what the action made stale. The names ride
264
+ back with the result, and the answer to the action carries the re-rendered
265
+ region with it rather than the browser being told to ask again — the same
266
+ thing `revalidate()` does from a JavaScript action, described in
267
+ [Sections](/guides/sections).
268
+
269
+ ## Route middleware
270
+
271
+ The renderer owns the route table, but Laravel still decides whether a route
272
+ may render. A `middleware.ts` beside or above a page names middleware in Laravel's
273
+ own vocabulary:
274
+
275
+ ```ts title="resources/js/app/admin/middleware.ts"
276
+ export const middleware = ['auth', 'verified', 'can:update,post']
277
+ ```
278
+
279
+ Before anything at or below that directory renders, the names are sent to
280
+ PHP and run through the real pipeline against the real request. It fails
281
+ closed: anything that is not a literal `true` is a refusal, so a middleware
282
+ that aborts, redirects or simply errors keeps the page from rendering rather
283
+ than being read as silence. A page frozen at build time is asked too, before
284
+ the file is served — a guard that held until the build froze the page would
285
+ otherwise stop holding, silently.
286
+
287
+ ## Production
288
+
289
+ ```sh
290
+ composer install --no-dev --optimize-autoloader
291
+ npm ci && npm run build
292
+ ```
293
+
294
+ The build runs `rsc:action-manifest`, so PHP has to boot on the build
295
+ machine. It writes `.output/` — the server, the engine, the frozen pages and
296
+ the assets — which travels with the deployment; nothing in it is read from the
297
+ source tree at runtime.
298
+
299
+ Both processes read `RSC_HOST_CALL_SECRET`, and the renderer also needs
300
+ `APP_URL` (or `RSC_BACKEND`) to know where Laravel is. Run it with the app's
301
+ `.env` and it has both:
302
+
303
+ ```ini title="/etc/systemd/system/rsc-renderer.service"
304
+ [Service]
305
+ User=www-data
306
+ WorkingDirectory=/var/www/app
307
+ EnvironmentFile=/var/www/app/.env
308
+ ExecStart=/usr/local/bin/bun /var/www/app/.output/server/index.mjs
309
+ Restart=always
310
+ ```
311
+
312
+ ### Which process faces the internet
313
+
314
+ The renderer, unless you have a reason. It serves assets and frozen pages
315
+ straight off disk, renders the rest, forwards what it does not own to Laravel,
316
+ and holds a PHP worker for the length of a **host call** — a query, a policy
317
+ check — never a whole render.
318
+
319
+ Where it forwards to is decided at build time: `vite build` reads `APP_URL`
320
+ (or `RSC_BACKEND`) from `.env` and bakes it in, while host calls read the same
321
+ names from the process at runtime. Building on a machine whose `.env` names a
322
+ different backend than production's means setting `RSC_BACKEND` for the
323
+ build.
324
+
325
+ ```nginx
326
+ server {
327
+ server_name example.com;
328
+
329
+ location / {
330
+ proxy_pass http://127.0.0.1:3000;
331
+ proxy_http_version 1.1;
332
+ proxy_buffering off; # Suspense boundaries stream; buffering holds them to the end
333
+ }
334
+
335
+ # The host-call endpoint is PHP's, and must not be reachable from outside.
336
+ location /__rsc/host-call {
337
+ allow 127.0.0.1;
338
+ deny all;
339
+ include fastcgi_params;
340
+ fastcgi_pass unix:/run/php/php8.3-fpm.sock;
341
+ }
342
+ }
343
+ ```
344
+
345
+ Laravel needs the renderer in `trustProxies` for `url()`, `route()` and its
346
+ redirects to come out against the public origin rather than its own.
347
+
348
+ Setting `RSC_RENDERER_URL` puts Laravel in front instead: it proxies anything
349
+ it does not route, which is what development does. In production it has the
350
+ cost the caution above describes, at scale — a worker is held for the whole
351
+ render, the render calls back for data, and with `W` workers that caps you at
352
+ `W − 1` concurrent renders, deadlocking when every worker is busy proxying.
353
+ If you want it anyway, give `/__rsc/host-call` its own PHP-FPM pool so a
354
+ proxying worker can never starve a data worker.
355
+
356
+ ### What a host call costs
357
+
358
+ The transport is loopback, well under a millisecond. The cost is Laravel
359
+ handling a request: under PHP-FPM every call boots the framework, under
360
+ [Octane](https://laravel.com/docs/octane) it stays booted and a call is
361
+ closer to a millisecond. What matters is how many *sequential* calls a page
362
+ needs. A page with no `middleware.ts` middleware makes no guard call; sibling
363
+ components awaiting `rpc()` are rendered concurrently, so their calls
364
+ overlap — and calls issued in the same tick travel as **one** request, a
365
+ batch the package answers in one Laravel request; `cache()` dedupes
366
+ identical calls within a request; a frozen page makes none at all and a
367
+ shell only for its holes. A guarded page is therefore typically two Laravel
368
+ requests — the guard, then the batch of its reads — and a host-call-heavy app
369
+ is better served by Octane, where each is a millisecond rather than a boot.
370
+
371
+ ## Why there is a secret
372
+
373
+ Inertia runs a second process too, and posts to it with no secret at all. The
374
+ difference is direction. Inertia's SSR server *receives*: Laravel has already
375
+ resolved the data and hands it over. This one is *asked*: the renderer has no
376
+ data, so it calls back and runs a named function in your application, against
377
+ your database, under the visitor's session — and that function has to be a
378
+ Laravel route, because a Laravel route is the only thing that has your session,
379
+ your container and your models. It is on the same public surface as the rest
380
+ of the site.
381
+
382
+ So without a secret the endpoint is not registered at all — absent, not open.
383
+ The renderer presents it in `X-Rsc-Host-Secret`; a mismatch is 403 before
384
+ anything is dispatched. The visitor's cookie also travels, and the two answer
385
+ different questions: the cookie says who the page is *for*, the secret says who
386
+ is *asking*. Neither substitutes for the other, which is also why the endpoint
387
+ carries no CSRF check — a browser can be tricked into sending cookies, never
388
+ into sending a header it does not know.
389
+
390
+ Restrict it at the web server as well, as above. The secret is the layer this
391
+ package can guarantee; the network is the one it cannot.
392
+
393
+ ## Build-time
394
+
395
+ The prerender probe replaces `rpc()` with a promise that never settles, so a
396
+ component awaiting PHP suspends by construction and the page is classified as
397
+ a shell or dynamic — never frozen with yesterday's rows baked in. That is a
398
+ stronger guarantee than a pure JavaScript app gets. See
399
+ [Partial prerendering](/guides/ppr).
400
+
401
+ ---
402
+
403
+ The package is `rsc-kit/laravel` on Packagist, source and issues at
404
+ [rsc-kit/laravel](https://github.com/rsc-kit/laravel). A backend in another
405
+ language answers the same endpoint — [Go](/hosts/go) does — and the contract
406
+ is [Your own backend →](/hosts/your-own-backend)
package/guides/mcp.md CHANGED
@@ -18,11 +18,15 @@ The scaffold already did. Every project gets a `.mcp.json` at its root:
18
18
  ```json title=".mcp.json"
19
19
  {
20
20
  "mcpServers": {
21
- "rsc-kit": { "command": "npx", "args": ["-y", "@rsc-kit/mcp"] }
21
+ "rsc-kit": { "command": "bunx", "args": ["@rsc-kit/mcp"] }
22
22
  }
23
23
  }
24
24
  ```
25
25
 
26
+ On a Node project, `"command": "npx", "args": ["-y", "@rsc-kit/mcp"]` — the
27
+ same server, launched by the runtime the project has. The scaffold writes
28
+ whichever fits.
29
+
26
30
  Claude Code reads that as project-scoped configuration and asks you to approve
27
31
  it the first time it starts the server. Nothing is installed until then, and
28
32
  nothing about your build or dev server changes — it is a file an agent reads.
@@ -32,12 +36,14 @@ For a project that predates the file, `create-rsc-kit init` writes it (and
32
36
  leaves one that is already there alone), or add it by hand:
33
37
 
34
38
  ```sh
35
- claude mcp add rsc-kit -- npx -y @rsc-kit/mcp
39
+ claude mcp add rsc-kit -- bunx @rsc-kit/mcp # or: npx -y @rsc-kit/mcp
36
40
  ```
37
41
 
38
- Any other MCP client takes the same entry — a stdio server, command `npx`,
39
- arguments `-y @rsc-kit/mcp` — in its own file: `.cursor/mcp.json` under
40
- `mcpServers`, `.vscode/mcp.json` under `servers`.
42
+ Any other MCP client takes the same entry — a stdio server, command `bunx`
43
+ (or `npx`), argument `@rsc-kit/mcp` — in its own file: `.cursor/mcp.json`
44
+ under `mcpServers`, `.vscode/mcp.json` under `servers`. If a client reports
45
+ the server as unreachable, run the command by hand: `bunx @rsc-kit/mcp`
46
+ waits on stdin for a client, and printing nothing is it working.
41
47
 
42
48
  ## What it answers about your app
43
49
 
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