I actually didn’t know how to do this myself and was just manually using python -m http.server with system python3!
Let’s start off by remembering the bare minimum we need to successfully serve documentation:
- Documentation emitted by a build step, which consists of:
- A sources.tar file containing our relevant Zig source files
- An index.html file to control rendering of the web page
- A main.wasm file which is built from “docs/wasm” in Zig’s source code and has many purposes
- A main.js file which bootstraps the WASM and controls the DOM
- An HTTP server capable of serving files
That’s it!
So, all we really need to do is build an application that spins up an HTTP server capable of serving files to the client.
We build, install and run that application in a set of steps, and make said steps depend on the documentation steps and be installed to the same directory the documentation is installed to.
Zig’s HTTP server application for zig std can be found at “compiler/std-docs.zig” in Zig’s source code.
This application is special in that it actually builds the WASM binary and source file tarball itself - meaning we can skip those subroutines, since our build script will pre-build those for us.
Here’s a super-quick graph of what it does:
- Define a Context struct for the request handler callback containing the following:
- A
std.mem.Allocator instance as a GPA
- A
std.fs.Dir used as the server’s root directory for serving files
- Three
[]const u8 path literals used to locate the standard library sources, Zig executable and global cache directory for building the WASM binary
- Define
fn accept(context: *Context, connection: std.net.Server.Connection) void { as a threaded handler callback for HTTP requests
- Parse the command line arguments as follows:
- Skip the first argument which is our binary name
- The next three arguments are always specified as the zig lib directory, zig executable path and global cache directory
- Print help and exit if “-h” or “–help” is ever specified
- Parse and set the port number the server will listen on if “-p” or “–port” is ever specified
- If it’s never specified, we set our port number as 0 which means that the system will be allowed to choose the port number
- Unless “–no-open-browser” is ever specified, we run an extra function to open a browser window
- Now that the command line arguments are parsed, we get our server’s root directory by calling
var lib_dir = try std.fs.cwd().openDir(zig_lib_directory, .{});
- We create our IP address as a
std.net.Address using .parseIp() using the localhost IPv4 literal "127.0.0.1" and the port number we remembered from earlier
- We use
var http_server = try address.listen(.{ .reuse_address = true, }) on our std.net.Address to obtain a std.net.Server
- Now that we have this server, we call
http_server.listen_address.in.getPort() to get the port number
- This is only necessary because if the port number is 0, this is the point at which the system chooses it and so we have to get its new value
- We format the IP address we created and print it so the user knows what address the server will live at
- If we’re supposed to open a browser window, then we also use this formatted IP address to call our browser open function
- We initialise the context with our GPA and all of our remembered directory variables
- We then spin up a main loop wherein we call
const connection = try http_server.accept()
- If the connection is accepted successfully, we attempt to spawn a thread to handle it with our handler function - we use
_ = std.Thread.spawn(.{}, accept, .{ &context, connection }) catch |err| with some error handling to print the error and close the connection
So how are we gonna adapt this application for our purposes?
Well, we don’t really need the zig lib directory, zig executable path or global cache directory, since we’re not compiling anything.
This means we can simply remove the application’s CLI layer and Context struct, and rely on setting its CWD at build-time.
Most of the rest of it will be the exact same.
Here’s my cut-down version of std-docs.zig:
const std = @import("std");
const builtin = @import("builtin");
const gpa: std.mem.Allocator = std.heap.smp_allocator;
var server_root_dir: std.fs.Dir = undefined;
const cache_control_header: std.http.Header = .{
.name = "cache-control",
.value = "max-age=0, must-revalidate",
};
pub fn main() !void {
server_root_dir = std.fs.cwd();
// DEBUG: Print the server's root directory
var realpath_buf: [4096]u8 = @splat(0);
std.log.info("Server root dirname: {s}", .{
try server_root_dir.realpath("", &realpath_buf),
});
defer server_root_dir.close();
const address: std.net.Address = try .parseIp("127.0.0.1", 0);
var server: std.net.Server = try address.listen(.{.reuse_address = true});
const port = server.listen_address.in.getPort();
// Print the address and port number.
// Also format them for use with the open browser window function.
const formatted_address = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}/", .{port});
defer gpa.free(formatted_address);
std.log.info("Listen server address: {s}", .{formatted_address});
// Open a browser window targeting our server.
try openBrowserTab(formatted_address);
while(true){
const connection = try server.accept();
_ = std.Thread.spawn(.{}, handle_request, .{connection}) catch |e| {
std.log.err("{t}: Unhandled connection!", .{e});
connection.stream.close();
// continue;
};
}
}
var recv_buffer: [4096]u8 = undefined;
var send_buffer: [4096]u8 = undefined;
fn handle_request(connection: std.net.Server.Connection) void {
defer connection.stream.close();
// Create std io readers and writers for the connection
var conn_reader = connection.stream.reader(&recv_buffer);
var conn_writer = connection.stream.writer(&send_buffer);
// Create a local HTTP handler using these writers
// This will be used to serve files in response
var http = std.http.Server.init(conn_reader.interface(), &conn_writer.interface);
while(http.reader.state == .ready){
var request = http.receiveHead() catch |e| switch(e) {
error.HttpConnectionClosing => return,
else => {
std.log.err("{t}: Closing HTTP connection early", .{e});
return;
},
};
// """"Switch"""" based on the request's target to serve the documentation.
if(
std.mem.eql(u8, request.head.target, "/") or
std.mem.eql(u8, request.head.target, "/index.html") or
std.mem.eql(u8, request.head.target, "/debug") or
std.mem.eql(u8, request.head.target, "/debug/")
){
// If the head is empty, it's likely the default server IP and we should serve index.html.
const index_html_buffer = server_root_dir.readFileAlloc(gpa, "index.html", @as(usize, 1) << 30) catch |e| {
std.log.err("{t}: Closing HTTP connection early; failed to get index.html", .{e});
return;
};
defer gpa.free(index_html_buffer);
request.respond(index_html_buffer, .{
.extra_headers = &.{
.{.name = "content-type", .value = "text/html"},
cache_control_header,
},
}) catch |e| {
std.log.err("{t}: Closing HTTP connection early; failed to serve index.html", .{e});
return;
};
} else if(
std.mem.eql(u8, request.head.target, "/main.wasm") or
std.mem.eql(u8, request.head.target, "/debug/main.wasm")
){
// Serve the WASM binary.
const main_wasm_buffer = server_root_dir.readFileAlloc(gpa, "main.wasm", @as(usize, 1) << 30) catch |e| {
std.log.err("{t}: Closing HTTP connection early; failed to get main.wasm", .{e});
return;
};
defer gpa.free(main_wasm_buffer);
request.respond(main_wasm_buffer, .{
.extra_headers = &.{
.{.name = "content-type", .value = "application/wasm"},
cache_control_header,
},
}) catch |e| {
std.log.err("{t}: Closing HTTP connection early; failed to serve main.wasm", .{e});
return;
};
} else if(
std.mem.eql(u8, request.head.target, "/main.js") or
std.mem.eql(u8, request.head.target, "/debug/main.js")
){
// Serve the JavaScript bootstrapper.
const main_js_buffer = server_root_dir.readFileAlloc(gpa, "main.js", @as(usize, 1) << 30) catch |e| {
std.log.err("{t}: Closing HTTP connection early; failed to get main.js", .{e});
return;
};
defer gpa.free(main_js_buffer);
request.respond(main_js_buffer, .{
.extra_headers = &.{
.{.name = "content-type", .value = "application/javascript"},
cache_control_header,
},
}) catch |e| {
std.log.err("{t}: Closing HTTP connection early; failed to serve main.js", .{e});
return;
};
} else if(
std.mem.eql(u8, request.head.target, "/sources.tar") or
std.mem.eql(u8, request.head.target, "/debug/sources.tar")
){
// Serve the source file tarball.
const sources_tar_buffer = server_root_dir.readFileAlloc(gpa, "sources.tar", @as(usize, 1) << 30) catch |e| {
std.log.err("{t}: Closing HTTP connection early; failed to get sources.tar", .{e});
return;
};
defer gpa.free(sources_tar_buffer);
request.respond(sources_tar_buffer, .{
.extra_headers = &.{
.{.name = "content-type", .value = "application/x-tar"},
cache_control_header,
},
}) catch |e| {
std.log.err("{t}: Closing HTTP connection early; failed to serve sources.tar", .{e});
return;
};
} else {
// We don't have any other files we'd want to serve, so respond with 404.
request.respond("not found", .{
.status = .not_found,
.extra_headers = &.{
.{ .name = "content-type", .value = "text/plain" },
},
}) catch |e| {
std.log.err("{t}: Closing HTTP connection early; failed to serve 404 message", .{e});
return;
};
}
}
}
fn openBrowserTab(url: []const u8) !void {
// Until https://github.com/ziglang/zig/issues/19205 is implemented, we
// spawn a thread for this child process.
_ = try std.Thread.spawn(.{}, openBrowserTabThread, .{url});
}
fn openBrowserTabThread(url: []const u8) !void {
const main_exe = switch (builtin.os.tag) {
.windows => "explorer",
.macos => "open",
else => "xdg-open",
};
var child = std.process.Child.init(&.{main_exe, url}, gpa);
child.stdin_behavior = .Ignore;
child.stdout_behavior = .Ignore;
child.stderr_behavior = .Ignore;
try child.spawn();
_ = try child.wait();
}
Great.
So how do we integrate this into our build script, exactly?
Like this:
const exe_serve_docs = b.addExecutable(.{
.name = "docs_server",
.root_module = b.createModule(.{
.root_source_file = b.path("src/doc_server.zig"),
.target = target,
.optimize = optimize,
}),
});
const install_serve_docs = b.addInstallArtifact(
exe_serve_docs, .{
.dest_dir = .{
.override = .{
.custom = "docs",
},
},
},
);
install_serve_docs.step.dependOn(&exe_serve_docs.step);
var run_serve_docs = b.addRunArtifact(exe_serve_docs);
// Make the application's cwd its install directory
run_serve_docs.setCwd(b.path("zig-out/docs"));
run_serve_docs.step.dependOn(&install_serve_docs.step);
This assumes that it will be followed by a documentation generator set of functions that looks something like this:
const exe_install_docs = b.addInstallDirectory(.{
.source_dir = exe.getEmittedDocs(),
.install_dir = .prefix,
.install_subdir = "docs",
});
const exe_docs = b.step("docs", "Generate HTML documentation");
exe_docs.dependOn(&exe_install_docs.step);
exe_docs.dependOn(&run_serve_docs.step);
Now, you should be able to run zig build docs and immediately spin up a webserver that renders your documentation.