borgo

package module
v0.22.1 Latest Latest
Warning

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

Go to latest
Published: Sep 11, 2026 License: MIT Imports: 36 Imported by: 0

README


borgo

npm borgo-framework npm create-borgo ci go reference license MIT

Italian for "village": small, self-governing, self-hosted.

The self-hosted React framework. Vercel developer experience. Go performance. Bun tooling.

File-based React pages server-rendered by Bun, API routes written in Go. You get the DX — bunx create-borgo@latest my-app, drop a file in pages/, drop a file in api/, one dev command — without the platform. Deployment is one Go binary and one Bun server on any box you control.

Why borgo?

  • The backend is Go. Not Node pretending to be a backend — a static binary on net/http with zero dependencies, real concurrency, and tens of megabytes of memory instead of hundreds. The process that pages you at 3 a.m. is the boring one.
  • The API types are generated from Go source. borgogen reads your handlers with go/types and writes the TypeScript bridge — routes, response types, request bodies, WebSocket payloads. Rename a Go field and tsc fails on the page that read it. No OpenAPI spec to keep honest, because there is no spec: the code is the spec.
  • React, unmodified. Bun, one toolchain. The ecosystem you already know — no fork, no compiler magic, no proprietary component model — with the dev loop of a modern meta-framework and no bundler config to own.
  • Self-hosted, by conviction. Any VPS, container host or bare-metal box. React, SSR, typed APIs, WebSockets, streaming and Docker — without depending on Vercel, Cloudflare or Netlify.
  • You can read the whole thing. Roughly twelve thousand lines of TypeScript and Go, with the reasoning threaded through them — a comment line for every four of code, saying why rather than what. Most of what makes Next-style frameworks pleasant is conventions, not machinery — and conventions are cheap.

Every position above is argued, with its bill attached, in why borgo works this way. Everything else you expect is here and is a file convention — layouts, streaming SSR, form actions that work with JavaScript off, typed SSE and WebSockets, sessions and auth, a typed environment schema, PWA plumbing, a nonced CSP by default, health checks and metrics — each with a paragraph below and a deep-dive page in docs/.

Quickstart

Prerequisites: Bun >= 1.4, Go >= 1.27.

bunx create-borgo@latest my-app
cd my-app
bun install
go mod tidy   # fetches the borgo go module
bun run dev

Three templates: base (default — a tour of loaders, actions, islands and SSE), minimal (one page, one route) and full (notes CRUD + auth + typed WebSockets) — pick with --template, or let the interactive prompt ask.

Open http://localhost:3000 — edit a page and watch fast refresh keep your state. For a guided build instead of a tour, getting started takes you from here to a working feature in about twenty minutes. When it's time to ship: docker compose up -d (the scaffold includes the Dockerfile), or see the deploy guide.

To poke at the full demo instead, clone this repo and run bun install, then cd examples/tasks && bun run dev.

Every page picks how it ships

Most frameworks make rendering strategy an application-level decision, or a different framework entirely. In borgo it is one export, per page, and every mode composes with the same loaders, layouts and typed API client:

You write The page is
nothing SSR — rendered on every request, streamed through Suspense
export const revalidate = 300 ISR — rendered once, cached and shared; re-rendered when the clock runs out
export const tags = ["notes"] …and (beside revalidate) also the moment Go calls borgo.RevalidateTag("notes") — on-demand invalidation from the handler that changed the data
export const prerender = true static — baked to plain HTML by borgo export, servable by nginx or any CDN, no server at all (prerenderPaths enumerates dynamic routes)
export const hydrate = false zero-JS — server HTML only, not one byte of JavaScript shipped
export const hydrate = "visible" deferred — hydration waits until the marked element (or the page root) scrolls into view
<Island name="Counter" /> islands — only that component's JavaScript, inside an otherwise static page

And after the first load, every hydrated page gets SPA-style client navigation for free: plain <a> tags become client-side transitions with per-route code splitting, hover/viewport prefetching and scroll restoration — no <Link> component, no router config. A zero-JS page keeps honest full-page links, because it shipped no runtime to do otherwise.

// pages/news.tsx - one real page from the full template
export const revalidate = 300;      // cached and shared for five minutes...
export const tags = ["notes"];      // ...unless Go invalidates it first

Deep dives: pages and routing for SSR and ISR, client navigation and hydration for hydration modes and islands, static export for the no-server case.

Conventions

Everything below is a file convention. Each gets one paragraph here and a deep-dive page in docs/.

Pages

React components in pages/, routed by file name — pages/tasks/[id].tsx → /tasks/:id. A page may export a loader that runs on the server before rendering; its result becomes the component's props. Loader and action code is stripped from client bundles, so server-only imports never reach the browser.

import type { LoaderContext } from "borgo-framework";
import type { Task } from "@/.borgo/api-types";

export async function loader({ params, api }: LoaderContext) {
  const { task } = await api("GET /api/tasks/{id}", { params: { id: params.id } });
  return { task };
}

export default function TaskDetail({ task }: { task: Task }) { /* ... */ }

Layouts (_layout.tsx, nested), per-page head exports, streaming SSR through Suspense, and custom _404.tsx/_500.tsx error pages round out the page model. Deep dive: pages and routing.

API routes and the typed bridge

API routes are Go files in api/; annotate a handler with a route directive and it is mounted for you. The Go runtime imposes no database and has zero dependencies.

//borgo:route GET /api/tasks
func ListTasks(w http.ResponseWriter, r *http.Request) {
    borgo.JSON(w, http.StatusOK, TaskList{Tasks: tasks})
}

borgogen statically analyzes the api package — no reflection, nothing at runtime — and generates the TypeScript route map the api client is typed by: response types from borgo.JSON[T]/borgo.WriteJSON calls (helpers followed), request types from borgo.Bind[T], custom marshalers covered by //borgo:type overrides. A wrong body fails tsc, and CI proves it. Deep dive: the typed bridge.

Form actions

A page may export an action; the front server runs it for POST requests to that page's URL. On hydrated pages the runtime enhances the form — the action runs over fetch, the page re-renders in place and the scroll position stays put — while without JavaScript the same form falls back to the classic post cycle. redirect(to) gives you post/redirect/get either way:

import { redirect, type ActionContext } from "borgo-framework";

export async function action({ request, api }: ActionContext) {
  const form = await request.formData();
  const title = String(form.get("title") ?? "").trim();
  await api("POST /api/tasks", { body: { title, body: String(form.get("body") ?? "") } });
  return redirect("/");
}

Deep dive: pages and routing.

Client navigation and hydration

Plain <a> tags become client-side transitions — no <Link> component — with per-route code splitting, hover/viewport prefetching, and scroll restoration on back/forward. Pages control their JavaScript: export const hydrate = false ships zero JS, "visible" defers hydration until scrolled into view, and <Island> components hydrate independently inside otherwise-static pages. Deep dive: client navigation and hydration.

Realtime

borgo.SSE and borgo.NewSSEHub make any handler an event stream, proxied without buffering. The front server is also a native WebSocket server: browsers join named topics with subscribe, Go publishes into them with borgo.Push(topic, event, data) — and borgogen types the payloads end to end, so checking event narrows data and an undeclared event name fails tsc. Topics are public broadcast channels: anyone who can reach the server can subscribe to any topic name, so keep private data behind an authenticated route (why, and what is coming).

borgo.Push("live", "task-created", task.Title)

Deep dive: realtime.

Sessions and auth

Mechanics, not policy: signed-cookie sessions (borgo.SetSession/GetSession/ClearSession, HMAC with SESSION_SECRET, expiry signed in), stdlib PBKDF2 password hashing behind a swappable interface, and borgo.Auth[U] — you supply a Lookup (and optionally Register) over your user store, it provides the login/logout/register handlers. borgo.Authed guards api routes with a JSON 401; loaders guard pages by returning redirect(); one double-submit token covers both unsafe paths for any browser that has been issued it — a hidden field on form actions (<CsrfField />), an X-CSRF-Token header on browser POST/PUT/PATCH/DELETE to /api/* (apiFetch) — login included. Deep dive: auth and sessions.

var auth = borgo.Auth[User]{Lookup: lookupUser, Register: createUser}

func init() {
    borgo.Handle("POST /api/login", auth.LoginHandler)
    borgo.Handle("GET /api/me", borgo.Authed(currentUser))
}

Static export

borgo export prerenders every statically exportable page into dist/site/ — plain HTML next to the built assets, servable by nginx, a CDN, anything. Pages with loaders opt in with export const prerender = true; dynamic routes list their param sets with prerenderPaths. hydrate = false pages export with zero JavaScript. Deep dive: static export.

Dev experience

borgo dev keeps the browser hot: component and hook edits apply through react-refresh with state intact, styles recompile and swap in place (style.scss by default, Tailwind v4 behind the opt-in --tailwind flag), Go changes rebuild the binary and reload once the new API answers, and a broken build keeps serving the error overlay instead of taking the port down. When something is off, borgo doctor diagnoses the environment — bun and go versions, the bun shim on PATH, docker, the two ports and who holds them, disk space, generated types, dependencies, write access — with a one-line fix beside each failing check. Deep dive: dev experience; stuck? FAQ and troubleshooting.

Security

A locked-down default posture, not a checklist you assemble: security headers and a strict Content-Security-Policy on every document — with the server-rendered props script nonced, so no 'unsafe-inline' is needed in production — CSRF on form actions and on proxied /api/* mutations, signed HttpOnly session cookies, request bodies bounded by the bytes that actually arrive (BORGO_MAX_BODY, not a declared Content-Length), a slowloris-resistant timeout matrix, an Origin check on WebSocket upgrades, and duplicate cookies treated as no cookie at all. Everything is overridable by environment variable, and the security page is equally explicit about what borgo deliberately leaves to you.

Health checks and metrics

The front server answers /healthz with {status, uptime, api} — probing the Go server's own /healthz (mounted automatically by borgo.Serve). Set BORGO_METRICS=1 and /metrics serves Prometheus text: request counts and a duration histogram by route pattern and status, hand-rolled, zero dependencies. Deep dive: deploy guide.

Architecture

Two processes, one front door:

  • Bun front server (borgo dev / borgo start) — server-renders pages with react-dom/server, serves static assets, proxies /api/* to the Go server. Loaders run here, fetching from Go during SSR; props are serialized into the HTML and the client bundle hydrates the same tree. Compression is built-in: borgo build precompresses assets to .gz/.br (hashed chunks served immutable), SSR HTML and API JSON are gzipped at runtime.
  • Go API server — plain net/http with method patterns, bootstrapped by borgo.Serve().
packages/borgo          npm: the bun/typescript core (cli, ssr server, router, build, runtime, typed api client)
packages/create-borgo   npm: project scaffolder (three templates: base, minimal, full)
*.go                    go module github.com/LuigiDavideMicca/borgo: route registry, server bootstrap,
                        sse, websocket push, sessions, cache helpers (standard library only)
cmd/borgogen            go: static analysis codegen for the typed bridge and route mounting. same module,
                        so go.mod requires golang.org/x/tools - a build-time tool that never links into
                        your api binary, which is why "zero deps" means zero *runtime* deps
examples/tasks          demo app: tasks crud with gorm + sqlite, sse, websockets, islands, deferred hydration
docs/                   getting started, then deep dives: pages, typed bridge, client nav, realtime,
                        auth, security, dev experience, pwa, deploy, faq

Commands (in an app): borgo dev (both servers, watch, fast refresh), borgo build (client assets in public/assets/, Go binary in dist/), borgo start (run from build output, supervising both processes; --front-only for split deployments with API_URL), borgo export (static site in dist/site/), borgo deploy init <caddy|nginx|systemd|compose> (deploy configs), borgo pwa init (manifest and service worker), borgo doctor (environment diagnosis). Ports via PORT (front, 3000) and API_PORT (Go, 3501).

Deploying

Let's be honest about this up front, because it is the trade the whole framework is built on: there is no Deploy button. You cannot push borgo to Vercel, Netlify or Cloudflare — by design — and no platform stands behind your uptime. What you get instead is a deployment you own end to end, on hardware that costs a fixed few euros a month, with no platform bill that scales with your success and no runtime you have to emulate locally.

Here is what that actually looks like, start to finish — a VPS with Docker and Caddy installed, and a domain pointed at it:

# on your machine: generate the reverse-proxy config, then make its two go-live
# edits - your domain in place of example.com, and the `tls internal` line deleted
bunx borgo deploy init caddy

# ship the app, then its one secret file (.env is gitignored - it travels by hand, once)
rsync -a --exclude node_modules --exclude .env . box:/srv/my-app/
scp .env box:/srv/my-app/.env

# on the box: build and run - the scaffolded Dockerfile compiles Go static and the client assets
ssh box "cd /srv/my-app && docker compose up -d"
ssh box "cp /srv/my-app/Caddyfile /etc/caddy/Caddyfile && systemctl reload caddy"

That is the whole first deploy; every one after it is the same rsync followed by docker compose up -d --build. Caddy handles the certificate from there, the container answers /healthz about 330 ms after docker run — measured, not estimated — and the full template's compose file requires SESSION_SECRET from that .env, so a forgotten key stops the deploy with a message instead of shipping an app whose every login fails.

The honest bill: backups, monitoring, OS updates and the box itself are yours now — that part no guide takes off your hands. What the deploy guide does cover is everything borgo-shaped: single-container and two-service layouts, nginx as the Caddy alternative (WebSockets and SSE included), a systemd unit for bare metal, static export hosting, and the full environment reference. borgo deploy init <caddy|nginx|systemd|compose> writes every one of those configs into your project, templated with your app's name and ports.

Tests

Three layers, all run by CI on every pull request and on every push to main:

  • Go (go test ./...) — table-driven tests for the route registry, sessions (sign/verify/tamper/expiry), password hashing and the borgo.Auth handlers (login/register/logout, timing-safe 401s, Authed), cache headers, the /healthz handler, SSE stream framing and hub broadcast/slow-client behavior, borgo.Push, and borgogen against a committed fixture app: route discovery (directives + Handle calls), helper following, WriteJSON, Bind, type overrides, Push event extraction, snapshot freshness, and the error paths (duplicate patterns, malformed directives) — while a computed push topic generates in silence, because a name decided at runtime is a choice, not a mistake.
  • TypeScript (bun test packages/borgo/test) — the router (patterns, matching, params), the api client (URL building, headers, ApiError, typed bodies plumbing), hydrate/refresh source parsing, manifest generation against a temp fixture (islands flags, client-route exclusion, precedence), every borgo doctor check against a fake environment, the export planner (loader/prerender/dynamic partitioning, path filling), the deploy config templates (ports, names, refuse-overwrite), and the Prometheus exposition format.
  • End-to-end (npx playwright test) — against a production build of examples/tasks: client navigation, hover/viewport prefetching, scroll restoration, islands, hydration modes, form actions (enhanced in-place submits, crash surfacing, anonymous-post CSRF), the auth round trip (register, loader guard, logout, login, forged-post CSRF rejection), the precache manifest, SSE, two-tab WebSockets with Go push, streaming SSR, error pages, /healthz on both servers, /metrics series, a borgo doctor smoke — plus a dev-server project asserting fast refresh preserves component state (including five consecutive rapid edits), hook add/remove remounts without a reload, custom hook edits hot-apply, Go edits reload exactly once and only after the api answers, CSS hot-swaps, and layouts fall back to a reload — and an export project that runs borgo export and serves dist/site from a plain static file server, asserting content, hydration against exported props, and the zero-JS page — plus an isr project asserting cached pages replay one render, carry a fresh CSP nonce per response, drop on borgo.RevalidateTag from the Go handler that wrote the data, and come back warm from disk after a restart.

Versioning and releases

release-please maintains a release PR from conventional commits; merging it tags vX.Y.Z and publishes both npm packages (borgo-framework, create-borgo) with linked versions via npm trusted publishing, provenance attached. The Go module github.com/LuigiDavideMicca/borgo lives at the repo root and resolves the same vX.Y.Z tag — one version number across all four artifacts that have to agree: the Go module, the two npm packages, and the borgo CLI that ships as borgo-framework's bin. See api stability. Upgrading? The borgo-framework README lists every behaviour that changed, one line each — from 0.21 and from 0.20 — and the environment reference has every variable with its grammar.

How it compares

Honest comparison with the frameworks a borgo adopter would otherwise pick. ✓ means shipped and documented here; a — links to the reasoning in the next section. For measured numbers rather than feature rows, see Benchmarks below.

borgo Next.js Nuxt SolidStart
Backend language Go Node Node Node
File-based routing, nested layouts ✓ ✓ ✓ ✓
SSR + streaming Suspense ✓ ✓ ✓ ✓
Typed server↔client bridge ✓ generated from Go source, request bodies included ✓ Server Actions (API routes: manual / tRPC) ✓ Nitro $fetch ✓ server functions
Client nav, prefetch, scroll restoration ✓ ✓ ✓ ✓
Per-route code splitting ✓ ✓ ✓ ✓
Hydration control ✓ page-level opt-out, deferred, islands — (RSC instead) ✓ islands (experimental) — (fine-grained reactivity instead)
Form actions ✓ ✓ ✓ ✓
SSE + WebSockets first-class ✓ typed event payloads bring your own ✓ Nitro bring your own
Static export ✓ borgo export ✓ ✓ ✓
Health endpoint + metrics ✓ built-in, opt-in Prometheus DIY DIY DIY
Sessions/auth ✓ signed cookie, hashing, login helpers, CSRF libraries modules libraries
Security headers + CSP by default ✓ nonced, overridable DIY modules DIY
Fast refresh ✓ state-preserving, bun-native transform ✓ ✓ ✓
React Server Components — ✓ n/a n/a
ISR (cached pages + on-demand invalidation) ✓ revalidate/tags + borgo.RevalidateTag from Go ✓ ✓ ✓
Edge / serverless targets — ✓ ✓ ✓
Image/font optimization — ✓ ✓ —
Plugin ecosystem — ✓ ✓ ✓
Deploy story one box: Docker/compose/systemd, generated configs Vercel or DIY many presets many presets
Framework size small enough to read: the whole thing, codegen and cli tooling included, is about twelve thousand lines of Go and TypeScript large large medium

Benchmarks

There is a benchmark harness in bench/, and it is built backwards from every benchmark you have learned to distrust: the method is written before any result, and the biases are declared before the table. "We wrote the harness and one of the subjects" is bias #1 on that list, stated in bench/README.md before any number appears, with four more after it.

  • Five scenarios, pinned by a contract: JSON floor, 15 kB serialisation, a server-rendered page, a static asset byte-identical across implementations, and memory per held SSE connection. Every implementation serves the same paths on one port, so nothing can quietly answer a cheaper route.
  • Six implementations beside borgo's: Next.js, Astro, Hono, Elysia, Express, Fastify — and a Fresh stub left deliberately empty, because a competitor we could not run would be a guess wearing a number.
  • Correctness before speed: every response is checked against the contract — exact bodies, key order on the wire, sha256 for the asset — before any load is generated. A fast wrong answer is not a result. A median success rate below 99% fails the scenario.
  • The machine testifies: every result file records CPU idle before and after, free memory, versions, the commit, and whether the tree was dirty. A run on a busy machine opens with a contamination warning instead of hiding it.

The committed run in bench/results/ is deliberately a single-implementation proof run of borgo alone, labelled "not a comparison" — it demonstrates the pipeline end to end on a machine that was never verified idle, and we would rather commit no comparative table than one nobody attested was clean. The harness runs all seven; the numbers worth citing are the ones you make:

bun bench/run.ts --list          # implementations and scenarios, run nothing
bun bench/run.ts --apps borgo    # one implementation
bun bench/run.ts                 # the full campaign, on your machine

The results render as a page — live at luigidavidemicca.github.io/borgo, republished on every push to main — itself a borgo app (bench/site/), exported static with borgo export, its charts inline SVG baked at build from the committed JSON, with the biases above every number. A test suite holds the page's figures byte-equal to the JSON, so the page cannot drift from the data.

What this is not

Everything here is a deliberate choice, with the reason attached:

  • No React Server Components. Loaders returning serialized props are the model: they cover data-on-the-server with a runtime small enough to read. RSC needs deep bundler/runtime integration that would be most of the framework's weight for one feature — the argument, its costs, and what would reopen it.
  • No edge or serverless targets. borgo is self-hosted by conviction — one box, two processes, a reverse proxy. Pages that declare revalidate are rendered once and shared with on-demand invalidation from Go — ISR economics without the edge — and borgo export covers the fully static case; what borgo will not do is deploy you to someone else's runtime.
  • No image/font optimization pipeline. The build is one Bun.build call and stays that way; put a CDN or vips in front if you need it.
  • No plugin system. The framework is small enough that the extension mechanism is reading the source and changing it.
  • Loader data is not streamed on client navigations — one JSON payload, fetched in parallel with the route chunk (and usually prefetched on hover). Streaming applies to initial SSR, where it matters most.
  • Auth is mechanics, not policy. Signed cookie, hashing, login/logout/register handlers and CSRF for actions are provided; the user store, its schema, OAuth and everything beyond username/password stay in your hands.
  • The typed bridge is static analysis, no runtime reflection. Helpers are followed across the packages of your module, inline json.NewEncoder(w).Encode(v) is read, and //borgo:type covers custom marshalers; what stays invisible is a helper outside your module, an encoder stored in a variable, and a dynamically chosen type — those routes type as unknown, so the escape hatch is visible, not silent.
  • WebSocket topics are a relay, not RPC — and not authorized. The front server forwards {event, data} between subscribers and Go; per-message business logic belongs in Go routes. The relay stays dumb in both senses: it runs no logic, and it asks your app nothing about who may join a topic. Treat every topic as public until per-topic authorization ships.

Development happens in issues.


Built by Luigi Micca.

Documentation

Overview

Package borgo is the go side of the borgo framework: a route registry and a server bootstrap. API files register their handlers in init() via Handle, and main calls Serve. The core imposes no database and no dependencies.

Index

Constants

View Source
const Version = "0.22.1"

Version is the version of the borgo module, the same number as the npm packages and the git tag of the release. Bumped by hand in the release PR: release-please cannot reach the repository root from packages/borgo, and a root package entry would claim the tag packages/borgo already owns. TestVersionMatchesManifest fails the build when this disagrees with .release-please-manifest.json.

Variables

View Source
var ErrNoSessionSecret = errors.New("borgo: SESSION_SECRET must be set to at least 32 bytes to use sessions (openssl rand -base64 48)")

ErrNoSessionSecret is returned by SetSession when SESSION_SECRET is unset or shorter than sessionSecretMinLen.

View Source
var ErrStreamClosed = errors.New("borgo: SSEStream is closed")

ErrStreamClosed is what Send and Ping return once the stream has been closed by SSEStream.Close. It is one value, so a caller that keeps writing sees the same error whichever call notices first, and can tell a stream it closed itself from a connection that failed under it:

if err := stream.Send("tick", n); errors.Is(err, borgo.ErrStreamClosed) {
	return
}

A stream ended by the client disconnecting or by the server shutting down does not report this: those write attempts fail with whatever the connection reported, because that is the more useful answer. Watch Done for those.

View Source
var ErrUserExists = errors.New("user already exists")

ErrUserExists signals from Auth.Register that the username is taken; the RegisterHandler answers it with 409 instead of 500.

Functions

func Authed added in v0.11.0

func Authed(next http.HandlerFunc) http.HandlerFunc

Authed guards an api route: without a valid session the request is answered 401 as JSON and the handler never runs. borgogen sees through the wrapper, so the route keeps its generated types. Pages guard themselves in their loader instead - see docs/auth-and-sessions.md.

func Bind

func Bind[T any](r *http.Request) (T, error)

Bind decodes the request body as JSON into T, reading at most 1 MB - use BindMax for routes that legitimately take more. borgogen reads T to type the route's request body for the TypeScript api client. On error, respond with BindError to get the right status.

The request must declare Content-Type: application/json; anything else, a missing header included, is refused as 415.

func BindError added in v0.11.0

func BindError(w http.ResponseWriter, err error)

BindError answers a Bind error: 413 when the body exceeded the limit, 415 for a non-JSON content type, 400 for anything else, as JSON.

func BindMax added in v0.11.0

func BindMax[T any](r *http.Request, limit int64) (T, error)

BindMax is Bind with an explicit body size limit in bytes; limit <= 0 disables the cap.

func Cache

func Cache(w http.ResponseWriter, maxAge time.Duration, staleWhileRevalidate ...time.Duration)

Cache marks the response publicly cacheable for maxAge. An optional staleWhileRevalidate window lets proxies serve stale content while they refresh in the background. A response that carries Set-Cookie is marked private instead, so shared caches never store it - in whichever order the handler calls the two.

func CheckEnv added in v0.21.0

func CheckEnv() error

CheckEnv settles the session and push environment while somebody is still watching the terminal. Serve and ServeContext call it before they bind; call it yourself at startup if you mount borgo's handlers on your own server, or the first request that writes a cookie is where you find out. It logs the warnings and returns the refusals, never exits: the caller may be a test binary or an embedder with cleanup of its own.

SESSION_SECURE is refused when it is not a boolean, not read as false: that issued a cookie the browser sends back over plain http. An unset SESSION_SECRET only warns, since borgo already refuses to issue or verify a session without one; a short one is refused, because a handful of bytes can be searched offline from a single captured cookie, and a warning let that run in production.

BORGO_HASH_SLOTS is re-read rather than replayed from init: a refusal frozen at init would outlive the correction and leave ServeContext dead for the life of the process. Init is the only place the cap can be sized, so a corrected value arriving later is logged as too late.

func ClearSession

func ClearSession(w http.ResponseWriter)

ClearSession deletes the session cookie.

func GetSession

func GetSession[T any](r *http.Request) (T, bool)

GetSession verifies the session cookie's signature and expiry and decodes its payload into T. The second return is false for a missing, tampered or expired session.

func Handle

func Handle(pattern string, h http.HandlerFunc)

Handle registers a handler under a net/http method pattern, e.g. "GET /api/tasks" or "GET /api/tasks/{id}".

func JSON

func JSON[T any](w http.ResponseWriter, status int, v T)

JSON writes v as a JSON response with the given status code. Unlike WriteJSON its type parameter is visible to static analysis: borgogen reads T from every JSON call in a handler to type the route for TypeScript.

func Middleware added in v0.21.0

func Middleware(h http.Handler) http.Handler

Middleware wraps h in the chain borgo.Serve installs around its own routes: panic recovery, gzip, and the Set-Cookie/Cache-Control guard that runs as each response's headers commit. An app mounting borgo handlers on its own server should wrap its mux in it -

srv := &http.Server{Handler: borgo.Middleware(mux)}

and gets the same guarantees borgo's own server has. Serve is defined in terms of this function, so the two cannot drift apart.

Without it, only the orders borgo's own setters see are closed: SetSession then borgo.NoCache, or a hand-written Cache-Control, escapes, because there is no last moment on somebody else's mux. And nothing that touches Cache-Control may sit outside the wrapper: an outer defer that writes `public` after this has committed reaches the wire beside the cookie.

func NoCache

func NoCache(w http.ResponseWriter)

NoCache marks the response as never cacheable - right for anything personalized or session-dependent.

func Push

func Push[T any](topic, event string, data T) error

Push publishes an event to every browser subscribed to a websocket topic on the front server (see the subscribe helper in the borgo npm package). The front server is assumed on localhost; set FRONT_URL when it is not, and BORGO_PUSH_KEY on both sides when pushing across hosts - over https, or with BORGO_PUSH_INSECURE if the clear-text hop is a deliberate one.

Called with literal topic and event strings, borgogen records T in the generated event map and the browser's subscribe callback for that topic is typed with it. A dynamic topic or event name stays out of the map: the push still happens, the browser side stays untyped.

func Revalidate added in v0.22.0

func Revalidate(path string) error

Revalidate drops the front server's cached copy of a page that declared `export const revalidate`. The natural call site is the handler that just wrote the data the page renders. An exact path drops the page and its query variants; a trailing star drops the prefix: Revalidate("/blog/*"). In dev there is no cache and the call is a no-op that still answers 204, so application code behaves the same in both modes.

func RevalidateTag added in v0.22.0

func RevalidateTag(tag string) error

RevalidateTag drops every cached page that listed the tag in its `tags` export - one call from the handler that wrote the posts, and every page depending on them re-renders on its next request.

func Serve

func Serve()

Serve mounts every registered route and listens on API_PORT (default 3501). It also answers GET /healthz, unless a registered route claims it. It blocks until the process is signalled, then shuts down gracefully; a listener that fails to start, or an environment CheckEnv refuses, is fatal.

Use ServeContext to get the error back instead of exiting - a test or a program that embeds the api needs to be able to stop the server and carry on.

func ServeContext added in v0.21.0

func ServeContext(ctx context.Context) error

ServeContext is Serve that returns instead of exiting. It mounts every registered route, listens on API_PORT and blocks until ctx is cancelled or the parent process named by BORGO_PARENT_PID exits, then shuts down gracefully within BORGO_SHUTDOWN_TIMEOUT and returns nil. A listener that cannot start or stops on its own, a refusal from CheckEnv and a malformed BORGO_*_TIMEOUT all come back as errors, never as an exit or a panic, and the route registry stays open after any of them.

When it returns, the port is released and every event stream this run was serving has ended.

func SetSession

func SetSession(w http.ResponseWriter, v any, maxAge time.Duration) error

SetSession stores v, JSON-encoded and HMAC-signed with SESSION_SECRET, in an http-only cookie. The expiry is signed too, so a client cannot extend it. Set SESSION_SECURE=1 (or "true") to add the Secure attribute behind https. A maxAge of zero or less writes an already-expired session: the browser deletes the cookie, and a copy kept elsewhere does not verify.

func WithHashSlot added in v0.22.1

func WithHashSlot(w http.ResponseWriter, r *http.Request, hash func()) bool

WithHashSlot runs hash while holding one of a bounded number of slots, so a flood of sign-ins cannot pin every core in 600,000 rounds of PBKDF2. It reports false - having already answered the request with 503 and a Retry-After - when the queue is too long, and when the client hung up before its turn came.

LoginHandler and RegisterHandler hold a slot already. This is exported for the handler you wrote yourself: an app whose sign-up form carries more than a username and a password cannot use RegisterHandler, and hashing outside a slot drops the protection silently.

var hash string
if !borgo.WithHashSlot(w, r, func() {
    hash, err = borgo.DefaultHasher().Hash(password)
}) {
    return
}

func WriteJSON

func WriteJSON(w http.ResponseWriter, status int, v any)

WriteJSON writes v as a JSON response with the given status code.

Types

type Auth added in v0.11.0

type Auth[U any] struct {
	// Lookup returns the user and its stored password hash for a username.
	// Any error is answered as invalid credentials, so a missing user is
	// indistinguishable from a wrong password.
	Lookup func(ctx context.Context, username string) (U, string, error)
	// Register creates a user from a username and an already-hashed password.
	// Optional: without it RegisterHandler answers 404. Return ErrUserExists
	// for a taken username.
	Register func(ctx context.Context, username, hash string) (U, error)
	// Principal maps the user to what the session stores. Optional: the
	// default stores the user itself. Keep it minimal - it rides in a cookie.
	Principal func(u U) any
	// MaxAge is the session lifetime, default 7 days.
	MaxAge time.Duration
	// Hasher verifies (and, on register, creates) password hashes.
	// Default: DefaultHasher().
	Hasher PasswordHasher
	// contains filtered or unexported fields
}

Auth wires an app-supplied user provider to ready-made login, logout and register handlers over the signed-cookie session. Mechanics, not policy: borgo imposes no database and no user schema - Lookup and Register are yours, the session stores whatever principal you choose.

func (*Auth[U]) LoginHandler added in v0.11.0

func (a *Auth[U]) LoginHandler(w http.ResponseWriter, r *http.Request)

LoginHandler verifies the posted {username, password} against Lookup and starts a session with the principal, responding with it as JSON. Under more parallel attempts than the box can hash it answers 503 with Retry-After.

func (*Auth[U]) LogoutHandler added in v0.11.0

func (a *Auth[U]) LogoutHandler(w http.ResponseWriter, r *http.Request)

LogoutHandler clears the session cookie.

func (*Auth[U]) RegisterHandler added in v0.11.0

func (a *Auth[U]) RegisterHandler(w http.ResponseWriter, r *http.Request)

RegisterHandler hashes the posted password, creates the user through Register and starts a session, responding 201 with the principal. A taken username is a 409, which tells the caller the name exists: pair it with a generic message in the ui if that matters to you.

type Credentials added in v0.11.0

type Credentials struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

Credentials is the JSON body the login and register handlers decode.

type PasswordHasher added in v0.11.0

type PasswordHasher interface {
	Hash(password string) (string, error)
	Verify(password, hash string) bool
}

PasswordHasher hashes and verifies passwords. The default is PBKDF2-SHA256 from the standard library (OWASP parameters), chosen so the runtime keeps zero dependencies; swap in argon2id via this interface if your threat model asks for it.

func DefaultHasher added in v0.11.0

func DefaultHasher() PasswordHasher

DefaultHasher returns the PBKDF2-SHA256 hasher used when Auth.Hasher is nil. Hashes embed their parameters ("pbkdf2$<iterations>$<salt>$<key>"), so stored passwords keep verifying if the defaults change.

It is a function and not a package variable on purpose: a variable of interface type can be reassigned by any code in the process, silently changing password hashing for every Auth that did not set its own Hasher. The value is stateless, so each call returns an equivalent hasher. To use a different algorithm, set Auth.Hasher on the Auth you own.

type SSEHub

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

SSEHub broadcasts events to every connected client. Register its ServeHTTP as a route handler and call Publish from anywhere:

var events = borgo.NewSSEHub()

//borgo:route GET /api/events
func Events(w http.ResponseWriter, r *http.Request) { events.ServeHTTP(w, r) }

func NewSSEHub

func NewSSEHub() *SSEHub

func (*SSEHub) Close added in v0.21.0

func (h *SSEHub) Close()

Close ends every open stream and makes the hub inert: later Publish calls are dropped and a request arriving afterwards gets an immediately-finished stream. Use it to retire a hub while the process keeps serving; a process-wide shutdown already ends every stream through Serve.

Safe from any goroutine and idempotent. Subscribers reports 0 as soon as it returns, though the handler goroutines take a moment to unwind.

func (*SSEHub) Publish

func (h *SSEHub) Publish(event string, data any)

Publish sends the event to every connected client. Clients too slow to keep up skip messages instead of blocking the publisher. A payload that will not encode is logged and dropped. Publishing to a closed hub does nothing.

func (*SSEHub) ServeHTTP

func (h *SSEHub) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP streams hub events to one client until it disconnects, the server shuts down, or the hub is closed.

func (*SSEHub) Subscribers added in v0.21.0

func (h *SSEHub) Subscribers() int

Subscribers is the number of streams currently connected to the hub - the server-sent-events counterpart of the WebSocket relay's built-in __count. Publish it on a timer for presence, or read it to decide whether producing an event is worth the work:

if hub.Subscribers() > 0 {
	hub.Publish("tick", expensive())
}

It is a sample: a client can connect or drop the instant after it returns. On a closed hub it reads 0 from the moment Close returns.

type SSEStream

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

SSEStream is one open server-sent-events response, from SSE. A zero value never opened: every write is refused with an error naming SSE, and Done reports it already finished rather than handing out a nil channel.

func SSE

SSE prepares the response for server-sent events and returns the stream. The front server proxies it to the browser without buffering.

func (*SSEStream) Close added in v0.21.0

func (s *SSEStream) Close()

Close ends the stream from the handler's side. Use it when nothing else can: a handler that detached the request context (context.WithoutCancel, r.Clone onto a background context) has a stream no disconnection and no shutdown will ever end.

Idempotent and safe from any goroutine. When it returns, Done is closed and every later Send and Ping fails with ErrStreamClosed; a write already in flight is neither interrupted nor waited for. Nothing is written to the client: the response ends when the handler returns, and an EventSource reconnects unless told otherwise.

func (*SSEStream) Done

func (s *SSEStream) Done() <-chan struct{}

Done closes when the client disconnects or the server starts shutting down. A stream handler must return once it fires. On a stream that never opened it is already closed.

func (*SSEStream) Ping

func (s *SSEStream) Ping() error

Ping writes a comment line so proxies don't close an idle stream.

func (*SSEStream) Send

func (s *SSEStream) Send(event string, data any) error

Send writes one named event with data encoded as JSON. The event name must not contain newlines.

Directories

Path Synopsis
cmd
borgogen command
Command borgogen statically analyzes an app's api/ package (go/ast + go/types, no runtime reflection) and generates two files:
Command borgogen statically analyzes an app's api/ package (go/ast + go/types, no runtime reflection) and generates two files:

Jump to

Keyboard shortcuts

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