Middleware
Middleware in pidgn are plain functions with the signature fn (*pidgn.Context) anyerror!void. They form a pipeline: each middleware can run code before and after the rest of the chain by calling ctx.next().
How the pipeline works
Section titled “How the pipeline works”Middleware execute in the order they are listed in the middleware array. Each one calls ctx.next() to pass control to the next middleware (or the final route handler). Code before ctx.next() runs on the way in; code after runs on the way out.
fn timing(ctx: *pidgn.Context) !void { const start = getTimestamp(); try ctx.next(); // run everything downstream const elapsed = getTimestamp() - start; std.log.info("took {d}us", .{elapsed});}If a middleware does not call ctx.next(), the pipeline stops and the response is sent immediately. This is how auth middleware rejects unauthenticated requests.
Execution order
Section titled “Execution order”Given this configuration:
const App = pidgn.Router.define(.{ .middleware = &.{ pidgn.errorHandler(.{}), // 1st: wraps everything in error handling pidgn.logger, // 2nd: logs request/response pidgn.cors(.{}), // 3rd: handles CORS headers pidgn.bodyParser, // 4th: parses request body }, .routes = &.{ pidgn.Router.get("/", index), },});A request flows through: errorHandler -> logger -> cors -> bodyParser -> route handler -> bodyParser (after) -> cors (after) -> logger (after) -> errorHandler (after).
Place error handling first so it catches errors from all downstream middleware. Place the logger early so it can measure total response time.
Built-in middleware
Section titled “Built-in middleware”pidgn ships with a broad set of middleware covering the most common web application needs:
| Middleware | Import | Description |
|---|---|---|
errorHandler(config) | pidgn.errorHandler | Catches errors from downstream handlers and returns a 500 response. Optionally shows error names in dev mode. |
logger | pidgn.logger | Logs method, path, status code, and response time to std.log. |
structuredLogger(config) | pidgn.structuredLogger | Structured logging with text or JSON format and configurable log levels. |
gzipCompress(config) | pidgn.gzipCompress | Compresses response bodies with gzip when the client accepts it and the body exceeds min_size. |
cors(config) | pidgn.cors | Adds CORS headers and handles preflight OPTIONS requests. |
bodyParser | pidgn.bodyParser | Parses request bodies (JSON, URL-encoded, multipart, text, binary) into ctx.parsed_body. |
staticFiles(config) | pidgn.staticFiles | Serves static files from a directory with MIME detection, caching, and ETag support. |
session(config) | pidgn.session | Cookie-based sessions with an in-memory store. Persists assigns across requests. |
csrf(config) | pidgn.csrf | CSRF protection. Generates tokens for safe methods, validates on unsafe methods. |
flash(config) | pidgn.flash | Session-backed one-shot messages that survive a single redirect. See Flash messages. |
bearerAuth(config) | pidgn.bearerAuth | Extracts Bearer tokens from the Authorization header into assigns. |
basicAuth(config) | pidgn.basicAuth | Extracts Basic auth credentials (username/password) into assigns. |
jwtAuth(config) | pidgn.jwtAuth | Verifies HMAC-SHA256 JWTs and stores the decoded payload in assigns. |
rateLimit(config) | pidgn.rateLimit | Token-bucket rate limiting per client (identified by header). |
throttle(config) | pidgn.throttle | Sliding-window throttle complementing rateLimit (smoother, not bursty). |
ipAllowlist(config) / ipBlocklist(config) | pidgn.ipAllowlist / pidgn.ipBlocklist | IP allow / block with IPv4 CIDR support. See IP access. |
csp(config) | pidgn.csp | Emits a Content-Security-Policy header. See Security headers. |
hsts(config) | pidgn.hsts | Emits a Strict-Transport-Security header. See Security headers. |
actionLog(config) | pidgn.actionLog | Per-request audit trail with a pluggable sink. |
localeMiddleware(config) | pidgn.localeMiddleware | Resolves the best locale for the request into ctx.assigns["locale"]. See Locale & i18n. |
geoIp(config) | pidgn.geoIp | Populates geo_country / geo_region / geo_city assigns from a pluggable provider. |
requestId(config) | pidgn.requestId | Propagates or generates a unique request ID, stored in assigns and the response header. |
health(config) | pidgn.health | Returns {"status":"ok"} at a configurable path (default /health). |
metrics(config) | pidgn.metrics | Collects request counts, status codes, latency and serves Prometheus metrics. |
telemetry(config) | pidgn.telemetry | Fires lifecycle events (request start/end) to a callback for custom observability. |
htmx(config) | pidgn.htmx | Sets htmx-related assigns (is_htmx, htmx_target) and provides a CDN script tag. |
pidgnJs(config) | pidgn.pidgnJs | Serves the embedded pidgn.js client library at a configurable path. |
swagger.ui(config) | pidgn.swagger.ui | Serves Swagger UI for your OpenAPI spec. |
Beyond the middleware listed above, pidgn also ships a set of request / response helpers you can call directly from a handler:
| Helper | Import | What it does |
|---|---|---|
SignedCookies(config) / EncryptedCookies(config) | pidgn.SignedCookies / pidgn.EncryptedCookies | Comptime-bound cookie helpers. HMAC-signed or AES-256-GCM encrypted. See Secure cookies. |
pagination.fromQuery(ctx, opts) | pidgn.pagination | Parses page / per_page from the query string with clamped bounds. See Pagination. |
sanitize.escapeHtml / stripTags / normalizeWhitespace | pidgn.sanitize | Input sanitization primitives. |
form.input / textarea / select / checkbox / csrfInput | pidgn.form | HTML form element helpers with auto-escaping. See Form builder. |
userAgent.parse / fromContext | pidgn.userAgent | Heuristic User-Agent parser (browser, OS, bot flag). |
range.respondWithRange(ctx, data, content_type) | pidgn.range | Honors HTTP Range headers (206 Partial Content). |
i18n.t(...) / tn(...) | pidgn.i18n | Translation lookup with placeholder interpolation and pluralization. See Locale & i18n. |
Storage / LocalStorage | pidgn.Storage / pidgn.LocalStorage | File storage trait with a local-disk backend. See File storage. |
Using built-in middleware
Section titled “Using built-in middleware”Most middleware accept a comptime config struct with sensible defaults. Pass .{} for defaults or override specific fields:
const App = pidgn.Router.define(.{ .middleware = &.{ pidgn.errorHandler(.{ .show_details = true }), pidgn.logger, pidgn.gzipCompress(.{ .min_size = 256 }), pidgn.requestId(.{}), pidgn.cors(.{ .allow_origins = &.{"https://myapp.com"} }), pidgn.bodyParser, pidgn.session(.{ .cookie_name = "my_app_session", .max_age = 86400 }), pidgn.csrf(.{}), pidgn.staticFiles(.{ .dir = "public", .prefix = "/static" }), }, .routes = &.{ ... },});Note that bodyParser and logger are bare function references (no config struct), while most others use the middleware(config) pattern.
Scoped middleware
Section titled “Scoped middleware”Apply middleware to a subset of routes using Router.scope:
.routes = pidgn.Router.scope("/api", &.{ pidgn.bearerAuth(.{ .required = true }), pidgn.rateLimit(.{ .max_requests = 100, .window_seconds = 60 }),}, &.{ pidgn.Router.get("/users", listUsers), pidgn.Router.post("/users", createUser),}),Scoped middleware runs after global middleware and only for routes within that scope.
Writing custom middleware
Section titled “Writing custom middleware”A middleware is any function matching the HandlerFn type:
pub const HandlerFn = *const fn (*pidgn.Context) anyerror!void;-
Write the function
fn requestTimer(ctx: *pidgn.Context) !void {ctx.assign("request_start", "now");try ctx.next();// Response is ready -- do post-processing here} -
Add it to the pipeline
.middleware = &.{pidgn.errorHandler(.{}),requestTimer,pidgn.logger,},
Passing data between middleware
Section titled “Passing data between middleware”Use ctx.assign() and ctx.getAssign() to store and retrieve string key-value pairs. The assigns store is a fixed-size (max 16 entries), zero-allocation structure:
fn authMiddleware(ctx: *pidgn.Context) !void { if (ctx.request.header("Authorization")) |token| { _ = token; ctx.assign("user_id", "42"); ctx.assign("role", "admin"); } try ctx.next();}
fn dashboard(ctx: *pidgn.Context) !void { const role = ctx.getAssign("role") orelse "guest"; ctx.text(.ok, role);}Short-circuiting
Section titled “Short-circuiting”To stop the pipeline and respond immediately, set the response and return without calling ctx.next():
fn requireAdmin(ctx: *pidgn.Context) !void { const role = ctx.getAssign("role") orelse "guest"; if (!std.mem.eql(u8, role, "admin")) { ctx.text(.forbidden, "403 Forbidden"); return; // do not call ctx.next() } try ctx.next();}Configurable middleware
Section titled “Configurable middleware”Use the same pattern as the built-in middleware: a comptime function that captures config and returns a HandlerFn:
pub const ApiKeyConfig = struct { header_name: []const u8 = "X-API-Key", required: bool = true,};
pub fn apiKey(comptime config: ApiKeyConfig) pidgn.HandlerFn { const S = struct { fn handle(ctx: *pidgn.Context) anyerror!void { if (ctx.request.header(config.header_name)) |key| { ctx.assign("api_key", key); } else if (config.required) { ctx.text(.unauthorized, "401 Unauthorized"); return; } try ctx.next(); } }; return &S.handle;}Then use it like any built-in:
.middleware = &.{ apiKey(.{ .header_name = "X-My-Key" }) },Next steps
Section titled “Next steps”- Context — the full API for request/response handling
- Error Handling — how the error handler middleware works
- Static Files — serving assets from disk
- Authentication — bearer, basic, JWT, sessions, and CSRF