@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/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +273 -18
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +41 -8
- package/guides/authorization.md +2 -2
- package/guides/backend-answered-pages.md +74 -3
- package/guides/bun.md +76 -0
- package/guides/coming-from-next.md +18 -3
- package/guides/deployment.md +10 -0
- package/guides/emails.md +13 -1
- package/guides/feature-flags.md +63 -0
- package/guides/fonts.md +25 -0
- package/guides/forms.md +103 -0
- package/guides/index.json +15 -0
- package/guides/installation.md +38 -13
- package/guides/laravel.md +27 -2
- package/guides/mcp.md +11 -5
- package/guides/offline.md +9 -4
- package/guides/openapi.md +99 -0
- package/guides/redirects.md +12 -1
- package/guides/server-actions.md +51 -7
- package/guides/testing.md +25 -2
- package/guides/typed-routes.md +16 -9
- package/guides/url-validation.md +8 -2
- package/guides/where-it-runs.md +87 -9
- package/guides/your-own-backend.md +28 -7
- package/package.json +1 -1
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_*` | `
|
|
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.
|
|
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
|
package/guides/deployment.md
CHANGED
|
@@ -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",
|
package/guides/installation.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
256
|
-
browser, whether or not a browser file reads
|
|
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
|
-
`
|
|
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
|
|
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.
|
|
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: '
|
|
306
|
-
client: {
|
|
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
|
|
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": "
|
|
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 `
|
|
39
|
-
|
|
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
|
|