Help us shape the future of Go! Take the 2026 Go Developer Survey

bifrost

package module
v1.3.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 5, 2026 License: MIT Imports: 32 Imported by: 2

README

Bifrost

Bifrost serves Vite-built React pages from an ordinary Go net/http application. Vite owns frontend builds, plugins, assets, and development HMR. Bun executes streaming React SSR. Go owns routing, loaders, HTTP, validation, and deployment.

app, err := bifrost.New(bifrost.Config{
    Assets: bifrostAssets,
    Routes: []bifrost.Route{
        bifrost.Server("/{$}", "pages/home.tsx", loadHome),
        bifrost.Static("/about", "pages/about.tsx", nil),
        bifrost.Client("/app", "pages/app.tsx"),
    },
})
if err != nil {
    log.Fatal(err)
}
if bifrost.Building() {
    return
}
defer app.Close(context.Background())
if err := http.ListenAndServe(":8080", app.Handler()); err != nil {
    log.Print(err)
}

Commands

bun install
go run github.com/3-lines-studio/bifrost/cmd/bifrost init ./myapp
go run github.com/3-lines-studio/bifrost/cmd/bifrost build ./cmd/web
go run github.com/3-lines-studio/bifrost/cmd/bifrost dev ./cmd/web
go run github.com/3-lines-studio/bifrost/cmd/bifrost version

Flags precede the package path. init runs bun install automatically (--no-install skips it). build accepts --sourcemaps, --static-workers, --output, and --vite-config. dev accepts --poll, --vite-port (zero picks a free port), and --vite-config.

dev performs one validated bootstrap build, then owns a Bun process hosting Vite's development server and the SSR bridge. The bridge outlives Go child restarts, so frontend module state is never thrown away by a Go edit. All browser assets are proxied through your Go origin at /_bifrost/dev/, so HMR, module loads, and page requests share one origin; the Vite server itself never has to be reachable from the browser. Server and Static pages include stylesheet links extracted from Vite's live SSR module graph, so development pages carry their styles in the HTML instead of flashing unstyled content. Go replacement is detected by a long-poll to /_bifrost/build-id that holds until the process is replaced, so idle development generates almost no requests.

Call bifrost.Building() immediately after New and return before opening databases or listeners. build runs the app through dedicated describe and static-generation phases, asks Vite to build each unique client and SSR view, prerenders Static routes through Bun, compiles a pinned standalone Bun renderer, validates Vite's manifests, writes a strict Bifrost manifest, and atomically replaces .bifrost.

The generated zz_bifrost_gen.go embeds .bifrost and provides the package-local bifrostAssets value used by Config.

React module contract

export function Head(props) {
  return <title>{props.title}</title>;
}

export function Page(props) {
  return <main>{props.title}</main>;
}

Page is required. Head is optional. Server and Static pages hydrate. Client pages mount into an empty shell. Loader and generator props are sent to the browser; never return secrets as props. Hydrated pages use React Client Component rules, so page components cannot themselves be async. Use React.lazy with Suspense for streamed deferred UI.

A Server loader may return request-scoped root document attributes without putting them in React props:

return bifrost.PageData{
    Props: pageProps,
    Document: bifrost.Document{Lang: "pt-BR", Class: "dark", Dir: "ltr"},
}, nil

StaticPage.Document provides the same attributes for generated pages. Bifrost validates the language, class, and direction before writing the response.

HTTP composition

Register Bifrost and ordinary handlers on one user-owned http.ServeMux, then wrap that mux with shared middleware:

mux := http.NewServeMux()
if err := app.Register(mux); err != nil {
    log.Fatal(err)
}
mux.Handle("/", apiRouter)
handler := sharedMiddleware(app.ResolveMarkdown(mux))

ResolveMarkdown serves server-rendered routes as Markdown for a .md path suffix or a preferred Accept: text/markdown media type. It leaves static pages, client pages, public files, and other mux handlers unchanged. Handler applies it automatically.

Use /{$} for an exact root page. The standard / pattern is a subtree fallback. Bifrost does not add router-specific adapters.

Build and runtime boundary

Build phases execute the application to collect immutable declarations. Code needed to construct Config, routes, loaders, and generators must be side-effect free. Check bifrost.Building() immediately after New, before opening listeners, databases, queues, or background workers.

When declarations live in an internal package, pass the generated package-local bifrostAssets from main into that package. See example/structured for this layout. This keeps one generated embedded tree and avoids a second copied embed.

SSR concurrency contract

Bifrost uses one isolated Bun renderer process by default. RenderConcurrency: N starts N production renderer processes, and each process handles one render at a time. Development always serializes SSR through its one Vite module graph. This prevents simultaneous requests from racing through one JavaScript module graph while allowing explicit production scaling.

Mutable JavaScript module globals still persist between sequential requests handled by the same worker. Do not store locale, user, authentication, or request data in module-level variables. Derive them from props or request-local React context.

Expose renderer readiness through the user-owned health endpoint:

if err := app.Ready(request.Context()); err != nil {
    http.Error(writer, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
    return
}
writer.WriteHeader(http.StatusNoContent)

Frontend plugins

Use normal Vite module and build plugins in vite.config.ts. Bifrost enforces its entry points, output roots, SSR bundling, and asset base while preserving user plugins and transforms. Bifrost owns dynamic HTML streaming, so plugins that require an HTML entry or transformIndexHtml are outside the current contract.

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
});

Tailwind then uses its normal CSS entry:

@import "tailwindcss";

Frontend route metadata

Bifrost resolves virtual:bifrost/routes to the Go route table in production builds and development, and invalidates it when routes change:

import { href, routes } from "virtual:bifrost/routes";

export function Nav() {
  return <nav>{routes.map((route) => route.pattern)}</nav>;
}

href(pattern, params) interpolates http.ServeMux patterns such as /post/{slug} and /files/{path...}. bifrost init writes the matching bifrost.d.ts declarations; add them manually to other projects.

The module is read-only metadata. Explicitly wired apps own their client-side navigation. Convention apps include it automatically.

Convention navigation

Use normal <a href="/posts/hello"> links. After the first server render, convention apps fetch the next route through the same Go middleware and loader, lazy-load its Vite module, and update one React root. Shared layouts stay mounted. Back/forward, scroll, hash links, focus, page head, and root document attributes update with the route.

Page-local state resets when the pathname changes, including dynamic parameters such as /posts/one → /posts/two. Query changes keep page state but reload props. Hash-only changes keep state without running the loader. Shared layouts keep state while they remain in the tree; leaving a layout discards its state. Back/forward restores scroll, not previously unmounted page state.

The current page stays visible with aria-busy="true" on #app while navigation runs. A newer navigation or refresh cancels the previous request. There is no prefetch or route-data cache; loaders run on route navigation and refresh, including back/forward between paths or queries.

External links, downloads, new tabs, and modified clicks keep browser behavior. Add data-bifrost-reload to a link to force a document load. Unsupported responses, incompatible builds, and heads with scripts, base tags, or HTTP-equivalent metadata fall back to document navigation. Direct visits and links without JavaScript still use SSR.

Navigation responses contain props and server-rendered head metadata, not page HTML. Bifrost still runs SSR to preserve render-error boundaries, discarding body chunks without buffering them. This saves document reloads, not SSR work. Custom middleware must preserve the navigation Accept header and Vary: Accept; do not cache these responses.

Generated convention routes use Route.WithNavigation(). Its view must export a hook-free renderPage(props, pageKey?) tree factory and an SSR Page component that renders the same tree. The factory keys the page branch by pageKey while keeping layout keys stable. The generated factory opts out of React Compiler memoization; hooks belong in the page and layout components inside it. Ordinary Server, Static, and Client declarations keep their existing behavior.

Programmatic navigation and refresh

Convention apps can import navigate and refresh from virtual:bifrost/navigation. Call them from browser event handlers or effects, not during rendering:

import { navigate, refresh } from "virtual:bifrost/navigation";

export function Actions() {
  return <>
    <button onClick={() => navigate("/posts")}>Posts</button>
    <button onClick={() => refresh()}>Refresh</button>
  </>;
}
  • navigate(href) follows the same path as an internal link and adds a history entry. Relative URLs resolve against the current browser URL. External HTTP(S) URLs use a document load; other URL schemes reject with TypeError.
  • refresh() reruns the current URL's middleware and loader without adding a history entry. It updates props and head metadata while keeping page/layout state, focus, and scroll. Call await refresh() after a successful mutation to display fresh server data. If the server redirects, Bifrost replaces the current history entry and applies normal route state and focus rules.
  • Both return Promise<void>. They resolve after the client update, a superseding request, or initiation of a document fallback; they do not wait for a fallback document to load. Importing them during SSR is safe, but calling them without a mounted client router rejects.

bifrost init includes the types. Existing convention apps can add this to bifrost.d.ts:

declare module "virtual:bifrost/navigation" {
  export function navigate(href: string): Promise<void>;
  export function refresh(): Promise<void>;
}

Run bash scripts/navigation-integration.sh for production and development browser checks (requires Chromium).

Browser performance

Bifrost emits render-blocking styles first, preloads every static client import, and gives module preloads low fetch priority so they do not compete with high-priority LCP images. Vite remains responsible for tree shaking and chunking. Hashed build assets use one-year immutable caching.

Serve production responses through Brotli or gzip compression. Compression remains the HTTP server, CDN, or reverse proxy's job because that layer owns content negotiation and caching. Import long-lived assets through Vite when possible so they receive hashed immutable URLs; files copied from public/ keep stable URLs and revalidate by default.

Track compressed transfer bytes, request count, LCP, CLS, and hydration time under network and CPU throttling. Local uncompressed load time is not a useful production browser metric.

Go application plugins

type AppPlugin interface {
    Name() string
    Register(*bifrost.AppRegistry) error
}

AppPlugins register once during New. They add validated page routes, standard Go middleware, typed error handling, asset headers, and runtime observation hooks. Frontend transforms belong to Vite. There is no global Go registry or generic event bus.

Guarantees

  • Standard http.ServeMux patterns and path values.
  • Props are encoded once and safely embedded for hydration.
  • Request-scoped root document attributes are validated and kept out of React props.
  • Immutable startup model and strict stale-manifest checks.
  • Vite manifests are authoritative; Go hashes but never renames Vite output.
  • Development requests, HMR, and module loads share one origin through the Go server, and the Vite bridge survives Go restarts.
  • Tailwind, React Compiler, Vite aliases, linked workspace packages, virtual modules, CSS Modules, assets, and shared client/SSR chunks are covered by integration tests.
  • Static and client requests do no render work.
  • SSR streams head and body frames.
  • Isolated renderer workers with bounded concurrency and queue.
  • End-to-end request cancellation through Go, Bun, and React streams.
  • Renderer readiness checks and process restart after transport failure.
  • Hashed assets use immutable cache headers.
  • Required build failures fail the whole build.

Platforms

Linux amd64 and arm64 production, containers, and macOS development. Windows is not supported.

Checks

make check
make integration
make dev-integration
make reproducible
make bench

See DESIGN.md, QUESTIONNAIRE.md, and IMPLEMENTATION.md for the model, decisions, completed scope, and measured limits.

Documentation

Overview

Package bifrost serves Vite-built React Server, Static, and Client pages through the standard Go net/http stack. Vite owns frontend builds and development; Bun executes streaming SSR; Go owns server behavior.

Index

Constants

This section is empty.

Variables

View Source
var ErrRendererBusy = errors.New("bifrost: renderer is busy")
View Source
var Version = detectedVersion()

Version identifies the Bifrost build tool in generated manifests. Release binaries derive it from Go module build information. It remains mutable so a release pipeline can set it with -ldflags when needed.

Functions

func Building added in v1.1.0

func Building() bool

Building reports whether the app is running under the Bifrost describe or static-generation protocol. Main should return immediately after New in this mode, before opening databases, listeners, or other services.

func ErrorStatus added in v1.3.0

func ErrorStatus(err error) (int, bool)

func IsRedirect added in v1.3.0

func IsRedirect(err error) bool

func NotFound added in v1.1.0

func NotFound(causes ...error) error

NotFound returns a loader error that sends a 404 response.

func Redirect added in v1.1.0

func Redirect(url string, statuses ...int) error

Redirect returns a loader error that sends an HTTP redirect.

func Status added in v1.3.0

func Status(status int, cause error) error

Types

type App added in v0.1.4

type App struct {
	// contains filtered or unexported fields
}

App is an immutable, validated application model. Runtime services are added to this type after model compilation succeeds.

func MustNew added in v1.1.0

func MustNew(config Config) *App

MustNew is New with panic-on-error behavior for programs that treat invalid app declarations as programmer errors.

func New

func New(config Config) (*App, error)

New validates declarations, runs plugin registration, and starts the production runtime. Build phases may omit Assets; normal applications may not.

func (*App) Close added in v1.1.0

func (a *App) Close(ctx context.Context) error

Close drains renderer work until ctx ends, then stops child processes.

func (*App) Diagnostics added in v1.1.0

func (a *App) Diagnostics() Diagnostics

Diagnostics returns a snapshot suitable for a development route table.

func (*App) Handler added in v0.1.4

func (a *App) Handler() http.Handler

Handler returns a standalone Bifrost handler. A setup failure produces a deterministic 503 response rather than a partially configured route table.

func (*App) Ready added in v1.1.0

func (a *App) Ready(ctx context.Context) error

Ready checks whether every renderer process can accept work. Applications with only Static or Client routes are ready when their runtime is compiled.

func (*App) Register added in v1.1.0

func (a *App) Register(mux *http.ServeMux) (err error)

Register installs Bifrost pages and assets into mux.

func (*App) ResolveMarkdown added in v1.1.1

func (a *App) ResolveMarkdown(mux *http.ServeMux) http.Handler

type AppPlugin added in v1.1.0

type AppPlugin interface {
	Name() string
	Register(*AppRegistry) error
}

AppPlugin adds server routes, HTTP middleware, and runtime hooks during app construction. Frontend plugins belong in vite.config.ts.

type AppRegistry added in v1.1.0

type AppRegistry struct {
	// contains filtered or unexported fields
}

AppRegistry contains typed server extension points. An AppRegistry is valid only during AppPlugin.Register.

func (*AppRegistry) AddRoutes added in v1.1.0

func (r *AppRegistry) AddRoutes(routes ...Route) error

AddRoutes adds normal Bifrost routes. They receive the same validation as routes declared in Config.

func (*AppRegistry) AssetHeaders added in v1.1.0

func (r *AppRegistry) AssetHeaders(hook AssetHeaderHook) error

AssetHeaders installs an asset response header hook. The bool argument is true for a public/ file and false for a hashed build artifact.

func (*AppRegistry) HandleErrors added in v1.1.0

func (r *AppRegistry) HandleErrors(handler ErrorHandler) error

HandleErrors installs the app error handler. Only one plugin may install it.

func (*AppRegistry) OnLoad added in v1.1.0

func (r *AppRegistry) OnLoad(hook LoadHook) error

OnLoad registers a typed loader observation hook.

func (*AppRegistry) OnQueue added in v1.1.0

func (r *AppRegistry) OnQueue(hook QueueHook) error

OnQueue registers a typed renderer queue observation hook.

func (*AppRegistry) OnRender added in v1.1.0

func (r *AppRegistry) OnRender(hook RenderHook) error

OnRender registers a typed renderer observation hook.

func (*AppRegistry) OnResponse added in v1.1.0

func (r *AppRegistry) OnResponse(hook ResponseHook) error

OnResponse registers a typed response observation hook.

func (*AppRegistry) Use added in v1.1.0

func (r *AppRegistry) Use(middleware Middleware) error

Use appends standard HTTP middleware.

type AssetHeaderHook added in v1.1.0

type AssetHeaderHook func(http.Header, bool)

AssetHeaderHook may add or replace headers for built and public assets.

type Config added in v1.1.0

type Config struct {
	// SourceRoot is the directory against which frontend view paths resolve. An
	// empty value means the current directory.
	SourceRoot string
	Routes     []Route
	AppPlugins []AppPlugin

	// Assets contains a production manifest and its referenced files. When it
	// is nil, New creates a declaration-only app for build and development
	// phases.
	Assets fs.FS

	// RenderConcurrency is the number of isolated production renderer processes.
	// Each process handles one render at a time. Zero uses one process. Development
	// always uses one process because it owns one Vite module graph.
	RenderConcurrency int
	// RenderQueue bounds requests waiting for renderer capacity. Zero uses 64.
	RenderQueue int

	Limits Limits
	Logger *slog.Logger
}

Config is the complete input to New. Its slices are copied during app construction.

type Diagnostics added in v1.1.0

type Diagnostics struct {
	SpecHash   string
	Routes     []RouteInfo
	AppPlugins []string
	Production bool
}

Diagnostics describes the compiled app without exposing internal state.

type Document added in v1.1.0

type Document struct {
	Lang  string
	Class string
	Dir   string
}

Document describes request-scoped attributes on the root HTML element. Empty Lang defaults to "en". Dir may be empty, "ltr", "rtl", or "auto".

type ErrorHandler added in v1.1.0

type ErrorHandler func(http.ResponseWriter, *http.Request, error)

ErrorHandler handles an error before Bifrost writes a response.

type Generator added in v1.1.0

type Generator func(context.Context) ([]StaticPage, error)

Generator returns all documents emitted by a static route.

type Limits added in v1.1.0

type Limits struct {
	MaxPropsBytes    int
	MaxHeadBytes     int
	MaxFrameBytes    int
	MaxMarkdownBytes int
}

Limits bounds buffered, attacker-influenced render data. The streamed total body size is not capped.

type LoadEvent added in v1.1.0

type LoadEvent struct {
	Pattern  string
	Duration time.Duration
	Err      error
}

LoadEvent reports one completed loader call.

type LoadHook added in v1.1.0

type LoadHook func(context.Context, LoadEvent)

LoadHook observes completed loader calls. It must not mutate request state.

type Loader added in v1.1.0

type Loader func(*http.Request) (any, error)

Loader returns JSON-encodable props for one server-rendered request.

type Middleware added in v1.1.0

type Middleware func(http.Handler) http.Handler

Middleware is standard Go HTTP middleware.

type PageData added in v1.1.0

type PageData struct {
	Props          any
	Document       Document
	Status         int
	ErrorFallbacks int
}

PageData lets a Server loader return props and root document attributes. A loader may still return plain props when it needs no document attributes.

type QueueEvent added in v1.1.0

type QueueEvent struct {
	Pattern string
	Wait    time.Duration
	Err     error
}

QueueEvent reports one renderer admission and queue wait.

type QueueHook added in v1.1.0

type QueueHook func(context.Context, QueueEvent)

QueueHook observes renderer admission and queue outcomes.

type RawProps added in v1.1.0

type RawProps json.RawMessage

RawProps lets an advanced loader provide pre-encoded JSON. Bifrost validates, compacts, and safely escapes it before rendering.

type RenderEvent added in v1.1.0

type RenderEvent struct {
	Pattern  string
	Duration time.Duration
	Err      error
}

RenderEvent reports one completed renderer call.

type RenderHook added in v1.1.0

type RenderHook func(context.Context, RenderEvent)

RenderHook observes completed renderer calls. It must not mutate render state.

type ResponseEvent added in v1.1.0

type ResponseEvent struct {
	Pattern  string
	Status   int
	Bytes    int64
	Duration time.Duration
	Err      error
}

ResponseEvent reports one completed page response.

type ResponseHook added in v1.1.0

type ResponseHook func(context.Context, ResponseEvent)

ResponseHook observes completed page responses.

type Route added in v0.1.4

type Route struct {
	// contains filtered or unexported fields
}

Route is an immutable page declaration. Use Server, Static, or Client to construct one.

func Client added in v1.1.0

func Client(pattern, view string) Route

Client declares a page mounted only in the browser.

func Server added in v1.1.0

func Server(pattern, view string, load Loader) Route

Server declares a page rendered on each request. A nil loader supplies empty props.

func Static added in v1.1.0

func Static(pattern, view string, generate Generator) Route

Static declares a page rendered during the production build. A nil generator is valid only for an exact route and emits that route's own path.

func (Route) WithNavigation added in v1.3.8

func (r Route) WithNavigation() Route

type RouteInfo added in v1.1.0

type RouteInfo struct {
	Pattern string
	View    string
	Kind    string
}

RouteInfo is immutable route diagnostic data.

type StaticPage added in v1.1.0

type StaticPage struct {
	Path     string
	Props    any
	Document Document
}

StaticPage describes one document emitted by a static route.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL