@rsc-kit/mcp 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@
20
20
  // Everything is read-only. There is no tool here that edits, builds or deploys,
21
21
  // deliberately: an agent already has a shell for those, and a server that can
22
22
  // change the project is one that can change it while answering a question.
23
+ import { readFileSync } from 'node:fs';
23
24
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
25
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25
26
  import { z } from 'zod';
@@ -61,7 +62,18 @@ const answering = (produce) => {
61
62
  throw error;
62
63
  }
63
64
  };
64
- const server = new McpServer({ name: 'rsc-kit', version: '0.1.0' });
65
+ // The published version, read from this package's own manifest: the server
66
+ // answered `0.1.0` to every client, which is the repo's placeholder and told
67
+ // nobody which release they were talking to.
68
+ const selfVersion = (() => {
69
+ try {
70
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version ?? '0.0.0';
71
+ }
72
+ catch {
73
+ return '0.0.0';
74
+ }
75
+ })();
76
+ const server = new McpServer({ name: 'rsc-kit', version: selfVersion });
65
77
  server.registerTool('list_routes', {
66
78
  title: 'List routes',
67
79
  description: 'Every route in this rsc-kit app, what the build did with each one, and how much javascript it ships. Start here when you need to know what exists.',
@@ -97,7 +109,7 @@ server.registerTool('heaviest_routes', {
97
109
  }));
98
110
  server.registerTool('how_to', {
99
111
  title: 'How to build it',
100
- description: 'How to do something in an rsc-kit app — forms, prefetching, validation, the action client, data loading with TanStack Query or SWR, Suspense boundaries, offline, PWA, api routes, authorization, and why a page is dynamic. Read this BEFORE writing the code: the patterns here differ from Next and plain React in ways that compile either way. The short answer; read_guide has the full one.',
112
+ description: 'How to do something in an rsc-kit app — forms, prefetching, validation, the action client, data loading with TanStack Query or SWR, Suspense boundaries, offline, PWA, api routes, authorization, a Laravel or other backend, once-per-process startup, and why a page is dynamic. Read this BEFORE writing the code: the patterns here differ from Next and plain React in ways that compile either way. The short answer; read_guide has the full one.',
101
113
  inputSchema: TOPIC_ARG,
102
114
  annotations: { readOnlyHint: true },
103
115
  }, (async ({ topic }) => text(howTo(topic))));
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n// An MCP server over what an rsc-kit build decided.\n//\n// claude mcp add rsc-kit -- npx -y @rsc-kit/mcp\n//\n// Four questions, all answered from `build-report.json` and none of them\n// requiring the app to be running:\n//\n// what routes exist, and what happened to each\n// why is this one not stored\n// which ones render per request\n// which ones cost the browser the most\n//\n// Why a server rather than letting an agent read the file: the file is JSON\n// with a `type` field whose values mean nothing without the documentation, and\n// an agent reading it guesses — \"shell\" invites being treated as a failure when\n// it is the normal, correct outcome for a page with data in it. These answers\n// say what each state means, in the same words the build printed.\n//\n// Everything is read-only. There is no tool here that edits, builds or deploys,\n// deliberately: an agent already has a shell for those, and a server that can\n// change the project is one that can change it while answering a question.\n\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport { z } from 'zod'\nimport { explainRoute, heaviestRoutes, listRoutes, whatIsDynamic } from './answers.js'\nimport { NoReport, loadReport } from './report.js'\nimport { howTo, listTopics } from './recipes.js'\nimport { listGuides, readGuide, searchGuides } from './bundleGuides.js'\n\n/** The project to read, from the argument or the working directory. */\nconst root = process.argv[2] ?? process.cwd()\n\n/**\n * Loaded per call, never cached.\n *\n * A build happens while this server is running — that is the normal case, not\n * the exception — and an answer from the build before it is worse than a slow\n * one. The file is small and this is not a hot path.\n */\nconst read = () => loadReport(root)\n\n/**\n * Declared once, outside the call.\n *\n * Inline, TypeScript walks the sdk's tool generics against the zod shape and\n * gives up with \"type instantiation is excessively deep\" — a compiler limit\n * rather than anything wrong with the schema. A named const with the handler's\n * argument annotated stops the inference chain before it gets there.\n */\nconst URL_ARG = { url: z.string().describe('The url, e.g. /orders or /posts/hello') }\nconst TOPIC_ARG = { topic: z.string().describe('One of the topics from list_topics, e.g. forms or validation') }\nconst SLUG_ARG = { slug: z.string().describe('One of the slugs from list_guides, e.g. forms or server-actions') }\nconst PHRASE_ARG = { phrase: z.string().describe('A word or phrase, e.g. fieldErrors or metadataBase') }\n\nconst text = (body: string) => ({ content: [{ type: 'text' as const, text: body }] })\n\n/** A missing report is an answer, not a crash: it says to run a build. */\nconst answering = (produce: () => string) => {\n try {\n return text(produce())\n } catch (error) {\n if (error instanceof NoReport) return text(error.message)\n\n throw error\n }\n}\n\n/**\n * The registration surface, named rather than inferred.\n *\n * `registerTool` is generic over the zod shape, and TypeScript walks those\n * generics until it gives up — \"type instantiation is excessively deep\", which\n * is a compiler limit rather than anything wrong with the schema. Describing\n * the one method used, with the argument type written out, stops the inference\n * before it gets there and costs nothing: the schemas below are still real zod\n * and still validate at runtime.\n */\ninterface Registrar {\n registerTool(\n name: string,\n config: {\n title?: string\n description?: string\n inputSchema?: Record<string, unknown>\n annotations?: { readOnlyHint?: boolean }\n },\n handler: (args: never) => Promise<{ content: { type: 'text'; text: string }[] }>,\n ): unknown\n connect(transport: StdioServerTransport): Promise<void>\n}\n\nconst server = new McpServer({ name: 'rsc-kit', version: '0.1.0' }) as unknown as Registrar\n\nserver.registerTool(\n 'list_routes',\n {\n title: 'List routes',\n description:\n 'Every route in this rsc-kit app, what the build did with each one, and how much javascript it ships. Start here when you need to know what exists.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return listRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'explain_route',\n {\n title: 'Explain a route',\n description:\n 'Why one url is stored at build time or rendered per request, what renders it, and what it costs the browser. Use this before changing a page to make it faster — the reason is recorded, not guessed.',\n inputSchema: URL_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ url }: { url: string }) =>\n answering(() => {\n const { report, builtAt } = read()\n\n return explainRoute(report, url, builtAt, Date.now())\n })) as never,\n)\n\nserver.registerTool(\n 'what_is_dynamic',\n {\n title: 'What renders per request',\n description:\n 'The routes that render per request rather than being stored, each with the reason. This is the answer to \"why is this site not static\".',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return whatIsDynamic(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'heaviest_routes',\n {\n title: 'Heaviest routes',\n description: 'The routes that make the browser download the most javascript, largest first.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return heaviestRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'how_to',\n {\n title: 'How to build it',\n description:\n 'How to do something in an rsc-kit app — forms, prefetching, validation, the action client, data loading with TanStack Query or SWR, Suspense boundaries, offline, PWA, api routes, authorization, and why a page is dynamic. Read this BEFORE writing the code: the patterns here differ from Next and plain React in ways that compile either way. The short answer; read_guide has the full one.',\n inputSchema: TOPIC_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ topic }: { topic: string }) => text(howTo(topic))) as never,\n)\n\nserver.registerTool(\n 'list_topics',\n {\n title: 'What this server can explain',\n description: 'Every topic how_to knows about, one line each.',\n annotations: { readOnlyHint: true },\n },\n async () => text(listTopics()),\n)\n\nserver.registerTool(\n 'list_guides',\n {\n title: 'The guides',\n description: 'Every guide from docs.rsc-kit.dev, bundled with this server — the full text behind how_to, one line each.',\n annotations: { readOnlyHint: true },\n },\n async () => text(listGuides()),\n)\n\nserver.registerTool(\n 'read_guide',\n {\n title: 'Read a guide',\n description:\n 'The complete guide for one topic, as published at docs.rsc-kit.dev — routing, forms, server-actions, validation, metadata, testing, deployment and the rest. Use it when how_to is not enough or names something it does not explain.',\n inputSchema: SLUG_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ slug }: { slug: string }) => text(readGuide(slug))) as never,\n)\n\nserver.registerTool(\n 'search_guides',\n {\n title: 'Search the guides',\n description: 'Every line in the guides that mentions a word or phrase, with the guide it is in. Use it to find which guide covers something before reading it.',\n inputSchema: PHRASE_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ phrase }: { phrase: string }) => text(searchGuides(phrase))) as never,\n)\n\nawait server.connect(new StdioServerTransport())\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n// An MCP server over what an rsc-kit build decided.\n//\n// claude mcp add rsc-kit -- npx -y @rsc-kit/mcp\n//\n// Four questions, all answered from `build-report.json` and none of them\n// requiring the app to be running:\n//\n// what routes exist, and what happened to each\n// why is this one not stored\n// which ones render per request\n// which ones cost the browser the most\n//\n// Why a server rather than letting an agent read the file: the file is JSON\n// with a `type` field whose values mean nothing without the documentation, and\n// an agent reading it guesses — \"shell\" invites being treated as a failure when\n// it is the normal, correct outcome for a page with data in it. These answers\n// say what each state means, in the same words the build printed.\n//\n// Everything is read-only. There is no tool here that edits, builds or deploys,\n// deliberately: an agent already has a shell for those, and a server that can\n// change the project is one that can change it while answering a question.\n\nimport { readFileSync } from 'node:fs'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport { z } from 'zod'\nimport { explainRoute, heaviestRoutes, listRoutes, whatIsDynamic } from './answers.js'\nimport { NoReport, loadReport } from './report.js'\nimport { howTo, listTopics } from './recipes.js'\nimport { listGuides, readGuide, searchGuides } from './bundleGuides.js'\n\n/** The project to read, from the argument or the working directory. */\nconst root = process.argv[2] ?? process.cwd()\n\n/**\n * Loaded per call, never cached.\n *\n * A build happens while this server is running — that is the normal case, not\n * the exception — and an answer from the build before it is worse than a slow\n * one. The file is small and this is not a hot path.\n */\nconst read = () => loadReport(root)\n\n/**\n * Declared once, outside the call.\n *\n * Inline, TypeScript walks the sdk's tool generics against the zod shape and\n * gives up with \"type instantiation is excessively deep\" — a compiler limit\n * rather than anything wrong with the schema. A named const with the handler's\n * argument annotated stops the inference chain before it gets there.\n */\nconst URL_ARG = { url: z.string().describe('The url, e.g. /orders or /posts/hello') }\nconst TOPIC_ARG = { topic: z.string().describe('One of the topics from list_topics, e.g. forms or validation') }\nconst SLUG_ARG = { slug: z.string().describe('One of the slugs from list_guides, e.g. forms or server-actions') }\nconst PHRASE_ARG = { phrase: z.string().describe('A word or phrase, e.g. fieldErrors or metadataBase') }\n\nconst text = (body: string) => ({ content: [{ type: 'text' as const, text: body }] })\n\n/** A missing report is an answer, not a crash: it says to run a build. */\nconst answering = (produce: () => string) => {\n try {\n return text(produce())\n } catch (error) {\n if (error instanceof NoReport) return text(error.message)\n\n throw error\n }\n}\n\n/**\n * The registration surface, named rather than inferred.\n *\n * `registerTool` is generic over the zod shape, and TypeScript walks those\n * generics until it gives up — \"type instantiation is excessively deep\", which\n * is a compiler limit rather than anything wrong with the schema. Describing\n * the one method used, with the argument type written out, stops the inference\n * before it gets there and costs nothing: the schemas below are still real zod\n * and still validate at runtime.\n */\ninterface Registrar {\n registerTool(\n name: string,\n config: {\n title?: string\n description?: string\n inputSchema?: Record<string, unknown>\n annotations?: { readOnlyHint?: boolean }\n },\n handler: (args: never) => Promise<{ content: { type: 'text'; text: string }[] }>,\n ): unknown\n connect(transport: StdioServerTransport): Promise<void>\n}\n\n// The published version, read from this package's own manifest: the server\n// answered `0.1.0` to every client, which is the repo's placeholder and told\n// nobody which release they were talking to.\nconst selfVersion = (() => {\n try {\n return (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { version?: string }).version ?? '0.0.0'\n } catch {\n return '0.0.0'\n }\n})()\n\nconst server = new McpServer({ name: 'rsc-kit', version: selfVersion }) as unknown as Registrar\n\nserver.registerTool(\n 'list_routes',\n {\n title: 'List routes',\n description:\n 'Every route in this rsc-kit app, what the build did with each one, and how much javascript it ships. Start here when you need to know what exists.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return listRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'explain_route',\n {\n title: 'Explain a route',\n description:\n 'Why one url is stored at build time or rendered per request, what renders it, and what it costs the browser. Use this before changing a page to make it faster — the reason is recorded, not guessed.',\n inputSchema: URL_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ url }: { url: string }) =>\n answering(() => {\n const { report, builtAt } = read()\n\n return explainRoute(report, url, builtAt, Date.now())\n })) as never,\n)\n\nserver.registerTool(\n 'what_is_dynamic',\n {\n title: 'What renders per request',\n description:\n 'The routes that render per request rather than being stored, each with the reason. This is the answer to \"why is this site not static\".',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return whatIsDynamic(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'heaviest_routes',\n {\n title: 'Heaviest routes',\n description: 'The routes that make the browser download the most javascript, largest first.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return heaviestRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'how_to',\n {\n title: 'How to build it',\n description:\n 'How to do something in an rsc-kit app — forms, prefetching, validation, the action client, data loading with TanStack Query or SWR, Suspense boundaries, offline, PWA, api routes, authorization, a Laravel or other backend, once-per-process startup, and why a page is dynamic. Read this BEFORE writing the code: the patterns here differ from Next and plain React in ways that compile either way. The short answer; read_guide has the full one.',\n inputSchema: TOPIC_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ topic }: { topic: string }) => text(howTo(topic))) as never,\n)\n\nserver.registerTool(\n 'list_topics',\n {\n title: 'What this server can explain',\n description: 'Every topic how_to knows about, one line each.',\n annotations: { readOnlyHint: true },\n },\n async () => text(listTopics()),\n)\n\nserver.registerTool(\n 'list_guides',\n {\n title: 'The guides',\n description: 'Every guide from docs.rsc-kit.dev, bundled with this server — the full text behind how_to, one line each.',\n annotations: { readOnlyHint: true },\n },\n async () => text(listGuides()),\n)\n\nserver.registerTool(\n 'read_guide',\n {\n title: 'Read a guide',\n description:\n 'The complete guide for one topic, as published at docs.rsc-kit.dev — routing, forms, server-actions, validation, metadata, testing, deployment and the rest. Use it when how_to is not enough or names something it does not explain.',\n inputSchema: SLUG_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ slug }: { slug: string }) => text(readGuide(slug))) as never,\n)\n\nserver.registerTool(\n 'search_guides',\n {\n title: 'Search the guides',\n description: 'Every line in the guides that mentions a word or phrase, with the guide it is in. Use it to find which guide covers something before reading it.',\n inputSchema: PHRASE_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ phrase }: { phrase: string }) => text(searchGuides(phrase))) as never,\n)\n\nawait server.connect(new StdioServerTransport())\n"]}
package/dist/recipes.js CHANGED
@@ -342,20 +342,40 @@ export default function Page() {
342
342
 
343
343
  Reach for this first. It is the thing RSC is for.
344
344
 
345
- **When the BROWSER decides to refetch** — a filter, a poll, a refresh — that is
346
- a cache library's job and this package does not ship one:
345
+ **When the BROWSER decides to refetch** — a filter, another page, a refresh —
346
+ call fetchQuery. It is a plain async function that returns the typed answer;
347
+ NO library is needed:
347
348
 
349
+ \`\`\`tsx
350
+ const [listings, setListings] = useState(initial) // the server-rendered value
351
+ const [pending, start] = useTransition()
352
+ const show = (kind) => start(async () => setListings(await fetchQuery(getListings, [kind])))
353
+ \`\`\`
354
+ Works in an onClick, onSubmit, useEffect - anywhere in the browser.
355
+
356
+ **When you want CACHING** (stale-while-revalidate, dedupe, offline), hand the
357
+ same call to the library that holds the answer; this package ships none:
348
358
  \`\`\`tsx
349
359
  useQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })
350
360
  useSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))
351
361
  \`\`\`
352
-
353
- \`fetchQuery\` sends the read as a GET and goes to the server every time, which
354
- is what a fetcher needs staleness and revalidation belong to the library
355
- holding the answer. Do not add a cache on top of it.
362
+ fetchQuery sends the read as a GET and goes to the server every time, which
363
+ is what a fetcher needs. It will never cache, dedupe or batch: a client
364
+ cache is the library's job, and a batch would lose the per-read cache key a
365
+ GET has. The ladder, most reads stopping on the first rung:
366
+ 1. the value now: fetchQuery + setState
367
+ 2. survive a reload / let a CDN serve it: query(fn, { cache: 'public', maxAge })
368
+ - HTTP caching, no code in the page
369
+ 3. staleness, background refresh, optimistic updates, shared across
370
+ components: TanStack or SWR with fetchQuery as the fetcher
371
+ Do not add a cache on top of it, and do not install TanStack for a single
372
+ button that reads once.
356
373
 
357
374
  Keep the arrow: TanStack calls a bare \`queryFn\` with its own context, and a
358
- server function serialises whatever it is handed.`,
375
+ server function serialises whatever it is handed.
376
+
377
+ A value that keeps CHANGING while someone watches - polling, SSE, realtime -
378
+ is how_to live-data, not this.`,
359
379
  },
360
380
  {
361
381
  topic: 'suspense',
@@ -511,22 +531,30 @@ page that is 82 kB because of one <Link> is fine.`,
511
531
  body: `\`src/app/**/route.ts\`, one export per method:
512
532
 
513
533
  \`\`\`ts title="src/app/api/posts/[id]/route.ts"
534
+ import type { RouteContext } from '@rsc-kit/core/route-schema'
535
+
514
536
  export const params = z.object({ id: z.coerce.number().int() })
515
537
  export const body = z.object({ title: z.string().min(1) })
516
538
 
517
- export async function GET(request: Request, { params }) {
539
+ export async function GET(request: Request, { params }: RouteContext<typeof params>) {
518
540
  const { id } = await params
519
541
 
520
542
  return Response.json(await findPost(id))
521
543
  }
522
544
 
523
- export async function POST(request: Request, { params, body }) {
545
+ export async function POST(request: Request, { params, body }: RouteContext<typeof params, never, typeof body>) {
524
546
  const { title } = await body
525
547
 
526
548
  return Response.json(await createPost(title), { status: 201 })
527
549
  }
528
550
  \`\`\`
529
551
 
552
+ TYPE THE CONTEXT: RouteContext<'/api/items/[id]'> types params from the
553
+ route's segments (the pattern is checked against the routes the build found);
554
+ RouteContext<typeof params> from a schema. Every field is a PROMISE - a sync
555
+ params.id is a compile error, never a route that 404s. Do NOT hand-write
556
+ { params: { id: string } }.
557
+
530
558
  A real \`Request\` in, a real \`Response\` out. \`params\`, \`searchParams\` and
531
559
  \`body\` are awaited, the same way a page's props are.
532
560
 
@@ -747,13 +775,15 @@ IMPORTS
747
775
  useParams() -> the page's params prop, passed down
748
776
  cookies(), headers() -> same names, from @rsc-kit/core/request
749
777
  redirect() / notFound() -> @rsc-kit/core/redirect / @rsc-kit/core/not-found
750
- revalidatePath/Tag -> revalidate('tag') on a section() - targeted, rides back with the action
778
+ revalidatePath/Tag -> revalidate('name') on a section() - targeted, rides back with the action; the
779
+ name is typed to the sections and slots the build found
751
780
  Metadata -> @rsc-kit/core/metadata (metadataBase, openGraph, twitter, icons as-is)
752
781
  app/robots.ts, app/sitemap.ts -> the same files and shapes; app/llms.ts beside them (how_to seo-files)
782
+ middleware.ts subdomain rewrite -> nothing: a host is a route segment (how_to domains)
753
783
  next/font -> Fontsource (how_to fonts)
754
784
  next/image -> unpic or vite-imagetools (how_to images)
755
785
  next/script -> a <script> tag (how_to scripts)
756
- NEXT_PUBLIC_* -> VITE_* via import.meta.env; server vars stay process.env
786
+ NEXT_PUBLIC_* -> PUBLIC_* in src/env.ts (how_to env); server vars typed there too
757
787
  next-safe-action -> createActionClient() (how_to action-client); returnValidationErrors -> return fieldErrors({...})
758
788
  cache from 'react' -> cache from @rsc-kit/core/cache: React's dedupes only inside a render; this one
759
789
  spans the request (guards, actions, api routes). The build names files still on React's
@@ -767,12 +797,18 @@ DIFFERENT ON PURPOSE
767
797
  It does not run for actions - the check goes in the action.
768
798
  - Actions return failures ({ validationErrors }, { serverError }), not throw.
769
799
  - No image optimizer, no opengraph-image.tsx - put opengraph-image.png in src/app.
770
- - Tests need no browser: createTestApp() is the deployed handler.
800
+ - Tests need no browser: createTestApp() is the deployed handler. It builds
801
+ with the project's own build script on the runtime the tests run under.
771
802
  - A component library (base-ui, Radix) imports as it did, from server
772
803
  components too. A shadcn-style components/ui/ folder keeps "use client" at
773
804
  the top of each file, as shipped; without it the server evaluates the
774
805
  library's internals for nothing.
775
806
 
807
+ SCAFFOLD FLAGS: --host=bun|node|worker --validation=zod|valibot|arktype|none
808
+ --env/--no-env (typed env vars via @t3-oss/env-core in src/env.ts, in the
809
+ chosen library; server vars never reach the browser, PUBLIC_ prefix for ones
810
+ that may). Pick the library the Next app already uses.
811
+
776
812
  ORDER: scaffold -> copy src/app -> fix imports -> build (it typechecks first,
777
813
  so a Link to a route that does not exist fails here) and READ the output: a
778
814
  route that is not ○ names what streams and from which component (a cookies()
@@ -870,18 +906,331 @@ export default function llms(): MetadataRoute.Llms {
870
906
  A relative url is made absolute with the root layout's metadataBase; without
871
907
  one it is a build error. Any of them may return a string, served as written.
872
908
 
873
- Each becomes an api route, so: one that reads nothing per request (the
874
- database is fine) is stored at build and served from the file; one that reads
875
- cookies() or awaits connection() runs per request. No middleware runs for
909
+ NO sitemap.ts? The build writes /sitemap.xml itself: every stored page and
910
+ every generateStaticParams url, lastModified = the build, minus guarded
911
+ routes (middleware.ts above them), failed pages and not-found. Needs the root
912
+ layout's metadataBase. Write sitemap.ts only when you need urls the build
913
+ cannot see or per-url changeFrequency/priority.
914
+
915
+ HOW FRESH - the function decides, the same rule every route follows:
916
+ write nothing -> the build's own sitemap, stored; fresh every deploy
917
+ sitemap.ts that reads the database -> stored at build (○); fresh every deploy
918
+ sitemap.ts that awaits connection() -> rendered per request (ƒ); fresh every crawl
919
+ \`\`\`ts
920
+ export default async function sitemap() {
921
+ await connection() // from '@rsc-kit/core/request' - per request, like a page
922
+ return (await db.post.findMany()).map((p) => ({ url: \`/blog/\${p.slug}\`, lastModified: p.updatedAt }))
923
+ }
924
+ \`\`\`
925
+ Reading the database at build is fine; the REQUEST makes it dynamic, not the
926
+ data. Same for robots.ts and llms.ts. No middleware runs for
876
927
  them - a root guard must not 401 the crawler. The url is typed
877
928
  (route('/sitemap.xml')).
878
929
 
879
930
  A file as written beside the root layout is served at the root as it is:
880
931
  robots.txt, sitemap.xml, sitemap-*.xml, llms.txt, llms-full.txt, humans.txt,
881
932
  security.txt, ads.txt. A file and a function for the same url is a build
882
- error. Do NOT put these in public/ and do NOT write a route.ts for them.
933
+ error. Do NOT put these in public/ and do NOT write a middleware.ts for them.
883
934
 
884
935
  Full guide: read_guide({ slug: 'seo-files' }).`,
936
+ },
937
+ {
938
+ topic: 'domains',
939
+ summary: 'Subdomains and custom domains as route segments - admin.example.com reaches app/admin, a tenant host binds [domain], no rewrite',
940
+ body: `A request from a host that is not the site's own is matched with the host
941
+ in FRONT of the path. The site's own hosts: the root layout's metadataBase,
942
+ www. of it, and rscKit({ hosts: [...] }) - which is ONLY for a name that is
943
+ neither the apex nor a subdomain of it (a staging/internal name, a second
944
+ brand domain); a normal setup needs no config beyond metadataBase. localhost
945
+ and ips are always own.
946
+
947
+ example.com/admin -> /admin app/admin/page.tsx
948
+ admin.example.com/ -> /admin the same file (subdomain of an own host = its label)
949
+ acme.example.com/settings -> /acme/settings app/[domain]/settings/page.tsx, domain "acme"
950
+ acme.com/settings -> /acme.com/settings the same file, domain "acme.com" (other host = whole host)
951
+
952
+ The visitor's url is untouched; only the match changes. A top-level [domain]
953
+ binds ONLY from a host, never from a path: example.com/nope is a 404, not a
954
+ tenant called "nope". Otherwise [domain] is an ordinary dynamic segment: params.domain in every page/layout under it, typed
955
+ route('/[domain]/settings', { domain }), loading/error files as usual. A
956
+ directory named for a host (app/admin/) wins over [domain].
957
+
958
+ \`\`\`tsx
959
+ // src/app/[domain]/layout.tsx
960
+ export default async function TenantLayout({ params, children }) {
961
+ const { domain } = await params
962
+ const tenant = await tenantByDomain(domain) // "acme" or "acme.com", as stored
963
+ if (!tenant) notFound()
964
+ return <TenantProvider tenant={tenant}>{children}</TenantProvider>
965
+ }
966
+ // src/app/[domain]/page.tsx - domains in a database: list them, they are stored at build
967
+ export async function generateStaticParams() {
968
+ return (await db.tenant.findMany()).map((t) => ({ domain: t.domain }))
969
+ }
970
+ \`\`\`
971
+
972
+ Only when a route could answer it (a top-level [domain] directory, or one
973
+ named for the host); otherwise the host is the site's own. Keep metadataBase as
974
+ the production host: localhost and ips are always own, so dev routes by path.
975
+ To try a tenant locally: curl -H 'X-Forwarded-Host: acme.example.com'
976
+ http://localhost:3000/ (or /etc/hosts). Behind a proxy the
977
+ host is X-Forwarded-Host, then Host. Not for a static export (a file server
978
+ sees no host). Do NOT write a middleware rewrite, do NOT
979
+ read the host in every page - the segment already is the host. The root layout
980
+ needs metadataBase (or rscKit({ hosts })) or every host is the site's own.
981
+
982
+ Full guide: read_guide({ slug: 'domains' }).`,
983
+ },
984
+ {
985
+ topic: 'identify',
986
+ summary: 'What a response says about itself - X-RSC-Kit (how it was served, always) and X-Powered-By + a generator tag (what built it, off with identify: false)',
987
+ body: `Every response carries X-RSC-Kit: stored | rendered | shell - a page from a
988
+ file the build wrote, rendered for this visitor, or a stored shell with its
989
+ holes rendered now. The header to read when a page is slower than expected
990
+ (like X-Nextjs-Cache); a CDN rule or health check can key on it. Names no
991
+ product; always sent.
992
+
993
+ By default a response also says what built it: X-Powered-By: rsc-kit and
994
+ <meta name="generator" content="rsc-kit"> in every document (BuiltWith,
995
+ Wappalyzer). The NAME only, never the version - a version in every response
996
+ is what a vulnerability scanner filters on.
997
+
998
+ rscKit({ identify: false }) turns off the name (header and tag) for a policy
999
+ that strips framework identifiers; X-RSC-Kit stays. Do not strip X-RSC-Kit
1000
+ at the proxy - it is what tells you whether a stored page was served.
1001
+
1002
+ Full guide: read_guide({ slug: 'response-headers' }).`,
1003
+ },
1004
+ {
1005
+ topic: 'backend',
1006
+ summary: 'BAP (Backend-Answered Pages): a Laravel, Go or other backend behind the renderer - rpc() reaches it, middleware.ts names its middleware, app/Rsc/Actions are its server actions',
1007
+ body: `The model is a BAP - Backend-Answered Pages: a page rendered in front of
1008
+ the backend rather than by it (MPA: backend renders; SPA: browser renders and
1009
+ calls an API; BAP: a renderer on the server renders and calls the backend
1010
+ over loopback). The backend is the part that is not a page - models, session,
1011
+ auth, policies, jobs - answering one private endpoint, and it keeps every
1012
+ route of its own. The whole model, and how to build for it:
1013
+ read_guide({ slug: 'backend-answered-pages' }).
1014
+
1015
+ Go: in a Go module, rsc-kit init sees go.mod, writes the JS half and .env
1016
+ (RSC_BACKEND + a generated secret) and prints the Go wiring; go get
1017
+ github.com/rsc-kit/go. A new app: bun create rsc-kit --backend=<url>.
1018
+
1019
+ A backend in another language answers that ONE endpoint, POST /__rsc/host-call,
1020
+ and the renderer wires itself from two variables in .env: RSC_BACKEND (a
1021
+ Laravel app's APP_URL counts) and RSC_HOST_CALL_SECRET. Both or neither.
1022
+
1023
+ Laravel: composer require rsc-kit/laravel, then php artisan rsc:install. It
1024
+ runs rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is
1025
+ moved aside - the renderer owns the frontend). Source is resources/js (the route tree is resources/js/app).
1026
+
1027
+ Reach PHP from a server component - rpc() is a global, typed in
1028
+ .rsc-kit/rsc-env.d.ts, server render only:
1029
+
1030
+ // app/Rsc/Orders.php: public function recent(int $limit): array
1031
+ const orders = await rpc<Order[]>('Orders.recent', 5)
1032
+
1033
+ The call runs AS THE VISITOR (their cookie is forwarded; auth()->user() is
1034
+ them). Refuse with attributes: #[Authenticated], #[Can('update', Order::class)],
1035
+ #[Middleware('throttle:60,1')]. A ValidationException lands on the form as
1036
+ validationErrors; Authentication/Authorization exceptions answer 401/403.
1037
+
1038
+ Guard a route in Laravel's vocabulary, no route declared in PHP:
1039
+
1040
+ // resources/js/app/admin/middleware.ts
1041
+ export const middleware = ['auth', 'verified', 'can:update,post']
1042
+
1043
+ Server actions are classes in app/Rsc/Actions; \`php artisan
1044
+ rsc:action-manifest\` (already in the dev/build scripts) writes the map and
1045
+ the build writes server-actions.generated.ts beside the app - import
1046
+ ordersCancel from it in a client component. Rsc::revalidate('orders') in the
1047
+ action returns the re-rendered region with the answer.
1048
+
1049
+ php artisan serve is one worker, which deadlocks the proxy - unless
1050
+ PHP_CLI_SERVER_WORKERS=4 in .env AND serve --no-reload (Laravel ignores
1051
+ the variable otherwise). Herd, Valet, FPM, Octane are fine as they are. Production: put the renderer in front (bun
1052
+ .output/server/index.mjs with the app's .env), restrict /__rsc/host-call at
1053
+ the web server.
1054
+
1055
+ Any other language implements the same endpoint - request { function, args },
1056
+ reply { result | validationErrors | unauthenticated | unauthorized | redirect
1057
+ | error, revalidate }, answers '__rsc.middleware' with true or a refusal, and
1058
+ a batch { calls: [...] } with { replies: [{ status, ...reply }] } in order.
1059
+ Calls issued in the same render tick travel as one batch, so parallel reads
1060
+ are one backend request; the renderer falls back to single calls for a
1061
+ backend without batches.
1062
+
1063
+ Full guides: read_guide({ slug: 'backend-answered-pages' }), read_guide({ slug: 'laravel' }), read_guide({ slug: 'go' }), read_guide({ slug: 'your-own-backend' }).`,
1064
+ },
1065
+ {
1066
+ topic: 'startup',
1067
+ summary: 'Once-per-process setup - src/instrumentation.ts is imported before any page and its register() awaited before the first request',
1068
+ body: `Setup that belongs to the process - validating env, configuring a shared
1069
+ package, warming a connection - goes in src/instrumentation.ts. Do NOT import
1070
+ a bootstrap module from pages to get the same effect; it depends on nobody
1071
+ forgetting, and the failure is a page throwing "not configured" for whoever
1072
+ reaches it first.
1073
+
1074
+ // src/instrumentation.ts
1075
+ import './env' // refuses at import -> server fails at startup
1076
+ export async function register() { // optional; the first render waits for it
1077
+ await db.connect()
1078
+ }
1079
+
1080
+ The generated entry imports this file FIRST, so a package configured here is
1081
+ configured before any page module evaluates. register() is awaited by every
1082
+ entry point (server, dev, prerender, middleware, actions, api routes), once
1083
+ per process. On a server it runs at startup and a failure exits the process;
1084
+ on a Worker it runs at the isolate's first request.
1085
+
1086
+ Worker rule: read bindings INSIDE register(), not at the top of the module -
1087
+ process.env is empty until the first request arrives.
1088
+
1089
+ A scaffolded app with env validation already has this file importing ./env.
1090
+ Build machines without production variables: SKIP_ENV_VALIDATION=1.
1091
+
1092
+ Never NODE_ENV in a .env: Vite sets it (development under vite, production
1093
+ under vite build) and a .env line overrides it for the build, which then
1094
+ compiles pages against React's dev JSX runtime (jsxDEV) and every route
1095
+ fails with React's opaque "message omitted in production builds". The build
1096
+ refuses this and names the file:line. A plugin cannot override it (Vite
1097
+ applies the .env value after plugins run), so remove the line - other tools
1098
+ that want it keep it in their own .env.
1099
+
1100
+ Full guide: read_guide({ slug: 'instrumentation' }).`,
1101
+ },
1102
+ {
1103
+ topic: 'bun',
1104
+ summary: 'Running on Bun - Vite on Bun\'s runtime (bun --bun vite), native deps external (serverExternalPackages), and the gotchas that are Bun\'s not ours',
1105
+ body: `The vite bin has a node shebang: \`bun run dev\` alone starts Vite - dev
1106
+ server, build, prerender - under NODE, and an app importing 'bun' or
1107
+ 'bun:sqlite' fails at first render with "Cannot find package 'bun'". Scripts
1108
+ on a Bun app: dev "bun --bun vite", build "bun --bun vite build". createTestApp
1109
+ runs the project's build script on the runtime the tests use.
1110
+
1111
+ Native dependencies (sharp, bcrypt, better-sqlite3, @prisma/client, puppeteer,
1112
+ ...) are external to the server bundles by default; Nitro traces them into
1113
+ .output/server/node_modules with their binaries. Add one:
1114
+ rscKit({ serverExternalPackages: ['@acme/native'] }). Same as Next's option.
1115
+
1116
+ Bun's, not the framework's: bun test loads the package .env (use
1117
+ --env-file=/dev/null to isolate); Stripe's constructEvent throws on Bun
1118
+ (no sync WebCrypto) - use constructEventAsync; Bun's pg puts SQLSTATE in
1119
+ errno where Node's pg uses code.
1120
+
1121
+ Never NODE_ENV in a .env (the build refuses it, naming the line). Build
1122
+ machines without secrets: SKIP_ENV_VALIDATION=1.
1123
+
1124
+ Full guide: read_guide({ slug: 'bun' }).`,
1125
+ },
1126
+ {
1127
+ topic: 'env',
1128
+ summary: 'Typed environment variables - src/env.ts with @t3-oss/env-core in the app\'s validation library; refused at startup by name. Never NODE_ENV in .env',
1129
+ body: `A scaffolded app has src/env.ts when it said yes to typed environment
1130
+ variables (create-rsc-kit --env, with --validation=zod|valibot|arktype). To
1131
+ add it to an app without one: install @t3-oss/env-core and write the same file.
1132
+
1133
+ \`\`\`ts
1134
+ // src/env.ts
1135
+ import * as z from 'zod' // or valibot / arktype - any Standard Schema library
1136
+ import { createEnv } from '@t3-oss/env-core'
1137
+
1138
+ export const env = createEnv({
1139
+ server: {
1140
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
1141
+ DATABASE_URL: z.url(),
1142
+ SESSION_SECRET: z.string().min(1),
1143
+ },
1144
+ clientPrefix: 'PUBLIC_',
1145
+ client: { PUBLIC_SITE_URL: z.url() },
1146
+ runtimeEnv: { ...process.env, ...import.meta.env },
1147
+ emptyStringAsUndefined: true,
1148
+ })
1149
+ \`\`\`
1150
+
1151
+ Read env.DATABASE_URL, never process.env.DATABASE_URL: the first is typed and
1152
+ was checked at startup (a missing or malformed one fails then, with its name),
1153
+ the second is string | undefined. A server variable never reaches the browser;
1154
+ a browser-readable one MUST start with PUBLIC_ and is read from import.meta.env
1155
+ (Vite), which is why runtimeEnv merges both. Commit .env.example, not .env.
1156
+
1157
+ Next: NEXT_PUBLIC_* becomes PUBLIC_*; @t3-oss/env-nextjs becomes
1158
+ @t3-oss/env-core with runtimeEnv as above (env-nextjs's experimental__runtimeEnv
1159
+ is not needed).`,
1160
+ },
1161
+ {
1162
+ topic: 'live-data',
1163
+ summary: 'A value that keeps changing - realtime, live updates: usePolling over a query, or server-sent events (SSE, streaming from a middleware.ts generator) with useEvents - both feed TanStack, SWR or setState',
1164
+ body: `Neither is part of query() - a query answers once and is cacheable.
1165
+
1166
+ POLLING - start here when you have no change feed yet. Reuses the query,
1167
+ goes through its Cache-Control (a CDN collapses many tabs into one origin
1168
+ read per interval), pauses when the tab is hidden, never overlaps two reads.
1169
+ \`\`\`tsx
1170
+ import { usePolling } from '@rsc-kit/core/usePolling'
1171
+ const { data, status, refresh } = usePolling(() => fetchQuery(getSeats), { every: 2_000 })
1172
+ \`\`\`
1173
+ UNTIL IT SETTLES - a job that ends. until(data) says the last read; onSettled
1174
+ fires once on it. The result is the DATA; what to
1175
+ do on settling is the page's choice:
1176
+ // a server-rendered list, some jobs still running: re-render through the server
1177
+ usePolling(() => fetchQuery(jobStatus, [id]), { every: 2_000, enabled: !isTerminal(job), until: isTerminal, onSettled: () => refresh('page') })
1178
+ // the page that owns the job's state machine: the value in hand
1179
+ const { data, status } = usePolling(read, { every: 1_500, until: isTerminal, onSettled: (f) => dispatch(f.status) })
1180
+ Settled = stopped until refresh() or the inputs change. status: 'reading' |
1181
+ 'paused' | 'settled' | 'idle'.
1182
+
1183
+ SERVER-SENT EVENTS - when something can push. Better per update (bytes only
1184
+ on change, instant), but holds a connection per open tab (fine on Bun/Node,
1185
+ a limit on Workers or a small container), is uncacheable, and needs a source
1186
+ of change to yield from - a generator that polls the DB itself just moved the
1187
+ polling. An ordinary route.ts: beside its pages, runs middleware.ts above it.
1188
+ \`\`\`ts
1189
+ // src/app/api/orders/[id]/events/route.ts
1190
+ import { events, named } from '@rsc-kit/core/events'
1191
+ export const GET = events(async function* ({ params, signal }) {
1192
+ const { id } = await params
1193
+ for await (const status of orderStatus(id, { signal })) yield { status }
1194
+ // yield named('paid', order, { id: order.id }) names a message / gives an id
1195
+ })
1196
+ \`\`\`
1197
+ \`\`\`tsx
1198
+ import { useEvents } from '@rsc-kit/core/useEvents'
1199
+ const { latest, all, status, close } = useEvents<Status>(\`/api/orders/\${id}/events\`)
1200
+ // <Status> is the message type the route yields; a url infers nothing, so
1201
+ // without it latest is unknown. Declare the type beside the route, import both sides.
1202
+ \`\`\`
1203
+ events() frames JSON, sends a keepalive, sets text/event-stream + no-store,
1204
+ ends the generator on disconnect (signal). EventSource reconnects itself and
1205
+ resumes with Last-Event-ID when you yielded ids.
1206
+
1207
+ NO LIBRARY NEEDED. Both hooks ARE state: read data (polling) or latest
1208
+ (events) and render it. Neither needs TanStack or SWR.
1209
+ const { latest } = useEvents<Status>(url); const current = latest ?? initial // the server value until the first message
1210
+ WITH A STORE - when the value already lives somewhere, hand every value on so
1211
+ that stays the truth: a useState, a reducer, or a cache library:
1212
+ useEvents<Order>(url, { onMessage: setOrder }) // useState
1213
+ useEvents<Order>(url, { onMessage: (m) => dispatch({ type: 'update', m }) }) // reducer
1214
+ useEvents<Order>(url, { onMessage: (m) => queryClient.setQueryData(['order', id], m) }) // TanStack
1215
+ useEvents<Order>(url, { onMessage: (m) => mutate(['order', id], m, false) }) // SWR
1216
+ usePolling(read, { every, onData: setSeats })
1217
+ STABLE CALLBACKS: a callback a timer/subscription/listener calls that must see
1218
+ the latest props is useEffectEvent from React (19.2+), never a ref assigned
1219
+ each render, and never in a dependency array. Both hooks are built on it.
1220
+
1221
+ ERRORS: both hooks expose error as state AND fire onError - a failed poll read
1222
+ (the next interval still reads) or a dropped stream (EventSource reconnects
1223
+ itself). Use onError for a toast/log; do NOT watch error in a useEffect.
1224
+ usePolling's onError gets (error, { failures }) - failed reads in a row, reset
1225
+ by a success - so toast on the third, not the first.
1226
+ fetchQuery(query, args) types args from the query: fetchQuery(getSeats) for a
1227
+ query that takes nothing, fetchQuery(status, [{ id }]) refused if the query's
1228
+ input has no id.
1229
+ Do NOT put a stream on query() or on a server action, and do NOT poll from
1230
+ inside an events() generator.
1231
+
1232
+ Full guides: read_guide({ slug: 'queries' }) for polling, read_guide({ slug:
1233
+ 'api-routes' }) for the streaming route.`,
885
1234
  },
886
1235
  {
887
1236
  topic: 'images',
@@ -1033,7 +1382,8 @@ export function howTo(topic) {
1033
1382
  // A near miss is common and worth answering rather than refusing: someone
1034
1383
  // asks for "form" or "queries" and means the obvious thing.
1035
1384
  RECIPES.find((r) => r.topic.startsWith(wanted) || wanted.startsWith(r.topic)) ??
1036
- RECIPES.find((r) => r.summary.toLowerCase().includes(wanted));
1385
+ // The summary is prose, so a multi-word ask is matched with its spaces back.
1386
+ RECIPES.find((r) => r.summary.toLowerCase().replace(/[-\s]+/g, ' ').includes(wanted.replace(/-/g, ' ')));
1037
1387
  if (!found)
1038
1388
  return `No topic "${topic}".\n\n${listTopics()}`;
1039
1389
  return `# ${found.topic} — ${found.summary}\n\n${found.body}`;