Repository navigation
Routing
Routing in Mesh is done by hand - you build a tree of RouteNodes, and findRoute walks it segment by segment to find a match. There is no DSL, no builder, no macros. Just a struct and a function.
The Server owns the tree, receives each request from zap, calls findRoute, picks the handler whose method matches, runs the middleware chain, and finally invokes the handler.
Three types participate in routing:
-
RouteNode- a single node in the tree. Represents one path segment and optionally holds handlers and children. -
Context- per-request state. Carries the allocator, the database handle, and up to 4 captured path parameters. -
Found- the result of a lookup. Handlers plus captured params.
And one function: findRoute.
pub const RouteNode = struct {
segment: []const u8,
param: ?[]const u8 = null,
handlers: []const MethodHandler = &.{},
children: []const RouteNode = &.{},
};| Field | Type | Purpose |
|---|---|---|
segment |
[]const u8 |
The literal segment this node matches. For the root node it's "". |
param |
?[]const u8 |
If null, the segment is exact. If set, the segment is dynamic and the value is the parameter name. |
handlers |
[]const MethodHandler |
Handlers attached to this node. Empty means "not a leaf, keep walking." |
children |
[]const RouteNode |
Sub-nodes. Empty means "leaf." |
The rule is simple: a node is a leaf if it has handlers. A node with children but no handlers is a pure waypoint. A node with both - like /users/:uid in the test tree - matches both as a leaf and as a waypoint.
pub const MethodHandler = struct {
method: method.Method,
handler: Handler,
};One HTTP method, one handler. A single node can hold several MethodHandlers - one per supported verb.
pub const Handler = *const fn (zap.Request, Context) void;A function pointer. Handlers take a request and a context and return void. They are responsible for sending a response - nothing does it on their behalf.
pub const Context = struct {
allocator: std.mem.Allocator,
db: db_mod.DB,
params: [4]?[]const u8 = @splat(null),
};| Field | Type | Purpose |
|---|---|---|
allocator |
std.mem.Allocator |
The allocator the server was initialized with. |
db |
DB |
The database handle. Passed through from the server. |
params |
[4]?[]const u8 |
Captured path parameters, indexed by the order they were matched. Unused slots are null. |
Four is the hard limit. A path with more than four dynamic segments will only capture the first four - the rest are matched but not stored. In practice this is never a problem; paths with five or more variables are a design smell.
pub const Found = struct {
handlers: []const MethodHandler,
params: [4]?[]const u8 = @splat(null),
};What findRoute returns when it finds a leaf. handlers is the list from the matched node; params is whatever was captured along the way.
pub fn findRoute(path: []const u8, routes: *const RouteNode) ?FoundWalks the tree segment by segment and returns the matching leaf, or null if nothing matched.
Signature details:
| Input | Type | Description |
|---|---|---|
path |
[]const u8 |
The request path, e.g. "/users/42". |
routes |
*const RouteNode |
The root of the tree. |
Returns ?Found. null means no route matched.
The walk splits the path on / using tokenizeScalar, which skips empty segments. That means //users//42 behaves the same as /users/42, and a trailing slash is ignored - /health and /health/ match the same node.
At each segment, the algorithm tries matches in this order:
-
Exact match. Iterate over
children; the first child withparam == nullandsegment == currentwins. -
Param match. If no exact match, iterate again; the first child with
param != nullwins, and itssegmentvalue is captured intoparams[pi].
The reason exact wins over param is so that /users/me doesn't get swallowed by /users/:id. If you register both, me is reachable.
There is one extra rule for param matching: if the param node has no children and there are still segments left in the path, it's skipped. That prevents /users/:id from matching the first segment of /users/42/posts when the actual intent was /users/:uid/posts/:pid.
pub const Middleware = *const fn (r: zap.Request, ctx: router.Context) bool;A middleware is a function that runs before the handler. Returning true lets the chain continue; returning false stops it - and the contract is that the middleware has already sent a response.
Middlewares run in the order they were listed on the server. The Context they receive is the same one the handler will get.
pub const Server = struct {
db: DB,
allocator: std.mem.Allocator,
root: *const router.RouteNode,
port: u16 = 8080,
threads: i16 = 4,
log: bool = true,
io: std.Io,
middlewares: []const Middleware = &.{},
worker_thread: ?std.Thread = null,
started: bool = false,
// ...
};| Field | Default | Purpose |
|---|---|---|
db |
- | Database handle passed to every Context. |
allocator |
- | Allocator used for per-request work (arena parents). |
root |
- | The routing tree. |
port |
8080 |
Listening port. |
threads |
4 |
Worker threads for zap.start. |
log |
true |
Whether zap logs requests. |
io |
- | Used by the background saver thread. |
middlewares |
&.{} |
Chain that runs before every handler. |
worker_thread |
null |
Set by start, cleared by stop. |
started |
false |
Guards against double-start. |
pub fn start(self: *Server) !voidStarts the listener and blocks. Sequence:
- Copies
selfinto static state (ctx,root_route,mws) -zapcallbacks don't take userdata, so the tree has to live somewhere globally reachable. - Sets
runningtotrue. - Spawns the background worker thread. Spawn failure is non-fatal - a warning is printed and the server continues without periodic saves.
- Initializes
zap.HttpListeneronself.port. - Calls
zap.start, which blocks until the server stops.
Returns error.AlreadyRunning if start was called on an already-started server.
pub fn stop(self: *Server) voidSignals the worker to exit, joins it, and clears the static state. Safe to call without a preceding start.
The dispatch logic - onRequestStatic - runs on every request:
- Look up
ctxandroot_route. If missing, return an error. - If
r.pathisnull, send400 "Not path". - Call
findRoute(path, root). Ifnull, send404with the path viaresponse.notFound. - If
r.methodisnull, send400 "Missing HTTP method". - Iterate the found handlers; the first whose method matches via
method.matcheswins. If none match, send405 Method not allowed. - Build a local
Contextfrom the server's allocator/db plus the captured params. - Run the middleware chain. If any returns
false, stop - the middleware already responded. - Call the handler.
Note step 5: since method.matches treats GET and HEAD as the same, a HEAD request reaches your GET handler with an empty body.
const router = @import("router.zig");
fn health(_: Mesh.zap.Request, _: router.Context) void {
// send 200 OK, body "ok"
}
const routes = router.RouteNode{
.segment = "",
.children = &.{
.{
.segment = "health",
.handlers = &.{
.{ .method = .GET, .handler = health },
},
},
},
};The root is always "". Every real route is a child of it.
fn listUsers(_: Mesh.zap.Request, _: router.Context) void {}
fn createUser(_: Mesh.zap.Request, _: router.Context) void {}
const routes = router.RouteNode{
.segment = "",
.children = &.{
.{
.segment = "users",
.handlers = &.{
.{ .method = .GET, .handler = listUsers },
.{ .method = .POST, .handler = createUser },
},
},
},
};One node, two methods. findRoute returns both MethodHandlers and the dispatcher picks the one matching the request.
fn getUser(_: Mesh.zap.Request, ctx: router.Context) void {
const id = ctx.params[0] orelse return;
_ = id;
}
const routes = router.RouteNode{
.segment = "",
.children = &.{
.{
.segment = "users",
.children = &.{
.{
.segment = ":id",
.param = "id",
.handlers = &.{
.{ .method = .GET, .handler = getUser },
},
},
},
},
},
};The segment field is the literal ":id", and param is the name "id" - the two are stored separately. The captured value is in ctx.params[0] because it's the first dynamic segment on the path. There is no by-name lookup; the index in params is determined by the order the segments were matched.
fn getPost(_: Mesh.zap.Request, ctx: router.Context) void {
const uid = ctx.params[0] orelse return;
const pid = ctx.params[1] orelse return;
_ = uid;
_ = pid;
}
const routes = router.RouteNode{
.segment = "",
.children = &.{
.{
.segment = "users",
.children = &.{
.{
.segment = ":uid",
.param = "uid",
.children = &.{
.{
.segment = "posts",
.children = &.{
.{
.segment = ":pid",
.param = "pid",
.handlers = &.{
.{ .method = .GET, .handler = getPost },
},
},
},
},
},
},
},
},
},
};A request to /users/7/posts/99 captures params[0] = "7" and params[1] = "99".
With both users/me and users/:id registered:
const routes = router.RouteNode{
.segment = "",
.children = &.{
.{
.segment = "users",
.children = &.{
.{
.segment = "me",
.handlers = &.{
.{ .method = .GET, .handler = getMe },
},
},
.{
.segment = ":id",
.param = "id",
.handlers = &.{
.{ .method = .GET, .handler = getUser },
},
},
},
},
},
};/users/me hits getMe with no params captured. /users/42 falls through to getUser with params[0] = "42".
fn auth(r: Mesh.zap.Request, _: router.Context) bool {
const header = r.getHeader("authorization") orelse {
response.sendError(r, .unauthorized, "no token");
return false;
};
if (!std.mem.startsWith(u8, header, "Bearer ")) {
response.sendError(r, .unauthorized, "bad token");
return false;
}
return true;
}
var server = Server{
.db = db,
.allocator = allocator,
.root = &routes,
.io = io,
.middlewares = &.{ auth },
};Middleware runs before the handler for every matched route. Returning false stops the chain - the handler never runs, and the response is assumed already sent.
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
var real_db = try ddb.DB.init("data.db", gpa.allocator(), io);
defer real_db.deinit();
var server = Server{
.db = Mesh.DB.fromDDB(&real_db),
.allocator = gpa.allocator(),
.root = &routes,
.port = 8080,
.io = io,
};
defer server.stop();
try server.start(); // blocks
}start blocks until the process is killed or stop is called from another thread.
-
No by-name param lookup.
Contextdoesn't have aparam("id")method. Params are positional. You either remember the index or wrap your handler with a small adapter that copiesctx.params[0]into a named variable. -
Four params max. The array is fixed at four. If you need more, capture the first three and re-tokenize
r.pathinside the handler for the rest. -
findRoutewalks linearly. Exact matches are checked by iteratingchildren; param matches by iterating again. With dozens of routes this is fine. With hundreds, consider ordering your children by expected frequency. -
The tree is static. Routes are
const. There's no runtime registration -Server.rootis a*const RouteNodeand the child slices are&.{}literals. All routes must be known at compile time. -
No 404 fallback handler.
findRoutereturningnulltriggersresponse.notFounddirectly. To customize the 404 body, either replaceresponse.notFoundor check the result offindRouteyourself before dispatch (which would mean not usingServer's built-in dispatch). -
Empty segments are skipped.
//users//42and/users/42are equivalent. This is convenient but means you can't register a route that depends on the difference - like//usersvs/users. -
Method mismatch is a 405, not a 404. A route that exists but has no handler for the request method sends
405 Method not allowed. This is correct HTTP behavior but is easy to misread as a routing bug when you forget to register aPOSThandler. -
Static state means one
Serverper process.Server.ctx,Server.root_route,Server.mws, andServer.runningarevardeclarations on the type. TwoServerinstances would clobber each other's state. This is a direct consequence ofzapnot passing userdata to its request callback.