@rsc-kit/mcp 0.14.0 → 0.16.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.
Files changed (52) hide show
  1. package/dist/answers.d.ts +7 -0
  2. package/dist/answers.js +28 -0
  3. package/dist/answers.js.map +1 -1
  4. package/dist/bundleGuides.d.ts +27 -0
  5. package/dist/bundleGuides.js +138 -0
  6. package/dist/bundleGuides.js.map +1 -0
  7. package/dist/index.js +21 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/recipes.js +138 -17
  10. package/dist/recipes.js.map +1 -1
  11. package/dist/report.d.ts +13 -0
  12. package/dist/report.js +1 -1
  13. package/dist/report.js.map +1 -1
  14. package/guides/api-routes.md +168 -0
  15. package/guides/authorization.md +288 -0
  16. package/guides/caching.md +57 -0
  17. package/guides/coming-from-next.md +151 -0
  18. package/guides/connection.md +98 -0
  19. package/guides/edge-caching.md +159 -0
  20. package/guides/errors.md +109 -0
  21. package/guides/file-uploads.md +119 -0
  22. package/guides/fonts.md +117 -0
  23. package/guides/forms.md +528 -0
  24. package/guides/getting-started.md +132 -0
  25. package/guides/images.md +83 -0
  26. package/guides/index.json +187 -0
  27. package/guides/installation.md +338 -0
  28. package/guides/introduction.md +119 -0
  29. package/guides/mcp.md +113 -0
  30. package/guides/metadata.md +289 -0
  31. package/guides/navigation.md +84 -0
  32. package/guides/no-javascript.md +76 -0
  33. package/guides/offline.md +215 -0
  34. package/guides/ppr.md +181 -0
  35. package/guides/pwa.md +260 -0
  36. package/guides/queries.md +340 -0
  37. package/guides/quick-start.md +99 -0
  38. package/guides/react-compiler.md +153 -0
  39. package/guides/redirects.md +143 -0
  40. package/guides/response-headers.md +66 -0
  41. package/guides/route-interception.md +206 -0
  42. package/guides/routing.md +458 -0
  43. package/guides/sections.md +74 -0
  44. package/guides/server-actions.md +444 -0
  45. package/guides/static-generation.md +347 -0
  46. package/guides/testing.md +158 -0
  47. package/guides/third-party-scripts.md +105 -0
  48. package/guides/typed-routes.md +139 -0
  49. package/guides/url-validation.md +143 -0
  50. package/guides/validation.md +175 -0
  51. package/guides/view-transitions.md +120 -0
  52. package/package.json +4 -3
@@ -0,0 +1,159 @@
1
+ # Serving shells from a CDN
2
+
3
+ > Putting build-time shells on the edge, and what rsc-kit does not do.
4
+
5
+ A prerendered page is a file. So is a PPR shell. Both can sit on a CDN and be
6
+ served without touching your origin.
7
+
8
+ ## What the build gives you
9
+
10
+ ```
11
+ build/static/
12
+ about.html a whole page, frozen
13
+ posts/hello.html likewise
14
+ posts/[slug].ppr.html a shell — static parts frozen, holes still empty
15
+ about.flight the payload for a client-side navigation
16
+ about.seg1.flight the same, for a client already holding one layout
17
+ ```
18
+
19
+ `.html` is a finished page. `.ppr.html` is a shell: the layout, nav and
20
+ everything outside a `<Suspense>` boundary, with the fallbacks still in place.
21
+ Neither contains per-visitor data — they were rendered at build time, in no
22
+ request's context, which is what makes them safe for a shared cache.
23
+
24
+ ## Caching them
25
+
26
+ The host already sends the right header:
27
+
28
+ ```
29
+ Cache-Control: public, max-age=0, must-revalidate
30
+ ```
31
+
32
+ `public` says a shared cache may store it. `max-age=0, must-revalidate` says
33
+ **check with the origin first**. So a CDN in front of this holds the bytes but
34
+ still asks every time — which saves bandwidth and nothing else.
35
+
36
+ To actually serve from the edge, add a cache rule for the paths you want held,
37
+ and give the CDN an edge TTL. On Cloudflare that is a Cache Rule with *Edge TTL
38
+ → Override origin*.
39
+
40
+ **Do not blanket-cache the whole site.** A route with middleware is deliberately
41
+ sent as:
42
+
43
+ ```
44
+ Cache-Control: private, no-store
45
+ ```
46
+
47
+ because middleware runs per visitor — that is a page whose content depends on
48
+ who asked. A zone-wide "cache everything" rule overrides that and serves one
49
+ person's gated page to everyone. Scope the rule to the paths you know are
50
+ static.
51
+
52
+ Deployments are handled for you: cached responses carry a build version, so a
53
+ new deploy does not leave old shells being served against a new payload.
54
+
55
+ ## Finishing a shell at the edge
56
+
57
+ A shell has holes in it, and something has to fill them. That happens at your
58
+ origin, into the same response — the shell is written first, then the
59
+ boundaries it could not finish.
60
+
61
+ You get this with no configuration: request a PPR route and the document you
62
+ receive already contains its dynamic content.
63
+
64
+ A small inline script from React moves each hole into place as the HTML parses.
65
+ So the content appears without waiting for the app bundle or for hydration — on
66
+ a slow connection, the difference between a spinner and a page. It also means
67
+ the content is in the HTML a crawler reads.
68
+
69
+ This is not the same as working without JavaScript: with scripting off, the
70
+ fallbacks stay.
71
+
72
+ To serve the shell itself from a CDN, two endpoints exist for an edge worker:
73
+
74
+ ```
75
+ GET /_rsc/ppr-shell?url=/dashboard the build-time shell, cacheable
76
+ POST /_rsc/ppr-resume?url=/dashboard the holes, for this visitor
77
+ ```
78
+
79
+ There is a complete Cloudflare implementation in
80
+ [`examples/cloudflare-ppr-worker`](https://github.com/rsc-kit/rsc-kit/tree/main/examples/cloudflare-ppr-worker),
81
+ with no KV and no build step. The cache fills itself from the shell endpoint: a
82
+ miss goes to the origin while the shell warms behind it, and a hit streams the
83
+ shell then pipes the resumed holes onto the same response.
84
+
85
+ ### What the response looks like on the wire
86
+
87
+ Measured on a deployed worker, for a page whose hole takes 2.5 s:
88
+
89
+ ```
90
+ headers 55 ms
91
+ first body byte 56 ms
92
+ shell heading 56 ms ← the page is on screen here
93
+ fallback markup 56 ms
94
+ hole content 2549 ms ← same response, no second request
95
+ stream complete 2549 ms
96
+ ```
97
+
98
+ Before this, the same document finished in 2 ms and contained no hole at all —
99
+ the content arrived later, on a separate payload fetch, after React had
100
+ hydrated.
101
+
102
+ **The trade is that the response stays open until the holes finish.** Previously
103
+ the document closed immediately and `load` fired early; now it fires when the
104
+ slowest boundary resolves. Nothing a visitor sees is slower — the shell paints
105
+ at the same moment either way — but page-level metrics that key on `load` will
106
+ read differently, and a proxy with a short response timeout needs to allow for
107
+ the whole render rather than just the shell.
108
+
109
+ ## Guarded routes are never cached
110
+
111
+ The shell endpoint answers `404` for any route that declares middleware. Such a
112
+ page is not cacheable by a shared cache at all, so it never becomes a cache
113
+ entry — refused at the source rather than checked at the edge.
114
+
115
+ The resume endpoint runs that route's middleware against **the caller's own
116
+ cookies**, and refuses before rendering anything. An edge worker must therefore
117
+ forward the visitor's request rather than making one of its own; a resume asked
118
+ for with no cookies is an anonymous visitor and gets an anonymous answer.
119
+
120
+ ## What stays on your origin
121
+
122
+ Next's PPR protocol hands the postponed blob to the CDN and takes it back on the
123
+ resume, which means the resume endpoint parses something an attacker can write.
124
+ That is the shape of a known denial-of-service against it.
125
+
126
+ Here the endpoint takes a **url**. The origin reads its own state from disk, and
127
+ a body posted to it is ignored. This is only possible because — unlike a generic
128
+ CDN — the origin already has the artifact, so there is nothing to hand out and
129
+ take back.
130
+
131
+ ## When a CDN owns the response
132
+
133
+ Everything above rests on one fact: on this host, a per-visitor response head
134
+ can only come from middleware. `responseHeaders()` and `cookies().set()` throw
135
+ outside it, so a route that declares no middleware has no way to acquire one —
136
+ which is why "declares middleware" is a safe answer to "is this cacheable".
137
+
138
+ An auth proxy in front of the app breaks that, and quietly. One with sliding
139
+ expiry re-issues the session on an ordinary `200`, so the response leaving your
140
+ CDN carries a `Set-Cookie` this host never sent. Marked `public`, that is one
141
+ visitor's session handed to the next.
142
+
143
+ **Give any path your proxy covers a `middleware.ts`**, even an empty one. That
144
+ is what makes a route covered here, and covered routes are excluded from every
145
+ cache decision: `private, no-store` on the page, and refused outright by the
146
+ shell endpoint.
147
+
148
+ Two things worth knowing about this failure if you go looking for it.
149
+
150
+ You cannot detect this by inspecting the response. Nothing added a
151
+ `Vary: Cookie`, and looking for `Set-Cookie` only catches the cookie version —
152
+ an `x-user-id`, a CSRF token or a locale header leaks the same way.
153
+
154
+ The reliable question is structural: who owns the response head on this route?
155
+
156
+ It fails both ways. The loud one is a session leaking to the next visitor. The
157
+ quiet one is the reverse — an anonymous response cached first, then served to
158
+ someone who should have had a session, silently signing them out. The first gets
159
+ reported; the second looks like a flaky login.
@@ -0,0 +1,109 @@
1
+ # Errors and 404s
2
+
3
+ > What a visitor sees when a page throws, or asks for a url nothing answers.
4
+
5
+ Two files, both optional, both following the same rule as `loading.tsx`: the
6
+ nearest one wins.
7
+
8
+ ## When a page throws
9
+
10
+ Put an `error.tsx` in the directory you want to cover:
11
+
12
+ ```tsx title="src/app/orders/error.tsx"
13
+ 'use client'
14
+
15
+ import type { RouteErrorProps } from '@rsc-kit/core/RouteErrorBoundary'
16
+
17
+ export default function OrdersError({ error, reset }: RouteErrorProps) {
18
+ return (
19
+ <section>
20
+ <h1>That did not work</h1>
21
+ <p>{error.message}</p>
22
+ <button type="button" onClick={reset}>Try again</button>
23
+ </section>
24
+ )
25
+ }
26
+ ```
27
+
28
+ Anything below it that throws renders this instead. The layouts above stay on
29
+ screen — only the segment that failed is replaced.
30
+
31
+ `reset()` renders the segment again, for a failure that might not happen twice.
32
+ Navigating away clears it on its own.
33
+
34
+ :::caution[It has to be a client component]
35
+ `'use client'` at the top, or the build refuses it. Catching a render error is
36
+ a class component's job, it runs in the browser, and `reset` is a callback the
37
+ browser calls — none of which a server component can do.
38
+ :::
39
+
40
+ ### What `error.message` says in production
41
+
42
+ React replaces it with a generic sentence and puts a hash on `error.digest`.
43
+ That is React, not this package: the real message could name a table, a query or
44
+ a path, and none of that should reach a browser.
45
+
46
+ Log the digest where you log the error, and the two line up.
47
+
48
+ ### It does not catch everything
49
+
50
+ - **Errors in the layout above it.** The boundary sits inside that layout, so a
51
+ layout that throws needs an `error.tsx` a directory up.
52
+ - **The build.** A page that throws every time it renders fails the build
53
+ rather than shipping a stored error page. The boundary is for a request that
54
+ goes wrong, not a page that is broken.
55
+
56
+ ## When nothing answers the url
57
+
58
+ `src/app/not-found.tsx` is rendered for any url no route matches:
59
+
60
+ ```tsx title="src/app/not-found.tsx"
61
+ import Link from '@rsc-kit/core/Link'
62
+
63
+ export default function NotFound() {
64
+ return (
65
+ <main>
66
+ <h1>No such page</h1>
67
+ <p>Nothing answers that url. <Link href="/">Go home</Link>.</p>
68
+ </main>
69
+ )
70
+ }
71
+ ```
72
+
73
+ It renders through your root layout like any other page, and is served with a
74
+ real **404** — a page that says "not found" under a `200` is a page search
75
+ engines index.
76
+
77
+ An ordinary server component, so it can be `async` and read whatever it likes.
78
+ Without one, an unmatched url gets a plain `Not found` string.
79
+
80
+ ### When the page has to decide
81
+
82
+ A url can match a route and still name nothing — `/posts/42` is a real route
83
+ and there may be no post 42. Only the page can know that, so the page says so:
84
+
85
+ ```tsx title="src/app/posts/[slug]/page.tsx"
86
+ import { notFound } from '@rsc-kit/core/not-found'
87
+
88
+ export default async function PostPage({ params }) {
89
+ const post = await findPost((await params).slug)
90
+
91
+ if (!post) notFound()
92
+
93
+ return <article>{post.title}</article>
94
+ }
95
+ ```
96
+
97
+ Same page, same **404**. It throws, so nothing after it runs — and if you wrap
98
+ the call in a `try`/`catch`, rethrow what you do not recognise, or a missing
99
+ page becomes a blank region.
100
+
101
+ :::caution[Where you call it decides the status]
102
+ Called above every Suspense boundary — at the top of a page, in a layout —
103
+ nothing has been written yet, so the response is a real 404.
104
+
105
+ Called deeper, inside a boundary, the shell has already gone out with the
106
+ status line on it. The boundary shows its fallback instead and the status stays
107
+ `200`. If the status matters — and for anything a crawler or a cache sees, it
108
+ does — do the lookup above the boundary.
109
+ :::
@@ -0,0 +1,119 @@
1
+ # File uploads
2
+
3
+ > Sending files through a server action without encoding them.
4
+
5
+ A `File` is a value a server action can take. React's Flight format carries it
6
+ as binary, so there is nothing to encode on the way out and nothing to decode
7
+ on the way in.
8
+
9
+ ## One file
10
+
11
+ ```tsx title="src/components/AvatarUpload.tsx"
12
+ "use client";
13
+
14
+ import { useState } from 'react';
15
+ import { uploadAvatar } from '../actions';
16
+
17
+ export function AvatarUpload() {
18
+ const [url, setUrl] = useState<string | null>(null);
19
+
20
+ async function submit(formData: FormData) {
21
+ const file = formData.get('avatar') as File;
22
+
23
+ if (!file || file.size === 0) return;
24
+
25
+ setUrl(await uploadAvatar(file));
26
+ }
27
+
28
+ return (
29
+ <form action={submit}>
30
+ <input type="file" name="avatar" accept="image/*" />
31
+ <button type="submit">Upload</button>
32
+ {url && <img src={url} alt="" />}
33
+ </form>
34
+ );
35
+ }
36
+ ```
37
+
38
+ The action receives the `File` itself — name, type, size and all:
39
+
40
+ ```ts title="src/actions.ts"
41
+ 'use server'
42
+
43
+ import { writeFile } from 'node:fs/promises';
44
+
45
+ export async function uploadAvatar(file: File): Promise<string> {
46
+ const bytes = new Uint8Array(await file.arrayBuffer());
47
+
48
+ await writeFile(`./public/avatars/${file.name}`, bytes);
49
+
50
+ return `/avatars/${file.name}`;
51
+ }
52
+ ```
53
+
54
+ Nothing about that is upload-specific: it is the same call, the same
55
+ serialisation and the same round trip as [any other server
56
+ action](/guides/server-actions).
57
+
58
+ ## The whole form at once
59
+
60
+ An action can take the `FormData` instead of individual arguments, which is
61
+ usually simpler when the form mixes files and fields:
62
+
63
+ ```tsx
64
+ "use client";
65
+
66
+ import { createPost } from '../actions';
67
+
68
+ export function NewPost() {
69
+ return (
70
+ <form action={createPost}>
71
+ <input name="title" />
72
+ <textarea name="body" />
73
+ <input type="file" name="cover" accept="image/*" />
74
+ <button type="submit">Create</button>
75
+ </form>
76
+ );
77
+ }
78
+ ```
79
+
80
+ ```ts title="src/actions.ts"
81
+ 'use server'
82
+
83
+ export async function createPost(formData: FormData) {
84
+ const title = formData.get('title') as string;
85
+ const cover = formData.get('cover') as File;
86
+
87
+ // …
88
+ }
89
+ ```
90
+
91
+ ## Several files
92
+
93
+ `formData.getAll` gives every file from a `multiple` input, and an array of
94
+ `File` serialises as readily as one:
95
+
96
+ ```tsx
97
+ const files = formData.getAll('photos') as File[];
98
+
99
+ await uploadPhotos(files);
100
+ ```
101
+
102
+ ```ts title="src/actions.ts"
103
+ 'use server'
104
+
105
+ export async function uploadPhotos(files: File[]) {
106
+ for (const file of files) {
107
+ // …
108
+ }
109
+ }
110
+ ```
111
+
112
+ ## Size
113
+
114
+ The whole body is buffered before the action runs — it is one `POST`, not a
115
+ stream — so an upload occupies memory for as long as it takes. Two consequences
116
+ worth planning for:
117
+
118
+ - Keep any limit your host imposes as low as the app actually needs.
119
+ - For genuinely large files, do not send them through an action at all. Have the action mint a pre-signed URL and let the browser upload straight to storage; the bytes never touch your server.
@@ -0,0 +1,117 @@
1
+ # Fonts
2
+
3
+ > Self-hosted fonts from npm, and what next/font was doing for you.
4
+
5
+ Install the font, import its CSS, name it in a variable. Vite does the rest.
6
+
7
+ ```sh
8
+ bun add @fontsource-variable/fraunces @fontsource-variable/geist
9
+ ```
10
+
11
+ ```css title="src/app/styles.css"
12
+ @import '@fontsource-variable/fraunces/full.css';
13
+ @import '@fontsource-variable/fraunces/full-italic.css';
14
+ @import '@fontsource-variable/geist';
15
+
16
+ :root {
17
+ --font-display: 'Fraunces Variable', ui-serif, Georgia, Cambria, 'Times New Roman', serif;
18
+ --font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
19
+ 'Helvetica Neue', Arial, sans-serif,
20
+ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
21
+ }
22
+ ```
23
+
24
+ Put your font in *front* of a full stack rather than in place of one. Two things
25
+ the rest of the stack does that a bare `sans-serif` does not:
26
+
27
+ - **Emoji.** Geist has no emoji glyphs, so an emoji falls through to whatever is
28
+ named after it. With nothing there, some Linux and older Android setups draw a
29
+ monochrome glyph or a box. The four emoji fonts at the end are consulted only
30
+ for characters the main font cannot draw, so they cost nothing.
31
+ - **A closer fallback.** During the `swap` window the browser shows the next
32
+ font. `-apple-system` and `Segoe UI` are nearer Geist in width and x-height
33
+ than Helvetica or Arial, so the moment of swap is a smaller jolt.
34
+
35
+ Those are Tailwind's own defaults, and shadcn's generated `--font-sans:
36
+ 'Geist Variable', sans-serif` drops both. Keep the stack.
37
+
38
+ [Fontsource](https://fontsource.org) packages every Google font — and many
39
+ others — as woff2 files with the `@font-face` rules already written. Vite
40
+ hashes the files and serves them beside your other assets, so **nothing is
41
+ fetched from Google at runtime**, and nothing is downloaded at build time
42
+ either. The files are in `node_modules`.
43
+
44
+ ## Porting from `next/font`
45
+
46
+ ```ts
47
+ // before
48
+ const fraunces = Fraunces({
49
+ variable: '--font-display',
50
+ subsets: ['latin'],
51
+ style: ['normal', 'italic'],
52
+ axes: ['SOFT', 'opsz'],
53
+ display: 'swap',
54
+ })
55
+ ```
56
+
57
+ Every option there is something Fontsource already did:
58
+
59
+ | `next/font` | Fontsource |
60
+ | --- | --- |
61
+ | `variable: '--font-display'` | the `:root` rule above — you write the variable, once |
62
+ | `subsets: ['latin']` | every subset ships, each with a `unicode-range`; the browser fetches only the ones the page uses |
63
+ | `style: ['normal', 'italic']` | `full.css` and `full-italic.css` |
64
+ | `axes: ['SOFT', 'opsz']` | `full.css` — every axis of the variable font; `standard.css` is weight alone |
65
+ | `display: 'swap'` | already in every rule |
66
+ | `className={fraunces.variable}` | nothing — the variable is on `:root` |
67
+
68
+ So the layout loses its font imports and the `className` gains nothing:
69
+
70
+ ```tsx title="src/app/layout.tsx"
71
+ import './styles.css'
72
+
73
+ export default function RootLayout({ children }) {
74
+ return (
75
+ <html lang="en" className="h-full antialiased dark font-sans">
76
+
77
+ </html>
78
+ )
79
+ }
80
+ ```
81
+
82
+ The subsetting is worth a second look, because it is *better* than what you
83
+ had. `next/font` asks you to name subsets at build time and ships those.
84
+ Fontsource ships all of them, each behind a `unicode-range`, and the browser
85
+ downloads only the ones a page's text actually needs — a Latin-only page
86
+ fetches one file whatever else is installed.
87
+
88
+ ## Preloading
89
+
90
+ The one thing `next/font` did that needs a line here. It added a `<link
91
+ rel="preload">` for each font so the browser finds it before the stylesheet
92
+ does. Without one, the font is discovered when the CSS is parsed, and the text
93
+ shows in the fallback face for a moment longer.
94
+
95
+ Import the file for its url, and React hoists the link:
96
+
97
+ ```tsx title="src/app/layout.tsx"
98
+ import fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2?url'
99
+
100
+ export default function RootLayout({ children }) {
101
+ return (
102
+ <html>
103
+ <head>
104
+ <link rel="preload" href={fraunces} as="font" type="font/woff2" crossOrigin="anonymous" />
105
+ </head>
106
+
107
+ ```
108
+
109
+ `?url` is Vite's — it hands back the hashed path the build will serve. Preload
110
+ the one file the first paint needs, usually the Latin regular; preloading all
111
+ of them defeats the subsetting.
112
+
113
+ ## A font that is not on npm
114
+
115
+ A file you own goes in `public/` and gets its own `@font-face` in your CSS, the
116
+ same as anywhere. Or in `src/` and imported with `?url` as above, which hashes
117
+ it.