@rsc-kit/mcp 0.22.9 → 0.24.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/recipes.js +109 -11
- package/dist/recipes.js.map +1 -1
- package/guides/backend-answered-pages.md +68 -1
- package/guides/domains.md +38 -5
- package/guides/forms.md +5 -0
- package/guides/go.md +54 -15
- package/guides/installation.md +32 -1
- package/guides/laravel.md +46 -15
- package/guides/ppr.md +81 -2
- package/guides/sections.md +10 -0
- package/guides/url-validation.md +5 -3
- package/guides/your-own-backend.md +24 -9
- package/package.json +1 -1
|
@@ -152,7 +152,8 @@ A global the renderer installs in its own process, not something you import:
|
|
|
152
152
|
carrying `{ "function": "Orders.recent", "args": [5] }`, the shared secret,
|
|
153
153
|
and the visitor's cookie — so the backend's session and auth are the
|
|
154
154
|
visitor's — answered with the function's return value as JSON, typed by the
|
|
155
|
-
`T` you give it
|
|
155
|
+
`T` you give it. The build declares it in `.rsc-kit/rsc-env.d.ts`, so there is
|
|
156
|
+
nothing to import and no `declare` to write. A refusal
|
|
156
157
|
comes back as itself — a validation error, unauthenticated, unauthorized, a
|
|
157
158
|
redirect — and reaches the page as that kind, never as a 500. Calls made by
|
|
158
159
|
sibling components in the same tick travel as one batch and are answered in
|
|
@@ -166,6 +167,72 @@ no database driver, no ORM, no auth library in it, because the backend owns
|
|
|
166
167
|
those, which is why the built server for a Laravel or Go app is a fraction of
|
|
167
168
|
the size of the same pages with their data layer in JavaScript.
|
|
168
169
|
|
|
170
|
+
### What the backend tells the build
|
|
171
|
+
|
|
172
|
+
The backend writes `rsc-host.json` at the project root, beside
|
|
173
|
+
`vite.config.ts`:
|
|
174
|
+
|
|
175
|
+
```json title="rsc-host.json"
|
|
176
|
+
{
|
|
177
|
+
"actions": { "ordersCreate": "Orders.create" },
|
|
178
|
+
"functions": ["Orders.create", "Orders.recent"]
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`actions` are its server actions, by the name the app imports them under; the
|
|
183
|
+
build writes a `"use server"` stub for each in `src/server-actions.generated.ts`.
|
|
184
|
+
`functions` are the names `rpc()` may be called with, and become the type of
|
|
185
|
+
its first argument, so `rpc('Orders.recnet')` fails the typecheck.
|
|
186
|
+
|
|
187
|
+
Have Vite write it, so it cannot go stale. `hostManifest` runs the backend's
|
|
188
|
+
command as `vite` and `vite build` start:
|
|
189
|
+
|
|
190
|
+
```ts title="vite.config.ts"
|
|
191
|
+
rscKit({ hostManifest: { command: ['go', 'run', '.', '-manifest', '../rsc-host.json'], cwd: 'backend' } })
|
|
192
|
+
rscKit({ hostManifest: { command: ['php', 'artisan', 'rsc:host-manifest'] } })
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A build fails if the command does. Under dev it is reported, and the file
|
|
196
|
+
already there is used. A dev server restarts when the file changes.
|
|
197
|
+
|
|
198
|
+
### Testing it
|
|
199
|
+
|
|
200
|
+
`createTestApp` answers the backend in the test, through the same client the
|
|
201
|
+
app uses against a real one, so nothing has to be running:
|
|
202
|
+
|
|
203
|
+
```ts title="tests/app.test.ts"
|
|
204
|
+
import { createTestApp, hostReply, HOST_MIDDLEWARE } from '@rsc-kit/core/testing'
|
|
205
|
+
|
|
206
|
+
const app = await createTestApp({
|
|
207
|
+
host: {
|
|
208
|
+
'Orders.recent': ({ args }) => [{ id: 1, total: Number(args[0]) * 100 }],
|
|
209
|
+
'Orders.create': ({ args }) => (args[0] ? { created: args[0] } : hostReply.invalid({ name: ['Required.'] })),
|
|
210
|
+
// The guards middleware.ts names: true lets the page render.
|
|
211
|
+
[HOST_MIDDLEWARE]: ({ headers }) =>
|
|
212
|
+
headers.get('cookie')?.includes('session=') ? true : hostReply.unauthenticated(),
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
expect((await app.fetch('/admin')).status).toBe(401)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Each handler gets the call's `args` and the visitor `headers` the renderer
|
|
220
|
+
forwarded. Return a value for the result, or one of the protocol's answers:
|
|
221
|
+
`hostReply.unauthenticated()`, `unauthorized()`, `redirect(to)`,
|
|
222
|
+
`refuse(status, message)`, `invalid(errors)`, or `revalidating(result,
|
|
223
|
+
...regions)`. A name with no handler fails the call, naming it.
|
|
224
|
+
|
|
225
|
+
A url the app does not own, such as a Go route or `/login`, is forwarded to the
|
|
226
|
+
backend, and in a test that is a 502. `backend` answers those instead, handed
|
|
227
|
+
the request as the backend would receive it:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const app = await createTestApp({
|
|
231
|
+
host: { /* … */ },
|
|
232
|
+
backend: (request) => new Response('the login page', { headers: { 'content-type': 'text/html' } }),
|
|
233
|
+
})
|
|
234
|
+
```
|
|
235
|
+
|
|
169
236
|
**Guards are the backend's, named in `middleware.ts`.** The backend decides
|
|
170
237
|
whether a route may render, in its own vocabulary, and the renderer asks
|
|
171
238
|
before anything at or below that directory renders — including a page frozen
|
package/guides/domains.md
CHANGED
|
@@ -52,20 +52,53 @@ address bar, and a link to `/billing` on that page goes to
|
|
|
52
52
|
|
|
53
53
|
## A tenant tree
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
Whether the tenant exists is a guard, so it goes in `middleware.ts`. It runs
|
|
56
|
+
before anything is sent, on every path, and its `notFound()` is a real 404.
|
|
57
|
+
Its argument is the route's params:
|
|
58
|
+
|
|
59
|
+
```ts title="src/app/[domain]/middleware.ts"
|
|
56
60
|
import { notFound } from '@rsc-kit/core/not-found';
|
|
57
61
|
import { tenantByDomain } from '@/lib/tenants';
|
|
58
62
|
|
|
59
|
-
export default async function
|
|
63
|
+
export default async function knownTenant({ domain }) {
|
|
64
|
+
if (!(await tenantByDomain(domain))) notFound(); // "acme" or "acme.com", as stored
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The layout is handed the same params, as a promise. A route that lists no
|
|
69
|
+
urls is stored as one shell for every tenant, so what reads the tenant goes
|
|
70
|
+
under `<Suspense>`; awaited above every boundary, there is no value to store
|
|
71
|
+
the shell with, and the build says so and names the layout:
|
|
72
|
+
|
|
73
|
+
```tsx title="src/app/[domain]/layout.tsx"
|
|
74
|
+
import { Suspense } from 'react';
|
|
75
|
+
import { tenantByDomain } from '@/lib/tenants';
|
|
76
|
+
|
|
77
|
+
async function TenantName({ params }) {
|
|
60
78
|
const { domain } = await params;
|
|
61
|
-
const tenant = await tenantByDomain(domain); // "acme" or "acme.com", as stored
|
|
62
79
|
|
|
63
|
-
|
|
80
|
+
return (await tenantByDomain(domain))!.name;
|
|
81
|
+
}
|
|
64
82
|
|
|
65
|
-
|
|
83
|
+
export default function TenantLayout({ params, children }) {
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<header>
|
|
87
|
+
<Suspense fallback={null}>
|
|
88
|
+
<TenantName params={params} />
|
|
89
|
+
</Suspense>
|
|
90
|
+
</header>
|
|
91
|
+
{children}
|
|
92
|
+
</>
|
|
93
|
+
);
|
|
66
94
|
}
|
|
67
95
|
```
|
|
68
96
|
|
|
97
|
+
A layout sees its own segments' params and those above it, never a deeper
|
|
98
|
+
one's, and it is rendered again when one of its own values changes:
|
|
99
|
+
`acme.example.com/settings` to `globex.example.com/settings` renders the
|
|
100
|
+
tenant layout for globex, while the root layout above it stays mounted.
|
|
101
|
+
|
|
69
102
|
`[domain]` is an ordinary dynamic segment: `params.domain` in every page and
|
|
70
103
|
layout below it, `route('/[domain]/settings', { domain })` typed, `loading.tsx`
|
|
71
104
|
and `error.tsx` where you put them. A directory named for a host,
|
package/guides/forms.md
CHANGED
|
@@ -61,6 +61,11 @@ prop, in a component of its own, with shadcn or without:
|
|
|
61
61
|
- **`error('title')`** — the field's message, or `undefined`. Show it, or test
|
|
62
62
|
it: `!!error('title')` is "invalid". Nested fields are paths:
|
|
63
63
|
`error('address.city')`, `error('items[0].name')`.
|
|
64
|
+
- **`formError`** — the refusal that is not about a field: the action's
|
|
65
|
+
`serverError`, such as a payment required or a slot just taken, or a
|
|
66
|
+
validation message for the whole form. `{formError && <p role="alert">{formError}</p>}`.
|
|
67
|
+
It clears with the field errors. A failure thrown on the way, such as the
|
|
68
|
+
network, still goes to `onError`.
|
|
64
69
|
- **`field('title')`** — a binding to spread onto a control, for the few fields
|
|
65
70
|
whose value this component has to hold. Most fields need nothing: they are
|
|
66
71
|
uncontrolled, the DOM holds the value, and it is read back on submit.
|
package/guides/go.md
CHANGED
|
@@ -59,6 +59,34 @@ the renderer's origin too.
|
|
|
59
59
|
|
|
60
60
|
## Reading from Go
|
|
61
61
|
|
|
62
|
+
Register an ordinary Go function with `Handle`, and its types reach the app:
|
|
63
|
+
|
|
64
|
+
```go
|
|
65
|
+
reg.Handle("Orders.recent", func(ctx context.Context, limit int) ([]Order, error) {
|
|
66
|
+
// The visitor's cookie, forwarded from the page request: this runs as
|
|
67
|
+
// them, not as nobody.
|
|
68
|
+
return db.RecentOrders(ctx, rsckit.HeadersFrom(ctx).Get("Cookie"), limit)
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```tsx title="src/app/orders/page.tsx"
|
|
73
|
+
const orders = await rpc('Orders.recent', 5) // Order[]; rpc('Orders.recent', 'five') fails the typecheck
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **Any parameters, in order.** `rpc()`'s arguments are bound to the
|
|
77
|
+
function's parameters positionally, whatever JSON can carry: numbers,
|
|
78
|
+
strings, structs, slices, maps. A leading `context.Context` is the call's.
|
|
79
|
+
- **Optional and variadic.** A trailing pointer parameter may be left out
|
|
80
|
+
and is `nil`; a variadic one takes the rest.
|
|
81
|
+
- **What it returns.** A value and an error, only an error, or only a value.
|
|
82
|
+
- **Types for the app.** The parameter and result types are written to
|
|
83
|
+
`rsc-host.json` as JSON Schema. The build turns each struct into a
|
|
84
|
+
TypeScript interface in the `RscHost` namespace, by its `json` tags:
|
|
85
|
+
`omitempty` is optional, a pointer is `| null`, `time.Time` is a string.
|
|
86
|
+
A type with its own `MarshalJSON` is `unknown`, since its shape is its own.
|
|
87
|
+
|
|
88
|
+
`Register` still takes the untyped form, binding by hand:
|
|
89
|
+
|
|
62
90
|
```go
|
|
63
91
|
reg.Register("Orders.recent", func(ctx context.Context, args rsckit.Args) (any, error) {
|
|
64
92
|
var limit int
|
|
@@ -84,7 +112,8 @@ export default async function Orders() {
|
|
|
84
112
|
|
|
85
113
|
`args.Bind` decodes the positional arguments `rpc()` was given; too few is an
|
|
86
114
|
error, extra ones are ignored. Whatever the function returns is encoded as
|
|
87
|
-
JSON and decoded on the other side as whatever `rpc<T>()` was told
|
|
115
|
+
JSON and decoded on the other side as whatever `rpc<T>()` was told: an
|
|
116
|
+
untyped function is `unknown` until you say.
|
|
88
117
|
|
|
89
118
|
### Refusing
|
|
90
119
|
|
|
@@ -136,23 +165,18 @@ does not happen is the failure this exists to prevent.
|
|
|
136
165
|
## Server actions
|
|
137
166
|
|
|
138
167
|
```go
|
|
139
|
-
reg.
|
|
140
|
-
var id int
|
|
141
|
-
if err := args.Bind(&id); err != nil {
|
|
142
|
-
return nil, err
|
|
143
|
-
}
|
|
144
|
-
|
|
168
|
+
reg.HandleAction("ordersCancel", "Orders.cancel", func(ctx context.Context, id int) error {
|
|
145
169
|
if err := orders.Cancel(ctx, id); err != nil {
|
|
146
|
-
return
|
|
170
|
+
return err
|
|
147
171
|
}
|
|
148
172
|
|
|
149
173
|
rsckit.Revalidate(ctx, "orders")
|
|
150
174
|
|
|
151
|
-
return nil
|
|
175
|
+
return nil
|
|
152
176
|
})
|
|
153
177
|
|
|
154
|
-
//
|
|
155
|
-
reg.
|
|
178
|
+
// The actions and every function, for the build: stubs, and rpc()'s type.
|
|
179
|
+
reg.WriteManifest("rsc-host.json")
|
|
156
180
|
```
|
|
157
181
|
|
|
158
182
|
```tsx title="src/app/orders/CancelButton.tsx"
|
|
@@ -164,12 +188,27 @@ export function CancelButton({ id }: { id: number }) {
|
|
|
164
188
|
}
|
|
165
189
|
```
|
|
166
190
|
|
|
191
|
+
The stub is typed from the Go function: `ordersCancel(id: number): Promise<void>`.
|
|
192
|
+
A form can post to an action directly, `<Form action={ordersCreate}>`, and its
|
|
193
|
+
fields arrive as the first parameter: a struct, with numbers and booleans
|
|
194
|
+
coerced by its types and a repeated field a slice.
|
|
195
|
+
|
|
167
196
|
`Revalidate` names a [section](/guides/sections) or `page`, and the answer to
|
|
168
197
|
the action carries it re-rendered rather than the browser being told to ask
|
|
169
|
-
again.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
198
|
+
again.
|
|
199
|
+
|
|
200
|
+
Have Vite write the manifest, so it cannot go stale: give the program a flag
|
|
201
|
+
that writes it and exits, and name the command in the config. It runs as dev
|
|
202
|
+
and every build start, and a build fails if it does:
|
|
203
|
+
|
|
204
|
+
```ts title="vite.config.ts"
|
|
205
|
+
rscKit({ hostManifest: { command: ['go', 'run', '.', '-manifest', '../rsc-host.json'], cwd: 'backend' } })
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The manifest also lists every registered function, so `rpc()` is typed by
|
|
209
|
+
the names Go holds and `rpc('Orders.recnet')` fails the typecheck. Tests
|
|
210
|
+
answer these functions without Go running:
|
|
211
|
+
[Testing it](/hosts/backend-answered-pages#testing-it).
|
|
173
212
|
|
|
174
213
|
## Production
|
|
175
214
|
|
package/guides/installation.md
CHANGED
|
@@ -14,7 +14,8 @@ bun create rsc-kit@latest my-app
|
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
It asks where it will run (Bun, Node or Cloudflare Workers), whether you want the
|
|
17
|
-
React Compiler and which implementation, whether to include Tailwind,
|
|
17
|
+
React Compiler and which implementation, whether to include Tailwind and, if
|
|
18
|
+
so, shadcn/ui, and
|
|
18
19
|
whether the app should be installable — offline, a manifest and starter icons
|
|
19
20
|
([`--pwa`](/guides/pwa#starting-one-installable)). Every answer has a flag, so it
|
|
20
21
|
also runs unattended:
|
|
@@ -23,6 +24,36 @@ also runs unattended:
|
|
|
23
24
|
bun create rsc-kit@latest my-app --host=bun --compiler=oxc --tailwind
|
|
24
25
|
```
|
|
25
26
|
|
|
27
|
+
| Flag | What it does |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `--host=bun\|node\|worker` | Where it runs, which picks the Nitro preset. |
|
|
30
|
+
| `--compiler=none\|oxc\|babel` | The React Compiler. The prompt offers oxc. |
|
|
31
|
+
| `--tailwind`, `--no-tailwind` | Include Tailwind. |
|
|
32
|
+
| `--lint`, `--no-lint` | Include oxlint. |
|
|
33
|
+
| `--validation=zod\|valibot\|arktype\|none` | The schema library forms, actions and env use. |
|
|
34
|
+
| `--env`, `--no-env` | Typed environment variables. |
|
|
35
|
+
| `--pwa`, `--no-pwa` | Installable: offline, a manifest and starter icons. Off by default. |
|
|
36
|
+
| `--shadcn` | shadcn/ui, set up for RSC. Needs Tailwind. See below. |
|
|
37
|
+
| `--source-dir <dir>` | Where `app/` lives. Default `src`. |
|
|
38
|
+
| `--backend=<url>` | A backend answering `rpc()`, such as a Go server. Writes `.env` with a secret. |
|
|
39
|
+
| `--no-install`, `--no-git` | Skip installing dependencies, or `git init`. |
|
|
40
|
+
| `--yes` | Accept every default and ask nothing. |
|
|
41
|
+
|
|
42
|
+
shadcn/ui is asked about only after Tailwind is chosen, because its
|
|
43
|
+
components are styled with it; `--shadcn` with `--no-tailwind` is refused.
|
|
44
|
+
Either way, it runs shadcn's own `init` once dependencies are installed, so it
|
|
45
|
+
asks its own questions, such as the component library; with `--yes` it takes
|
|
46
|
+
its defaults. Then it sets `"rsc": true` in `components.json`. shadcn sees a
|
|
47
|
+
plain Vite project and writes `false`, and the components it adds that need
|
|
48
|
+
the browser, a dialog or a dropdown, would then lack `"use client"`. In an
|
|
49
|
+
existing app, `rsc-kit init` asks the same question when the project has or
|
|
50
|
+
adds Tailwind, and when `components.json` is already there it asks only
|
|
51
|
+
whether to set the flag. Running `shadcn init` yourself
|
|
52
|
+
leaves it `false`; set it to `true` by hand.
|
|
53
|
+
|
|
54
|
+
Use `--yes`, not `-y`, with `bun create`: Bun reads `-y` as its own flag and
|
|
55
|
+
refuses it. `bunx create-rsc-kit my-app -y` accepts either.
|
|
56
|
+
|
|
26
57
|
What comes out builds, prerenders, typechecks and serves before you edit it.
|
|
27
58
|
|
|
28
59
|
The rest of this page is for adding rsc-kit to an app you already have.
|
package/guides/laravel.md
CHANGED
|
@@ -83,6 +83,7 @@ export default defineConfig({
|
|
|
83
83
|
sourceDir: 'resources/js',
|
|
84
84
|
outDir: 'bootstrap/rsc/vite',
|
|
85
85
|
hotFile: 'public/rsc-hot',
|
|
86
|
+
hostManifest: { command: ['php', 'artisan', 'rsc:host-manifest'] },
|
|
86
87
|
}),
|
|
87
88
|
react(),
|
|
88
89
|
],
|
|
@@ -98,19 +99,21 @@ dev server — read below.
|
|
|
98
99
|
```json title="package.json"
|
|
99
100
|
{
|
|
100
101
|
"scripts": {
|
|
101
|
-
"dev": "
|
|
102
|
-
"build": "
|
|
102
|
+
"dev": "vite",
|
|
103
|
+
"build": "vite build",
|
|
103
104
|
"start": "bun .output/server/index.mjs"
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
```
|
|
107
108
|
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
`rsc-host
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
`vite.config.ts` runs `php artisan rsc:host-manifest` as dev and every build
|
|
110
|
+
start (`hostManifest`). Your server actions are found by reflection through
|
|
111
|
+
Composer's autoloader — the only thing that sees what a class inherits from
|
|
112
|
+
its parents and traits — so PHP writes them to `rsc-host.json` and the build
|
|
113
|
+
reads it. It lists every callable too, which types `rpc()`'s first argument.
|
|
114
|
+
Part of starting Vite rather than a step to remember: a stale manifest names a
|
|
115
|
+
method that has since been renamed, and nothing fails until the browser calls
|
|
116
|
+
it.
|
|
114
117
|
|
|
115
118
|
If you had customised `dev` or `build` yourself, `init` leaves them alone and
|
|
116
119
|
puts the renderer's at `rsc:dev` and `rsc:build`, and says so.
|
|
@@ -208,6 +211,34 @@ session bound is theirs.
|
|
|
208
211
|
`.rsc-kit/rsc-env.d.ts`. It exists in server components during a render and
|
|
209
212
|
nowhere else — a client component reaches PHP through a server action.
|
|
210
213
|
|
|
214
|
+
### Typed from the PHP signature
|
|
215
|
+
|
|
216
|
+
`rsc:host-manifest` writes what each method takes and returns, so the call
|
|
217
|
+
is typed without a type argument:
|
|
218
|
+
|
|
219
|
+
```php
|
|
220
|
+
public function find(int $id, ?FindOptions $options = null): OrderData
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
const order = await rpc('Orders.find', 7) // OrderData; rpc('Orders.find', 'seven') fails the typecheck
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- **Parameters** are positional, as the call is made: `int`, `float`,
|
|
228
|
+
`string` and `bool` are typed, a nullable one adds `| null`, one with a
|
|
229
|
+
default may be left out, and a variadic one takes the rest.
|
|
230
|
+
- **A form request** in front is the call's one argument, typed from its
|
|
231
|
+
`rules()`: `integer`, `numeric`, `boolean` and `array` as such, the rest as
|
|
232
|
+
strings, `required` ones required.
|
|
233
|
+
- **A result** is typed when PHP can say what `json_encode` will write: a
|
|
234
|
+
scalar, a backed enum (its values), or a plain class with public typed
|
|
235
|
+
properties — a data object — which becomes an interface named for it.
|
|
236
|
+
- **Left open** as `unknown`: `array`, a model, a collection, anything
|
|
237
|
+
`JsonSerializable`, and an untyped parameter. Their shape is decided at
|
|
238
|
+
runtime, so say it at the call: `rpc<Order[]>('Orders.recent')`.
|
|
239
|
+
|
|
240
|
+
Action stubs are typed the same way, and accept the posting `FormData` too.
|
|
241
|
+
|
|
211
242
|
### Refusing
|
|
212
243
|
|
|
213
244
|
Attributes on the class or the method, and the refusal travels as itself
|
|
@@ -240,8 +271,8 @@ method runs, which is where `ValidationException` usually comes from.
|
|
|
240
271
|
|
|
241
272
|
## Server actions
|
|
242
273
|
|
|
243
|
-
A class under `app/Rsc/Actions/` is a server action. `rsc:
|
|
244
|
-
writes the
|
|
274
|
+
A class under `app/Rsc/Actions/` is a server action. `rsc:host-manifest`
|
|
275
|
+
writes it to the manifest, and the build writes a `"use server"` module beside your pages
|
|
245
276
|
exporting one function per method, named `classMethod`.
|
|
246
277
|
|
|
247
278
|
Make one with its guards already on it:
|
|
@@ -258,11 +289,11 @@ under `app/Rsc` instead. A slash nests: `Billing/Invoices`, discovered like
|
|
|
258
289
|
any other. The attributes it writes are the ones the registry reads, so what
|
|
259
290
|
you asked for at the prompt is what runs.
|
|
260
291
|
|
|
261
|
-
It writes the
|
|
262
|
-
when the
|
|
292
|
+
It writes the manifest as well, and a dev server that is running starts again
|
|
293
|
+
when the manifest changes — so the export is there to import the moment the
|
|
263
294
|
command returns, with nothing to restart by hand. A class you write yourself
|
|
264
|
-
is picked up the next time `rsc:
|
|
265
|
-
|
|
295
|
+
is picked up the next time `rsc:host-manifest` runs, which starting Vite
|
|
296
|
+
does.
|
|
266
297
|
|
|
267
298
|
```php title="app/Rsc/Actions/Orders.php"
|
|
268
299
|
namespace App\Rsc\Actions;
|
|
@@ -321,7 +352,7 @@ composer install --no-dev --optimize-autoloader
|
|
|
321
352
|
npm ci && npm run build
|
|
322
353
|
```
|
|
323
354
|
|
|
324
|
-
The build runs `rsc:
|
|
355
|
+
The build runs `rsc:host-manifest`, so PHP has to boot on the build
|
|
325
356
|
machine. It writes `.output/` — the server, the engine, the frozen pages and
|
|
326
357
|
the assets — which travels with the deployment; nothing in it is read from the
|
|
327
358
|
source tree at runtime.
|
package/guides/ppr.md
CHANGED
|
@@ -187,9 +187,88 @@ Work with it rather than around it:
|
|
|
187
187
|
- **Leave out `animate-pulse` for anything that is usually gone quickly.** A
|
|
188
188
|
pulse is what makes a short fallback look like a flash.
|
|
189
189
|
|
|
190
|
-
##
|
|
190
|
+
## The default: a page that paints, and slots that wait
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
Write the page as a synchronous component. Everything it knows without
|
|
193
|
+
asking — the title, the copy, the card frames, a button that needs nothing —
|
|
194
|
+
is in the stored shell. Each read lives in its own async component, under its
|
|
195
|
+
own `<Suspense>`, with a skeleton the shape of what it stands for:
|
|
196
|
+
|
|
197
|
+
```tsx title="src/app/integrations/shopify/page.tsx"
|
|
198
|
+
import { Suspense } from 'react';
|
|
199
|
+
import { ConnectButtonSlot } from './connect-button-slot';
|
|
200
|
+
import { ConnectionSlot } from './connection-slot';
|
|
201
|
+
import { ConnectionsSkeleton } from '../connections-skeleton';
|
|
202
|
+
|
|
203
|
+
export default function ShopifyPage() {
|
|
204
|
+
return (
|
|
205
|
+
<Card>
|
|
206
|
+
<CardHeader>
|
|
207
|
+
{/* Fixed copy: in the shell, painted at once. */}
|
|
208
|
+
<CardTitle>Shopify</CardTitle>
|
|
209
|
+
<CardDescription>Connect your store so your agent can look up orders.</CardDescription>
|
|
210
|
+
{/* Nothing to show until it knows: no fallback. */}
|
|
211
|
+
<Suspense>
|
|
212
|
+
<ConnectButtonSlot />
|
|
213
|
+
</Suspense>
|
|
214
|
+
</CardHeader>
|
|
215
|
+
<CardContent>
|
|
216
|
+
<Suspense fallback={<ConnectionsSkeleton rows={1} />}>
|
|
217
|
+
<ConnectionSlot />
|
|
218
|
+
</Suspense>
|
|
219
|
+
</CardContent>
|
|
220
|
+
</Card>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
```tsx title="src/app/integrations/shopify/connection-slot.tsx"
|
|
226
|
+
export async function ConnectionSlot() {
|
|
227
|
+
const agent = await currentAgent(); // its own read, its own check
|
|
228
|
+
const connection = await shopifyConnection(agent.id);
|
|
229
|
+
|
|
230
|
+
return connection ? <ConnectionRow connection={connection} /> : <EmptyState />;
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
What that buys:
|
|
235
|
+
|
|
236
|
+
- **The visitor sees the page, not a placeholder of it.** The shell is the
|
|
237
|
+
real heading and copy, and only the parts that wait show a skeleton.
|
|
238
|
+
- **Each part arrives when it is ready.** A fast read is not held back by a
|
|
239
|
+
slow one beside it; each boundary fills on its own.
|
|
240
|
+
- **The skeleton can be exact.** It stands for one known thing — a list of
|
|
241
|
+
one row, a button — so nothing moves when the content lands.
|
|
242
|
+
- **A slot is self-contained.** It does its own read and its own permission
|
|
243
|
+
check, so it can move to another page, or be revalidated as a
|
|
244
|
+
[section](/guides/sections), without the page around it.
|
|
245
|
+
|
|
246
|
+
## When loading.tsx is the wrong tool
|
|
247
|
+
|
|
248
|
+
`loading.tsx` wraps the whole page in one boundary. It is a fit for a page
|
|
249
|
+
that is one read and nothing else, such as a detail page whose skeleton really
|
|
250
|
+
is the page. Elsewhere it costs more than it saves:
|
|
251
|
+
|
|
252
|
+
- **It hides what the page already knows.** The heading, the copy and the
|
|
253
|
+
frames wait with the data, so the shell is a guess at the page rather than
|
|
254
|
+
the page.
|
|
255
|
+
- **The slowest read decides for all of them.** Everything under the one
|
|
256
|
+
boundary paints when the last of it is ready.
|
|
257
|
+
- **A status decided inside it is not the response's.** A page under
|
|
258
|
+
`loading.tsx` that calls `notFound()` or `redirect()`, or reads a backend
|
|
259
|
+
that answers 404, may already have sent its shell with a 200. Whether a page
|
|
260
|
+
exists for this visitor is a check for [`middleware.ts`](/guides/authorization),
|
|
261
|
+
which runs before anything is sent.
|
|
262
|
+
- **It does not cover its layout.** It sits below the layout beside it, so a
|
|
263
|
+
layout that waits — on its params, on the session — needs a `<Suspense>` of
|
|
264
|
+
its own inside the layout.
|
|
265
|
+
- **At the root it catches mistakes.** A root `loading.tsx` wraps every page,
|
|
266
|
+
so a page waiting in the wrong place is stored behind the app-wide
|
|
267
|
+
fallback, and the build can only warn. See below.
|
|
268
|
+
|
|
269
|
+
## loading.tsx, when it fits
|
|
270
|
+
|
|
271
|
+
Rather than wrapping a page by hand, put a `loading.tsx` beside it. The
|
|
193
272
|
build wraps the page in `<Suspense fallback={<Loading />}>` for you, and the
|
|
194
273
|
page can await at its top level:
|
|
195
274
|
|
package/guides/sections.md
CHANGED
|
@@ -56,6 +56,16 @@ The rest of the page is untouched — not re-rendered and not re-fetched. Whatev
|
|
|
56
56
|
state lives outside the section, including a half-filled form beside it, stays
|
|
57
57
|
exactly as it was.
|
|
58
58
|
|
|
59
|
+
### From an action called on another page
|
|
60
|
+
|
|
61
|
+
An action says what it changed, not which page called it. A backend's
|
|
62
|
+
action cannot know at all: Go's `rsckit.Revalidate(ctx, "orders")` and
|
|
63
|
+
Laravel's `Rsc::revalidate('orders')` name the data. When the page that
|
|
64
|
+
called the action has no `orders` section, there is nothing on screen to
|
|
65
|
+
refresh, and the name is skipped; the action still answers. A name no page
|
|
66
|
+
in the app has is a mistake, and the action fails, naming the sections and
|
|
67
|
+
slots the calling page does have.
|
|
68
|
+
|
|
59
69
|
## The name is scoped to the module, not the app
|
|
60
70
|
|
|
61
71
|
Two pages may both call their section `orders`. The name is resolved through the
|
package/guides/url-validation.md
CHANGED
|
@@ -19,12 +19,14 @@ export const searchParams = z.object({
|
|
|
19
19
|
tag: z.array(z.string()).default([]),
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// Renamed as they are destructured: `typeof params` must name the schema
|
|
23
|
+
// exported above, not the prop.
|
|
24
|
+
export default async function PostPage({ params: p, searchParams: q }: PageProps<
|
|
23
25
|
typeof params,
|
|
24
26
|
typeof searchParams
|
|
25
27
|
>) {
|
|
26
|
-
const { slug } = await
|
|
27
|
-
const { page, tag } = await
|
|
28
|
+
const { slug } = await p // string
|
|
29
|
+
const { page, tag } = await q // number, string[]
|
|
28
30
|
}
|
|
29
31
|
```
|
|
30
32
|
|
|
@@ -195,19 +195,34 @@ guard saying no means the inner one should never have been asked.
|
|
|
195
195
|
## Server actions
|
|
196
196
|
|
|
197
197
|
A `"use server"` function the browser can call is a name the renderer forwards
|
|
198
|
-
to you. Write
|
|
199
|
-
|
|
198
|
+
to you. Write `rsc-host.json` at the project root, beside `vite.config.ts`:
|
|
199
|
+
`actions` maps the JavaScript name to whatever your side dispatches on, and
|
|
200
|
+
`functions` lists every name `rpc()` may call.
|
|
201
|
+
|
|
202
|
+
```json title="rsc-host.json"
|
|
203
|
+
{
|
|
204
|
+
"actions": { "ordersCancel": "Orders.cancel", "profileUpdate": "Profile.update" },
|
|
205
|
+
"functions": ["Orders.cancel", "Orders.recent", "Profile.update"]
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
A function can also carry its types, so the app's `rpc()` and the stubs are
|
|
210
|
+
typed: `types` maps a name to its positional `params`, how many trailing ones
|
|
211
|
+
are `optional`, a variadic `rest`, and its `result`, each a JSON Schema;
|
|
212
|
+
`defs` holds the named object types they refer to by `"$ref": "#/defs/Name"`.
|
|
200
213
|
|
|
201
|
-
```json
|
|
202
|
-
|
|
214
|
+
```json
|
|
215
|
+
"types": { "Orders.recent": { "params": [{ "type": "integer" }], "result": { "type": "array", "items": { "$ref": "#/defs/Order" } } } },
|
|
216
|
+
"defs": { "Order": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] } }
|
|
203
217
|
```
|
|
204
218
|
|
|
205
219
|
The build writes `server-actions.generated.ts` in the source directory
|
|
206
|
-
exporting each
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
220
|
+
exporting each action, so a client component imports `ordersCancel` and
|
|
221
|
+
calls it, and types `rpc()`'s first argument with the functions. Have Vite
|
|
222
|
+
run the command that writes it, `rscKit({ hostManifest: { command } })`, so
|
|
223
|
+
it cannot go stale: a stale one names a method since renamed, and nothing
|
|
224
|
+
fails until the browser calls it. Laravel's `rsc:host-manifest` is this
|
|
225
|
+
step; a Go registry's `WriteManifest` writes the same file.
|
|
211
226
|
|
|
212
227
|
## Urls you own
|
|
213
228
|
|
package/package.json
CHANGED