Skip to content

Routing

Neon32eeee edited this page Oct 17, 2026 · 1 revision

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.

Entities

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.

RouteNode

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.

MethodHandler

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.

Handler

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.

Context

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.

Found

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.

findRoute

pub fn findRoute(path: []const u8, routes: *const RouteNode) ?Found

Walks 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.

Matching rules

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:

  1. Exact match. Iterate over children; the first child with param == null and segment == current wins.
  2. Param match. If no exact match, iterate again; the first child with param != null wins, and its segment value is captured into params[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.

Middleware

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.

Server

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.

Server.start

pub fn start(self: *Server) !void

Starts the listener and blocks. Sequence:

  1. Copies self into static state (ctx, root_route, mws) - zap callbacks don't take userdata, so the tree has to live somewhere globally reachable.
  2. Sets running to true.
  3. Spawns the background worker thread. Spawn failure is non-fatal - a warning is printed and the server continues without periodic saves.
  4. Initializes zap.HttpListener on self.port.
  5. Calls zap.start, which blocks until the server stops.

Returns error.AlreadyRunning if start was called on an already-started server.

Server.stop

pub fn stop(self: *Server) void

Signals the worker to exit, joins it, and clears the static state. Safe to call without a preceding start.

Request dispatch

The dispatch logic - onRequestStatic - runs on every request:

  1. Look up ctx and root_route. If missing, return an error.
  2. If r.path is null, send 400 "Not path".
  3. Call findRoute(path, root). If null, send 404 with the path via response.notFound.
  4. If r.method is null, send 400 "Missing HTTP method".
  5. Iterate the found handlers; the first whose method matches via method.matches wins. If none match, send 405 Method not allowed.
  6. Build a local Context from the server's allocator/db plus the captured params.
  7. Run the middleware chain. If any returns false, stop - the middleware already responded.
  8. 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.

Examples

1. A minimal tree

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.

2. Registering several methods on one path

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.

3. Dynamic segments

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.

4. Nested dynamic segments

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".

5. Exact beats param

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".

6. Adding middleware

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.

7. Starting the server

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.

Notes

  • No by-name param lookup. Context doesn't have a param("id") method. Params are positional. You either remember the index or wrap your handler with a small adapter that copies ctx.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.path inside the handler for the rest.
  • findRoute walks linearly. Exact matches are checked by iterating children; 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.root is a *const RouteNode and the child slices are &.{} literals. All routes must be known at compile time.
  • No 404 fallback handler. findRoute returning null triggers response.notFound directly. To customize the 404 body, either replace response.notFound or check the result of findRoute yourself before dispatch (which would mean not using Server's built-in dispatch).
  • Empty segments are skipped. //users//42 and /users/42 are equivalent. This is convenient but means you can't register a route that depends on the difference - like //users vs /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 a POST handler.
  • Static state means one Server per process. Server.ctx, Server.root_route, Server.mws, and Server.running are var declarations on the type. Two Server instances would clobber each other's state. This is a direct consequence of zap not passing userdata to its request callback.