@rsc-kit/mcp 0.18.1 → 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/recipes.js +225 -15
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +22 -5
- package/guides/authorization.md +2 -2
- package/guides/backend-answered-pages.md +27 -3
- package/guides/coming-from-next.md +17 -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 +10 -0
- package/guides/installation.md +14 -8
- package/guides/laravel.md +27 -2
- 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 +22 -1
- package/guides/typed-routes.md +16 -9
- package/guides/where-it-runs.md +74 -9
- package/guides/your-own-backend.md +28 -7
- package/package.json +1 -1
|
@@ -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
|
],
|
|
@@ -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/redirects.md
CHANGED
|
@@ -123,7 +123,18 @@ window, and nothing warns.
|
|
|
123
123
|
## From a server action
|
|
124
124
|
|
|
125
125
|
An action is not a render, so there is no shell to be on either side of. Throw
|
|
126
|
-
from the action
|
|
126
|
+
from the action — a plain `"use server"` function or one built from
|
|
127
|
+
`createActionClient()` — and the client follows it as a navigation: the
|
|
128
|
+
layouts stay mounted and the history entry the form was on is replaced, so
|
|
129
|
+
Back does not return to the submitted form. A `<Form>` shows nothing for it;
|
|
130
|
+
signing in and arriving is the success.
|
|
131
|
+
|
|
132
|
+
To the caller, the action **resolves** — with `{ redirected: '/where' }` —
|
|
133
|
+
once the navigation is under way. It does not throw: a `startTransition(async
|
|
134
|
+
() => { await logOut() })` with no `catch` around it would otherwise reject
|
|
135
|
+
into React, which unmounts the root, and a logout ended on a white page.
|
|
136
|
+
There is nothing to catch; a component that awaited the action is on its
|
|
137
|
+
way off the screen, and the object it got is safe to read any field of.
|
|
127
138
|
|
|
128
139
|
```ts title="src/actions.ts"
|
|
129
140
|
'use server'
|
package/guides/server-actions.md
CHANGED
|
@@ -84,6 +84,12 @@ server with it.
|
|
|
84
84
|
Files travel this way too, without any encoding of your own — see
|
|
85
85
|
[File uploads](/guides/file-uploads).
|
|
86
86
|
|
|
87
|
+
The id is a hash of the module and the export in a build —
|
|
88
|
+
`9396f92f746f#createOrder` — and nothing about the file is in it. In
|
|
89
|
+
development it is the module's path as Vite serves it
|
|
90
|
+
(`/@fs/Users/…/actions.ts#createOrder`), which is what a
|
|
91
|
+
`$ACTION_ID_…` hidden input in a form shows there; a build never carries it.
|
|
92
|
+
|
|
87
93
|
## Returning UI
|
|
88
94
|
|
|
89
95
|
Step 5 is the same serialiser a page goes through, so an action can answer
|
|
@@ -269,11 +275,18 @@ forgotten `next()` would otherwise look exactly like a check that passed.
|
|
|
269
275
|
```ts
|
|
270
276
|
const result = await createPost({ title: 'x', body: 'y' });
|
|
271
277
|
|
|
272
|
-
result
|
|
273
|
-
result
|
|
274
|
-
result
|
|
278
|
+
result?.data // what the handler returned
|
|
279
|
+
result?.validationErrors // { title: ['Too short'] }
|
|
280
|
+
result?.serverError // 'Something went wrong.'
|
|
275
281
|
```
|
|
276
282
|
|
|
283
|
+
The `?.` is the type's, not decoration: an action that `redirect()`s
|
|
284
|
+
resolves with `undefined` on the client — the page is on its way elsewhere —
|
|
285
|
+
and `result.serverError` on that is a `TypeError` inside a transition, which
|
|
286
|
+
unmounts the root. The type is `Promise<ActionResult<Data> | undefined>`,
|
|
287
|
+
so the unasked read does not compile. In a test, where nothing redirects,
|
|
288
|
+
`result!` is fine.
|
|
289
|
+
|
|
277
290
|
Returned rather than thrown, and that is not a style choice: React serialises a
|
|
278
291
|
rejected server action opaquely — production strips the message and leaves a
|
|
279
292
|
digest — so a thrown validation error reaches the browser as "an error
|
|
@@ -357,6 +370,37 @@ one can be wired in without this package having an opinion about which. See
|
|
|
357
370
|
[Writing to the response](/guides/authorization#writing-to-the-response) for
|
|
358
371
|
where else it works, and where it does not.
|
|
359
372
|
|
|
373
|
+
## Work the visitor should not wait for
|
|
374
|
+
|
|
375
|
+
An audit row, a welcome email, a cache warm: the action's answer should not
|
|
376
|
+
wait for it, and it must still finish. `after()` queues work to run once the
|
|
377
|
+
answer is on its way:
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
'use server';
|
|
381
|
+
|
|
382
|
+
import { after } from '@rsc-kit/core/request';
|
|
383
|
+
|
|
384
|
+
export async function signup(formData: FormData) {
|
|
385
|
+
const user = await createUser(formData);
|
|
386
|
+
|
|
387
|
+
after(() => sendWelcomeEmail(user));
|
|
388
|
+
after(() => audit('signup', user.id));
|
|
389
|
+
|
|
390
|
+
redirect('/welcome');
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Why not a detached promise: on a long-lived process it happens to run to
|
|
395
|
+
completion; on a Worker the isolate is torn down when the response ends
|
|
396
|
+
unless the work was handed to the platform's `waitUntil`, so a promise nobody
|
|
397
|
+
awaited dies silently — some of the time, which is the worst way. `after()`
|
|
398
|
+
is the same call on every host: handed to `waitUntil` where one exists, kept
|
|
399
|
+
by the process elsewhere. A rejection is reported to the log and never
|
|
400
|
+
reaches the response, which has already gone. It works from a component, a
|
|
401
|
+
`middleware.ts`, an api route and an action alike; outside a request — a
|
|
402
|
+
build — the work simply runs.
|
|
403
|
+
|
|
360
404
|
## Two at once
|
|
361
405
|
|
|
362
406
|
Nothing queues them. Each call is an ordinary `fetch`, so two submits fired
|
|
@@ -409,11 +453,11 @@ double-click sends two requests unless you disable the button.
|
|
|
409
453
|
An action that fails does not answer with a Flight stream, so the client turns
|
|
410
454
|
the response into an error before the decoder ever sees it:
|
|
411
455
|
|
|
412
|
-
| Response | What the client
|
|
456
|
+
| Response | What the client does | What you do |
|
|
413
457
|
| --- | --- | --- |
|
|
414
|
-
| `X-RSC-Redirect` header | `
|
|
415
|
-
| `422` | `ServerValidationError`, carrying `errors` | Show the messages. `<Form>` does it for you. |
|
|
416
|
-
| Any other failure | `Error`, naming the status | Whatever the app needs. |
|
|
458
|
+
| `X-RSC-Redirect` header | Starts the navigation and **resolves** with `{ redirected }` | Nothing — the page is on its way to the location, and there is nothing to catch. |
|
|
459
|
+
| `422` | Throws `ServerValidationError`, carrying `errors` | Show the messages. `<Form>` does it for you. |
|
|
460
|
+
| Any other failure | Throws `Error`, naming the status | Whatever the app needs. |
|
|
417
461
|
|
|
418
462
|
```tsx
|
|
419
463
|
"use client";
|
package/guides/testing.md
CHANGED
|
@@ -54,6 +54,24 @@ there is the round trip. Here the function *is* the interesting part — the
|
|
|
54
54
|
validation, the middleware, the authorisation — and it is a unit test.
|
|
55
55
|
:::
|
|
56
56
|
|
|
57
|
+
One line a port brings needs a stub: `import 'server-only'`. Keep it — the
|
|
58
|
+
build honours it, and a client file that imports the module fails to build
|
|
59
|
+
rather than shipping a secret. But under Vite the package resolves to an
|
|
60
|
+
empty module on the server, and under `bun test` it is the real package,
|
|
61
|
+
which throws on import; an action file that carries the line is not callable
|
|
62
|
+
in a unit test until a preload stubs it. The scaffold ships the two files:
|
|
63
|
+
|
|
64
|
+
```toml title="bunfig.toml"
|
|
65
|
+
[test]
|
|
66
|
+
preload = ["./tests/preload.ts"]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```ts title="tests/preload.ts"
|
|
70
|
+
import { mock } from 'bun:test'
|
|
71
|
+
|
|
72
|
+
mock.module('server-only', () => ({}))
|
|
73
|
+
```
|
|
74
|
+
|
|
57
75
|
### Reading the request
|
|
58
76
|
|
|
59
77
|
`cookies()`, `headers()` and the rest read from a scope the host opens per
|
|
@@ -120,7 +138,10 @@ that never ran, a stored page that should not have been, a `404` that came back
|
|
|
120
138
|
otherwise — the first run pays for it, the rest do not, and an edit is picked
|
|
121
139
|
up. It runs your own `build` script — `bun run build` under Bun, `npm run
|
|
122
140
|
build` under Node — so what is tested is what ships, on the runtime it ships
|
|
123
|
-
on.
|
|
141
|
+
on. Files the build wrote to `.output/public` — the hashed assets, `sw.js`,
|
|
142
|
+
`manifest.webmanifest`, the icons — are answered too, the way Nitro's static
|
|
143
|
+
layer answers them in production, so a test can prove a page's links resolve
|
|
144
|
+
and not only that the page renders. Pass
|
|
124
145
|
`{ build: false }` in a ci step that already built.
|
|
125
146
|
|
|
126
147
|
One build and one loaded module per test run, shared across files. That is both
|