@rsc-kit/mcp 0.1.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/README.md +40 -0
- package/dist/answers.d.ts +21 -0
- package/dist/answers.js +124 -0
- package/dist/answers.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +107 -0
- package/dist/index.js.map +1 -0
- package/dist/recipes.d.ts +11 -0
- package/dist/recipes.js +459 -0
- package/dist/recipes.js.map +1 -0
- package/dist/report.d.ts +38 -0
- package/dist/report.js +54 -0
- package/dist/report.js.map +1 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @rsc-kit/mcp
|
|
2
|
+
|
|
3
|
+
An MCP server over an [rsc-kit](https://rsc-kit.dev) app: what the build
|
|
4
|
+
decided, and how to build things the way this framework expects.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
claude mcp add rsc-kit -- npx -y @rsc-kit/mcp
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Point it at a project other than the working directory by passing the path:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
claude mcp add rsc-kit -- npx -y @rsc-kit/mcp /path/to/app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What it answers
|
|
17
|
+
|
|
18
|
+
**From the last build** — `build-report.json`, which every build writes:
|
|
19
|
+
|
|
20
|
+
- `list_routes` — every route, what happened to it, what it ships
|
|
21
|
+
- `explain_route` — why one url is stored or rendered per request
|
|
22
|
+
- `what_is_dynamic` — the routes that are not stored, with reasons
|
|
23
|
+
- `heaviest_routes` — what costs the browser most
|
|
24
|
+
|
|
25
|
+
**From the guides** — the patterns that differ from Next and plain React in
|
|
26
|
+
ways that compile either way:
|
|
27
|
+
|
|
28
|
+
- `list_topics` / `how_to` — forms, prefetching, validation, the action
|
|
29
|
+
client, data loading with TanStack Query or SWR, Suspense, offline, PWA,
|
|
30
|
+
api routes, authorization, and why a page is dynamic
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
34
|
+
Everything is read-only. There is no tool here that edits, builds or deploys —
|
|
35
|
+
an agent already has a shell for those, and a server that can change a project
|
|
36
|
+
is one that can change it while answering a question about it.
|
|
37
|
+
|
|
38
|
+
Answers come from the last build, and every one of them says how old it is. If
|
|
39
|
+
there has been no build, it says so rather than reporting that there are no
|
|
40
|
+
routes.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BuildReport } from './report.js';
|
|
2
|
+
/**
|
|
3
|
+
* Every answer says how old it is.
|
|
4
|
+
*
|
|
5
|
+
* The one way this server misleads is by being confidently stale: it reports
|
|
6
|
+
* the last build, and the file on disk may have changed since. Saying so on
|
|
7
|
+
* every answer is cheaper than being wrong once.
|
|
8
|
+
*/
|
|
9
|
+
export declare function asOf(builtAt: Date, now: number): string;
|
|
10
|
+
export declare function listRoutes(report: BuildReport, builtAt: Date, now: number): string;
|
|
11
|
+
export declare function explainRoute(report: BuildReport, url: string, builtAt: Date, now: number): string;
|
|
12
|
+
/**
|
|
13
|
+
* The routes that are not stored, and why.
|
|
14
|
+
*
|
|
15
|
+
* The question behind most of the others — someone asking "why is my site
|
|
16
|
+
* slow" wants this list, not the whole table. Routes that are fine are left
|
|
17
|
+
* out entirely rather than listed and dismissed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function whatIsDynamic(report: BuildReport, builtAt: Date, now: number): string;
|
|
20
|
+
/** The heaviest routes, for the question that follows the size column. */
|
|
21
|
+
export declare function heaviestRoutes(report: BuildReport, builtAt: Date, now: number, top?: number): string;
|
package/dist/answers.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The answers, as text, with no protocol in them.
|
|
2
|
+
//
|
|
3
|
+
// Separated from the server so they can be tested by calling them, and so the
|
|
4
|
+
// wording is reviewable in one place. An agent reads these as prose and acts on
|
|
5
|
+
// them, so a vague sentence here becomes a wrong edit somewhere else — "this
|
|
6
|
+
// route is dynamic" invites a fix, "this route reads cookies, which is why"
|
|
7
|
+
// invites the right one.
|
|
8
|
+
import { MEANING, routeFor } from './report.js';
|
|
9
|
+
const kb = (bytes) => `${bytes < 10_000 ? (bytes / 1000).toFixed(1) : Math.round(bytes / 1000)} kB`;
|
|
10
|
+
const age = (builtAt, now) => {
|
|
11
|
+
const minutes = Math.round((now - builtAt.getTime()) / 60_000);
|
|
12
|
+
if (minutes < 1)
|
|
13
|
+
return 'just now';
|
|
14
|
+
if (minutes < 60)
|
|
15
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
16
|
+
const hours = Math.round(minutes / 60);
|
|
17
|
+
if (hours < 24)
|
|
18
|
+
return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
19
|
+
return `${Math.round(hours / 24)} days ago`;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Every answer says how old it is.
|
|
23
|
+
*
|
|
24
|
+
* The one way this server misleads is by being confidently stale: it reports
|
|
25
|
+
* the last build, and the file on disk may have changed since. Saying so on
|
|
26
|
+
* every answer is cheaper than being wrong once.
|
|
27
|
+
*/
|
|
28
|
+
export function asOf(builtAt, now) {
|
|
29
|
+
return `(from the last build, ${age(builtAt, now)})`;
|
|
30
|
+
}
|
|
31
|
+
export function listRoutes(report, builtAt, now) {
|
|
32
|
+
const lines = [
|
|
33
|
+
`${report.routes.length} routes and ${report.apis.length} api routes ${asOf(builtAt, now)}`,
|
|
34
|
+
'',
|
|
35
|
+
];
|
|
36
|
+
for (const route of report.routes) {
|
|
37
|
+
const size = route.clientJs === null ? '' : ` ${kb(route.clientJs)}`;
|
|
38
|
+
lines.push(`${route.url}${size} — ${MEANING[route.type] ?? route.type}`);
|
|
39
|
+
if (route.reason)
|
|
40
|
+
lines.push(` ${route.reason}`);
|
|
41
|
+
}
|
|
42
|
+
if (report.apis.length) {
|
|
43
|
+
lines.push('', 'api routes:');
|
|
44
|
+
for (const api of report.apis) {
|
|
45
|
+
lines.push(`${api.url} — ${MEANING[api.type] ?? api.type}`);
|
|
46
|
+
if (api.reason)
|
|
47
|
+
lines.push(` ${api.reason}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
lines.push('', `${report.totals.static} static, ${report.totals.partial} partial prerender, ${report.totals.dynamic} dynamic` +
|
|
51
|
+
(report.totals.failed ? `, ${report.totals.failed} failed` : ''));
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
function isPage(route) {
|
|
55
|
+
return 'component' in route;
|
|
56
|
+
}
|
|
57
|
+
export function explainRoute(report, url, builtAt, now) {
|
|
58
|
+
const route = routeFor(report, url);
|
|
59
|
+
if (!route) {
|
|
60
|
+
// The urls, not just "not found": the caller has a url that does not exist,
|
|
61
|
+
// and the most useful next thing is the ones that do.
|
|
62
|
+
return (`No route for ${url} ${asOf(builtAt, now)}.\n\n` +
|
|
63
|
+
'Known urls:\n' +
|
|
64
|
+
[...report.routes, ...report.apis].map((r) => ` ${r.url}`).join('\n'));
|
|
65
|
+
}
|
|
66
|
+
const lines = [`${route.url} — ${MEANING[route.type] ?? route.type} ${asOf(builtAt, now)}`];
|
|
67
|
+
if (isPage(route)) {
|
|
68
|
+
lines.push(`Rendered by ${route.component}.`);
|
|
69
|
+
if (route.clientJs !== null) {
|
|
70
|
+
lines.push(`Ships ${kb(route.clientJs)} of javascript, gzipped.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (route.reason)
|
|
74
|
+
lines.push('', `Why it is not stored whole: ${route.reason}`);
|
|
75
|
+
if (isPage(route) && route.warning)
|
|
76
|
+
lines.push('', `Warning: ${route.warning}`);
|
|
77
|
+
if (route.type === 'frozen') {
|
|
78
|
+
lines.push('', 'Nothing to fix. It is rendered once at build time and served as a file.');
|
|
79
|
+
}
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The routes that are not stored, and why.
|
|
84
|
+
*
|
|
85
|
+
* The question behind most of the others — someone asking "why is my site
|
|
86
|
+
* slow" wants this list, not the whole table. Routes that are fine are left
|
|
87
|
+
* out entirely rather than listed and dismissed.
|
|
88
|
+
*/
|
|
89
|
+
export function whatIsDynamic(report, builtAt, now) {
|
|
90
|
+
const pages = report.routes.filter((r) => r.type !== 'frozen');
|
|
91
|
+
const apis = report.apis.filter((a) => a.type !== 'frozen');
|
|
92
|
+
if (pages.length === 0 && apis.length === 0) {
|
|
93
|
+
return `Every route is stored at build time ${asOf(builtAt, now)}. Nothing renders per request.`;
|
|
94
|
+
}
|
|
95
|
+
const lines = [
|
|
96
|
+
`${pages.length + apis.length} of ${report.routes.length + report.apis.length} routes render per request ${asOf(builtAt, now)}:`,
|
|
97
|
+
'',
|
|
98
|
+
];
|
|
99
|
+
for (const route of [...pages, ...apis]) {
|
|
100
|
+
lines.push(`${route.url} — ${route.reason ?? MEANING[route.type] ?? route.type}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push('', 'Reading the request is what makes a route dynamic: cookies(), headers(), searchParams(),', 'or connection() said deliberately. That is usually correct — a page whose content depends', 'on who is asking cannot be one stored file. Change it only if the read was accidental.');
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
/** The heaviest routes, for the question that follows the size column. */
|
|
106
|
+
export function heaviestRoutes(report, builtAt, now, top = 10) {
|
|
107
|
+
const weighed = report.routes
|
|
108
|
+
.filter((r) => r.clientJs !== null)
|
|
109
|
+
.sort((a, b) => (b.clientJs ?? 0) - (a.clientJs ?? 0));
|
|
110
|
+
if (weighed.length === 0) {
|
|
111
|
+
return `No route shipped measurable javascript ${asOf(builtAt, now)}.`;
|
|
112
|
+
}
|
|
113
|
+
const lightest = weighed[weighed.length - 1].clientJs ?? 0;
|
|
114
|
+
return [
|
|
115
|
+
`Heaviest routes ${asOf(builtAt, now)}:`,
|
|
116
|
+
'',
|
|
117
|
+
...weighed.slice(0, top).map((r) => `${kb(r.clientJs ?? 0)} ${r.url}`),
|
|
118
|
+
'',
|
|
119
|
+
`The lightest route ships ${kb(lightest)}, so the difference between them is`,
|
|
120
|
+
`${kb((weighed[0].clientJs ?? 0) - lightest)} of client components — most of the rest is React itself,`,
|
|
121
|
+
'which every route pays for.',
|
|
122
|
+
].join('\n');
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=answers.js.map
|
|
@@ -0,0 +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 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 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"]}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// An MCP server over what an rsc-kit build decided.
|
|
3
|
+
//
|
|
4
|
+
// claude mcp add rsc-kit -- npx -y @rsc-kit/mcp
|
|
5
|
+
//
|
|
6
|
+
// Four questions, all answered from `build-report.json` and none of them
|
|
7
|
+
// requiring the app to be running:
|
|
8
|
+
//
|
|
9
|
+
// what routes exist, and what happened to each
|
|
10
|
+
// why is this one not stored
|
|
11
|
+
// which ones render per request
|
|
12
|
+
// which ones cost the browser the most
|
|
13
|
+
//
|
|
14
|
+
// Why a server rather than letting an agent read the file: the file is JSON
|
|
15
|
+
// with a `type` field whose values mean nothing without the documentation, and
|
|
16
|
+
// an agent reading it guesses — "shell" invites being treated as a failure when
|
|
17
|
+
// it is the normal, correct outcome for a page with data in it. These answers
|
|
18
|
+
// say what each state means, in the same words the build printed.
|
|
19
|
+
//
|
|
20
|
+
// Everything is read-only. There is no tool here that edits, builds or deploys,
|
|
21
|
+
// deliberately: an agent already has a shell for those, and a server that can
|
|
22
|
+
// change the project is one that can change it while answering a question.
|
|
23
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
24
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
import { explainRoute, heaviestRoutes, listRoutes, whatIsDynamic } from './answers.js';
|
|
27
|
+
import { NoReport, loadReport } from './report.js';
|
|
28
|
+
import { howTo, listTopics } from './recipes.js';
|
|
29
|
+
/** The project to read, from the argument or the working directory. */
|
|
30
|
+
const root = process.argv[2] ?? process.cwd();
|
|
31
|
+
/**
|
|
32
|
+
* Loaded per call, never cached.
|
|
33
|
+
*
|
|
34
|
+
* A build happens while this server is running — that is the normal case, not
|
|
35
|
+
* the exception — and an answer from the build before it is worse than a slow
|
|
36
|
+
* one. The file is small and this is not a hot path.
|
|
37
|
+
*/
|
|
38
|
+
const read = () => loadReport(root);
|
|
39
|
+
/**
|
|
40
|
+
* Declared once, outside the call.
|
|
41
|
+
*
|
|
42
|
+
* Inline, TypeScript walks the sdk's tool generics against the zod shape and
|
|
43
|
+
* gives up with "type instantiation is excessively deep" — a compiler limit
|
|
44
|
+
* rather than anything wrong with the schema. A named const with the handler's
|
|
45
|
+
* argument annotated stops the inference chain before it gets there.
|
|
46
|
+
*/
|
|
47
|
+
const URL_ARG = { url: z.string().describe('The url, e.g. /orders or /posts/hello') };
|
|
48
|
+
const TOPIC_ARG = { topic: z.string().describe('One of the topics from list_topics, e.g. forms or validation') };
|
|
49
|
+
const text = (body) => ({ content: [{ type: 'text', text: body }] });
|
|
50
|
+
/** A missing report is an answer, not a crash: it says to run a build. */
|
|
51
|
+
const answering = (produce) => {
|
|
52
|
+
try {
|
|
53
|
+
return text(produce());
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof NoReport)
|
|
57
|
+
return text(error.message);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const server = new McpServer({ name: 'rsc-kit', version: '0.1.0' });
|
|
62
|
+
server.registerTool('list_routes', {
|
|
63
|
+
title: 'List routes',
|
|
64
|
+
description: 'Every route in this rsc-kit app, what the build did with each one, and how much javascript it ships. Start here when you need to know what exists.',
|
|
65
|
+
annotations: { readOnlyHint: true },
|
|
66
|
+
}, async () => answering(() => {
|
|
67
|
+
const { report, builtAt } = read();
|
|
68
|
+
return listRoutes(report, builtAt, Date.now());
|
|
69
|
+
}));
|
|
70
|
+
server.registerTool('explain_route', {
|
|
71
|
+
title: 'Explain a route',
|
|
72
|
+
description: 'Why one url is stored at build time or rendered per request, what renders it, and what it costs the browser. Use this before changing a page to make it faster — the reason is recorded, not guessed.',
|
|
73
|
+
inputSchema: URL_ARG,
|
|
74
|
+
annotations: { readOnlyHint: true },
|
|
75
|
+
}, (async ({ url }) => answering(() => {
|
|
76
|
+
const { report, builtAt } = read();
|
|
77
|
+
return explainRoute(report, url, builtAt, Date.now());
|
|
78
|
+
})));
|
|
79
|
+
server.registerTool('what_is_dynamic', {
|
|
80
|
+
title: 'What renders per request',
|
|
81
|
+
description: 'The routes that render per request rather than being stored, each with the reason. This is the answer to "why is this site not static".',
|
|
82
|
+
annotations: { readOnlyHint: true },
|
|
83
|
+
}, async () => answering(() => {
|
|
84
|
+
const { report, builtAt } = read();
|
|
85
|
+
return whatIsDynamic(report, builtAt, Date.now());
|
|
86
|
+
}));
|
|
87
|
+
server.registerTool('heaviest_routes', {
|
|
88
|
+
title: 'Heaviest routes',
|
|
89
|
+
description: 'The routes that make the browser download the most javascript, largest first.',
|
|
90
|
+
annotations: { readOnlyHint: true },
|
|
91
|
+
}, async () => answering(() => {
|
|
92
|
+
const { report, builtAt } = read();
|
|
93
|
+
return heaviestRoutes(report, builtAt, Date.now());
|
|
94
|
+
}));
|
|
95
|
+
server.registerTool('how_to', {
|
|
96
|
+
title: 'How to build it',
|
|
97
|
+
description: 'How to do something in an rsc-kit app — forms, prefetching, validation, the action client, data loading with TanStack Query or SWR, Suspense boundaries, offline, PWA, api routes, authorization, and why a page is dynamic. Read this BEFORE writing the code: the patterns here differ from Next and plain React in ways that compile either way.',
|
|
98
|
+
inputSchema: TOPIC_ARG,
|
|
99
|
+
annotations: { readOnlyHint: true },
|
|
100
|
+
}, (async ({ topic }) => text(howTo(topic))));
|
|
101
|
+
server.registerTool('list_topics', {
|
|
102
|
+
title: 'What this server can explain',
|
|
103
|
+
description: 'Every topic how_to knows about, one line each.',
|
|
104
|
+
annotations: { readOnlyHint: true },
|
|
105
|
+
}, async () => text(listTopics()));
|
|
106
|
+
await server.connect(new StdioServerTransport());
|
|
107
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n// An MCP server over what an rsc-kit build decided.\n//\n// claude mcp add rsc-kit -- npx -y @rsc-kit/mcp\n//\n// Four questions, all answered from `build-report.json` and none of them\n// requiring the app to be running:\n//\n// what routes exist, and what happened to each\n// why is this one not stored\n// which ones render per request\n// which ones cost the browser the most\n//\n// Why a server rather than letting an agent read the file: the file is JSON\n// with a `type` field whose values mean nothing without the documentation, and\n// an agent reading it guesses — \"shell\" invites being treated as a failure when\n// it is the normal, correct outcome for a page with data in it. These answers\n// say what each state means, in the same words the build printed.\n//\n// Everything is read-only. There is no tool here that edits, builds or deploys,\n// deliberately: an agent already has a shell for those, and a server that can\n// change the project is one that can change it while answering a question.\n\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport { z } from 'zod'\nimport { explainRoute, heaviestRoutes, listRoutes, whatIsDynamic } from './answers.js'\nimport { NoReport, loadReport } from './report.js'\nimport { howTo, listTopics } from './recipes.js'\n\n/** The project to read, from the argument or the working directory. */\nconst root = process.argv[2] ?? process.cwd()\n\n/**\n * Loaded per call, never cached.\n *\n * A build happens while this server is running — that is the normal case, not\n * the exception — and an answer from the build before it is worse than a slow\n * one. The file is small and this is not a hot path.\n */\nconst read = () => loadReport(root)\n\n/**\n * Declared once, outside the call.\n *\n * Inline, TypeScript walks the sdk's tool generics against the zod shape and\n * gives up with \"type instantiation is excessively deep\" — a compiler limit\n * rather than anything wrong with the schema. A named const with the handler's\n * argument annotated stops the inference chain before it gets there.\n */\nconst URL_ARG = { url: z.string().describe('The url, e.g. /orders or /posts/hello') }\nconst TOPIC_ARG = { topic: z.string().describe('One of the topics from list_topics, e.g. forms or validation') }\n\nconst text = (body: string) => ({ content: [{ type: 'text' as const, text: body }] })\n\n/** A missing report is an answer, not a crash: it says to run a build. */\nconst answering = (produce: () => string) => {\n try {\n return text(produce())\n } catch (error) {\n if (error instanceof NoReport) return text(error.message)\n\n throw error\n }\n}\n\n/**\n * The registration surface, named rather than inferred.\n *\n * `registerTool` is generic over the zod shape, and TypeScript walks those\n * generics until it gives up — \"type instantiation is excessively deep\", which\n * is a compiler limit rather than anything wrong with the schema. Describing\n * the one method used, with the argument type written out, stops the inference\n * before it gets there and costs nothing: the schemas below are still real zod\n * and still validate at runtime.\n */\ninterface Registrar {\n registerTool(\n name: string,\n config: {\n title?: string\n description?: string\n inputSchema?: Record<string, unknown>\n annotations?: { readOnlyHint?: boolean }\n },\n handler: (args: never) => Promise<{ content: { type: 'text'; text: string }[] }>,\n ): unknown\n connect(transport: StdioServerTransport): Promise<void>\n}\n\nconst server = new McpServer({ name: 'rsc-kit', version: '0.1.0' }) as unknown as Registrar\n\nserver.registerTool(\n 'list_routes',\n {\n title: 'List routes',\n description:\n 'Every route in this rsc-kit app, what the build did with each one, and how much javascript it ships. Start here when you need to know what exists.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return listRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'explain_route',\n {\n title: 'Explain a route',\n description:\n 'Why one url is stored at build time or rendered per request, what renders it, and what it costs the browser. Use this before changing a page to make it faster — the reason is recorded, not guessed.',\n inputSchema: URL_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ url }: { url: string }) =>\n answering(() => {\n const { report, builtAt } = read()\n\n return explainRoute(report, url, builtAt, Date.now())\n })) as never,\n)\n\nserver.registerTool(\n 'what_is_dynamic',\n {\n title: 'What renders per request',\n description:\n 'The routes that render per request rather than being stored, each with the reason. This is the answer to \"why is this site not static\".',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return whatIsDynamic(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'heaviest_routes',\n {\n title: 'Heaviest routes',\n description: 'The routes that make the browser download the most javascript, largest first.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return heaviestRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'how_to',\n {\n title: 'How to build it',\n description:\n 'How to do something in an rsc-kit app — forms, prefetching, validation, the action client, data loading with TanStack Query or SWR, Suspense boundaries, offline, PWA, api routes, authorization, and why a page is dynamic. Read this BEFORE writing the code: the patterns here differ from Next and plain React in ways that compile either way.',\n inputSchema: TOPIC_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ topic }: { topic: string }) => text(howTo(topic))) as never,\n)\n\nserver.registerTool(\n 'list_topics',\n {\n title: 'What this server can explain',\n description: 'Every topic how_to knows about, one line each.',\n annotations: { readOnlyHint: true },\n },\n async () => text(listTopics()),\n)\n\nawait server.connect(new StdioServerTransport())\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface Recipe {
|
|
2
|
+
topic: string;
|
|
3
|
+
summary: string;
|
|
4
|
+
body: string;
|
|
5
|
+
}
|
|
6
|
+
/** Every topic, with one line each — what a caller reads before choosing. */
|
|
7
|
+
export declare function listTopics(): string;
|
|
8
|
+
/** One recipe, or the list plus a nudge when the topic is not one. */
|
|
9
|
+
export declare function howTo(topic: string): string;
|
|
10
|
+
/** For tests, so a recipe cannot be added without being reachable. */
|
|
11
|
+
export declare const TOPICS: string[];
|
package/dist/recipes.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// How to build the things this framework has, in the shape that works.
|
|
2
|
+
//
|
|
3
|
+
// The other half of this server, and the more useful one. Introspection answers
|
|
4
|
+
// "what did my build do"; this answers "how do I do X here", which is the
|
|
5
|
+
// question an agent actually has — and the one it otherwise answers from Next
|
|
6
|
+
// and React habits that produce code which looks right and is not.
|
|
7
|
+
//
|
|
8
|
+
// Long-form on purpose. AGENTS.md has to be short enough to sit in context for
|
|
9
|
+
// every turn, so it can only say the rule. These are fetched when the topic
|
|
10
|
+
// comes up, so they can afford the working example and the caveat under it.
|
|
11
|
+
//
|
|
12
|
+
// Every snippet here is the recommended spelling from the guides, not a
|
|
13
|
+
// paraphrase. When a guide changes, this changes with it — a recipe that has
|
|
14
|
+
// drifted is worse than no recipe, because it is followed with confidence.
|
|
15
|
+
const RECIPES = [
|
|
16
|
+
{
|
|
17
|
+
topic: 'forms',
|
|
18
|
+
summary: 'Submitting to a server action, with pending state and field errors',
|
|
19
|
+
body: `Use <Form>. It takes the server action itself, not a url.
|
|
20
|
+
|
|
21
|
+
\`\`\`tsx
|
|
22
|
+
'use client'
|
|
23
|
+
import Form from '@rsc-kit/core/Form'
|
|
24
|
+
import { createPost } from '../actions'
|
|
25
|
+
|
|
26
|
+
export function NewPost() {
|
|
27
|
+
return (
|
|
28
|
+
<Form action={createPost} schema={schema}>
|
|
29
|
+
{({ pending, errors }) => (
|
|
30
|
+
<>
|
|
31
|
+
<input name="title" />
|
|
32
|
+
{errors.title?.[0] && <p>{errors.title[0]}</p>}
|
|
33
|
+
<button disabled={pending}>Save</button>
|
|
34
|
+
</>
|
|
35
|
+
)}
|
|
36
|
+
</Form>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
Passing \`schema\` validates in the browser BEFORE the action is called, so a
|
|
42
|
+
mistake costs no round trip. It is a courtesy, never a control: the action is a
|
|
43
|
+
public endpoint reachable without your form, so the server must check too.
|
|
44
|
+
|
|
45
|
+
A schema on the server (\`client.input(schema)\`) does NOT give you client-side
|
|
46
|
+
validation. Pass it to the form as well — the same schema is fine.
|
|
47
|
+
|
|
48
|
+
For imperative control use \`useForm\` instead:
|
|
49
|
+
|
|
50
|
+
\`\`\`ts
|
|
51
|
+
const form = useForm({ title: '' }, { schema })
|
|
52
|
+
await form.submit(createPost)
|
|
53
|
+
\`\`\`
|
|
54
|
+
|
|
55
|
+
It works with no javascript at all: the form posts, the action runs, the page
|
|
56
|
+
re-renders. That is why the fields are real \`name\` attributes rather than
|
|
57
|
+
controlled state.`,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
topic: 'prefetch',
|
|
61
|
+
summary: 'Making a navigation feel instant',
|
|
62
|
+
body: `\`<Link>\` prefetches on hover by default. Usually there is nothing to do.
|
|
63
|
+
|
|
64
|
+
\`\`\`tsx
|
|
65
|
+
import Link from '@rsc-kit/core/Link'
|
|
66
|
+
|
|
67
|
+
<Link href="/orders">Orders</Link>
|
|
68
|
+
<Link href="/orders" prefetch={false}>Orders</Link> // opt out
|
|
69
|
+
<Link href="/orders" cacheFor={30_000}>Orders</Link> // hold the payload longer
|
|
70
|
+
\`\`\`
|
|
71
|
+
|
|
72
|
+
\`href\` is typed to the routes the build found, so a link to a page that no
|
|
73
|
+
longer exists stops compiling. Cast with \`as Href\` only when the destination
|
|
74
|
+
is genuinely computed.
|
|
75
|
+
|
|
76
|
+
To prefetch from code — a row about to be clicked, a wizard's next step:
|
|
77
|
+
|
|
78
|
+
\`\`\`ts
|
|
79
|
+
import { prefetch } from '@rsc-kit/core/navigate'
|
|
80
|
+
|
|
81
|
+
prefetch('/orders/42')
|
|
82
|
+
\`\`\`
|
|
83
|
+
|
|
84
|
+
What is prefetched is the RSC payload, not the html, so it is small and it warms
|
|
85
|
+
the same cache the navigation will read.`,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
topic: 'validation',
|
|
89
|
+
summary: 'Checking input — forms, actions, urls and request bodies',
|
|
90
|
+
body: `One contract everywhere: any Standard Schema (Zod, Valibot, ArkType).
|
|
91
|
+
|
|
92
|
+
**Actions** validate on arrival and RETURN their failures, because React strips
|
|
93
|
+
a thrown message in production:
|
|
94
|
+
|
|
95
|
+
\`\`\`ts
|
|
96
|
+
export const createPost = client.input(schema).handler(async ({ input, ctx }) => …)
|
|
97
|
+
\`\`\`
|
|
98
|
+
|
|
99
|
+
**Urls** validate by exporting a schema beside the page or route:
|
|
100
|
+
|
|
101
|
+
\`\`\`ts
|
|
102
|
+
export const params = z.object({ slug: z.string().min(1) })
|
|
103
|
+
export const searchParams = z.object({ page: z.coerce.number().int().min(1).default(1) })
|
|
104
|
+
\`\`\`
|
|
105
|
+
|
|
106
|
+
Values arrive parsed and typed — \`?page=3\` is the number 3, a missing one is
|
|
107
|
+
the default. Never hand-parse \`Number(searchParams.get('page'))\`.
|
|
108
|
+
|
|
109
|
+
**Api route bodies** the same way:
|
|
110
|
+
|
|
111
|
+
\`\`\`ts
|
|
112
|
+
export const body = z.object({ title: z.string().min(1) })
|
|
113
|
+
|
|
114
|
+
export async function POST(request: Request, { body }) {
|
|
115
|
+
const { title } = await body
|
|
116
|
+
}
|
|
117
|
+
\`\`\`
|
|
118
|
+
|
|
119
|
+
The failures answer differently on purpose:
|
|
120
|
+
|
|
121
|
+
bad params 404 — the url does not describe a page
|
|
122
|
+
bad searchParams the error boundary (400 for an api route)
|
|
123
|
+
bad body 422, the status an action already uses
|
|
124
|
+
|
|
125
|
+
A bad query is deliberately NOT a 404, or one bad link makes a real page look
|
|
126
|
+
deleted.`,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
topic: 'action-client',
|
|
130
|
+
summary: 'Middleware for server actions, so a check cannot be forgotten',
|
|
131
|
+
body: `\`\`\`ts title="src/server/client.ts"
|
|
132
|
+
'use server'
|
|
133
|
+
import { createActionClient } from '@rsc-kit/core/action'
|
|
134
|
+
|
|
135
|
+
export const client = createActionClient({ onError: report })
|
|
136
|
+
.use(async ({ next }) => {
|
|
137
|
+
const user = await currentUser()
|
|
138
|
+
|
|
139
|
+
if (!user) throw new ServerAuthenticationError()
|
|
140
|
+
|
|
141
|
+
return next({ ctx: { user } })
|
|
142
|
+
})
|
|
143
|
+
\`\`\`
|
|
144
|
+
|
|
145
|
+
\`\`\`ts title="src/server/posts.ts"
|
|
146
|
+
'use server'
|
|
147
|
+
import { client } from './client'
|
|
148
|
+
|
|
149
|
+
export const createPost = client.input(schema).handler(async ({ input, ctx }) => …)
|
|
150
|
+
export const getPosts = client.query(async ({ ctx }) => …)
|
|
151
|
+
\`\`\`
|
|
152
|
+
|
|
153
|
+
\`.handler()\` is a mutation (POST). \`.query()\` is a read (GET). Both run the
|
|
154
|
+
chain, so \`ctx.user\` is typed and non-null inside them.
|
|
155
|
+
|
|
156
|
+
The point is not convenience. An action cannot be added without the check,
|
|
157
|
+
because there is no other constructor to reach for.
|
|
158
|
+
|
|
159
|
+
Stack clients for a narrower rule:
|
|
160
|
+
|
|
161
|
+
\`\`\`ts
|
|
162
|
+
export const admin = client.use(async ({ ctx, next }) => {
|
|
163
|
+
if (!ctx.user.isAdmin) throw new ServerAuthorizationError()
|
|
164
|
+
return next({ ctx })
|
|
165
|
+
})
|
|
166
|
+
\`\`\`
|
|
167
|
+
|
|
168
|
+
Route \`middleware.ts\` does NOT run for actions — an action renders no route.
|
|
169
|
+
That is why the check goes here.`,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
topic: 'data',
|
|
173
|
+
summary: 'Loading data, streaming it, and when the browser needs to refetch',
|
|
174
|
+
body: `**In a server component, just await it.** No loader, no getServerSideProps.
|
|
175
|
+
|
|
176
|
+
\`\`\`tsx
|
|
177
|
+
export default async function Page() {
|
|
178
|
+
const posts = await db.posts.all()
|
|
179
|
+
}
|
|
180
|
+
\`\`\`
|
|
181
|
+
|
|
182
|
+
**Better: do not await.** Pass the promise down and let a client component
|
|
183
|
+
resolve it — the shell paints at once and the rows stream into the same
|
|
184
|
+
response, with no request from the browser:
|
|
185
|
+
|
|
186
|
+
\`\`\`tsx
|
|
187
|
+
export default function Page() {
|
|
188
|
+
const posts = getPosts() // not awaited
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<Suspense fallback={<Skeleton />}>
|
|
192
|
+
<List posts={posts} /> {/* 'use client': use(posts) */}
|
|
193
|
+
</Suspense>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
\`\`\`
|
|
197
|
+
|
|
198
|
+
Reach for this first. It is the thing RSC is for.
|
|
199
|
+
|
|
200
|
+
**When the BROWSER decides to refetch** — a filter, a poll, a refresh — that is
|
|
201
|
+
a cache library's job and this package does not ship one:
|
|
202
|
+
|
|
203
|
+
\`\`\`tsx
|
|
204
|
+
useQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })
|
|
205
|
+
useSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))
|
|
206
|
+
\`\`\`
|
|
207
|
+
|
|
208
|
+
\`fetchQuery\` sends the read as a GET and goes to the server every time, which
|
|
209
|
+
is what a fetcher needs — staleness and revalidation belong to the library
|
|
210
|
+
holding the answer. Do not add a cache on top of it.
|
|
211
|
+
|
|
212
|
+
Keep the arrow: TanStack calls a bare \`queryFn\` with its own context, and a
|
|
213
|
+
server function serialises whatever it is handed.`,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
topic: 'suspense',
|
|
217
|
+
summary: 'Where boundaries go, and why the build cares',
|
|
218
|
+
body: `A boundary is what lets a page be stored with a hole in it rather than not
|
|
219
|
+
stored at all.
|
|
220
|
+
|
|
221
|
+
\`\`\`tsx
|
|
222
|
+
<Suspense fallback={<Skeleton />}>
|
|
223
|
+
<Slow />
|
|
224
|
+
</Suspense>
|
|
225
|
+
\`\`\`
|
|
226
|
+
|
|
227
|
+
Or a \`loading.tsx\` beside the page, which is the same thing for the whole
|
|
228
|
+
route.
|
|
229
|
+
|
|
230
|
+
The build renders every page. Whatever has not resolved when the budget expires
|
|
231
|
+
becomes the hole; everything above it is stored and served instantly. So a page
|
|
232
|
+
with no boundary above its slow part cannot be stored at all — the build says
|
|
233
|
+
so:
|
|
234
|
+
|
|
235
|
+
ƒ /orders
|
|
236
|
+
blocks before anything can paint. Add a loading.tsx beside it, or put a
|
|
237
|
+
<Suspense> above the waiting, and it has a skeleton to store.
|
|
238
|
+
|
|
239
|
+
A boundary does NOT fix a frozen \`Date.now()\`. Prerendering renders straight
|
|
240
|
+
through a component that never awaits, so the value is captured exactly as
|
|
241
|
+
before. A boundary becomes a hole only when something inside it waits.`,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
topic: 'offline',
|
|
245
|
+
summary: 'Service worker, and what it does and does not cache',
|
|
246
|
+
body: `\`\`\`ts title="vite.config.ts"
|
|
247
|
+
rscKit({ offline: true })
|
|
248
|
+
\`\`\`
|
|
249
|
+
|
|
250
|
+
The build writes a service worker that precaches the client bundle and caches
|
|
251
|
+
pages at runtime — a document fetch warms its payload, a payload fetch warms its
|
|
252
|
+
document, so a page reached by a link still works when reloaded offline.
|
|
253
|
+
|
|
254
|
+
Pages the build stored whole are served from the cache FIRST, because they
|
|
255
|
+
cannot change until a deploy and a deploy sweeps the cache. Everything else is
|
|
256
|
+
network-first with the cache as fallback.
|
|
257
|
+
|
|
258
|
+
Nothing marked \`no-store\` is ever kept — which is how a guarded page and a
|
|
259
|
+
session-reading query stay out of a cache that has no notion of who asked.
|
|
260
|
+
|
|
261
|
+
In a component:
|
|
262
|
+
|
|
263
|
+
\`\`\`tsx
|
|
264
|
+
import { useOnline } from '@rsc-kit/core/useOnline'
|
|
265
|
+
|
|
266
|
+
const online = useOnline()
|
|
267
|
+
\`\`\`
|
|
268
|
+
|
|
269
|
+
There is no push and no background sync. Push needs a subscription endpoint and
|
|
270
|
+
a sender; background sync needs idempotent replay. Both are the app's decisions.`,
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
topic: 'pwa',
|
|
274
|
+
summary: 'Making the app installable',
|
|
275
|
+
body: `A manifest file beside the routes:
|
|
276
|
+
|
|
277
|
+
\`\`\`ts title="src/app/manifest.ts"
|
|
278
|
+
import type { WebManifest } from '@rsc-kit/core/manifest-file'
|
|
279
|
+
|
|
280
|
+
export default {
|
|
281
|
+
name: 'Orders',
|
|
282
|
+
shortName: 'Orders',
|
|
283
|
+
themeColor: '#0b0b0c',
|
|
284
|
+
backgroundColor: '#ffffff',
|
|
285
|
+
} satisfies WebManifest
|
|
286
|
+
\`\`\`
|
|
287
|
+
|
|
288
|
+
Read at build time, so it must be an object literal — not computed, not
|
|
289
|
+
imported from elsewhere.
|
|
290
|
+
|
|
291
|
+
**Icons need no listing.** Put them in \`src/app/\` and the build finds them:
|
|
292
|
+
|
|
293
|
+
favicon.ico served at /favicon.ico
|
|
294
|
+
icon-192.png <link rel="icon">, and the manifest's icons
|
|
295
|
+
icon-512.png
|
|
296
|
+
apple-icon.png <link rel="apple-touch-icon">
|
|
297
|
+
opengraph-image.png <meta property="og:image">
|
|
298
|
+
twitter-image.png <meta name="twitter:image">
|
|
299
|
+
|
|
300
|
+
Sizes are read from the filename. The build says whether it worked:
|
|
301
|
+
|
|
302
|
+
[rsc-kit] manifest: Orders is installable
|
|
303
|
+
[rsc-kit] manifest: no icons, so no browser will offer to install this.
|
|
304
|
+
|
|
305
|
+
There is no layout to edit — React hoists the tags into <head>.
|
|
306
|
+
|
|
307
|
+
Pair it with \`offline: true\`. They are separate options because they are
|
|
308
|
+
separate decisions.`,
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
topic: 'no-javascript',
|
|
312
|
+
summary: 'Shipping a route with no client runtime at all',
|
|
313
|
+
body: `\`\`\`ts title="src/app/about/page.tsx"
|
|
314
|
+
export const clientJs = false
|
|
315
|
+
\`\`\`
|
|
316
|
+
|
|
317
|
+
The route ships no bootstrap and no client runtime. The build REFUSES it if the
|
|
318
|
+
tree renders a client component, and names the component — they usually come
|
|
319
|
+
from a shared layout rather than the page itself.
|
|
320
|
+
|
|
321
|
+
Links still work; they are ordinary anchors, so navigation is a full page load.
|
|
322
|
+
|
|
323
|
+
Most pages do not need this. A page with nothing interactive already ships only
|
|
324
|
+
the shared runtime, and the size column in the build output tells you what each
|
|
325
|
+
one actually costs.`,
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
topic: 'api-routes',
|
|
329
|
+
summary: 'HTTP endpoints beside the pages',
|
|
330
|
+
body: `\`src/app/**/route.ts\`, one export per method:
|
|
331
|
+
|
|
332
|
+
\`\`\`ts title="src/app/api/posts/[id]/route.ts"
|
|
333
|
+
export const params = z.object({ id: z.coerce.number().int() })
|
|
334
|
+
export const body = z.object({ title: z.string().min(1) })
|
|
335
|
+
|
|
336
|
+
export async function GET(request: Request, { params }) {
|
|
337
|
+
const { id } = await params
|
|
338
|
+
|
|
339
|
+
return Response.json(await findPost(id))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function POST(request: Request, { params, body }) {
|
|
343
|
+
const { title } = await body
|
|
344
|
+
|
|
345
|
+
return Response.json(await createPost(title), { status: 201 })
|
|
346
|
+
}
|
|
347
|
+
\`\`\`
|
|
348
|
+
|
|
349
|
+
A real \`Request\` in, a real \`Response\` out. \`params\`, \`searchParams\` and
|
|
350
|
+
\`body\` are awaited, the same way a page's props are.
|
|
351
|
+
|
|
352
|
+
They run their directory's \`middleware.ts\`, so an endpoint under a guarded
|
|
353
|
+
path is guarded.
|
|
354
|
+
|
|
355
|
+
A \`GET\` that reads nothing from the request is answered from disk. Awaiting
|
|
356
|
+
\`searchParams\` says the answer depends on the query; never touching it means
|
|
357
|
+
the stored answer is served for any query at all.
|
|
358
|
+
|
|
359
|
+
Exporting a \`body\` schema consumes the stream, so \`request.json()\` inside the
|
|
360
|
+
handler will find it already read. Use the parsed value.`,
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
topic: 'authorization',
|
|
364
|
+
summary: 'Guarding pages, actions, api routes and queries',
|
|
365
|
+
body: `Each entry point defends itself. There is no single place that covers all of
|
|
366
|
+
them, and believing otherwise is how a hole is left.
|
|
367
|
+
|
|
368
|
+
**A page or an api route**: \`middleware.ts\` in its directory guards everything
|
|
369
|
+
at or below it.
|
|
370
|
+
|
|
371
|
+
\`\`\`ts title="src/app/admin/middleware.ts"
|
|
372
|
+
import { redirect } from '@rsc-kit/core/redirect'
|
|
373
|
+
|
|
374
|
+
export default async function guard() {
|
|
375
|
+
if (!(await currentUser())) redirect('/login')
|
|
376
|
+
}
|
|
377
|
+
\`\`\`
|
|
378
|
+
|
|
379
|
+
**An action or a query**: middleware does NOT run — they render no route. Build
|
|
380
|
+
them from an action client so the check cannot be forgotten. See the
|
|
381
|
+
\`action-client\` topic.
|
|
382
|
+
|
|
383
|
+
**Authorise on identity, not arguments.** \`deletePost(id)\` that trusts the id
|
|
384
|
+
is the whole of an IDOR: the caller chooses the id, so check the row belongs to
|
|
385
|
+
\`ctx.user\`.
|
|
386
|
+
|
|
387
|
+
A guarded page can still be frozen at build time — the guard is a serving
|
|
388
|
+
decision, not a build one. Its response is marked private so no cache keeps it.`,
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
topic: 'dynamic',
|
|
392
|
+
summary: 'Why a page is not static, and how to choose',
|
|
393
|
+
body: `A page is stored at build time unless it reads the request. Reading it is what
|
|
394
|
+
opts out, and the accessors are async:
|
|
395
|
+
|
|
396
|
+
\`\`\`ts
|
|
397
|
+
import { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'
|
|
398
|
+
|
|
399
|
+
const theme = (await cookies()).get('theme')
|
|
400
|
+
await connection() // "render this per visitor", said deliberately
|
|
401
|
+
\`\`\`
|
|
402
|
+
|
|
403
|
+
A page's \`params\` and \`searchParams\` props are promises for the same reason.
|
|
404
|
+
|
|
405
|
+
The build says which call did it, per route:
|
|
406
|
+
|
|
407
|
+
◐ /locale 85 kB
|
|
408
|
+
dynamic — called cookies(), headers()
|
|
409
|
+
|
|
410
|
+
That is usually correct — a page whose content depends on who is asking cannot
|
|
411
|
+
be one stored file. Change it only when the read was accidental.
|
|
412
|
+
|
|
413
|
+
For a parameterised route, \`generateStaticParams\` turns one shell into a page
|
|
414
|
+
per url:
|
|
415
|
+
|
|
416
|
+
\`\`\`ts
|
|
417
|
+
export async function generateStaticParams() {
|
|
418
|
+
return (await db.posts.all()).map((p) => ({ slug: p.slug }))
|
|
419
|
+
}
|
|
420
|
+
\`\`\`
|
|
421
|
+
|
|
422
|
+
A value that must differ per visitor but needs no server — a clock,
|
|
423
|
+
localStorage, a map — belongs in the browser only:
|
|
424
|
+
|
|
425
|
+
\`\`\`tsx
|
|
426
|
+
'use client'
|
|
427
|
+
import { browser } from 'react-dom'
|
|
428
|
+
|
|
429
|
+
function Clock() {
|
|
430
|
+
use(browser('the time is the visitor\\'s, not the build machine\\'s'))
|
|
431
|
+
}
|
|
432
|
+
\`\`\`
|
|
433
|
+
|
|
434
|
+
It needs a Suspense boundary, and the page stays frozen.`,
|
|
435
|
+
},
|
|
436
|
+
];
|
|
437
|
+
/** Every topic, with one line each — what a caller reads before choosing. */
|
|
438
|
+
export function listTopics() {
|
|
439
|
+
return [
|
|
440
|
+
'Topics. Ask for one with how_to({ topic }).',
|
|
441
|
+
'',
|
|
442
|
+
...RECIPES.map((r) => `${r.topic.padEnd(16)} ${r.summary}`),
|
|
443
|
+
].join('\n');
|
|
444
|
+
}
|
|
445
|
+
/** One recipe, or the list plus a nudge when the topic is not one. */
|
|
446
|
+
export function howTo(topic) {
|
|
447
|
+
const wanted = topic.trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
448
|
+
const found = RECIPES.find((r) => r.topic === wanted) ??
|
|
449
|
+
// A near miss is common and worth answering rather than refusing: someone
|
|
450
|
+
// asks for "form" or "queries" and means the obvious thing.
|
|
451
|
+
RECIPES.find((r) => r.topic.startsWith(wanted) || wanted.startsWith(r.topic)) ??
|
|
452
|
+
RECIPES.find((r) => r.summary.toLowerCase().includes(wanted));
|
|
453
|
+
if (!found)
|
|
454
|
+
return `No topic "${topic}".\n\n${listTopics()}`;
|
|
455
|
+
return `# ${found.topic} — ${found.summary}\n\n${found.body}`;
|
|
456
|
+
}
|
|
457
|
+
/** For tests, so a recipe cannot be added without being reachable. */
|
|
458
|
+
export const TOPICS = RECIPES.map((r) => r.topic);
|
|
459
|
+
//# sourceMappingURL=recipes.js.map
|
|
@@ -0,0 +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\nFor imperative control use \\`useForm\\` instead:\n\n\\`\\`\\`ts\nconst form = useForm({ title: '' }, { schema })\nawait form.submit(createPost)\n\\`\\`\\`\n\nIt works with no javascript at all: the form posts, the action runs, the page\nre-renders. That is why the fields are real \\`name\\` attributes rather than\ncontrolled state.`,\n },\n {\n topic: 'prefetch',\n summary: 'Making a navigation feel instant',\n body: `\\`<Link>\\` prefetches on hover by default. Usually there is nothing to do.\n\n\\`\\`\\`tsx\nimport Link from '@rsc-kit/core/Link'\n\n<Link href=\"/orders\">Orders</Link>\n<Link href=\"/orders\" prefetch={false}>Orders</Link> // opt out\n<Link href=\"/orders\" cacheFor={30_000}>Orders</Link> // hold the payload longer\n\\`\\`\\`\n\n\\`href\\` is typed to the routes the build found, so a link to a page that no\nlonger exists stops compiling. Cast with \\`as Href\\` only when the destination\nis genuinely computed.\n\nTo prefetch from code — a row about to be clicked, a wizard's next step:\n\n\\`\\`\\`ts\nimport { prefetch } from '@rsc-kit/core/navigate'\n\nprefetch('/orders/42')\n\\`\\`\\`\n\nWhat is prefetched is the RSC payload, not the html, so it is small and it warms\nthe same cache the navigation will read.`,\n },\n {\n topic: 'validation',\n summary: 'Checking input — forms, actions, urls and request bodies',\n body: `One contract everywhere: any Standard Schema (Zod, Valibot, ArkType).\n\n**Actions** validate on arrival and RETURN their failures, because React strips\na thrown message in production:\n\n\\`\\`\\`ts\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\n\\`\\`\\`\n\n**Urls** validate by exporting a schema beside the page or route:\n\n\\`\\`\\`ts\nexport const params = z.object({ slug: z.string().min(1) })\nexport const searchParams = z.object({ page: z.coerce.number().int().min(1).default(1) })\n\\`\\`\\`\n\nValues arrive parsed and typed — \\`?page=3\\` is the number 3, a missing one is\nthe default. Never hand-parse \\`Number(searchParams.get('page'))\\`.\n\n**Api route bodies** the same way:\n\n\\`\\`\\`ts\nexport const body = z.object({ title: z.string().min(1) })\n\nexport async function POST(request: Request, { body }) {\n const { title } = await body\n}\n\\`\\`\\`\n\nThe failures answer differently on purpose:\n\n bad params 404 — the url does not describe a page\n bad searchParams the error boundary (400 for an api route)\n bad body 422, the status an action already uses\n\nA bad query is deliberately NOT a 404, or one bad link makes a real page look\ndeleted.`,\n },\n {\n topic: 'action-client',\n summary: 'Middleware for server actions, so a check cannot be forgotten',\n body: `\\`\\`\\`ts title=\"src/server/client.ts\"\n'use server'\nimport { createActionClient } from '@rsc-kit/core/action'\n\nexport const client = createActionClient({ onError: report })\n .use(async ({ next }) => {\n const user = await currentUser()\n\n if (!user) throw new ServerAuthenticationError()\n\n return next({ ctx: { user } })\n })\n\\`\\`\\`\n\n\\`\\`\\`ts title=\"src/server/posts.ts\"\n'use server'\nimport { client } from './client'\n\nexport const createPost = client.input(schema).handler(async ({ input, ctx }) => …)\nexport const getPosts = client.query(async ({ ctx }) => …)\n\\`\\`\\`\n\n\\`.handler()\\` is a mutation (POST). \\`.query()\\` is a read (GET). Both run the\nchain, so \\`ctx.user\\` is typed and non-null inside them.\n\nThe point is not convenience. An action cannot be added without the check,\nbecause there is no other constructor to reach for.\n\nStack clients for a narrower rule:\n\n\\`\\`\\`ts\nexport const admin = client.use(async ({ ctx, next }) => {\n if (!ctx.user.isAdmin) throw new ServerAuthorizationError()\n return next({ ctx })\n})\n\\`\\`\\`\n\nRoute \\`middleware.ts\\` does NOT run for actions — an action renders no route.\nThat is why the check goes here.`,\n },\n {\n topic: 'data',\n summary: 'Loading data, streaming it, and when the browser needs to refetch',\n body: `**In a server component, just await it.** No loader, no getServerSideProps.\n\n\\`\\`\\`tsx\nexport default async function Page() {\n const posts = await db.posts.all()\n}\n\\`\\`\\`\n\n**Better: do not await.** Pass the promise down and let a client component\nresolve it — the shell paints at once and the rows stream into the same\nresponse, with no request from the browser:\n\n\\`\\`\\`tsx\nexport default function Page() {\n const posts = getPosts() // not awaited\n\n return (\n <Suspense fallback={<Skeleton />}>\n <List posts={posts} /> {/* 'use client': use(posts) */}\n </Suspense>\n )\n}\n\\`\\`\\`\n\nReach for this first. It is the thing RSC is for.\n\n**When the BROWSER decides to refetch** — a filter, a poll, a refresh — that is\na cache library's job and this package does not ship one:\n\n\\`\\`\\`tsx\nuseQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })\nuseSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))\n\\`\\`\\`\n\n\\`fetchQuery\\` sends the read as a GET and goes to the server every time, which\nis what a fetcher needs — staleness and revalidation belong to the library\nholding the answer. Do not add a cache on top of it.\n\nKeep the arrow: TanStack calls a bare \\`queryFn\\` with its own context, and a\nserver function serialises whatever it is handed.`,\n },\n {\n topic: 'suspense',\n summary: 'Where boundaries go, and why the build cares',\n body: `A boundary is what lets a page be stored with a hole in it rather than not\nstored at all.\n\n\\`\\`\\`tsx\n<Suspense fallback={<Skeleton />}>\n <Slow />\n</Suspense>\n\\`\\`\\`\n\nOr a \\`loading.tsx\\` beside the page, which is the same thing for the whole\nroute.\n\nThe build renders every page. Whatever has not resolved when the budget expires\nbecomes the hole; everything above it is stored and served instantly. So a page\nwith no boundary above its slow part cannot be stored at all — the build says\nso:\n\n ƒ /orders\n blocks before anything can paint. Add a loading.tsx beside it, or put a\n <Suspense> above the waiting, and it has a skeleton to store.\n\nA boundary does NOT fix a frozen \\`Date.now()\\`. Prerendering renders straight\nthrough a component that never awaits, so the value is captured exactly as\nbefore. A boundary becomes a hole only when something inside it waits.`,\n },\n {\n topic: 'offline',\n summary: 'Service worker, and what it does and does not cache',\n body: `\\`\\`\\`ts title=\"vite.config.ts\"\nrscKit({ offline: true })\n\\`\\`\\`\n\nThe build writes a service worker that precaches the client bundle and caches\npages at runtime — a document fetch warms its payload, a payload fetch warms its\ndocument, so a page reached by a link still works when reloaded offline.\n\nPages the build stored whole are served from the cache FIRST, because they\ncannot change until a deploy and a deploy sweeps the cache. Everything else is\nnetwork-first with the cache as fallback.\n\nNothing marked \\`no-store\\` is ever kept — which is how a guarded page and a\nsession-reading query stay out of a cache that has no notion of who asked.\n\nIn a component:\n\n\\`\\`\\`tsx\nimport { useOnline } from '@rsc-kit/core/useOnline'\n\nconst online = useOnline()\n\\`\\`\\`\n\nThere is no push and no background sync. Push needs a subscription endpoint and\na sender; background sync needs idempotent replay. Both are the app's decisions.`,\n },\n {\n topic: 'pwa',\n summary: 'Making the app installable',\n body: `A manifest file beside the routes:\n\n\\`\\`\\`ts title=\"src/app/manifest.ts\"\nimport type { WebManifest } from '@rsc-kit/core/manifest-file'\n\nexport default {\n name: 'Orders',\n shortName: 'Orders',\n themeColor: '#0b0b0c',\n backgroundColor: '#ffffff',\n} satisfies WebManifest\n\\`\\`\\`\n\nRead at build time, so it must be an object literal — not computed, not\nimported from elsewhere.\n\n**Icons need no listing.** Put them in \\`src/app/\\` and the build finds them:\n\n favicon.ico served at /favicon.ico\n icon-192.png <link rel=\"icon\">, and the manifest's icons\n icon-512.png\n apple-icon.png <link rel=\"apple-touch-icon\">\n opengraph-image.png <meta property=\"og:image\">\n twitter-image.png <meta name=\"twitter:image\">\n\nSizes are read from the filename. The build says whether it worked:\n\n [rsc-kit] manifest: Orders is installable\n [rsc-kit] manifest: no icons, so no browser will offer to install this.\n\nThere is no layout to edit — React hoists the tags into <head>.\n\nPair it with \\`offline: true\\`. They are separate options because they are\nseparate decisions.`,\n },\n {\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\nThey run their directory's \\`middleware.ts\\`, so an endpoint under a guarded\npath is guarded.\n\nA \\`GET\\` that reads nothing from the request is answered from disk. Awaiting\n\\`searchParams\\` says the answer depends on the query; never touching it means\nthe stored answer is served for any query at all.\n\nExporting a \\`body\\` schema consumes the stream, so \\`request.json()\\` inside the\nhandler will find it already read. Use the parsed value.`,\n },\n {\n topic: 'authorization',\n summary: 'Guarding pages, actions, api routes and queries',\n body: `Each entry point defends itself. There is no single place that covers all of\nthem, and believing otherwise is how a hole is left.\n\n**A page or an api route**: \\`middleware.ts\\` in its directory guards everything\nat or below it.\n\n\\`\\`\\`ts title=\"src/app/admin/middleware.ts\"\nimport { redirect } from '@rsc-kit/core/redirect'\n\nexport default async function guard() {\n if (!(await currentUser())) redirect('/login')\n}\n\\`\\`\\`\n\n**An action or a query**: middleware does NOT run — they render no route. Build\nthem from an action client so the check cannot be forgotten. See the\n\\`action-client\\` topic.\n\n**Authorise on identity, not arguments.** \\`deletePost(id)\\` that trusts the id\nis the whole of an IDOR: the caller chooses the id, so check the row belongs to\n\\`ctx.user\\`.\n\nA guarded page can still be frozen at build time — the guard is a serving\ndecision, not a build one. Its response is marked private so no cache keeps it.`,\n },\n {\n topic: 'dynamic',\n summary: 'Why a page is not static, and how to choose',\n body: `A page is stored at build time unless it reads the request. Reading it is what\nopts out, and the accessors are async:\n\n\\`\\`\\`ts\nimport { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'\n\nconst theme = (await cookies()).get('theme')\nawait connection() // \"render this per visitor\", said deliberately\n\\`\\`\\`\n\nA page's \\`params\\` and \\`searchParams\\` props are promises for the same reason.\n\nThe build says which call did it, per route:\n\n ◐ /locale 85 kB\n dynamic — called cookies(), headers()\n\nThat is usually correct — a page whose content depends on who is asking cannot\nbe one stored file. Change it only when the read was accidental.\n\nFor a parameterised route, \\`generateStaticParams\\` turns one shell into a page\nper url:\n\n\\`\\`\\`ts\nexport async function generateStaticParams() {\n return (await db.posts.all()).map((p) => ({ slug: p.slug }))\n}\n\\`\\`\\`\n\nA value that must differ per visitor but needs no server — a clock,\nlocalStorage, a map — belongs in the browser only:\n\n\\`\\`\\`tsx\n'use client'\nimport { browser } from 'react-dom'\n\nfunction Clock() {\n use(browser('the time is the visitor\\\\'s, not the build machine\\\\'s'))\n}\n\\`\\`\\`\n\nIt needs a Suspense boundary, and the page stays frozen.`,\n },\n]\n\n/** Every topic, with one line each — what a caller reads before choosing. */\nexport function listTopics(): string {\n return [\n 'Topics. Ask for one with how_to({ topic }).',\n '',\n ...RECIPES.map((r) => `${r.topic.padEnd(16)} ${r.summary}`),\n ].join('\\n')\n}\n\n/** One recipe, or the list plus a nudge when the topic is not one. */\nexport function howTo(topic: string): string {\n const wanted = topic.trim().toLowerCase().replace(/[\\s_]+/g, '-')\n const found =\n RECIPES.find((r) => r.topic === wanted) ??\n // A near miss is common and worth answering rather than refusing: someone\n // asks for \"form\" or \"queries\" and means the obvious thing.\n RECIPES.find((r) => r.topic.startsWith(wanted) || wanted.startsWith(r.topic)) ??\n RECIPES.find((r) => r.summary.toLowerCase().includes(wanted))\n\n if (!found) return `No topic \"${topic}\".\\n\\n${listTopics()}`\n\n return `# ${found.topic} — ${found.summary}\\n\\n${found.body}`\n}\n\n/** For tests, so a recipe cannot be added without being reachable. */\nexport const TOPICS = RECIPES.map((r) => r.topic)\n"]}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ReportedRoute {
|
|
2
|
+
url: string;
|
|
3
|
+
component: string;
|
|
4
|
+
type: string;
|
|
5
|
+
reason: string | null;
|
|
6
|
+
warning: string | null;
|
|
7
|
+
clientJs: number | null;
|
|
8
|
+
}
|
|
9
|
+
export interface ReportedApiRoute {
|
|
10
|
+
url: string;
|
|
11
|
+
name: string;
|
|
12
|
+
type: string;
|
|
13
|
+
reason: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface BuildReport {
|
|
16
|
+
version: number;
|
|
17
|
+
routes: ReportedRoute[];
|
|
18
|
+
apis: ReportedApiRoute[];
|
|
19
|
+
totals: {
|
|
20
|
+
static: number;
|
|
21
|
+
partial: number;
|
|
22
|
+
dynamic: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export declare class NoReport extends Error {
|
|
27
|
+
constructor(root: string);
|
|
28
|
+
}
|
|
29
|
+
/** The report, and how old it is. */
|
|
30
|
+
export declare function loadReport(root: string): {
|
|
31
|
+
report: BuildReport;
|
|
32
|
+
builtAt: Date;
|
|
33
|
+
from: string;
|
|
34
|
+
};
|
|
35
|
+
/** What each classification means, in one line, for an answer that has to stand alone. */
|
|
36
|
+
export declare const MEANING: Record<string, string>;
|
|
37
|
+
/** A route by url, tolerating a trailing slash either way. */
|
|
38
|
+
export declare function routeFor(report: BuildReport, url: string): ReportedRoute | ReportedApiRoute | null;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Reading what the build wrote down.
|
|
2
|
+
//
|
|
3
|
+
// Everything this server answers comes from one file: `build-report.json`, in
|
|
4
|
+
// the build's own output directory. Nothing here runs a build, imports the
|
|
5
|
+
// app, or re-derives the route tree — the build already decided all of this,
|
|
6
|
+
// and a second implementation would be a second thing that can be wrong.
|
|
7
|
+
//
|
|
8
|
+
// The consequence to be honest about: an answer is only as fresh as the last
|
|
9
|
+
// build. So every answer says when it was built, and a missing report says
|
|
10
|
+
// "run a build" rather than "there are no routes".
|
|
11
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
/** Where a build leaves its report, in the order worth looking. */
|
|
14
|
+
const LIKELY = ['.rsc', 'build', 'dist', '.output'];
|
|
15
|
+
export class NoReport extends Error {
|
|
16
|
+
constructor(root) {
|
|
17
|
+
super(`No build report under ${root}. This server answers from what the last build ` +
|
|
18
|
+
'decided, so there has to have been one — run the build and ask again.');
|
|
19
|
+
this.name = 'NoReport';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** The report, and how old it is. */
|
|
23
|
+
export function loadReport(root) {
|
|
24
|
+
const base = resolve(root);
|
|
25
|
+
for (const dir of LIKELY) {
|
|
26
|
+
const file = join(base, dir, 'build-report.json');
|
|
27
|
+
if (!existsSync(file))
|
|
28
|
+
continue;
|
|
29
|
+
return {
|
|
30
|
+
report: JSON.parse(readFileSync(file, 'utf-8')),
|
|
31
|
+
// The file's own mtime rather than a timestamp inside it: a stamp written
|
|
32
|
+
// into the file changes the file on every build even when nothing else
|
|
33
|
+
// did, which defeats every cache keyed on its contents.
|
|
34
|
+
builtAt: statSync(file).mtime,
|
|
35
|
+
from: file,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
throw new NoReport(base);
|
|
39
|
+
}
|
|
40
|
+
/** What each classification means, in one line, for an answer that has to stand alone. */
|
|
41
|
+
export const MEANING = {
|
|
42
|
+
frozen: 'stored whole at build time and served as a file',
|
|
43
|
+
shell: 'a stored shell, with the rest rendered per request',
|
|
44
|
+
blocked: 'could not be stored at all — rendered per request',
|
|
45
|
+
dynamic: 'answered per request',
|
|
46
|
+
error: 'failed to render',
|
|
47
|
+
};
|
|
48
|
+
/** A route by url, tolerating a trailing slash either way. */
|
|
49
|
+
export function routeFor(report, url) {
|
|
50
|
+
const wanted = (url.split('?')[0].replace(/\/+$/, '') || '/').toLowerCase();
|
|
51
|
+
const matches = (candidate) => (candidate.replace(/\/+$/, '') || '/').toLowerCase() === wanted;
|
|
52
|
+
return (report.routes.find((r) => matches(r.url)) ?? report.apis.find((a) => matches(a.url)) ?? null);
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +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;AAyBzC,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,mDAAmD;IAC5D,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 BuildReport {\n version: number\n routes: ReportedRoute[]\n apis: ReportedApiRoute[]\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: 'could not be stored at all — rendered per request',\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"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rsc-kit/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"rsc-kit-mcp": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "bun test tests",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
23
|
+
"prepack": "bun run build"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
27
|
+
"zod": "^3.25"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/bun": "^1.4.2",
|
|
31
|
+
"typescript": "^7.0.2"
|
|
32
|
+
}
|
|
33
|
+
}
|