@rsc-kit/mcp 0.18.0 → 0.19.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/guides/bun.md ADDED
@@ -0,0 +1,76 @@
1
+ # Running on Bun
2
+
3
+ > What is different when the runtime is Bun — and what only looks like it is.
4
+
5
+ Bun is the default host, and most of an app never notices. What follows is
6
+ what an app *does* notice, collected from real ports: the framework's part
7
+ first, then the things that will look like the framework's fault and are not.
8
+
9
+ ## Vite runs on Bun too
10
+
11
+ `vite` is a bin with a Node shebang, so `bun run dev` alone starts Vite —
12
+ and with it the dev server, the build and the prerender — under **Node**. An
13
+ app that imports `bun`, `bun:sqlite` or a Bun-only driver then fails at the
14
+ first render with `Cannot find package 'bun'`, in a project that just said
15
+ it was a Bun app.
16
+
17
+ The scaffold's scripts run Vite on Bun's runtime, and an existing app should
18
+ too:
19
+
20
+ ```json title="package.json"
21
+ {
22
+ "scripts": {
23
+ "dev": "bun --bun vite",
24
+ "build": "bun --bun vite build",
25
+ "start": "bun .output/server/index.mjs"
26
+ }
27
+ }
28
+ ```
29
+
30
+ `createTestApp()` runs that `build` script, so a test builds on the same
31
+ runtime the server ships on.
32
+
33
+ ## Native dependencies stay outside the bundle
34
+
35
+ A package with a native binary — `sharp`, `bcrypt`, `better-sqlite3`,
36
+ `@prisma/client` — cannot be rolled into a server bundle: the build succeeds
37
+ and the server cannot load its own binary. The usual ones are left external
38
+ by default, and Nitro traces each into `.output/server/node_modules` with its
39
+ binaries, so the deployment is still one directory. For one that is not on
40
+ the list:
41
+
42
+ ```ts title="vite.config.ts"
43
+ rscKit({ serverExternalPackages: ['@acme/native-thing'] })
44
+ ```
45
+
46
+ The same applies on Node; it is only more visible on Bun because the
47
+ scaffold builds there.
48
+
49
+ ## A build machine without the secrets
50
+
51
+ The build prerenders pages, which runs the app, which runs `instrumentation.ts`,
52
+ which imports `env.ts`. A build machine without the production variables
53
+ sets `SKIP_ENV_VALIDATION=1`; the server that runs the build validates at
54
+ startup regardless. And never `NODE_ENV` in a `.env` — Vite honours it, and
55
+ `NODE_ENV=development` turns `vite build` into a build that cannot render;
56
+ [the build refuses it](/installation#environment-variables) and names the
57
+ line.
58
+
59
+ ## Not the framework's, but you will meet them
60
+
61
+ - **`bun test` reads `.env`.** Bun loads the package's `.env` into every test
62
+ run. A test that must not see the app's variables — or must see only the
63
+ ones it sets — runs with `bun test --env-file=/dev/null`.
64
+ - **Stripe's SDK is async-only on Bun.** `stripe.webhooks.constructEvent()`
65
+ throws on every call because Bun has no synchronous WebCrypto;
66
+ `constructEventAsync()` is the same check, awaited.
67
+ - **`pg` puts SQLSTATE in `errno`.** Bun's Postgres driver reports the
68
+ five-character SQLSTATE (`23505`) where Node's `pg` reports it as `code`.
69
+ Match on both if the app runs on both.
70
+ - **Bun's `setTimeout(0)` is a real millisecond**, as Node's is. Nothing to
71
+ do; worth knowing when a test counts ticks.
72
+
73
+ ---
74
+
75
+ Everything else — presets, deployment, the single binary — is on
76
+ [Where it runs](/hosts/where-it-runs).
@@ -29,25 +29,30 @@ it, the server evaluates the library's internals for nothing on every render.
29
29
  | Next.js | here |
30
30
  | --- | --- |
31
31
  | `next/link` | `@rsc-kit/core/Link` — `href` is typed to your routes, and `search` to the page's schema |
32
+ | `import type { Route } from 'next'` | `import type { Route } from '@rsc-kit/core/routes'` — pages and `route.ts` files alike, as in Next; a link to a route is an anchor, never prefetched |
32
33
  | `useRouter().push(url)` | `visit(url)` from `@rsc-kit/core/router`; `replace: true` for `.replace()` |
33
34
  | `useRouter().refresh()` | `refresh()` from `@rsc-kit/core/router` — or better, `revalidate()` from the action |
34
35
  | `usePathname()` | `@rsc-kit/core/usePathname` |
35
36
  | `useSearchParams()` | `@rsc-kit/core/useSearchParams`, or [nuqs](/guides/routing#search-params-as-state) with our adapter |
36
37
  | `useParams()` | the page's `params` prop, passed down |
37
- | `cookies()`, `headers()` from `next/headers` | the same names from `@rsc-kit/core/request` |
38
+ | `cookies()`, `headers()` from `next/headers` | the same names and shapes from `@rsc-kit/core/request` — `cookies().get(name)` is `{ name, value }`, `getAll()` a list, `set()` takes either call form |
38
39
  | `redirect()` from `next/navigation` | `@rsc-kit/core/redirect` |
40
+ | `after()` from `next/server` | `after()` from `@rsc-kit/core/request` — handed to `waitUntil` on a Worker, detached on a process; [server actions](/guides/server-actions#work-the-visitor-should-not-wait-for) |
39
41
  | `notFound()` | `@rsc-kit/core/not-found` |
40
42
  | `revalidatePath()`, `revalidateTag()` | `revalidate('tag')` from `@rsc-kit/core/revalidate` — see below, it is narrower |
41
43
  | `Metadata` from `next` | `@rsc-kit/core/metadata` — `metadataBase`, `openGraph`, `twitter`, `icons` as you had them |
42
44
  | `next/font` | [Fontsource](/guides/fonts): install the font, import its CSS |
43
45
  | `next/image` | [unpic](/guides/images) for a CDN, `vite-imagetools` for files in the repo |
44
46
  | `next/script` | [a `<script>` tag](/guides/third-party-scripts): React 19 hoists and dedupes `async` scripts itself |
45
- | `NEXT_PUBLIC_*` | `VITE_*`, read through `import.meta.env`; everything else stays `process.env` on the server |
47
+ | `NEXT_PUBLIC_*` | `PUBLIC_*`, read through `import.meta.env` (`VITE_*` works too); everything else stays `process.env` on the server |
48
+ | `flags/next` (Vercel's Flags SDK) | unchanged — the build answers its `next/headers` import with `headers()` and `cookies()` from here; [Feature flags](/guides/feature-flags) |
46
49
  | `next.config.js` | `vite.config.ts` — Tailwind, aliases and plugins are Vite's |
50
+ | `serverExternalPackages` | `rscKit({ serverExternalPackages })`, with the same default list — sharp, bcrypt, prisma and the rest stay outside the bundle; [Running on Bun](/hosts/bun#native-dependencies-stay-outside-the-bundle) |
47
51
  | `instrumentation.ts` with `register()` | the same file, in `src/` — imported before any page and awaited before the first request; [startup](/guides/instrumentation) |
48
52
  | `next-safe-action` | `createActionClient()` — same shape, [below](#actions) |
49
53
  | `experimental.optimizePackageImports` | on by default for every barrel package the server imports, with no list — read from the barrel itself; `rscKit({ barrelImports: false })` turns it off |
50
54
  | a `middleware.ts` rewrite for subdomains | nothing — a host is a route segment, so `acme.example.com/` reaches `app/[domain]/page.tsx` and `admin.example.com/` reaches `app/admin/page.tsx` — [domains](/guides/domains) |
55
+ | a hand-written OpenAPI spec, `@scalar/nextjs-api-reference` | `rscKit({ openapi })` derives the document from every `route.ts` and its schemas; the Scalar package mounts unchanged as a `route.ts` — [OpenAPI](/guides/openapi) |
51
56
  | `app/robots.ts`, `app/sitemap.ts` | the same files, the same shapes — [robots, sitemap and llms.txt](/guides/seo-files); `app/llms.ts` beside them |
52
57
  | `@react-email/render` in a server action | the same call, in a module that starts with `"use ssr"` — [emails](/guides/emails). Next fails the same way where it renders server components; the directive is how this one moves it |
53
58
  | `cache` from `react` | `cache` from `@rsc-kit/core/cache` — React's memoises only inside a component render; this one spans the request, so a guard, the layout and the action share one call. The build names server files still importing React's |
@@ -158,7 +163,17 @@ in Next; see [Testing](/guides/testing).
158
163
  what streams and from which component. Most surprises are a `cookies()`
159
164
  in a layout reaching every page — the build says so under the summary.
160
165
  4. Actions not built from a client are listed. Decide for each.
161
- 5. `bun run check`. Then a browser, for the parts that are a browser's.
166
+ 5. **Convert the forms and the actions, do not carry them.** A Next app's
167
+ `useActionState` + `useFormStatus` pair, a `react-hook-form` or
168
+ TanStack Form setup, and `useState` per input all still compile here —
169
+ which is exactly why an agent leaves them. Each one becomes
170
+ `<Form action={…} schema={…}>` (pending state, field errors, works before
171
+ hydration, uncontrolled by default) and a handler from
172
+ `createActionClient()` (input validated, `validationErrors` returned
173
+ rather than thrown, one `onError`). Drop the form library from
174
+ `package.json` when the last form is converted; a port that keeps two
175
+ form systems has ported nothing.
176
+ 6. `bun run check`. Then a browser, for the parts that are a browser's.
162
177
 
163
178
  An agent doing the port has all of this: the `.mcp.json` in the scaffold
164
179
  answers `how_to({ topic })` and `read_guide({ slug })` from the installed
@@ -124,6 +124,16 @@ they are covered. See [serving shells from a CDN](/guides/edge-caching).
124
124
 
125
125
  Cached responses carry a build version, so a deploy invalidates them.
126
126
 
127
+ **Build, then start — never build under a running server.** The server loads
128
+ its rsc and ssr services lazily, on the first request that needs each, and
129
+ a `vite build` that is rewriting `.output/` at that moment hands it a file
130
+ that is half-written or briefly missing. The runtime caches that failed
131
+ import — Bun does, and so does Node's ESM loader — so the route answers 500
132
+ with an `ENOENT` for a file that now exists, and keeps doing so until the
133
+ process restarts. In development use `vite` (the dev server), which owns its
134
+ own rebuilds; in production build into a fresh directory or stop the server
135
+ first, and start it against a finished `.output/`.
136
+
127
137
  Ship `.output/` from the same commit as the code that serves it. A server
128
138
  running one build against another's frozen pages is the one combination nothing
129
139
  checks for you.
package/guides/emails.md CHANGED
@@ -87,4 +87,16 @@ src/lib/nodemailer.ts. Put the rendering — the template and the call — in a
87
87
  module that starts with "use ssr" …
88
88
  ```
89
89
 
90
- A build prints the same once, as a warning, naming the file.
90
+ A build prints the same once, as a warning, naming the file — and the
91
+ build still succeeds, because the refusal is the call's, not the import's:
92
+ every export of the stub throws it when called, so a server does not fail to
93
+ boot over an action nobody has run yet.
94
+
95
+ A dependency whose imports the build never looks inside — `@react-email/render`
96
+ imported from an action, say — is read once for the import and warned about
97
+ the same way, naming the app file and the package:
98
+
99
+ ```
100
+ src/actions/send-otp.ts imports @react-email/render, which imports
101
+ react-dom/server, and that cannot run where server components render …
102
+ ```
@@ -0,0 +1,63 @@
1
+ # Feature flags
2
+
3
+ > Vercel's Flags SDK runs unchanged — flags/next, without Next.
4
+
5
+ Nothing to install from here. The [Flags SDK](https://flags-sdk.dev) is
6
+ framework-agnostic at its core, and its Next integration — `flags/next`,
7
+ the one with `flag()`, `dedupe()` and the adapters — needs one thing from
8
+ Next: `next/headers`, for `headers()` and `cookies()`. Those exist here
9
+ under the same names and with the same shapes, one object per request,
10
+ which is what the SDK's per-request dedupe keys on. The build answers
11
+ `next/headers` with them, so the SDK runs as written:
12
+
13
+ ```sh
14
+ bun add flags
15
+ ```
16
+
17
+ ```ts title="src/flags.ts"
18
+ import { flag, dedupe } from 'flags/next'
19
+
20
+ const visitor = dedupe(async ({ cookies, headers }) => ({
21
+ id: cookies.get('visitor')?.value ?? 'anonymous',
22
+ country: headers.get('x-vercel-ip-country') ?? headers.get('cf-ipcountry') ?? '',
23
+ }))
24
+
25
+ export const showBanner = flag<boolean, { id: string; country: string }>({
26
+ key: 'show-banner',
27
+ identify: visitor,
28
+ decide: ({ entities }) => entities?.country === 'GB',
29
+ })
30
+ ```
31
+
32
+ ```tsx title="src/app/page.tsx"
33
+ import { showBanner } from '../flags'
34
+
35
+ export default async function Home() {
36
+ const banner = await showBanner()
37
+
38
+ return banner ? <Banner /> : null
39
+ }
40
+ ```
41
+
42
+ A flag reads the request, so the page that awaits one renders per visitor —
43
+ the build says `headers() in run, cookies() in run stream per request` and
44
+ stores the rest as a shell, given a `<Suspense>` or a `loading.tsx` above
45
+ the read. That is the same line any `headers()` call earns; see
46
+ [Static generation](/guides/static-generation). An adapter — Statsig,
47
+ LaunchDarkly, Vercel's own — is the SDK's, configured the way its docs say.
48
+
49
+ The discovery endpoint is a route handler in the shape a `route.ts` already
50
+ has:
51
+
52
+ ```ts title="src/app/.well-known/vercel/flags/route.ts"
53
+ import { createFlagsDiscoveryEndpoint, getProviderData } from 'flags/next'
54
+ import * as flags from '../../../../flags'
55
+
56
+ export const GET = createFlagsDiscoveryEndpoint(async () => getProviderData(flags))
57
+ export const openapi = false
58
+ ```
59
+
60
+ What does not carry over is `precompute()`: it is built on a Next
61
+ middleware rewriting the url to a permutation, which is Next's routing.
62
+ Read the flag in the page instead; a flag read under a boundary costs one
63
+ streamed hole, not the page.
package/guides/fonts.md CHANGED
@@ -110,6 +110,31 @@ export default function RootLayout({ children }) {
110
110
  the one file the first paint needs, usually the Latin regular; preloading all
111
111
  of them defeats the subsetting.
112
112
 
113
+ ## A font never blocks the page
114
+
115
+ Text paints before the web font arrives, always. That is not a tuning
116
+ choice; it is the rule everything below serves, and a font can break it in
117
+ three ways, each of which the setup above has already closed:
118
+
119
+ - **A stylesheet from another origin.** `<link href="https://fonts.googleapis.com/…">`
120
+ is render-blocking CSS from a host the browser has not connected to: a DNS
121
+ lookup, a TLS handshake and a round trip before the first paint, on every
122
+ cold load. Self-hosting through Fontsource keeps the `@font-face` rules in
123
+ your own stylesheet, which is inlined into the document.
124
+ - **`font-display: block`, or none at all.** Without a `font-display`, the
125
+ browser is free to hide text for up to three seconds while it waits — the
126
+ invisible-text flash. Every rule the setup writes says `swap` (paint the
127
+ fallback now, swap when the font lands) or `optional` (paint once, in
128
+ whichever is ready). Never `block`, and never leave it unset in a rule you
129
+ own.
130
+ - **Preloading everything.** A `<link rel="preload">` per file puts every
131
+ weight and subset ahead of the page in the network queue. Preload the one
132
+ file the first paint needs; the rest arrive with the stylesheet.
133
+
134
+ A font that is slow — a cold cache, a throttled connection — then costs a
135
+ swap or a fallback, never a blank page. The section below is how to make
136
+ that swap invisible.
137
+
113
138
  ## Getting to 100 on a phone
114
139
 
115
140
  Fontsource's stylesheet is the right default and the wrong last mile. It
package/guides/forms.md CHANGED
@@ -7,6 +7,21 @@ that handles the state for you, and a hook for when you want to hold it
7
7
  yourself. Both cover validation errors, pending state, optimistic updates and
8
8
  GET-form navigation.
9
9
 
10
+ ## The rule: uncontrolled, unless one field needs otherwise
11
+
12
+ A form here is **uncontrolled by default**. Inputs keep their own value in
13
+ the DOM, an initial value is React's `defaultValue`, and the action reads
14
+ `FormData` on submit. No `useState` per field, no `value`/`onChange` pair,
15
+ no re-render of the whole form on every keystroke — and after a refused
16
+ submit the values are still there, because nothing re-rendered the inputs.
17
+
18
+ Reach for a controlled field only where the UI has to react *as* the user
19
+ types — a character count, a live preview, a dependent select — and bind
20
+ that one field with `useField` (below), which scopes the re-render to it.
21
+ Coming from react-hook-form this is the same default; coming from TanStack
22
+ Form or from `useState`-per-input it is the opposite, and the difference is
23
+ most of why these forms stay fast.
24
+
10
25
  ## The `<Form>` component
11
26
 
12
27
  The simplest way to handle forms. Works without any hooks — just pass a server action and use the render-prop for pending state and errors.
@@ -275,6 +290,54 @@ genuinely reloads. Then the server renders the page again, and putting the
275
290
  values back is the server's job — return them from the action and render them
276
291
  as `defaultValue`.
277
292
 
293
+ ### The schema is written for the shape it wants
294
+
295
+ Every value in a `FormData` is a string or a file, and a control that is off
296
+ is not there at all — a fact that used to leak into every schema as
297
+ `z.coerce.number()` and a per-checkbox `.transform()`. It does not any more.
298
+ The form is read *the way the schema means it*, on both sides:
299
+
300
+ ```ts
301
+ const settings = z.object({
302
+ notify: z.boolean(), // unchecked posts nothing → false; "on" → true
303
+ limit: z.number().int().min(1), // "5" → 5
304
+ tags: z.array(z.string()), // one tag → ['a']; none ticked → []
305
+ policy: z.string().optional(), // hidden behind a switch → absent when off
306
+ auth: z.discriminatedUnion('kind', [ // auth[kind] picks the branch
307
+ z.object({ kind: z.literal('none') }),
308
+ z.object({ kind: z.literal('bearer'), token: z.string().min(1) }),
309
+ ]),
310
+ rules: z.array(z.object({ on: z.boolean(), max: z.number() })), // rules[0][on], rules[0][max]
311
+ }).refine((s) => !s.notify || (s.policy ?? '').length > 0, {
312
+ path: ['policy'],
313
+ message: 'Say what to notify about.',
314
+ })
315
+ ```
316
+
317
+ Nothing in that schema knows it will meet a form. A schema describes itself
318
+ (Standard JSON Schema — Zod 4 and ArkType do; Valibot not yet, and its
319
+ values arrive as strings), and the decoder coerces to it: an absent
320
+ `boolean` is `false`, `"on"`/`"1"` is `true`; a numeric string for a
321
+ `number` is the number, an empty one for an optional number is absent; an
322
+ `array` given one value is a list of one and given nothing is empty; nested
323
+ names nest (`fields[0][name]` and `fields[0].name` alike); a union takes the
324
+ branch its discriminator names; a control left blank is absent for a field
325
+ the schema does not require, whatever its type — `z.email().optional()`
326
+ accepts the empty input, an optional union is not read as its first branch
327
+ — and is `""` for one it requires, so `z.string().min(1)` can say so. A value the schema refuses is still refused as itself — `"many"` for a
328
+ number is the error you expect.
329
+
330
+ A leaf JSON Schema cannot describe — `z.date()`, a custom check — costs only
331
+ that leaf, which arrives as posted; the fields beside it are still read the
332
+ way the schema means. A schema that cannot describe itself at all is said
333
+ once, in development, with the library's reason.
334
+
335
+ `<Form>` validates that object in the browser and the action decodes the
336
+ same object on the server, from one codec, so a form that passes here passes
337
+ there. A value sent through `<Form transform>` is encoded the same way back
338
+ — a boolean as `"1"`/`"0"`, a nested object as `key[prop]` — and decodes to
339
+ what was given.
340
+
278
341
  ### Lists of values
279
342
 
280
343
  A repeated name is an array:
@@ -405,6 +468,28 @@ and fades. It is state rather than a timer in every form that wants one,
405
468
  because the timer has to be cleared when the component goes away and that is
406
469
  the part people forget.
407
470
 
471
+ ### Nothing to save yet
472
+
473
+ ```tsx
474
+ {({ dirty, reset }) => (
475
+ <>
476
+ <Button type="submit" disabled={!dirty}>Save</Button>
477
+ {dirty && <Button type="button" variant="ghost" onClick={reset}>Discard</Button>}
478
+ </>
479
+ )}
480
+ ```
481
+
482
+ `dirty` is whether anything differs from what the form started with. It is
483
+ read from the form itself — a snapshot of its `FormData` on mount, compared
484
+ on every input — so an uncontrolled field counts, which is the reason it is
485
+ the form's to answer and not something a component beside it could work
486
+ out. A successful submit makes the current values the new baseline;
487
+ `reset()` goes back to the first one. A bound control with no native
488
+ element behind it counts too, through the store. Only a form that reads
489
+ `dirty` is measured, and it renders once when the value first differs — not
490
+ on the keystrokes after — so a field that subscribes for itself keeps its
491
+ promise of rendering alone.
492
+
408
493
  ### Why not a `<Field>` component
409
494
 
410
495
  TanStack Form and react-hook-form both hand you a field through a render prop —
@@ -519,6 +604,24 @@ the markup is submittable on its own. Someone who hits enter before the
519
604
  javascript arrives still reaches the server; the page reloads with the result
520
605
  instead of updating in place.
521
606
 
607
+ What happens on the wire: React writes the action's id into the form as a
608
+ hidden field and points the form at the page's own url, and the browser
609
+ posts there. The host reads the fields, runs the action they name exactly as
610
+ the enhanced path would have called it, and renders the page again with what
611
+ it returned seated in the form that posted — so a refusal shows on its
612
+ fields, `error('email')` and `fieldState` included, for a visitor with no
613
+ javascript at all. A `redirect()` the action throws is followed as a
614
+ document's would be; a cookie it sets is on the answer. Same origin only, as
615
+ an action is, and the answer is never stored. A stored page is the one most
616
+ likely to be submitted this way — it paints before its runtime arrives —
617
+ which is why this is not optional.
618
+
619
+ The seating is React's own `useActionState`, which `<Form>` uses under a
620
+ wrapper bound to your action: React hands a form-state action a
621
+ `(previousState, formData)` pair, and your action keeps taking the
622
+ `FormData` alone. A `useActionState` of your own works the same way, with
623
+ an action written for the pair.
624
+
522
625
  The two do not fight. The handler calls `preventDefault()` first, and React does
523
626
  not run a form action for a submit that was cancelled — so the enhanced path
524
627
  wins whenever there is one, and the native path is what is left when there is
package/guides/index.json CHANGED
@@ -14,6 +14,11 @@
14
14
  "title": "Backend-Answered Pages",
15
15
  "description": "BAP — the model for an rsc-kit app with a backend behind it, and how to build for it."
16
16
  },
17
+ {
18
+ "slug": "bun",
19
+ "title": "Running on Bun",
20
+ "description": "What is different when the runtime is Bun — and what only looks like it is."
21
+ },
17
22
  {
18
23
  "slug": "caching",
19
24
  "title": "Asking once per request",
@@ -54,6 +59,11 @@
54
59
  "title": "Errors and 404s",
55
60
  "description": "What a visitor sees when a page throws, or asks for a url nothing answers."
56
61
  },
62
+ {
63
+ "slug": "feature-flags",
64
+ "title": "Feature flags",
65
+ "description": "Vercel's Flags SDK runs unchanged — flags/next, without Next."
66
+ },
57
67
  {
58
68
  "slug": "file-uploads",
59
69
  "title": "File uploads",
@@ -129,6 +139,11 @@
129
139
  "title": "Offline",
130
140
  "description": "Knowing when the server cannot be reached, and carrying on without it."
131
141
  },
142
+ {
143
+ "slug": "openapi",
144
+ "title": "OpenAPI",
145
+ "description": "A document derived from your route.ts files, and Scalar's page over it."
146
+ },
132
147
  {
133
148
  "slug": "ppr",
134
149
  "title": "Partial prerendering",
@@ -32,8 +32,9 @@ The rest of this page is for adding rsc-kit to an app you already have.
32
32
  <PackageManagers pkg="vite @vitejs/plugin-rsc @vitejs/plugin-react @types/react @types/react-dom" dev />
33
33
 
34
34
  `@rsc-kit/core` brings the Vite plugin, the render engine and the client
35
- runtime. React 19 and Vite 8 are peer dependencies — the versions the RSC build
36
- needs, not ones this package pins for you.
35
+ runtime. React 19.2 or later and Vite 8 are peer dependencies — the versions
36
+ the RSC build needs, not ones this package pins for you. 19.2 is where
37
+ `useEffectEvent` arrived, which the live-data hooks are built on.
37
38
 
38
39
  <Aside type="note" title="What @vitejs/plugin-react is for">
39
40
  Fast Refresh: edit a client component and React keeps its state, instead of
@@ -62,6 +63,7 @@ export default defineConfig({
62
63
  sourceDir: "src",
63
64
  outDir: "build",
64
65
  offline: true,
66
+ openapi: { info: { title: 'Example API', version: '1.0.0' } },
65
67
  }),
66
68
  react(),
67
69
  ],
@@ -167,14 +169,20 @@ runs](/hosts/where-it-runs).
167
169
  {
168
170
  "type": "module",
169
171
  "scripts": {
170
- "dev": "vite",
171
- "build": "vite build",
172
+ "dev": "bun --bun vite",
173
+ "build": "bun --bun vite build",
172
174
  "start": "bun .output/server/index.mjs",
173
- "compile": "bun build --compile .output/server/index.mjs --outfile my-app"
175
+ "compile": "bun --bun vite build && bun build --compile .output/server/index.mjs --outfile dist/app"
174
176
  }
175
177
  }
176
178
  ```
177
179
 
180
+ `bun --bun` runs Vite on Bun's runtime. The `vite` bin has a Node shebang, so
181
+ `bun run dev` alone would start the dev server, the build and the prerender
182
+ under Node — and a project importing `bun` or `bun:sqlite` fails at the first
183
+ render. On Node the scripts are plain `vite` and `vite build`;
184
+ [Running on Bun](/hosts/bun) has the rest.
185
+
178
186
  `build` freezes every page it can, so there is no separate prerender step and
179
187
  no command for one — freezing runs the app, which needs the bundle the build
180
188
  just wrote, and only the build knows where that is. A page that must render
@@ -252,12 +260,24 @@ for before they will run.
252
260
  There is nothing to install and nothing this package adds — Vite already owns
253
261
  this. Two rules and one declaration.
254
262
 
255
- Anything named `VITE_*` is **inlined into the client bundle** and ships to the
256
- browser, whether or not a browser file reads it. Everything else stays on the
257
- server, read through `process.env`.
263
+ Anything named `PUBLIC_*` (or Vite's own `VITE_*`) is **inlined into the
264
+ client bundle** and ships to the browser, whether or not a browser file reads
265
+ it. Everything else stays on the server, read through `process.env`.
258
266
 
259
267
  The prefix is the whole boundary, so never put a secret behind it.
260
- `VITE_STRIPE_KEY` is a published key.
268
+ `PUBLIC_STRIPE_KEY` is a published key.
269
+
270
+ And one line never to write into a `.env`: `NODE_ENV`. Vite sets it itself —
271
+ `development` under `vite`, `production` under `vite build` — and honours a
272
+ `.env` that sets it, so `NODE_ENV=development` in `.env` turns `vite build`
273
+ into a development build: the pages compile against React's development JSX
274
+ runtime, the server bundles carry React's production build, and every route
275
+ fails to render with React's opaque "message omitted in production builds".
276
+ The build refuses that up front and names the file and line, because a
277
+ plugin cannot override it — Vite applies the `.env` value after plugins have
278
+ run. If another tool in the repository wants the line, keep it in that
279
+ tool's own `.env`, not the app's; the env schema still validates
280
+ `NODE_ENV` from what the runtime sets.
261
281
 
262
282
  Declare the ones you use, and Vite types them:
263
283
 
@@ -268,7 +288,7 @@ interface ViteTypeOptions {
268
288
  }
269
289
 
270
290
  interface ImportMetaEnv {
271
- readonly VITE_API_URL: string;
291
+ readonly PUBLIC_API_URL: string;
272
292
  }
273
293
  ```
274
294
 
@@ -280,7 +300,7 @@ interface ImportMetaEnv {
280
300
 
281
301
  #### When they are read
282
302
 
283
- `import.meta.env.VITE_*` is a literal in the bundle: changing one means a
303
+ `import.meta.env.PUBLIC_*` is a literal in the bundle: changing one means a
284
304
  rebuild. `process.env.*` is a live read, so the deploy's environment wins —
285
305
  with one exception the build makes for you. A route that reads an environment
286
306
  variable and nothing request-bound is frozen at build time, value included,
@@ -302,13 +322,18 @@ import { z } from 'zod';
302
322
 
303
323
  export const env = createEnv({
304
324
  server: { DATABASE_URL: z.string().url() },
305
- clientPrefix: 'VITE_',
306
- client: { VITE_API_URL: z.string().url() },
325
+ clientPrefix: 'PUBLIC_',
326
+ client: { PUBLIC_API_URL: z.string().url() },
307
327
  runtimeEnv: typeof process === 'undefined' ? import.meta.env : { ...import.meta.env, ...process.env },
308
328
  emptyStringAsUndefined: true,
309
329
  });
310
330
  ```
311
331
 
332
+ `typeof process === 'undefined'` is not decoration: a `"use client"` file
333
+ that imports this for a `PUBLIC_` value runs where there is no `process`,
334
+ and `{ ...process.env }` there is a `ReferenceError` before the first
335
+ render. The scaffold's `env.ts` is written this way.
336
+
312
337
  Read `env.DATABASE_URL` instead of `process.env.DATABASE_URL` and three things
313
338
  follow. It is a `string`, not `string | undefined`. A server variable touched
314
339
  from a client component throws by name rather than being silently `undefined`.
package/guides/laravel.md CHANGED
@@ -127,6 +127,16 @@ already use. Vite **is** the renderer in development. It writes
127
127
  Laravel does not route is handed through to the address inside it. Stop the
128
128
  dev server and the file goes with it, and so does the proxy.
129
129
 
130
+ Per url the rule is: **if the React tree has it, React renders it**;
131
+ otherwise Laravel does. That holds for a url Laravel also routes — a fresh
132
+ application ships `Route::get('/', …)` to its welcome page, and after
133
+ install `/` is `resources/js/app/page.tsx`, not the welcome page. The build
134
+ writes its route table to `bootstrap/rsc/vite/routes.json` on every `vite`
135
+ and `vite build`, and the package registers those urls after `routes/web.php`
136
+ loads, so a page added to the tree is routed on the next request. The
137
+ welcome route can stay or go; it answers nothing while the page exists.
138
+ Until the first `vite` there is no table, and Laravel answers everything.
139
+
130
140
  The other direction works too. The renderer reads `APP_URL` and
131
141
  `RSC_HOST_CALL_SECRET` from the app's own `.env`, and a url the route tree does
132
142
  not own — `/login`, a Blade page, a webhook, a file under `/storage` — is
@@ -232,7 +242,21 @@ method runs, which is where `ValidationException` usually comes from.
232
242
 
233
243
  A class under `app/Rsc/Actions/` is a server action. `rsc:action-manifest`
234
244
  writes the map, and the build writes a `"use server"` module beside your pages
235
- exporting one function per method, named `classMethod`:
245
+ exporting one function per method, named `classMethod`.
246
+
247
+ Make one with its guards already on it:
248
+
249
+ ```sh
250
+ php artisan make:rsc-action Orders --method=cancel --auth --can=update,Order --revalidate=orders
251
+ ```
252
+
253
+ `--method` per call (none makes the class invokable, reached as `orders`),
254
+ `--auth` for `#[Authenticated]`, `--can=ability` or `--can=ability,Model` for
255
+ `#[Can]`, `--middleware=throttle:60,1` for `#[Middleware]`, `--revalidate`
256
+ for the `Rsc::revalidate()` line, and `--rpc` to make a class for `rpc()`
257
+ under `app/Rsc` instead. A slash nests: `Billing/Invoices`. The attributes it
258
+ writes are the ones the registry reads, so what you asked for at the prompt
259
+ is what runs.
236
260
 
237
261
  ```php title="app/Rsc/Actions/Orders.php"
238
262
  namespace App\Rsc\Actions;
@@ -362,7 +386,8 @@ closer to a millisecond. What matters is how many *sequential* calls a page
362
386
  needs. A page with no `middleware.ts` middleware makes no guard call; sibling
363
387
  components awaiting `rpc()` are rendered concurrently, so their calls
364
388
  overlap — and calls issued in the same tick travel as **one** request, a
365
- batch the package answers in one Laravel request; `cache()` dedupes
389
+ batch the package answers in one Laravel request, one line per call as
390
+ each finishes, so a fast read is not held behind a slow one; `cache()` dedupes
366
391
  identical calls within a request; a frozen page makes none at all and a
367
392
  shell only for its holes. A guarded page is therefore typically two Laravel
368
393
  requests — the guard, then the batch of its reads — and a host-call-heavy app
package/guides/mcp.md CHANGED
@@ -18,11 +18,15 @@ The scaffold already did. Every project gets a `.mcp.json` at its root:
18
18
  ```json title=".mcp.json"
19
19
  {
20
20
  "mcpServers": {
21
- "rsc-kit": { "command": "npx", "args": ["-y", "@rsc-kit/mcp"] }
21
+ "rsc-kit": { "command": "bunx", "args": ["@rsc-kit/mcp"] }
22
22
  }
23
23
  }
24
24
  ```
25
25
 
26
+ On a Node project, `"command": "npx", "args": ["-y", "@rsc-kit/mcp"]` — the
27
+ same server, launched by the runtime the project has. The scaffold writes
28
+ whichever fits.
29
+
26
30
  Claude Code reads that as project-scoped configuration and asks you to approve
27
31
  it the first time it starts the server. Nothing is installed until then, and
28
32
  nothing about your build or dev server changes — it is a file an agent reads.
@@ -32,12 +36,14 @@ For a project that predates the file, `create-rsc-kit init` writes it (and
32
36
  leaves one that is already there alone), or add it by hand:
33
37
 
34
38
  ```sh
35
- claude mcp add rsc-kit -- npx -y @rsc-kit/mcp
39
+ claude mcp add rsc-kit -- bunx @rsc-kit/mcp # or: npx -y @rsc-kit/mcp
36
40
  ```
37
41
 
38
- Any other MCP client takes the same entry — a stdio server, command `npx`,
39
- arguments `-y @rsc-kit/mcp` — in its own file: `.cursor/mcp.json` under
40
- `mcpServers`, `.vscode/mcp.json` under `servers`.
42
+ Any other MCP client takes the same entry — a stdio server, command `bunx`
43
+ (or `npx`), argument `@rsc-kit/mcp` — in its own file: `.cursor/mcp.json`
44
+ under `mcpServers`, `.vscode/mcp.json` under `servers`. If a client reports
45
+ the server as unreachable, run the command by hand: `bunx @rsc-kit/mcp`
46
+ waits on stdin for a client, and printing nothing is it working.
41
47
 
42
48
  ## What it answers about your app
43
49