@rsc-kit/mcp 0.18.0 → 0.19.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/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +273 -18
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +41 -8
- package/guides/authorization.md +2 -2
- package/guides/backend-answered-pages.md +74 -3
- package/guides/bun.md +76 -0
- package/guides/coming-from-next.md +18 -3
- package/guides/deployment.md +10 -0
- package/guides/emails.md +13 -1
- package/guides/feature-flags.md +63 -0
- package/guides/fonts.md +25 -0
- package/guides/forms.md +103 -0
- package/guides/index.json +15 -0
- package/guides/installation.md +38 -13
- package/guides/laravel.md +27 -2
- package/guides/mcp.md +11 -5
- package/guides/offline.md +9 -4
- package/guides/openapi.md +99 -0
- package/guides/redirects.md +12 -1
- package/guides/server-actions.md +51 -7
- package/guides/testing.md +25 -2
- package/guides/typed-routes.md +16 -9
- package/guides/url-validation.md +8 -2
- package/guides/where-it-runs.md +87 -9
- package/guides/your-own-backend.md +28 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// Everything is read-only. There is no tool here that edits, builds or deploys,
|
|
21
21
|
// deliberately: an agent already has a shell for those, and a server that can
|
|
22
22
|
// change the project is one that can change it while answering a question.
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
23
24
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
24
25
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
25
26
|
import { z } from 'zod';
|
|
@@ -61,7 +62,18 @@ const answering = (produce) => {
|
|
|
61
62
|
throw error;
|
|
62
63
|
}
|
|
63
64
|
};
|
|
64
|
-
|
|
65
|
+
// The published version, read from this package's own manifest: the server
|
|
66
|
+
// answered `0.1.0` to every client, which is the repo's placeholder and told
|
|
67
|
+
// nobody which release they were talking to.
|
|
68
|
+
const selfVersion = (() => {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).version ?? '0.0.0';
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return '0.0.0';
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
const server = new McpServer({ name: 'rsc-kit', version: selfVersion });
|
|
65
77
|
server.registerTool('list_routes', {
|
|
66
78
|
title: 'List routes',
|
|
67
79
|
description: 'Every route in this rsc-kit app, what the build did with each one, and how much javascript it ships. Start here when you need to know what exists.',
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n// An MCP server over what an rsc-kit build decided.\n//\n// claude mcp add rsc-kit -- npx -y @rsc-kit/mcp\n//\n// Four questions, all answered from `build-report.json` and none of them\n// requiring the app to be running:\n//\n// what routes exist, and what happened to each\n// why is this one not stored\n// which ones render per request\n// which ones cost the browser the most\n//\n// Why a server rather than letting an agent read the file: the file is JSON\n// with a `type` field whose values mean nothing without the documentation, and\n// an agent reading it guesses — \"shell\" invites being treated as a failure when\n// it is the normal, correct outcome for a page with data in it. These answers\n// say what each state means, in the same words the build printed.\n//\n// Everything is read-only. There is no tool here that edits, builds or deploys,\n// deliberately: an agent already has a shell for those, and a server that can\n// change the project is one that can change it while answering a question.\n\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport { z } from 'zod'\nimport { explainRoute, heaviestRoutes, listRoutes, whatIsDynamic } from './answers.js'\nimport { NoReport, loadReport } from './report.js'\nimport { howTo, listTopics } from './recipes.js'\nimport { listGuides, readGuide, searchGuides } from './bundleGuides.js'\n\n/** The project to read, from the argument or the working directory. */\nconst root = process.argv[2] ?? process.cwd()\n\n/**\n * Loaded per call, never cached.\n *\n * A build happens while this server is running — that is the normal case, not\n * the exception — and an answer from the build before it is worse than a slow\n * one. The file is small and this is not a hot path.\n */\nconst read = () => loadReport(root)\n\n/**\n * Declared once, outside the call.\n *\n * Inline, TypeScript walks the sdk's tool generics against the zod shape and\n * gives up with \"type instantiation is excessively deep\" — a compiler limit\n * rather than anything wrong with the schema. A named const with the handler's\n * argument annotated stops the inference chain before it gets there.\n */\nconst URL_ARG = { url: z.string().describe('The url, e.g. /orders or /posts/hello') }\nconst TOPIC_ARG = { topic: z.string().describe('One of the topics from list_topics, e.g. forms or validation') }\nconst SLUG_ARG = { slug: z.string().describe('One of the slugs from list_guides, e.g. forms or server-actions') }\nconst PHRASE_ARG = { phrase: z.string().describe('A word or phrase, e.g. fieldErrors or metadataBase') }\n\nconst text = (body: string) => ({ content: [{ type: 'text' as const, text: body }] })\n\n/** A missing report is an answer, not a crash: it says to run a build. */\nconst answering = (produce: () => string) => {\n try {\n return text(produce())\n } catch (error) {\n if (error instanceof NoReport) return text(error.message)\n\n throw error\n }\n}\n\n/**\n * The registration surface, named rather than inferred.\n *\n * `registerTool` is generic over the zod shape, and TypeScript walks those\n * generics until it gives up — \"type instantiation is excessively deep\", which\n * is a compiler limit rather than anything wrong with the schema. Describing\n * the one method used, with the argument type written out, stops the inference\n * before it gets there and costs nothing: the schemas below are still real zod\n * and still validate at runtime.\n */\ninterface Registrar {\n registerTool(\n name: string,\n config: {\n title?: string\n description?: string\n inputSchema?: Record<string, unknown>\n annotations?: { readOnlyHint?: boolean }\n },\n handler: (args: never) => Promise<{ content: { type: 'text'; text: string }[] }>,\n ): unknown\n connect(transport: StdioServerTransport): Promise<void>\n}\n\nconst server = new McpServer({ name: 'rsc-kit', version: '0.1.0' }) as unknown as Registrar\n\nserver.registerTool(\n 'list_routes',\n {\n title: 'List routes',\n description:\n 'Every route in this rsc-kit app, what the build did with each one, and how much javascript it ships. Start here when you need to know what exists.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return listRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'explain_route',\n {\n title: 'Explain a route',\n description:\n 'Why one url is stored at build time or rendered per request, what renders it, and what it costs the browser. Use this before changing a page to make it faster — the reason is recorded, not guessed.',\n inputSchema: URL_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ url }: { url: string }) =>\n answering(() => {\n const { report, builtAt } = read()\n\n return explainRoute(report, url, builtAt, Date.now())\n })) as never,\n)\n\nserver.registerTool(\n 'what_is_dynamic',\n {\n title: 'What renders per request',\n description:\n 'The routes that render per request rather than being stored, each with the reason. This is the answer to \"why is this site not static\".',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return whatIsDynamic(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'heaviest_routes',\n {\n title: 'Heaviest routes',\n description: 'The routes that make the browser download the most javascript, largest first.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return heaviestRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'how_to',\n {\n title: 'How to build it',\n description:\n 'How to do something in an rsc-kit app — forms, prefetching, validation, the action client, data loading with TanStack Query or SWR, Suspense boundaries, offline, PWA, api routes, authorization, a Laravel or other backend, once-per-process startup, and why a page is dynamic. Read this BEFORE writing the code: the patterns here differ from Next and plain React in ways that compile either way. The short answer; read_guide has the full one.',\n inputSchema: TOPIC_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ topic }: { topic: string }) => text(howTo(topic))) as never,\n)\n\nserver.registerTool(\n 'list_topics',\n {\n title: 'What this server can explain',\n description: 'Every topic how_to knows about, one line each.',\n annotations: { readOnlyHint: true },\n },\n async () => text(listTopics()),\n)\n\nserver.registerTool(\n 'list_guides',\n {\n title: 'The guides',\n description: 'Every guide from docs.rsc-kit.dev, bundled with this server — the full text behind how_to, one line each.',\n annotations: { readOnlyHint: true },\n },\n async () => text(listGuides()),\n)\n\nserver.registerTool(\n 'read_guide',\n {\n title: 'Read a guide',\n description:\n 'The complete guide for one topic, as published at docs.rsc-kit.dev — routing, forms, server-actions, validation, metadata, testing, deployment and the rest. Use it when how_to is not enough or names something it does not explain.',\n inputSchema: SLUG_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ slug }: { slug: string }) => text(readGuide(slug))) as never,\n)\n\nserver.registerTool(\n 'search_guides',\n {\n title: 'Search the guides',\n description: 'Every line in the guides that mentions a word or phrase, with the guide it is in. Use it to find which guide covers something before reading it.',\n inputSchema: PHRASE_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ phrase }: { phrase: string }) => text(searchGuides(phrase))) as never,\n)\n\nawait server.connect(new StdioServerTransport())\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\n// An MCP server over what an rsc-kit build decided.\n//\n// claude mcp add rsc-kit -- npx -y @rsc-kit/mcp\n//\n// Four questions, all answered from `build-report.json` and none of them\n// requiring the app to be running:\n//\n// what routes exist, and what happened to each\n// why is this one not stored\n// which ones render per request\n// which ones cost the browser the most\n//\n// Why a server rather than letting an agent read the file: the file is JSON\n// with a `type` field whose values mean nothing without the documentation, and\n// an agent reading it guesses — \"shell\" invites being treated as a failure when\n// it is the normal, correct outcome for a page with data in it. These answers\n// say what each state means, in the same words the build printed.\n//\n// Everything is read-only. There is no tool here that edits, builds or deploys,\n// deliberately: an agent already has a shell for those, and a server that can\n// change the project is one that can change it while answering a question.\n\nimport { readFileSync } from 'node:fs'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport { z } from 'zod'\nimport { explainRoute, heaviestRoutes, listRoutes, whatIsDynamic } from './answers.js'\nimport { NoReport, loadReport } from './report.js'\nimport { howTo, listTopics } from './recipes.js'\nimport { listGuides, readGuide, searchGuides } from './bundleGuides.js'\n\n/** The project to read, from the argument or the working directory. */\nconst root = process.argv[2] ?? process.cwd()\n\n/**\n * Loaded per call, never cached.\n *\n * A build happens while this server is running — that is the normal case, not\n * the exception — and an answer from the build before it is worse than a slow\n * one. The file is small and this is not a hot path.\n */\nconst read = () => loadReport(root)\n\n/**\n * Declared once, outside the call.\n *\n * Inline, TypeScript walks the sdk's tool generics against the zod shape and\n * gives up with \"type instantiation is excessively deep\" — a compiler limit\n * rather than anything wrong with the schema. A named const with the handler's\n * argument annotated stops the inference chain before it gets there.\n */\nconst URL_ARG = { url: z.string().describe('The url, e.g. /orders or /posts/hello') }\nconst TOPIC_ARG = { topic: z.string().describe('One of the topics from list_topics, e.g. forms or validation') }\nconst SLUG_ARG = { slug: z.string().describe('One of the slugs from list_guides, e.g. forms or server-actions') }\nconst PHRASE_ARG = { phrase: z.string().describe('A word or phrase, e.g. fieldErrors or metadataBase') }\n\nconst text = (body: string) => ({ content: [{ type: 'text' as const, text: body }] })\n\n/** A missing report is an answer, not a crash: it says to run a build. */\nconst answering = (produce: () => string) => {\n try {\n return text(produce())\n } catch (error) {\n if (error instanceof NoReport) return text(error.message)\n\n throw error\n }\n}\n\n/**\n * The registration surface, named rather than inferred.\n *\n * `registerTool` is generic over the zod shape, and TypeScript walks those\n * generics until it gives up — \"type instantiation is excessively deep\", which\n * is a compiler limit rather than anything wrong with the schema. Describing\n * the one method used, with the argument type written out, stops the inference\n * before it gets there and costs nothing: the schemas below are still real zod\n * and still validate at runtime.\n */\ninterface Registrar {\n registerTool(\n name: string,\n config: {\n title?: string\n description?: string\n inputSchema?: Record<string, unknown>\n annotations?: { readOnlyHint?: boolean }\n },\n handler: (args: never) => Promise<{ content: { type: 'text'; text: string }[] }>,\n ): unknown\n connect(transport: StdioServerTransport): Promise<void>\n}\n\n// The published version, read from this package's own manifest: the server\n// answered `0.1.0` to every client, which is the repo's placeholder and told\n// nobody which release they were talking to.\nconst selfVersion = (() => {\n try {\n return (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { version?: string }).version ?? '0.0.0'\n } catch {\n return '0.0.0'\n }\n})()\n\nconst server = new McpServer({ name: 'rsc-kit', version: selfVersion }) as unknown as Registrar\n\nserver.registerTool(\n 'list_routes',\n {\n title: 'List routes',\n description:\n 'Every route in this rsc-kit app, what the build did with each one, and how much javascript it ships. Start here when you need to know what exists.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return listRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'explain_route',\n {\n title: 'Explain a route',\n description:\n 'Why one url is stored at build time or rendered per request, what renders it, and what it costs the browser. Use this before changing a page to make it faster — the reason is recorded, not guessed.',\n inputSchema: URL_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ url }: { url: string }) =>\n answering(() => {\n const { report, builtAt } = read()\n\n return explainRoute(report, url, builtAt, Date.now())\n })) as never,\n)\n\nserver.registerTool(\n 'what_is_dynamic',\n {\n title: 'What renders per request',\n description:\n 'The routes that render per request rather than being stored, each with the reason. This is the answer to \"why is this site not static\".',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return whatIsDynamic(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'heaviest_routes',\n {\n title: 'Heaviest routes',\n description: 'The routes that make the browser download the most javascript, largest first.',\n annotations: { readOnlyHint: true },\n },\n async () =>\n answering(() => {\n const { report, builtAt } = read()\n\n return heaviestRoutes(report, builtAt, Date.now())\n }),\n)\n\nserver.registerTool(\n 'how_to',\n {\n title: 'How to build it',\n description:\n 'How to do something in an rsc-kit app — forms, prefetching, validation, the action client, data loading with TanStack Query or SWR, Suspense boundaries, offline, PWA, api routes, authorization, a Laravel or other backend, once-per-process startup, and why a page is dynamic. Read this BEFORE writing the code: the patterns here differ from Next and plain React in ways that compile either way. The short answer; read_guide has the full one.',\n inputSchema: TOPIC_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ topic }: { topic: string }) => text(howTo(topic))) as never,\n)\n\nserver.registerTool(\n 'list_topics',\n {\n title: 'What this server can explain',\n description: 'Every topic how_to knows about, one line each.',\n annotations: { readOnlyHint: true },\n },\n async () => text(listTopics()),\n)\n\nserver.registerTool(\n 'list_guides',\n {\n title: 'The guides',\n description: 'Every guide from docs.rsc-kit.dev, bundled with this server — the full text behind how_to, one line each.',\n annotations: { readOnlyHint: true },\n },\n async () => text(listGuides()),\n)\n\nserver.registerTool(\n 'read_guide',\n {\n title: 'Read a guide',\n description:\n 'The complete guide for one topic, as published at docs.rsc-kit.dev — routing, forms, server-actions, validation, metadata, testing, deployment and the rest. Use it when how_to is not enough or names something it does not explain.',\n inputSchema: SLUG_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ slug }: { slug: string }) => text(readGuide(slug))) as never,\n)\n\nserver.registerTool(\n 'search_guides',\n {\n title: 'Search the guides',\n description: 'Every line in the guides that mentions a word or phrase, with the guide it is in. Use it to find which guide covers something before reading it.',\n inputSchema: PHRASE_ARG,\n annotations: { readOnlyHint: true },\n },\n (async ({ phrase }: { phrase: string }) => text(searchGuides(phrase))) as never,\n)\n\nawait server.connect(new StdioServerTransport())\n"]}
|
package/dist/recipes.js
CHANGED
|
@@ -15,8 +15,15 @@
|
|
|
15
15
|
const RECIPES = [
|
|
16
16
|
{
|
|
17
17
|
topic: 'forms',
|
|
18
|
-
summary: 'Submitting to a server action, with pending state and field errors',
|
|
19
|
-
body: `
|
|
18
|
+
summary: 'Submitting to a server action, with pending state and field errors. Uncontrolled by default - no useState per field',
|
|
19
|
+
body: `THE RULE: forms are UNCONTROLLED. Inputs keep their value in the DOM,
|
|
20
|
+
an initial value is defaultValue, the action reads FormData. Do NOT write
|
|
21
|
+
useState + value/onChange per input, and do NOT reach for TanStack Form.
|
|
22
|
+
Control ONE field only when the UI must react as the user types (a character
|
|
23
|
+
count, a live preview, a dependent select) - bind it with useField, which
|
|
24
|
+
scopes the re-render to that field. Everything else stays uncontrolled.
|
|
25
|
+
|
|
26
|
+
Use <Form>. It takes the server action itself, not a url.
|
|
20
27
|
|
|
21
28
|
\`\`\`tsx
|
|
22
29
|
'use client'
|
|
@@ -531,22 +538,30 @@ page that is 82 kB because of one <Link> is fine.`,
|
|
|
531
538
|
body: `\`src/app/**/route.ts\`, one export per method:
|
|
532
539
|
|
|
533
540
|
\`\`\`ts title="src/app/api/posts/[id]/route.ts"
|
|
541
|
+
import type { RouteContext } from '@rsc-kit/core/route-schema'
|
|
542
|
+
|
|
534
543
|
export const params = z.object({ id: z.coerce.number().int() })
|
|
535
544
|
export const body = z.object({ title: z.string().min(1) })
|
|
536
545
|
|
|
537
|
-
export async function GET(request: Request, { params }) {
|
|
546
|
+
export async function GET(request: Request, { params }: RouteContext<typeof params>) {
|
|
538
547
|
const { id } = await params
|
|
539
548
|
|
|
540
549
|
return Response.json(await findPost(id))
|
|
541
550
|
}
|
|
542
551
|
|
|
543
|
-
export async function POST(request: Request, { params, body }) {
|
|
552
|
+
export async function POST(request: Request, { params, body }: RouteContext<typeof params, never, typeof body>) {
|
|
544
553
|
const { title } = await body
|
|
545
554
|
|
|
546
555
|
return Response.json(await createPost(title), { status: 201 })
|
|
547
556
|
}
|
|
548
557
|
\`\`\`
|
|
549
558
|
|
|
559
|
+
TYPE THE CONTEXT: RouteContext<'/api/items/[id]'> types params from the
|
|
560
|
+
route's segments (the pattern is checked against the routes the build found);
|
|
561
|
+
RouteContext<typeof params> from a schema. Every field is a PROMISE - a sync
|
|
562
|
+
params.id is a compile error, never a route that 404s. Do NOT hand-write
|
|
563
|
+
{ params: { id: string } }.
|
|
564
|
+
|
|
550
565
|
A real \`Request\` in, a real \`Response\` out. \`params\`, \`searchParams\` and
|
|
551
566
|
\`body\` are awaited, the same way a page's props are.
|
|
552
567
|
|
|
@@ -556,17 +571,31 @@ build found:
|
|
|
556
571
|
import { apiUrl } from '@rsc-kit/core/routes'
|
|
557
572
|
await fetch(apiUrl('/api/posts/' + id))
|
|
558
573
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
574
|
+
A route.ts is a Route too (type Route from '@rsc-kit/core/routes' covers pages
|
|
575
|
+
AND route.ts files, as Next's does): Link, visit and redirect accept it. The
|
|
576
|
+
client treats a link to a route as an anchor - never prefetched, a full
|
|
577
|
+
navigation, not a payload fetch. ApiRoute is the narrower union for apiUrl:
|
|
578
|
+
apiUrl refuses a page, because fetching one gets html. It checks the PATH, not
|
|
579
|
+
the response type - for types across the boundary use a server action or a
|
|
580
|
+
query, where the return type is the function's because it is the same function.
|
|
563
581
|
|
|
564
582
|
They run their directory's \`middleware.ts\`, so an endpoint under a guarded
|
|
565
583
|
path is guarded.
|
|
566
584
|
|
|
567
585
|
A \`GET\` that reads nothing from the request is answered from disk. Awaiting
|
|
568
586
|
\`searchParams\` says the answer depends on the query; never touching it means
|
|
569
|
-
the stored answer is served for any query at all.
|
|
587
|
+
the stored answer is served for any query at all. NEVER read the query with
|
|
588
|
+
new URL(request.url).searchParams (the Next way): the build cannot see that
|
|
589
|
+
read, and reading request.url at all makes the route dynamic (the table says
|
|
590
|
+
"reads the request - url"). A webhook verification handshake (hub.mode,
|
|
591
|
+
hub.challenge) reads the awaited searchParams. A GET that answers 4xx/5xx to
|
|
592
|
+
the build is never stored either (the table says "answered 403 to the build").
|
|
593
|
+
|
|
594
|
+
redirect() thrown from a handler is the route's answer: a real 3xx Location
|
|
595
|
+
for whoever asked (a signed-url export, a moved endpoint); notFound() is its
|
|
596
|
+
404. A guard's redirect above a route.ts is a refusal: a browser that
|
|
597
|
+
navigated there gets the Location, code that fetched it gets 401 +
|
|
598
|
+
X-RSC-Redirect (fetch would follow a Location and hand back the login page).
|
|
570
599
|
|
|
571
600
|
Exporting a \`body\` schema consumes the stream, so \`request.json()\` inside the
|
|
572
601
|
handler will find it already read. Use the parsed value.
|
|
@@ -614,7 +643,7 @@ opts out, and the accessors are async:
|
|
|
614
643
|
\`\`\`ts
|
|
615
644
|
import { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'
|
|
616
645
|
|
|
617
|
-
const theme = (await cookies()).get('theme')
|
|
646
|
+
const theme = (await cookies()).get('theme')?.value // { name, value } | undefined, as in Next
|
|
618
647
|
await connection() // "render this per visitor", said deliberately
|
|
619
648
|
\`\`\`
|
|
620
649
|
|
|
@@ -750,6 +779,61 @@ scores 99. Three moves:
|
|
|
750
779
|
range you ask for (font-weight: 400 500) so nothing requests a missing file.
|
|
751
780
|
Put the preloads before the <style> with the faces. Measured on rsc-kit.dev:
|
|
752
781
|
Speed Index 1.7s to 0.9s, 99 to a steady 100, fonts 114 kB to 74 kB.
|
|
782
|
+
THE RULE: a font never blocks the page - text paints in the fallback before
|
|
783
|
+
the web font arrives. Three ways to break it, all avoided: a fonts.googleapis
|
|
784
|
+
<link> (render-blocking CSS from a cold origin - self-host via Fontsource
|
|
785
|
+
instead), font-display: block or unset (invisible text for up to 3s - every
|
|
786
|
+
rule says swap or optional), preloading every file (preload only what the
|
|
787
|
+
first paint needs).
|
|
788
|
+
|
|
789
|
+
AFTER THE ANSWER: after(() => sendEmail(user)) from @rsc-kit/core/request
|
|
790
|
+
queues work to run once the response is on its way - from an action, a
|
|
791
|
+
component, middleware or an api route. Do NOT use a detached promise: on a
|
|
792
|
+
Worker the isolate dies with the response unless work is handed to
|
|
793
|
+
waitUntil, which after() does; on a process it runs detached. Rejections
|
|
794
|
+
are logged, never surfaced.
|
|
795
|
+
|
|
796
|
+
WRITE THE SCHEMA FOR THE SHAPE IT WANTS. The form is read the way the schema
|
|
797
|
+
means it, on both sides (Zod 4 / ArkType describe themselves as JSON Schema;
|
|
798
|
+
Valibot not yet - its values arrive as strings):
|
|
799
|
+
notify: z.boolean() // unchecked posts nothing -> false; "on" -> true
|
|
800
|
+
limit: z.number().int() // "5" -> 5. NOT z.coerce.number()
|
|
801
|
+
tags: z.array(z.string()) // one -> ['a'], none ticked -> []
|
|
802
|
+
policy: z.string().optional() // hidden behind a switch -> absent when off
|
|
803
|
+
+ .refine((s) => !s.notify || s.policy) for "required when the switch is on"
|
|
804
|
+
Nested names nest: fields[0][name] / fields[0].name -> { fields: [{ name }] };
|
|
805
|
+
auth[kind] picks a discriminated union's branch. No per-checkbox transform,
|
|
806
|
+
no checkbox() helper. The action decodes the same object the form validated.
|
|
807
|
+
Offline (rscKit({ offline: true })): the precache is what boots the app - js,
|
|
808
|
+
css, fonts, manifest, icons, / and /offline with their boot payloads; images,
|
|
809
|
+
wasm and the share card are cached on first use. A frozen page never visited
|
|
810
|
+
falls back to /offline like any other navigation. Do not add a second service
|
|
811
|
+
worker or a precache list; app/sw.js is importScripts'd into this one.
|
|
812
|
+
|
|
813
|
+
An action that redirect()s RESOLVES with { redirected: '/where' } on the
|
|
814
|
+
client once the navigation starts - it does not throw, so a plain
|
|
815
|
+
startTransition(async () => await logOut()) needs no catch (a rejection
|
|
816
|
+
there unmounts the root). The result is always an object (exactly one of
|
|
817
|
+
data / validationErrors / serverError / redirected is set), so reading any
|
|
818
|
+
field of it is safe. Do NOT wrap actions in a hook to catch
|
|
819
|
+
ServerRedirectError; nothing throws.
|
|
820
|
+
|
|
821
|
+
Before hydration a submit is a native POST to the page's url (React's hidden
|
|
822
|
+
$ACTION_ fields); the host runs the action and re-renders the page with the
|
|
823
|
+
result seated in the <Form> that posted - a refusal shows on its fields
|
|
824
|
+
without javascript; a redirect() is followed, a cookie lands. Nothing to
|
|
825
|
+
configure; <Form> uses useActionState under a wrapper so the action keeps
|
|
826
|
+
its (formData) signature.
|
|
827
|
+
A blank control is absent for any optional field (z.email().optional()
|
|
828
|
+
accepts it; an optional union is not read as its first branch) and "" for a
|
|
829
|
+
required one (z.string().min(1) refuses it). A leaf
|
|
830
|
+
with no JSON Schema (z.date()) arrives as posted; its siblings still coerce.
|
|
831
|
+
<Form ref={...}> is fine: the caller's ref is filled beside the form's own.
|
|
832
|
+
Render props also carry dirty (anything differs from mount, uncontrolled
|
|
833
|
+
fields included; baseline moves on a successful submit; reset() clears it):
|
|
834
|
+
{({ dirty, reset }) => <Button disabled={!dirty}>Save</Button>} - the RHF
|
|
835
|
+
isDirty gate for Save/Discard.
|
|
836
|
+
|
|
753
837
|
Full guide: read_guide({ slug: 'fonts' }).`,
|
|
754
838
|
},
|
|
755
839
|
{
|
|
@@ -772,15 +856,23 @@ IMPORTS
|
|
|
772
856
|
Metadata -> @rsc-kit/core/metadata (metadataBase, openGraph, twitter, icons as-is)
|
|
773
857
|
app/robots.ts, app/sitemap.ts -> the same files and shapes; app/llms.ts beside them (how_to seo-files)
|
|
774
858
|
middleware.ts subdomain rewrite -> nothing: a host is a route segment (how_to domains)
|
|
859
|
+
flags/next (Vercel Flags SDK) -> unchanged: the build aliases next/headers to @rsc-kit/core/request
|
|
860
|
+
(how_to feature-flags); precompute() does not carry over
|
|
775
861
|
next/font -> Fontsource (how_to fonts)
|
|
776
862
|
next/image -> unpic or vite-imagetools (how_to images)
|
|
777
863
|
next/script -> a <script> tag (how_to scripts)
|
|
778
864
|
NEXT_PUBLIC_* -> PUBLIC_* in src/env.ts (how_to env); server vars typed there too
|
|
865
|
+
import 'server-only' -> keep it (the build honours it). Under bun test the real package throws on
|
|
866
|
+
import, so the scaffold's tests/preload.ts stubs it: bunfig.toml
|
|
867
|
+
[test] preload = ["./tests/preload.ts"], mock.module('server-only', () => ({})).
|
|
868
|
+
A project without those two files adds them before unit-testing an action.
|
|
779
869
|
next-safe-action -> createActionClient() (how_to action-client); returnValidationErrors -> return fieldErrors({...})
|
|
780
870
|
cache from 'react' -> cache from @rsc-kit/core/cache: React's dedupes only inside a render; this one
|
|
781
871
|
spans the request (guards, actions, api routes). The build names files still on React's
|
|
782
872
|
@react-email/render, renderToString in an action -> the same call, in a module that starts with "use ssr"
|
|
873
|
+
(the build warns naming the app file and the package; the stub throws when called)
|
|
783
874
|
(how_to emails). Next gets away with it only for externalised packages; here it is explicit
|
|
875
|
+
import type { Route } from 'next' -> import type { Route } from '@rsc-kit/core/routes' (pages AND route.ts; a link to a route.ts is an anchor, never prefetched)
|
|
784
876
|
|
|
785
877
|
DIFFERENT ON PURPOSE
|
|
786
878
|
- No export const dynamic / revalidate = 60. A page is frozen unless it READS
|
|
@@ -789,7 +881,10 @@ DIFFERENT ON PURPOSE
|
|
|
789
881
|
It does not run for actions - the check goes in the action.
|
|
790
882
|
- Actions return failures ({ validationErrors }, { serverError }), not throw.
|
|
791
883
|
- No image optimizer, no opengraph-image.tsx - put opengraph-image.png in src/app.
|
|
792
|
-
- Tests need no browser: createTestApp() is the deployed handler.
|
|
884
|
+
- Tests need no browser: createTestApp() is the deployed handler. It builds
|
|
885
|
+
with the project's own build script on the runtime the tests run under, and
|
|
886
|
+
answers files the build wrote to .output/public (assets, sw.js, the
|
|
887
|
+
manifest, icons) as production does - app.fetch('/sw.js') is a real test.
|
|
793
888
|
- A component library (base-ui, Radix) imports as it did, from server
|
|
794
889
|
components too. A shadcn-style components/ui/ folder keeps "use client" at
|
|
795
890
|
the top of each file, as shipped; without it the server evaluates the
|
|
@@ -806,7 +901,40 @@ route that is not ○ names what streams and from which component (a cookies()
|
|
|
806
901
|
in a layout reaches every page; the build says so) -> decide each action the
|
|
807
902
|
build lists as running no middleware -> check.
|
|
808
903
|
|
|
904
|
+
CONVERT THE FORMS AND ACTIONS - do not carry them. useActionState +
|
|
905
|
+
useFormStatus, react-hook-form, TanStack Form and useState-per-input all
|
|
906
|
+
still COMPILE here, which is why a port leaves them. Each becomes
|
|
907
|
+
<Form action={…} schema={…}> (how_to forms) and a createActionClient()
|
|
908
|
+
handler (how_to action-client). Remove the form library when the last form
|
|
909
|
+
is converted. A port that keeps two form systems has ported nothing.
|
|
910
|
+
|
|
809
911
|
Full guide: read_guide({ slug: 'coming-from-next' }).`,
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
topic: 'feature-flags',
|
|
915
|
+
summary: "Vercel's Flags SDK (flags/next) runs unchanged: next/headers is answered by headers()/cookies() here",
|
|
916
|
+
body: `bun add flags. Then flags/next as written for Next:
|
|
917
|
+
|
|
918
|
+
import { flag, dedupe } from 'flags/next'
|
|
919
|
+
const visitor = dedupe(async ({ cookies, headers }) => ({ id: cookies.get('visitor')?.value ?? 'anon' }))
|
|
920
|
+
export const showBanner = flag<boolean, { id: string }>({ key: 'show-banner', identify: visitor, decide: ({ entities }) => entities?.id === 'ada' })
|
|
921
|
+
|
|
922
|
+
// page.tsx (server component)
|
|
923
|
+
const on = await showBanner()
|
|
924
|
+
|
|
925
|
+
The build aliases next/headers to @rsc-kit/core/request - same names, same
|
|
926
|
+
shapes (cookies().get(name)?.value), one object per request, which the SDK's
|
|
927
|
+
dedupe keys on. Nothing to configure, no shim to write.
|
|
928
|
+
|
|
929
|
+
A flag reads the request, so the page renders per visitor: put a <Suspense>
|
|
930
|
+
or loading.tsx above the read and the build stores the rest as a shell (the
|
|
931
|
+
table says "headers() in run, cookies() in run stream per request").
|
|
932
|
+
|
|
933
|
+
Discovery endpoint: a route.ts -
|
|
934
|
+
export const GET = createFlagsDiscoveryEndpoint(async () => getProviderData(flags))
|
|
935
|
+
export const openapi = false
|
|
936
|
+
precompute() does NOT carry over (it rewrites urls in Next middleware); read
|
|
937
|
+
the flag in the page.`,
|
|
810
938
|
},
|
|
811
939
|
{
|
|
812
940
|
topic: 'emails',
|
|
@@ -1015,6 +1143,15 @@ Laravel: composer require rsc-kit/laravel, then php artisan rsc:install. It
|
|
|
1015
1143
|
runs rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is
|
|
1016
1144
|
moved aside - the renderer owns the frontend). Source is resources/js (the route tree is resources/js/app).
|
|
1017
1145
|
|
|
1146
|
+
rpc() is a global the renderer installs in its own process (never imported,
|
|
1147
|
+
never in the browser bundle): one POST to the backend's host-call endpoint
|
|
1148
|
+
with { function, args }, the secret and the visitor's cookie; the answer is
|
|
1149
|
+
the return value as JSON, typed by rpc<T>(). Refusals arrive as their kind
|
|
1150
|
+
(422/401/403/redirect), never a 500; sibling calls in one tick are batched
|
|
1151
|
+
and each resolves the moment the backend answers it.
|
|
1152
|
+
A BAP server bundle carries no database driver, ORM or auth library - the
|
|
1153
|
+
backend owns those.
|
|
1154
|
+
|
|
1018
1155
|
Reach PHP from a server component - rpc() is a global, typed in
|
|
1019
1156
|
.rsc-kit/rsc-env.d.ts, server render only:
|
|
1020
1157
|
|
|
@@ -1031,12 +1168,23 @@ Guard a route in Laravel's vocabulary, no route declared in PHP:
|
|
|
1031
1168
|
// resources/js/app/admin/middleware.ts
|
|
1032
1169
|
export const middleware = ['auth', 'verified', 'can:update,post']
|
|
1033
1170
|
|
|
1171
|
+
Make one: php artisan make:rsc-action Orders --method=cancel --auth
|
|
1172
|
+
--can=update,Order --middleware=throttle:60,1 --revalidate=orders (--rpc for
|
|
1173
|
+
an rpc() class under app/Rsc; no --method = invokable). Do NOT hand-write the
|
|
1174
|
+
attributes from memory; the command writes the ones the registry reads.
|
|
1034
1175
|
Server actions are classes in app/Rsc/Actions; \`php artisan
|
|
1035
1176
|
rsc:action-manifest\` (already in the dev/build scripts) writes the map and
|
|
1036
1177
|
the build writes server-actions.generated.ts beside the app - import
|
|
1037
1178
|
ordersCancel from it in a client component. Rsc::revalidate('orders') in the
|
|
1038
1179
|
action returns the re-rendered region with the answer.
|
|
1039
1180
|
|
|
1181
|
+
Per url: if the React tree has it, React renders it, otherwise Laravel does
|
|
1182
|
+
- including / : the welcome route in routes/web.php answers nothing while
|
|
1183
|
+
resources/js/app/page.tsx exists (the package registers the tree's urls from
|
|
1184
|
+
bootstrap/rsc/vite/routes.json after routes/web.php). Do NOT tell the user
|
|
1185
|
+
to delete the welcome route to make the page show; do not add Laravel routes
|
|
1186
|
+
for React pages.
|
|
1187
|
+
|
|
1040
1188
|
php artisan serve is one worker, which deadlocks the proxy - unless
|
|
1041
1189
|
PHP_CLI_SERVER_WORKERS=4 in .env AND serve --no-reload (Laravel ignores
|
|
1042
1190
|
the variable otherwise). Herd, Valet, FPM, Octane are fine as they are. Production: put the renderer in front (bun
|
|
@@ -1046,10 +1194,13 @@ the web server.
|
|
|
1046
1194
|
Any other language implements the same endpoint - request { function, args },
|
|
1047
1195
|
reply { result | validationErrors | unauthenticated | unauthorized | redirect
|
|
1048
1196
|
| error, revalidate }, answers '__rsc.middleware' with true or a refusal, and
|
|
1049
|
-
a batch { calls: [...] }
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1197
|
+
a batch { calls: [...] } as application/x-ndjson - one line per call AS IT
|
|
1198
|
+
FINISHES, { index, status, ...reply }, in any order, flushed each time
|
|
1199
|
+
(X-Accel-Buffering: no) - or, less good, one JSON { replies: [...] } in
|
|
1200
|
+
order (every call then waits for the slowest). Calls issued in the same
|
|
1201
|
+
render tick travel as one batch, so parallel reads are one backend request
|
|
1202
|
+
and a fast read still resolves while a slow sibling runs; the renderer falls
|
|
1203
|
+
back to single calls for a backend without batches.
|
|
1053
1204
|
|
|
1054
1205
|
Full guides: read_guide({ slug: 'backend-answered-pages' }), read_guide({ slug: 'laravel' }), read_guide({ slug: 'go' }), read_guide({ slug: 'your-own-backend' }).`,
|
|
1055
1206
|
},
|
|
@@ -1080,11 +1231,107 @@ process.env is empty until the first request arrives.
|
|
|
1080
1231
|
A scaffolded app with env validation already has this file importing ./env.
|
|
1081
1232
|
Build machines without production variables: SKIP_ENV_VALIDATION=1.
|
|
1082
1233
|
|
|
1234
|
+
Never NODE_ENV in a .env: Vite sets it (development under vite, production
|
|
1235
|
+
under vite build) and a .env line overrides it for the build, which then
|
|
1236
|
+
compiles pages against React's dev JSX runtime (jsxDEV) and every route
|
|
1237
|
+
fails with React's opaque "message omitted in production builds". The build
|
|
1238
|
+
refuses this and names the file:line. A plugin cannot override it (Vite
|
|
1239
|
+
applies the .env value after plugins run), so remove the line - other tools
|
|
1240
|
+
that want it keep it in their own .env.
|
|
1241
|
+
|
|
1083
1242
|
Full guide: read_guide({ slug: 'instrumentation' }).`,
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
topic: 'bun',
|
|
1246
|
+
summary: 'Running on Bun - Vite on Bun\'s runtime (bun --bun vite), native deps external (serverExternalPackages), and the gotchas that are Bun\'s not ours',
|
|
1247
|
+
body: `The vite bin has a node shebang: \`bun run dev\` alone starts Vite - dev
|
|
1248
|
+
server, build, prerender - under NODE, and an app importing 'bun' or
|
|
1249
|
+
'bun:sqlite' fails at first render with "Cannot find package 'bun'". Scripts
|
|
1250
|
+
on a Bun app: dev "bun --bun vite", build "bun --bun vite build". createTestApp
|
|
1251
|
+
runs the project's build script on the runtime the tests use.
|
|
1252
|
+
|
|
1253
|
+
Native dependencies (sharp, bcrypt, better-sqlite3, @prisma/client, puppeteer,
|
|
1254
|
+
...) are external to the server bundles by default; Nitro traces them into
|
|
1255
|
+
.output/server/node_modules with their binaries. Add one:
|
|
1256
|
+
rscKit({ serverExternalPackages: ['@acme/native'] }). Same as Next's option.
|
|
1257
|
+
|
|
1258
|
+
The other direction: a dependency with "use client" files that does NOT
|
|
1259
|
+
declare react as a peerDependency (generated wrappers, workspace packages
|
|
1260
|
+
with react under dependencies) would be left external by plugin-rsc and its
|
|
1261
|
+
directive never read - hooks then run on the server. The build detects direct
|
|
1262
|
+
dependencies in that state and bundles them, printing
|
|
1263
|
+
"[rsc-kit] bundling <pkg>: it has "use client" files but does not declare
|
|
1264
|
+
react as a peer dependency". Nothing to configure; fix the package's
|
|
1265
|
+
peerDependencies when it is yours.
|
|
1266
|
+
|
|
1267
|
+
Bun's, not the framework's: bun test loads the package .env (use
|
|
1268
|
+
--env-file=/dev/null to isolate); Stripe's constructEvent throws on Bun
|
|
1269
|
+
(no sync WebCrypto) - use constructEventAsync; Bun's pg puts SQLSTATE in
|
|
1270
|
+
errno where Node's pg uses code.
|
|
1271
|
+
|
|
1272
|
+
Never NODE_ENV in a .env (the build refuses it, naming the line). Build
|
|
1273
|
+
machines without secrets: SKIP_ENV_VALIDATION=1.
|
|
1274
|
+
|
|
1275
|
+
reflect-metadata (tsyringe, typeorm, inversify - often under
|
|
1276
|
+
@simplewebauthn/server): nothing to import; when the graph has it the build
|
|
1277
|
+
loads it in a Nitro plugin ahead of the app, so the "tsyringe requires a
|
|
1278
|
+
reflect polyfill" boot error does not happen in a directory or a binary.
|
|
1279
|
+
|
|
1280
|
+
Build, then start - NEVER run vite build while bun/node .output/server/index.mjs
|
|
1281
|
+
is serving from that .output: services load lazily and a failed import of a
|
|
1282
|
+
half-written chunk is cached by the runtime (ENOENT 500s until restart).
|
|
1283
|
+
|
|
1284
|
+
Single binary (Bun): bun build --compile .output/server/compile.mjs
|
|
1285
|
+
--outfile dist/app (the scaffold's "compile" script). compile.mjs is written
|
|
1286
|
+
by the build and embeds the frozen pages; with serveStatic: 'inline' in the
|
|
1287
|
+
Nitro plugin the assets (and their .br/.gz) are inside too. Ship dist/app
|
|
1288
|
+
alone - a Dockerfile copies nothing else, not .output/public.
|
|
1289
|
+
|
|
1290
|
+
Nothing is sent raw: a built bun/node server gzips what it answers
|
|
1291
|
+
(documents, streams flushed per chunk, payloads, stored pages, api routes)
|
|
1292
|
+
for a request that accepts it, and the build writes .br/.gz beside every
|
|
1293
|
+
public asset, served by Nitro. Nothing to configure; a Worker leaves it to
|
|
1294
|
+
the platform. Off: compress: false on the handler / compressPublicAssets:
|
|
1295
|
+
false in Nitro config; Cache-Control: no-transform exempts one answer. Do
|
|
1296
|
+
NOT add a compression middleware or precompress assets yourself.
|
|
1297
|
+
|
|
1298
|
+
Full guide: read_guide({ slug: 'bun' }).`,
|
|
1299
|
+
},
|
|
1300
|
+
{
|
|
1301
|
+
topic: 'openapi',
|
|
1302
|
+
summary: 'An OpenAPI document derived from route.ts files - rscKit({ openapi }) - and Scalar\'s page over it, mounted as a route',
|
|
1303
|
+
body: `Do NOT hand-write an OpenAPI spec. rscKit({ openapi: true }) in
|
|
1304
|
+
vite.config.ts answers /openapi.json, derived from every route.ts: the
|
|
1305
|
+
directory is the path ([id] -> {id}), each method export an operation,
|
|
1306
|
+
params/searchParams/body schemas the parameters and request body (Zod 4 and
|
|
1307
|
+
ArkType describe themselves as JSON Schema; Valibot not yet), a middleware.ts
|
|
1308
|
+
above a route a security requirement + 401/403. Stored at build, no middleware.
|
|
1309
|
+
|
|
1310
|
+
Document-level parts go on the option:
|
|
1311
|
+
rscKit({ openapi: { info, servers, security, components: { securitySchemes } } })
|
|
1312
|
+
What a route says about itself, beside its handler:
|
|
1313
|
+
export const openapi = { summary, tags, responses: { 200: {...} }, POST: { summary } }
|
|
1314
|
+
export const openapi = false // leave this route out (the reference page, a webhook)
|
|
1315
|
+
export const openapi = { DELETE: false } // one method out; HEAD/OPTIONS never documented
|
|
1316
|
+
Webhook-heavy app: rscKit({ openapi: { include: 'declared' } }) documents only
|
|
1317
|
+
routes that export openapi, so callbacks need no opt-out line.
|
|
1318
|
+
Response bodies are declared in openapi.responses until a typed helper exists.
|
|
1319
|
+
|
|
1320
|
+
The page: Scalar's own package, one route, nothing shipped by the engine:
|
|
1321
|
+
// src/app/reference/route.ts
|
|
1322
|
+
import { ApiReference } from '@scalar/nextjs-api-reference'
|
|
1323
|
+
export const GET = ApiReference({ url: '/openapi.json' })
|
|
1324
|
+
export const openapi = false
|
|
1325
|
+
|
|
1326
|
+
Porting a spec file: delete its paths (they are the routes now, and body
|
|
1327
|
+
validates at runtime), move info/servers/security to the option, move a
|
|
1328
|
+
route's summary/tags/responses to its openapi export.
|
|
1329
|
+
|
|
1330
|
+
Full guide: read_guide({ slug: 'openapi' }).`,
|
|
1084
1331
|
},
|
|
1085
1332
|
{
|
|
1086
1333
|
topic: 'env',
|
|
1087
|
-
summary: 'Typed environment variables - src/env.ts with @t3-oss/env-core in the app\'s validation library; refused at startup by name',
|
|
1334
|
+
summary: 'Typed environment variables - src/env.ts with @t3-oss/env-core in the app\'s validation library; refused at startup by name. Never NODE_ENV in .env',
|
|
1088
1335
|
body: `A scaffolded app has src/env.ts when it said yes to typed environment
|
|
1089
1336
|
variables (create-rsc-kit --env, with --validation=zod|valibot|arktype). To
|
|
1090
1337
|
add it to an app without one: install @t3-oss/env-core and write the same file.
|
|
@@ -1102,8 +1349,11 @@ export const env = createEnv({
|
|
|
1102
1349
|
},
|
|
1103
1350
|
clientPrefix: 'PUBLIC_',
|
|
1104
1351
|
client: { PUBLIC_SITE_URL: z.url() },
|
|
1105
|
-
|
|
1352
|
+
// No bare process.env: a "use client" file importing this for a PUBLIC_
|
|
1353
|
+
// value has no process, and the spread throws before the first render.
|
|
1354
|
+
runtimeEnv: { ...(typeof process === 'undefined' ? {} : process.env), ...import.meta.env },
|
|
1106
1355
|
emptyStringAsUndefined: true,
|
|
1356
|
+
skipValidation: typeof process !== 'undefined' && !!process.env.SKIP_ENV_VALIDATION,
|
|
1107
1357
|
})
|
|
1108
1358
|
\`\`\`
|
|
1109
1359
|
|
|
@@ -1111,7 +1361,8 @@ Read env.DATABASE_URL, never process.env.DATABASE_URL: the first is typed and
|
|
|
1111
1361
|
was checked at startup (a missing or malformed one fails then, with its name),
|
|
1112
1362
|
the second is string | undefined. A server variable never reaches the browser;
|
|
1113
1363
|
a browser-readable one MUST start with PUBLIC_ and is read from import.meta.env
|
|
1114
|
-
(Vite
|
|
1364
|
+
(Vite; the engine registers PUBLIC_ beside VITE_ as a client prefix, nothing
|
|
1365
|
+
to configure), which is why runtimeEnv merges both. Commit .env.example, not .env.
|
|
1115
1366
|
|
|
1116
1367
|
Next: NEXT_PUBLIC_* becomes PUBLIC_*; @t3-oss/env-nextjs becomes
|
|
1117
1368
|
@t3-oss/env-core with runtimeEnv as above (env-nextjs's experimental__runtimeEnv
|
|
@@ -1173,6 +1424,10 @@ that stays the truth: a useState, a reducer, or a cache library:
|
|
|
1173
1424
|
useEvents<Order>(url, { onMessage: (m) => queryClient.setQueryData(['order', id], m) }) // TanStack
|
|
1174
1425
|
useEvents<Order>(url, { onMessage: (m) => mutate(['order', id], m, false) }) // SWR
|
|
1175
1426
|
usePolling(read, { every, onData: setSeats })
|
|
1427
|
+
STABLE CALLBACKS: a callback a timer/subscription/listener calls that must see
|
|
1428
|
+
the latest props is useEffectEvent from React (19.2+), never a ref assigned
|
|
1429
|
+
each render, and never in a dependency array. Both hooks are built on it.
|
|
1430
|
+
|
|
1176
1431
|
ERRORS: both hooks expose error as state AND fire onError - a failed poll read
|
|
1177
1432
|
(the next interval still reads) or a dropped stream (EventSource reconnects
|
|
1178
1433
|
itself). Use onError for a toast/log; do NOT watch error in a useEffect.
|