@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.
@@ -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 (the `rsc-env.d.ts` the build writes declares it). A refusal
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
- ```tsx title="src/app/[domain]/layout.tsx"
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 TenantLayout({ params, children }) {
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
- if (!tenant) notFound();
80
+ return (await tenantByDomain(domain))!.name;
81
+ }
64
82
 
65
- return <TenantProvider tenant={tenant}>{children}</TenantProvider>;
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.RegisterAction("ordersCancel", "Orders.cancel", func(ctx context.Context, args rsckit.Args) (any, error) {
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 nil, err
170
+ return err
147
171
  }
148
172
 
149
173
  rsckit.Revalidate(ctx, "orders")
150
174
 
151
- return nil, nil
175
+ return nil
152
176
  })
153
177
 
154
- // Before each build. The build reads it and writes the stubs.
155
- reg.WriteActionManifest("rsc-host-actions.json")
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. Write the manifest as part of the build — the example's `build`
170
- script runs the Go program with `-manifest-only` first — because a stale map
171
- names a function that has since been renamed, and nothing fails until the
172
- browser calls it.
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
 
@@ -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, and
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": "php artisan rsc:action-manifest && vite",
102
- "build": "php artisan rsc:action-manifest && vite 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
- `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.
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:action-manifest`
244
- writes the map, and the build writes a `"use server"` module beside your pages
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 map as well, and a dev server that is running starts again
262
- when the map changes — so the export is there to import the moment 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:action-manifest` runs, which the `dev` and
265
- `build` scripts do first.
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:action-manifest`, so PHP has to boot on the build
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
- ## loading.tsx instead
190
+ ## The default: a page that paints, and slots that wait
191
191
 
192
- Rather than wrapping every page by hand, put a `loading.tsx` beside it. The
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
 
@@ -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
@@ -19,12 +19,14 @@ export const searchParams = z.object({
19
19
  tag: z.array(z.string()).default([]),
20
20
  })
21
21
 
22
- export default async function PostPage({ params, searchParams }: PageProps<
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 params // string
27
- const { page, tag } = await searchParams // number, string[]
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 the map to `rsc-host-actions.json` at the project root before
199
- each build — the JavaScript name to whatever your side dispatches on:
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 title="rsc-host-actions.json"
202
- { "ordersCancel": "Orders.cancel", "profileUpdate": "Profile.update" }
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 as an action, so a client component imports `ordersCancel`
207
- and calls it. Regenerate the file as part of `build` rather than by hand: a
208
- stale map names a method that has since been renamed, and nothing fails until
209
- the browser calls it. Laravel's `rsc:action-manifest` is this step; a Go
210
- registry writes the same file from the names it holds.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rsc-kit/mcp",
3
- "version": "0.22.9",
3
+ "version": "0.24.0",
4
4
  "description": "An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.",
5
5
  "type": "module",
6
6
  "license": "MIT",