UNPKG

@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
# 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@latest init # in the directory with go.mod bun create rsc-kit@latest 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.