@rsc-kit/mcp 0.18.1 → 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/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: `Use <Form>. It takes the server action itself, not a url.
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'
@@ -564,17 +571,31 @@ build found:
564
571
  import { apiUrl } from '@rsc-kit/core/routes'
565
572
  await fetch(apiUrl('/api/posts/' + id))
566
573
 
567
- Pages and api routes are separate unions: Link refuses an api url, apiUrl
568
- refuses a page. It checks the PATH, not the response type - for types across
569
- the boundary use a server action or a query, where the return type is the
570
- function's because it is the same function.
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.
571
581
 
572
582
  They run their directory's \`middleware.ts\`, so an endpoint under a guarded
573
583
  path is guarded.
574
584
 
575
585
  A \`GET\` that reads nothing from the request is answered from disk. Awaiting
576
586
  \`searchParams\` says the answer depends on the query; never touching it means
577
- 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).
578
599
 
579
600
  Exporting a \`body\` schema consumes the stream, so \`request.json()\` inside the
580
601
  handler will find it already read. Use the parsed value.
@@ -622,7 +643,7 @@ opts out, and the accessors are async:
622
643
  \`\`\`ts
623
644
  import { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'
624
645
 
625
- const theme = (await cookies()).get('theme')
646
+ const theme = (await cookies()).get('theme')?.value // { name, value } | undefined, as in Next
626
647
  await connection() // "render this per visitor", said deliberately
627
648
  \`\`\`
628
649
 
@@ -758,6 +779,61 @@ scores 99. Three moves:
758
779
  range you ask for (font-weight: 400 500) so nothing requests a missing file.
759
780
  Put the preloads before the <style> with the faces. Measured on rsc-kit.dev:
760
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
+
761
837
  Full guide: read_guide({ slug: 'fonts' }).`,
762
838
  },
763
839
  {
@@ -780,15 +856,23 @@ IMPORTS
780
856
  Metadata -> @rsc-kit/core/metadata (metadataBase, openGraph, twitter, icons as-is)
781
857
  app/robots.ts, app/sitemap.ts -> the same files and shapes; app/llms.ts beside them (how_to seo-files)
782
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
783
861
  next/font -> Fontsource (how_to fonts)
784
862
  next/image -> unpic or vite-imagetools (how_to images)
785
863
  next/script -> a <script> tag (how_to scripts)
786
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.
787
869
  next-safe-action -> createActionClient() (how_to action-client); returnValidationErrors -> return fieldErrors({...})
788
870
  cache from 'react' -> cache from @rsc-kit/core/cache: React's dedupes only inside a render; this one
789
871
  spans the request (guards, actions, api routes). The build names files still on React's
790
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)
791
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)
792
876
 
793
877
  DIFFERENT ON PURPOSE
794
878
  - No export const dynamic / revalidate = 60. A page is frozen unless it READS
@@ -798,7 +882,9 @@ DIFFERENT ON PURPOSE
798
882
  - Actions return failures ({ validationErrors }, { serverError }), not throw.
799
883
  - No image optimizer, no opengraph-image.tsx - put opengraph-image.png in src/app.
800
884
  - Tests need no browser: createTestApp() is the deployed handler. It builds
801
- with the project's own build script on the runtime the tests run under.
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.
802
888
  - A component library (base-ui, Radix) imports as it did, from server
803
889
  components too. A shadcn-style components/ui/ folder keeps "use client" at
804
890
  the top of each file, as shipped; without it the server evaluates the
@@ -815,7 +901,40 @@ route that is not ○ names what streams and from which component (a cookies()
815
901
  in a layout reaches every page; the build says so) -> decide each action the
816
902
  build lists as running no middleware -> check.
817
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
+
818
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.`,
819
938
  },
820
939
  {
821
940
  topic: 'emails',
@@ -1024,6 +1143,15 @@ Laravel: composer require rsc-kit/laravel, then php artisan rsc:install. It
1024
1143
  runs rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is
1025
1144
  moved aside - the renderer owns the frontend). Source is resources/js (the route tree is resources/js/app).
1026
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
+
1027
1155
  Reach PHP from a server component - rpc() is a global, typed in
1028
1156
  .rsc-kit/rsc-env.d.ts, server render only:
1029
1157
 
@@ -1040,12 +1168,23 @@ Guard a route in Laravel's vocabulary, no route declared in PHP:
1040
1168
  // resources/js/app/admin/middleware.ts
1041
1169
  export const middleware = ['auth', 'verified', 'can:update,post']
1042
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.
1043
1175
  Server actions are classes in app/Rsc/Actions; \`php artisan
1044
1176
  rsc:action-manifest\` (already in the dev/build scripts) writes the map and
1045
1177
  the build writes server-actions.generated.ts beside the app - import
1046
1178
  ordersCancel from it in a client component. Rsc::revalidate('orders') in the
1047
1179
  action returns the re-rendered region with the answer.
1048
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
+
1049
1188
  php artisan serve is one worker, which deadlocks the proxy - unless
1050
1189
  PHP_CLI_SERVER_WORKERS=4 in .env AND serve --no-reload (Laravel ignores
1051
1190
  the variable otherwise). Herd, Valet, FPM, Octane are fine as they are. Production: put the renderer in front (bun
@@ -1055,10 +1194,13 @@ the web server.
1055
1194
  Any other language implements the same endpoint - request { function, args },
1056
1195
  reply { result | validationErrors | unauthenticated | unauthorized | redirect
1057
1196
  | error, revalidate }, answers '__rsc.middleware' with true or a refusal, and
1058
- a batch { calls: [...] } with { replies: [{ status, ...reply }] } in order.
1059
- Calls issued in the same render tick travel as one batch, so parallel reads
1060
- are one backend request; the renderer falls back to single calls for a
1061
- backend without batches.
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.
1062
1204
 
1063
1205
  Full guides: read_guide({ slug: 'backend-answered-pages' }), read_guide({ slug: 'laravel' }), read_guide({ slug: 'go' }), read_guide({ slug: 'your-own-backend' }).`,
1064
1206
  },
@@ -1113,6 +1255,15 @@ Native dependencies (sharp, bcrypt, better-sqlite3, @prisma/client, puppeteer,
1113
1255
  .output/server/node_modules with their binaries. Add one:
1114
1256
  rscKit({ serverExternalPackages: ['@acme/native'] }). Same as Next's option.
1115
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
+
1116
1267
  Bun's, not the framework's: bun test loads the package .env (use
1117
1268
  --env-file=/dev/null to isolate); Stripe's constructEvent throws on Bun
1118
1269
  (no sync WebCrypto) - use constructEventAsync; Bun's pg puts SQLSTATE in
@@ -1121,7 +1272,62 @@ errno where Node's pg uses code.
1121
1272
  Never NODE_ENV in a .env (the build refuses it, naming the line). Build
1122
1273
  machines without secrets: SKIP_ENV_VALIDATION=1.
1123
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
+
1124
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' }).`,
1125
1331
  },
1126
1332
  {
1127
1333
  topic: 'env',
@@ -1143,8 +1349,11 @@ export const env = createEnv({
1143
1349
  },
1144
1350
  clientPrefix: 'PUBLIC_',
1145
1351
  client: { PUBLIC_SITE_URL: z.url() },
1146
- runtimeEnv: { ...process.env, ...import.meta.env },
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 },
1147
1355
  emptyStringAsUndefined: true,
1356
+ skipValidation: typeof process !== 'undefined' && !!process.env.SKIP_ENV_VALIDATION,
1148
1357
  })
1149
1358
  \`\`\`
1150
1359
 
@@ -1152,7 +1361,8 @@ Read env.DATABASE_URL, never process.env.DATABASE_URL: the first is typed and
1152
1361
  was checked at startup (a missing or malformed one fails then, with its name),
1153
1362
  the second is string | undefined. A server variable never reaches the browser;
1154
1363
  a browser-readable one MUST start with PUBLIC_ and is read from import.meta.env
1155
- (Vite), which is why runtimeEnv merges both. Commit .env.example, not .env.
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.
1156
1366
 
1157
1367
  Next: NEXT_PUBLIC_* becomes PUBLIC_*; @t3-oss/env-nextjs becomes
1158
1368
  @t3-oss/env-core with runtimeEnv as above (env-nextjs's experimental__runtimeEnv