@rsc-kit/mcp 0.17.0 → 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/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/recipes.js +319 -14
- package/dist/recipes.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 +3 -0
- package/guides/deployment.md +129 -0
- package/guides/domains.md +129 -0
- package/guides/errors.md +11 -1
- package/guides/go.md +194 -0
- package/guides/index.json +40 -0
- package/guides/installation.md +23 -4
- package/guides/instrumentation.md +91 -0
- package/guides/introduction.md +4 -0
- package/guides/laravel.md +406 -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/seo-files.md +45 -0
- package/guides/typed-routes.md +17 -0
- package/guides/where-it-runs.md +155 -0
- package/guides/your-own-backend.md +238 -0
- package/package.json +2 -2
|
@@ -44,7 +44,10 @@ it, the server evaluates the library's internals for nothing on every render.
|
|
|
44
44
|
| `next/script` | [a `<script>` tag](/guides/third-party-scripts): React 19 hoists and dedupes `async` scripts itself |
|
|
45
45
|
| `NEXT_PUBLIC_*` | `VITE_*`, read through `import.meta.env`; everything else stays `process.env` on the server |
|
|
46
46
|
| `next.config.js` | `vite.config.ts` — Tailwind, aliases and plugins are Vite's |
|
|
47
|
+
| `instrumentation.ts` with `register()` | the same file, in `src/` — imported before any page and awaited before the first request; [startup](/guides/instrumentation) |
|
|
47
48
|
| `next-safe-action` | `createActionClient()` — same shape, [below](#actions) |
|
|
49
|
+
| `experimental.optimizePackageImports` | on by default for every barrel package the server imports, with no list — read from the barrel itself; `rscKit({ barrelImports: false })` turns it off |
|
|
50
|
+
| a `middleware.ts` rewrite for subdomains | nothing — a host is a route segment, so `acme.example.com/` reaches `app/[domain]/page.tsx` and `admin.example.com/` reaches `app/admin/page.tsx` — [domains](/guides/domains) |
|
|
48
51
|
| `app/robots.ts`, `app/sitemap.ts` | the same files, the same shapes — [robots, sitemap and llms.txt](/guides/seo-files); `app/llms.ts` beside them |
|
|
49
52
|
| `@react-email/render` in a server action | the same call, in a module that starts with `"use ssr"` — [emails](/guides/emails). Next fails the same way where it renders server components; the directive is how this one moves it |
|
|
50
53
|
| `cache` from `react` | `cache` from `@rsc-kit/core/cache` — React's memoises only inside a component render; this one spans the request, so a guard, the layout and the action share one call. The build names server files still importing React's |
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Deploying
|
|
2
|
+
|
|
3
|
+
> What to ship, and the two settings that fail quietly.
|
|
4
|
+
|
|
5
|
+
`npm run build` writes one directory, and it is the deployment:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
.output/server the server Nitro built, and the engine it calls
|
|
9
|
+
.output/public hashed assets and the pages frozen at build time
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm run build
|
|
14
|
+
npm run start # node or bun .output/server/index.mjs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`npx vite preview` runs the same build through Vite's preview server, which is
|
|
18
|
+
what Nitro suggests at the end of a build. Both serve the real thing; `start`
|
|
19
|
+
is what a deployment runs.
|
|
20
|
+
|
|
21
|
+
## Anywhere that runs the runtime
|
|
22
|
+
|
|
23
|
+
`.output/` is self-contained. Copy it and start it — a container, a VPS, a
|
|
24
|
+
process manager, a platform that runs a Node or Bun process. There is nothing to
|
|
25
|
+
register and no platform API to satisfy, and **no `node_modules`**: the
|
|
26
|
+
dependencies are in the bundle.
|
|
27
|
+
|
|
28
|
+
```dockerfile
|
|
29
|
+
FROM oven/bun:1 AS build
|
|
30
|
+
WORKDIR /app
|
|
31
|
+
COPY package.json bun.lock ./
|
|
32
|
+
RUN bun install --frozen-lockfile
|
|
33
|
+
COPY . .
|
|
34
|
+
RUN bun run build
|
|
35
|
+
|
|
36
|
+
FROM oven/bun:1
|
|
37
|
+
WORKDIR /app
|
|
38
|
+
COPY --from=build /app/.output ./.output
|
|
39
|
+
CMD ["bun", ".output/server/index.mjs"]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The second stage carries `.output` and nothing else. On the docs application
|
|
43
|
+
that is 1.0 MB against 104 MB of `node_modules`.
|
|
44
|
+
|
|
45
|
+
## Or a platform, without a Dockerfile
|
|
46
|
+
|
|
47
|
+
Change the preset and Nitro produces what that platform expects — a Worker and
|
|
48
|
+
its `wrangler.json`, a Vercel function, a Netlify handler. See
|
|
49
|
+
[Where it runs](/hosts/where-it-runs).
|
|
50
|
+
|
|
51
|
+
## Two things that fail quietly
|
|
52
|
+
|
|
53
|
+
**Do not set `NODE_ENV` when starting the server.** The build bakes its mode
|
|
54
|
+
into the bundle, so a server started with nothing set is production because it
|
|
55
|
+
was *built* that way. Setting it at start time is a second source of truth and
|
|
56
|
+
the one that can disagree — and when it disagrees the failure is silent: every
|
|
57
|
+
page renders, and none of them hydrate.
|
|
58
|
+
|
|
59
|
+
The check is React's debug rows in the payload:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
curl -s https://your-app.example.com/ | grep -c ':D{'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`0` on a correct production build. Anything else means a development bundle
|
|
66
|
+
reached the client.
|
|
67
|
+
|
|
68
|
+
**Serve `.output/public` at the root.** Nitro does this itself, so this only
|
|
69
|
+
matters behind a CDN: point it at that directory and let the hashed filenames do
|
|
70
|
+
the caching — they are content-addressed, so they can be cached forever.
|
|
71
|
+
|
|
72
|
+
## Server actions across a deploy
|
|
73
|
+
|
|
74
|
+
A server action can close over server-side values, and React encrypts those
|
|
75
|
+
before sending them to the browser so the page cannot read them. The process
|
|
76
|
+
that decrypts them on the way back has to hold the same key.
|
|
77
|
+
|
|
78
|
+
By default that key is generated at build time and baked in. Every instance
|
|
79
|
+
running the same build agrees, so the only exposure is the deploy itself: a
|
|
80
|
+
browser sitting on a page from the old build calls an action on the new one,
|
|
81
|
+
and the key has changed underneath it. The call fails.
|
|
82
|
+
|
|
83
|
+
For most apps that window is seconds and nobody notices. If yours is long
|
|
84
|
+
enough to care about — a slow rollout, long-lived pages, an app people leave
|
|
85
|
+
open — pin the key:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
# once, kept wherever you keep secrets
|
|
89
|
+
openssl rand -base64 32
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
RSC_ACTION_ENCRYPTION_KEY=<that value>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Set it at **build time** and the build stops baking its own; the value is read
|
|
97
|
+
from the environment when the server runs, so the same artifact deploys
|
|
98
|
+
anywhere.
|
|
99
|
+
|
|
100
|
+
:::danger[Set it everywhere or nowhere]
|
|
101
|
+
Half-configured is worse than unconfigured. If one instance reads the variable
|
|
102
|
+
and another falls back to a baked key, they disagree, and the symptom is an
|
|
103
|
+
action that fails for some visitors and not others with nothing in the logs
|
|
104
|
+
pointing at a key.
|
|
105
|
+
|
|
106
|
+
Unset, everything works — the build-time key is used, which is the default
|
|
107
|
+
precisely because it cannot be got half right.
|
|
108
|
+
:::
|
|
109
|
+
|
|
110
|
+
## Frozen pages and the routes that own them
|
|
111
|
+
|
|
112
|
+
`.output/public` holds whole frozen pages and PPR shells alongside the assets.
|
|
113
|
+
They are read through the `prerendered` reader, which is a function rather than a
|
|
114
|
+
directory precisely so a runtime with no filesystem — a Worker — can supply them
|
|
115
|
+
from a binding instead.
|
|
116
|
+
|
|
117
|
+
A route that declares middleware is never cached publicly: it is sent as
|
|
118
|
+
`private, no-store`, because middleware runs per visitor. If something in front
|
|
119
|
+
of your app also owns the response — an auth proxy re-issuing a session on
|
|
120
|
+
pass-through — give the paths it covers a `middleware.ts` so this host knows
|
|
121
|
+
they are covered. See [serving shells from a CDN](/guides/edge-caching).
|
|
122
|
+
|
|
123
|
+
## Rebuild on deploy
|
|
124
|
+
|
|
125
|
+
Cached responses carry a build version, so a deploy invalidates them.
|
|
126
|
+
|
|
127
|
+
Ship `.output/` from the same commit as the code that serves it. A server
|
|
128
|
+
running one build against another's frozen pages is the one combination nothing
|
|
129
|
+
checks for you.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Domains and subdomains
|
|
2
|
+
|
|
3
|
+
> A host as a route segment — admin.example.com reaches app/admin, a tenant's host binds [domain] — with nothing to rewrite.
|
|
4
|
+
|
|
5
|
+
Next routes a subdomain with a `middleware.ts` that rewrites
|
|
6
|
+
`acme.example.com/settings` to `/acme/settings` before matching, and the
|
|
7
|
+
route tree never learns a host was involved. Here the same rule is the
|
|
8
|
+
router's own, so the build can see it: typed routes, one stored page per
|
|
9
|
+
tenant, and no middleware to write.
|
|
10
|
+
|
|
11
|
+
## The rule
|
|
12
|
+
|
|
13
|
+
A request from a host that is not the site's own is matched **with the host
|
|
14
|
+
in front of the path**:
|
|
15
|
+
|
|
16
|
+
| request | matched as | file |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| `example.com/admin` | `/admin` | `app/admin/page.tsx` |
|
|
19
|
+
| `admin.example.com/` | `/admin` | `app/admin/page.tsx` — the same file |
|
|
20
|
+
| `acme.example.com/settings` | `/acme/settings` | `app/[domain]/settings/page.tsx`, `domain: "acme"` |
|
|
21
|
+
| `acme.com/settings` | `/acme.com/settings` | the same file, `domain: "acme.com"` |
|
|
22
|
+
|
|
23
|
+
A subdomain of the site contributes its label; any other host contributes the
|
|
24
|
+
whole host. The site's own hosts — the one in the root layout's
|
|
25
|
+
`metadataBase`, `www.` of it, and any named in `rscKit({ hosts })` — contribute
|
|
26
|
+
nothing, so the apex keeps path routing and an app adds tenants without moving
|
|
27
|
+
a file. `localhost` and an ip address are always the site's own.
|
|
28
|
+
|
|
29
|
+
Nothing to configure for that: `metadataBase` names the apex, and a
|
|
30
|
+
directory does the rest. `hosts` is for a name that is neither the apex nor a
|
|
31
|
+
subdomain of it and is still the site rather than a tenant — a staging or
|
|
32
|
+
internal name, or a second brand domain:
|
|
33
|
+
|
|
34
|
+
```ts title="vite.config.ts"
|
|
35
|
+
rscKit({ hosts: ['app.internal', 'example.co.uk'] })
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Listing a subdomain there makes it the site by path instead of a tenant —
|
|
39
|
+
the "app on `app.example.com`, marketing on the apex" split — which is a
|
|
40
|
+
choice, not a requirement.
|
|
41
|
+
|
|
42
|
+
And only when a route could answer it: a `[domain]` directory at the top of
|
|
43
|
+
`app/`, or a directory named for the host. An app with a `metadataBase` and no
|
|
44
|
+
tenant tree routes every host by path, and a proxy that forwards to the app by
|
|
45
|
+
an internal name is not read as a tenant called `internal`.
|
|
46
|
+
|
|
47
|
+
The visitor's url is untouched: `acme.example.com/settings` stays in the
|
|
48
|
+
address bar, and a link to `/billing` on that page goes to
|
|
49
|
+
`acme.example.com/billing`. Only the match changed.
|
|
50
|
+
|
|
51
|
+
## A tenant tree
|
|
52
|
+
|
|
53
|
+
```tsx title="src/app/[domain]/layout.tsx"
|
|
54
|
+
import { notFound } from '@rsc-kit/core/not-found';
|
|
55
|
+
import { tenantByDomain } from '@/lib/tenants';
|
|
56
|
+
|
|
57
|
+
export default async function TenantLayout({ params, children }) {
|
|
58
|
+
const { domain } = await params;
|
|
59
|
+
const tenant = await tenantByDomain(domain); // "acme" or "acme.com", as stored
|
|
60
|
+
|
|
61
|
+
if (!tenant) notFound();
|
|
62
|
+
|
|
63
|
+
return <TenantProvider tenant={tenant}>{children}</TenantProvider>;
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`[domain]` is an ordinary dynamic segment: `params.domain` in every page and
|
|
68
|
+
layout below it, `route('/[domain]/settings', { domain })` typed, `loading.tsx`
|
|
69
|
+
and `error.tsx` where you put them. A directory named for a host,
|
|
70
|
+
`app/admin/`, wins over `[domain]` the way a static segment wins over a
|
|
71
|
+
parameter anywhere else.
|
|
72
|
+
|
|
73
|
+
One difference from a parameter deeper in the tree: a `[domain]` at the top
|
|
74
|
+
of `app/` binds **only from a host**, never from a path. `example.com/nope`
|
|
75
|
+
is a 404, not a tenant called `nope`, and `acme.example.com/` cannot be
|
|
76
|
+
reached as `example.com/acme`. Next has no such guard — its `[domain]` folder
|
|
77
|
+
matches any path once the rewrite is in place — which is why Next apps tuck
|
|
78
|
+
the tenant tree under a route group.
|
|
79
|
+
|
|
80
|
+
## Domains in a database
|
|
81
|
+
|
|
82
|
+
`generateStaticParams` on the tenant route is the hook. The listed hosts are
|
|
83
|
+
rendered at build and stored, one file per host; a host added afterwards
|
|
84
|
+
falls through to the plain tree, or — if the page reads the request — renders
|
|
85
|
+
on demand and resolves at request time:
|
|
86
|
+
|
|
87
|
+
```ts title="src/app/[domain]/page.tsx"
|
|
88
|
+
export async function generateStaticParams() {
|
|
89
|
+
const tenants = await db.tenant.findMany({ select: { domain: true } });
|
|
90
|
+
|
|
91
|
+
return tenants.map((t) => ({ domain: t.domain }));
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A tenant's page that reads `cookies()` or awaits `connection()` is dynamic
|
|
96
|
+
for that tenant and stored for none, exactly as any page is.
|
|
97
|
+
|
|
98
|
+
## Locally
|
|
99
|
+
|
|
100
|
+
Keep `metadataBase` as the production host. `localhost` and an ip address
|
|
101
|
+
are always the site's own, so the dev server routes by path as it always
|
|
102
|
+
did, and nothing changes until a `[domain]` directory exists. To try a tenant
|
|
103
|
+
without DNS, send the host the router will see in production:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
curl -H 'X-Forwarded-Host: acme.example.com' http://localhost:3000/
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Or point `acme.example.com` at `127.0.0.1` in `/etc/hosts` and open it on
|
|
110
|
+
the dev server's port in a browser.
|
|
111
|
+
|
|
112
|
+
## Behind a proxy
|
|
113
|
+
|
|
114
|
+
The host is read from `X-Forwarded-Host` first, then `Host`. A load balancer
|
|
115
|
+
that terminates TLS and forwards to the app by an internal name still routes
|
|
116
|
+
by the name the visitor typed.
|
|
117
|
+
|
|
118
|
+
## Not for a static export
|
|
119
|
+
|
|
120
|
+
An export is served by a file server, which sees no host. Host routing is a
|
|
121
|
+
server feature; an exported site is the site's own on every host it is
|
|
122
|
+
served from.
|
|
123
|
+
|
|
124
|
+
## Coming from Next
|
|
125
|
+
|
|
126
|
+
Delete the rewrite in `middleware.ts` and the `[domain]` directory works as
|
|
127
|
+
it did; the segment binds the same value the rewrite put there. Next's
|
|
128
|
+
`rewrite()` for anything else is not here — a host maps to a tree by file,
|
|
129
|
+
not by code.
|
package/guides/errors.md
CHANGED
|
@@ -7,6 +7,15 @@ nearest one wins.
|
|
|
7
7
|
|
|
8
8
|
## When a page throws
|
|
9
9
|
|
|
10
|
+
With nothing of your own, a page that throws shows the engine's error page
|
|
11
|
+
where the page was, and the layouts around it stay: in development the
|
|
12
|
+
message and stack, in production "Something went wrong" with the digest to
|
|
13
|
+
search the server log for, and a **Try again** in both. It used to show
|
|
14
|
+
nothing — React unmounted the document on hydration, a black page with the
|
|
15
|
+
cause nowhere near it.
|
|
16
|
+
|
|
17
|
+
That page is the fallback. To show something of your own:
|
|
18
|
+
|
|
10
19
|
Put an `error.tsx` in the directory you want to cover:
|
|
11
20
|
|
|
12
21
|
```tsx title="src/app/orders/error.tsx"
|
|
@@ -48,7 +57,8 @@ Log the digest where you log the error, and the two line up.
|
|
|
48
57
|
### It does not catch everything
|
|
49
58
|
|
|
50
59
|
- **Errors in the layout above it.** The boundary sits inside that layout, so a
|
|
51
|
-
layout that throws needs an `error.tsx` a directory up.
|
|
60
|
+
layout that throws needs an `error.tsx` a directory up. The engine's own
|
|
61
|
+
page is outermost and catches those too.
|
|
52
62
|
- **The build.** A page that throws every time it renders fails the build
|
|
53
63
|
rather than shipping a stored error page. The boundary is for a request that
|
|
54
64
|
goes wrong, not a page that is broken.
|
package/guides/go.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Go
|
|
2
|
+
|
|
3
|
+
> A Go process behind the renderer — functions, guards and actions in Go.
|
|
4
|
+
|
|
5
|
+
Go owns the request: sessions, auth, the database. The renderer owns
|
|
6
|
+
rendering, because that half is React. A server component reaches Go by
|
|
7
|
+
calling `rpc()`, which leaves the renderer as an ordinary POST; a `middleware.ts`
|
|
8
|
+
names guards Go runs before a page renders; a server action the browser calls
|
|
9
|
+
is a Go function the build wrote a stub for.
|
|
10
|
+
|
|
11
|
+
A [BAP](/hosts/backend-answered-pages) with Go behind it. There is no
|
|
12
|
+
JavaScript to write for Go: the app is what the scaffold writes, and the Go
|
|
13
|
+
side is one package answering
|
|
14
|
+
[the endpoint every backend answers](/hosts/your-own-backend).
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
go get github.com/rsc-kit/go
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Wiring
|
|
21
|
+
|
|
22
|
+
In a Go module, `init` sees `go.mod` and does the JavaScript half — the route
|
|
23
|
+
tree, `vite.config.ts`, `.env` with `RSC_BACKEND` and a generated secret, the
|
|
24
|
+
scripts — and prints the Go half for you to add:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
bunx rsc-kit@latest init # in the directory with go.mod
|
|
28
|
+
bun create rsc-kit@latest my-app --backend=http://127.0.0.1:8080 # or a new app
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
What it wrote is two lines, read by `vite` in development and by the built
|
|
32
|
+
server in production:
|
|
33
|
+
|
|
34
|
+
```ini title=".env"
|
|
35
|
+
RSC_BACKEND=http://127.0.0.1:8080
|
|
36
|
+
RSC_HOST_CALL_SECRET=a-long-random-string
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
And the endpoint, on whatever mux you already have:
|
|
40
|
+
|
|
41
|
+
```go title="main.go"
|
|
42
|
+
reg := rsckit.NewRegistry()
|
|
43
|
+
// … Register, Middleware, RegisterAction below …
|
|
44
|
+
|
|
45
|
+
callback, err := rsckit.NewCallbackHandler(reg, os.Getenv("RSC_HOST_CALL_SECRET"))
|
|
46
|
+
if err != nil {
|
|
47
|
+
log.Fatal(err) // refuses to exist without a secret
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
mux := http.NewServeMux()
|
|
51
|
+
mux.Handle("POST /__rsc/host-call", callback)
|
|
52
|
+
http.ListenAndServe("127.0.0.1:8080", mux)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The renderer forwards any url its route tree does not own to `RSC_BACKEND`,
|
|
56
|
+
so a Go route on that mux — `/login`, a webhook, an upload — is reachable at
|
|
57
|
+
the renderer's origin too.
|
|
58
|
+
|
|
59
|
+
## Reading from Go
|
|
60
|
+
|
|
61
|
+
```go
|
|
62
|
+
reg.Register("Orders.recent", func(ctx context.Context, args rsckit.Args) (any, error) {
|
|
63
|
+
var limit int
|
|
64
|
+
if err := args.Bind(&limit); err != nil {
|
|
65
|
+
return nil, err
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The visitor's cookie, forwarded from the page request: this runs as
|
|
69
|
+
// them, not as nobody.
|
|
70
|
+
session := rsckit.HeadersFrom(ctx).Get("Cookie")
|
|
71
|
+
|
|
72
|
+
return db.RecentOrders(ctx, session, limit)
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```tsx title="src/app/orders/page.tsx"
|
|
77
|
+
export default async function Orders() {
|
|
78
|
+
const orders = await rpc<Order[]>('Orders.recent', 5)
|
|
79
|
+
|
|
80
|
+
return <ul>{orders.map((o) => <li key={o.id}>{o.number}</li>)}</ul>
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`args.Bind` decodes the positional arguments `rpc()` was given; too few is an
|
|
85
|
+
error, extra ones are ignored. Whatever the function returns is encoded as
|
|
86
|
+
JSON and decoded on the other side as whatever `rpc<T>()` was told.
|
|
87
|
+
|
|
88
|
+
### Refusing
|
|
89
|
+
|
|
90
|
+
Return a value and it is the result. Return one of these and the render is
|
|
91
|
+
told what happened, rather than handed a 500:
|
|
92
|
+
|
|
93
|
+
| return | the render gets |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `rsckit.InvalidField("name", "…")`, `rsckit.Invalid(map)` | 422, each message under its input on the form that submitted |
|
|
96
|
+
| `rsckit.Unauthenticated()` | 401, the engine's own authentication error |
|
|
97
|
+
| `rsckit.Unauthorized("…")` | 403 |
|
|
98
|
+
| `rsckit.Redirect("/login")` | the browser goes there |
|
|
99
|
+
| `rsckit.Refuse(429, "slow down")` | that status, kept |
|
|
100
|
+
| any other `error` | 500, with the message |
|
|
101
|
+
|
|
102
|
+
Wrapped errors still answer as what they are — `errors.As` finds the refusal
|
|
103
|
+
inside `fmt.Errorf("…: %w", err)` — and a panic becomes an error for that one
|
|
104
|
+
call rather than taking the server down.
|
|
105
|
+
|
|
106
|
+
## Route middleware
|
|
107
|
+
|
|
108
|
+
```ts title="src/app/admin/middleware.ts"
|
|
109
|
+
export const middleware = ['auth', 'can:manage-orders', 'throttle:60,1']
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```go
|
|
113
|
+
reg.Middleware("auth", func(ctx context.Context, _ string) error {
|
|
114
|
+
if !signedIn(rsckit.HeadersFrom(ctx)) {
|
|
115
|
+
return rsckit.Redirect("/login")
|
|
116
|
+
}
|
|
117
|
+
return nil
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
reg.Middleware("can", func(ctx context.Context, ability string) error {
|
|
121
|
+
if !allowed(ctx, ability) {
|
|
122
|
+
return rsckit.Unauthorized("you may not " + ability)
|
|
123
|
+
}
|
|
124
|
+
return nil
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The renderer sends the list before anything at or below `admin/` renders —
|
|
129
|
+
including a page it froze at build time, before the file is served. A guard
|
|
130
|
+
receives what follows the colon in its name (`"manage-orders"`, `"60,1"`,
|
|
131
|
+
`""`); guards run in order and stop at the first refusal. A name nothing is
|
|
132
|
+
registered for refuses rather than passes: a declared check that silently
|
|
133
|
+
does not happen is the failure this exists to prevent.
|
|
134
|
+
|
|
135
|
+
## Server actions
|
|
136
|
+
|
|
137
|
+
```go
|
|
138
|
+
reg.RegisterAction("ordersCancel", "Orders.cancel", func(ctx context.Context, args rsckit.Args) (any, error) {
|
|
139
|
+
var id int
|
|
140
|
+
if err := args.Bind(&id); err != nil {
|
|
141
|
+
return nil, err
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if err := orders.Cancel(ctx, id); err != nil {
|
|
145
|
+
return nil, err
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
rsckit.Revalidate(ctx, "orders")
|
|
149
|
+
|
|
150
|
+
return nil, nil
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// Before each build. The build reads it and writes the stubs.
|
|
154
|
+
reg.WriteActionManifest("rsc-host-actions.json")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```tsx title="src/app/orders/CancelButton.tsx"
|
|
158
|
+
'use client'
|
|
159
|
+
import { ordersCancel } from '../../server-actions.generated'
|
|
160
|
+
|
|
161
|
+
export function CancelButton({ id }: { id: number }) {
|
|
162
|
+
return <button onClick={() => ordersCancel(id)}>Cancel</button>
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`Revalidate` names a [section](/guides/sections) or `page`, and the answer to
|
|
167
|
+
the action carries it re-rendered rather than the browser being told to ask
|
|
168
|
+
again. Write the manifest as part of the build — the example's `build`
|
|
169
|
+
script runs the Go program with `-manifest-only` first — because a stale map
|
|
170
|
+
names a function that has since been renamed, and nothing fails until the
|
|
171
|
+
browser calls it.
|
|
172
|
+
|
|
173
|
+
## Production
|
|
174
|
+
|
|
175
|
+
`bun run build` and `bun run start` with the same two variables in the
|
|
176
|
+
renderer's environment. Put the renderer in front and restrict
|
|
177
|
+
`/__rsc/host-call` at the web server, as [Laravel's page](/hosts/laravel/#which-process-faces-the-internet)
|
|
178
|
+
shows; a Go worker is held for the length of a host call, never a render.
|
|
179
|
+
|
|
180
|
+
Or put Go in front: `rsckit.NewRenderer(url)` is a streaming reverse proxy
|
|
181
|
+
to the renderer, and `rsckit.NewHandler(renderer, callback, path)` routes
|
|
182
|
+
the callback path to the endpoint and everything else through it. Both sides
|
|
183
|
+
mark what they forward, so a url neither owns is a 404 rather than a loop.
|
|
184
|
+
|
|
185
|
+
On a Worker, the two variables are a var and a secret in `wrangler.json`,
|
|
186
|
+
and the Go process has to be reachable from Cloudflare's network — an
|
|
187
|
+
`https` origin behind the secret and, ideally, an allow-list, since loopback
|
|
188
|
+
is not an option there.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
The runnable version is
|
|
193
|
+
[`examples/go-backend`](https://github.com/rsc-kit/rsc-kit/tree/main/examples/go-backend)
|
|
194
|
+
in the repository: `bun run backend`, `bun run dev`, open the renderer.
|
package/guides/index.json
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
"title": "Authorization",
|
|
10
10
|
"description": "Protecting pages, server actions and API routes."
|
|
11
11
|
},
|
|
12
|
+
{
|
|
13
|
+
"slug": "backend-answered-pages",
|
|
14
|
+
"title": "Backend-Answered Pages",
|
|
15
|
+
"description": "BAP — the model for an rsc-kit app with a backend behind it, and how to build for it."
|
|
16
|
+
},
|
|
12
17
|
{
|
|
13
18
|
"slug": "caching",
|
|
14
19
|
"title": "Asking once per request",
|
|
@@ -24,6 +29,16 @@
|
|
|
24
29
|
"title": "Rendering per request",
|
|
25
30
|
"description": "Marking work that belongs to the visitor, not to the build."
|
|
26
31
|
},
|
|
32
|
+
{
|
|
33
|
+
"slug": "deployment",
|
|
34
|
+
"title": "Deploying",
|
|
35
|
+
"description": "What to ship, and the two settings that fail quietly."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"slug": "domains",
|
|
39
|
+
"title": "Domains and subdomains",
|
|
40
|
+
"description": "A host as a route segment — admin.example.com reaches app/admin, a tenant's host binds [domain] — with nothing to rewrite."
|
|
41
|
+
},
|
|
27
42
|
{
|
|
28
43
|
"slug": "edge-caching",
|
|
29
44
|
"title": "Serving shells from a CDN",
|
|
@@ -59,6 +74,11 @@
|
|
|
59
74
|
"title": "Getting started",
|
|
60
75
|
"description": "Serve React Server Components from any JavaScript backend."
|
|
61
76
|
},
|
|
77
|
+
{
|
|
78
|
+
"slug": "go",
|
|
79
|
+
"title": "Go",
|
|
80
|
+
"description": "A Go process behind the renderer — functions, guards and actions in Go."
|
|
81
|
+
},
|
|
62
82
|
{
|
|
63
83
|
"slug": "images",
|
|
64
84
|
"title": "Images",
|
|
@@ -69,11 +89,21 @@
|
|
|
69
89
|
"title": "Installation",
|
|
70
90
|
"description": "From an empty directory to a streaming RSC app."
|
|
71
91
|
},
|
|
92
|
+
{
|
|
93
|
+
"slug": "instrumentation",
|
|
94
|
+
"title": "Startup",
|
|
95
|
+
"description": "instrumentation.ts runs once, before anything else."
|
|
96
|
+
},
|
|
72
97
|
{
|
|
73
98
|
"slug": "introduction",
|
|
74
99
|
"title": "Introduction",
|
|
75
100
|
"description": "React Server Components as a Vite plugin, deployed wherever you like."
|
|
76
101
|
},
|
|
102
|
+
{
|
|
103
|
+
"slug": "laravel",
|
|
104
|
+
"title": "Laravel",
|
|
105
|
+
"description": "React Server Components in front, a Laravel application behind them."
|
|
106
|
+
},
|
|
77
107
|
{
|
|
78
108
|
"slug": "mcp",
|
|
79
109
|
"title": "Working with an AI agent",
|
|
@@ -193,5 +223,15 @@
|
|
|
193
223
|
"slug": "view-transitions",
|
|
194
224
|
"title": "View transitions",
|
|
195
225
|
"description": "What React's ViewTransition animates in an app built with this, and what it does not."
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"slug": "where-it-runs",
|
|
229
|
+
"title": "Where it runs",
|
|
230
|
+
"description": "A host is a Nitro preset, not a server you write."
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"slug": "your-own-backend",
|
|
234
|
+
"title": "Your own backend",
|
|
235
|
+
"description": "The one endpoint a backend in any language answers."
|
|
196
236
|
}
|
|
197
237
|
]
|
package/guides/installation.md
CHANGED
|
@@ -47,6 +47,7 @@ import { defineConfig } from "vite";
|
|
|
47
47
|
import react from "@vitejs/plugin-react";
|
|
48
48
|
import { nitro } from "nitro/vite";
|
|
49
49
|
import { rscKit } from "@rsc-kit/core/vite";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
53
|
* The full route tree, built the way every scaffolded app is built.
|
|
@@ -64,6 +65,13 @@ export default defineConfig({
|
|
|
64
65
|
}),
|
|
65
66
|
react(),
|
|
66
67
|
],
|
|
68
|
+
// `@/thing` for `src/thing`, the same alias tsconfig.json declares in
|
|
69
|
+
// `paths`: both, or the import type-checks and then fails to resolve.
|
|
70
|
+
resolve: {
|
|
71
|
+
alias: {
|
|
72
|
+
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
67
75
|
});
|
|
68
76
|
```
|
|
69
77
|
|
|
@@ -210,15 +218,18 @@ not match its `[param]` segment is a compile error rather than a blank page.
|
|
|
210
218
|
"target": "ESNext",
|
|
211
219
|
"module": "ESNext",
|
|
212
220
|
"moduleResolution": "bundler",
|
|
221
|
+
"paths": {
|
|
222
|
+
"@/*": ["./src/*"]
|
|
223
|
+
},
|
|
213
224
|
"jsx": "react-jsx",
|
|
214
225
|
"strict": true,
|
|
215
226
|
"noEmit": true,
|
|
227
|
+
"isolatedModules": true,
|
|
228
|
+
"moduleDetection": "force",
|
|
229
|
+
"verbatimModuleSyntax": true,
|
|
216
230
|
"skipLibCheck": true,
|
|
217
231
|
"resolveJsonModule": true,
|
|
218
|
-
"types": [
|
|
219
|
-
"@types/bun",
|
|
220
|
-
"vite/client"
|
|
221
|
-
]
|
|
232
|
+
"types": ["@types/bun", "vite/client"]
|
|
222
233
|
},
|
|
223
234
|
"include": ["src/**/*", "server/**/*", ".rsc-kit/**/*", "vite.config.ts"]
|
|
224
235
|
}
|
|
@@ -228,6 +239,14 @@ not match its `[param]` segment is a compile error rather than a blank page.
|
|
|
228
239
|
of a stylesheet (`import './styles.css'` in the root layout) is an error, and
|
|
229
240
|
`import.meta.env` is untyped.
|
|
230
241
|
|
|
242
|
+
Three of those are worth keeping even if you trim the rest.
|
|
243
|
+
`verbatimModuleSyntax` makes a type-only import say so, which is what keeps a
|
|
244
|
+
`type` import of a client component from pulling the component into the server
|
|
245
|
+
bundle. `isolatedModules` and `moduleDetection` are what a per-file transpiler
|
|
246
|
+
like Vite's assumes. And `paths` pairs with `resolve.alias` in `vite.config.ts`
|
|
247
|
+
— `@/thing` for `src/thing` — which some tools (shadcn's `init`, for one) check
|
|
248
|
+
for before they will run.
|
|
249
|
+
|
|
231
250
|
### Environment variables
|
|
232
251
|
|
|
233
252
|
There is nothing to install and nothing this package adds — Vite already owns
|