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.Appbinds 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 theschnell.websubmodule) 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 ahost_respondextern.
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:
const schnell = @import("schnell");
const web = @import("web"); // schnell.web submodule, needed for WasmApp + JWTQuick start
Native app
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
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:
fn handle(ctx_ptr: ?*anyopaque, allocator: std.mem.Allocator, req: *const Request, res: *Response) !voidThe four arguments:
ctx_ptris whatever pointer you passed toapp.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.allocatoris a per-request arena. Everything you allocate during the handler is freed once the response is sent. Do not stash pointers from it.reqis the parsed request (read-only).resis the response builder. Set the status, headers, and body, then return.
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
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:
| Method | Returns | Purpose |
|---|---|---|
getHeader(name) | ?[]const u8 | Case-insensitive header lookup. |
getQuery(name) | ?[]const u8 | Single value from the URL query string. |
getParams(T) | T | Deserialize the whole query string into a struct by field name. |
getCookie(name) | ?[]const u8 | Cookie value from the Cookie header. |
getLocal(key) | ?[]const u8 | Read a value set by middleware (e.g. authenticated user id). |
setLocal(key, value) | !void | Write a value middleware or routing left for the handler. |
getBody(allocator, T) | !T | Parse the body as JSON or form-urlencoded into T. |
getFormParam(name) | ?[]const u8 | Read a single form field (form-encoded or multipart). |
getMultipartField(name) | ?Part | Multipart upload field, including filename + content type. |
contentLength() | !?usize | Parse 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
pub const Response = struct {
status: Status = .ok,
headers: ArrayList(Header),
body: ArrayList(u8),
};| Method | Purpose |
|---|---|
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:
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.
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:
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
// 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.
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:
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:
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:
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:
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:
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:
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:
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:
- Subscribes to a Planck change stream over a single TCP connection (
WatchClient). - Decodes each change and republishes it onto an in-process
EventBuskeyed by topic. - Holds N browser
EventSourceconnections open, each subscribed to one or more topics, with bounded per-subscriber queues, heartbeats, and replay rings forLast-Event-IDreconnect.
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
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.
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:
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
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:
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.
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 ofapp.yaml. schnell loads it throughtls.config.CertKeyPair. - Disable TLS. Set the shell's
tls.enabledtofalsewhen 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
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
try app.healthz("/healthz"); // returns {"status":"ok"}
try app.readyz("/readyz"); // same default; override with your own handler for real readiness checksConfiguration
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:
const AppConfig = struct {
server: schnell.ServerConfig = .{},
};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: 65536Every 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:
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
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:
# 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: 5000The 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 location | Top-level server: | Nested wasm.http: in Planck service config |
| Who owns it | The app's config.yaml | The service's config.yaml |
| Port | Set in YAML | Set in YAML (wasm.http.port); deploy validator refuses 0 |
| Zig init | App.init(allocator, config.server) | WasmApp.init(allocator, .{ ... }) |
Supported field types
| Zig type | YAML | Notes |
|---|---|---|
[]const u8 | key: "value" | Quotes optional |
?[]const u8 | key: "value" | null if absent |
u16, u32, u64 | port: 8080 | Decimal |
i32, i64 | offset: -100 | Signed |
bool | enabled: true | true / 1 is true, otherwise false |
f64 | ratio: 0.75 | Floating point |
Test client
For handler-level tests without a real socket:
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.
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
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
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):
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.
// 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.
| Symbol | Signature | Purpose |
|---|---|---|
host_respond | (ptr: [*]const u8, len: u32) void | Hand 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
.{
.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
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:
- Use
dep.path("src/root.zig"), notdep.module(...). Calling.module()returns a module that the dep's ownbuild.zigbuilt, which is exactly where the duplication comes from. - Declare each module after every module it imports.
- One
createModuleper logical dep. Share the same binding across every consumer. addImport(name, mod)controls the string consumers use in@import(name). In case the package is published asplanck_zig_clientbut downstream code wants to@import("planck"), doaddImport("planck", planck_zig_client).- Call
wireDeps(b, target, optimize)once per build configuration and pass the resultingdepsstruct everywhere.
Troubleshooting
error: dependency '<name>' not found. The name inb.dependency("<name>", .{})must exactly match a key inbuild.zig.zon's.dependencies.@import("foo")fails to resolve inside a dep's source. The dep's transitiveaddImportis missing. Look at itsbuild.zig.zon; every entry in.dependenciesneeds a matchingaddImportinwireDeps.- Duplicated module disambiguation (
bsonvsbson0). Somewhereb.dependency("bson", .{}).module("bson")is being called. Search for.module(inbuild.zigand replace it with thedep.path(...)+b.createModule(...)pattern. - Hash mismatch on first run. Re-run
zig fetch --save=<name> <url>and replace the hash inbuild.zig.zon.
Key types reference
| Type | Import | Purpose |
|---|---|---|
App | schnell.App | Native HTTP application. |
Server | schnell.Server | Low-level HTTP server (rarely used directly). |
ServerConfig | schnell.ServerConfig | Server config struct; goes in your YAML config. |
Request | schnell.Request | Parsed HTTP request. |
Response | schnell.Response | HTTP response builder. html, json, write. |
Router | schnell.Router | Path-based request router. |
Middleware | schnell.Middleware | Middleware vtable. |
CorsMiddleware | schnell.CorsMiddleware | CORS headers. |
RateLimitMiddleware | schnell.RateLimitMiddleware | Rate limiting. |
RequestIdMiddleware | schnell.RequestIdMiddleware | X-Request-Id generation. |
CsrfMiddleware | schnell.CsrfMiddleware | CSRF protection. |
SessionStore(T) | schnell.SessionStore | Generic session store. |
SystemDbSessionBackend | schnell.SystemDbSessionBackend | Persistent session backend on Planck's system catalog. |
StateStore | schnell.StateStore | CSRF state tokens for OAuth. |
Client | schnell.Client | Outbound HTTP client. |
Config | schnell.Config | YAML config loader. |
Schema | schnell.Schema | Field validation. |
Metrics | schnell.Metrics | Metrics vtable. |
TestClient | schnell.TestClient | In-process test client. |
Method | schnell.Method | HTTP method enum. |
Status | schnell.Status | HTTP status enum. |
Url | schnell.Url | URL encode/decode. |
Multipart | schnell.Multipart | Multipart form parser. |
WasmApp | web.WasmApp | WASM application. Same routing surface as App. |
WasmRequest | web.Request | Request decoded from the host frame. |
WasmResponse | web.Response | Response builder for WASM; toBytes serializes for host_respond. |
TokenAuthMiddleware | web.TokenAuthMiddleware | Bearer / token auth gate (WASM side). |
JwtAuthMiddleware | web.JwtAuthMiddleware | JWT auth gate (WASM-friendly). |
auth.GoogleAuthProvider | schnell.auth.GoogleAuthProvider | Google OAuth implementation of AuthProvider. |
pay.StripeProvider | schnell.pay.StripeProvider | Stripe implementation of PaymentProvider. |
notify.SendGridProvider | schnell.notify.SendGridProvider | SendGrid implementation of NotificationProvider. |
ssehub.EventBus | ssehub.EventBus | Topic registry + publish + replay + heartbeat. |
ssehub.WatchClient | ssehub.WatchClient | One fiber polling a Planck change stream. |
ssehub.Subscriber | ssehub.Subscriber | Bounded queue + writer for one browser EventSource. |