@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
388 lines (305 loc) • 15.9 kB
Markdown
# Go
> A Go process behind the renderer — functions, guards and actions in Go.
Go owns the request: sessions, auth, the database. The renderer owns
rendering, because that half is React. A server component reaches Go by
calling `rpc()`, which leaves the renderer as an ordinary POST; a `middleware.ts`
names guards Go runs before a page renders; a server action the browser calls
is a Go function the build wrote a stub for.
A [BAP](/hosts/backend-answered-pages) with Go behind it. There is no
JavaScript to write for Go: the app is what the scaffold writes, and the Go
side is one package answering
[the endpoint every backend answers](/hosts/your-own-backend).
```sh
go get github.com/rsc-kit/go
```
## Wiring
In a Go module, `init` sees `go.mod` and does the JavaScript half — the
project's first `package.json` if it has none, the route tree,
`vite.config.ts`, `.env` with `RSC_BACKEND` and a generated secret, the scripts
— and prints the Go half for you to add. Nothing in the Go module is touched:
```sh
bunx rsc-kit init # in the directory with go.mod
bun create rsc-kit my-app --backend=http://127.0.0.1:8080 # or a new app
```
What it wrote is two lines, read by `vite` in development and by the built
server in production:
```ini title=".env"
RSC_BACKEND=http://127.0.0.1:8080
RSC_HOST_CALL_SECRET=… # openssl rand -base64 32 - see Secrets under Deployment
```
And the endpoint, on whatever mux you already have:
```go title="main.go"
reg := rsckit.NewRegistry()
// … Register, Middleware, RegisterAction below …
callback, err := rsckit.NewCallbackHandler(reg, os.Getenv("RSC_HOST_CALL_SECRET"))
if err != nil {
log.Fatal(err) // refuses to exist without a secret
}
mux := http.NewServeMux()
mux.Handle("POST /__rsc/host-call", callback)
http.ListenAndServe("127.0.0.1:8080", mux)
```
The renderer forwards any url its route tree does not own to `RSC_BACKEND`,
so a Go route on that mux — `/login`, a webhook, an upload — is reachable at
the renderer's origin too.
### When a page route would match it
One url, one owner: the route tree has it, or the backend does. A dynamic route
at the root, `[team]/[app]`, has every url of two segments, the backend's
`/gitlab/connect` among them. It renders and says `notFound()`, and a page that
said that is the app's 404, so it is not handed on. Sign-in and an OAuth
callback become a team called `gitlab`.
Name the prefixes Go owns, and they are forwarded before any page is asked:
```ts title="vite.config.ts"
rscKit({ backendPaths: ['/auth', '/github', '/gitlab'] })
```
Each is a prefix at a segment boundary: `/auth` is `/auth` and everything under
it, for every method, and never `/authors`. A team called `auth` is then
unreachable, which is the price of the name. `/` and the renderer's own `/_rsc`
are refused where they are written, and with no backend where the server runs
such a url is the app's not-found page. A test reaches it as it does any
backend url, with `createTestApp({ backend })`.
### Running it locally
Two processes: the Go program, and Vite, which is the renderer in
development. Run Go with `RSC_DEBUG=1`, so a failure says where in Go it
happened:
```sh
RSC_DEBUG=1 go run . # the backend, on 127.0.0.1:8080
bun run dev # the renderer; open its address
```
Never set `RSC_DEBUG` in production: a trace names your files.
### Environment variables
| Variable | Read by | What it is |
| --- | --- | --- |
| `RSC_BACKEND` | the renderer | Where the Go endpoint is, e.g. `http://127.0.0.1:8080`. Falls back to `APP_URL`. Read from `.env` by `vite`, and from the environment by the built server; the url forwarding is fixed at build time. |
| `RSC_HOST_CALL_SECRET` | both | The shared secret. The renderer sends it; your Go code passes it to `NewCallbackHandler`. Without it neither side talks. |
| `RSC_HOST_CALL_PATH` | the renderer | The endpoint's path. Default `/__rsc/host-call`. |
| `RSC_DEBUG` | Go | `1` sends a failure's trace with its answer. Development only. |
| `RSC_TRUST_FORWARDED` | the renderer | `1` passes the visitor's `X-Forwarded-For` through to Go. Only behind a proxy you control that writes it. |
The Go adapter reads only `RSC_DEBUG` itself. The secret reaches it through
your code, as above.
## Reading from Go
Register an ordinary Go function with `Handle`, and its types reach the app:
```go
reg.Handle("Orders.recent", func(ctx context.Context, limit int) ([]Order, error) {
// The visitor's cookie, forwarded from the page request: this runs as
// them, not as nobody.
return db.RecentOrders(ctx, rsckit.HeadersFrom(ctx).Get("Cookie"), limit)
})
```
```tsx title="src/app/orders/page.tsx"
const orders = await rpc('Orders.recent', 5) // Order[]; rpc('Orders.recent', 'five') fails the typecheck
```
- **Any parameters, in order.** `rpc()`'s arguments are bound to the
function's parameters positionally, whatever JSON can carry: numbers,
strings, structs, slices, maps. A leading `context.Context` is the call's.
- **Optional and variadic.** A trailing pointer parameter may be left out
and is `nil`; a variadic one takes the rest.
- **What it returns.** A value and an error, only an error, or only a value.
- **Types for the app.** The parameter and result types are written to
`rsc-host.json` as JSON Schema. The build turns each struct into a
TypeScript interface in the `RscHost` namespace, by its `json` tags:
`omitempty` is optional, a pointer is `| null`, `time.Time` is a string.
A type with its own `MarshalJSON` is `unknown`, since its shape is its own.
- **Empty, not null.** A nil slice or map in the result is sent as `[]` or
`{}`, as its type says; only a nil pointer is `null`.
`Register` still takes the untyped form, binding by hand:
```go
reg.Register("Orders.recent", func(ctx context.Context, args rsckit.Args) (any, error) {
var limit int
if err := args.Bind(&limit); err != nil {
return nil, err
}
// The visitor's cookie, forwarded from the page request: this runs as
// them, not as nobody.
session := rsckit.HeadersFrom(ctx).Get("Cookie")
return db.RecentOrders(ctx, session, limit)
})
```
```tsx title="src/app/orders/page.tsx"
export default async function Orders() {
const orders = await rpc<Order[]>('Orders.recent', 5)
return <ul>{orders.map((o) => <li key={o.id}>{o.number}</li>)}</ul>
}
```
`args.Bind` decodes the positional arguments `rpc()` was given; too few is an
error, extra ones are ignored. Whatever the function returns is encoded as
JSON and decoded on the other side as whatever `rpc<T>()` was told: an
untyped function is `unknown` until you say.
### When it fails
In development, set `Debug` on the callback handler, or `RSC_DEBUG=1` in the
environment, and a failure says where in Go it happened: a panic sends the
frames it unwound, an error the chain it wraps. The renderer shows them under
its own stack, as the error's cause:
```text
Error: Host call "Orders.recent" failed: host function panicked: runtime error: slice bounds out of range
Caused in the backend by Backend panic(runtime.boundsError): …
at backend/main.go:64 main.main.func3
```
Never in production: a trace names your files.
### Refusing
Return a value and it is the result. Return one of these and the render is
told what happened, rather than handed a 500:
| return | the render gets |
| --- | --- |
| `rsckit.InvalidField("name", "…")`, `rsckit.Invalid(map)` | 422, each message under its input on the form that submitted |
| `rsckit.Unauthenticated()` | 401, the engine's own authentication error |
| `rsckit.Unauthorized("…")` | 403 |
| `rsckit.Redirect("/login")` | the browser goes there |
| `rsckit.Refuse(429, "slow down")` | that status, kept. An action shows the message to the visitor as written |
| `rsckit.RefuseWith(409, "still in use", data)` | the same, with data the page can act on: see [server actions](/guides/server-actions#refusing-with-something-to-act-on) |
| any other `error` | 500, with the message |
Wrapped errors still answer as what they are — `errors.As` finds the refusal
inside `fmt.Errorf("…: %w", err)` — and a panic becomes an error for that one
call rather than taking the server down.
## Route middleware
```ts title="src/app/admin/middleware.ts"
export const middleware = ['auth', 'can:manage-orders', 'throttle:60,1']
```
```go
reg.Middleware("auth", func(ctx context.Context, _ string) error {
if !signedIn(rsckit.HeadersFrom(ctx)) {
return rsckit.Redirect("/login")
}
return nil
})
reg.Middleware("can", func(ctx context.Context, ability string) error {
if !allowed(ctx, ability) {
return rsckit.Unauthorized("you may not " + ability)
}
return nil
})
```
The renderer sends the list before anything at or below `admin/` renders —
including a page it froze at build time, before the file is served. A guard
receives what follows the colon in its name (`"manage-orders"`, `"60,1"`,
`""`); guards run in order and stop at the first refusal. A name nothing is
registered for refuses rather than passes: a declared check that silently
does not happen is the failure this exists to prevent.
## Server actions
```go
reg.HandleAction("ordersCancel", "Orders.cancel", func(ctx context.Context, id int) error {
if err := orders.Cancel(ctx, id); err != nil {
return err
}
rsckit.Revalidate(ctx, "orders")
return nil
})
// The actions and every function, for the build: stubs, and rpc()'s type.
reg.WriteManifest("rsc-host.json")
```
```tsx title="src/app/orders/CancelButton.tsx"
'use client'
import { ordersCancel } from '../../server-actions.generated'
export function CancelButton({ id }: { id: number }) {
return <button onClick={() => ordersCancel(id)}>Cancel</button>
}
```
The stub is typed from the Go function, plus the one answer Go can give it that
is not its result: `ordersCancel(id: number): Promise<void | Redirected>`. An
action that returns `rsckit.Redirect("/login")` (or a guard that does, for an
expired session) resolves the call with `{ redirected }` once the browser is on
its way, so narrow with `isRedirected` before reading a value or reporting
success. A call the backend refuses (`rsckit.Refuse(429, "slow down")`) is not a
result at all: awaited directly the stub rejects with an `ActionRefusedError`
carrying the message and the status, and `<Form>` shows it as `formError`. An
input the backend refuses (`rsckit.Invalid`) rejects with a
`ServerValidationError`, whose `.fieldErrors` and `.formErrors` are the
messages. Only a redirect resolves. See [Redirects](/guides/redirects).
An action that signs someone in sets the session cookie with
`rsckit.SetCookie(ctx, &http.Cookie{...})`, and the renderer puts it on the
page's response.
Tag the structs a form posts with `json` names. An untagged field goes into
the manifest under Go's name, `Type`, while a form posts `type` and the backend
refuses under `type`, so `error('type')` is a type error on a bound stub. A tag
(`json:"type"`) gives the form, the manifest and the refusal one spelling.
Where tags are not an option, `fields` adds the other names on top.
A form can post to an action directly, `<Form action={ordersCreate}>`, and its
fields arrive as the struct parameter: the first, or the one after the
arguments you bound. An action scoped to a team, `func(ctx, team string, in NewApp)`,
is `<Form action={appsCreate.bind(null, team)}>`. Numbers and booleans are
coerced by the struct's types and a repeated field is a slice.
`Revalidate` names a [section](/guides/sections) or `page`, and the answer to
the action carries it re-rendered rather than the browser being told to ask
again.
Have Vite write the manifest, so it cannot go stale: give the program a flag
that writes it and exits, and name the command in the config. It runs as dev
and every build start, and a build fails if it does:
```ts title="vite.config.ts"
rscKit({ hostManifest: { command: ['go', 'run', '.', '-manifest', '../rsc-host.json'], cwd: 'backend' } })
```
The manifest also lists every registered function, so `rpc()` is typed by
the names Go holds and `rpc('Orders.recnet')` fails the typecheck. Tests
answer these functions without Go running:
[Testing it](/hosts/backend-answered-pages#testing-it).
## Saying data changed
An action can say what it changed. A webhook, a job or another visitor's
action cannot reach the tab that is showing the data - so a section names
what it depends on, and Go says when that changed, from anywhere with a
context:
```tsx title="src/app/t/[team]/repos.section.tsx"
export default section('repos', Repos, { refreshOn: ({ params }) => [`team:${params.team}:repos`] })
```
```go
// in the GitHub webhook handler
reg.Changed(ctx, "team:"+teamID+":repos")
```
Every open tab showing the section refreshes it, the moment the webhook
lands, with nothing polling. The registry answers `__rsc.changed` by holding
the renderer's ask until a version moves - woken at once by a `Changed` in
this process, checking the store every `rsckit.ChangedPoll` (1s) for one made
by another instance.
Versions live in a `VersionStore`. The default is in memory, which is one
instance; with more than one, a webhook lands on whichever instance the
balancer picked, so give them all the same store:
```go
// CREATE TABLE rsc_versions (name TEXT PRIMARY KEY, version BIGINT NOT NULL)
reg.Versions(&rsckit.SQLVersions{DB: db, Placeholder: rsckit.Dollar}) // Postgres; nil Placeholder is "?"
```
To hear another instance's change at once instead of within the second - and
stop reading the store while nothing changes - set `Notify` so each bump
sends a `pg_notify`, and run a listener with `WakeOn`. The adapter depends on
no driver, so the listener is yours; with pgx:
```go
reg.Versions(&rsckit.SQLVersions{DB: db, Placeholder: rsckit.Dollar, Notify: "rsc_versions"})
reg.WakeOn(ctx, func(ctx context.Context, connected, wake func()) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
if _, err := conn.Exec(ctx, "LISTEN rsc_versions"); err != nil {
return err
}
connected()
for {
if _, err := conn.Conn().WaitForNotification(ctx); err != nil {
return err
}
wake()
}
})
```
A listener that drops runs again with backoff, and the poll covers the gap.
Through PgBouncer in transaction mode, connect the listener straight to
Postgres. One instance needs none of this.
Anything with a `Bump` and a `Versions` fits. Set `RSC_SIGNING_SECRET` for the renderer, the same on every instance:
it signs the names a page hands the tab, and is not the host-call secret.
Generating and rotating both: [Secrets](/hosts/deployment#secrets). [Sections](/guides/live-data)
has the whole picture.
## Production
`bun run build` and `bun run start` with the same two variables in the
renderer's environment. Put the renderer in front and restrict
`/__rsc/host-call` at the web server, as [Laravel's page](/hosts/laravel/#which-process-faces-the-internet)
shows; a Go worker is held for the length of a host call, never a render.
Or put Go in front: `rsckit.NewRenderer(url)` is a streaming reverse proxy
to the renderer, and `rsckit.NewHandler(renderer, callback, path)` routes
the callback path to the endpoint and everything else through it. Both sides
mark what they forward, so a url neither owns is a 404 rather than a loop.
On a Worker, the two variables are a var and a secret in `wrangler.json`,
and the Go process has to be reachable from Cloudflare's network — an
`https` origin behind the secret and, ideally, an allow-list, since loopback
is not an option there.
---
The runnable version is
[`examples/go-backend`](https://github.com/rsc-kit/rsc-kit/tree/main/examples/go-backend)
in the repository: `bun run backend`, `bun run dev`, open the renderer.