Skip to content

Go

A Go process behind the renderer — functions, guards and actions in Go.

Updated View as Markdown

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 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.

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:

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:

.envini
RSC_BACKEND=http://127.0.0.1:8080
RSC_HOST_CALL_SECRET=a-long-random-string

And the endpoint, on whatever mux you already have:

main.gogo
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.

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:

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:

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)
})
src/app/orders/page.tsxtsx
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:

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)
})
src/app/orders/page.tsxtsx
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:

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
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

src/app/admin/middleware.tsts
export const middleware = ['auth', 'can:manage-orders', 'throttle:60,1']
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

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")
src/app/orders/CancelButton.tsxtsx
'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: ordersCancel(id: number): Promise<void>. A form can post to an action directly, <Form action={ordersCreate}>, and its fields arrive as the first parameter: a struct, with numbers and booleans coerced by its types and a repeated field a slice.

Revalidate names a section 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:

vite.config.tsts
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.

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:

src/app/t/[team]/repos.section.tsxtsx
export default section('repos', Repos, { refreshOn: ({ params }) => [`team:${params.team}:repos`] })
// 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.TagsPoll (1s) for one made by another instance.

Versions live in a TagStore. 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:

// CREATE TABLE rsc_tags (name TEXT PRIMARY KEY, version BIGINT NOT NULL)
reg.Names(&rsckit.SQLTags{DB: db, Placeholder: rsckit.Dollar})   // Postgres; nil Placeholder is "?"

Anything with a Bump and a Versions fits. Sections 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 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 in the repository: bun run backend, bun run dev, open the renderer.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close