@rsc-kit/mcp 0.18.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/recipes.js +242 -17
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +22 -5
- package/guides/authorization.md +22 -2
- package/guides/backend-answered-pages.md +27 -3
- package/guides/coming-from-next.md +17 -3
- package/guides/deployment.md +25 -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 +10 -0
- package/guides/installation.md +15 -9
- package/guides/laravel.md +27 -2
- package/guides/offline.md +9 -4
- package/guides/openapi.md +99 -0
- package/guides/quick-start.md +3 -1
- package/guides/redirects.md +19 -4
- package/guides/routing.md +4 -4
- package/guides/server-actions.md +51 -7
- package/guides/testing.md +22 -1
- package/guides/typed-routes.md +15 -9
- package/guides/where-it-runs.md +74 -9
- package/guides/your-own-backend.md +28 -7
- package/package.json +1 -1
package/guides/deployment.md
CHANGED
|
@@ -42,6 +42,21 @@ CMD ["bun", ".output/server/index.mjs"]
|
|
|
42
42
|
The second stage carries `.output` and nothing else. On the docs application
|
|
43
43
|
that is 1.0 MB against 104 MB of `node_modules`.
|
|
44
44
|
|
|
45
|
+
<Aside type="tip" title="What a port measured">
|
|
46
|
+
A production app ported from Next, same pages, same backend, the Bun binary
|
|
47
|
+
image beside the Next one it replaced:
|
|
48
|
+
|
|
49
|
+
| | Next | rsc-kit |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| docker image | 104.56 MiB | 50.12 MiB |
|
|
52
|
+
| docker build | 5 m 13 s | 2 m 45 s |
|
|
53
|
+
| Lighthouse | — | 99 mobile, 100 desktop |
|
|
54
|
+
|
|
55
|
+
Nothing was tuned for the numbers. The image is smaller because the bundle
|
|
56
|
+
carries no `node_modules`; the build is faster because there is one build,
|
|
57
|
+
not a build and a trace.
|
|
58
|
+
</Aside>
|
|
59
|
+
|
|
45
60
|
## Or a platform, without a Dockerfile
|
|
46
61
|
|
|
47
62
|
Change the preset and Nitro produces what that platform expects — a Worker and
|
|
@@ -124,6 +139,16 @@ they are covered. See [serving shells from a CDN](/guides/edge-caching).
|
|
|
124
139
|
|
|
125
140
|
Cached responses carry a build version, so a deploy invalidates them.
|
|
126
141
|
|
|
142
|
+
**Build, then start — never build under a running server.** The server loads
|
|
143
|
+
its rsc and ssr services lazily, on the first request that needs each, and
|
|
144
|
+
a `vite build` that is rewriting `.output/` at that moment hands it a file
|
|
145
|
+
that is half-written or briefly missing. The runtime caches that failed
|
|
146
|
+
import — Bun does, and so does Node's ESM loader — so the route answers 500
|
|
147
|
+
with an `ENOENT` for a file that now exists, and keeps doing so until the
|
|
148
|
+
process restarts. In development use `vite` (the dev server), which owns its
|
|
149
|
+
own rebuilds; in production build into a fresh directory or stop the server
|
|
150
|
+
first, and start it against a finished `.output/`.
|
|
151
|
+
|
|
127
152
|
Ship `.output/` from the same commit as the code that serves it. A server
|
|
128
153
|
running one build against another's frozen pages is the one combination nothing
|
|
129
154
|
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
|
@@ -59,6 +59,11 @@
|
|
|
59
59
|
"title": "Errors and 404s",
|
|
60
60
|
"description": "What a visitor sees when a page throws, or asks for a url nothing answers."
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
"slug": "feature-flags",
|
|
64
|
+
"title": "Feature flags",
|
|
65
|
+
"description": "Vercel's Flags SDK runs unchanged — flags/next, without Next."
|
|
66
|
+
},
|
|
62
67
|
{
|
|
63
68
|
"slug": "file-uploads",
|
|
64
69
|
"title": "File uploads",
|
|
@@ -134,6 +139,11 @@
|
|
|
134
139
|
"title": "Offline",
|
|
135
140
|
"description": "Knowing when the server cannot be reached, and carrying on without it."
|
|
136
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
|
+
},
|
|
137
147
|
{
|
|
138
148
|
"slug": "ppr",
|
|
139
149
|
"title": "Partial prerendering",
|
package/guides/installation.md
CHANGED
|
@@ -63,6 +63,7 @@ export default defineConfig({
|
|
|
63
63
|
sourceDir: "src",
|
|
64
64
|
outDir: "build",
|
|
65
65
|
offline: true,
|
|
66
|
+
openapi: { info: { title: 'Example API', version: '1.0.0' } },
|
|
66
67
|
}),
|
|
67
68
|
react(),
|
|
68
69
|
],
|
|
@@ -171,7 +172,7 @@ runs](/hosts/where-it-runs).
|
|
|
171
172
|
"dev": "bun --bun vite",
|
|
172
173
|
"build": "bun --bun vite build",
|
|
173
174
|
"start": "bun .output/server/index.mjs",
|
|
174
|
-
"compile": "bun --bun vite build && bun build --compile .output/server/
|
|
175
|
+
"compile": "bun --bun vite build && bun build --compile .output/server/compile.mjs --outfile dist/app"
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
178
|
```
|
|
@@ -259,12 +260,12 @@ for before they will run.
|
|
|
259
260
|
There is nothing to install and nothing this package adds — Vite already owns
|
|
260
261
|
this. Two rules and one declaration.
|
|
261
262
|
|
|
262
|
-
Anything named `VITE_*` is **inlined into the
|
|
263
|
-
browser, whether or not a browser file reads
|
|
264
|
-
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`.
|
|
265
266
|
|
|
266
267
|
The prefix is the whole boundary, so never put a secret behind it.
|
|
267
|
-
`
|
|
268
|
+
`PUBLIC_STRIPE_KEY` is a published key.
|
|
268
269
|
|
|
269
270
|
And one line never to write into a `.env`: `NODE_ENV`. Vite sets it itself —
|
|
270
271
|
`development` under `vite`, `production` under `vite build` — and honours a
|
|
@@ -287,7 +288,7 @@ interface ViteTypeOptions {
|
|
|
287
288
|
}
|
|
288
289
|
|
|
289
290
|
interface ImportMetaEnv {
|
|
290
|
-
readonly
|
|
291
|
+
readonly PUBLIC_API_URL: string;
|
|
291
292
|
}
|
|
292
293
|
```
|
|
293
294
|
|
|
@@ -299,7 +300,7 @@ interface ImportMetaEnv {
|
|
|
299
300
|
|
|
300
301
|
#### When they are read
|
|
301
302
|
|
|
302
|
-
`import.meta.env.
|
|
303
|
+
`import.meta.env.PUBLIC_*` is a literal in the bundle: changing one means a
|
|
303
304
|
rebuild. `process.env.*` is a live read, so the deploy's environment wins —
|
|
304
305
|
with one exception the build makes for you. A route that reads an environment
|
|
305
306
|
variable and nothing request-bound is frozen at build time, value included,
|
|
@@ -321,13 +322,18 @@ import { z } from 'zod';
|
|
|
321
322
|
|
|
322
323
|
export const env = createEnv({
|
|
323
324
|
server: { DATABASE_URL: z.string().url() },
|
|
324
|
-
clientPrefix: '
|
|
325
|
-
client: {
|
|
325
|
+
clientPrefix: 'PUBLIC_',
|
|
326
|
+
client: { PUBLIC_API_URL: z.string().url() },
|
|
326
327
|
runtimeEnv: typeof process === 'undefined' ? import.meta.env : { ...import.meta.env, ...process.env },
|
|
327
328
|
emptyStringAsUndefined: true,
|
|
328
329
|
});
|
|
329
330
|
```
|
|
330
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
|
+
|
|
331
337
|
Read `env.DATABASE_URL` instead of `process.env.DATABASE_URL` and three things
|
|
332
338
|
follow. It is a `string`, not `string | undefined`. A server variable touched
|
|
333
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/offline.md
CHANGED
|
@@ -65,8 +65,10 @@ rscKit({ offline: true })
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
The build writes `sw.js` beside the assets and the generated entry registers
|
|
68
|
-
it
|
|
69
|
-
|
|
68
|
+
it — once the page has loaded, or at once if it already has by the time the
|
|
69
|
+
runtime boots. With it on, a page you have visited survives a full reload
|
|
70
|
+
with no network at all — not just a navigation, a reload — and comes back
|
|
71
|
+
interactive.
|
|
70
72
|
|
|
71
73
|
Everything else lives in one page's memory — the pages a boundary keeps
|
|
72
74
|
mounted, the prefetch cache. Reload with no network and the browser shows its
|
|
@@ -86,7 +88,8 @@ on every navigation, so it happens on the first load.
|
|
|
86
88
|
|
|
87
89
|
| | |
|
|
88
90
|
| --- | --- |
|
|
89
|
-
|
|
|
91
|
+
| what boots the app | at install: the scripts, stylesheets, fonts, manifest and icons, plus `/` and the offline page with the payloads they boot from. Not an image, a wasm module or the share card — those are cached the first time they are used, so an install costs what a first page costs and not a megabyte more |
|
|
92
|
+
| any other hashed asset | the first time it is asked for, and from the cache forever after — the name changes when the bytes do |
|
|
90
93
|
| a page you loaded | its document, and the payload it boots from |
|
|
91
94
|
| a page you reached by link | its payload, and its document fetched once to go with it |
|
|
92
95
|
| a page you never visited | nothing |
|
|
@@ -178,7 +181,9 @@ export default function Offline() {
|
|
|
178
181
|
Nothing in the file makes it special. It is an ordinary route, and what makes it
|
|
179
182
|
the fallback is that the build stored it and the worker precached it. It is also
|
|
180
183
|
the one page that can honestly stand in for another, because it is about being
|
|
181
|
-
offline rather than about the url it appears under.
|
|
184
|
+
offline rather than about the url it appears under. It stands in for every
|
|
185
|
+
navigation nothing can answer — a page the build stored but this browser
|
|
186
|
+
never visited included.
|
|
182
187
|
|
|
183
188
|
**It has to be static.** A fallback that renders per request cannot be served
|
|
184
189
|
when there is no request to be made, so the build checks and says when it will
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# OpenAPI
|
|
2
|
+
|
|
3
|
+
> A document derived from your route.ts files, and Scalar's page over it.
|
|
4
|
+
|
|
5
|
+
Every `route.ts` already says what an OpenAPI operation needs: the methods it
|
|
6
|
+
exports, and the `params`, `searchParams` and `body` schemas beside them. So
|
|
7
|
+
the document is derived, not written — the way Elysia derives its from the
|
|
8
|
+
schemas on its routes — and it cannot go stale.
|
|
9
|
+
|
|
10
|
+
```ts title="vite.config.ts"
|
|
11
|
+
rscKit({
|
|
12
|
+
openapi: {
|
|
13
|
+
info: { title: 'Shop API', version: '1.0.0' },
|
|
14
|
+
servers: [{ url: 'https://api.shop.example' }],
|
|
15
|
+
security: [{ bearerAuth: [] }],
|
|
16
|
+
components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } } },
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`openapi: true` is the same with defaults. The document answers at
|
|
22
|
+
`/openapi.json` (`path` moves it), as an api route the build stores, without
|
|
23
|
+
middleware — a document exists to be read.
|
|
24
|
+
|
|
25
|
+
## What a route contributes
|
|
26
|
+
|
|
27
|
+
```ts title="src/app/api/orders/[id]/route.ts"
|
|
28
|
+
import { z } from 'zod'
|
|
29
|
+
import type { RouteContext } from '@rsc-kit/core/route-schema'
|
|
30
|
+
|
|
31
|
+
export const params = z.object({ id: z.coerce.number().int() })
|
|
32
|
+
export const searchParams = z.object({ expand: z.boolean().optional() })
|
|
33
|
+
export const body = z.object({ title: z.string().min(1) })
|
|
34
|
+
|
|
35
|
+
export const openapi = {
|
|
36
|
+
tags: ['Orders'],
|
|
37
|
+
PATCH: { summary: 'Rename an order' },
|
|
38
|
+
responses: { 200: { description: 'The order' } },
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function GET(request: Request, { params }: RouteContext<typeof params>) { … }
|
|
42
|
+
export async function PATCH(request: Request, { params, body }: RouteContext<typeof params, never, typeof body>) { … }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| from | into the document |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| the directory | the path: `/api/orders/[id]` → `/api/orders/{id}` |
|
|
48
|
+
| each method export | an operation |
|
|
49
|
+
| `params` | the path parameters' types; a segment with no schema is a string |
|
|
50
|
+
| `searchParams` | one query parameter per property, required where the schema requires it |
|
|
51
|
+
| `body` | the request body of `POST`, `PUT`, `PATCH`, `DELETE`, and a documented `422` |
|
|
52
|
+
| a `middleware.ts` above | a `session` security requirement, and `401`/`403` |
|
|
53
|
+
| `export const openapi` | anything else an operation may say — `summary`, `description`, `tags`, `responses` — shared, or per method under `GET`/`POST`/… |
|
|
54
|
+
|
|
55
|
+
A schema contributes by describing itself as JSON Schema (Standard JSON
|
|
56
|
+
Schema): Zod 4 and ArkType do; Valibot needs its own converter and
|
|
57
|
+
contributes nothing yet. Response bodies are what a route declares in
|
|
58
|
+
`openapi.responses` — a handler returns `Response`, so nothing else knows the
|
|
59
|
+
shape — until a typed response helper carries it.
|
|
60
|
+
|
|
61
|
+
`export const openapi = false` leaves a route out: the page that renders the
|
|
62
|
+
document, a webhook meant for one caller. `{ DELETE: false }` leaves one
|
|
63
|
+
method out. An app whose routes are mostly webhooks turns the default
|
|
64
|
+
around with `rscKit({ openapi: { include: 'declared' } })`: only a route
|
|
65
|
+
that exports `openapi` is documented, and a callback needs no line. `HEAD` and `OPTIONS` are never documented — the engine answers
|
|
66
|
+
them for every route, and a file exporting `OPTIONS` for a CORS preflight is
|
|
67
|
+
not describing an operation.
|
|
68
|
+
|
|
69
|
+
## The page
|
|
70
|
+
|
|
71
|
+
Scalar's API Reference is a route handler in this shape already, so the
|
|
72
|
+
page is one file and none of it is ours:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
bun add @scalar/nextjs-api-reference
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```ts title="src/app/reference/route.ts"
|
|
79
|
+
import { ApiReference } from '@scalar/nextjs-api-reference'
|
|
80
|
+
|
|
81
|
+
export const GET = ApiReference({ url: '/openapi.json' })
|
|
82
|
+
export const openapi = false
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Stored at build like any route that reads nothing per request. Scalar's
|
|
86
|
+
package is the app's dependency and the app's version; the engine ships no
|
|
87
|
+
UI and no dependency for it, whether or not the document is on.
|
|
88
|
+
|
|
89
|
+
## Coming from a hand-written spec
|
|
90
|
+
|
|
91
|
+
A spec kept in a file has three parts, and two of them move:
|
|
92
|
+
|
|
93
|
+
- the `paths` — delete them; they are the routes and their schemas now, and
|
|
94
|
+
the same `body` schema validates the request at runtime, which the
|
|
95
|
+
hand-written spec never did
|
|
96
|
+
- `info`, `servers`, `security`, `components.securitySchemes` — into
|
|
97
|
+
`rscKit({ openapi })`
|
|
98
|
+
- a route's `summary`, `tags` and response shapes — into its
|
|
99
|
+
`export const openapi`
|
package/guides/quick-start.md
CHANGED
|
@@ -29,7 +29,9 @@ export const env = createEnv({
|
|
|
29
29
|
},
|
|
30
30
|
clientPrefix: 'PUBLIC_',
|
|
31
31
|
client: {},
|
|
32
|
-
|
|
32
|
+
// No bare process.env: a "use client" file importing this for a PUBLIC_
|
|
33
|
+
// value has no process, and the spread would throw before the first render.
|
|
34
|
+
runtimeEnv: { ...(typeof process === 'undefined' ? {} : process.env), ...import.meta.env },
|
|
33
35
|
emptyStringAsUndefined: true,
|
|
34
36
|
})
|
|
35
37
|
```
|
package/guides/redirects.md
CHANGED
|
@@ -55,7 +55,11 @@ windows, and nothing you write chooses between them:
|
|
|
55
55
|
Neither buffers the response. Before anything is written the host is still
|
|
56
56
|
waiting on the shell, so a component that redirects instead of rendering is
|
|
57
57
|
caught there. After that, React already carries an error digest to the client
|
|
58
|
-
and the destination rides along in it
|
|
58
|
+
and the destination rides along in it, and the browser performs it as a
|
|
59
|
+
navigation — the layouts above stay mounted, the url being left is replaced
|
|
60
|
+
rather than kept in history. An `error.tsx` on the route never sees it: a
|
|
61
|
+
redirect is the page's answer, not a failure, whether it is thrown by the
|
|
62
|
+
page, by a component under its own `<Suspense>`, or by a parallel route slot.
|
|
59
63
|
|
|
60
64
|
<Aside type="caution" title="Middleware belongs above the boundaries">
|
|
61
65
|
A `loading.tsx` wraps the whole page in `<Suspense>`. That is usually what you
|
|
@@ -85,10 +89,10 @@ renders, on every path. This is the one built for the job.
|
|
|
85
89
|
layout skips the check:
|
|
86
90
|
|
|
87
91
|
```bash
|
|
88
|
-
curl -H 'X-RSC:
|
|
92
|
+
curl -H 'X-RSC: 1' -H 'X-RSC-Segments: app/layout' /guarded
|
|
89
93
|
# 204, X-RSC-Redirect: /orders ← the middleware ran
|
|
90
94
|
|
|
91
|
-
curl -H 'X-RSC:
|
|
95
|
+
curl -H 'X-RSC: 1' -H 'X-RSC-Segments: app/layout,app/guarded/layout' /guarded
|
|
92
96
|
# 200, and the page's content ← it did not
|
|
93
97
|
```
|
|
94
98
|
|
|
@@ -123,7 +127,18 @@ window, and nothing warns.
|
|
|
123
127
|
## From a server action
|
|
124
128
|
|
|
125
129
|
An action is not a render, so there is no shell to be on either side of. Throw
|
|
126
|
-
from the action
|
|
130
|
+
from the action — a plain `"use server"` function or one built from
|
|
131
|
+
`createActionClient()` — and the client follows it as a navigation: the
|
|
132
|
+
layouts stay mounted and the history entry the form was on is replaced, so
|
|
133
|
+
Back does not return to the submitted form. A `<Form>` shows nothing for it;
|
|
134
|
+
signing in and arriving is the success.
|
|
135
|
+
|
|
136
|
+
To the caller, the action **resolves** — with `{ redirected: '/where' }` —
|
|
137
|
+
once the navigation is under way. It does not throw: a `startTransition(async
|
|
138
|
+
() => { await logOut() })` with no `catch` around it would otherwise reject
|
|
139
|
+
into React, which unmounts the root, and a logout ended on a white page.
|
|
140
|
+
There is nothing to catch; a component that awaited the action is on its
|
|
141
|
+
way off the screen, and the object it got is safe to read any field of.
|
|
127
142
|
|
|
128
143
|
```ts title="src/actions.ts"
|
|
129
144
|
'use server'
|
package/guides/routing.md
CHANGED
|
@@ -232,14 +232,14 @@ fails at the list rather than at the `Link` that renders it:
|
|
|
232
232
|
|
|
233
233
|
```tsx title="src/components/Nav.tsx"
|
|
234
234
|
// `satisfies` rather than a type annotation: an annotation would widen href to
|
|
235
|
-
//
|
|
235
|
+
// Route and lose which one each entry is, while this keeps the literals and
|
|
236
236
|
// still checks them — so a typo fails here, at the list, rather than at the
|
|
237
237
|
// Link that renders it.
|
|
238
238
|
const links = [
|
|
239
239
|
{ href: '/', label: 'Home' },
|
|
240
240
|
{ href: '/dashboard', label: 'Dashboard' },
|
|
241
241
|
{ href: '/posts/hello-world', label: 'A Post' },
|
|
242
|
-
] satisfies { href:
|
|
242
|
+
] satisfies { href: Route; label: string }[]
|
|
243
243
|
```
|
|
244
244
|
|
|
245
245
|
### When the value is not already a url
|
|
@@ -269,9 +269,9 @@ When a destination is computed rather than written, cast it — that is the seam
|
|
|
269
269
|
where you are telling the typechecker something it cannot know:
|
|
270
270
|
|
|
271
271
|
```ts
|
|
272
|
-
import type {
|
|
272
|
+
import type { Route } from '@rsc-kit/core/routes';
|
|
273
273
|
|
|
274
|
-
<Link href={savedPath as
|
|
274
|
+
<Link href={savedPath as Route}>Resume</Link>
|
|
275
275
|
```
|
|
276
276
|
|
|
277
277
|
Two limits. A dynamic segment widens to `${string}`, and a template literal type
|