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
- Variables
- func Authed(next http.HandlerFunc) http.HandlerFunc
- func Bind[T any](r *http.Request) (T, error)
- func BindError(w http.ResponseWriter, err error)
- func BindMax[T any](r *http.Request, limit int64) (T, error)
- func Cache(w http.ResponseWriter, maxAge time.Duration, ...)
- func CheckEnv() error
- func ClearSession(w http.ResponseWriter)
- func GetSession[T any](r *http.Request) (T, bool)
- func Handle(pattern string, h http.HandlerFunc)
- func JSON[T any](w http.ResponseWriter, status int, v T)
- func Middleware(h http.Handler) http.Handler
- func NoCache(w http.ResponseWriter)
- func Push[T any](topic, event string, data T) error
- func Revalidate(path string) error
- func RevalidateTag(tag string) error
- func Serve()
- func ServeContext(ctx context.Context) error
- func SetSession(w http.ResponseWriter, v any, maxAge time.Duration) error
- func WithHashSlot(w http.ResponseWriter, r *http.Request, hash func()) bool
- func WriteJSON(w http.ResponseWriter, status int, v any)
- type Auth
- type Credentials
- type PasswordHasher
- type SSEHub
- type SSEStream
Constants ¶
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 ¶
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.
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.
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 ¶
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
BindMax is Bind with an explicit body size limit in bytes; limit <= 0 disables the cap.
func Cache ¶
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 ¶
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
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 ¶
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
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
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
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 ¶
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
}
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
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 (*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 ¶
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
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.
Source Files
¶
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: |