@rsc-kit/mcp 0.13.0 → 0.14.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 CHANGED
@@ -261,6 +261,21 @@ export const getPosts = client.query(async ({ ctx }) => …)
261
261
  \`.handler()\` is a mutation (POST). \`.query()\` is a read (GET). Both run the
262
262
  chain, so \`ctx.user\` is typed and non-null inside them.
263
263
 
264
+ For a failure the schema cannot know - an account not found, a slug taken -
265
+ the handler is given \`fieldErrors\`, typed to its own input so a field the
266
+ schema does not have is a compile error:
267
+
268
+ \`\`\`ts
269
+ .handler(async ({ input, fieldErrors }) => {
270
+ if (!account) fieldErrors({ email: 'Account not found' })
271
+ })
272
+ \`\`\`
273
+
274
+ It throws, so nothing after it runs. It lands in validationErrors on that
275
+ field, the same place a schema refusal does. This is next-safe-action's
276
+ returnValidationErrors with no schema argument, no _errors nesting and no
277
+ return to forget.
278
+
264
279
  The point is not convenience. An action cannot be added without the check,
265
280
  because there is no other constructor to reach for.
266
281
 
@@ -574,6 +589,155 @@ function Clock() {
574
589
 
575
590
  It needs a Suspense boundary, and the page stays frozen.`,
576
591
  },
592
+ {
593
+ topic: 'metadata',
594
+ summary: 'Titles, share cards, and the one setting production needs',
595
+ body: `\`\`\`tsx
596
+ export const metadata: Metadata = {
597
+ title: 'Orders',
598
+ openGraph: { title: 'Orders', description: '…', images: '/cover.png' },
599
+ }
600
+ \`\`\`
601
+
602
+ A layout takes a title TEMPLATE - { template: '%s · Site', default: 'Site' } -
603
+ and layouts merge outward-in, so site-wide values go on the root layout once.
604
+
605
+ **Set metadataBase on the root layout. It is not optional in production.**
606
+
607
+ \`\`\`tsx
608
+ metadataBase: new URL('https://example.com')
609
+ \`\`\`
610
+
611
+ A share-card scraper needs an ABSOLUTE image url and Facebook, Slack and
612
+ LinkedIn refuse a relative one silently - the link unfurls with no image and
613
+ nothing says why. metadataBase makes every relative url, image and icon
614
+ absolute. Same name as Next, so a port carries it across.
615
+
616
+ Use the structured objects, not the flat 'og:title' spellings: openGraph and
617
+ twitter are typed, an image can be { url, width, height, alt }, and it is the
618
+ shape a Next app already has. og: renders as property=, twitter: as name= -
619
+ what each scraper reads.
620
+
621
+ An opengraph-image.png in app/ is found by name and needs no listing; it still
622
+ needs metadataBase to go out absolute.`,
623
+ },
624
+ {
625
+ topic: 'fonts',
626
+ summary: 'Self-hosted fonts from npm, and porting next/font',
627
+ body: `There is no font loader. Install the font from Fontsource, import its
628
+ css, name it in a variable:
629
+
630
+ \`\`\`css
631
+ @import '@fontsource-variable/fraunces/full.css';
632
+ @import '@fontsource-variable/geist';
633
+
634
+ :root {
635
+ --font-display: 'Fraunces Variable', ui-serif, Georgia, serif;
636
+ --font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
637
+ 'Helvetica Neue', Arial, sans-serif,
638
+ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
639
+ }
640
+ \`\`\`
641
+
642
+ Put the font in FRONT of a full stack, not in place of one. A bare
643
+ 'Geist Variable', sans-serif drops the emoji fonts - Geist has no emoji glyphs,
644
+ and with nothing named after it some systems draw a box - and drops the
645
+ metrics-matched fallback that makes the swap moment smaller. Those are
646
+ Tailwind's own defaults; shadcn's generated line loses both.
647
+
648
+ Vite hashes the woff2 files and serves them with the other assets. Nothing is
649
+ fetched from Google at runtime and nothing is downloaded at build - the files
650
+ are in node_modules.
651
+
652
+ Porting next/font: every option was something Fontsource already did.
653
+ subsets -> every subset ships behind a unicode-range and the browser fetches
654
+ only what the page uses. style: ['italic'] -> full-italic.css. axes -> full.css
655
+ has every axis; standard.css is weight only. display: 'swap' -> already in
656
+ every rule. className={font.variable} -> nothing, the variable is on :root.
657
+
658
+ The one line next/font added that you add yourself is the preload:
659
+
660
+ \`\`\`tsx
661
+ import fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2?url'
662
+ <link rel="preload" href={fraunces} as="font" type="font/woff2" crossOrigin="anonymous" />
663
+ \`\`\`
664
+
665
+ ?url is Vite's and gives the hashed path. Preload the one file the first paint
666
+ needs; preloading all of them defeats the subsetting.
667
+
668
+ Do NOT reach for next/font, @next/font or a Google Fonts link tag.`,
669
+ },
670
+ {
671
+ topic: 'scripts',
672
+ summary: 'Third-party scripts - analytics, tag managers - without a Script component',
673
+ body: `Write the script tag. React 19 does what Next's Script component existed for.
674
+
675
+ An external script with async, rendered from a server component, is HOISTED
676
+ into head and DEDUPLICATED by React - the same src in three components is one
677
+ tag. That is afterInteractive:
678
+
679
+ \`\`\`tsx
680
+ <script async src="https://www.clarity.ms/tag/abc123" />
681
+ \`\`\`
682
+
683
+ An inline snippet renders where it is written and runs during parse, before
684
+ hydration - the earlier moment, which is what an analytics snippet wants:
685
+
686
+ \`\`\`tsx
687
+ <script id="ms-clarity" dangerouslySetInnerHTML={{ __html: '...' }} />
688
+ \`\`\`
689
+
690
+ Put site-wide scripts in the ROOT LAYOUT, which renders once and is kept
691
+ across navigations.
692
+
693
+ There is no Script component to import. The only case needing one - a script
694
+ that touches DOM React rendered, or an onLoad callback - is a client component
695
+ with useEffect that creates the tag. Ten lines of the user's own.`,
696
+ },
697
+ {
698
+ topic: 'testing',
699
+ summary: 'Unit-testing actions, queries and routes; the whole app without a port',
700
+ body: `Almost everything is a function. Any test runner works.
701
+
702
+ **Actions, queries, api routes: import and call.** "use server" is a string in
703
+ a test file, so the function is importable. An action built on the action
704
+ client runs its whole middleware chain when called and RETURNS its failures:
705
+
706
+ \`\`\`ts
707
+ const result = await createPost({ title: '' })
708
+ expect(result.validationErrors).toEqual({ title: ['too short'] })
709
+ \`\`\`
710
+
711
+ An api route takes a Request and the context the engine gives it - params is a
712
+ PROMISE:
713
+
714
+ \`\`\`ts
715
+ const res = await GET(new Request('https://app.test/api/x'), { params: Promise.resolve({ id: '1' }) })
716
+ \`\`\`
717
+
718
+ **Anything reading cookies() or headers():** open the request scope yourself.
719
+
720
+ \`\`\`ts
721
+ import { withRequest } from '@rsc-kit/core/request'
722
+ await withRequest(new Request('https://app.test/', { headers: { Cookie: 'session=abc' } }), currentUser)
723
+ \`\`\`
724
+
725
+ **The whole app as Request -> Response, no port:**
726
+
727
+ \`\`\`ts
728
+ import { createTestApp } from '@rsc-kit/core/testing'
729
+ const app = await createTestApp()
730
+ const res = await app.fetch('/admin', { redirect: 'manual' }) // real router, real middleware
731
+ \`\`\`
732
+
733
+ It builds when the source is newer than the last build - the first run pays,
734
+ the rest do not. This is where a guard that never ran or a 404 that came back
735
+ 200 shows up.
736
+
737
+ **What still needs a browser:** a server action called OVER THE WIRE (the id is
738
+ React's and private), hydration, navigation. Playwright against vite preview.
739
+ That limit is narrower than Next's: the action's logic is a unit test here.`,
740
+ },
577
741
  ];
578
742
  /** Every topic, with one line each — what a caller reads before choosing. */
579
743
  export function listTopics() {
@@ -1 +1 @@
1
- {"version":3,"file":"recipes.js","sourceRoot":"","sources":["../src/recipes.ts"],"names":[],"mappings":"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","sourcesContent":["// How to build the things this framework has, in the shape that works.\n//\n// The other half of this server, and the more useful one. Introspection answers\n// \"what did my build do\"; this answers \"how do I do X here\", which is the\n// question an agent actually has — and the one it otherwise answers from Next\n// and React habits that produce code which looks right and is not.\n//\n// Long-form on purpose. AGENTS.md has to be short enough to sit in context for\n// every turn, so it can only say the rule. These are fetched when the topic\n// comes up, so they can afford the working example and the caveat under it.\n//\n// Every snippet here is the recommended spelling from the guides, not a\n// paraphrase. When a guide changes, this changes with it — a recipe that has\n// drifted is worse than no recipe, because it is followed with confidence.\n\nexport interface Recipe {\n topic: string\n summary: string\n body: string\n}\n\nconst RECIPES: Recipe[] = [\n {\n topic: 'forms',\n summary: 'Submitting to a server action, with pending state and field errors',\n body: `Use <Form>. It takes the server action itself, not a url.\n\n\\`\\`\\`tsx\n'use client'\nimport Form from '@rsc-kit/core/Form'\nimport { createPost } from '../actions'\n\nexport function NewPost() {\n return (\n <Form action={createPost} schema={schema}>\n {({ pending, errors }) => (\n <>\n <input name=\"title\" />\n {errors.title?.[0] && <p>{errors.title[0]}</p>}\n <button disabled={pending}>Save</button>\n </>\n )}\n </Form>\n )\n}\n\\`\\`\\`\n\nPassing \\`schema\\` validates in the browser BEFORE the action is called, so a\nmistake costs no round trip. It is a courtesy, never a control: the action is a\npublic endpoint reachable without your form, so the server must check too.\n\nA schema on the server (\\`client.input(schema)\\`) does NOT give you client-side\nvalidation. Pass it to the form as well — the same schema is fine.\n\nValues are uncontrolled, so an initial one is React's own \\`defaultValue\\`. A\nrefused submit keeps what was typed, because the DOM kept it.\n\nA repeated name is an array. With one selected it is a string, which no\nz.array() accepts - so for anything that is a list by nature end the name in\n\\`[]\\` and it is always an array, brackets dropped from the key:\n\n\\`\\`\\`tsx\n<input type=\"checkbox\" name=\"tags[]\" value=\"react\" /> // -> { tags: ['react'] }\n\\`\\`\\`\n\nNames that describe a shape build it: \\`address.city\\` nests, and\n\\`items[0].name\\` (or \\`items[0][name]\\`) makes an array of objects. That is\nthe shape the schema was written against, and errors come back keyed the same\nway because Standard Schema issue paths join with dots too.\n\nFor a control with no native element behind it - a rich editor, a Radix select -\nor a value read as it is typed, bind it with \\`field()\\`. It is the same four\nprops react-hook-form's Controller gives:\n\n\\`\\`\\`tsx\n<Form action={save} defaultValues={{ body: '' }}>\n {({ field }) => (\n <>\n <Editor {...field('body')} />\n <span>{field('body').value.length}/100</span>\n </>\n )}\n</Form>\n\\`\\`\\`\n\nonChange takes a DOM event OR a bare value, so native inputs and Radix\ncomponents both work. A bound field is still an ordinary named input, so it\narrives in FormData with the rest - nothing merges.\n\n\\`fieldState(name)\\` is the other half: { touched, invalid, errors }. Two\nobjects rather than one because touched and invalid are not DOM attributes and\nspreading them would warn on every field.\n\n\\`\\`\\`tsx\nconst title = fieldState('title')\n<Field data-invalid={title.invalid}>\n <Input {...field('title')} aria-invalid={title.invalid} />\n <FieldError errors={title.errors.map((message) => ({ message }))} />\n</Field>\n\\`\\`\\`\n\nA field is checked when it is LEFT, not as it is typed, and it works on\nuncontrolled fields too - the form listens for focusout rather than each field\nlistening for blur.\n\nThere is no per-field render prop component here, and that is deliberate.\nTanStack Form is controlled-first, so it needs one - without per-field\nsubscriptions a keystroke re-renders every field. react-hook-form is\nuncontrolled-first like this, and its Controller scopes the re-render of a\ncontrolled field to itself.\n\nfield() is a function call instead, which keeps the markup flat and means a\nbound field re-renders the form rather than only itself. Right for the one or\ntwo controlled fields a form usually has.\n\nWhen it is not, put the field in its own component and use \\`useField\\` there -\nit re-renders that component and nothing else, which is what Controller achieves\nwith a render prop:\n\n\\`\\`\\`tsx\nfunction Title() {\n const { invalid, errors, ...bound } = useField('title')\n\n return <Input {...bound} aria-invalid={invalid} />\n}\n\\`\\`\\`\n\n\\`useFormValues()\\` reads every bound value from anywhere inside the form - a\npreview, a summary. Only BOUND values: an uncontrolled input's value is the\nDOM's and nothing can know it changed.\n\nBoth read a context, so they work below <Form>. For something that is NOT a\ndescendant - a top bar, a sidebar preview - create the store above both and\nhand it in:\n\n\\`\\`\\`tsx\nconst store = useFormStore({ title: '' })\n\n<TopBar store={store} /> // outside the form\n<Form action={save} store={store}>…</Form>\n\\`\\`\\`\n\nuseFormStore is the values and nothing else - no submit, no errors. Creating it\ndoes not subscribe to it, so the holder does not re-render per keystroke and\ntake the subtree with it. useField(name, store) and useFormValues(store) take\none explicitly; without one they read the context.\n\nA submit from outside the form is html, not a second api:\n\n\\`\\`\\`tsx\n<Form id=\"bug-report\" action={reportBug}>…</Form>\n<Button type=\"submit\" form=\"bug-report\">Submit</Button>\n\\`\\`\\`\n\nThere is no useForm hook. <Form> is the whole surface.\n\nFields are real \\`name\\` attributes rather than controlled state, so the form\nreads a native FormData and any component rendering a real control works.\n\nIt works before hydration. The action is on the form element as well as in the\nsubmit handler, so the markup is submittable on its own - the handler calls\npreventDefault() first and React does not run a form action for a cancelled\nsubmit, so exactly one path runs.\n\n**shadcn/ui works as-is.** Input, Textarea, Button and Label are styled native\nelements, so \\`name\\` does what it always does. Select, Checkbox, Switch and\nRadioGroup are Radix underneath and render a hidden native control whenever\ngiven a \\`name\\` - omit it and they are invisible to the form, which is the\nonly thing to remember.\n\nDo NOT use shadcn's own Form/FormField/FormControl with this. Those wrap\nreact-hook-form, a different system for the same job. One or the other.`,\n },\n {\n topic: 'prefetch',\n summary: 'Making a navigation feel instant',\n body: `\\`<Link>\\` prefetches on hover by default. Usually there is nothing to do.\n\n\\`\\`\\`tsx\nimport Link from '@rsc-kit/core/Link'\n\n<Link href=\"/orders\">Orders</Link>\n<Link href=\"/orders\" prefetch={false}>Orders</Link> // opt out\n<Link href=\"/orders\" cacheFor={30_000}>Orders</Link> // hold the payload longer\n\\`\\`\\`\n\n\\`href\\` is typed to the routes the build found, so a link to a page that no\nlonger exists stops compiling. Cast with \\`as Href\\` only when the destination\nis genuinely computed.\n\nTo prefetch from code — a row about to be clicked, a wizard's next step:\n\n\\`\\`\\`ts\nimport { prefetch } from '@rsc-kit/core/navigate'\n\nprefetch('/orders/42')\n\\`\\`\\`\n\nWhat is prefetched is the RSC payload, not the html, so it is small and it warms\nthe same cache the navigation will read.`,\n },\n {\n topic: 'validation',\n summary: 'Checking input — forms, actions, urls and request bodies',\n body: `One contract everywhere: any Standard Schema (Zod, Valibot, ArkType).\n\n**Actions** validate on arrival and RETURN their failures, because React strips\na thrown message in production:\n\n\\`\\`\\`ts\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\n\\`\\`\\`\n\n**Urls** validate by exporting a schema beside the page or route:\n\n\\`\\`\\`ts\nexport const params = z.object({ slug: z.string().min(1) })\nexport const searchParams = z.object({ page: z.coerce.number().int().min(1).default(1) })\n\\`\\`\\`\n\nValues arrive parsed and typed — \\`?page=3\\` is the number 3, a missing one is\nthe default. Never hand-parse \\`Number(searchParams.get('page'))\\`.\n\n**Api route bodies** the same way:\n\n\\`\\`\\`ts\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function POST(request: Request, { body }) {\n const { title } = await body\n}\n\\`\\`\\`\n\nThe failures answer differently on purpose:\n\n bad params 404 — the url does not describe a page\n bad searchParams the error boundary (400 for an api route)\n bad body 422, the status an action already uses\n\nA bad query is deliberately NOT a 404, or one bad link makes a real page look\ndeleted.`,\n },\n {\n topic: 'action-client',\n summary: 'Middleware for server actions, so a check cannot be forgotten',\n body: `\\`\\`\\`ts title=\"src/server/client.ts\"\n'use server'\nimport { createActionClient } from '@rsc-kit/core/action'\n\nexport const client = createActionClient({ onError: report })\n .use(async ({ next }) => {\n const user = await currentUser()\n\n if (!user) throw new ServerAuthenticationError()\n\n return next({ ctx: { user } })\n })\n\\`\\`\\`\n\n\\`\\`\\`ts title=\"src/server/posts.ts\"\n'use server'\nimport { client } from './client'\n\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\nexport const getPosts = client.query(async ({ ctx }) => …)\n\\`\\`\\`\n\n\\`.handler()\\` is a mutation (POST). \\`.query()\\` is a read (GET). Both run the\nchain, so \\`ctx.user\\` is typed and non-null inside them.\n\nThe point is not convenience. An action cannot be added without the check,\nbecause there is no other constructor to reach for.\n\nStack clients for a narrower rule:\n\n\\`\\`\\`ts\nexport const admin = client.use(async ({ ctx, next }) => {\n if (!ctx.user.isAdmin) throw new ServerAuthorizationError()\n return next({ ctx })\n})\n\\`\\`\\`\n\nRoute \\`middleware.ts\\` does NOT run for actions — an action renders no route.\nThat is why the check goes here.`,\n },\n {\n topic: 'data',\n summary: 'Loading data, streaming it, and when the browser needs to refetch',\n body: `**In a server component, just await it.** No loader, no getServerSideProps.\n\n\\`\\`\\`tsx\nexport default async function Page() {\n const posts = await db.posts.all()\n}\n\\`\\`\\`\n\n**Better: do not await.** Pass the promise down and let a client component\nresolve it — the shell paints at once and the rows stream into the same\nresponse, with no request from the browser:\n\n\\`\\`\\`tsx\nexport default function Page() {\n const posts = getPosts() // not awaited\n\n return (\n <Suspense fallback={<Skeleton />}>\n <List posts={posts} /> {/* 'use client': use(posts) */}\n </Suspense>\n )\n}\n\\`\\`\\`\n\nReach for this first. It is the thing RSC is for.\n\n**When the BROWSER decides to refetch** — a filter, a poll, a refresh — that is\na cache library's job and this package does not ship one:\n\n\\`\\`\\`tsx\nuseQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })\nuseSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))\n\\`\\`\\`\n\n\\`fetchQuery\\` sends the read as a GET and goes to the server every time, which\nis what a fetcher needs — staleness and revalidation belong to the library\nholding the answer. Do not add a cache on top of it.\n\nKeep the arrow: TanStack calls a bare \\`queryFn\\` with its own context, and a\nserver function serialises whatever it is handed.`,\n },\n {\n topic: 'suspense',\n summary: 'Where boundaries go, and why the build cares',\n body: `A boundary is what lets a page be stored with a hole in it rather than not\nstored at all.\n\n\\`\\`\\`tsx\n<Suspense fallback={<Skeleton />}>\n <Slow />\n</Suspense>\n\\`\\`\\`\n\nOr a \\`loading.tsx\\` beside the page, which is the same thing for the whole\nroute.\n\nThe build renders every page. Whatever has not resolved when the budget expires\nbecomes the hole; everything above it is stored and served instantly. So a page\nwith no boundary above its slow part cannot be stored at all — the build says\nso:\n\n ƒ /orders\n blocks before anything can paint. Add a loading.tsx beside it, or put a\n <Suspense> above the waiting, and it has a skeleton to store.\n\nA boundary does NOT fix a frozen \\`Date.now()\\`. Prerendering renders straight\nthrough a component that never awaits, so the value is captured exactly as\nbefore. A boundary becomes a hole only when something inside it waits.`,\n },\n {\n topic: 'offline',\n summary: 'Service worker, and what it does and does not cache',\n body: `\\`\\`\\`ts title=\"vite.config.ts\"\nrscKit({ offline: true })\n\\`\\`\\`\n\nThe build writes a service worker that precaches the client bundle and caches\npages at runtime — a document fetch warms its payload, a payload fetch warms its\ndocument, so a page reached by a link still works when reloaded offline.\n\nPages the build stored whole are served from the cache FIRST, because they\ncannot change until a deploy and a deploy sweeps the cache. Everything else is\nnetwork-first with the cache as fallback.\n\nNothing marked \\`no-store\\` is ever kept — which is how a guarded page and a\nsession-reading query stay out of a cache that has no notion of who asked.\n\nIn a component:\n\n\\`\\`\\`tsx\nimport { useOnline } from '@rsc-kit/core/useOnline'\n\nconst online = useOnline()\n\\`\\`\\`\n\nThere is no push and no background sync. Push needs a subscription endpoint and\na sender; background sync needs idempotent replay. Both are the app's decisions.`,\n },\n {\n topic: 'pwa',\n summary: 'Making the app installable',\n body: `A manifest file beside the routes:\n\n\\`\\`\\`ts title=\"src/app/manifest.ts\"\nimport type { WebManifest } from '@rsc-kit/core/manifest-file'\n\nexport default {\n name: 'Orders',\n shortName: 'Orders',\n themeColor: '#0b0b0c',\n backgroundColor: '#ffffff',\n} satisfies WebManifest\n\\`\\`\\`\n\nRead at build time, so it must be an object literal — not computed, not\nimported from elsewhere.\n\n**Icons need no listing.** Put them in \\`src/app/\\` and the build finds them:\n\n favicon.ico served at /favicon.ico\n icon-192.png <link rel=\"icon\">, and the manifest's icons\n icon-512.png\n apple-icon.png <link rel=\"apple-touch-icon\">\n opengraph-image.png <meta property=\"og:image\">\n twitter-image.png <meta name=\"twitter:image\">\n\nSizes are read from the filename. The build says whether it worked:\n\n [rsc-kit] manifest: Orders is installable\n [rsc-kit] manifest: no icons, so no browser will offer to install this.\n\nThere is no layout to edit — React hoists the tags into <head>.\n\nPair it with \\`offline: true\\`. They are separate options because they are\nseparate decisions.\n\n**Push and background sync** need listeners the generated worker does not have,\nso it imports yours from \\`src/app/sw.js\\` — plain javascript, evaluated by the\nbrowser with no build step in front of it:\n\n\\`\\`\\`js\nself.addEventListener('push', (event) => {\n const payload = event.data ? event.data.json() : {}\n\n event.waitUntil(self.registration.showNotification(payload.title, { body: payload.body }))\n})\n\\`\\`\\`\n\nThe rest is the web api and \\`web-push\\`, not this package: VAPID keys, a\nsubscribe call behind a button, the subscription stored by a server action\nagainst a USER rather than a session, and a sender that deletes an endpoint on\n404 or 410 rather than retrying a dead one forever.\n\nFor background sync, make the endpoint idempotent. The browser decides when a\nsync runs and may run it more than once — a request that reached the server\nwhose response did not arrive is retried, and if that posts a message twice the\nperson sent it twice.`,\n },\n {\n topic: 'no-javascript',\n summary: 'Shipping a route with no client runtime at all',\n body: `\\`\\`\\`ts title=\"src/app/about/page.tsx\"\nexport const clientJs = false\n\\`\\`\\`\n\nThe route ships no bootstrap and no client runtime. The build REFUSES it if the\ntree renders a client component, and names the component — they usually come\nfrom a shared layout rather than the page itself.\n\nLinks still work; they are ordinary anchors, so navigation is a full page load.\n\nMost pages do not need this. A page with nothing interactive already ships only\nthe shared runtime, and the size column in the build output tells you what each\none actually costs.`,\n },\n {\n topic: 'api-routes',\n summary: 'HTTP endpoints beside the pages',\n body: `\\`src/app/**/route.ts\\`, one export per method:\n\n\\`\\`\\`ts title=\"src/app/api/posts/[id]/route.ts\"\nexport const params = z.object({ id: z.coerce.number().int() })\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function GET(request: Request, { params }) {\n const { id } = await params\n\n return Response.json(await findPost(id))\n}\n\nexport async function POST(request: Request, { params, body }) {\n const { title } = await body\n\n return Response.json(await createPost(title), { status: 201 })\n}\n\\`\\`\\`\n\nA real \\`Request\\` in, a real \\`Response\\` out. \\`params\\`, \\`searchParams\\` and\n\\`body\\` are awaited, the same way a page's props are.\n\nFetch one through \\`apiUrl\\` and the path is checked against the routes the\nbuild found:\n\n import { apiUrl } from '@rsc-kit/core/routes'\n await fetch(apiUrl('/api/posts/' + id))\n\nPages and api routes are separate unions: Link refuses an api url, apiUrl\nrefuses a page. It checks the PATH, not the response type - for types across\nthe boundary use a server action or a query, where the return type is the\nfunction's because it is the same function.\n\nThey run their directory's \\`middleware.ts\\`, so an endpoint under a guarded\npath is guarded.\n\nA \\`GET\\` that reads nothing from the request is answered from disk. Awaiting\n\\`searchParams\\` says the answer depends on the query; never touching it means\nthe stored answer is served for any query at all.\n\nExporting a \\`body\\` schema consumes the stream, so \\`request.json()\\` inside the\nhandler will find it already read. Use the parsed value.`,\n },\n {\n topic: 'authorization',\n summary: 'Guarding pages, actions, api routes and queries',\n body: `Each entry point defends itself. There is no single place that covers all of\nthem, and believing otherwise is how a hole is left.\n\n**A page or an api route**: \\`middleware.ts\\` in its directory guards everything\nat or below it.\n\n\\`\\`\\`ts title=\"src/app/admin/middleware.ts\"\nimport { redirect } from '@rsc-kit/core/redirect'\n\nexport default async function guard() {\n if (!(await currentUser())) redirect('/login')\n}\n\\`\\`\\`\n\n**An action or a query**: middleware does NOT run — they render no route. Build\nthem from an action client so the check cannot be forgotten. See the\n\\`action-client\\` topic.\n\n**Authorise on identity, not arguments.** \\`deletePost(id)\\` that trusts the id\nis the whole of an IDOR: the caller chooses the id, so check the row belongs to\n\\`ctx.user\\`.\n\nA guarded page can still be frozen at build time — the guard is a serving\ndecision, not a build one. Its response is marked private so no cache keeps it.`,\n },\n {\n topic: 'dynamic',\n summary: 'Why a page is not static, and how to choose',\n body: `A page is stored at build time unless it reads the request. Reading it is what\nopts out, and the accessors are async:\n\n\\`\\`\\`ts\nimport { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'\n\nconst theme = (await cookies()).get('theme')\nawait connection() // \"render this per visitor\", said deliberately\n\\`\\`\\`\n\nA page's \\`params\\` and \\`searchParams\\` props are promises for the same reason.\n\nThe build says which call did it, per route:\n\n ◐ /locale 85 kB\n dynamic — called cookies(), headers()\n\nThat is usually correct — a page whose content depends on who is asking cannot\nbe one stored file. Change it only when the read was accidental.\n\nFor a parameterised route, \\`generateStaticParams\\` turns one shell into a page\nper url:\n\n\\`\\`\\`ts\nexport async function generateStaticParams() {\n return (await db.posts.all()).map((p) => ({ slug: p.slug }))\n}\n\\`\\`\\`\n\nA value that must differ per visitor but needs no server — a clock,\nlocalStorage, a map — belongs in the browser only:\n\n\\`\\`\\`tsx\n'use client'\nimport { browser } from 'react-dom'\n\nfunction Clock() {\n use(browser('the time is the visitor\\\\'s, not the build machine\\\\'s'))\n}\n\\`\\`\\`\n\nIt needs a Suspense boundary, and the page stays frozen.`,\n },\n]\n\n/** Every topic, with one line each — what a caller reads before choosing. */\nexport function listTopics(): string {\n return [\n 'Topics. Ask for one with how_to({ topic }).',\n '',\n ...RECIPES.map((r) => `${r.topic.padEnd(16)} ${r.summary}`),\n ].join('\\n')\n}\n\n/** One recipe, or the list plus a nudge when the topic is not one. */\nexport function howTo(topic: string): string {\n const wanted = topic.trim().toLowerCase().replace(/[\\s_]+/g, '-')\n const found =\n RECIPES.find((r) => r.topic === wanted) ??\n // A near miss is common and worth answering rather than refusing: someone\n // asks for \"form\" or \"queries\" and means the obvious thing.\n RECIPES.find((r) => r.topic.startsWith(wanted) || wanted.startsWith(r.topic)) ??\n RECIPES.find((r) => r.summary.toLowerCase().includes(wanted))\n\n if (!found) return `No topic \"${topic}\".\\n\\n${listTopics()}`\n\n return `# ${found.topic} — ${found.summary}\\n\\n${found.body}`\n}\n\n/** For tests, so a recipe cannot be added without being reachable. */\nexport const TOPICS = RECIPES.map((r) => r.topic)\n"]}
1
+ {"version":3,"file":"recipes.js","sourceRoot":"","sources":["../src/recipes.ts"],"names":[],"mappings":"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","sourcesContent":["// How to build the things this framework has, in the shape that works.\n//\n// The other half of this server, and the more useful one. Introspection answers\n// \"what did my build do\"; this answers \"how do I do X here\", which is the\n// question an agent actually has — and the one it otherwise answers from Next\n// and React habits that produce code which looks right and is not.\n//\n// Long-form on purpose. AGENTS.md has to be short enough to sit in context for\n// every turn, so it can only say the rule. These are fetched when the topic\n// comes up, so they can afford the working example and the caveat under it.\n//\n// Every snippet here is the recommended spelling from the guides, not a\n// paraphrase. When a guide changes, this changes with it — a recipe that has\n// drifted is worse than no recipe, because it is followed with confidence.\n\nexport interface Recipe {\n topic: string\n summary: string\n body: string\n}\n\nconst RECIPES: Recipe[] = [\n {\n topic: 'forms',\n summary: 'Submitting to a server action, with pending state and field errors',\n body: `Use <Form>. It takes the server action itself, not a url.\n\n\\`\\`\\`tsx\n'use client'\nimport Form from '@rsc-kit/core/Form'\nimport { createPost } from '../actions'\n\nexport function NewPost() {\n return (\n <Form action={createPost} schema={schema}>\n {({ pending, errors }) => (\n <>\n <input name=\"title\" />\n {errors.title?.[0] && <p>{errors.title[0]}</p>}\n <button disabled={pending}>Save</button>\n </>\n )}\n </Form>\n )\n}\n\\`\\`\\`\n\nPassing \\`schema\\` validates in the browser BEFORE the action is called, so a\nmistake costs no round trip. It is a courtesy, never a control: the action is a\npublic endpoint reachable without your form, so the server must check too.\n\nA schema on the server (\\`client.input(schema)\\`) does NOT give you client-side\nvalidation. Pass it to the form as well — the same schema is fine.\n\nValues are uncontrolled, so an initial one is React's own \\`defaultValue\\`. A\nrefused submit keeps what was typed, because the DOM kept it.\n\nA repeated name is an array. With one selected it is a string, which no\nz.array() accepts - so for anything that is a list by nature end the name in\n\\`[]\\` and it is always an array, brackets dropped from the key:\n\n\\`\\`\\`tsx\n<input type=\"checkbox\" name=\"tags[]\" value=\"react\" /> // -> { tags: ['react'] }\n\\`\\`\\`\n\nNames that describe a shape build it: \\`address.city\\` nests, and\n\\`items[0].name\\` (or \\`items[0][name]\\`) makes an array of objects. That is\nthe shape the schema was written against, and errors come back keyed the same\nway because Standard Schema issue paths join with dots too.\n\nFor a control with no native element behind it - a rich editor, a Radix select -\nor a value read as it is typed, bind it with \\`field()\\`. It is the same four\nprops react-hook-form's Controller gives:\n\n\\`\\`\\`tsx\n<Form action={save} defaultValues={{ body: '' }}>\n {({ field }) => (\n <>\n <Editor {...field('body')} />\n <span>{field('body').value.length}/100</span>\n </>\n )}\n</Form>\n\\`\\`\\`\n\nonChange takes a DOM event OR a bare value, so native inputs and Radix\ncomponents both work. A bound field is still an ordinary named input, so it\narrives in FormData with the rest - nothing merges.\n\n\\`fieldState(name)\\` is the other half: { touched, invalid, errors }. Two\nobjects rather than one because touched and invalid are not DOM attributes and\nspreading them would warn on every field.\n\n\\`\\`\\`tsx\nconst title = fieldState('title')\n<Field data-invalid={title.invalid}>\n <Input {...field('title')} aria-invalid={title.invalid} />\n <FieldError errors={title.errors.map((message) => ({ message }))} />\n</Field>\n\\`\\`\\`\n\nA field is checked when it is LEFT, not as it is typed, and it works on\nuncontrolled fields too - the form listens for focusout rather than each field\nlistening for blur.\n\nThere is no per-field render prop component here, and that is deliberate.\nTanStack Form is controlled-first, so it needs one - without per-field\nsubscriptions a keystroke re-renders every field. react-hook-form is\nuncontrolled-first like this, and its Controller scopes the re-render of a\ncontrolled field to itself.\n\nfield() is a function call instead, which keeps the markup flat and means a\nbound field re-renders the form rather than only itself. Right for the one or\ntwo controlled fields a form usually has.\n\nWhen it is not, put the field in its own component and use \\`useField\\` there -\nit re-renders that component and nothing else, which is what Controller achieves\nwith a render prop:\n\n\\`\\`\\`tsx\nfunction Title() {\n const { invalid, errors, ...bound } = useField('title')\n\n return <Input {...bound} aria-invalid={invalid} />\n}\n\\`\\`\\`\n\n\\`useFormValues()\\` reads every bound value from anywhere inside the form - a\npreview, a summary. Only BOUND values: an uncontrolled input's value is the\nDOM's and nothing can know it changed.\n\nBoth read a context, so they work below <Form>. For something that is NOT a\ndescendant - a top bar, a sidebar preview - create the store above both and\nhand it in:\n\n\\`\\`\\`tsx\nconst store = useFormStore({ title: '' })\n\n<TopBar store={store} /> // outside the form\n<Form action={save} store={store}>…</Form>\n\\`\\`\\`\n\nuseFormStore is the values and nothing else - no submit, no errors. Creating it\ndoes not subscribe to it, so the holder does not re-render per keystroke and\ntake the subtree with it. useField(name, store) and useFormValues(store) take\none explicitly; without one they read the context.\n\nA submit from outside the form is html, not a second api:\n\n\\`\\`\\`tsx\n<Form id=\"bug-report\" action={reportBug}>…</Form>\n<Button type=\"submit\" form=\"bug-report\">Submit</Button>\n\\`\\`\\`\n\nThere is no useForm hook. <Form> is the whole surface.\n\nFields are real \\`name\\` attributes rather than controlled state, so the form\nreads a native FormData and any component rendering a real control works.\n\nIt works before hydration. The action is on the form element as well as in the\nsubmit handler, so the markup is submittable on its own - the handler calls\npreventDefault() first and React does not run a form action for a cancelled\nsubmit, so exactly one path runs.\n\n**shadcn/ui works as-is.** Input, Textarea, Button and Label are styled native\nelements, so \\`name\\` does what it always does. Select, Checkbox, Switch and\nRadioGroup are Radix underneath and render a hidden native control whenever\ngiven a \\`name\\` - omit it and they are invisible to the form, which is the\nonly thing to remember.\n\nDo NOT use shadcn's own Form/FormField/FormControl with this. Those wrap\nreact-hook-form, a different system for the same job. One or the other.`,\n },\n {\n topic: 'prefetch',\n summary: 'Making a navigation feel instant',\n body: `\\`<Link>\\` prefetches on hover by default. Usually there is nothing to do.\n\n\\`\\`\\`tsx\nimport Link from '@rsc-kit/core/Link'\n\n<Link href=\"/orders\">Orders</Link>\n<Link href=\"/orders\" prefetch={false}>Orders</Link> // opt out\n<Link href=\"/orders\" cacheFor={30_000}>Orders</Link> // hold the payload longer\n\\`\\`\\`\n\n\\`href\\` is typed to the routes the build found, so a link to a page that no\nlonger exists stops compiling. Cast with \\`as Href\\` only when the destination\nis genuinely computed.\n\nTo prefetch from code — a row about to be clicked, a wizard's next step:\n\n\\`\\`\\`ts\nimport { prefetch } from '@rsc-kit/core/navigate'\n\nprefetch('/orders/42')\n\\`\\`\\`\n\nWhat is prefetched is the RSC payload, not the html, so it is small and it warms\nthe same cache the navigation will read.`,\n },\n {\n topic: 'validation',\n summary: 'Checking input — forms, actions, urls and request bodies',\n body: `One contract everywhere: any Standard Schema (Zod, Valibot, ArkType).\n\n**Actions** validate on arrival and RETURN their failures, because React strips\na thrown message in production:\n\n\\`\\`\\`ts\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\n\\`\\`\\`\n\n**Urls** validate by exporting a schema beside the page or route:\n\n\\`\\`\\`ts\nexport const params = z.object({ slug: z.string().min(1) })\nexport const searchParams = z.object({ page: z.coerce.number().int().min(1).default(1) })\n\\`\\`\\`\n\nValues arrive parsed and typed — \\`?page=3\\` is the number 3, a missing one is\nthe default. Never hand-parse \\`Number(searchParams.get('page'))\\`.\n\n**Api route bodies** the same way:\n\n\\`\\`\\`ts\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function POST(request: Request, { body }) {\n const { title } = await body\n}\n\\`\\`\\`\n\nThe failures answer differently on purpose:\n\n bad params 404 — the url does not describe a page\n bad searchParams the error boundary (400 for an api route)\n bad body 422, the status an action already uses\n\nA bad query is deliberately NOT a 404, or one bad link makes a real page look\ndeleted.`,\n },\n {\n topic: 'action-client',\n summary: 'Middleware for server actions, so a check cannot be forgotten',\n body: `\\`\\`\\`ts title=\"src/server/client.ts\"\n'use server'\nimport { createActionClient } from '@rsc-kit/core/action'\n\nexport const client = createActionClient({ onError: report })\n .use(async ({ next }) => {\n const user = await currentUser()\n\n if (!user) throw new ServerAuthenticationError()\n\n return next({ ctx: { user } })\n })\n\\`\\`\\`\n\n\\`\\`\\`ts title=\"src/server/posts.ts\"\n'use server'\nimport { client } from './client'\n\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\nexport const getPosts = client.query(async ({ ctx }) => …)\n\\`\\`\\`\n\n\\`.handler()\\` is a mutation (POST). \\`.query()\\` is a read (GET). Both run the\nchain, so \\`ctx.user\\` is typed and non-null inside them.\n\nFor a failure the schema cannot know - an account not found, a slug taken -\nthe handler is given \\`fieldErrors\\`, typed to its own input so a field the\nschema does not have is a compile error:\n\n\\`\\`\\`ts\n.handler(async ({ input, fieldErrors }) => {\n if (!account) fieldErrors({ email: 'Account not found' })\n})\n\\`\\`\\`\n\nIt throws, so nothing after it runs. It lands in validationErrors on that\nfield, the same place a schema refusal does. This is next-safe-action's\nreturnValidationErrors with no schema argument, no _errors nesting and no\nreturn to forget.\n\nThe point is not convenience. An action cannot be added without the check,\nbecause there is no other constructor to reach for.\n\nStack clients for a narrower rule:\n\n\\`\\`\\`ts\nexport const admin = client.use(async ({ ctx, next }) => {\n if (!ctx.user.isAdmin) throw new ServerAuthorizationError()\n return next({ ctx })\n})\n\\`\\`\\`\n\nRoute \\`middleware.ts\\` does NOT run for actions — an action renders no route.\nThat is why the check goes here.`,\n },\n {\n topic: 'data',\n summary: 'Loading data, streaming it, and when the browser needs to refetch',\n body: `**In a server component, just await it.** No loader, no getServerSideProps.\n\n\\`\\`\\`tsx\nexport default async function Page() {\n const posts = await db.posts.all()\n}\n\\`\\`\\`\n\n**Better: do not await.** Pass the promise down and let a client component\nresolve it — the shell paints at once and the rows stream into the same\nresponse, with no request from the browser:\n\n\\`\\`\\`tsx\nexport default function Page() {\n const posts = getPosts() // not awaited\n\n return (\n <Suspense fallback={<Skeleton />}>\n <List posts={posts} /> {/* 'use client': use(posts) */}\n </Suspense>\n )\n}\n\\`\\`\\`\n\nReach for this first. It is the thing RSC is for.\n\n**When the BROWSER decides to refetch** — a filter, a poll, a refresh — that is\na cache library's job and this package does not ship one:\n\n\\`\\`\\`tsx\nuseQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })\nuseSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))\n\\`\\`\\`\n\n\\`fetchQuery\\` sends the read as a GET and goes to the server every time, which\nis what a fetcher needs — staleness and revalidation belong to the library\nholding the answer. Do not add a cache on top of it.\n\nKeep the arrow: TanStack calls a bare \\`queryFn\\` with its own context, and a\nserver function serialises whatever it is handed.`,\n },\n {\n topic: 'suspense',\n summary: 'Where boundaries go, and why the build cares',\n body: `A boundary is what lets a page be stored with a hole in it rather than not\nstored at all.\n\n\\`\\`\\`tsx\n<Suspense fallback={<Skeleton />}>\n <Slow />\n</Suspense>\n\\`\\`\\`\n\nOr a \\`loading.tsx\\` beside the page, which is the same thing for the whole\nroute.\n\nThe build renders every page. Whatever has not resolved when the budget expires\nbecomes the hole; everything above it is stored and served instantly. So a page\nwith no boundary above its slow part cannot be stored at all — the build says\nso:\n\n ƒ /orders\n blocks before anything can paint. Add a loading.tsx beside it, or put a\n <Suspense> above the waiting, and it has a skeleton to store.\n\nA boundary does NOT fix a frozen \\`Date.now()\\`. Prerendering renders straight\nthrough a component that never awaits, so the value is captured exactly as\nbefore. A boundary becomes a hole only when something inside it waits.`,\n },\n {\n topic: 'offline',\n summary: 'Service worker, and what it does and does not cache',\n body: `\\`\\`\\`ts title=\"vite.config.ts\"\nrscKit({ offline: true })\n\\`\\`\\`\n\nThe build writes a service worker that precaches the client bundle and caches\npages at runtime — a document fetch warms its payload, a payload fetch warms its\ndocument, so a page reached by a link still works when reloaded offline.\n\nPages the build stored whole are served from the cache FIRST, because they\ncannot change until a deploy and a deploy sweeps the cache. Everything else is\nnetwork-first with the cache as fallback.\n\nNothing marked \\`no-store\\` is ever kept — which is how a guarded page and a\nsession-reading query stay out of a cache that has no notion of who asked.\n\nIn a component:\n\n\\`\\`\\`tsx\nimport { useOnline } from '@rsc-kit/core/useOnline'\n\nconst online = useOnline()\n\\`\\`\\`\n\nThere is no push and no background sync. Push needs a subscription endpoint and\na sender; background sync needs idempotent replay. Both are the app's decisions.`,\n },\n {\n topic: 'pwa',\n summary: 'Making the app installable',\n body: `A manifest file beside the routes:\n\n\\`\\`\\`ts title=\"src/app/manifest.ts\"\nimport type { WebManifest } from '@rsc-kit/core/manifest-file'\n\nexport default {\n name: 'Orders',\n shortName: 'Orders',\n themeColor: '#0b0b0c',\n backgroundColor: '#ffffff',\n} satisfies WebManifest\n\\`\\`\\`\n\nRead at build time, so it must be an object literal — not computed, not\nimported from elsewhere.\n\n**Icons need no listing.** Put them in \\`src/app/\\` and the build finds them:\n\n favicon.ico served at /favicon.ico\n icon-192.png <link rel=\"icon\">, and the manifest's icons\n icon-512.png\n apple-icon.png <link rel=\"apple-touch-icon\">\n opengraph-image.png <meta property=\"og:image\">\n twitter-image.png <meta name=\"twitter:image\">\n\nSizes are read from the filename. The build says whether it worked:\n\n [rsc-kit] manifest: Orders is installable\n [rsc-kit] manifest: no icons, so no browser will offer to install this.\n\nThere is no layout to edit — React hoists the tags into <head>.\n\nPair it with \\`offline: true\\`. They are separate options because they are\nseparate decisions.\n\n**Push and background sync** need listeners the generated worker does not have,\nso it imports yours from \\`src/app/sw.js\\` — plain javascript, evaluated by the\nbrowser with no build step in front of it:\n\n\\`\\`\\`js\nself.addEventListener('push', (event) => {\n const payload = event.data ? event.data.json() : {}\n\n event.waitUntil(self.registration.showNotification(payload.title, { body: payload.body }))\n})\n\\`\\`\\`\n\nThe rest is the web api and \\`web-push\\`, not this package: VAPID keys, a\nsubscribe call behind a button, the subscription stored by a server action\nagainst a USER rather than a session, and a sender that deletes an endpoint on\n404 or 410 rather than retrying a dead one forever.\n\nFor background sync, make the endpoint idempotent. The browser decides when a\nsync runs and may run it more than once — a request that reached the server\nwhose response did not arrive is retried, and if that posts a message twice the\nperson sent it twice.`,\n },\n {\n topic: 'no-javascript',\n summary: 'Shipping a route with no client runtime at all',\n body: `\\`\\`\\`ts title=\"src/app/about/page.tsx\"\nexport const clientJs = false\n\\`\\`\\`\n\nThe route ships no bootstrap and no client runtime. The build REFUSES it if the\ntree renders a client component, and names the component — they usually come\nfrom a shared layout rather than the page itself.\n\nLinks still work; they are ordinary anchors, so navigation is a full page load.\n\nMost pages do not need this. A page with nothing interactive already ships only\nthe shared runtime, and the size column in the build output tells you what each\none actually costs.`,\n },\n {\n topic: 'api-routes',\n summary: 'HTTP endpoints beside the pages',\n body: `\\`src/app/**/route.ts\\`, one export per method:\n\n\\`\\`\\`ts title=\"src/app/api/posts/[id]/route.ts\"\nexport const params = z.object({ id: z.coerce.number().int() })\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function GET(request: Request, { params }) {\n const { id } = await params\n\n return Response.json(await findPost(id))\n}\n\nexport async function POST(request: Request, { params, body }) {\n const { title } = await body\n\n return Response.json(await createPost(title), { status: 201 })\n}\n\\`\\`\\`\n\nA real \\`Request\\` in, a real \\`Response\\` out. \\`params\\`, \\`searchParams\\` and\n\\`body\\` are awaited, the same way a page's props are.\n\nFetch one through \\`apiUrl\\` and the path is checked against the routes the\nbuild found:\n\n import { apiUrl } from '@rsc-kit/core/routes'\n await fetch(apiUrl('/api/posts/' + id))\n\nPages and api routes are separate unions: Link refuses an api url, apiUrl\nrefuses a page. It checks the PATH, not the response type - for types across\nthe boundary use a server action or a query, where the return type is the\nfunction's because it is the same function.\n\nThey run their directory's \\`middleware.ts\\`, so an endpoint under a guarded\npath is guarded.\n\nA \\`GET\\` that reads nothing from the request is answered from disk. Awaiting\n\\`searchParams\\` says the answer depends on the query; never touching it means\nthe stored answer is served for any query at all.\n\nExporting a \\`body\\` schema consumes the stream, so \\`request.json()\\` inside the\nhandler will find it already read. Use the parsed value.`,\n },\n {\n topic: 'authorization',\n summary: 'Guarding pages, actions, api routes and queries',\n body: `Each entry point defends itself. There is no single place that covers all of\nthem, and believing otherwise is how a hole is left.\n\n**A page or an api route**: \\`middleware.ts\\` in its directory guards everything\nat or below it.\n\n\\`\\`\\`ts title=\"src/app/admin/middleware.ts\"\nimport { redirect } from '@rsc-kit/core/redirect'\n\nexport default async function guard() {\n if (!(await currentUser())) redirect('/login')\n}\n\\`\\`\\`\n\n**An action or a query**: middleware does NOT run — they render no route. Build\nthem from an action client so the check cannot be forgotten. See the\n\\`action-client\\` topic.\n\n**Authorise on identity, not arguments.** \\`deletePost(id)\\` that trusts the id\nis the whole of an IDOR: the caller chooses the id, so check the row belongs to\n\\`ctx.user\\`.\n\nA guarded page can still be frozen at build time — the guard is a serving\ndecision, not a build one. Its response is marked private so no cache keeps it.`,\n },\n {\n topic: 'dynamic',\n summary: 'Why a page is not static, and how to choose',\n body: `A page is stored at build time unless it reads the request. Reading it is what\nopts out, and the accessors are async:\n\n\\`\\`\\`ts\nimport { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'\n\nconst theme = (await cookies()).get('theme')\nawait connection() // \"render this per visitor\", said deliberately\n\\`\\`\\`\n\nA page's \\`params\\` and \\`searchParams\\` props are promises for the same reason.\n\nThe build says which call did it, per route:\n\n ◐ /locale 85 kB\n dynamic — called cookies(), headers()\n\nThat is usually correct — a page whose content depends on who is asking cannot\nbe one stored file. Change it only when the read was accidental.\n\nFor a parameterised route, \\`generateStaticParams\\` turns one shell into a page\nper url:\n\n\\`\\`\\`ts\nexport async function generateStaticParams() {\n return (await db.posts.all()).map((p) => ({ slug: p.slug }))\n}\n\\`\\`\\`\n\nA value that must differ per visitor but needs no server — a clock,\nlocalStorage, a map — belongs in the browser only:\n\n\\`\\`\\`tsx\n'use client'\nimport { browser } from 'react-dom'\n\nfunction Clock() {\n use(browser('the time is the visitor\\\\'s, not the build machine\\\\'s'))\n}\n\\`\\`\\`\n\nIt needs a Suspense boundary, and the page stays frozen.`,\n },\n {\n topic: 'metadata',\n summary: 'Titles, share cards, and the one setting production needs',\n body: `\\`\\`\\`tsx\nexport const metadata: Metadata = {\n title: 'Orders',\n openGraph: { title: 'Orders', description: '…', images: '/cover.png' },\n}\n\\`\\`\\`\n\nA layout takes a title TEMPLATE - { template: '%s · Site', default: 'Site' } -\nand layouts merge outward-in, so site-wide values go on the root layout once.\n\n**Set metadataBase on the root layout. It is not optional in production.**\n\n\\`\\`\\`tsx\nmetadataBase: new URL('https://example.com')\n\\`\\`\\`\n\nA share-card scraper needs an ABSOLUTE image url and Facebook, Slack and\nLinkedIn refuse a relative one silently - the link unfurls with no image and\nnothing says why. metadataBase makes every relative url, image and icon\nabsolute. Same name as Next, so a port carries it across.\n\nUse the structured objects, not the flat 'og:title' spellings: openGraph and\ntwitter are typed, an image can be { url, width, height, alt }, and it is the\nshape a Next app already has. og: renders as property=, twitter: as name= -\nwhat each scraper reads.\n\nAn opengraph-image.png in app/ is found by name and needs no listing; it still\nneeds metadataBase to go out absolute.`,\n },\n {\n topic: 'fonts',\n summary: 'Self-hosted fonts from npm, and porting next/font',\n body: `There is no font loader. Install the font from Fontsource, import its\ncss, name it in a variable:\n\n\\`\\`\\`css\n@import '@fontsource-variable/fraunces/full.css';\n@import '@fontsource-variable/geist';\n\n:root {\n --font-display: 'Fraunces Variable', ui-serif, Georgia, serif;\n --font-sans: 'Geist Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,\n 'Helvetica Neue', Arial, sans-serif,\n 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';\n}\n\\`\\`\\`\n\nPut the font in FRONT of a full stack, not in place of one. A bare\n'Geist Variable', sans-serif drops the emoji fonts - Geist has no emoji glyphs,\nand with nothing named after it some systems draw a box - and drops the\nmetrics-matched fallback that makes the swap moment smaller. Those are\nTailwind's own defaults; shadcn's generated line loses both.\n\nVite hashes the woff2 files and serves them with the other assets. Nothing is\nfetched from Google at runtime and nothing is downloaded at build - the files\nare in node_modules.\n\nPorting next/font: every option was something Fontsource already did.\nsubsets -> every subset ships behind a unicode-range and the browser fetches\nonly what the page uses. style: ['italic'] -> full-italic.css. axes -> full.css\nhas every axis; standard.css is weight only. display: 'swap' -> already in\nevery rule. className={font.variable} -> nothing, the variable is on :root.\n\nThe one line next/font added that you add yourself is the preload:\n\n\\`\\`\\`tsx\nimport fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2?url'\n<link rel=\"preload\" href={fraunces} as=\"font\" type=\"font/woff2\" crossOrigin=\"anonymous\" />\n\\`\\`\\`\n\n?url is Vite's and gives the hashed path. Preload the one file the first paint\nneeds; preloading all of them defeats the subsetting.\n\nDo NOT reach for next/font, @next/font or a Google Fonts link tag.`,\n },\n {\n topic: 'scripts',\n summary: 'Third-party scripts - analytics, tag managers - without a Script component',\n body: `Write the script tag. React 19 does what Next's Script component existed for.\n\nAn external script with async, rendered from a server component, is HOISTED\ninto head and DEDUPLICATED by React - the same src in three components is one\ntag. That is afterInteractive:\n\n\\`\\`\\`tsx\n<script async src=\"https://www.clarity.ms/tag/abc123\" />\n\\`\\`\\`\n\nAn inline snippet renders where it is written and runs during parse, before\nhydration - the earlier moment, which is what an analytics snippet wants:\n\n\\`\\`\\`tsx\n<script id=\"ms-clarity\" dangerouslySetInnerHTML={{ __html: '...' }} />\n\\`\\`\\`\n\nPut site-wide scripts in the ROOT LAYOUT, which renders once and is kept\nacross navigations.\n\nThere is no Script component to import. The only case needing one - a script\nthat touches DOM React rendered, or an onLoad callback - is a client component\nwith useEffect that creates the tag. Ten lines of the user's own.`,\n },\n {\n topic: 'testing',\n summary: 'Unit-testing actions, queries and routes; the whole app without a port',\n body: `Almost everything is a function. Any test runner works.\n\n**Actions, queries, api routes: import and call.** \"use server\" is a string in\na test file, so the function is importable. An action built on the action\nclient runs its whole middleware chain when called and RETURNS its failures:\n\n\\`\\`\\`ts\nconst result = await createPost({ title: '' })\nexpect(result.validationErrors).toEqual({ title: ['too short'] })\n\\`\\`\\`\n\nAn api route takes a Request and the context the engine gives it - params is a\nPROMISE:\n\n\\`\\`\\`ts\nconst res = await GET(new Request('https://app.test/api/x'), { params: Promise.resolve({ id: '1' }) })\n\\`\\`\\`\n\n**Anything reading cookies() or headers():** open the request scope yourself.\n\n\\`\\`\\`ts\nimport { withRequest } from '@rsc-kit/core/request'\nawait withRequest(new Request('https://app.test/', { headers: { Cookie: 'session=abc' } }), currentUser)\n\\`\\`\\`\n\n**The whole app as Request -> Response, no port:**\n\n\\`\\`\\`ts\nimport { createTestApp } from '@rsc-kit/core/testing'\nconst app = await createTestApp()\nconst res = await app.fetch('/admin', { redirect: 'manual' }) // real router, real middleware\n\\`\\`\\`\n\nIt builds when the source is newer than the last build - the first run pays,\nthe rest do not. This is where a guard that never ran or a 404 that came back\n200 shows up.\n\n**What still needs a browser:** a server action called OVER THE WIRE (the id is\nReact's and private), hydration, navigation. Playwright against vite preview.\nThat limit is narrower than Next's: the action's logic is a unit test here.`,\n },\n]\n\n/** Every topic, with one line each — what a caller reads before choosing. */\nexport function listTopics(): string {\n return [\n 'Topics. Ask for one with how_to({ topic }).',\n '',\n ...RECIPES.map((r) => `${r.topic.padEnd(16)} ${r.summary}`),\n ].join('\\n')\n}\n\n/** One recipe, or the list plus a nudge when the topic is not one. */\nexport function howTo(topic: string): string {\n const wanted = topic.trim().toLowerCase().replace(/[\\s_]+/g, '-')\n const found =\n RECIPES.find((r) => r.topic === wanted) ??\n // A near miss is common and worth answering rather than refusing: someone\n // asks for \"form\" or \"queries\" and means the obvious thing.\n RECIPES.find((r) => r.topic.startsWith(wanted) || wanted.startsWith(r.topic)) ??\n RECIPES.find((r) => r.summary.toLowerCase().includes(wanted))\n\n if (!found) return `No topic \"${topic}\".\\n\\n${listTopics()}`\n\n return `# ${found.topic} — ${found.summary}\\n\\n${found.body}`\n}\n\n/** For tests, so a recipe cannot be added without being reachable. */\nexport const TOPICS = RECIPES.map((r) => r.topic)\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rsc-kit/mcp",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.",
5
5
  "type": "module",
6
6
  "license": "MIT",