Skip to content

schnell Developer Reference

schnell is a small HTTP server toolkit for Zig. You write pub fn main(), build an App, register routes and middleware, and call run. Handlers are plain functions over a request and a response. Nothing is hidden, and nothing takes over your control flow.

The same crate handles two runtime shapes:

  • Native. schnell.App binds a TCP listener, drives fibers, and serves the requests itself. This is what you use for shell apps, dev servers, and BFFs.
  • WASM. schnell.WasmApp (from the schnell.web submodule) gives you the same routing + middleware + handler surface, but here the WASM module is loaded by Planck and the request bytes come in through host calls. The bytes go back out through a host_respond extern.

Routing, request/response, middleware, sessions, schema validation, and the config loader are identical across both modes. Only the entry point and the I/O substrate differ.

Server-Sent Events to browsers live in a separate crate, ssehub. schnell's job is HTTP. ssehub's job is to watch a Planck change stream and fan it out to N browser EventSource connections. The two pair up cleanly. See SSE fan-out with ssehub below.

Import:

zig
const schnell = @import("schnell");
const web = @import("web");   // schnell.web submodule, needed for WasmApp + JWT

Quick start

Native app

zig
const std = @import("std");
const schnell = @import("schnell");

fn home(_: ?*anyopaque, _: std.mem.Allocator, _: *const schnell.Request, res: *schnell.Response) !void {
    try res.html("<h1>hello</h1>");
}

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    const allocator = gpa.allocator();

    var threaded: std.Io.Threaded = .init(allocator, .{ .async_limit = .unlimited });
    defer threaded.deinit();
    const io = threaded.io();

    var app = try schnell.App.init(allocator, .{
        .host = "127.0.0.1",
        .port = 3000,
        .static_dir = "public",
    });
    defer app.deinit();

    try app.get("/", home, null);

    try app.run(io);
}

That is the whole program. App.init takes a Server.Config. app.get / post / put / delete / patch register the routes. app.run(io) blocks; call app.stop(io) from another fiber when you want to drain.

WASM service

zig
const std = @import("std");
const web = @import("web");
const planck = @import("planck");

extern fn host_respond(ptr: [*]const u8, len: u32) void;

var app: web.WasmApp = undefined;
var client: *planck.PlanckClient = undefined;

const Ctx = struct { client: *planck.PlanckClient };
var ctx: Ctx = undefined;

export fn init() i32 {
    const allocator = std.heap.wasm_allocator;

    client = planck.PlanckClient.init(allocator, .{}) catch return -1;
    ctx = .{ .client = client };

    app = web.WasmApp.init(allocator, .{}) catch return -1;

    app.get("/items", listItems, &ctx) catch return -1;

    app.onResponse(struct {
        fn hook(_: *const web.Request, res: *web.Response, buf: []u8) void {
            const bytes = res.toBytes(buf) catch return;
            host_respond(bytes.ptr, @intCast(bytes.len));
        }
    }.hook);

    return 0;
}

export fn process(p: [*]const u8, l: u32) i32 {
    app.process(p, l) catch return -1;
    return 0;
}

fn listItems(ctx_ptr: ?*anyopaque, allocator: std.mem.Allocator, req: *const web.Request, res: *web.Response) !void {
    _ = ctx_ptr;
    _ = req;
    try res.json(try std.fmt.allocPrint(allocator, "{{\"items\":[]}}", .{}));
}

The host calls init once when the module loads, and then process(ptr, len) per request. Routing and handler signatures are exactly the same as in the native API. The only thing that changes is how the response bytes get out: the host hands you a buffer via the response hook, you serialize into it, and you call host_respond.


Handler signature

Every handler has the same signature:

zig
fn handle(ctx_ptr: ?*anyopaque, allocator: std.mem.Allocator, req: *const Request, res: *Response) !void

The four arguments:

  • ctx_ptr is whatever pointer you passed to app.get(...). Cast it back inside the handler. This is your shared application state: a Planck client, a config struct, a session store, whatever the handler happens to need.
  • allocator is a per-request arena. Everything you allocate during the handler is freed once the response is sent. Do not stash pointers from it.
  • req is the parsed request (read-only).
  • res is the response builder. Set the status, headers, and body, then return.
zig
const std = @import("std");
const web = @import("web");
const Request = web.Request;
const Response = web.Response;

const Ctx = @import("../ctx.zig").Ctx;
const repo = @import("../repo.zig");
const CategoryList = @import("../fragments/category_list.zig").CategoryList;

pub fn handle(ctx_ptr: ?*anyopaque, allocator: std.mem.Allocator, _: *const Request, res: *Response) !void {
    const ctx: *Ctx = @ptrCast(@alignCast(ctx_ptr orelse return error.NoContext));

    const categories = try repo.listCategories(ctx.client, allocator);
    defer allocator.free(categories);

    var out: std.ArrayList(u8) = .empty;
    try CategoryList.render(.{ .categories = categories }, &out, allocator);
    try res.html(out.items);
}

The function must be named handle. The router looks up pub fn handle by name when the route is registered. Pull the data through your Ctx, render the ZSX fragment into a std.ArrayList(u8), and pass out.items to res.html.

For streaming responses (long polls, downloads, raw SSE from inside schnell itself) see Streaming responses.


Request

zig
pub const Request = struct {
    method: Method,
    path: []const u8,
    query_string: ?[]const u8,
    body: []const u8,
    headers: ArrayList(Header),
    cookies: StringHashMap([]const u8),
    locals: *StringHashMap([]const u8),
    io: ?std.Io,
    request_id: []const u8,
    keep_alive: bool,
};

Reading the common things:

MethodReturnsPurpose
getHeader(name)?[]const u8Case-insensitive header lookup.
getQuery(name)?[]const u8Single value from the URL query string.
getParams(T)TDeserialize the whole query string into a struct by field name.
getCookie(name)?[]const u8Cookie value from the Cookie header.
getLocal(key)?[]const u8Read a value set by middleware (e.g. authenticated user id).
setLocal(key, value)!voidWrite a value middleware or routing left for the handler.
getBody(allocator, T)!TParse the body as JSON or form-urlencoded into T.
getFormParam(name)?[]const u8Read a single form field (form-encoded or multipart).
getMultipartField(name)?PartMultipart upload field, including filename + content type.
contentLength()!?usizeParse the Content-Length header.

Path parameters from patterns like /users/:id land in req.getLocal("id"). The pattern grammar is :<name> for a single segment.

req.io is the fiber-local std.Io handle. Pass the same to schnell.Client and SessionStore when you make outbound calls or look up sessions from inside a handler.


Response

zig
pub const Response = struct {
    status: Status = .ok,
    headers: ArrayList(Header),
    body: ArrayList(u8),
};
MethodPurpose
setHeader(name, value)Replace (or insert) a header. Rejects CR/LF in either argument.
appendHeader(name, value)Append without replacement. Used for Set-Cookie.
setCookie(cookie)Set a cookie with the standard fields (path, domain, max_age, secure, http_only, same_site).
html(data)Set Content-Type: text/html; charset=utf-8 and append to the body.
json(data)Set Content-Type: application/json and append to the body.
write(data)Append raw bytes to the body. Doesn't touch headers.

There is no render method. To render a ZSX template, build into an ArrayList(u8) and pass that to res.html. The pattern is given in ZSX templates.

status is a typed enum. Set it directly: res.status = .created; or res.status = .bad_request;.


Routing

The native and WASM apps share the same route registration surface. Methods on App / WasmApp:

zig
try app.get("/",            home,         &ctx);
try app.get("/users",       listUsers,    &ctx);
try app.get("/users/:id",   getUser,      &ctx);
try app.post("/users",      createUser,   &ctx);
try app.delete("/users/:id", deleteUser,  &ctx);
try app.put("/users/:id",   updateUser,   &ctx);
try app.patch("/users/:id", patchUser,    &ctx);

// Generic form, for dynamically computed methods:
try app.route(.get, "/things", listThings, &ctx);

The third argument is your context pointer. Pass null in case the handler doesn't need any.

Path patterns are plain strings. A segment of the form :name matches one URL segment and lands in req.getLocal(name). Routes are matched in registration order, and the first match wins.

Streaming responses

For SSE, long polls, and large downloads from inside schnell, register a streaming route. The handler signature swaps *Response for *std.Io.Writer and writes the wire bytes (status line, headers, body) directly.

zig
fn tailLogs(_: ?*anyopaque, _: std.mem.Allocator, _: *const schnell.Request, w: *std.Io.Writer) !void {
    try w.writeAll("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n");
    // ... write `data: ...\n\n` frames as events arrive ...
}

try app.routeStreaming(.get, "/logs/tail", tailLogs, &ctx);

If what you actually want is a server-side change stream fanned out to many browser EventSource clients with replay rings and bounded queues, see SSE fan-out with ssehub. Do not try to rebuild all of that inside a streaming handler.


Middleware

A middleware runs before (and optionally after) every request. Write it as a struct with an execute method:

zig
pub const RequireAdmin = struct {
    sessions: *MySessionStore,

    pub fn execute(self: *RequireAdmin, _: std.mem.Allocator, req: *const Request, res: *Response) !schnell.Middleware.Action {
        const token = schnell.readSessionCookie(req, "sid") orelse {
            res.status = .unauthorized;
            return .stop;
        };
        const session = try self.sessions.get(req.io.?, token) orelse {
            res.status = .unauthorized;
            return .stop;
        };
        if (!std.mem.eql(u8, session.role, "admin")) {
            res.status = .forbidden;
            return .stop;
        }
        try req.setLocal("user_id", session.user_id);
        return .next;
    }

    pub fn middleware(self: *RequireAdmin) schnell.Middleware {
        return schnell.Middleware.from(RequireAdmin, self);
    }
};

var require_admin = RequireAdmin{ .sessions = &sessions };
try app.use(require_admin.middleware());

Return .next to continue, or .stop to short-circuit (the response you have already populated is what goes back).

A method named after(self, allocator, req, res, matched) is optional. If it is present, it runs after the handler returns. Useful for logging, metrics, and audit trails.

Built-ins

zig
// CORS
var cors = schnell.CorsMiddleware.init(.{
    .allow_origin = "*",
    .allow_methods = "GET, POST, PUT, DELETE, OPTIONS",
    .allow_headers = "Content-Type, Authorization",
});
try app.use(cors.middleware());

// Request ID (X-Request-Id, for log correlation)
var req_id = schnell.RequestIdMiddleware.init(io);
try app.use(req_id.middleware());

// Rate limiting (token bucket)
var limiter = schnell.RateLimiter.init(allocator, io, .{ .requests_per_minute = 100 });
var rate_mw = schnell.RateLimitMiddleware.init(io, &limiter);
try app.use(rate_mw.middleware());

// CSRF (double-submit cookie)
var csrf = schnell.CsrfMiddleware.init(io);
try app.use(csrf.middleware());

The WASM-friendly bearer-token middleware (web.TokenAuthMiddleware) and the JWT middleware (web.JwtAuthMiddleware) live in the web submodule, because they are commonly used inside WASM services where the rest of schnell's HTTP server isn't linked in.


Sessions

SessionStore(T) is generic over your per-session payload. The payload type needs a dupe(allocator) and a deinit(allocator) so that the store can own its own copies of the strings inside.

zig
const AppData = struct {
    user_id: u64,
    role: []const u8,
    email: []const u8,

    pub fn dupe(self: AppData, allocator: std.mem.Allocator) !AppData {
        return .{
            .user_id = self.user_id,
            .role = try allocator.dupe(u8, self.role),
            .email = try allocator.dupe(u8, self.email),
        };
    }

    pub fn deinit(self: *AppData, allocator: std.mem.Allocator) void {
        allocator.free(self.role);
        allocator.free(self.email);
    }
};

const Sessions = schnell.SessionStore(AppData);

var sessions = Sessions.init(allocator, io, .{
    .max_entries = 100_000,
    .default_ttl_ms = 7 * 24 * 3600 * 1000,
});
sessions.start();
defer sessions.deinit();

// Create
const token = try sessions.create(io, .{
    .user_id = 42,
    .role = "admin",
    .email = "alice@example.com",
});

// Read
if (sessions.get(io, token)) |s| {
    // s.user_id, s.role, s.email
}

// Rotate (keeps data, new token)
const new_token = try sessions.rotate(io, token);

// Destroy
sessions.destroy(io, token);

Read the session cookie that the browser sent:

zig
const token = schnell.readSessionCookie(req, "sid") orelse {
    res.status = .unauthorized;
    return;
};

The default backend is in-memory. For persistence across restarts, use schnell.SystemDbSessionBackend(AppData), which writes the sessions to Planck's system catalog. The SessionBackend(AppData) interface is small enough that you can plug in your own as well.


Schema validation

Define a schema once at comptime, validate the parsed bodies against it, and return structured errors:

zig
const CreateUser = schnell.Schema(&.{
    .{ "name",  .{ .field_type = .string, .required = true, .min_length = 1, .max_length = 80 } },
    .{ "email", .{ .field_type = .string, .required = true, .pattern = "[^@]+@[^@]+" } },
    .{ "age",   .{ .field_type = .int,    .required = false, .min = 0, .max = 130 } },
    .{ "role",  .{ .field_type = .string, .enum_values = &.{ "admin", "user", "guest" } } },
});

fn createUser(_: ?*anyopaque, allocator: std.mem.Allocator, req: *const Request, res: *Response) !void {
    const body = try req.getBody(allocator, CreateUserBody);
    if (CreateUser.validate(&body)) |err| {
        res.status = .bad_request;
        return res.json(try err.toJson(allocator));
    }
    // ... insert ...
}

Field types: string, int, int32, double, float, boolean, date, uuid, timestamp, object_id, array, object, binary, decimal128.

Rules: required, min, max, min_length, max_length, pattern, enum_values.


ZSX templates

ZSX is the template engine. You write a .zsx file (a JSX-flavored Zig DSL), planctl compiles it to a .zig fragment at build time, and the runtime cost is zero parsing and no per-tag allocation. The compiled fragment is a struct with a render method that appends HTML to an ArrayList(u8).

A handler that returns a rendered fragment looks like this:

zig
const std = @import("std");
const web = @import("web");
const Request = web.Request;
const Response = web.Response;

const ProductList = @import("../fragments/product_list.zig").ProductList;

pub fn handle(ctx_ptr: ?*anyopaque, allocator: std.mem.Allocator, req: *const Request, res: *Response) !void {
    const ctx: *Ctx = @ptrCast(@alignCast(ctx_ptr orelse return error.NoContext));

    const products = try loadProducts(ctx.client, allocator, req);

    var out: std.ArrayList(u8) = .empty;
    try ProductList.render(.{ .products = products }, &out, allocator);
    try res.html(out.items);
}

The render call always takes the same three arguments, in this order: the args struct, a pointer to the output ArrayList(u8), and the per-request allocator. The handler then hands out.items to res.html.

For the full ZSX syntax (control flow, partials, slot expressions, escape rules, build wiring) see the planctl User Manual. The reference implementations in the samples repo are the easiest way to get a feel for it.


Querying Planck from a handler

For WASM services (and native shells that talk to Planck) the query builder lives in the planck crate:

zig
const planck = @import("planck");

var q = planck.Query.initWithAllocator(ctx.client, allocator);
defer q.deinit();

var resp = try q.store("orders")
    .where("Status", .eq, .{ .string = "open" })
    .where("StoreID", .eq, .{ .int = 3 })
    .orderBy("CreatedAt", .desc)
    .skip(0)
    .limit(50)
    .select(&.{ "_id", "Items", "Total" })
    .run();
defer resp.deinit();

const orders = try resp.decode(allocator, Order);
defer allocator.free(orders);

The builder is fluent. Chain .where, .orderBy, .limit, .skip, .select in any order, and call .run() last. Substring search is not part of the builder. Filter in code on the decoded slice if you need it (the samples do exactly this for product name search).


HTTP client

Outbound HTTP/HTTPS for service-to-service calls:

zig
var resp = try schnell.Client.request(allocator, io, .{
    .method = .post,
    .url = "http://127.0.0.1:3008/api/orders",
    .headers = &.{ .{ "Content-Type", "application/json" } },
    .body = "{\"item\":\"pizza\"}",
    .timeout_ms = 5000,
});
defer resp.deinit();

if (resp.status == 200) {
    // resp.body
}

For retries:

zig
var resp = try schnell.Client.requestWithRetry(allocator, io,
    .{ .url = "http://flaky/api" },
    .{ .max_attempts = 3, .retry_on_5xx = true },
);

Inside a handler, use the fiber-local Io:

zig
fn handle(_: ?*anyopaque, allocator: std.mem.Allocator, req: *const Request, _: *Response) !void {
    const io = req.io orelse return error.NoIo;
    var resp = try schnell.Client.request(allocator, io, .{ .url = "http://upstream/api" });
    defer resp.deinit();
    // ...
}

From a WASM service, web.callService(...) routes through the host's upstream pool (so the circuit breakers, retries, and timeouts all come from the Planck side). Use that wherever you can.


SSE fan-out with ssehub

When you want to push live updates to browsers (datastar patches, dashboard refreshes, kitchen tickets), the right tool is ssehub, a separate crate in the same org.

ssehub sits in its own service process and does three things:

  1. Subscribes to a Planck change stream over a single TCP connection (WatchClient).
  2. Decodes each change and republishes it onto an in-process EventBus keyed by topic.
  3. Holds N browser EventSource connections open, each subscribed to one or more topics, with bounded per-subscriber queues, heartbeats, and replay rings for Last-Event-ID reconnect.

This is browser fan-out. It is not a service-to-service message bus. Services do not talk to each other through ssehub; they talk to Planck, and ssehub broadcasts the resulting changes out to the connected browsers.

Minimal ssehub service

zig
const std = @import("std");
const schnell = @import("schnell");
const ssehub = @import("ssehub");

const Ctx = struct {
    bus: *ssehub.EventBus,
    io: std.Io,
};

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    const allocator = gpa.allocator();

    var threaded: std.Io.Threaded = .init(allocator, .{ .async_limit = .unlimited });
    defer threaded.deinit();
    const io = threaded.io();

    // 1. Bus.
    var bus = ssehub.EventBus.init(allocator, io, .{
        .heartbeat_interval_ms = 15_000,
        .max_subscribers_total = 4000,
        .retry_ms = 3000,
    });
    defer bus.deinit();
    try bus.registerTopic("kitchen",  .{ .replay_buffer_size = 50 });
    try bus.registerTopic("delivery", .{ .replay_buffer_size = 50 });
    try bus.start();

    // 2. Watch Planck. One TCP socket, one fiber.
    var ctx = Ctx{ .bus = &bus, .io = io };
    const watch = try ssehub.WatchClient.init(allocator, io,
        "127.0.0.1:24010;uid=admin;key=...;tls=true",
        &.{ "orders" },
    );
    defer watch.deinit();
    watch.onFrame(processFrame, &ctx);
    try watch.start();

    // 3. HTTP server with one streaming route per topic.
    var app = try schnell.App.init(allocator, .{ .host = "127.0.0.1", .port = 3030 });
    defer app.deinit();
    try app.routeStreaming(.get, "/events/kitchen",  kitchenStream,  &ctx);
    try app.routeStreaming(.get, "/events/delivery", deliveryStream, &ctx);

    try app.run(io);
}

fn processFrame(frame: ssehub.ChangeRecord, allocator: std.mem.Allocator, ctx_ptr: ?*anyopaque) anyerror!void {
    const c: *Ctx = @ptrCast(@alignCast(ctx_ptr.?));
    // Decode the change, render an HTML fragment, publish to whichever
    // topics care. The samples in `pizzaqsr-hda-mono/sse/` show the
    // full pattern (per-order topics, server-side mirror, datastar
    // wire format).
    _ = try c.bus.publish("kitchen", .{
        .event = "datastar-patch-elements",
        .data  = "elements <div id=\"order-42\">...</div>",
    });
    _ = allocator;
    _ = frame;
}

Streaming a topic to a browser

A streaming handler subscribes to a topic, and then blocks on the subscriber writer. The subscriber drains the bus's per-subscriber queue and writes the SSE frames to the socket. When the browser disconnects, the write fails and the subscriber returns.

zig
fn kitchenStream(ctx_ptr: ?*anyopaque, allocator: std.mem.Allocator, req: *const schnell.Request, w: *std.Io.Writer) !void {
    const c: *Ctx = @ptrCast(@alignCast(ctx_ptr.?));

    try ssehub.wire.writeHeaders(w);
    try ssehub.wire.writeRetry(w, c.bus.config.retry_ms);
    try w.flush();

    const last_event_id = parseLastEventId(req.getHeader("Last-Event-ID"));

    const sub = try ssehub.Subscriber.init(allocator, w, c.io, ssehub.DEFAULT_QUEUE_SIZE);
    defer sub.deinit();

    try c.bus.subscribe("kitchen", sub, .{ .last_event_id = last_event_id });
    defer c.bus.unsubscribe("kitchen", sub);

    sub.runWriter();   // blocks until disconnect or topic deletion
}

The browser side is just the standard EventSource API:

javascript
const es = new EventSource("/events/kitchen");
es.addEventListener("datastar-patch-elements", function (ev) {
  // ev.data is the patch payload
});

When the connection drops, the browser auto-reconnects with Last-Event-ID, and the bus walks its per-topic replay ring from last_event_id + 1 before the live events resume.

Topic configuration

zig
try bus.registerTopic("kitchen", .{
    .max_subscribers = 1000,
    .replay_buffer_size = 100,
    .drop_strategy = .drop_newest, // discard incoming when queue full (default)
});
try bus.registerTopic("orders", .{
    .drop_strategy = .disconnect,  // boot the subscriber; EventSource reconnects + replays
});

.drop_newest is fine for dashboards (a lossy refresh does no harm there). .disconnect is the better choice for streams where the browser must eventually catch up. Combined with replay, the disconnect + reconnect cycle makes the missed events visible to the client.

Filter predicates

When one topic carries events for many distinct entities (every order goes through "orders", but each browser tab only cares about one), pass a filter:

zig
fn forOrder(event: *const ssehub.SseEvent, ctx: ?*anyopaque) bool {
    const order_key: []const u8 = @as(*const []const u8, @ptrCast(@alignCast(ctx.?))).*;
    return std.mem.indexOf(u8, event.data, order_key) != null;
}

try bus.subscribe("orders", sub, .{
    .filter = forOrder,
    .filter_ctx = &my_order_key,
});

The alternative is per-entity topics ("order:abc"). That means more topic bookkeeping, but cheaper dispatch. Pick based on your subscriber-to-event ratio.

Multiple Planck upstreams

WatchClient is one-conn / one-fiber by design. For micro-style apps where related data lives across N Planck DBs, spawn N WatchClient instances in main. Each runs its own TCP socket and its own watch fiber. They can share a single bus + context, or split it.

zig
const w_orders = try ssehub.WatchClient.init(allocator, io,
    "127.0.0.1:24102;uid=admin;key=...;tls=true", &.{ "orders" });
defer w_orders.deinit();
w_orders.onFrame(processFrame, &ctx);
try w_orders.start();

const w_inventory = try ssehub.WatchClient.init(allocator, io,
    "127.0.0.1:24104;uid=admin;key=...;tls=true", &.{ "products" });
defer w_inventory.deinit();
w_inventory.onFrame(processFrame, &ctx);
try w_inventory.start();

processFrame distinguishes which store the change came from via frame.store_ns. One Planck going down only stalls its own WatchClient; the others keep flowing.

What ssehub is not

  • It is not a cross-service message bus. Service A does not publish events for service B to consume.
  • It is not durable. The replay ring is bounded and in-memory; a process crash loses anything not yet delivered.
  • It is not cross-region. One process fans out to one set of browsers.

For all three of those, reach for a real broker. The bus interface stays the same, so the swap is mechanical when you genuinely need it.

For the full ssehub story (metrics, drop strategies, scaling out by partitioning rather than scaling up) see the ssehub README.


HTTPS / TLS

Mono apps default to TLS 1.3 with a self-signed certificate. The shell binds an HTTPS listener on the first run, generates a key + cert pair, and serves the SPA bundle plus the JSON API over TLS. The browser shows a "not trusted" warning the first time, which you accept once during local dev; in production you wire your own cert.

Two escape hatches:

  • Bring your own cert. Point the shell at a key + cert pair on disk via the tls: section of app.yaml. schnell loads it through tls.config.CertKeyPair.
  • Disable TLS. Set the shell's tls.enabled to false when something upstream is already terminating TLS (Caddy, nginx, Traefik). In that case the shell listens on plain HTTP on a loopback port and the proxy fronts it.

The default exists because "works over HTTPS out of the box" is a much better starting point than "works over HTTP and you will fix it later." Self-signed is fine for local; you only swap to a real cert when you are ready.


Static files

zig
var app = try schnell.App.init(allocator, .{
    .static_dir = "public",
});

Files are loaded into memory at startup. Requests that do not match any route fall back to static serving. MIME types come from the file extension. In case the directory does not exist, static serving is silently disabled (a warning is logged).


Health probes

zig
try app.healthz("/healthz");   // returns {"status":"ok"}
try app.readyz("/readyz");     // same default; override with your own handler for real readiness checks

Configuration

schnell ships a schema-driven YAML config loader. Your Zig struct is itself the schema. Unknown sections and keys produce hard errors with line numbers, so you cannot silently misspell a key and then watch your app fall back to a default.

We do not use environment variable overrides. Config comes from YAML files. The loader supports multiple paths (try ./config.yaml first, then ~/.myapp/config.yaml, then defaults) so that the same binary works in dev and prod by swapping the file, not by setting env vars.

Mandatory server: section

Every schnell app has a top-level server: section. Type it as schnell.ServerConfig:

zig
const AppConfig = struct {
    server: schnell.ServerConfig = .{},
};
yaml
server:
  host: "0.0.0.0"
  port: 8080
  static_dir: "public"
  max_connections: 50000
  max_header_size: 8192
  max_body_size: 4194304
  idle_timeout_ms: 60000
  max_requests_per_connection: 10000
  drain_timeout_ms: 10000
  response_buffer_size: 65536

Every field has a sensible default. A minimal config is just port and static_dir.

Custom sections

Add whatever your app needs. Use ?struct = null for optional sections so that they are skipped silently if absent:

zig
const AppConfig = struct {
    server: schnell.ServerConfig = .{},

    stripe: ?struct {
        secret_key: []const u8 = "",
        publishable_key: []const u8 = "",
        webhook_secret: []const u8 = "",
    } = null,

    features: ?struct {
        enable_beta: bool = false,
        max_upload_mb: u32 = 10,
    } = null,
};

Loading

zig
const config = try schnell.Config.load(AppConfig, allocator, io, .{
    .paths = &.{
        "./config.yaml",
        "~/.myapp/config.yaml",
    },
});

var app = try schnell.App.init(allocator, config.server);

if (config.stripe) |stripe| {
    var provider = schnell.pay.StripeProvider.init(allocator, .{
        .secret_key = stripe.secret_key,
    });
    _ = provider;
}

WASM services and config

WASM services do not get a top-level server: block. The HTTP server settings live inside the wasm: section of the Planck service config:

yaml
# Planck service config.yaml
name: "kitchen"
address: "0.0.0.0"
port: 24010
primary: true

wasm:
  enabled: true
  min_instances: 2
  max_instances: 8
  autoscale: true
  http:
    host: "0.0.0.0"
    port: 3010
    max_connections: 10000
    max_header_size: 8192
    max_body_size: 1048576
    response_buffer_size: 65536
    idle_timeout_ms: 30000
    max_requests_per_connection: 10000
    drain_timeout_ms: 5000

The Planck host reads config.wasm.http and uses it to spin up the listener that fronts the WASM module.

Shell App (schnell.App)WASM Service (web.WasmApp)
Config locationTop-level server:Nested wasm.http: in Planck service config
Who owns itThe app's config.yamlThe service's config.yaml
PortSet in YAMLSet in YAML (wasm.http.port); deploy validator refuses 0
Zig initApp.init(allocator, config.server)WasmApp.init(allocator, .{ ... })

Supported field types

Zig typeYAMLNotes
[]const u8key: "value"Quotes optional
?[]const u8key: "value"null if absent
u16, u32, u64port: 8080Decimal
i32, i64offset: -100Signed
boolenabled: truetrue / 1 is true, otherwise false
f64ratio: 0.75Floating point

Test client

For handler-level tests without a real socket:

zig
test "GET /users/:id returns 200" {
    var ctx = Ctx{ /* ... */ };
    var app = try schnell.App.init(testing.allocator, .{ .host = "127.0.0.1", .port = 0 });
    defer app.deinit();
    try app.get("/users/:id", getUser, &ctx);

    var client = schnell.TestClient.init(testing.allocator, &app);
    const resp = try client.request(.{ .method = .get, .path = "/users/42" });
    defer resp.deinit();

    try testing.expectEqual(@as(u16, 200), resp.status);
}

TestClient.request dispatches in-process. No socket, no fibers. Fast enough that you can run thousands of them per test.


Metrics

Optional and off by default. Plug a Metrics sink into App and the framework emits counters and histograms for request count, latency, response size, and error count.

zig
const MyMetrics = struct {
    pub fn counter(self: *MyMetrics, name: []const u8, n: u64, labels: []const schnell.MetricsLabel) void { /* ... */ }
    pub fn gauge(self: *MyMetrics, name: []const u8, value: i64, labels: []const schnell.MetricsLabel) void { /* ... */ }
    pub fn histogram(self: *MyMetrics, name: []const u8, value: f64, labels: []const schnell.MetricsLabel) void { /* ... */ }
};

var my_metrics = MyMetrics{};
const metrics = schnell.Metrics.from(MyMetrics, &my_metrics);

var app = try schnell.App.init(allocator, .{
    .host = "127.0.0.1",
    .port = 3000,
    .metrics = metrics,
});

For tests, schnell.RecordingMetricsSink captures everything in memory so that you can assert on the emitted metrics.

SystemDbMetricsSink writes the counters and histograms to Planck's system catalog, which is convenient for a single-binary app that wants its own metrics queryable through the same database it is already using.


URL utilities

zig
const decoded = schnell.Url.decode("hello%20world");                        // in-place
const decoded2 = try schnell.Url.decodeAlloc(allocator, "a%2Fb");           // new buffer

const enc_form = try schnell.Url.encode(allocator, "hello world", .form);   // "hello+world"
const enc_path = try schnell.Url.encode(allocator, "hello world", .path);   // "hello%20world"

Multipart form parsing

zig
const content_type = req.getHeader("Content-Type") orelse return error.BadRequest;
var parser = schnell.Multipart.init(content_type, req.body) orelse return error.BadRequest;
var it = parser.iterator();

while (it.next()) |part| {
    if (std.mem.eql(u8, part.name, "file")) {
        // part.filename, part.content_type, part.data
    }
}

CSRF state store

For OAuth callback CSRF protection (storing the state parameter you sent to the IdP and then checking that the same value comes back):

zig
var states = schnell.StateStore.init(allocator);
defer states.deinit();

// Generate before redirect:
const state = states.generate(io);

// Validate on callback (single-use, auto-consumed):
if (states.validateAndConsume(io, state_param)) {
    // good, proceed with code exchange
} else {
    // invalid or expired, reject
}

The default TTL is 5 minutes.


Providers

Optional vendor adapters. Each one is a thin layer over the vendor's API; the underlying interface (AuthProvider, PaymentProvider, NotificationProvider) sits in schnell core, while the concrete implementations live in the namespaced submodules. You only import the ones you actually use.

zig
// Google OAuth
var google = schnell.auth.GoogleAuthProvider.init(allocator, .{
    .client_id = cfg.google.client_id,
    .client_secret = cfg.google.client_secret,
    .redirect_uri = cfg.google.redirect_uri,
});
const auth_provider = google.authProvider();

// Stripe payments
var stripe = schnell.pay.StripeProvider.init(allocator, .{
    .secret_key = cfg.stripe.secret_key,
});
const payment = stripe.paymentProvider();

// SendGrid notifications
var sg = schnell.notify.SendGridProvider.init(allocator, .{
    .api_key = cfg.sendgrid.api_key,
});
const notifier = sg.notificationProvider();

To roll your own, implement the vtable directly. Each *Provider factory is short enough to read end-to-end.


Host externs (WASM only)

Declared in the service's entry point. schnell wires them up automatically.

SymbolSignaturePurpose
host_respond(ptr: [*]const u8, len: u32) voidHand response bytes back to the host.

SSE used to be hosted inside the WASM runtime via the host_sse_publish / host_register_sse_endpoint externs. That was retired when SSE moved out into ssehub. Events now flow Planck change stream -> ssehub -> browsers. WASM services do not publish SSE; they write to Planck, and ssehub picks it up.


Architecture, in one line

The platform, end to end: HTTP listener · WASM runtime · change streams.

  • The HTTP listener is schnell (whether you are running it native as a shell, or letting Planck run it in front of a WASM module).
  • The WASM runtime is Planck's, hosting your service .wasm.
  • Change streams are Planck's record of every write, which is what ssehub watches and fans out.

Everything in this doc plugs into one of those three pieces. Operational tasks (backup, gc, WAL truncate, stats, export, import, restore) live in workbench, the control plane, and are driven from there against each connected planck.


Dependency wiring

Before @import("schnell") works in your code, Zig needs to know which *std.Build.Module to give you. The naive way is b.dependency("schnell", .{}).module("schnell"). On deep transitive graphs that produces multiple module instances of the same logical package (you will see bson vs bson0, utils vs utils0, and the types from those modules stop matching across boundaries).

The durable pattern: use b.dependency("name", .{}).path("src/root.zig") to get a builder-resolved path, then call b.createModule(...) yourself. One module instance per logical dep, wired explicitly. A small Deps struct + wireDeps() helper at the bottom of build.zig keeps every artifact (exe, tests, release builds) sharing the same instances.

build.zig.zon

zig
.{
    .name = .myapp,
    .version = "0.1.0",
    .fingerprint = 0x...,
    .minimum_zig_version = "0.16.0",
    .dependencies = .{
        .schnell = .{
            .url = "https://github.com/planckapps/schnell/archive/refs/tags/v0.1.0.tar.gz",
            .hash = "schnell-0.1.0-...",
        },
        .bson  = .{ .url = "...", .hash = "bson-0.1.0-..."  },
        .utils = .{ .url = "...", .hash = "utils-0.1.0-..." },
        .tls   = .{ .url = "...", .hash = "tls-0.1.0-..."   },
        .proto = .{ .url = "...", .hash = "proto-0.1.0-..." },
        .planck_zig_client = .{ .url = "...", .hash = "planck_zig_client-0.3.0-..." },
    },
    .paths = .{ "build.zig", "build.zig.zon", "src" },
}

build.zig

zig
const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const deps = wireDeps(b, target, optimize);

    const exe_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    exe_mod.addImport("schnell", deps.schnell);
    exe_mod.addImport("bson",  deps.bson);

    const exe = b.addExecutable(.{ .name = "app", .root_module = exe_mod });
    b.installArtifact(exe);

    const test_mod = b.createModule(.{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });
    test_mod.addImport("schnell", deps.schnell);
    test_mod.addImport("bson",  deps.bson);
    const tests = b.addTest(.{ .root_module = test_mod });
    b.step("test", "Run tests").dependOn(&b.addRunArtifact(tests).step);
}

const Deps = struct {
    bson: *std.Build.Module,
    utils: *std.Build.Module,
    tls: *std.Build.Module,
    proto: *std.Build.Module,
    planck_zig_client: *std.Build.Module,
    schnell: *std.Build.Module,
};

fn wireDeps(b: *std.Build, target: anytype, optimize: anytype) Deps {
    const bson = b.createModule(.{
        .root_source_file = b.dependency("bson", .{}).path("src/root.zig"),
        .target = target, .optimize = optimize,
    });
    const utils = b.createModule(.{
        .root_source_file = b.dependency("utils", .{}).path("src/root.zig"),
        .target = target, .optimize = optimize,
    });
    const tls = b.createModule(.{
        .root_source_file = b.dependency("tls", .{}).path("src/root.zig"),
        .target = target, .optimize = optimize,
    });

    const proto = b.createModule(.{
        .root_source_file = b.dependency("proto", .{}).path("src/root.zig"),
        .target = target, .optimize = optimize,
    });
    proto.addImport("utils", utils);

    const planck_zig_client = b.createModule(.{
        .root_source_file = b.dependency("planck_zig_client", .{}).path("src/root.zig"),
        .target = target, .optimize = optimize,
    });
    planck_zig_client.addImport("tls",   tls);
    planck_zig_client.addImport("bson",  bson);
    planck_zig_client.addImport("utils", utils);
    planck_zig_client.addImport("proto", proto);

    const schnell = b.createModule(.{
        .root_source_file = b.dependency("schnell", .{}).path("src/root.zig"),
        .target = target, .optimize = optimize,
    });
    schnell.addImport("bson",              bson);
    schnell.addImport("utils",             utils);
    schnell.addImport("tls",               tls);
    schnell.addImport("proto",             proto);
    schnell.addImport("planck_zig_client", planck_zig_client);

    return .{
        .bson = bson, .utils = utils, .tls = tls, .proto = proto,
        .planck_zig_client = planck_zig_client, .schnell = schnell,
    };
}

Rules:

  1. Use dep.path("src/root.zig"), not dep.module(...). Calling .module() returns a module that the dep's own build.zig built, which is exactly where the duplication comes from.
  2. Declare each module after every module it imports.
  3. One createModule per logical dep. Share the same binding across every consumer.
  4. addImport(name, mod) controls the string consumers use in @import(name). In case the package is published as planck_zig_client but downstream code wants to @import("planck"), do addImport("planck", planck_zig_client).
  5. Call wireDeps(b, target, optimize) once per build configuration and pass the resulting deps struct everywhere.

Troubleshooting

  • error: dependency '<name>' not found. The name in b.dependency("<name>", .{}) must exactly match a key in build.zig.zon's .dependencies.
  • @import("foo") fails to resolve inside a dep's source. The dep's transitive addImport is missing. Look at its build.zig.zon; every entry in .dependencies needs a matching addImport in wireDeps.
  • Duplicated module disambiguation (bson vs bson0). Somewhere b.dependency("bson", .{}).module("bson") is being called. Search for .module( in build.zig and replace it with the dep.path(...) + b.createModule(...) pattern.
  • Hash mismatch on first run. Re-run zig fetch --save=<name> <url> and replace the hash in build.zig.zon.

Key types reference

TypeImportPurpose
Appschnell.AppNative HTTP application.
Serverschnell.ServerLow-level HTTP server (rarely used directly).
ServerConfigschnell.ServerConfigServer config struct; goes in your YAML config.
Requestschnell.RequestParsed HTTP request.
Responseschnell.ResponseHTTP response builder. html, json, write.
Routerschnell.RouterPath-based request router.
Middlewareschnell.MiddlewareMiddleware vtable.
CorsMiddlewareschnell.CorsMiddlewareCORS headers.
RateLimitMiddlewareschnell.RateLimitMiddlewareRate limiting.
RequestIdMiddlewareschnell.RequestIdMiddlewareX-Request-Id generation.
CsrfMiddlewareschnell.CsrfMiddlewareCSRF protection.
SessionStore(T)schnell.SessionStoreGeneric session store.
SystemDbSessionBackendschnell.SystemDbSessionBackendPersistent session backend on Planck's system catalog.
StateStoreschnell.StateStoreCSRF state tokens for OAuth.
Clientschnell.ClientOutbound HTTP client.
Configschnell.ConfigYAML config loader.
Schemaschnell.SchemaField validation.
Metricsschnell.MetricsMetrics vtable.
TestClientschnell.TestClientIn-process test client.
Methodschnell.MethodHTTP method enum.
Statusschnell.StatusHTTP status enum.
Urlschnell.UrlURL encode/decode.
Multipartschnell.MultipartMultipart form parser.
WasmAppweb.WasmAppWASM application. Same routing surface as App.
WasmRequestweb.RequestRequest decoded from the host frame.
WasmResponseweb.ResponseResponse builder for WASM; toBytes serializes for host_respond.
TokenAuthMiddlewareweb.TokenAuthMiddlewareBearer / token auth gate (WASM side).
JwtAuthMiddlewareweb.JwtAuthMiddlewareJWT auth gate (WASM-friendly).
auth.GoogleAuthProviderschnell.auth.GoogleAuthProviderGoogle OAuth implementation of AuthProvider.
pay.StripeProviderschnell.pay.StripeProviderStripe implementation of PaymentProvider.
notify.SendGridProviderschnell.notify.SendGridProviderSendGrid implementation of NotificationProvider.
ssehub.EventBusssehub.EventBusTopic registry + publish + replay + heartbeat.
ssehub.WatchClientssehub.WatchClientOne fiber polling a Planck change stream.
ssehub.Subscriberssehub.SubscriberBounded queue + writer for one browser EventSource.