Tokamak is a server-side framework for Zig, built around http.zig and a simple dependency injection container.
Note, that it is not designed to be used alone, but with a reverse proxy in front of it, like Nginx or Cloudfront, which will handle SSL, caching, sanitization, etc.
- Switched to http.zig for improved performance over
std.http
.- Implemented hierarchical and introspectable routes.
- Added basic Swagger support.
- Added
tk.static.dir()
for serving entire directories.
Simple things should be easy to do.
const tk = @import("tokamak");
const routes = []const tk.Route = &.{
.get("/", hello),
};
fn hello() ![]const u8 {
return "Hello";
}
pub fn main() !void {
const server = try tk.Server.start(allocator, routes, .{ .port = 8080 });
try server.start();
}
The framework is built around the concept of dependency injection. This means that your handler function can take any number of parameters, and the framework will try to provide them for you.
Notable types you can inject are:
std.mem.Allocator
(request-scoped arena allocator)*tk.Request
(current request, including headers, body reader, etc.)*tk.Response
(current response, with methods to send data, set headers, etc.)tk.Injector
(the injector itself, see below)- and everything you provide yourself
For example, you can easily write a handler function which will create a string on the fly and return it to the client without any tight coupling to the server or the request/response types.
fn hello(allocator: std.mem.Allocator) ![]const u8 {
return std.fmt.allocPrint(allocator, "Hello {}", .{std.time.timestamp()});
}
If you return any other type than []const u8
, the framework will try to
serialize it to JSON.
fn hello() !HelloRes {
return .{ .message = "Hello" };
}
If you need a more fine-grained control over the response, you can inject a
*tk.Response
and use its methods directly.
But this will of course make your code tightly coupled to respective types and it should be avoided if possible.
fn hello(res: *tk.Response) !void {
try res.json(.{ .message = "Hello" }, .{});
}
You can also provide your own (global) dependencies by passing your own
tk.Injector
to the server.
pub fn main() !void {
var db = try sqlite.open("my.db");
var cx = .{ &db };
const server = try tk.Server.init(allocator, routes, .{
.injector = tk.Injector.init(&cx, null),
.port = 8080
});
try server.start();
}
We don't have 1:1 middleware support like in Express.js, but given that our
routes can be nested and that the prefix
, path
and method
fields are
optional, you can easily achieve the same effect.
For example, here's a simple function which will return a logger route:
fn logger(children: []const Route) tk.Route {
const H = struct {
fn handleLogger(ctx: *Context) anyerror!void {
log.debug("{s} {s}", .{ @tagName(ctx.req.method), ctx.req.url });
return ctx.next();
}
};
return .{ .handler = &H.handleLogger, .children = children };
}
const routes = []const tk.Route = &.{
logger(&.{
.get("/", hello),
}),
};
As you can see, the handler takes a *Context
and returns anyerror!void
.
It can do some pre-processing, logging, etc., and then call ctx.next()
to
continue with the next handler in the chain.
Zig doesn't have closures, so we can't just capture variables from the outer scope. But what we can do is to use our dependency injection context to provide some dependencies to any middleware or handler function further in the chain.
Middlewares do not support the shorthand syntax for dependency injection, so you need to use
ctx.injector.get(T)
to get your dependencies manually.
fn auth(ctx: *Context) anyerror!void {
const db = ctx.injector.get(*Db);
const token = try jwt.parse(ctx.req.getHeader("Authorization"));
const user = db.find(User, token.id) catch null;
return ctx.nextScoped(&.{ user });
}
There's a simple router built in, in the spirit of Express.js. It supports
up to 16 basic path params, and *
wildcard. The example below shows how deps
and params will be passed to the handler function.
const tk = @import("tokamak");
const routes: []const tk.Route = &.{
.get("/", hello), // fn(...deps)
.get("/hello/:name", helloName), // fn(...deps, name)
.get("/hello/:name/:age", helloNameAge), // fn(...deps, name, age)
.get("/hello/*", helloWildcard), // fn(...deps)
.post("/hello", helloPost), // fn(...deps, body)
.post0("/hello", helloPost0), // fn(...deps)
...
};
There's also Route.router(T)
method, which accepts special DSL-like struct,
which allows you to define routes together with the fns in a single place.
const routes: []const tk.Route = &.{
tk.logger(.{}),
.get("/", tk.send("Hello")), // this is the classic, express-style routing
.group("/api", &.{ .router(api) }), // and this is our shorthand
.send(error.NotFound),
};
const api = struct {
pub fn @"GET /"() []const u8 {
return "Hello";
}
pub fn @"GET /:name"(allocator: std.mem.Allocator, name: []const u8) ![]const u8 {
return std.fmt.allocPrint(allocator, "Hello {s}", .{name});
}
};
If your handler returns an error, the framework will try to serialize it to JSON and send it to the client.
fn hello() !void {
// This will send 500 and {"error": "TODO"}
return error.TODO;
}
To send a static file, you can use the tk.static.file(path)
middleware.
const routes: []const tk.Route = &.{
.get("/", tk.static.file("static/index.html")),
};
You can also serve entire directories with tk.static.dir(path)
.
const routes: []const tk.Route = &.{
tk.static.dir("public", .{}),
};
And of course, the tk.static.dir()
also works with wildcard routes.
const routes: []const tk.Route = &.{
tk.get("/assets/*", tk.static.dir("assets", .{ .index = null })),
};
If you want to embed some files into the binary, you can specify such paths to
the tokamak
module in your build.zig
file.
const embed: []const []const u8 = &.{
"static/index.html",
};
const tokamak = b.dependency("tokamak", .{ .embed = embed });
exe.root_module.addImport("tokamak", tokamak.module("tokamak"));
In this case, only the files listed in the embed
array will be embedded into
the binary and any other files will be served from the filesystem.
The framework will try to guess the MIME type based on the file extension, but you can also provide your own in the root module.
pub const mime_types = tk.mime_types ++ .{
.{ ".foo", "text/foo" },
};
For a simple configuration, you can use the tk.config.read(T, opts)
function,
which will read the configuration from a JSON file. The opts
parameter is
optional and can be used to specify the path to the config file and parsing
options.
const Cfg = struct {
foo: u32,
bar: []const u8,
};
const cfg = try tk.config.read(Cfg, .{ .path = "config.json" });
There's also experimental tk.config.write(T, opts)
function, which will write
the configuration back to the file.
The tk.monitor(procs)
allows you to execute multiple processes in parallel and
restart them automatically if they exit. It takes a tuple of { name, fn_ptr, args_tuple }
triples as input. It will only work on systems with fork()
.
What this means is that you can easily create a self-contained binary which will stay up and running, even if something crashes unexpectedly.
The function takes over the main thread, forks, and it might lead to unexpected behavior if you're not careful. Only use it if you know what you're doing.
monitor(.{
.{ "server", &runServer, .{ 8080 } },
.{ "worker", &runWorker, .{} },
...
});
MIT