@rsc-kit/mcp 0.16.3 → 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/answers.d.ts +14 -1
- package/dist/answers.js +76 -34
- package/dist/answers.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +434 -14
- package/dist/recipes.js.map +1 -1
- package/dist/report.d.ts +12 -0
- package/dist/report.js +17 -15
- package/dist/report.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 +12 -4
- package/guides/deployment.md +129 -0
- package/guides/domains.md +129 -0
- package/guides/emails.md +90 -0
- package/guides/errors.md +16 -2
- package/guides/go.md +194 -0
- package/guides/images.md +10 -4
- package/guides/index.json +50 -0
- package/guides/installation.md +31 -13
- package/guides/instrumentation.md +91 -0
- package/guides/introduction.md +4 -0
- package/guides/laravel.md +406 -0
- package/guides/mcp.md +1 -1
- package/guides/metadata.md +1 -1
- package/guides/offline.md +8 -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/routing.md +5 -1
- package/guides/seo-files.md +140 -0
- package/guides/typed-routes.md +26 -0
- package/guides/view-transitions.md +53 -7
- package/guides/where-it-runs.md +155 -0
- package/guides/your-own-backend.md +238 -0
- package/package.json +2 -2
|
@@ -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
|
@@ -59,7 +59,7 @@ The reason a page is dynamic is **recorded, not guessed**:
|
|
|
59
59
|
Rendered by app/locale/page.
|
|
60
60
|
Ships 85 kB of javascript, gzipped.
|
|
61
61
|
|
|
62
|
-
Why it is not stored whole:
|
|
62
|
+
Why it is not stored whole: cookies(), headers() stream per request; the rest is stored
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
That matters more than it sounds. Asked to "make this page faster", an agent
|
package/guides/metadata.md
CHANGED
|
@@ -117,7 +117,7 @@ one above it. A page that exports no title gets the `default`.
|
|
|
117
117
|
| `description` | `<meta name="description">` |
|
|
118
118
|
| `keywords` | `<meta name="keywords">`, joined with commas if you pass an array |
|
|
119
119
|
| `author` | `<meta name="author">` |
|
|
120
|
-
| `robots` | `<meta name="robots">` |
|
|
120
|
+
| `robots` | `<meta name="robots">` — a string, or the object Next takes: `{ index: false, follow: false }` is `noindex, nofollow`; `googleBot` is `<meta name="googlebot">` |
|
|
121
121
|
| `metadataBase` | nothing itself — it is what makes every relative url below absolute |
|
|
122
122
|
| `openGraph` | `<meta property="og:…">`, one per field; each image brings its size and alt |
|
|
123
123
|
| `twitter` | `<meta name="twitter:…">` |
|
package/guides/offline.md
CHANGED
|
@@ -74,6 +74,14 @@ own error page: no script runs, so nothing held in JavaScript is reachable.
|
|
|
74
74
|
|
|
75
75
|
A service worker is the only thing that survives that.
|
|
76
76
|
|
|
77
|
+
Nothing of it runs in development. A worker is registered per origin, and one
|
|
78
|
+
a production run left on `localhost:3000` would otherwise sit in front of the
|
|
79
|
+
dev server on the same port — a stored page is cache-first, so a refresh would
|
|
80
|
+
bring back yesterday's document and a `504 Outdated Optimize Dep` for each of
|
|
81
|
+
its modules. The dev server answers `/sw.js` with a worker that removes the old
|
|
82
|
+
one, its caches, and reloads the pages it held; the browser asks for that file
|
|
83
|
+
on every navigation, so it happens on the first load.
|
|
84
|
+
|
|
77
85
|
### What is cached, and when
|
|
78
86
|
|
|
79
87
|
| | |
|
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
|