@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.
@@ -0,0 +1,91 @@
1
+ # Startup
2
+
3
+ > instrumentation.ts runs once, before anything else.
4
+
5
+ Some setup belongs to the process, not to a page: validating the
6
+ environment, configuring a shared package, warming a connection. Put it in
7
+ `src/instrumentation.ts` and it runs once — before any page module
8
+ evaluates, and before the first request.
9
+
10
+ ```ts title="src/instrumentation.ts"
11
+ // Runs at import, before any page: a bad variable stops the server here.
12
+ import './env'
13
+
14
+ // Anything asynchronous the app needs before it serves. The first render
15
+ // waits for it.
16
+ export async function register() {
17
+ await db.connect()
18
+ }
19
+ ```
20
+
21
+ The name is Next's, so a reader arriving from there knows what it is. Both
22
+ halves are optional: a file with only imports is a bootstrap, a file with
23
+ only `register()` is a hook.
24
+
25
+ ## Why it is a framework file
26
+
27
+ Two things make this a file the framework has to know about rather than a
28
+ module the app imports.
29
+
30
+ **Import order.** A page that configures a shared package at import time —
31
+ a logger, an ORM, a metrics client — runs before any module the app could
32
+ put in front of it, because the generated entry imports the pages. The entry
33
+ imports `instrumentation.ts` *first*, so whatever it sets up is set up when
34
+ the first page module evaluates. Importing a bootstrap module from every
35
+ file that needs it works until someone forgets, and the failure is a page
36
+ that throws "not configured" for the one visitor who reached it first.
37
+
38
+ **Before the first request.** `register()` is awaited by every entry point
39
+ a render can come through — the built server, the dev server, the
40
+ prerender, a route's middleware check, a server action, an api route — so
41
+ "before the first render" holds without the app knowing the list.
42
+
43
+ ## When it runs
44
+
45
+ | where | when |
46
+ | --- | --- |
47
+ | a server (`bun`, `node`, any long-lived preset) | at startup, before the server reports itself up |
48
+ | a Worker | at the isolate's first request — there is no startup, and a binding is only readable once a request has arrived |
49
+ | `vite dev` | when the dev server first evaluates the app, and again when the file or anything it imports changes |
50
+ | `vite build` | before the first page is prerendered |
51
+
52
+ Once per process. A `register()` that resolved is not called again; one
53
+ that rejected is retried by the next request, so a database that was not up
54
+ yet is asked again rather than leaving the process permanently refusing.
55
+
56
+ On a server, a failure at startup — the file throwing at import, or
57
+ `register()` rejecting — is reported and the process exits. A server that
58
+ could not bootstrap has nothing correct to serve, and a health check that
59
+ passed on a server about to fail its first visitor is worse than one that
60
+ never passed. The dev server stays up and reports the error on the page.
61
+
62
+ ## Environment validation
63
+
64
+ The scaffold writes this file when the project validates its environment,
65
+ and the only line it needs is the import: `src/env.ts` refuses at import, and
66
+ importing it here is what makes a missing variable the server's failure at
67
+ startup rather than a visitor's three calls later.
68
+
69
+ The build prerenders pages, so it runs the bootstrap too. A build machine
70
+ without the production variables sets `SKIP_ENV_VALIDATION=1`, which the
71
+ generated schema honours; the server that runs the build validates at
72
+ startup regardless.
73
+
74
+ ## On a Worker
75
+
76
+ Read bindings inside `register()`, not at the top of the module. A Worker
77
+ evaluates the module before any request exists, and `process.env` is empty
78
+ until Nitro maps the first request's bindings onto it — so a top-level
79
+ `process.env.DATABASE_URL` is `undefined` at exactly the moment it looks
80
+ like it should not be. Inside `register()`, which runs at the first request,
81
+ it is there.
82
+
83
+ ```ts
84
+ export async function register() {
85
+ const url = process.env.DATABASE_URL // readable here on every host
86
+ await db.connect(url)
87
+ }
88
+ ```
89
+
90
+ The same file works unchanged on a server, where both moments have the
91
+ environment.
@@ -96,6 +96,10 @@ Swap the preset for `node`, `cloudflare_module`, `vercel`, `netlify` or `deno`
96
96
  and the same app deploys there instead. The build produces a `.output`
97
97
  directory; nothing else changes.
98
98
 
99
+ With a backend behind it — Laravel, Go, anything answering one endpoint — the
100
+ app is a **BAP**, [Backend-Answered Pages](/hosts/backend-answered-pages):
101
+ the page is rendered in front of the backend rather than by it.
102
+
99
103
  ## What it is not
100
104
 
101
105
  <Aside type="note" title="No data layer, no CSS pipeline, no dev server of its own">
@@ -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)