@rsc-kit/mcp 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +319 -14
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +106 -6
- package/guides/authorization.md +39 -0
- package/guides/backend-answered-pages.md +163 -0
- package/guides/coming-from-next.md +3 -0
- package/guides/deployment.md +129 -0
- package/guides/domains.md +129 -0
- package/guides/errors.md +11 -1
- package/guides/go.md +194 -0
- package/guides/index.json +40 -0
- package/guides/installation.md +23 -4
- package/guides/instrumentation.md +91 -0
- package/guides/introduction.md +4 -0
- package/guides/laravel.md +406 -0
- package/guides/queries.md +138 -8
- package/guides/quick-start.md +31 -0
- package/guides/redirects.md +14 -0
- package/guides/response-headers.md +22 -0
- package/guides/seo-files.md +45 -0
- package/guides/typed-routes.md +17 -0
- package/guides/where-it-runs.md +155 -0
- package/guides/your-own-backend.md +238 -0
- package/package.json +2 -2
|
@@ -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.
|
package/guides/introduction.md
CHANGED
|
@@ -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)
|