Documentation
¶
Overview ¶
Package webhttp provides resilient server-side HTTP plumbing for building small services on top of net/http.
It bundles the pieces almost every server ends up hand-rolling:
- request-id injection plus one-line access logging (RequestLogger),
- a status recorder that stays transparent to http.ResponseController and also implements http.Flusher/http.Hijacker/io.ReaderFrom passthroughs, so both ResponseController-based and direct-type-assertion callers (plus the sendfile fast path) keep working (StatusRecorder),
- a composable middleware set: an ordering combinator (Chain) plus its batteries-included correct-order convenience (DefaultStack), a panic recoverer (Recoverer), baseline response security headers (SecurityHeaders), access logging as middleware (Logging), a JSON per-route timeout (RouteTimeout), and a shared token-bucket rate limiter (RateLimiter, with the SessionCreateRateLimit preset for heavy-child-spawning create endpoints), plus a spoof-aware client-IP resolver that reads X-Forwarded-For only from trusted proxy hops (ClientIP),
- an embedded-static file handler with construction-time content-hash ETags and precomputed gzip, with per-path cache policy left to the app (StaticHandler, WithStaticCacheControl),
- a CSP inline-script hash extractor for pinning script-src to the exact embedded page bytes instead of 'unsafe-inline' (InlineScriptHashes; the policy string itself stays app-owned, passed via WithCSP),
- JSON response and error helpers (WriteJSON, WriteJSONStatus, Ok, WriteError),
- request-prelude helpers for body limiting, method gating, and JSON decoding (LimitBody, RequireMethod, DecodeBody),
- an HTTP readiness gate for load balancers (Ready, ReadinessHandler),
- a graceful server bootstrap (NewServer, Run).
The middleware share the standard func(http.Handler) http.Handler shape (the Middleware alias) and compose with Chain, whose first-listed entry is the outermost wrapper. A typical stack is Chain(mux, Logging(), Recoverer(), SecurityHeaders()): logging outermost so a recovered panic is logged as its 500 rather than a misleading 200.
webhttp is the inbound-server counterpart to httpx (github.com/cplieger/httpx), which is the outbound-client toolkit: httpx makes resilient requests going OUT, webhttp handles the requests coming IN. The two are complementary and share no code.
The package has zero dependencies beyond the standard library. It ships the mechanism only; each consuming application layers its own route table, error taxonomy, and named helpers on top.
The sse subpackage (github.com/cplieger/webhttp/sse) adds a broadcast hub for Server-Sent Events: replay ring with Last-Event-ID resume, topic filtering, keepalives, client caps, and a shutdown drain gate. It is the streaming counterpart to the request/response helpers here (RouteTimeout deliberately cannot wrap a stream; the sse handler owns its own deadlines).
Index ¶
- Constants
- Variables
- func Chain(h http.Handler, mw ...Middleware) http.Handler
- func ClientIP(r *http.Request, trusted ...*net.IPNet) string
- func DecodeBody(w http.ResponseWriter, r *http.Request, v any, errMsg string) bool
- func DecodeBodyOptional(w http.ResponseWriter, r *http.Request, v any)
- func DecodeJSONInto(w http.ResponseWriter, r *http.Request, v any, maxBytes int64) error
- func DefaultStack(h http.Handler, opts ...StackOption) http.Handler
- func InlineScriptHashes(html []byte) []string
- func JSONHeaders(w http.ResponseWriter)
- func LimitBody(w http.ResponseWriter, r *http.Request, maxBytes int64)
- func NewRequestID() string
- func NewServer(handler http.Handler, opts ...ServerOption) *http.Server
- func Ok(w http.ResponseWriter)
- func ParseCIDRs(entries []string) (nets []*net.IPNet, invalid []string)
- func ReadinessHandler(c ReadinessChecker) http.HandlerFunc
- func RequestIDFromContext(ctx context.Context) string
- func RequestLogger(next http.Handler, opts ...LogOption) http.Handler
- func RequireMethod(w http.ResponseWriter, r *http.Request, method string) bool
- func RouteTimeout(h http.Handler, d time.Duration, msg string) http.Handler
- func Run(ctx context.Context, srv *http.Server, ln net.Listener, ...) error
- func StaticHandler(fsys fs.FS, opts ...StaticOption) (http.Handler, error)
- func ValidRequestID(s string) bool
- func WithRequestID(ctx context.Context, id string) context.Context
- func WriteError(w http.ResponseWriter, r *http.Request, status int, code, msg string)
- func WriteJSON(w http.ResponseWriter, v any)
- func WriteJSONStatus(w http.ResponseWriter, code int, v any)
- type ErrorResponder
- type ErrorResponse
- type LogOption
- func WithClientIP(trusted ...*net.IPNet) LogOption
- func WithClientIPFunc(fn func(*http.Request) string) LogOption
- func WithLogger(l *slog.Logger) LogOption
- func WithRecordMetric(fn func(method, path string, status int, d time.Duration)) LogOption
- func WithSkipFunc(fn func(*http.Request) bool) LogOption
- func WithSkipPaths(paths ...string) LogOption
- type Middleware
- type RateLimitOption
- type ReadinessChecker
- type Ready
- type RecoverOption
- type RunOption
- type SecurityOption
- func WithCOOP(v string) SecurityOption
- func WithCSP(policy string) SecurityOption
- func WithFrameOptions(v string) SecurityOption
- func WithHSTS(maxAge time.Duration, includeSubdomains, preload bool) SecurityOption
- func WithPermissionsPolicy(v string) SecurityOption
- func WithReferrerPolicy(v string) SecurityOption
- type ServerOption
- func WithErrorLog(l *log.Logger) ServerOption
- func WithIdleTimeout(d time.Duration) ServerOption
- func WithMaxHeaderBytes(n int) ServerOption
- func WithReadHeaderTimeout(d time.Duration) ServerOption
- func WithReadTimeout(d time.Duration) ServerOption
- func WithWriteTimeout(d time.Duration) ServerOption
- type StackOption
- type StaticOption
- type StatusRecorder
- func (s *StatusRecorder) Flush()
- func (s *StatusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
- func (s *StatusRecorder) ReadFrom(src io.Reader) (int64, error)
- func (s *StatusRecorder) Status() int
- func (s *StatusRecorder) Unwrap() http.ResponseWriter
- func (s *StatusRecorder) Write(b []byte) (int, error)
- func (s *StatusRecorder) WriteHeader(code int)
- func (s *StatusRecorder) Wrote() bool
Examples ¶
Constants ¶
const HeaderRequestID = "X-Request-ID"
HeaderRequestID is the canonical request-id header. RequestLogger reads it from the inbound request and echoes it on the response.
const MaxJSONBody int64 = 1 << 20
MaxJSONBody is the default maximum JSON request-body size (1 MiB) applied by DecodeBody and DecodeBodyOptional.
Variables ¶
var ErrTrailingData = errors.New("webhttp: unexpected data after JSON value")
ErrTrailingData is returned by DecodeJSONInto when the body holds more than a single JSON value (data follows the first decoded value). Callers that map decode errors to a status treat it like any other malformed-body error (400).
Functions ¶
func Chain ¶
func Chain(h http.Handler, mw ...Middleware) http.Handler
Chain wraps h with the given middlewares and returns the composed handler. The FIRST middleware listed becomes the OUTERMOST wrapper: it is the first to see the request on the way in and the last to touch the response on the way out. The LAST middleware listed sits closest to h.
So Chain(h, A, B, C) is equivalent to A(B(C(h))): a request flows A -> B -> C -> h and the response unwinds h -> C -> B -> A. List middlewares in the order you want them to execute; a typical stack puts logging outermost so it observes the final status, then a panic recoverer, then app concerns:
handler := webhttp.Chain(mux, webhttp.Logging(), // outermost: logs every request webhttp.Recoverer(), // catches panics from everything below it webhttp.SecurityHeaders(), // sets headers before the app runs )
A nil middleware in the list is skipped, so callers can include entries conditionally.
func ClientIP ¶
ClientIP returns the best-effort client IP for r.
The spoofing model is the point of this helper. X-Forwarded-For is set by clients and intermediaries and is trivially forgeable, so it is consulted ONLY when the immediate TCP peer (the host part of r.RemoteAddr) is a proxy you control, i.e. it falls inside one of the caller-supplied trusted ranges:
- With NO trusted ranges, or when the direct peer is not inside one, the forwarded header is ignored entirely and the peer address (which cannot be spoofed at this layer) is returned. This is the safe default: no header a client sends can move the result off the real socket peer.
- When the peer IS a trusted proxy, X-Forwarded-For is walked from the RIGHT. Each entry that is itself a trusted proxy is skipped (those are your own hops, which appended the address they saw); the first untrusted entry from the right is returned as the client. A malformed entry at that boundary stops the walk, and the peer is returned.
The right-to-left, skip-trusted walk is the only correct reading when the proxy APPENDS the peer it observed to X-Forwarded-For (as Caddy and most reverse proxies do): the LEFTMOST entry is then whatever the client SENT and is attacker-controlled, while the rightmost entries are the trustworthy hops. Consequently the trusted set must contain EVERY proxy hop between the client and this server; if a hop is missing from the set the walk stops there and that hop's address is returned as the client.
X-Real-IP is deliberately NOT consulted: it is client-settable and a proxy such as Caddy does not overwrite it, so honoring it would reintroduce a spoof vector. It can return as an explicit opt-in if a proxy that overwrites it is ever adopted.
The caller supplies the trusted CIDRs (typically the reverse proxy's address range); the library hardcodes none. A malformed r.RemoteAddr with no port is used verbatim as the host and, being unparseable, is never trusted.
func DecodeBody ¶
DecodeBody limits the body to MaxJSONBody and decodes exactly one JSON value into v. The decoded value must be the entire body: trailing data or a second JSON value is rejected. On any decode failure it writes a 400 error response (code "bad_request") carrying errMsg and returns false; on success it returns true. It is DecodeJSONInto at the default cap with a coded-400 response — see DecodeJSONInto for the mechanism and the app-taxonomy escape hatch.
func DecodeBodyOptional ¶
func DecodeBodyOptional(w http.ResponseWriter, r *http.Request, v any)
DecodeBodyOptional limits the body to MaxJSONBody and attempts a JSON decode into v, ignoring any error. Use it when the body is optional and a missing or malformed body should leave v at its zero value rather than fail the request.
func DecodeJSONInto ¶ added in v1.2.0
DecodeJSONInto caps the request body at maxBytes (via LimitBody) and decodes exactly one JSON value into v, rejecting trailing data (a second value or any bytes past the first are an error). It returns the decode error and writes NOTHING to w — so callers that layer their own error taxonomy or need a non-default cap can map the result themselves. The returned error is:
- a *http.MaxBytesError (test with errors.As) when the body exceeded maxBytes — the caller decides 413 vs 400;
- ErrTrailingData when a second JSON value follows the first;
- otherwise the underlying *json.SyntaxError / *json.UnmarshalTypeError / io.EOF (empty body) for a malformed body — typically a 400.
It is the shared mechanism behind DecodeBody: DecodeBody is DecodeJSONInto at the MaxJSONBody cap plus a coded-400 WriteError on any error. Apps with a bare {"error":…} envelope, a per-endpoint size cap, or a 413/400 split (see the webhttp consumers) build their own one-liner on it instead of reproducing the cap + decode + trailing-check by hand.
func DefaultStack ¶ added in v1.1.0
func DefaultStack(h http.Handler, opts ...StackOption) http.Handler
DefaultStack wraps h with the observability-safe middleware stack every server should have, composed in the one correct order so the common path is correct-by-construction. It is exactly:
Chain(h, Logging(loggingOpts...), // outermost Recoverer(recoverOpts...), // inside Logging SecurityHeaders(securityOpts...), // innermost )
The order is load-bearing. Logging sits OUTSIDE Recoverer, so a recovered panic is written as its 500 before RequestLogger's deferred access line runs and the request logs as 500; the reversed order would log a misleading 200 (see Recoverer). DefaultStack exists so callers do not have to remember that ordering.
It is a convenience over the primitives, not a replacement for them. Chain, Logging, Recoverer, and SecurityHeaders remain the composable API for any stack that needs a different shape — extra middleware, a different order, a subset, or a per-route timeout (RouteTimeout). Configure the composed layers with WithLoggingOptions, WithRecovererOptions, and WithSecurityHeadersOptions; with no options each layer uses its own defaults (slog.Default() logging, nosniff + X-Frame-Options: DENY + Referrer-Policy baseline headers, no CSP).
func InlineScriptHashes ¶ added in v1.8.0
InlineScriptHashes scans HTML for inline <script> elements (those WITHOUT a src attribute) and returns a CSP source token 'sha256-<base64>' for each, hashing the exact bytes between the element's '>' and its '</script>' — precisely the content a browser hashes for a Content-Security-Policy script-src hash. External (src=) scripts are skipped; 'self' already covers them.
It exists so an app serving a build-controlled embedded page (an importmap plus a module bootstrap are the classic pair) can pin its script-src to exact hashes instead of 'unsafe-inline', computing the tokens at startup from the very bytes it will serve — a policy that then survives any reformat or rebuild with no hand-maintained hash constant. Feed the result into the app's policy string and pass that via WithCSP; the library builds no policy itself (a CSP is application-specific).
The scanner is byte-precise and dependency-free: case-insensitive tag matching, quote-aware attribute scanning (a '>' or "src=" inside a quoted attribute value does not confuse it), and "src" matched only as a real attribute name (srcset and data-src do not count). It is an extractor for pages the APP controls, not an HTML sanitizer for untrusted input. It returns an empty slice on script-less or malformed input; a caller whose page is known to carry inline scripts should treat an empty result as a malformed build and fail startup rather than degrade its policy.
func JSONHeaders ¶
func JSONHeaders(w http.ResponseWriter)
JSONHeaders sets the standard JSON response headers: an application/json content type and the X-Content-Type-Options: nosniff guard against MIME sniffing. Call it before WriteHeader.
func LimitBody ¶
func LimitBody(w http.ResponseWriter, r *http.Request, maxBytes int64)
LimitBody caps the request body at maxBytes by replacing r.Body with an http.MaxBytesReader. A read past the limit then fails with a *http.MaxBytesError (and net/http is asked to close the connection); it does not by itself write a 413, so the caller chooses the response status. DecodeBody, for example, surfaces the resulting decode error as a 400. Call it before reading the body.
func NewRequestID ¶
func NewRequestID() string
NewRequestID returns a fresh request id: 16 cryptographically random bytes, hex-encoded to 32 characters. If the system random source fails, it falls back to fallbackRequestID — a UTC timestamp joined to a process-local base36 counter — which contains no dot, stays within the ValidRequestID character set, and is unique per request so a sustained entropy failure does not produce colliding ids that break log/response correlation.
func NewServer ¶
func NewServer(handler http.Handler, opts ...ServerOption) *http.Server
NewServer builds an *http.Server for handler with streaming-safe defaults: ReadHeaderTimeout 10s (a slowloris guard), IdleTimeout 120s, MaxHeaderBytes 1 MiB, and ReadTimeout/WriteTimeout left unset (0) so SSE, WebSocket, and other long-lived responses work out of the box. Options override the defaults.
Because ReadTimeout and WriteTimeout are unset by default, only header reading is time-bounded (by ReadHeaderTimeout); a slow request BODY is not. A non-streaming handler should add WithReadTimeout to bound slowloris-style slow bodies. A streaming handler, which cannot use a whole-request timeout, should instead apply per-request deadlines via http.ResponseController.SetReadDeadline/SetWriteDeadline. Note that MaxBytesReader (see LimitBody) bounds body SIZE, not the time taken to send it.
func Ok ¶
func Ok(w http.ResponseWriter)
Ok writes a 200 response with the JSON body {"ok":true}. It is the canonical success acknowledgement for an endpoint that has no other payload.
Example ¶
package main
import (
"fmt"
"net/http/httptest"
"github.com/cplieger/webhttp"
)
func main() {
rr := httptest.NewRecorder()
webhttp.Ok(rr)
fmt.Print(rr.Body.String())
}
Output: {"ok":true}
func ParseCIDRs ¶ added in v1.2.0
ParseCIDRs parses trusted reverse-proxy entries into the *net.IPNet set that ClientIP and WithClientIP consult. Each entry is either CIDR notation ("10.0.0.0/8") or a bare IP address ("192.168.1.5", "::1"), a bare address being treated as a single host (/32 for IPv4, /128 for IPv6). Surrounding whitespace is trimmed and blank entries are skipped.
It returns the successfully parsed networks and, separately, the list of entries that were neither a valid CIDR nor a valid IP. Returning both (rather than failing on the first bad entry) lets a strict caller reject the input — e.g. config-file validation surfacing the offending entry — while a lenient caller logs the bad entries and proceeds with the valid subset, e.g. reading a TRUSTED_PROXIES environment variable where one typo should not disable proxy awareness entirely. Both usages share this one parser instead of each app reimplementing the CIDR/bare-IP handling. The returned slice is passed straight to ClientIP(r, nets...) or WithClientIP(nets...); an empty result means "trust nothing", the spoof-proof default that logs the socket peer.
func ReadinessHandler ¶
func ReadinessHandler(c ReadinessChecker) http.HandlerFunc
ReadinessHandler returns a handler that reports serving state as JSON: 200 with {"status":"ok"} when c reports ready, otherwise 503 with {"status":"unready","reason":"starting up or shutting down"}.
This is the HTTP SERVING-STATE gate (note the lowercase "ok"), meant for a load balancer asking "should this instance receive traffic right now?". It is deliberately distinct from the cplieger health library's container file-marker probe, which answers {"status":"OK","timestamp":…} for a Docker HEALTHCHECK asking "is the process alive?". The two are complementary and are not the same endpoint.
func RequestIDFromContext ¶
RequestIDFromContext returns the request id stored in ctx by WithRequestID, or "" if none is present.
func RequestLogger ¶
RequestLogger returns middleware that gives each request a request id, echoes it on the response HeaderRequestID header, threads it through the request context (see RequestIDFromContext), and emits one access-log line at Info after next returns:
logger.Info("http", "method", …, "path", …, "status", …,
"duration_ms", …, "request_id", …)
With WithClientIP the line additionally carries a "client_ip" attribute resolved by ClientIP (spoof-proof, honoring only the trusted proxy ranges passed to the option); WithClientIPFunc is the variant that resolves it with a caller-supplied function, for a dynamic (e.g. config-reloaded) trusted set.
It records the status via a StatusRecorder that stays transparent to http.ResponseController, so wrapped handlers can still Flush and Hijack. An inbound HeaderRequestID is reused when it satisfies ValidRequestID; otherwise a new id is minted with NewRequestID.
A request matched by WithSkipPaths or WithSkipFunc still gets an id minted, echoed, and threaded, but is served through the raw writer with no recorder, no access-log line, and no metric hook.
The access-log line and metric hook are emitted from a deferred call, so a handler that panics is still logged (the status shows the recorded value) before the panic continues up the stack to net/http.
func RequireMethod ¶
RequireMethod reports whether the request method equals method. On a mismatch it sets the RFC 9110 Allow header to method, writes a 405 error response (code "method_not_allowed"), and returns false, so a handler can guard with:
if !webhttp.RequireMethod(w, r, http.MethodPost) {
return
}
func RouteTimeout ¶
RouteTimeout wraps h with http.TimeoutHandler so a handler that runs longer than d is cut off with a 503, but replaces net/http's plain-text/HTML timeout body with a JSON ErrorResponse ({"error":msg,"code":"timeout"}) served as application/json. An empty msg defaults to "request timed out".
A non-positive d disables the timeout: h is returned unwrapped, so its response passes through untouched with no 503 relabeling. (http.TimeoutHandler with a zero or negative duration would otherwise fire the timeout immediately.)
The JSON relabeling keys on status alone: any 503 that reaches the client WITHOUT a Content-Type already set is served as application/json, because the wrapper cannot tell http.TimeoutHandler's own timeout envelope apart from a downstream handler's intentional 503. A handler that emits its own 503 must therefore set an explicit Content-Type, or it will be relabeled application/json (its body bytes are left unchanged; only the headers are added).
It CANNOT wrap streaming or hijacking handlers: http.TimeoutHandler buffers the entire response in memory to be able to discard it on timeout, so SSE, WebSocket upgrades, and other long-lived or flushing responses do not work through it. Apply RouteTimeout only to bounded request/response handlers, and use per-request deadlines (http.ResponseController.SetWriteDeadline) for streaming routes instead.
Because the timeout body is produced inside http.TimeoutHandler, outside request scope, it carries no request_id (unlike WriteError). The 503 envelope is otherwise identical to the package's other JSON errors.
func Run ¶
func Run(ctx context.Context, srv *http.Server, ln net.Listener, onShutdown func(ctx context.Context), opts ...RunOption) error
Run serves srv on ln until ctx is cancelled, then shuts down gracefully.
It starts srv.Serve(ln) in a goroutine (treating http.ErrServerClosed as a clean stop) and blocks until either ctx is cancelled or Serve returns on its own. On cancellation it computes a single shutdown deadline (now + the shutdown grace period) and runs the shutdown sequence against it: first the WithPreDrain hook if one is registered (readiness flips, stream releases — see WithPreDrain), then srv.Shutdown with a context bounded by the deadline, then, if onShutdown is non-nil, onShutdown with a context bounded by that SAME deadline: each later phase runs within whatever grace budget REMAINS after the earlier ones, not a fresh full window. Run returns the first non-ErrServerClosed error it observes (a serve error, else a shutdown error), or nil on a clean graceful stop.
The caller binds ln up front (for example with net.ListenConfig.Listen) so a port-in-use error surfaces synchronously before Run is called, and passes application teardown as onShutdown.
func StaticHandler ¶ added in v1.8.0
StaticHandler serves an embedded (or any fs.FS) static tree with the revalidation and compression plumbing embed.FS is missing.
embed.FS reports a zero ModTime, so a bare http.FileServer emits neither Last-Modified nor ETag, leaving the browser no way to revalidate: every full page load re-downloads every asset. The bytes of an embedded tree are fixed for the process lifetime, so StaticHandler walks the tree ONCE at construction and precomputes, per file:
- a content-hash ETag (sha256), so http.ServeContent answers a matching If-None-Match with 304 Not Modified;
- a gzip representation (best compression), kept only when it is actually smaller — already-compressed formats (woff2, png) and tiny files stay identity-only.
Responses for known assets carry the ETag, a Cache-Control value from the cache policy (default "no-cache"; see WithStaticCacheControl), and Vary: Accept-Encoding (bodies vary by encoding, so shared caches must key on it on every path). A client that offers gzip on a non-Range GET/HEAD gets the precompressed body with Content-Encoding: gzip and a DISTINCT ETag (the identity tag suffixed -gz), including its own 304 handling; everything else falls through to the identity http.FileServer, which serves the index.html for "/" and handles Range, directories, and 404s.
The error is non-nil only when walking fsys fails (an unreadable embedded tree is a malformed build; fail startup rather than serve a partial site). Serving is allocation-free per request apart from net/http's own work: all hashing and compression happened at construction.
func ValidRequestID ¶
ValidRequestID reports whether s is a well-formed request id: between 1 and 64 characters, each an ASCII letter, digit, underscore, or hyphen. Anything else (empty, too long, or containing another byte) is rejected, so a client cannot smuggle log-forging newlines or header-splitting content through the echoed id.
Example ¶
package main
import (
"fmt"
"github.com/cplieger/webhttp"
)
func main() {
fmt.Println(webhttp.ValidRequestID("abc-123_XYZ"))
fmt.Println(webhttp.ValidRequestID("bad id"))
}
Output: true false
func WithRequestID ¶
WithRequestID returns a copy of ctx carrying the request id.
func WriteError ¶
WriteError writes an ErrorResponse with the given HTTP status: msg becomes Error, code becomes Code, and the request id is pulled from the request context (via RequestIDFromContext) so a client can correlate the failure with the access log. It is nil-safe: when r is nil the RequestID field stays empty.
WriteError ships the MECHANISM only. Each consuming application keeps its own named-helper and error-code taxonomy on top of it.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/cplieger/webhttp"
)
func main() {
rr := httptest.NewRecorder()
// r may be nil; the request_id field is then omitted.
webhttp.WriteError(rr, nil, http.StatusBadRequest, "bad_request", "invalid payload")
fmt.Print(rr.Body.String())
}
Output: {"error":"invalid payload","code":"bad_request"}
func WriteJSON ¶
func WriteJSON(w http.ResponseWriter, v any)
WriteJSON writes v as a JSON body with a 200 status.
func WriteJSONStatus ¶
func WriteJSONStatus(w http.ResponseWriter, code int, v any)
WriteJSONStatus sets the JSON headers, writes the status code, and encodes v as the response body. The status is committed before encoding begins, so an encode failure cannot change it; such a failure is logged at Warn rather than returned, because the response line is already on the wire.
Types ¶
type ErrorResponder ¶ added in v1.4.0
ErrorResponder renders an error response body. It has the exact signature of WriteError, which is its canonical instance and the library-wide default, so the JSON envelope is the zero-config behavior. Middleware that emits an error body (currently Recoverer, via WithRecoverResponder) accepts one so a non-JSON endpoint - an XML or plain-text service - can keep its error body on its own content type instead of the default JSON. A responder owns writing the status and any headers, and is invoked only when the response has not been committed.
type ErrorResponse ¶
type ErrorResponse struct {
// Error is the human-readable error message.
Error string `json:"error"`
// Code is an optional machine-readable error code.
Code string `json:"code,omitempty"`
// RequestID is the request id for log correlation, when available.
RequestID string `json:"request_id,omitempty"`
}
ErrorResponse is the JSON error envelope written by WriteError. Code and RequestID are omitted from the output when empty.
type LogOption ¶
type LogOption func(*logConfig)
LogOption configures RequestLogger.
func WithClientIP ¶ added in v1.2.0
WithClientIP adds a "client_ip" attribute to the access-log line, set to the best-effort client IP resolved by ClientIP with the given trusted proxy ranges. With no trusted ranges the immediate socket peer is logged (the spoof-proof default); pass the reverse-proxy CIDRs to resolve the real client from a trusted X-Forwarded-For, exactly as ClientIP does. The attribute is omitted entirely unless this option is supplied, so the default access line is unchanged. It is resolved once per request, inside the deferred access log, so it costs nothing on skipped (streaming) paths.
func WithClientIPFunc ¶ added in v1.2.0
WithClientIPFunc is like WithClientIP but resolves the "client_ip" attribute with a caller-supplied function instead of a fixed trusted-proxy set. Use it when the trusted set is not known at construction — e.g. it is reloaded from config at runtime behind a hot-reloadable resolver — or when client-IP resolution is otherwise app-specific: fn is called once per logged request (never on a skipped path), and its result is logged verbatim as "client_ip". It composes with WithRecordMetric. WithClientIP and WithClientIPFunc both enable the attribute and are mutually exclusive; whichever is applied last wins. A nil fn is ignored (matching the package's skip-nil option convention), so a trailing WithClientIPFunc(nil) neither enables the attribute nor clears a prior WithClientIP.
func WithLogger ¶
WithLogger sets the slog.Logger used for access-log lines. Defaults to slog.Default().
func WithRecordMetric ¶
WithRecordMetric registers a hook invoked once per logged request with the final method, path, status, and latency. It fires from a deferred call, so a panicking handler is still recorded. Requests skipped via WithSkipPaths or WithSkipFunc are excluded from the hook as well as from access logging: a stream's open-to-close duration paired with a synthetic status is misleading, which is the whole reason the path is skipped.
func WithSkipFunc ¶
WithSkipFunc registers a predicate; when it returns true for a request, that request is passed through WITHOUT an access-log line or metric (like a WithSkipPaths match), while the request id is still minted, echoed, and threaded. Use it for streaming routes with path parameters (e.g. "/ws/{id}") that an exact WithSkipPaths match cannot cover.
func WithSkipPaths ¶
WithSkipPaths marks exact request paths (compared against r.URL.Path) that should pass through WITHOUT an access-log line AND without a metric hook. Use it for long-lived streams (SSE, WebSocket) whose single open-forever request would otherwise emit one misleading high-latency line and a synthetic status at close. The request id is still minted, echoed, and threaded into the context for skipped paths. Because the match is exact, streaming routes with path parameters (e.g. "/ws/{id}") need WithSkipFunc instead.
type Middleware ¶
Middleware is the standard net/http decorator shape: it wraps an http.Handler and returns a new one. It is a type alias, so any plain func(http.Handler) http.Handler value is a Middleware without conversion.
func Logging ¶
func Logging(opts ...LogOption) Middleware
Logging returns a Chain-composable Middleware wrapping RequestLogger with the given options. It is exactly RequestLogger in middleware form; use RequestLogger directly when you are not composing with Chain. See RequestLogger for the request-id and access-log behavior and the available LogOption values.
func RateLimiter ¶ added in v1.3.0
func RateLimiter(burst int, interval time.Duration, opts ...RateLimitOption) Middleware
RateLimiter returns middleware that throttles requests through a single shared token bucket (standard library only, no external dependency). burst is the maximum number of tokens and the initial fill; interval is the time to accrue one token (the refill cadence). Each admitted request consumes one token; a request that arrives with the bucket empty is answered with a 429 via WriteError(w, r, http.StatusTooManyRequests, "rate_limited", "rate limit exceeded") (code and message overridable with WithRateLimitError) and does not reach the next handler. The 429 carries a conservative Retry-After hint: the whole seconds to accrue one token, i.e. ceil(interval), clamped to at least 1s.
The bucket is process-wide for the middleware instance — shared across all requests and all clients — so it bounds the AGGREGATE rate of the wrapped route. That is the right tool for capping an expensive shared resource (for example, spawning a heavy child process per request), not for per-client fairness. Per-client limiting is intentionally out of scope; a caller behind a trusted proxy that needs it can key its own buckets on ClientIP.
A non-positive burst or a non-positive interval disables limiting: the middleware returns the next handler unwrapped (mirroring RouteTimeout's non-positive "off" contract), so a config-driven zero cleanly means "no limit".
Apply it to the specific handler you want to throttle, and pair it with WithRateLimitWhen to gate only the expensive method+path when the handler serves several:
sessions := webhttp.RateLimiter(6, time.Second,
webhttp.WithRateLimitWhen(func(r *http.Request) bool {
return r.Method == http.MethodPost
}),
)(sessionsHandler)
func Recoverer ¶
func Recoverer(opts ...RecoverOption) Middleware
Recoverer returns middleware that recovers a panic from a downstream handler, logs it at Error with the stack and the request id (via RequestIDFromContext), fires any WithPanicHook callback, and writes a 500 error via the configured ErrorResponder - WriteError by default, i.e. the JSON envelope {"error":"internal server error","code":"internal_error"}. Override the responder with WithRecoverResponder to render the 500 on another content type. Without it, a handler panic unwinds to net/http, which closes the connection abruptly with no response body.
The http.ErrAbortHandler sentinel is deliberately NOT recovered: per the net/http contract it is re-panicked so the server aborts the response the way the handler intended (it is not logged and fires no hook).
Placement relative to Logging matters. Put Recoverer INSIDE RequestLogger, i.e. Logging outermost, as in Chain(mux, Logging(), Recoverer()): the recovered request then records a 500 before RequestLogger's deferred access line runs, so the request logs as 500. If Recoverer sits OUTSIDE the logger, RequestLogger's deferred line runs during the panic unwind and records the StatusRecorder's default 200, a misleading access line even though the client still receives the 500.
The 500 is best-effort and never double-writes: if the handler already committed the response (wrote headers or body) before panicking, the status is on the wire and cannot be changed, so Recoverer skips the body entirely (it still logs the panic and fires the hook) rather than corrupting the partial response or mislabeling its status under an outer Logging. To detect commitment it observes the response through a StatusRecorder, reusing an existing one (such as RequestLogger's) when present.
func SecurityHeaders ¶
func SecurityHeaders(opts ...SecurityOption) Middleware
SecurityHeaders returns middleware that sets a baseline of response security headers before calling the next handler. Set by default are X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin. The X-Frame-Options and Referrer-Policy defaults are configurable (override with a value, or omit with an empty string); nosniff is set by default with no option to change it here. Content-Security-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, and Strict-Transport-Security are off unless their options are supplied.
All of these are set BEFORE next runs, so none is immutable: the middleware establishes the baseline but does not lock it. A handler that needs a different value for a specific response can still override (or delete) any of them, nosniff included, on the response header.
func SessionCreateRateLimit ¶ added in v1.8.0
func SessionCreateRateLimit(path string) Middleware
SessionCreateRateLimit returns middleware gating POST requests to path (an exact match, e.g. "/api/sessions") behind the standard session-create token bucket: burst 6, one token accrued per second, and a 429 envelope of code "rate_limited" / message "session creation rate exceeded". Non-POST methods and other paths pass through without consuming a token, so a handler that multiplexes list (GET) and close (DELETE) on the same path stays unthrottled.
This is a preset over RateLimiter for the create-a-heavy-child endpoint shape, where each admitted request forks an expensive process (a PTY, an agent subprocess) and what needs bounding is aggregate create churn. The tuning lives here once; an app needing different numbers or a different predicate composes RateLimiter directly.
type RateLimitOption ¶ added in v1.3.0
type RateLimitOption func(*rateLimitConfig)
RateLimitOption configures RateLimiter.
func WithRateLimitError ¶ added in v1.3.0
func WithRateLimitError(code, msg string) RateLimitOption
WithRateLimitError sets the error code and message written in the 429 JSON envelope (via WriteError). Defaults to "rate_limited" / "rate limit exceeded".
func WithRateLimitWhen ¶ added in v1.3.0
func WithRateLimitWhen(pred func(*http.Request) bool) RateLimitOption
WithRateLimitWhen restricts limiting to requests for which pred returns true; every other request passes through without consuming a token. Use it to gate a single method+path on a handler that multiplexes several — for example, throttle only POST and leave GET/DELETE on the same path unthrottled. A nil predicate is ignored, so the default (limit every request) stands.
type ReadinessChecker ¶
type ReadinessChecker interface {
// Ready reports whether the service should receive traffic right now.
Ready() bool
}
ReadinessChecker is the readiness view ReadinessHandler needs. *Ready satisfies it; a service with a composite readiness model can supply its own implementation.
type Ready ¶
type Ready struct {
// contains filtered or unexported fields
}
Ready is a concurrency-safe readiness flag. Its zero value reports not ready, so a service starts unready until it calls Set(true) once initialization completes, and can flip back to unready during shutdown.
type RecoverOption ¶
type RecoverOption func(*recoverConfig)
RecoverOption configures Recoverer.
func WithPanicHook ¶
func WithPanicHook(fn func(v any, stack []byte)) RecoverOption
WithPanicHook registers a callback invoked with the recovered value and the captured stack when a panic is caught. Use it to increment a metric or notify an error tracker. It runs after the panic is logged and before the 500 response is written; a nil hook is ignored.
func WithRecoverLogger ¶
func WithRecoverLogger(l *slog.Logger) RecoverOption
WithRecoverLogger sets the slog.Logger used to report a recovered panic. Defaults to slog.Default().
func WithRecoverResponder ¶ added in v1.4.0
func WithRecoverResponder(fn ErrorResponder) RecoverOption
WithRecoverResponder sets the ErrorResponder that writes the 500 body after a recovered panic (only when the response is not already committed). It defaults to WriteError - the JSON envelope; supply one to render the 500 on a different content type, for example an XML endpoint returning its own error document. The responder owns writing the status and headers. A nil responder is ignored, keeping the default.
type RunOption ¶
type RunOption func(*runConfig)
RunOption configures Run.
func WithPreDrain ¶ added in v1.9.0
WithPreDrain registers a hook Run invokes after ctx is cancelled and strictly before srv.Shutdown starts draining in-flight requests. It is the place for the release-the-streams phase a graceful stop needs ahead of the drain: flip a readiness gate so a load balancer stops routing here, cancel the server's BaseContext or shut down an SSE hub so long-lived connections end (otherwise Shutdown waits its full grace period for them). fn receives a context bounded by the SAME shutdown deadline that Shutdown and onShutdown share; whatever budget it spends is no longer available to them. A nil fn is ignored.
func WithShutdownGrace ¶
WithShutdownGrace sets how long Run allows for graceful shutdown: the window for the pre-drain hook to run, for in-flight requests to finish, and for the onShutdown teardown to run. Defaults to 5s.
type SecurityOption ¶
type SecurityOption func(*securityConfig)
SecurityOption configures SecurityHeaders.
func WithCOOP ¶
func WithCOOP(v string) SecurityOption
WithCOOP sets the Cross-Origin-Opener-Policy header (e.g. "same-origin"). Unset by default.
func WithCSP ¶
func WithCSP(policy string) SecurityOption
WithCSP sets the Content-Security-Policy header. The library never builds a policy for you: a CSP is application-specific (it must match the app's script and style sources, nonces, or hashes), so pass the exact policy string the app needs. Unset by default.
func WithFrameOptions ¶
func WithFrameOptions(v string) SecurityOption
WithFrameOptions overrides the X-Frame-Options header (default "DENY"). Pass an empty string to omit the header, for example when a Content-Security-Policy frame-ancestors directive supersedes it.
func WithHSTS ¶
func WithHSTS(maxAge time.Duration, includeSubdomains, preload bool) SecurityOption
WithHSTS enables the Strict-Transport-Security header with the given max-age and the includeSubDomains and preload directives. HSTS is OFF by default: enable it only for a service reached exclusively over HTTPS, because a browser that sees the header will refuse plain-HTTP and untrusted-certificate connections to the host for the whole max-age window. A negative max-age is clamped to zero (which instructs browsers to forget the policy).
func WithPermissionsPolicy ¶
func WithPermissionsPolicy(v string) SecurityOption
WithPermissionsPolicy sets the Permissions-Policy header (browser feature gating, e.g. "geolocation=(), camera=()"). Unset by default.
func WithReferrerPolicy ¶
func WithReferrerPolicy(v string) SecurityOption
WithReferrerPolicy overrides the Referrer-Policy header (default "strict-origin-when-cross-origin"). Pass an empty string to omit it.
type ServerOption ¶
ServerOption configures the *http.Server built by NewServer.
func WithErrorLog ¶
func WithErrorLog(l *log.Logger) ServerOption
WithErrorLog sets http.Server.ErrorLog so connection-level errors go to the caller's logger instead of the standard logger. Wire it to slog with slog.NewLogLogger(handler, slog.LevelError).
func WithIdleTimeout ¶
func WithIdleTimeout(d time.Duration) ServerOption
WithIdleTimeout sets http.Server.IdleTimeout, the keep-alive idle deadline.
func WithMaxHeaderBytes ¶
func WithMaxHeaderBytes(n int) ServerOption
WithMaxHeaderBytes sets http.Server.MaxHeaderBytes.
func WithReadHeaderTimeout ¶
func WithReadHeaderTimeout(d time.Duration) ServerOption
WithReadHeaderTimeout sets http.Server.ReadHeaderTimeout, the slowloris guard bounding how long a client may take to send request headers.
func WithReadTimeout ¶
func WithReadTimeout(d time.Duration) ServerOption
WithReadTimeout sets http.Server.ReadTimeout, the deadline for reading the entire request. Leave it unset for streaming request bodies.
func WithWriteTimeout ¶
func WithWriteTimeout(d time.Duration) ServerOption
WithWriteTimeout sets http.Server.WriteTimeout, the deadline for writing the entire response. It is unset by default: streaming apps (SSE, WebSocket, long responses) MUST omit it, because it would cut off an in-progress stream.
type StackOption ¶ added in v1.1.0
type StackOption func(*stackConfig)
StackOption configures DefaultStack by supplying options to one of its composed layers. The three routers (WithLoggingOptions, WithRecovererOptions, WithSecurityHeadersOptions) carry each layer's own option family through unchanged; a nil StackOption is skipped.
func WithLoggingOptions ¶ added in v1.1.0
func WithLoggingOptions(opts ...LogOption) StackOption
WithLoggingOptions forwards LogOption values to DefaultStack's Logging layer (see RequestLogger for the option list, e.g. WithLogger, WithSkipPaths, WithRecordMetric).
func WithRecovererOptions ¶ added in v1.1.0
func WithRecovererOptions(opts ...RecoverOption) StackOption
WithRecovererOptions forwards RecoverOption values to DefaultStack's Recoverer layer (e.g. WithRecoverLogger, WithPanicHook).
func WithSecurityHeadersOptions ¶ added in v1.1.0
func WithSecurityHeadersOptions(opts ...SecurityOption) StackOption
WithSecurityHeadersOptions forwards SecurityOption values to DefaultStack's SecurityHeaders layer (e.g. WithCSP, WithHSTS, WithFrameOptions).
type StaticOption ¶ added in v1.8.0
type StaticOption func(*staticConfig)
StaticOption configures StaticHandler.
func WithStaticCacheControl ¶ added in v1.8.0
func WithStaticCacheControl(fn func(assetPath string) string) StaticOption
WithStaticCacheControl sets the per-asset Cache-Control policy. fn receives the normalized asset path — no leading slash, and "index.html" for the root ("/") request — and returns the Cache-Control value for that asset; an empty return omits the header. The default policy returns "no-cache" for every asset: revalidate on every load (the content-hash ETag makes that a cheap 304), the right choice when asset paths are stable rather than content-addressed. Supply a policy to differ per path, for example immutable large fonts alongside no-cache app code. A nil fn is ignored, keeping the default.
The policy is MECHANISM-free: it chooses header values only. Whether an asset is ETagged, gzipped, or revalidated is the handler's fixed behavior.
type StatusRecorder ¶
type StatusRecorder struct {
http.ResponseWriter
// contains filtered or unexported fields
}
StatusRecorder wraps an http.ResponseWriter to capture the response status code. Middleware that needs to observe the final status (access logging, metrics) wraps the writer in a StatusRecorder.
It stays transparent to streaming in two complementary ways. The Unwrap method lets http.NewResponseController walk to the underlying writer, so ResponseController-based callers reach its Flusher, Hijacker, and deadline setters. It also implements http.Flusher, http.Hijacker, and io.ReaderFrom directly, so a handler or library that type-asserts those interfaces on the writer (as gorilla/websocket does with w.(http.Hijacker)) still works, and io.Copy/http.ServeContent keep the zero-copy sendfile fast path. Each passthrough returns the underlying writer's own result, so, for example, Hijack reports http.ErrNotSupported on an HTTP/2 stream.
func NewStatusRecorder ¶
func NewStatusRecorder(w http.ResponseWriter) *StatusRecorder
NewStatusRecorder wraps w. The recorded status defaults to http.StatusOK (200) and stays there until WriteHeader or the first Write records a status.
func (*StatusRecorder) Flush ¶
func (s *StatusRecorder) Flush()
Flush forwards to the underlying writer via http.ResponseController (which walks the Unwrap chain), so a handler that type-asserts http.Flusher directly still streams through the recorder. A no-op if the underlying writer cannot flush.
func (*StatusRecorder) Hijack ¶
func (s *StatusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error)
Hijack forwards to the underlying writer via http.ResponseController for libraries that hijack the connection through a direct http.Hijacker assertion. Returns an error (e.g. http.ErrNotSupported) when the underlying writer, such as an HTTP/2 stream, cannot be hijacked.
func (*StatusRecorder) ReadFrom ¶
func (s *StatusRecorder) ReadFrom(src io.Reader) (int64, error)
ReadFrom preserves the zero-copy (sendfile) fast path for io.Copy and http.ServeContent when the underlying writer implements io.ReaderFrom. Writing the body implies a 200 status when WriteHeader was not called.
func (*StatusRecorder) Status ¶
func (s *StatusRecorder) Status() int
Status returns the recorded status code, or http.StatusOK if none was set.
func (*StatusRecorder) Unwrap ¶
func (s *StatusRecorder) Unwrap() http.ResponseWriter
Unwrap returns the wrapped http.ResponseWriter. http.NewResponseController walks Unwrap to reach a Flusher, Hijacker, or other optional interface implemented by the underlying writer, so streaming still works through the recorder.
func (*StatusRecorder) Write ¶
func (s *StatusRecorder) Write(b []byte) (int, error)
Write records an implicit 200 status on the first write when WriteHeader was not called (mirroring net/http), then forwards to the wrapped writer.
func (*StatusRecorder) WriteHeader ¶
func (s *StatusRecorder) WriteHeader(code int)
WriteHeader records the first explicit status code and forwards every call to the wrapped writer. Only the first code is recorded, matching net/http's first-code-wins semantics; a later call still forwards (so net/http logs its standard "superfluous WriteHeader" warning) but does not change Status.
func (*StatusRecorder) Wrote ¶
func (s *StatusRecorder) Wrote() bool
Wrote reports whether the response has been committed, i.e. WriteHeader, the first Write or ReadFrom, or a successful Flush or Hijack has run. Middleware such as Recoverer consults it to avoid writing a second status and body onto a response a handler has already started, which would corrupt the body and mislabel the status.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package sse provides a broadcast hub for Server-Sent Events: fan-out to connected clients, a replay ring with monotonic event IDs so a reconnecting client resumes via the standard Last-Event-ID header, proxy-defensive response headers, keepalive comments, an optional concurrent-client cap, per-subscriber topic filtering, and a shutdown drain gate.
|
Package sse provides a broadcast hub for Server-Sent Events: fan-out to connected clients, a replay ring with monotonic event IDs so a reconnecting client resumes via the standard Last-Event-ID header, proxy-defensive response headers, keepalive comments, an optional concurrent-client cap, per-subscriber topic filtering, and a shutdown drain gate. |