@rsc-kit/mcp 0.16.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/answers.d.ts +14 -1
- package/dist/answers.js +76 -34
- package/dist/answers.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +434 -14
- package/dist/recipes.js.map +1 -1
- package/dist/report.d.ts +12 -0
- package/dist/report.js +17 -15
- package/dist/report.js.map +1 -1
- package/guides/api-routes.md +106 -6
- package/guides/authorization.md +39 -0
- package/guides/backend-answered-pages.md +163 -0
- package/guides/coming-from-next.md +12 -4
- package/guides/deployment.md +129 -0
- package/guides/domains.md +129 -0
- package/guides/emails.md +90 -0
- package/guides/errors.md +16 -2
- package/guides/go.md +194 -0
- package/guides/images.md +10 -4
- package/guides/index.json +50 -0
- package/guides/installation.md +31 -13
- package/guides/instrumentation.md +91 -0
- package/guides/introduction.md +4 -0
- package/guides/laravel.md +406 -0
- package/guides/mcp.md +1 -1
- package/guides/metadata.md +1 -1
- package/guides/offline.md +8 -0
- package/guides/queries.md +138 -8
- package/guides/quick-start.md +31 -0
- package/guides/redirects.md +14 -0
- package/guides/response-headers.md +22 -0
- package/guides/routing.md +5 -1
- package/guides/seo-files.md +140 -0
- package/guides/typed-routes.md +26 -0
- package/guides/view-transitions.md +53 -7
- package/guides/where-it-runs.md +155 -0
- package/guides/your-own-backend.md +238 -0
- package/package.json +2 -2
package/dist/recipes.js
CHANGED
|
@@ -342,20 +342,40 @@ export default function Page() {
|
|
|
342
342
|
|
|
343
343
|
Reach for this first. It is the thing RSC is for.
|
|
344
344
|
|
|
345
|
-
**When the BROWSER decides to refetch** — a filter,
|
|
346
|
-
|
|
345
|
+
**When the BROWSER decides to refetch** — a filter, another page, a refresh —
|
|
346
|
+
call fetchQuery. It is a plain async function that returns the typed answer;
|
|
347
|
+
NO library is needed:
|
|
347
348
|
|
|
349
|
+
\`\`\`tsx
|
|
350
|
+
const [listings, setListings] = useState(initial) // the server-rendered value
|
|
351
|
+
const [pending, start] = useTransition()
|
|
352
|
+
const show = (kind) => start(async () => setListings(await fetchQuery(getListings, [kind])))
|
|
353
|
+
\`\`\`
|
|
354
|
+
Works in an onClick, onSubmit, useEffect - anywhere in the browser.
|
|
355
|
+
|
|
356
|
+
**When you want CACHING** (stale-while-revalidate, dedupe, offline), hand the
|
|
357
|
+
same call to the library that holds the answer; this package ships none:
|
|
348
358
|
\`\`\`tsx
|
|
349
359
|
useQuery({ queryKey: ['posts', kind], queryFn: () => fetchQuery(getPosts, [kind]) })
|
|
350
360
|
useSWR(['posts', kind], () => fetchQuery(getPosts, [kind]))
|
|
351
361
|
\`\`\`
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
is
|
|
355
|
-
|
|
362
|
+
fetchQuery sends the read as a GET and goes to the server every time, which
|
|
363
|
+
is what a fetcher needs. It will never cache, dedupe or batch: a client
|
|
364
|
+
cache is the library's job, and a batch would lose the per-read cache key a
|
|
365
|
+
GET has. The ladder, most reads stopping on the first rung:
|
|
366
|
+
1. the value now: fetchQuery + setState
|
|
367
|
+
2. survive a reload / let a CDN serve it: query(fn, { cache: 'public', maxAge })
|
|
368
|
+
- HTTP caching, no code in the page
|
|
369
|
+
3. staleness, background refresh, optimistic updates, shared across
|
|
370
|
+
components: TanStack or SWR with fetchQuery as the fetcher
|
|
371
|
+
Do not add a cache on top of it, and do not install TanStack for a single
|
|
372
|
+
button that reads once.
|
|
356
373
|
|
|
357
374
|
Keep the arrow: TanStack calls a bare \`queryFn\` with its own context, and a
|
|
358
|
-
server function serialises whatever it is handed
|
|
375
|
+
server function serialises whatever it is handed.
|
|
376
|
+
|
|
377
|
+
A value that keeps CHANGING while someone watches - polling, SSE, realtime -
|
|
378
|
+
is how_to live-data, not this.`,
|
|
359
379
|
},
|
|
360
380
|
{
|
|
361
381
|
topic: 'suspense',
|
|
@@ -603,7 +623,7 @@ A page's \`params\` and \`searchParams\` props are promises for the same reason.
|
|
|
603
623
|
The build says which call did it, per route:
|
|
604
624
|
|
|
605
625
|
◐ /locale 85 kB
|
|
606
|
-
|
|
626
|
+
cookies(), headers() stream per request; the rest is stored
|
|
607
627
|
|
|
608
628
|
That is usually correct — a page whose content depends on who is asking cannot
|
|
609
629
|
be one stored file. Change it only when the read was accidental.
|
|
@@ -747,13 +767,20 @@ IMPORTS
|
|
|
747
767
|
useParams() -> the page's params prop, passed down
|
|
748
768
|
cookies(), headers() -> same names, from @rsc-kit/core/request
|
|
749
769
|
redirect() / notFound() -> @rsc-kit/core/redirect / @rsc-kit/core/not-found
|
|
750
|
-
revalidatePath/Tag -> revalidate('
|
|
770
|
+
revalidatePath/Tag -> revalidate('name') on a section() - targeted, rides back with the action; the
|
|
771
|
+
name is typed to the sections and slots the build found
|
|
751
772
|
Metadata -> @rsc-kit/core/metadata (metadataBase, openGraph, twitter, icons as-is)
|
|
773
|
+
app/robots.ts, app/sitemap.ts -> the same files and shapes; app/llms.ts beside them (how_to seo-files)
|
|
774
|
+
middleware.ts subdomain rewrite -> nothing: a host is a route segment (how_to domains)
|
|
752
775
|
next/font -> Fontsource (how_to fonts)
|
|
753
776
|
next/image -> unpic or vite-imagetools (how_to images)
|
|
754
777
|
next/script -> a <script> tag (how_to scripts)
|
|
755
|
-
NEXT_PUBLIC_* ->
|
|
778
|
+
NEXT_PUBLIC_* -> PUBLIC_* in src/env.ts (how_to env); server vars typed there too
|
|
756
779
|
next-safe-action -> createActionClient() (how_to action-client); returnValidationErrors -> return fieldErrors({...})
|
|
780
|
+
cache from 'react' -> cache from @rsc-kit/core/cache: React's dedupes only inside a render; this one
|
|
781
|
+
spans the request (guards, actions, api routes). The build names files still on React's
|
|
782
|
+
@react-email/render, renderToString in an action -> the same call, in a module that starts with "use ssr"
|
|
783
|
+
(how_to emails). Next gets away with it only for externalised packages; here it is explicit
|
|
757
784
|
|
|
758
785
|
DIFFERENT ON PURPOSE
|
|
759
786
|
- No export const dynamic / revalidate = 60. A page is frozen unless it READS
|
|
@@ -768,11 +795,397 @@ DIFFERENT ON PURPOSE
|
|
|
768
795
|
the top of each file, as shipped; without it the server evaluates the
|
|
769
796
|
library's internals for nothing.
|
|
770
797
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
798
|
+
SCAFFOLD FLAGS: --host=bun|node|worker --validation=zod|valibot|arktype|none
|
|
799
|
+
--env/--no-env (typed env vars via @t3-oss/env-core in src/env.ts, in the
|
|
800
|
+
chosen library; server vars never reach the browser, PUBLIC_ prefix for ones
|
|
801
|
+
that may). Pick the library the Next app already uses.
|
|
802
|
+
|
|
803
|
+
ORDER: scaffold -> copy src/app -> fix imports -> build (it typechecks first,
|
|
804
|
+
so a Link to a route that does not exist fails here) and READ the output: a
|
|
805
|
+
route that is not ○ names what streams and from which component (a cookies()
|
|
806
|
+
in a layout reaches every page; the build says so) -> decide each action the
|
|
807
|
+
build lists as running no middleware -> check.
|
|
774
808
|
|
|
775
809
|
Full guide: read_guide({ slug: 'coming-from-next' }).`,
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
topic: 'emails',
|
|
813
|
+
summary: 'Render React to HTML on the server - an email, a PDF, a feed - from an action or a route, with "use ssr"',
|
|
814
|
+
body: `@react-email/render, renderToString, anything on react-dom/server, called from
|
|
815
|
+
a server action or a route, fails: "react-dom/server is not supported in React
|
|
816
|
+
Server Components". React means it: where server components render, react is
|
|
817
|
+
the server-only build - the renderer needs the client build's internals, and
|
|
818
|
+
the components it would render import that same react (no useState, no
|
|
819
|
+
useContext). No alias fixes it. The rendering has to run in the ssr
|
|
820
|
+
environment, the one that turns pages into HTML for the browser.
|
|
821
|
+
|
|
822
|
+
Put the rendering - the template AND the call that renders it - in a module
|
|
823
|
+
that starts with "use ssr". Everything else imports it normally:
|
|
824
|
+
|
|
825
|
+
\`\`\`tsx
|
|
826
|
+
// src/lib/email/render.tsx
|
|
827
|
+
"use ssr";
|
|
828
|
+
import { render } from '@react-email/render'
|
|
829
|
+
import { OtpEmail } from './otp-email'
|
|
830
|
+
|
|
831
|
+
export async function renderOtpEmail(code: string) {
|
|
832
|
+
const email = <OtpEmail code={code} />
|
|
833
|
+
const [html, text] = await Promise.all([render(email), render(email, { plainText: true })])
|
|
834
|
+
return { html, text }
|
|
835
|
+
}
|
|
836
|
+
\`\`\`
|
|
837
|
+
|
|
838
|
+
\`\`\`ts
|
|
839
|
+
// src/lib/email/send-otp.ts - a plain server module, called from the action
|
|
840
|
+
import { renderOtpEmail } from './render'
|
|
841
|
+
export async function sendOtpEmail(to: string, code: string) {
|
|
842
|
+
const { html, text } = await renderOtpEmail(code)
|
|
843
|
+
await transporter.sendMail({ to, subject: 'Your code', html, text })
|
|
844
|
+
}
|
|
845
|
+
\`\`\`
|
|
846
|
+
|
|
847
|
+
Where server components render, the build replaces the module with async
|
|
848
|
+
proxies of its exports that call across - what "use client" does for a
|
|
849
|
+
component, in the other direction. Same process; dev and build; nothing to
|
|
850
|
+
configure.
|
|
851
|
+
|
|
852
|
+
RULES
|
|
853
|
+
- Exports are async functions. The call crosses environments, so the answer is
|
|
854
|
+
a promise. A sync function, a value, a class, export { } or export * is
|
|
855
|
+
refused at build with its name. Types are fine.
|
|
856
|
+
- Pass DATA across, not elements: renderOtpEmail(code), never
|
|
857
|
+
render(<OtpEmail/>) from the caller. An element built on the calling side
|
|
858
|
+
carries components from that side's react, and they render with no hooks.
|
|
859
|
+
- The module's imports are the ssr side's: @react-email/components,
|
|
860
|
+
react-dom/server, a PDF or Markdown renderer. Keep the module to rendering;
|
|
861
|
+
the database call belongs on the calling side.
|
|
862
|
+
|
|
863
|
+
Imported react-dom/server directly (through a library, usually)? It now throws
|
|
864
|
+
the fix in its message, naming the app file that pulled it in, and the build
|
|
865
|
+
warns once with the same. Do NOT alias react-dom/server, externalise react, or
|
|
866
|
+
move the action out of the app - the directive is the whole fix.
|
|
867
|
+
|
|
868
|
+
Full guide: read_guide({ slug: 'emails' }).`,
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
topic: 'seo-files',
|
|
872
|
+
summary: 'robots.txt, sitemap.xml and llms.txt from a file beside the root layout - the shapes Next uses, stored at build when they can be',
|
|
873
|
+
body: `Files beside the root layout, named for what they answer:
|
|
874
|
+
src/app/robots.ts -> /robots.txt default export returns MetadataRoute.Robots
|
|
875
|
+
src/app/sitemap.ts -> /sitemap.xml default export returns MetadataRoute.Sitemap (an array)
|
|
876
|
+
src/app/llms.ts -> /llms.txt default export returns MetadataRoute.Llms
|
|
877
|
+
src/app/llms-full.ts -> /llms-full.txt default export returns a string
|
|
878
|
+
The types: import type { MetadataRoute } from '@rsc-kit/core/metadata'. The
|
|
879
|
+
same names and shapes as Next's app/robots.ts and app/sitemap.ts; copy them.
|
|
880
|
+
|
|
881
|
+
\`\`\`ts
|
|
882
|
+
// src/app/robots.ts
|
|
883
|
+
export default function robots(): MetadataRoute.Robots {
|
|
884
|
+
return { rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }], sitemap: '/sitemap.xml' }
|
|
885
|
+
}
|
|
886
|
+
// src/app/sitemap.ts
|
|
887
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
888
|
+
const posts = await db.post.findMany()
|
|
889
|
+
return [{ url: '/', priority: 1 }, ...posts.map((p) => ({ url: \`/blog/\${p.slug}\`, lastModified: p.updatedAt }))]
|
|
890
|
+
}
|
|
891
|
+
// src/app/llms.ts
|
|
892
|
+
export default function llms(): MetadataRoute.Llms {
|
|
893
|
+
return { title: 'Acme', summary: 'What it is.', sections: [{ title: 'Pages', links: [{ title: 'Pricing', url: '/pricing' }] }] }
|
|
894
|
+
}
|
|
895
|
+
\`\`\`
|
|
896
|
+
|
|
897
|
+
A relative url is made absolute with the root layout's metadataBase; without
|
|
898
|
+
one it is a build error. Any of them may return a string, served as written.
|
|
899
|
+
|
|
900
|
+
NO sitemap.ts? The build writes /sitemap.xml itself: every stored page and
|
|
901
|
+
every generateStaticParams url, lastModified = the build, minus guarded
|
|
902
|
+
routes (middleware.ts above them), failed pages and not-found. Needs the root
|
|
903
|
+
layout's metadataBase. Write sitemap.ts only when you need urls the build
|
|
904
|
+
cannot see or per-url changeFrequency/priority.
|
|
905
|
+
|
|
906
|
+
HOW FRESH - the function decides, the same rule every route follows:
|
|
907
|
+
write nothing -> the build's own sitemap, stored; fresh every deploy
|
|
908
|
+
sitemap.ts that reads the database -> stored at build (○); fresh every deploy
|
|
909
|
+
sitemap.ts that awaits connection() -> rendered per request (ƒ); fresh every crawl
|
|
910
|
+
\`\`\`ts
|
|
911
|
+
export default async function sitemap() {
|
|
912
|
+
await connection() // from '@rsc-kit/core/request' - per request, like a page
|
|
913
|
+
return (await db.post.findMany()).map((p) => ({ url: \`/blog/\${p.slug}\`, lastModified: p.updatedAt }))
|
|
914
|
+
}
|
|
915
|
+
\`\`\`
|
|
916
|
+
Reading the database at build is fine; the REQUEST makes it dynamic, not the
|
|
917
|
+
data. Same for robots.ts and llms.ts. No middleware runs for
|
|
918
|
+
them - a root guard must not 401 the crawler. The url is typed
|
|
919
|
+
(route('/sitemap.xml')).
|
|
920
|
+
|
|
921
|
+
A file as written beside the root layout is served at the root as it is:
|
|
922
|
+
robots.txt, sitemap.xml, sitemap-*.xml, llms.txt, llms-full.txt, humans.txt,
|
|
923
|
+
security.txt, ads.txt. A file and a function for the same url is a build
|
|
924
|
+
error. Do NOT put these in public/ and do NOT write a middleware.ts for them.
|
|
925
|
+
|
|
926
|
+
Full guide: read_guide({ slug: 'seo-files' }).`,
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
topic: 'domains',
|
|
930
|
+
summary: 'Subdomains and custom domains as route segments - admin.example.com reaches app/admin, a tenant host binds [domain], no rewrite',
|
|
931
|
+
body: `A request from a host that is not the site's own is matched with the host
|
|
932
|
+
in FRONT of the path. The site's own hosts: the root layout's metadataBase,
|
|
933
|
+
www. of it, and rscKit({ hosts: [...] }) - which is ONLY for a name that is
|
|
934
|
+
neither the apex nor a subdomain of it (a staging/internal name, a second
|
|
935
|
+
brand domain); a normal setup needs no config beyond metadataBase. localhost
|
|
936
|
+
and ips are always own.
|
|
937
|
+
|
|
938
|
+
example.com/admin -> /admin app/admin/page.tsx
|
|
939
|
+
admin.example.com/ -> /admin the same file (subdomain of an own host = its label)
|
|
940
|
+
acme.example.com/settings -> /acme/settings app/[domain]/settings/page.tsx, domain "acme"
|
|
941
|
+
acme.com/settings -> /acme.com/settings the same file, domain "acme.com" (other host = whole host)
|
|
942
|
+
|
|
943
|
+
The visitor's url is untouched; only the match changes. A top-level [domain]
|
|
944
|
+
binds ONLY from a host, never from a path: example.com/nope is a 404, not a
|
|
945
|
+
tenant called "nope". Otherwise [domain] is an ordinary dynamic segment: params.domain in every page/layout under it, typed
|
|
946
|
+
route('/[domain]/settings', { domain }), loading/error files as usual. A
|
|
947
|
+
directory named for a host (app/admin/) wins over [domain].
|
|
948
|
+
|
|
949
|
+
\`\`\`tsx
|
|
950
|
+
// src/app/[domain]/layout.tsx
|
|
951
|
+
export default async function TenantLayout({ params, children }) {
|
|
952
|
+
const { domain } = await params
|
|
953
|
+
const tenant = await tenantByDomain(domain) // "acme" or "acme.com", as stored
|
|
954
|
+
if (!tenant) notFound()
|
|
955
|
+
return <TenantProvider tenant={tenant}>{children}</TenantProvider>
|
|
956
|
+
}
|
|
957
|
+
// src/app/[domain]/page.tsx - domains in a database: list them, they are stored at build
|
|
958
|
+
export async function generateStaticParams() {
|
|
959
|
+
return (await db.tenant.findMany()).map((t) => ({ domain: t.domain }))
|
|
960
|
+
}
|
|
961
|
+
\`\`\`
|
|
962
|
+
|
|
963
|
+
Only when a route could answer it (a top-level [domain] directory, or one
|
|
964
|
+
named for the host); otherwise the host is the site's own. Keep metadataBase as
|
|
965
|
+
the production host: localhost and ips are always own, so dev routes by path.
|
|
966
|
+
To try a tenant locally: curl -H 'X-Forwarded-Host: acme.example.com'
|
|
967
|
+
http://localhost:3000/ (or /etc/hosts). Behind a proxy the
|
|
968
|
+
host is X-Forwarded-Host, then Host. Not for a static export (a file server
|
|
969
|
+
sees no host). Do NOT write a middleware rewrite, do NOT
|
|
970
|
+
read the host in every page - the segment already is the host. The root layout
|
|
971
|
+
needs metadataBase (or rscKit({ hosts })) or every host is the site's own.
|
|
972
|
+
|
|
973
|
+
Full guide: read_guide({ slug: 'domains' }).`,
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
topic: 'identify',
|
|
977
|
+
summary: 'What a response says about itself - X-RSC-Kit (how it was served, always) and X-Powered-By + a generator tag (what built it, off with identify: false)',
|
|
978
|
+
body: `Every response carries X-RSC-Kit: stored | rendered | shell - a page from a
|
|
979
|
+
file the build wrote, rendered for this visitor, or a stored shell with its
|
|
980
|
+
holes rendered now. The header to read when a page is slower than expected
|
|
981
|
+
(like X-Nextjs-Cache); a CDN rule or health check can key on it. Names no
|
|
982
|
+
product; always sent.
|
|
983
|
+
|
|
984
|
+
By default a response also says what built it: X-Powered-By: rsc-kit and
|
|
985
|
+
<meta name="generator" content="rsc-kit"> in every document (BuiltWith,
|
|
986
|
+
Wappalyzer). The NAME only, never the version - a version in every response
|
|
987
|
+
is what a vulnerability scanner filters on.
|
|
988
|
+
|
|
989
|
+
rscKit({ identify: false }) turns off the name (header and tag) for a policy
|
|
990
|
+
that strips framework identifiers; X-RSC-Kit stays. Do not strip X-RSC-Kit
|
|
991
|
+
at the proxy - it is what tells you whether a stored page was served.
|
|
992
|
+
|
|
993
|
+
Full guide: read_guide({ slug: 'response-headers' }).`,
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
topic: 'backend',
|
|
997
|
+
summary: 'BAP (Backend-Answered Pages): a Laravel, Go or other backend behind the renderer - rpc() reaches it, middleware.ts names its middleware, app/Rsc/Actions are its server actions',
|
|
998
|
+
body: `The model is a BAP - Backend-Answered Pages: a page rendered in front of
|
|
999
|
+
the backend rather than by it (MPA: backend renders; SPA: browser renders and
|
|
1000
|
+
calls an API; BAP: a renderer on the server renders and calls the backend
|
|
1001
|
+
over loopback). The backend is the part that is not a page - models, session,
|
|
1002
|
+
auth, policies, jobs - answering one private endpoint, and it keeps every
|
|
1003
|
+
route of its own. The whole model, and how to build for it:
|
|
1004
|
+
read_guide({ slug: 'backend-answered-pages' }).
|
|
1005
|
+
|
|
1006
|
+
Go: in a Go module, rsc-kit init sees go.mod, writes the JS half and .env
|
|
1007
|
+
(RSC_BACKEND + a generated secret) and prints the Go wiring; go get
|
|
1008
|
+
github.com/rsc-kit/go. A new app: bun create rsc-kit --backend=<url>.
|
|
1009
|
+
|
|
1010
|
+
A backend in another language answers that ONE endpoint, POST /__rsc/host-call,
|
|
1011
|
+
and the renderer wires itself from two variables in .env: RSC_BACKEND (a
|
|
1012
|
+
Laravel app's APP_URL counts) and RSC_HOST_CALL_SECRET. Both or neither.
|
|
1013
|
+
|
|
1014
|
+
Laravel: composer require rsc-kit/laravel, then php artisan rsc:install. It
|
|
1015
|
+
runs rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is
|
|
1016
|
+
moved aside - the renderer owns the frontend). Source is resources/js (the route tree is resources/js/app).
|
|
1017
|
+
|
|
1018
|
+
Reach PHP from a server component - rpc() is a global, typed in
|
|
1019
|
+
.rsc-kit/rsc-env.d.ts, server render only:
|
|
1020
|
+
|
|
1021
|
+
// app/Rsc/Orders.php: public function recent(int $limit): array
|
|
1022
|
+
const orders = await rpc<Order[]>('Orders.recent', 5)
|
|
1023
|
+
|
|
1024
|
+
The call runs AS THE VISITOR (their cookie is forwarded; auth()->user() is
|
|
1025
|
+
them). Refuse with attributes: #[Authenticated], #[Can('update', Order::class)],
|
|
1026
|
+
#[Middleware('throttle:60,1')]. A ValidationException lands on the form as
|
|
1027
|
+
validationErrors; Authentication/Authorization exceptions answer 401/403.
|
|
1028
|
+
|
|
1029
|
+
Guard a route in Laravel's vocabulary, no route declared in PHP:
|
|
1030
|
+
|
|
1031
|
+
// resources/js/app/admin/middleware.ts
|
|
1032
|
+
export const middleware = ['auth', 'verified', 'can:update,post']
|
|
1033
|
+
|
|
1034
|
+
Server actions are classes in app/Rsc/Actions; \`php artisan
|
|
1035
|
+
rsc:action-manifest\` (already in the dev/build scripts) writes the map and
|
|
1036
|
+
the build writes server-actions.generated.ts beside the app - import
|
|
1037
|
+
ordersCancel from it in a client component. Rsc::revalidate('orders') in the
|
|
1038
|
+
action returns the re-rendered region with the answer.
|
|
1039
|
+
|
|
1040
|
+
php artisan serve is one worker, which deadlocks the proxy - unless
|
|
1041
|
+
PHP_CLI_SERVER_WORKERS=4 in .env AND serve --no-reload (Laravel ignores
|
|
1042
|
+
the variable otherwise). Herd, Valet, FPM, Octane are fine as they are. Production: put the renderer in front (bun
|
|
1043
|
+
.output/server/index.mjs with the app's .env), restrict /__rsc/host-call at
|
|
1044
|
+
the web server.
|
|
1045
|
+
|
|
1046
|
+
Any other language implements the same endpoint - request { function, args },
|
|
1047
|
+
reply { result | validationErrors | unauthenticated | unauthorized | redirect
|
|
1048
|
+
| error, revalidate }, answers '__rsc.middleware' with true or a refusal, and
|
|
1049
|
+
a batch { calls: [...] } with { replies: [{ status, ...reply }] } in order.
|
|
1050
|
+
Calls issued in the same render tick travel as one batch, so parallel reads
|
|
1051
|
+
are one backend request; the renderer falls back to single calls for a
|
|
1052
|
+
backend without batches.
|
|
1053
|
+
|
|
1054
|
+
Full guides: read_guide({ slug: 'backend-answered-pages' }), read_guide({ slug: 'laravel' }), read_guide({ slug: 'go' }), read_guide({ slug: 'your-own-backend' }).`,
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
topic: 'startup',
|
|
1058
|
+
summary: 'Once-per-process setup - src/instrumentation.ts is imported before any page and its register() awaited before the first request',
|
|
1059
|
+
body: `Setup that belongs to the process - validating env, configuring a shared
|
|
1060
|
+
package, warming a connection - goes in src/instrumentation.ts. Do NOT import
|
|
1061
|
+
a bootstrap module from pages to get the same effect; it depends on nobody
|
|
1062
|
+
forgetting, and the failure is a page throwing "not configured" for whoever
|
|
1063
|
+
reaches it first.
|
|
1064
|
+
|
|
1065
|
+
// src/instrumentation.ts
|
|
1066
|
+
import './env' // refuses at import -> server fails at startup
|
|
1067
|
+
export async function register() { // optional; the first render waits for it
|
|
1068
|
+
await db.connect()
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
The generated entry imports this file FIRST, so a package configured here is
|
|
1072
|
+
configured before any page module evaluates. register() is awaited by every
|
|
1073
|
+
entry point (server, dev, prerender, middleware, actions, api routes), once
|
|
1074
|
+
per process. On a server it runs at startup and a failure exits the process;
|
|
1075
|
+
on a Worker it runs at the isolate's first request.
|
|
1076
|
+
|
|
1077
|
+
Worker rule: read bindings INSIDE register(), not at the top of the module -
|
|
1078
|
+
process.env is empty until the first request arrives.
|
|
1079
|
+
|
|
1080
|
+
A scaffolded app with env validation already has this file importing ./env.
|
|
1081
|
+
Build machines without production variables: SKIP_ENV_VALIDATION=1.
|
|
1082
|
+
|
|
1083
|
+
Full guide: read_guide({ slug: 'instrumentation' }).`,
|
|
1084
|
+
},
|
|
1085
|
+
{
|
|
1086
|
+
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',
|
|
1088
|
+
body: `A scaffolded app has src/env.ts when it said yes to typed environment
|
|
1089
|
+
variables (create-rsc-kit --env, with --validation=zod|valibot|arktype). To
|
|
1090
|
+
add it to an app without one: install @t3-oss/env-core and write the same file.
|
|
1091
|
+
|
|
1092
|
+
\`\`\`ts
|
|
1093
|
+
// src/env.ts
|
|
1094
|
+
import * as z from 'zod' // or valibot / arktype - any Standard Schema library
|
|
1095
|
+
import { createEnv } from '@t3-oss/env-core'
|
|
1096
|
+
|
|
1097
|
+
export const env = createEnv({
|
|
1098
|
+
server: {
|
|
1099
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
1100
|
+
DATABASE_URL: z.url(),
|
|
1101
|
+
SESSION_SECRET: z.string().min(1),
|
|
1102
|
+
},
|
|
1103
|
+
clientPrefix: 'PUBLIC_',
|
|
1104
|
+
client: { PUBLIC_SITE_URL: z.url() },
|
|
1105
|
+
runtimeEnv: { ...process.env, ...import.meta.env },
|
|
1106
|
+
emptyStringAsUndefined: true,
|
|
1107
|
+
})
|
|
1108
|
+
\`\`\`
|
|
1109
|
+
|
|
1110
|
+
Read env.DATABASE_URL, never process.env.DATABASE_URL: the first is typed and
|
|
1111
|
+
was checked at startup (a missing or malformed one fails then, with its name),
|
|
1112
|
+
the second is string | undefined. A server variable never reaches the browser;
|
|
1113
|
+
a browser-readable one MUST start with PUBLIC_ and is read from import.meta.env
|
|
1114
|
+
(Vite), which is why runtimeEnv merges both. Commit .env.example, not .env.
|
|
1115
|
+
|
|
1116
|
+
Next: NEXT_PUBLIC_* becomes PUBLIC_*; @t3-oss/env-nextjs becomes
|
|
1117
|
+
@t3-oss/env-core with runtimeEnv as above (env-nextjs's experimental__runtimeEnv
|
|
1118
|
+
is not needed).`,
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
topic: 'live-data',
|
|
1122
|
+
summary: 'A value that keeps changing - realtime, live updates: usePolling over a query, or server-sent events (SSE, streaming from a middleware.ts generator) with useEvents - both feed TanStack, SWR or setState',
|
|
1123
|
+
body: `Neither is part of query() - a query answers once and is cacheable.
|
|
1124
|
+
|
|
1125
|
+
POLLING - start here when you have no change feed yet. Reuses the query,
|
|
1126
|
+
goes through its Cache-Control (a CDN collapses many tabs into one origin
|
|
1127
|
+
read per interval), pauses when the tab is hidden, never overlaps two reads.
|
|
1128
|
+
\`\`\`tsx
|
|
1129
|
+
import { usePolling } from '@rsc-kit/core/usePolling'
|
|
1130
|
+
const { data, status, refresh } = usePolling(() => fetchQuery(getSeats), { every: 2_000 })
|
|
1131
|
+
\`\`\`
|
|
1132
|
+
UNTIL IT SETTLES - a job that ends. until(data) says the last read; onSettled
|
|
1133
|
+
fires once on it. The result is the DATA; what to
|
|
1134
|
+
do on settling is the page's choice:
|
|
1135
|
+
// a server-rendered list, some jobs still running: re-render through the server
|
|
1136
|
+
usePolling(() => fetchQuery(jobStatus, [id]), { every: 2_000, enabled: !isTerminal(job), until: isTerminal, onSettled: () => refresh('page') })
|
|
1137
|
+
// the page that owns the job's state machine: the value in hand
|
|
1138
|
+
const { data, status } = usePolling(read, { every: 1_500, until: isTerminal, onSettled: (f) => dispatch(f.status) })
|
|
1139
|
+
Settled = stopped until refresh() or the inputs change. status: 'reading' |
|
|
1140
|
+
'paused' | 'settled' | 'idle'.
|
|
1141
|
+
|
|
1142
|
+
SERVER-SENT EVENTS - when something can push. Better per update (bytes only
|
|
1143
|
+
on change, instant), but holds a connection per open tab (fine on Bun/Node,
|
|
1144
|
+
a limit on Workers or a small container), is uncacheable, and needs a source
|
|
1145
|
+
of change to yield from - a generator that polls the DB itself just moved the
|
|
1146
|
+
polling. An ordinary route.ts: beside its pages, runs middleware.ts above it.
|
|
1147
|
+
\`\`\`ts
|
|
1148
|
+
// src/app/api/orders/[id]/events/route.ts
|
|
1149
|
+
import { events, named } from '@rsc-kit/core/events'
|
|
1150
|
+
export const GET = events(async function* ({ params, signal }) {
|
|
1151
|
+
const { id } = await params
|
|
1152
|
+
for await (const status of orderStatus(id, { signal })) yield { status }
|
|
1153
|
+
// yield named('paid', order, { id: order.id }) names a message / gives an id
|
|
1154
|
+
})
|
|
1155
|
+
\`\`\`
|
|
1156
|
+
\`\`\`tsx
|
|
1157
|
+
import { useEvents } from '@rsc-kit/core/useEvents'
|
|
1158
|
+
const { latest, all, status, close } = useEvents<Status>(\`/api/orders/\${id}/events\`)
|
|
1159
|
+
// <Status> is the message type the route yields; a url infers nothing, so
|
|
1160
|
+
// without it latest is unknown. Declare the type beside the route, import both sides.
|
|
1161
|
+
\`\`\`
|
|
1162
|
+
events() frames JSON, sends a keepalive, sets text/event-stream + no-store,
|
|
1163
|
+
ends the generator on disconnect (signal). EventSource reconnects itself and
|
|
1164
|
+
resumes with Last-Event-ID when you yielded ids.
|
|
1165
|
+
|
|
1166
|
+
NO LIBRARY NEEDED. Both hooks ARE state: read data (polling) or latest
|
|
1167
|
+
(events) and render it. Neither needs TanStack or SWR.
|
|
1168
|
+
const { latest } = useEvents<Status>(url); const current = latest ?? initial // the server value until the first message
|
|
1169
|
+
WITH A STORE - when the value already lives somewhere, hand every value on so
|
|
1170
|
+
that stays the truth: a useState, a reducer, or a cache library:
|
|
1171
|
+
useEvents<Order>(url, { onMessage: setOrder }) // useState
|
|
1172
|
+
useEvents<Order>(url, { onMessage: (m) => dispatch({ type: 'update', m }) }) // reducer
|
|
1173
|
+
useEvents<Order>(url, { onMessage: (m) => queryClient.setQueryData(['order', id], m) }) // TanStack
|
|
1174
|
+
useEvents<Order>(url, { onMessage: (m) => mutate(['order', id], m, false) }) // SWR
|
|
1175
|
+
usePolling(read, { every, onData: setSeats })
|
|
1176
|
+
ERRORS: both hooks expose error as state AND fire onError - a failed poll read
|
|
1177
|
+
(the next interval still reads) or a dropped stream (EventSource reconnects
|
|
1178
|
+
itself). Use onError for a toast/log; do NOT watch error in a useEffect.
|
|
1179
|
+
usePolling's onError gets (error, { failures }) - failed reads in a row, reset
|
|
1180
|
+
by a success - so toast on the third, not the first.
|
|
1181
|
+
fetchQuery(query, args) types args from the query: fetchQuery(getSeats) for a
|
|
1182
|
+
query that takes nothing, fetchQuery(status, [{ id }]) refused if the query's
|
|
1183
|
+
input has no id.
|
|
1184
|
+
Do NOT put a stream on query() or on a server action, and do NOT poll from
|
|
1185
|
+
inside an events() generator.
|
|
1186
|
+
|
|
1187
|
+
Full guides: read_guide({ slug: 'queries' }) for polling, read_guide({ slug:
|
|
1188
|
+
'api-routes' }) for the streaming route.`,
|
|
776
1189
|
},
|
|
777
1190
|
{
|
|
778
1191
|
topic: 'images',
|
|
@@ -802,6 +1215,12 @@ import heroSrc from '../hero.png?w=800&format=webp'
|
|
|
802
1215
|
<img srcSet={hero} src={heroSrc} sizes="(min-width: 800px) 800px, 100vw" width={800} height={600} alt="..." />
|
|
803
1216
|
\`\`\`
|
|
804
1217
|
|
|
1218
|
+
Declare the query tails in src/images.d.ts, or the build's typecheck stops on
|
|
1219
|
+
the imports (a pattern may hold ONE *, so '*?*' matches nothing):
|
|
1220
|
+
|
|
1221
|
+
declare module '*&as=srcset' { const srcset: string; export default srcset }
|
|
1222
|
+
declare module '*&format=webp' { const url: string; export default url }
|
|
1223
|
+
|
|
805
1224
|
Hundreds of files in the repo: that is a CDN's job; move them and use unpic.
|
|
806
1225
|
An icon or a logo: a plain <img>, or inline the svg.
|
|
807
1226
|
|
|
@@ -918,7 +1337,8 @@ export function howTo(topic) {
|
|
|
918
1337
|
// A near miss is common and worth answering rather than refusing: someone
|
|
919
1338
|
// asks for "form" or "queries" and means the obvious thing.
|
|
920
1339
|
RECIPES.find((r) => r.topic.startsWith(wanted) || wanted.startsWith(r.topic)) ??
|
|
921
|
-
|
|
1340
|
+
// The summary is prose, so a multi-word ask is matched with its spaces back.
|
|
1341
|
+
RECIPES.find((r) => r.summary.toLowerCase().replace(/[-\s]+/g, ' ').includes(wanted.replace(/-/g, ' ')));
|
|
922
1342
|
if (!found)
|
|
923
1343
|
return `No topic "${topic}".\n\n${listTopics()}`;
|
|
924
1344
|
return `# ${found.topic} — ${found.summary}\n\n${found.body}`;
|