This document is the detailed guide for extending zhttp with:
- custom middlewares (runtime request/response behavior), and
- custom operations (compile-time route-table transforms).
It also explains the design decisions behind the current API.
zhttp has two extension layers:
- Runtime layer: middlewares + endpoint handlers.
- Compile-time layer: operations that mutate the route table before runtime.
Route registration is endpoint-first:
zhttp.get("/users/{id}", UserEndpoint)Each endpoint type provides metadata in EndpointInfo:
- captures (
headers,query,path) - per-endpoint middlewares
- per-endpoint operation tags
Global middlewares/operations are configured in Server(.{ ... }).
This section is the explicit contract for every public API that takes type/anytype style inputs.
-
zhttp.Server(def: anytype)def.routesis required and is a tuple/struct ofzhttp.get/post/...route declarations.- optional
def.middlewaresis a middleware tuple (.{ MwA, MwB, ... }). - optional
def.operationsis an operation tuple (.{ OpA, OpB, ... }). - each route endpoint type must expose:
pub const Info: zhttp.router.EndpointInfopub fn call(comptime rctx: zhttp.ReqCtx, req: rctx.T()) !rctx.Response(Body)- optional upgrade hook:
pub fn upgrade(server, stream, r, w, line, res) void
-
zhttp.router.route(..., endpoint: type)andzhttp.router.get/post/put/delete/patch/head/options- same endpoint shape as above.
-
zhttp.router.Compiled(Context, routes: anytype, global_middlewares: anytype)routesis a tuple ofRouteDeclvalues.global_middlewaresis accepted bymiddleware.typeList(tuple /[]const type/[N]type/*const [N]type).
-
zhttp.middleware.info(Mw: type)- middleware type must expose:
pub const Info: zhttp.middleware.MiddlewareInfopub fn call(comptime rctx: zhttp.ReqCtx, req: rctx.T()) !Res
- middleware type must expose:
-
zhttp.middleware.needsHeaders/needsQuery/needsParams(mws: anytype) -
zhttp.middleware.contextType(mws: anytype) -
zhttp.middleware.staticContextType(mws: anytype) -
zhttp.middleware.contextST(mws: anytype)mwsmust be accepted byzhttp.middleware.typeList.- each middleware in
mwsmust satisfymiddleware.info(...)checks.
-
zhttp.middleware.initContext(mws: anytype, Ctx: type)Ctxmust be the exact type returned bycontextType(mws).
-
zhttp.middleware.initStaticContext(Ctx: type, ...)/deinitStaticContext(Ctx: type, ...)Ctxshould be the exact type returned bystaticContextType(...).- each static context field type may expose:
pub fn init(io: std.Io, allocator: std.mem.Allocator, route_decl: zhttp.route_decl.RouteDecl) Self | !Selfpub fn deinit(self: *Self, io: std.Io, allocator: std.mem.Allocator) void | !void
-
zhttp.middleware.typeList(mws: anytype)- accepted input shapes:
- tuple of middleware types
[]const type[N]type*const [N]type
- accepted input shapes:
zhttp.operations.apply(routes_tuple: anytype, global_middlewares_tuple: anytype, operations_tuple: anytype)routes_tupleis a tuple ofRouteDecl.global_middlewares_tupleis accepted bymiddleware.typeList.- each operation type in
operations_tupleexposes:pub fn operation(comptime opctx: zhttp.operations.OperationCtx, router: opctx.T()) void- optional
pub fn maxAddedRoutes(comptime base_route_count: usize) usize
-
zhttp.response.Response(Body: type)Bodycan be:[]const u8[][]const u8void- custom struct with:
pub fn body(self: @This(), comptime rctx: zhttp.ReqCtx, req: rctx.TReadOnly(), cw: *zhttp.response.ChunkedWriter) !void
- endpoint handlers may also return a prebuilt response struct instead of
Response(Body)when it exposes:pub fn write(self: @This(), w: *std.Io.Writer, keep_alive: bool, send_body: bool) !voidand optionalclose: bool - server dispatch automatically bypasses the per-connection writer buffer for these prebuilt writes
-
zhttp.response.writeAny(rctx: anytype, req_ro: anytype, w, res: anytype, ...)rctxis azhttp.ReqCtxvalue.req_roisrctx.TReadOnly().resiszhttp.response.Response(Body)(or a compatible struct withstatus,headers,body, optionalclose).- prebuilt response structs expose:
pub fn write(self: @This(), w: *std.Io.Writer, keep_alive: bool, send_body: bool) !void keep_aliveandsend_bodyare already resolved to the effective connection/body policy for that request
-
zhttp.response.writeUpgrade(w, res: anytype)resmust exposestatusandheaders.
-
zhttp.request.RequestPWithPatternExt(ServerPtr: type, route_index, rd, MwCtx)ServerPtris*Serverwhere pointee exposes:- fields:
io,gpa,ctx RouteStaticType(route_index)routeStatic(route_index)routeStaticConst(route_index)
- fields:
rdis a resolvedRouteDecl.MwCtxis merged middleware request-context type for that route.
-
zhttp.request.Request(Headers: type, Query: type, param_names, MwCtx: type)HeadersandQueryare capture structs whose field types follow parser contract (below).MwCtxis the middleware context struct type.
-
request helpers that take
name: anytype:req.middlewareData(name)req.middlewareDataConst(name)req.middlewareStatic(name)req.middlewareStaticConst(name)- accepted
nameshapes:- enum literal (for example
.auth) - string/byte-array literal (for example
"auth")
- enum literal (for example
-
zhttp.upgrade.websocketHandshakeRequest(req: anytype)reqmust expose:methodbaseConst().versionheader(.connection/.upgrade/.sec_websocket_key/.sec_websocket_version/.sec_websocket_protocol/.sec_websocket_extensions/.origin/.host)
-
zhttp.upgrade.acceptWebSocket(req: anytype, opts)- same requirements as
websocketHandshakeRequest(req) - plus
req.allocator().
- same requirements as
Parser field type contract used by zhttp.parse helpers:
const Parser = struct {
pub const empty: @This() = .{};
pub fn parse(self: *@This(), allocator: std.mem.Allocator, raw: []const u8) !void {}
pub fn doneParsing(self: *@This(), was_present: bool) !void {}
pub fn get(self: *const @This()) ValueType { ... }
pub fn destroy(self: *@This(), allocator: std.mem.Allocator) void {}
};zhttp.parse.structFields(T: type)->Tmust bestruct.zhttp.parse.emptyStruct(T: type)-> every field type inTprovidesempty.zhttp.parse.destroyStruct(value: anytype, allocator)->valueis*Struct, fields providedestroy.zhttp.parse.doneParsingStruct(value: anytype, present)->valueis*Struct, fields providedoneParsing,present.lenmatches field count.zhttp.parse.Lookup(T: type, kind)->Tisstructcapture schema with no duplicate keys after normalization.zhttp.parse.mergeStructs(A: type, B: type)-> both structs; duplicate field names must have identical types.zhttp.parse.mergeHeaderStructs(A: type, B: type)-> same as above but key matching is case-insensitive and_equals-.zhttp.parse.mergeStructsMany(types_tuple: anytype)-> tuple of struct types.zhttp.parse.Optional(P: type)/zhttp.parse.SliceOf(P: type)->Pfollows parser contract.zhttp.parse.Int(T: type)-> integer type.zhttp.parse.Float(T: type)-> float type.zhttp.parse.Enum(E: type)-> enum type.
A middleware is a type that exposes:
pub const Info: zhttp.middleware.MiddlewareInfopub fn call(comptime rctx: zhttp.ReqCtx, req: rctx.T()) !rctx.Response(Body)
Body follows the same rules as endpoints:
[]const u8[][]const u8void- custom struct with
pub fn body(self, comptime rctx, req: rctx.TReadOnly(), cw) !void
Minimal example:
const Auth = struct {
pub const Info = zhttp.middleware.MiddlewareInfo{
.name = "auth",
.header = struct {
authorization: zhttp.parse.Optional(zhttp.parse.String),
},
.data = struct {
user_id: u64 = 0,
},
};
pub fn call(comptime rctx: zhttp.ReqCtx, req: rctx.T()) !rctx.Response([]const u8) {
const auth = req.header(.authorization) orelse
return zhttp.Res.text(401, "missing auth\n");
if (!std.mem.eql(u8, auth, "bearer ok")) {
return zhttp.Res.text(403, "bad auth\n");
}
req.middlewareData(.auth).user_id = 42;
return rctx.next(req);
}
};MiddlewareInfo is the only public metadata contract for middlewares:
name: []const u8required unique middleware key.data: ?typeoptional per-request mutable state storage.static_context: ?typeoptional per-route startup-initialized state.path: ?typeoptional required path captures.query: ?typeoptional required query captures.header: ?typeoptional required header captures.
Legacy public Data/Needs patterns are no longer supported.
For a route, zhttp merges endpoint captures with all middleware capture needs.
Rules:
path/querymerges are exact-name merges.headermerges are normalized merges:- case-insensitive
_and-are treated as equivalent
- same normalized header + same field type: coalesced (allowed).
- same normalized header + different type: compile error.
These are enforced at compile time.
Execution order is deterministic:
- global middlewares (from
Server(.middlewares)), then - endpoint middlewares (from
EndpointInfo.middlewares), then - endpoint
call.
rctx.next(req) advances the chain.
Patterns:
- Guard middleware: return early (
401/403/...) withoutnext. - Transform middleware: call
next, then edit response headers/body metadata. - State middleware: write to
req.middlewareData(...), later read in endpoint/other middleware.
If Info.data is non-null and non-zero-sized, zhttp stores it per request.
Access:
- mutable:
req.middlewareData(.name_or_string) - read-only:
req.middlewareDataConst(...)
Name dedup behavior:
- same
Info.name+ samedatatype: shared field (coalesced). - same
Info.name+ differentdatatype: compile error.
static_context is initialized once per route in Server.init.
Type can expose:
pub fn init(io: std.Io, allocator: std.mem.Allocator, route_decl: zhttp.route_decl.RouteDecl) Self | !SelfIf omitted, zero-init is used.
Access:
- mutable:
req.middlewareStatic(...) - read-only:
req.middlewareStaticConst(...)
Init errors propagate out of Server.init.
A middleware may optionally expose:
pub fn Override(comptime rctx: zhttp.ReqCtx) typeReturned type can override base request methods (header, bodyAll, etc.).
Important constraints:
- first parameter must be
rctx.T(),*rctx.T(), or*const rctx.T(). - parameter count must match the base method (zhttp supports at most one extra arg beyond
req). - this is optional; most middlewares should not implement it.
rctx.TReadOnly() is the override-free request view. It exposes the same surface as
rctx.T() but dispatches directly to the base request implementation instead of
walking the middleware override chain.
Use Override only for cross-cutting request behavior changes.
Response bodies may be:
[]const u8for one contiguous body[][]const u8for vectored fixed-length bodiesvoidfor empty bodies- a custom body struct with
pub fn body(self, comptime rctx, req: rctx.TReadOnly(), cw) !void
Request body accessors available to middleware and endpoints:
req.bodyAll(max_bytes)reads and caches the full bodyreq.bodyReader()returns a one-way body reader for streaming consumersreq.allocator()uses request-lifetime allocationreq.gpa()returns the server allocator and must be freed manually
Before shipping a middleware:
Info.namestable and non-empty.- captures declared only in
MiddlewareInfo(path/query/header). - short-circuit and pass-through paths both tested.
- duplicate-header behavior tested (
assert_absentorcheck_then_addstyle). - if using
static_context, init success/failure paths tested.
Operations are compile-time route-table transforms.
They can add/remove/replace routes before runtime starts. They do not run per request.
Operation type must expose:
pub fn operation(comptime opctx: zhttp.operations.OperationCtx, router: opctx.T()) voidOptional capacity budget (at least one of):
pub const MaxAddedRoutes: usizepub fn maxAddedRoutes(comptime base_route_count: usize) usize
If omitted, budget is zero.
Operations are opt-in in two places:
- tag route endpoints with
EndpointInfo.operations = &.{MyOp} - register execution order in
Server(.{ .operations = .{MyOp, ...} })
opctx.filter(router) returns only indices tagged with the current operation type.
The operation router supports compile-time mutations and queries:
- mutate:
add,addMany,insert,replace,remove,swapRemove,clear - inspect:
count,all,route,routeConst - lookup:
hasMethodPath - middleware/operation queries:
hasMiddleware,hasSignature,hasMiddlewareDeclfirstMiddlewareWithSignature,firstMiddlewareWithDecl,firstMiddlewareWithDeclValuefilterByMiddleware,filterBySignature,filterByMiddlewareDecl,filterByOperation- path grouping helpers (
forEachPathGroup*)
const AutoHead = struct {
pub fn maxAddedRoutes(comptime base_route_count: usize) usize {
return base_route_count;
}
pub fn operation(comptime opctx: zhttp.operations.OperationCtx, r: opctx.T()) void {
for (opctx.filter(r)) |idx| {
const rd = r.routeConst(idx).*;
if (!std.mem.eql(u8, rd.method, "GET")) continue;
if (r.hasMethodPath("HEAD", rd.pattern)) continue;
r.add(zhttp.head(rd.pattern, rd.endpoint));
}
}
};Route opt-in:
const Endpoint = struct {
pub const Info: zhttp.router.EndpointInfo = .{
.operations = &.{AutoHead},
};
pub fn call(comptime rctx: zhttp.ReqCtx, req: rctx.T()) !rctx.Response([]const u8) { ... }
};
const App = zhttp.Server(.{
.operations = .{AutoHead},
.routes = .{ zhttp.get("/x", Endpoint) },
});- define explicit added-route budget.
- ensure transform is deterministic for tuple order.
- ensure idempotency (
hasMethodPathchecks before add). - test both tagged and untagged routes.
- test order interactions with other operations.
All per-route behavior (captures, middlewares, operation tags) lives on endpoint types. This keeps route registration minimal and avoids split metadata between route call sites and handler types.
Capture requirements and state shape are declared once. This enables compile-time merging, validation, and deterministic request wrapper generation.
Headers are case-insensitive and -/_ equivalent in field names.
Coalescing same-type duplicates prevents noisy conflicts while still catching true schema mismatches.
Expensive route-derived setup belongs in startup, not request hot paths.
static_context provides exactly that and keeps per-request work minimal.
Tagging expresses where an operation applies. Server operation tuple expresses when it runs. This separates scope from sequencing and makes transforms composable.
Operation router capacity is fixed at compile time (base routes + budgets). This avoids dynamic growth logic and preserves deterministic compile-time behavior.
Most extension errors are compile-time errors:
- missing/invalid
Info - invalid operation signatures
- conflicting capture schemas
- route collisions
This is intentional: fail early, keep runtime fast.
Override exists for advanced request-behavior interception, but is optional by design.
Most middleware should remain simple (Info + call + optional state).
Custom response body writers run after middleware/endpoint code has already produced
the response metadata. They therefore receive rctx.TReadOnly() instead of rctx.T().
This keeps the request API available while avoiding another middleware-override pass
during response streaming.
For middleware/operation contributions, add tests that cover:
- happy path
- failure/short-circuit path
- merge/conflict behavior
- ordering semantics
- startup init errors (for
static_context) - operation idempotency and budget assumptions
Endpoints select response serialization mode via rctx.Response(Body):
Body = []const u8: normalContent-Lengthresponse.Body = [][]const u8: vectored body, stillContent-Length.Body = CustomBody: chunked response (transfer-encoding: chunked).- returning a custom struct with
pub fn write(self, w, keep_alive, send_body) !void: prebuilt response bytes written directly to an unbuffered stream writer by the server
Chunked example:
const StreamEp = struct {
pub const Info: zhttp.router.EndpointInfo = .{};
pub fn call(comptime rctx: zhttp.ReqCtx, req: rctx.T()) !rctx.Response(CustomBody) {
_ = req;
return .{
.status = .ok,
.body = .{ .writeFn = struct {
fn write(cw: *zhttp.response.ChunkedWriter) std.Io.Writer.Error!void {
try cw.writeAll("part-a");
try cw.writeAll("part-b");
}
}.write },
};
}
};Prebuilt write example:
const Prebuilt = struct {
pub fn write(_: @This(), w: *std.Io.Writer, keep_alive: bool, send_body: bool) !void {
try w.writeAll(if (keep_alive)
if (send_body)
"HTTP/1.1 200 OK\r\nconnection: keep-alive\r\ncontent-length: 2\r\n\r\nok"
else
"HTTP/1.1 200 OK\r\nconnection: keep-alive\r\ncontent-length: 2\r\n\r\n"
else if (send_body)
"HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-length: 2\r\n\r\nok"
else
"HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-length: 2\r\n\r\n");
}
};
const PrebuiltEp = struct {
pub const Info: zhttp.router.EndpointInfo = .{};
pub fn call(comptime rctx: zhttp.ReqCtx, req: rctx.T()) !Prebuilt {
_ = req;
return .{};
}
};Recommended validation commands:
zig build test
zig build test-flake -- --iterations=100 --jobs=1
zig build test-flake -- --iterations=200 --jobs=1 --retries=5 --test-filter="Server stop"
zig build examples-check
zig build -Doptimize=ReleaseFast test
zig build -Doptimize=ReleaseFast examples-checktest-flake runs the test runner across a deterministic seed sweep and, on failure, extracts failing test lines, prints single-test repro commands (--zhttp-run-test + --seed), and reports rerun reproducibility for that seed. It only returns failure for seeds that reproduce on rerun(s). Pass --verbose to include full failing logs.