@rsc-kit/mcp 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/answers.js CHANGED
@@ -43,6 +43,8 @@ export function listRoutes(report, builtAt, now) {
43
43
  lines.push(`${route.url}${size} — ${MEANING[route.type] ?? route.type}`);
44
44
  if (route.reason)
45
45
  lines.push(` ${route.reason}`);
46
+ if (route.note)
47
+ lines.push(` ${route.note}`);
46
48
  }
47
49
  if (report.apis.length) {
48
50
  lines.push('', 'api routes:');
@@ -1 +1 @@
1
- {"version":3,"file":"answers.js","sourceRoot":"","sources":["../src/answers.ts"],"names":[],"mappings":"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","sourcesContent":["// The answers, as text, with no protocol in them.\n//\n// Separated from the server so they can be tested by calling them, and so the\n// wording is reviewable in one place. An agent reads these as prose and acts on\n// them, so a vague sentence here becomes a wrong edit somewhere else — \"this\n// route is dynamic\" invites a fix, \"this route reads cookies, which is why\"\n// invites the right one.\n\nimport type { BuildReport, ReportedApiRoute, ReportedRoute } from './report.js'\nimport { MEANING, routeFor } from './report.js'\n\nconst kb = (bytes: number) => `${bytes < 10_000 ? (bytes / 1000).toFixed(1) : Math.round(bytes / 1000)} kB`\n\nconst age = (builtAt: Date, now: number): string => {\n const minutes = Math.round((now - builtAt.getTime()) / 60_000)\n\n if (minutes < 1) return 'just now'\n if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`\n\n const hours = Math.round(minutes / 60)\n\n if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`\n\n return `${Math.round(hours / 24)} days ago`\n}\n\n/**\n * Every answer says how old it is.\n *\n * The one way this server misleads is by being confidently stale: it reports\n * the last build, and the file on disk may have changed since. Saying so on\n * every answer is cheaper than being wrong once.\n */\nexport function asOf(builtAt: Date, now: number): string {\n return `(from the last build, ${age(builtAt, now)})`\n}\n\nexport function listRoutes(report: BuildReport, builtAt: Date, now: number): string {\n const lines = [\n `${report.routes.length} routes and ${report.apis.length} api routes ${asOf(builtAt, now)}`,\n '',\n ]\n\n // First, before the table, because it changes what the table means: these\n // rows are from a build that did not finish, and nothing below is deployed.\n if (report.totals.failed > 0) {\n lines.unshift(\n `THE LAST BUILD FAILED: ${report.totals.failed} route${report.totals.failed === 1 ? '' : 's'} refused. Each one's line below says what to change. Fix it and build again.`,\n '',\n )\n }\n\n for (const route of report.routes) {\n const size = route.clientJs === null ? '' : ` ${kb(route.clientJs)}`\n\n lines.push(`${route.url}${size} — ${MEANING[route.type] ?? route.type}`)\n\n if (route.reason) lines.push(` ${route.reason}`)\n }\n\n if (report.apis.length) {\n lines.push('', 'api routes:')\n\n for (const api of report.apis) {\n lines.push(`${api.url} — ${MEANING[api.type] ?? api.type}`)\n\n if (api.reason) lines.push(` ${api.reason}`)\n }\n }\n\n lines.push(\n '',\n `${report.totals.static} static, ${report.totals.partial} partial prerender, ${report.totals.dynamic} dynamic` +\n (report.totals.failed ? `, ${report.totals.failed} failed` : ''),\n )\n\n lines.push('', ...actionLines(report))\n\n return lines.join('\\n')\n}\n\nfunction isPage(route: ReportedRoute | ReportedApiRoute): route is ReportedRoute {\n return 'component' in route\n}\n\nexport function explainRoute(\n report: BuildReport,\n url: string,\n builtAt: Date,\n now: number,\n): string {\n const route = routeFor(report, url)\n\n if (!route) {\n // The urls, not just \"not found\": the caller has a url that does not exist,\n // and the most useful next thing is the ones that do.\n return (\n `No route for ${url} ${asOf(builtAt, now)}.\\n\\n` +\n 'Known urls:\\n' +\n [...report.routes, ...report.apis].map((r) => ` ${r.url}`).join('\\n')\n )\n }\n\n const lines = [`${route.url} — ${MEANING[route.type] ?? route.type} ${asOf(builtAt, now)}`]\n\n if (isPage(route)) {\n lines.push(`Rendered by ${route.component}.`)\n\n if (route.clientJs !== null) {\n lines.push(`Ships ${kb(route.clientJs)} of javascript, gzipped.`)\n }\n }\n\n if (route.reason) lines.push('', `Why it is not stored whole: ${route.reason}`)\n\n if (isPage(route) && route.warning) lines.push('', `Warning: ${route.warning}`)\n\n if (route.type === 'frozen') {\n lines.push(\n '',\n 'Nothing to fix. It is rendered once at build time and served as a file.',\n )\n }\n\n return lines.join('\\n')\n}\n\n/**\n * The routes that are not stored, and why.\n *\n * The question behind most of the others — someone asking \"why is my site\n * slow\" wants this list, not the whole table. Routes that are fine are left\n * out entirely rather than listed and dismissed.\n */\nexport function whatIsDynamic(report: BuildReport, builtAt: Date, now: number): string {\n const pages = report.routes.filter((r) => r.type !== 'frozen')\n const apis = report.apis.filter((a) => a.type !== 'frozen')\n\n if (pages.length === 0 && apis.length === 0) {\n return `Every route is stored at build time ${asOf(builtAt, now)}. Nothing renders per request.`\n }\n\n const lines = [\n `${pages.length + apis.length} of ${report.routes.length + report.apis.length} routes render per request ${asOf(builtAt, now)}:`,\n '',\n ]\n\n for (const route of [...pages, ...apis]) {\n lines.push(`${route.url} — ${route.reason ?? MEANING[route.type] ?? route.type}`)\n }\n\n lines.push(\n '',\n 'Reading the request is what makes a route dynamic: cookies(), headers(), searchParams(),',\n 'or connection() said deliberately. That is usually correct — a page whose content depends',\n 'on who is asking cannot be one stored file. Change it only if the read was accidental.',\n )\n\n return lines.join('\\n')\n}\n\n/** The heaviest routes, for the question that follows the size column. */\nexport function heaviestRoutes(report: BuildReport, builtAt: Date, now: number, top = 10): string {\n const weighed = report.routes\n .filter((r) => r.clientJs !== null)\n .sort((a, b) => (b.clientJs ?? 0) - (a.clientJs ?? 0))\n\n if (weighed.length === 0) {\n return `No route shipped measurable javascript ${asOf(builtAt, now)}.`\n }\n\n const lightest = weighed[weighed.length - 1].clientJs ?? 0\n\n return [\n `Heaviest routes ${asOf(builtAt, now)}:`,\n '',\n ...weighed.slice(0, top).map((r) => `${kb(r.clientJs ?? 0)} ${r.url}`),\n '',\n `The lightest route ships ${kb(lightest)}, so the difference between them is`,\n `${kb((weighed[0].clientJs ?? 0) - lightest)} of client components — most of the rest is React itself,`,\n 'which every route pays for.',\n ].join('\\n')\n}\n\n/**\n * The actions, and the one fact about each that nothing else states: whether\n * anything checks who calls it. A bare \"use server\" export runs with no\n * middleware; an agent adding a delete button needs to know that before it\n * trusts the id it was handed.\n */\nexport function actionLines(report: BuildReport): string[] {\n const actions = report.actions\n\n if (!actions) return ['actions: not audited by this build (older @rsc-kit/core)']\n if (actions.length === 0) return ['actions: none']\n\n const bare = actions.filter((a) => !a.client)\n const lines = [`actions: ${actions.length}, ${actions.length - bare.length} built from an action client`]\n\n if (bare.length > 0) {\n lines.push(\n `${bare.length} run NO middleware — nothing checks who calls them: ` +\n bare.map((a) => `${a.name}${a.query ? ' (query)' : ''} in ${a.file}`).join(', '),\n 'Fine for a public action. For anything else, build it from an action client so the check cannot be forgotten — how_to({ topic: \"action-client\" }).',\n )\n }\n\n return lines\n}\n"]}
1
+ {"version":3,"file":"answers.js","sourceRoot":"","sources":["../src/answers.ts"],"names":[],"mappings":"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","sourcesContent":["// The answers, as text, with no protocol in them.\n//\n// Separated from the server so they can be tested by calling them, and so the\n// wording is reviewable in one place. An agent reads these as prose and acts on\n// them, so a vague sentence here becomes a wrong edit somewhere else — \"this\n// route is dynamic\" invites a fix, \"this route reads cookies, which is why\"\n// invites the right one.\n\nimport type { BuildReport, ReportedApiRoute, ReportedRoute } from './report.js'\nimport { MEANING, routeFor } from './report.js'\n\nconst kb = (bytes: number) => `${bytes < 10_000 ? (bytes / 1000).toFixed(1) : Math.round(bytes / 1000)} kB`\n\nconst age = (builtAt: Date, now: number): string => {\n const minutes = Math.round((now - builtAt.getTime()) / 60_000)\n\n if (minutes < 1) return 'just now'\n if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`\n\n const hours = Math.round(minutes / 60)\n\n if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`\n\n return `${Math.round(hours / 24)} days ago`\n}\n\n/**\n * Every answer says how old it is.\n *\n * The one way this server misleads is by being confidently stale: it reports\n * the last build, and the file on disk may have changed since. Saying so on\n * every answer is cheaper than being wrong once.\n */\nexport function asOf(builtAt: Date, now: number): string {\n return `(from the last build, ${age(builtAt, now)})`\n}\n\nexport function listRoutes(report: BuildReport, builtAt: Date, now: number): string {\n const lines = [\n `${report.routes.length} routes and ${report.apis.length} api routes ${asOf(builtAt, now)}`,\n '',\n ]\n\n // First, before the table, because it changes what the table means: these\n // rows are from a build that did not finish, and nothing below is deployed.\n if (report.totals.failed > 0) {\n lines.unshift(\n `THE LAST BUILD FAILED: ${report.totals.failed} route${report.totals.failed === 1 ? '' : 's'} refused. Each one's line below says what to change. Fix it and build again.`,\n '',\n )\n }\n\n for (const route of report.routes) {\n const size = route.clientJs === null ? '' : ` ${kb(route.clientJs)}`\n\n lines.push(`${route.url}${size} — ${MEANING[route.type] ?? route.type}`)\n\n if (route.reason) lines.push(` ${route.reason}`)\n if (route.note) lines.push(` ${route.note}`)\n }\n\n if (report.apis.length) {\n lines.push('', 'api routes:')\n\n for (const api of report.apis) {\n lines.push(`${api.url} — ${MEANING[api.type] ?? api.type}`)\n\n if (api.reason) lines.push(` ${api.reason}`)\n }\n }\n\n lines.push(\n '',\n `${report.totals.static} static, ${report.totals.partial} partial prerender, ${report.totals.dynamic} dynamic` +\n (report.totals.failed ? `, ${report.totals.failed} failed` : ''),\n )\n\n lines.push('', ...actionLines(report))\n\n return lines.join('\\n')\n}\n\nfunction isPage(route: ReportedRoute | ReportedApiRoute): route is ReportedRoute {\n return 'component' in route\n}\n\nexport function explainRoute(\n report: BuildReport,\n url: string,\n builtAt: Date,\n now: number,\n): string {\n const route = routeFor(report, url)\n\n if (!route) {\n // The urls, not just \"not found\": the caller has a url that does not exist,\n // and the most useful next thing is the ones that do.\n return (\n `No route for ${url} ${asOf(builtAt, now)}.\\n\\n` +\n 'Known urls:\\n' +\n [...report.routes, ...report.apis].map((r) => ` ${r.url}`).join('\\n')\n )\n }\n\n const lines = [`${route.url} — ${MEANING[route.type] ?? route.type} ${asOf(builtAt, now)}`]\n\n if (isPage(route)) {\n lines.push(`Rendered by ${route.component}.`)\n\n if (route.clientJs !== null) {\n lines.push(`Ships ${kb(route.clientJs)} of javascript, gzipped.`)\n }\n }\n\n if (route.reason) lines.push('', `Why it is not stored whole: ${route.reason}`)\n\n if (isPage(route) && route.warning) lines.push('', `Warning: ${route.warning}`)\n\n if (route.type === 'frozen') {\n lines.push(\n '',\n 'Nothing to fix. It is rendered once at build time and served as a file.',\n )\n }\n\n return lines.join('\\n')\n}\n\n/**\n * The routes that are not stored, and why.\n *\n * The question behind most of the others — someone asking \"why is my site\n * slow\" wants this list, not the whole table. Routes that are fine are left\n * out entirely rather than listed and dismissed.\n */\nexport function whatIsDynamic(report: BuildReport, builtAt: Date, now: number): string {\n const pages = report.routes.filter((r) => r.type !== 'frozen')\n const apis = report.apis.filter((a) => a.type !== 'frozen')\n\n if (pages.length === 0 && apis.length === 0) {\n return `Every route is stored at build time ${asOf(builtAt, now)}. Nothing renders per request.`\n }\n\n const lines = [\n `${pages.length + apis.length} of ${report.routes.length + report.apis.length} routes render per request ${asOf(builtAt, now)}:`,\n '',\n ]\n\n for (const route of [...pages, ...apis]) {\n lines.push(`${route.url} — ${route.reason ?? MEANING[route.type] ?? route.type}`)\n }\n\n lines.push(\n '',\n 'Reading the request is what makes a route dynamic: cookies(), headers(), searchParams(),',\n 'or connection() said deliberately. That is usually correct — a page whose content depends',\n 'on who is asking cannot be one stored file. Change it only if the read was accidental.',\n )\n\n return lines.join('\\n')\n}\n\n/** The heaviest routes, for the question that follows the size column. */\nexport function heaviestRoutes(report: BuildReport, builtAt: Date, now: number, top = 10): string {\n const weighed = report.routes\n .filter((r) => r.clientJs !== null)\n .sort((a, b) => (b.clientJs ?? 0) - (a.clientJs ?? 0))\n\n if (weighed.length === 0) {\n return `No route shipped measurable javascript ${asOf(builtAt, now)}.`\n }\n\n const lightest = weighed[weighed.length - 1].clientJs ?? 0\n\n return [\n `Heaviest routes ${asOf(builtAt, now)}:`,\n '',\n ...weighed.slice(0, top).map((r) => `${kb(r.clientJs ?? 0)} ${r.url}`),\n '',\n `The lightest route ships ${kb(lightest)}, so the difference between them is`,\n `${kb((weighed[0].clientJs ?? 0) - lightest)} of client components — most of the rest is React itself,`,\n 'which every route pays for.',\n ].join('\\n')\n}\n\n/**\n * The actions, and the one fact about each that nothing else states: whether\n * anything checks who calls it. A bare \"use server\" export runs with no\n * middleware; an agent adding a delete button needs to know that before it\n * trusts the id it was handed.\n */\nexport function actionLines(report: BuildReport): string[] {\n const actions = report.actions\n\n if (!actions) return ['actions: not audited by this build (older @rsc-kit/core)']\n if (actions.length === 0) return ['actions: none']\n\n const bare = actions.filter((a) => !a.client)\n const lines = [`actions: ${actions.length}, ${actions.length - bare.length} built from an action client`]\n\n if (bare.length > 0) {\n lines.push(\n `${bare.length} run NO middleware — nothing checks who calls them: ` +\n bare.map((a) => `${a.name}${a.query ? ' (query)' : ''} in ${a.file}`).join(', '),\n 'Fine for a public action. For anything else, build it from an action client so the check cannot be forgotten — how_to({ topic: \"action-client\" }).',\n )\n }\n\n return lines\n}\n"]}
@@ -8,8 +8,13 @@ export declare function toMarkdown(mdx: string, repoRoot: string): {
8
8
  entry: Omit<GuideEntry, 'slug'>;
9
9
  body: string;
10
10
  };
11
- /** Every guide under `from`, written as markdown into `into`, with an index. */
12
- export declare function bundleGuides(from: string, into: string, repoRoot: string): GuideEntry[];
11
+ /**
12
+ * Every page under each of `from`, written as markdown into `into`, with an
13
+ * index. The top-level pages - installation, coming from Next - and the
14
+ * guides land in one flat list, because a slug is what an agent asks for and
15
+ * which directory the site keeps it in is not its concern.
16
+ */
17
+ export declare function bundleGuides(from: string | string[], into: string, repoRoot: string): GuideEntry[];
13
18
  /** Where the bundled guides are, beside dist — or nowhere, before a build. */
14
19
  export declare function guidesDir(): string | null;
15
20
  export declare function listGuides(): string;
@@ -55,17 +55,25 @@ export function toMarkdown(mdx, repoRoot) {
55
55
  body: `# ${title}\n\n${description ? `> ${description}\n\n` : ''}${body.trim()}\n`,
56
56
  };
57
57
  }
58
- /** Every guide under `from`, written as markdown into `into`, with an index. */
58
+ /**
59
+ * Every page under each of `from`, written as markdown into `into`, with an
60
+ * index. The top-level pages - installation, coming from Next - and the
61
+ * guides land in one flat list, because a slug is what an agent asks for and
62
+ * which directory the site keeps it in is not its concern.
63
+ */
59
64
  export function bundleGuides(from, into, repoRoot) {
60
65
  rmSync(into, { recursive: true, force: true });
61
66
  mkdirSync(into, { recursive: true });
62
67
  const index = [];
63
- for (const file of readdirSync(from).filter((f) => f.endsWith('.mdx')).sort()) {
64
- const slug = basename(file, '.mdx');
65
- const { entry, body } = toMarkdown(readFileSync(join(from, file), 'utf-8'), repoRoot);
66
- writeFileSync(join(into, `${slug}.md`), body);
67
- index.push({ slug, ...entry });
68
+ for (const dir of Array.isArray(from) ? from : [from]) {
69
+ for (const file of readdirSync(dir).filter((f) => f.endsWith('.mdx')).sort()) {
70
+ const slug = basename(file, '.mdx');
71
+ const { entry, body } = toMarkdown(readFileSync(join(dir, file), 'utf-8'), repoRoot);
72
+ writeFileSync(join(into, `${slug}.md`), body);
73
+ index.push({ slug, ...entry });
74
+ }
68
75
  }
76
+ index.sort((a, b) => a.slug.localeCompare(b.slug));
69
77
  writeFileSync(join(into, 'index.json'), JSON.stringify(index, null, 2) + '\n');
70
78
  return index;
71
79
  }
@@ -1 +1 @@
1
- {"version":3,"file":"bundleGuides.js","sourceRoot":"","sources":["../src/bundleGuides.ts"],"names":[],"mappings":"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","sourcesContent":["// The guides, as files an agent can read without leaving its editor.\n//\n// how_to answers are short and opinionated, and they are copies — of the\n// guides at rsc-kit.dev, by hand, which is how a recipe once said <Form> worked\n// without javascript when it did not yet. The guides are the source. This\n// bundles them into the package at build time so read_guide answers with the\n// same text the site shows, and a recipe can point at the full version instead\n// of restating it.\n//\n// MDX to markdown is three edits: the frontmatter becomes a heading, the\n// component imports go, and <CodeFromFile> becomes the code it names — cut the\n// same way the site cuts it, region and all.\n\nimport { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'\nimport { basename, extname, join } from 'node:path'\n\nexport interface GuideEntry {\n slug: string\n title: string\n description: string\n}\n\nconst FRONTMATTER = /^---\\n([\\s\\S]*?)\\n---\\n/\nconst COMPONENT_IMPORT = /^import .* from [\"']@\\/components\\/.*[\"'];?\\n/gm\nconst CODE_FROM_FILE = /<CodeFromFile\\s+([^>]*?)\\/>/g\n\nfunction attribute(attrs: string, name: string): string | undefined {\n return new RegExp(`${name}=\"([^\"]*)\"`).exec(attrs)?.[1]\n}\n\nfunction frontmatterField(block: string, name: string): string {\n const match = new RegExp(`^${name}:\\\\s*(.*)$`, 'm').exec(block)\n\n return (match?.[1] ?? '').trim().replace(/^[\"'](.*)[\"']$/, '$1')\n}\n\n/** The lines between `#region <name>` and its `#endregion`, dedented. */\nfunction region(source: string, name: string, file: string): string {\n const lines = source.split('\\n')\n const start = lines.findIndex((line) => new RegExp(`#region\\\\s+${name}\\\\b`).test(line))\n\n if (start === -1) throw new Error(`No region \"${name}\" in ${file}`)\n\n const end = lines.findIndex((line, i) => i > start && /#endregion\\b/.test(line))\n\n if (end === -1) throw new Error(`Region \"${name}\" in ${file} is never closed`)\n\n const body = lines.slice(start + 1, end).filter((line) => !/#(region|endregion)\\b/.test(line))\n const indent = Math.min(...body.filter((l) => l.trim()).map((l) => /^\\s*/.exec(l)![0].length))\n\n return body.map((line) => line.slice(indent)).join('\\n')\n}\n\n/** One guide's MDX as markdown, with the samples it names inlined. */\nexport function toMarkdown(mdx: string, repoRoot: string): { entry: Omit<GuideEntry, 'slug'>; body: string } {\n const fm = FRONTMATTER.exec(mdx)\n const title = fm ? frontmatterField(fm[1]!, 'title') : ''\n const description = fm ? frontmatterField(fm[1]!, 'description') : ''\n\n let body = mdx.replace(FRONTMATTER, '').replace(COMPONENT_IMPORT, '')\n\n body = body.replace(CODE_FROM_FILE, (_, attrs: string) => {\n const file = attribute(attrs, 'file')!\n const source = readFileSync(join(repoRoot, file), 'utf-8')\n const name = attribute(attrs, 'region')\n const code = name ? region(source, name, file) : source.trimEnd()\n const lang = attribute(attrs, 'lang') ?? extname(file).slice(1)\n const heading = attribute(attrs, 'title') ?? file\n\n return `\\`\\`\\`${lang} title=\"${heading}\"\\n${code}\\n\\`\\`\\``\n })\n\n return {\n entry: { title, description },\n body: `# ${title}\\n\\n${description ? `> ${description}\\n\\n` : ''}${body.trim()}\\n`,\n }\n}\n\n/** Every guide under `from`, written as markdown into `into`, with an index. */\nexport function bundleGuides(from: string, into: string, repoRoot: string): GuideEntry[] {\n rmSync(into, { recursive: true, force: true })\n mkdirSync(into, { recursive: true })\n\n const index: GuideEntry[] = []\n\n for (const file of readdirSync(from).filter((f) => f.endsWith('.mdx')).sort()) {\n const slug = basename(file, '.mdx')\n const { entry, body } = toMarkdown(readFileSync(join(from, file), 'utf-8'), repoRoot)\n\n writeFileSync(join(into, `${slug}.md`), body)\n index.push({ slug, ...entry })\n }\n\n writeFileSync(join(into, 'index.json'), JSON.stringify(index, null, 2) + '\\n')\n\n return index\n}\n\n/** Where the bundled guides are, beside dist — or nowhere, before a build. */\nexport function guidesDir(): string | null {\n const dir = new URL('../guides/', import.meta.url).pathname\n\n return existsSync(join(dir, 'index.json')) ? dir : null\n}\n\nexport function listGuides(): string {\n const dir = guidesDir()\n\n if (!dir) return 'No guides are bundled in this install. They are at https://rsc-kit.dev.'\n\n const index = JSON.parse(readFileSync(join(dir, 'index.json'), 'utf-8')) as GuideEntry[]\n const width = Math.max(...index.map((g) => g.slug.length))\n\n return [\n 'The guides, as published. Read one with read_guide({ slug }).',\n '',\n ...index.map((g) => `${g.slug.padEnd(width)} ${g.description || g.title}`),\n ].join('\\n')\n}\n\nexport function readGuide(slug: string): string {\n const dir = guidesDir()\n\n if (!dir) return `No guides are bundled in this install. This one is at https://rsc-kit.dev/guides/${slug}.`\n\n const file = join(dir, `${basename(slug)}.md`)\n\n if (!existsSync(file)) return `No guide called \"${slug}\". list_guides has the names.`\n\n return readFileSync(file, 'utf-8')\n}\n\n/**\n * Lines matching a phrase across every bundled guide, with the guide and a\n * little context. A grep, deliberately - the guides are 250 KB and an agent\n * asking \"where is fieldErrors mentioned\" wants the lines, not a ranking.\n */\nexport function searchGuides(phrase: string, limit = 40): string {\n const dir = guidesDir()\n\n if (!dir) return 'No guides are bundled in this install. Search https://rsc-kit.dev instead.'\n\n const needle = phrase.trim().toLowerCase()\n\n if (!needle) return 'Give a word or phrase to search for.'\n\n const index = JSON.parse(readFileSync(join(dir, 'index.json'), 'utf-8')) as GuideEntry[]\n const hits: string[] = []\n\n for (const { slug } of index) {\n const lines = readFileSync(join(dir, `${slug}.md`), 'utf-8').split('\\n')\n\n for (let i = 0; i < lines.length && hits.length < limit; i++) {\n if (!lines[i]!.toLowerCase().includes(needle)) continue\n\n hits.push(`${slug}:${i + 1} ${lines[i]!.trim()}`)\n }\n\n if (hits.length >= limit) break\n }\n\n if (hits.length === 0) return `Nothing in the guides mentions \"${phrase}\".`\n\n return [\n `${hits.length}${hits.length === limit ? '+' : ''} lines mention \"${phrase}\". Read a guide with read_guide({ slug }).`,\n '',\n ...hits,\n ].join('\\n')\n}\n"]}
1
+ {"version":3,"file":"bundleGuides.js","sourceRoot":"","sources":["../src/bundleGuides.ts"],"names":[],"mappings":"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","sourcesContent":["// The guides, as files an agent can read without leaving its editor.\n//\n// how_to answers are short and opinionated, and they are copies — of the\n// guides at rsc-kit.dev, by hand, which is how a recipe once said <Form> worked\n// without javascript when it did not yet. The guides are the source. This\n// bundles them into the package at build time so read_guide answers with the\n// same text the site shows, and a recipe can point at the full version instead\n// of restating it.\n//\n// MDX to markdown is three edits: the frontmatter becomes a heading, the\n// component imports go, and <CodeFromFile> becomes the code it names — cut the\n// same way the site cuts it, region and all.\n\nimport { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'\nimport { basename, extname, join } from 'node:path'\n\nexport interface GuideEntry {\n slug: string\n title: string\n description: string\n}\n\nconst FRONTMATTER = /^---\\n([\\s\\S]*?)\\n---\\n/\nconst COMPONENT_IMPORT = /^import .* from [\"']@\\/components\\/.*[\"'];?\\n/gm\nconst CODE_FROM_FILE = /<CodeFromFile\\s+([^>]*?)\\/>/g\n\nfunction attribute(attrs: string, name: string): string | undefined {\n return new RegExp(`${name}=\"([^\"]*)\"`).exec(attrs)?.[1]\n}\n\nfunction frontmatterField(block: string, name: string): string {\n const match = new RegExp(`^${name}:\\\\s*(.*)$`, 'm').exec(block)\n\n return (match?.[1] ?? '').trim().replace(/^[\"'](.*)[\"']$/, '$1')\n}\n\n/** The lines between `#region <name>` and its `#endregion`, dedented. */\nfunction region(source: string, name: string, file: string): string {\n const lines = source.split('\\n')\n const start = lines.findIndex((line) => new RegExp(`#region\\\\s+${name}\\\\b`).test(line))\n\n if (start === -1) throw new Error(`No region \"${name}\" in ${file}`)\n\n const end = lines.findIndex((line, i) => i > start && /#endregion\\b/.test(line))\n\n if (end === -1) throw new Error(`Region \"${name}\" in ${file} is never closed`)\n\n const body = lines.slice(start + 1, end).filter((line) => !/#(region|endregion)\\b/.test(line))\n const indent = Math.min(...body.filter((l) => l.trim()).map((l) => /^\\s*/.exec(l)![0].length))\n\n return body.map((line) => line.slice(indent)).join('\\n')\n}\n\n/** One guide's MDX as markdown, with the samples it names inlined. */\nexport function toMarkdown(mdx: string, repoRoot: string): { entry: Omit<GuideEntry, 'slug'>; body: string } {\n const fm = FRONTMATTER.exec(mdx)\n const title = fm ? frontmatterField(fm[1]!, 'title') : ''\n const description = fm ? frontmatterField(fm[1]!, 'description') : ''\n\n let body = mdx.replace(FRONTMATTER, '').replace(COMPONENT_IMPORT, '')\n\n body = body.replace(CODE_FROM_FILE, (_, attrs: string) => {\n const file = attribute(attrs, 'file')!\n const source = readFileSync(join(repoRoot, file), 'utf-8')\n const name = attribute(attrs, 'region')\n const code = name ? region(source, name, file) : source.trimEnd()\n const lang = attribute(attrs, 'lang') ?? extname(file).slice(1)\n const heading = attribute(attrs, 'title') ?? file\n\n return `\\`\\`\\`${lang} title=\"${heading}\"\\n${code}\\n\\`\\`\\``\n })\n\n return {\n entry: { title, description },\n body: `# ${title}\\n\\n${description ? `> ${description}\\n\\n` : ''}${body.trim()}\\n`,\n }\n}\n\n/**\n * Every page under each of `from`, written as markdown into `into`, with an\n * index. The top-level pages - installation, coming from Next - and the\n * guides land in one flat list, because a slug is what an agent asks for and\n * which directory the site keeps it in is not its concern.\n */\nexport function bundleGuides(from: string | string[], into: string, repoRoot: string): GuideEntry[] {\n rmSync(into, { recursive: true, force: true })\n mkdirSync(into, { recursive: true })\n\n const index: GuideEntry[] = []\n\n for (const dir of Array.isArray(from) ? from : [from]) {\n for (const file of readdirSync(dir).filter((f) => f.endsWith('.mdx')).sort()) {\n const slug = basename(file, '.mdx')\n const { entry, body } = toMarkdown(readFileSync(join(dir, file), 'utf-8'), repoRoot)\n\n writeFileSync(join(into, `${slug}.md`), body)\n index.push({ slug, ...entry })\n }\n }\n\n index.sort((a, b) => a.slug.localeCompare(b.slug))\n\n writeFileSync(join(into, 'index.json'), JSON.stringify(index, null, 2) + '\\n')\n\n return index\n}\n\n/** Where the bundled guides are, beside dist — or nowhere, before a build. */\nexport function guidesDir(): string | null {\n const dir = new URL('../guides/', import.meta.url).pathname\n\n return existsSync(join(dir, 'index.json')) ? dir : null\n}\n\nexport function listGuides(): string {\n const dir = guidesDir()\n\n if (!dir) return 'No guides are bundled in this install. They are at https://rsc-kit.dev.'\n\n const index = JSON.parse(readFileSync(join(dir, 'index.json'), 'utf-8')) as GuideEntry[]\n const width = Math.max(...index.map((g) => g.slug.length))\n\n return [\n 'The guides, as published. Read one with read_guide({ slug }).',\n '',\n ...index.map((g) => `${g.slug.padEnd(width)} ${g.description || g.title}`),\n ].join('\\n')\n}\n\nexport function readGuide(slug: string): string {\n const dir = guidesDir()\n\n if (!dir) return `No guides are bundled in this install. This one is at https://rsc-kit.dev/guides/${slug}.`\n\n const file = join(dir, `${basename(slug)}.md`)\n\n if (!existsSync(file)) return `No guide called \"${slug}\". list_guides has the names.`\n\n return readFileSync(file, 'utf-8')\n}\n\n/**\n * Lines matching a phrase across every bundled guide, with the guide and a\n * little context. A grep, deliberately - the guides are 250 KB and an agent\n * asking \"where is fieldErrors mentioned\" wants the lines, not a ranking.\n */\nexport function searchGuides(phrase: string, limit = 40): string {\n const dir = guidesDir()\n\n if (!dir) return 'No guides are bundled in this install. Search https://rsc-kit.dev instead.'\n\n const needle = phrase.trim().toLowerCase()\n\n if (!needle) return 'Give a word or phrase to search for.'\n\n const index = JSON.parse(readFileSync(join(dir, 'index.json'), 'utf-8')) as GuideEntry[]\n const hits: string[] = []\n\n for (const { slug } of index) {\n const lines = readFileSync(join(dir, `${slug}.md`), 'utf-8').split('\\n')\n\n for (let i = 0; i < lines.length && hits.length < limit; i++) {\n if (!lines[i]!.toLowerCase().includes(needle)) continue\n\n hits.push(`${slug}:${i + 1} ${lines[i]!.trim()}`)\n }\n\n if (hits.length >= limit) break\n }\n\n if (hits.length === 0) return `Nothing in the guides mentions \"${phrase}\".`\n\n return [\n `${hits.length}${hits.length === limit ? '+' : ''} lines mention \"${phrase}\". Read a guide with read_guide({ slug }).`,\n '',\n ...hits,\n ].join('\\n')\n}\n"]}
package/dist/recipes.js CHANGED
@@ -472,20 +472,28 @@ person sent it twice.`,
472
472
  },
473
473
  {
474
474
  topic: 'no-javascript',
475
- summary: 'Shipping a route with no client runtime at all',
476
- body: `\`\`\`ts title="src/app/about/page.tsx"
477
- export const clientJs = false
478
- \`\`\`
475
+ summary: 'Shipping a route with no client runtime at all - automatic when nothing needs one',
476
+ body: `AUTOMATIC. A route that freezes whole, renders none of the app's client
477
+ components, and has no server action in its tree is stored without the
478
+ bootstrap. The build says so:
479
+
480
+ ○ /about no js
481
+ no client components, so ships no javascript
482
+
483
+ Nothing to declare. The check reads the rendered tree, so a <Link> in a shared
484
+ layout counts - most pages under a layout with a nav keep the runtime; a route
485
+ group with its own plain layout drops it. Navigation into such a page from a
486
+ Link elsewhere still works: its flight payload is written with the wrappers.
479
487
 
480
- The route ships no bootstrap and no client runtime. The build REFUSES it if the
481
- tree renders a client component, and names the component — they usually come
482
- from a shared layout rather than the page itself.
488
+ To force it OFF for a page the build would strip (it wants the service worker
489
+ or the update prompt): export const clientJs = true.
483
490
 
484
- Links still work; they are ordinary anchors, so navigation is a full page load.
491
+ To force it ON for a page that renders a client component you are content to
492
+ leave inert: export const clientJs = false. The build REFUSES that if the tree
493
+ renders a client component, and names it.
485
494
 
486
- Most pages do not need this. A page with nothing interactive already ships only
487
- the shared runtime, and the size column in the build output tells you what each
488
- one actually costs.`,
495
+ Do not restructure an app to chase this. The size column says what each route
496
+ costs; a page that is 82 kB because of one <Link> is fine.`,
489
497
  },
490
498
  {
491
499
  topic: 'api-routes',
@@ -684,6 +692,44 @@ import fraunces from '@fontsource-variable/fraunces/files/fraunces-latin-full-no
684
692
  needs; preloading all of them defeats the subsetting.
685
693
 
686
694
  Do NOT reach for next/font, @next/font or a Google Fonts link tag.`,
695
+ },
696
+ {
697
+ topic: 'from-next',
698
+ summary: 'Porting a Next.js app - what carries over, what to rename, what is different on purpose',
699
+ body: `The app/ conventions are the same: layout, page, loading, error, not-found,
700
+ route.ts, [slug], [...path], (group), @slot, (.)intercept. "use client" and
701
+ "use server" are React's. Copy src/app first, fix imports second.
702
+
703
+ IMPORTS
704
+ next/link -> @rsc-kit/core/Link (href typed; search typed by the page's schema)
705
+ useRouter().push / .replace -> visit(url) / visit(url, { replace: true }) from @rsc-kit/core/router
706
+ useRouter().refresh() -> refresh() from @rsc-kit/core/router, or revalidate() in the action
707
+ usePathname / useSearchParams-> @rsc-kit/core/usePathname, @rsc-kit/core/useSearchParams (nuqs: @rsc-kit/core/nuqs)
708
+ useParams() -> the page's params prop, passed down
709
+ cookies(), headers() -> same names, from @rsc-kit/core/request
710
+ redirect() / notFound() -> @rsc-kit/core/redirect / @rsc-kit/core/not-found
711
+ revalidatePath/Tag -> revalidate('tag') on a section() - targeted, rides back with the action
712
+ Metadata -> @rsc-kit/core/metadata (metadataBase, openGraph, twitter, icons as-is)
713
+ next/font -> Fontsource (how_to fonts)
714
+ next/image -> unpic or vite-imagetools (how_to images)
715
+ next/script -> a <script> tag (how_to scripts)
716
+ NEXT_PUBLIC_* -> VITE_* via import.meta.env; server vars stay process.env
717
+ next-safe-action -> createActionClient() (how_to action-client); returnValidationErrors -> return fieldErrors({...})
718
+
719
+ DIFFERENT ON PURPOSE
720
+ - No export const dynamic / revalidate = 60. A page is frozen unless it READS
721
+ the request; await connection() is the explicit mark. No time-based ISR.
722
+ - middleware.ts is per directory, on the server, full API; not one edge file.
723
+ It does not run for actions - the check goes in the action.
724
+ - Actions return failures ({ validationErrors }, { serverError }), not throw.
725
+ - No image optimizer, no opengraph-image.tsx - put opengraph-image.png in src/app.
726
+ - Tests need no browser: createTestApp() is the deployed handler.
727
+
728
+ ORDER: scaffold -> copy src/app -> fix imports -> typecheck -> build and READ
729
+ the output (a cookies() in a layout makes everything dynamic; the build says
730
+ so) -> decide each action the build lists as running no middleware -> check.
731
+
732
+ Full guide: read_guide({ slug: 'coming-from-next' }).`,
687
733
  },
688
734
  {
689
735
  topic: 'images',
@@ -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\nThe same schema types every LINK to that page. Write search params as an\nobject, never as a string:\n\n\\`\\`\\`tsx\n<Link href=\"/search\" search={{ q: 'shoes', page: 2 }}>…</Link> // typed by the page's schema\nvisit(href('/search', { q: 'shoes' })) // same check, as a string\n\\`\\`\\`\n\nA key the page never reads, or a number written as text, does not compile;\na key the page requires is required on the link. A page with no schema takes\nany scalars. Do NOT build \\`?q=\\${q}\\` by hand when the page has a schema.\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) return fieldErrors({ email: 'Account not found' })\n})\n\\`\\`\\`\n\nWRITE return fieldErrors(...). It throws either way, but TypeScript cannot see\na never-return through a destructured argument, so without the return the\nvalue you checked stays possibly-undefined on the next line. It lands in\nvalidationErrors on that field, the same place a schema refusal does. This is\nnext-safe-action's returnValidationErrors with no schema argument and no\n_errors nesting.\n\nA plain \"use server\" function with no action client imports the same thing,\nuntyped, from '@rsc-kit/core/action' - the engine converts the throw into the\nreturned { validationErrors } on the way out. Same rule: return fieldErrors(...).\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: 'images',\n summary: 'Responsive images with no optimizer - unpic for a CDN, imagetools for files in the repo',\n body: `There is NO image component and NO image server. Do not add next/image or\nwrite an optimizer route. next/image is a srcset-writing component plus a\nresize-on-request process; the first is a library, the second belongs to the\nCDN.\n\nAn image on a CDN (Cloudinary, imgix, Cloudflare Images, Bunny, Vercel,\nNetlify, ...): @unpic/react. Plain component, works in a server component,\nships no javascript, detects the CDN from the url:\n\n\\`\\`\\`tsx\nimport { Image } from '@unpic/react'\n\n<Image src=\"https://res.cloudinary.com/demo/image/upload/sample.jpg\" layout=\"constrained\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nA file in the repo, a handful of them: vite-imagetools, resized ONCE at build\ntime. Add imagetools() to the vite plugins, then:\n\n\\`\\`\\`tsx\nimport hero from '../hero.png?w=400;800;1200&format=webp&as=srcset'\nimport heroSrc from '../hero.png?w=800&format=webp'\n\n<img srcSet={hero} src={heroSrc} sizes=\"(min-width: 800px) 800px, 100vw\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nHundreds of files in the repo: that is a CDN's job; move them and use unpic.\nAn icon or a logo: a plain <img>, or inline the svg.\n\nFull guide: read_guide({ slug: 'images' }).`,\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**What to write when you add something.** Before running check, not after:\n\n- A guarded route (middleware.ts, or a page reading the session): a stranger\n is turned away, and someone signed in gets 200.\n \\`\\`\\`ts\n expect((await app.fetch('/admin', { redirect: 'manual' })).status).toBe(302)\n expect((await app.fetch('/admin', { headers: { Cookie: 'session=ada' } })).status).toBe(200)\n \\`\\`\\`\n- An action: its refusal, by calling it. Bad input answers validationErrors;\n a stranger answers serverError (or throws ServerAuthenticationError if you\n built it without the client).\n \\`\\`\\`ts\n expect((await createPost({ title: '' })).validationErrors).toBeDefined()\n \\`\\`\\`\n- An action that takes an id: someone else's id is refused. This is the IDOR\n test and the one most often missing.\n- A query: the shape of its answer, and what a filter changes.\n- An api route: status, content-type, and the 4xx it answers to a bad body.\n- A page that should stay static: assert on the build report - no test, a CI\n check that build-report.json still says frozen for it.\n\nDo NOT start a dev server, spawn a process or pick a port in a test. Do NOT\nadd a second runner. The one in tests/ goes through the real build already.`,\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\nThe same schema types every LINK to that page. Write search params as an\nobject, never as a string:\n\n\\`\\`\\`tsx\n<Link href=\"/search\" search={{ q: 'shoes', page: 2 }}>…</Link> // typed by the page's schema\nvisit(href('/search', { q: 'shoes' })) // same check, as a string\n\\`\\`\\`\n\nA key the page never reads, or a number written as text, does not compile;\na key the page requires is required on the link. A page with no schema takes\nany scalars. Do NOT build \\`?q=\\${q}\\` by hand when the page has a schema.\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) return fieldErrors({ email: 'Account not found' })\n})\n\\`\\`\\`\n\nWRITE return fieldErrors(...). It throws either way, but TypeScript cannot see\na never-return through a destructured argument, so without the return the\nvalue you checked stays possibly-undefined on the next line. It lands in\nvalidationErrors on that field, the same place a schema refusal does. This is\nnext-safe-action's returnValidationErrors with no schema argument and no\n_errors nesting.\n\nA plain \"use server\" function with no action client imports the same thing,\nuntyped, from '@rsc-kit/core/action' - the engine converts the throw into the\nreturned { validationErrors } on the way out. Same rule: return fieldErrors(...).\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 - automatic when nothing needs one',\n body: `AUTOMATIC. A route that freezes whole, renders none of the app's client\ncomponents, and has no server action in its tree is stored without the\nbootstrap. The build says so:\n\n ○ /about no js\n no client components, so ships no javascript\n\nNothing to declare. The check reads the rendered tree, so a <Link> in a shared\nlayout counts - most pages under a layout with a nav keep the runtime; a route\ngroup with its own plain layout drops it. Navigation into such a page from a\nLink elsewhere still works: its flight payload is written with the wrappers.\n\nTo force it OFF for a page the build would strip (it wants the service worker\nor the update prompt): export const clientJs = true.\n\nTo force it ON for a page that renders a client component you are content to\nleave inert: export const clientJs = false. The build REFUSES that if the tree\nrenders a client component, and names it.\n\nDo not restructure an app to chase this. The size column says what each route\ncosts; a page that is 82 kB because of one <Link> is fine.`,\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: 'from-next',\n summary: 'Porting a Next.js app - what carries over, what to rename, what is different on purpose',\n body: `The app/ conventions are the same: layout, page, loading, error, not-found,\nroute.ts, [slug], [...path], (group), @slot, (.)intercept. \"use client\" and\n\"use server\" are React's. Copy src/app first, fix imports second.\n\nIMPORTS\n next/link -> @rsc-kit/core/Link (href typed; search typed by the page's schema)\n useRouter().push / .replace -> visit(url) / visit(url, { replace: true }) from @rsc-kit/core/router\n useRouter().refresh() -> refresh() from @rsc-kit/core/router, or revalidate() in the action\n usePathname / useSearchParams-> @rsc-kit/core/usePathname, @rsc-kit/core/useSearchParams (nuqs: @rsc-kit/core/nuqs)\n useParams() -> the page's params prop, passed down\n cookies(), headers() -> same names, from @rsc-kit/core/request\n redirect() / notFound() -> @rsc-kit/core/redirect / @rsc-kit/core/not-found\n revalidatePath/Tag -> revalidate('tag') on a section() - targeted, rides back with the action\n Metadata -> @rsc-kit/core/metadata (metadataBase, openGraph, twitter, icons as-is)\n next/font -> Fontsource (how_to fonts)\n next/image -> unpic or vite-imagetools (how_to images)\n next/script -> a <script> tag (how_to scripts)\n NEXT_PUBLIC_* -> VITE_* via import.meta.env; server vars stay process.env\n next-safe-action -> createActionClient() (how_to action-client); returnValidationErrors -> return fieldErrors({...})\n\nDIFFERENT ON PURPOSE\n- No export const dynamic / revalidate = 60. A page is frozen unless it READS\n the request; await connection() is the explicit mark. No time-based ISR.\n- middleware.ts is per directory, on the server, full API; not one edge file.\n It does not run for actions - the check goes in the action.\n- Actions return failures ({ validationErrors }, { serverError }), not throw.\n- No image optimizer, no opengraph-image.tsx - put opengraph-image.png in src/app.\n- Tests need no browser: createTestApp() is the deployed handler.\n\nORDER: scaffold -> copy src/app -> fix imports -> typecheck -> build and READ\nthe output (a cookies() in a layout makes everything dynamic; the build says\nso) -> decide each action the build lists as running no middleware -> check.\n\nFull guide: read_guide({ slug: 'coming-from-next' }).`,\n },\n {\n topic: 'images',\n summary: 'Responsive images with no optimizer - unpic for a CDN, imagetools for files in the repo',\n body: `There is NO image component and NO image server. Do not add next/image or\nwrite an optimizer route. next/image is a srcset-writing component plus a\nresize-on-request process; the first is a library, the second belongs to the\nCDN.\n\nAn image on a CDN (Cloudinary, imgix, Cloudflare Images, Bunny, Vercel,\nNetlify, ...): @unpic/react. Plain component, works in a server component,\nships no javascript, detects the CDN from the url:\n\n\\`\\`\\`tsx\nimport { Image } from '@unpic/react'\n\n<Image src=\"https://res.cloudinary.com/demo/image/upload/sample.jpg\" layout=\"constrained\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nA file in the repo, a handful of them: vite-imagetools, resized ONCE at build\ntime. Add imagetools() to the vite plugins, then:\n\n\\`\\`\\`tsx\nimport hero from '../hero.png?w=400;800;1200&format=webp&as=srcset'\nimport heroSrc from '../hero.png?w=800&format=webp'\n\n<img srcSet={hero} src={heroSrc} sizes=\"(min-width: 800px) 800px, 100vw\" width={800} height={600} alt=\"...\" />\n\\`\\`\\`\n\nHundreds of files in the repo: that is a CDN's job; move them and use unpic.\nAn icon or a logo: a plain <img>, or inline the svg.\n\nFull guide: read_guide({ slug: 'images' }).`,\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**What to write when you add something.** Before running check, not after:\n\n- A guarded route (middleware.ts, or a page reading the session): a stranger\n is turned away, and someone signed in gets 200.\n \\`\\`\\`ts\n expect((await app.fetch('/admin', { redirect: 'manual' })).status).toBe(302)\n expect((await app.fetch('/admin', { headers: { Cookie: 'session=ada' } })).status).toBe(200)\n \\`\\`\\`\n- An action: its refusal, by calling it. Bad input answers validationErrors;\n a stranger answers serverError (or throws ServerAuthenticationError if you\n built it without the client).\n \\`\\`\\`ts\n expect((await createPost({ title: '' })).validationErrors).toBeDefined()\n \\`\\`\\`\n- An action that takes an id: someone else's id is refused. This is the IDOR\n test and the one most often missing.\n- A query: the shape of its answer, and what a filter changes.\n- An api route: status, content-type, and the 4xx it answers to a bad body.\n- A page that should stay static: assert on the build report - no test, a CI\n check that build-report.json still says frozen for it.\n\nDo NOT start a dev server, spawn a process or pick a port in a test. Do NOT\nadd a second runner. The one in tests/ goes through the real build already.`,\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/dist/report.d.ts CHANGED
@@ -4,6 +4,8 @@ export interface ReportedRoute {
4
4
  type: string;
5
5
  reason: string | null;
6
6
  warning: string | null;
7
+ /** How it was stored, when that is worth knowing - "no client components, so ships no javascript". */
8
+ note?: string | null;
7
9
  clientJs: number | null;
8
10
  }
9
11
  export interface ReportedApiRoute {
@@ -1 +1 @@
1
- {"version":3,"file":"report.js","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA,qCAAqC;AACrC,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,mDAAmD;AAEnD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAC5D,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAqCzC,mEAAmE;AACnE,MAAM,MAAM,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,CAAA;AAEnD,MAAM,OAAO,QAAS,SAAQ,KAAK;IACjC,YAAY,IAAY;QACtB,KAAK,CACH,yBAAyB,IAAI,iDAAiD;YAC5E,uEAAuE,CAC1E,CAAA;QACD,IAAI,CAAC,IAAI,GAAG,UAAU,CAAA;IACxB,CAAC;CACF;AAED,qCAAqC;AACrC,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE1B,KAAK,MAAM,GAAG,IAAI,MAAM,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,mBAAmB,CAAC,CAAA;QAEjD,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,SAAQ;QAE/B,OAAO;YACL,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAgB;YAC9D,0EAA0E;YAC1E,uEAAuE;YACvE,wDAAwD;YACxD,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,KAAK;YAC7B,IAAI,EAAE,IAAI;SACX,CAAA;IACH,CAAC;IAED,MAAM,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAA;AAC1B,CAAC;AAED,0FAA0F;AAC1F,MAAM,CAAC,MAAM,OAAO,GAA2B;IAC7C,MAAM,EAAE,iDAAiD;IACzD,KAAK,EAAE,oDAAoD;IAC3D,OAAO,EAAE,uFAAuF;IAChG,OAAO,EAAE,sBAAsB;IAC/B,KAAK,EAAE,kBAAkB;CAC1B,CAAA;AAED,8DAA8D;AAC9D,MAAM,UAAU,QAAQ,CAAC,MAAmB,EAAE,GAAW;IACvD,MAAM,MAAM,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,CAAA;IAC3E,MAAM,OAAO,GAAG,CAAC,SAAiB,EAAE,EAAE,CACpC,CAAC,SAAS,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,KAAK,MAAM,CAAA;IAEjE,OAAO,CACL,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,IAAI,CAC7F,CAAA;AACH,CAAC","sourcesContent":["// Reading what the build wrote down.\n//\n// Everything this server answers comes from one file: `build-report.json`, in\n// the build's own output directory. Nothing here runs a build, imports the\n// app, or re-derives the route tree — the build already decided all of this,\n// and a second implementation would be a second thing that can be wrong.\n//\n// The consequence to be honest about: an answer is only as fresh as the last\n// build. So every answer says when it was built, and a missing report says\n// \"run a build\" rather than \"there are no routes\".\n\nimport { existsSync, readFileSync, statSync } from 'node:fs'\nimport { join, resolve } from 'node:path'\n\nexport interface ReportedRoute {\n url: string\n component: string\n type: string\n reason: string | null\n warning: string | null\n clientJs: number | null\n}\n\nexport interface ReportedApiRoute {\n url: string\n name: string\n type: string\n reason: string | null\n}\n\nexport interface ReportedAction {\n id: string\n name: string\n file: string\n /** Built by createActionClient, so its middleware ran. */\n client: boolean\n /** A read (GET) rather than a mutation. */\n query: boolean\n}\n\nexport interface BuildReport {\n version: number\n routes: ReportedRoute[]\n apis: ReportedApiRoute[]\n /** Absent from reports written before actions were audited. */\n actions?: ReportedAction[]\n totals: { static: number; partial: number; dynamic: number; failed: number }\n}\n\n/** Where a build leaves its report, in the order worth looking. */\nconst LIKELY = ['.rsc', 'build', 'dist', '.output']\n\nexport class NoReport extends Error {\n constructor(root: string) {\n super(\n `No build report under ${root}. This server answers from what the last build ` +\n 'decided, so there has to have been one — run the build and ask again.',\n )\n this.name = 'NoReport'\n }\n}\n\n/** The report, and how old it is. */\nexport function loadReport(root: string): { report: BuildReport; builtAt: Date; from: string } {\n const base = resolve(root)\n\n for (const dir of LIKELY) {\n const file = join(base, dir, 'build-report.json')\n\n if (!existsSync(file)) continue\n\n return {\n report: JSON.parse(readFileSync(file, 'utf-8')) as BuildReport,\n // The file's own mtime rather than a timestamp inside it: a stamp written\n // into the file changes the file on every build even when nothing else\n // did, which defeats every cache keyed on its contents.\n builtAt: statSync(file).mtime,\n from: file,\n }\n }\n\n throw new NoReport(base)\n}\n\n/** What each classification means, in one line, for an answer that has to stand alone. */\nexport const MEANING: Record<string, string> = {\n frozen: 'stored whole at build time and served as a file',\n shell: 'a stored shell, with the rest rendered per request',\n blocked: 'REFUSED — nothing could paint before it read the request, so the build did not finish',\n dynamic: 'answered per request',\n error: 'failed to render',\n}\n\n/** A route by url, tolerating a trailing slash either way. */\nexport function routeFor(report: BuildReport, url: string): ReportedRoute | ReportedApiRoute | null {\n const wanted = (url.split('?')[0].replace(/\\/+$/, '') || '/').toLowerCase()\n const matches = (candidate: string) =>\n (candidate.replace(/\\/+$/, '') || '/').toLowerCase() === wanted\n\n return (\n report.routes.find((r) => matches(r.url)) ?? report.apis.find((a) => matches(a.url)) ?? null\n )\n}\n"]}
1
+ {"version":3,"file":"report.js","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA,qCAAqC;AACrC,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,mDAAmD;AAEnD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAC5D,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAuCzC,mEAAmE;AACnE,MAAM,MAAM,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,CAAA;AAEnD,MAAM,OAAO,QAAS,SAAQ,KAAK;IACjC,YAAY,IAAY;QACtB,KAAK,CACH,yBAAyB,IAAI,iDAAiD;YAC5E,uEAAuE,CAC1E,CAAA;QACD,IAAI,CAAC,IAAI,GAAG,UAAU,CAAA;IACxB,CAAC;CACF;AAED,qCAAqC;AACrC,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE1B,KAAK,MAAM,GAAG,IAAI,MAAM,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,mBAAmB,CAAC,CAAA;QAEjD,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,SAAQ;QAE/B,OAAO;YACL,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAgB;YAC9D,0EAA0E;YAC1E,uEAAuE;YACvE,wDAAwD;YACxD,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,KAAK;YAC7B,IAAI,EAAE,IAAI;SACX,CAAA;IACH,CAAC;IAED,MAAM,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAA;AAC1B,CAAC;AAED,0FAA0F;AAC1F,MAAM,CAAC,MAAM,OAAO,GAA2B;IAC7C,MAAM,EAAE,iDAAiD;IACzD,KAAK,EAAE,oDAAoD;IAC3D,OAAO,EAAE,uFAAuF;IAChG,OAAO,EAAE,sBAAsB;IAC/B,KAAK,EAAE,kBAAkB;CAC1B,CAAA;AAED,8DAA8D;AAC9D,MAAM,UAAU,QAAQ,CAAC,MAAmB,EAAE,GAAW;IACvD,MAAM,MAAM,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,CAAA;IAC3E,MAAM,OAAO,GAAG,CAAC,SAAiB,EAAE,EAAE,CACpC,CAAC,SAAS,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,KAAK,MAAM,CAAA;IAEjE,OAAO,CACL,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,IAAI,CAC7F,CAAA;AACH,CAAC","sourcesContent":["// Reading what the build wrote down.\n//\n// Everything this server answers comes from one file: `build-report.json`, in\n// the build's own output directory. Nothing here runs a build, imports the\n// app, or re-derives the route tree — the build already decided all of this,\n// and a second implementation would be a second thing that can be wrong.\n//\n// The consequence to be honest about: an answer is only as fresh as the last\n// build. So every answer says when it was built, and a missing report says\n// \"run a build\" rather than \"there are no routes\".\n\nimport { existsSync, readFileSync, statSync } from 'node:fs'\nimport { join, resolve } from 'node:path'\n\nexport interface ReportedRoute {\n url: string\n component: string\n type: string\n reason: string | null\n warning: string | null\n /** How it was stored, when that is worth knowing - \"no client components, so ships no javascript\". */\n note?: string | null\n clientJs: number | null\n}\n\nexport interface ReportedApiRoute {\n url: string\n name: string\n type: string\n reason: string | null\n}\n\nexport interface ReportedAction {\n id: string\n name: string\n file: string\n /** Built by createActionClient, so its middleware ran. */\n client: boolean\n /** A read (GET) rather than a mutation. */\n query: boolean\n}\n\nexport interface BuildReport {\n version: number\n routes: ReportedRoute[]\n apis: ReportedApiRoute[]\n /** Absent from reports written before actions were audited. */\n actions?: ReportedAction[]\n totals: { static: number; partial: number; dynamic: number; failed: number }\n}\n\n/** Where a build leaves its report, in the order worth looking. */\nconst LIKELY = ['.rsc', 'build', 'dist', '.output']\n\nexport class NoReport extends Error {\n constructor(root: string) {\n super(\n `No build report under ${root}. This server answers from what the last build ` +\n 'decided, so there has to have been one — run the build and ask again.',\n )\n this.name = 'NoReport'\n }\n}\n\n/** The report, and how old it is. */\nexport function loadReport(root: string): { report: BuildReport; builtAt: Date; from: string } {\n const base = resolve(root)\n\n for (const dir of LIKELY) {\n const file = join(base, dir, 'build-report.json')\n\n if (!existsSync(file)) continue\n\n return {\n report: JSON.parse(readFileSync(file, 'utf-8')) as BuildReport,\n // The file's own mtime rather than a timestamp inside it: a stamp written\n // into the file changes the file on every build even when nothing else\n // did, which defeats every cache keyed on its contents.\n builtAt: statSync(file).mtime,\n from: file,\n }\n }\n\n throw new NoReport(base)\n}\n\n/** What each classification means, in one line, for an answer that has to stand alone. */\nexport const MEANING: Record<string, string> = {\n frozen: 'stored whole at build time and served as a file',\n shell: 'a stored shell, with the rest rendered per request',\n blocked: 'REFUSED — nothing could paint before it read the request, so the build did not finish',\n dynamic: 'answered per request',\n error: 'failed to render',\n}\n\n/** A route by url, tolerating a trailing slash either way. */\nexport function routeFor(report: BuildReport, url: string): ReportedRoute | ReportedApiRoute | null {\n const wanted = (url.split('?')[0].replace(/\\/+$/, '') || '/').toLowerCase()\n const matches = (candidate: string) =>\n (candidate.replace(/\\/+$/, '') || '/').toLowerCase() === wanted\n\n return (\n report.routes.find((r) => matches(r.url)) ?? report.apis.find((a) => matches(a.url)) ?? null\n )\n}\n"]}