whatgoesup

Notes on making things

Zig web server

Let's begin by creating a very basic web server in Zig (0.17). Using its standard library, Zig gives us some primitives for listening on a socket, accepting the stream and handling incoming requests.

Before anything else, we create main() passing in std.process.Init. This is what Zig calls a "Juciy Main", a nicety that creates pre-initialised allocators, IO interfaces and an ENV map. We will be using the gpa allocator and IO later.

pub fn main(init: std.process.Init) !void {

Next we need to open a socket which is defined by an IP address, allowing us to bind and begin accepting requests. .reuse_address is a unix flag (SO_REUSEADDR) that enables the server to restart without waiting for connections to exist a wait status.

var address = try net.IpAddress.parse("127.0.0.1", 3000);

var listener = try address.listen(init.io, .{
    .reuse_address = true,
});
defer listener.deinit(init.io);

Next we start our server. .accept() extracts the first pending connection from the socket queue. Returning its stream, which we will use in the next code block.

We saw the keyword defer in the code above so I should really explain what it does. Defer executes a function/statement once the current code block ends. This means for defer stream.close(io) the stream will be closed, releasing the the socket handler. It's standard to defer closing/deinitialise a resource immediately after creating/opening it in Zig. This solves the problem in non-GC languages of forgetting to manually free resources.

while (true) {
    const stream = try listener.accept(init.io);
    defer stream.close(io);
    ...

We have now accepted an incoming stream of bytes from the socket. reader_buffer creates a location we can read those bytes into. The HTTP parser then packages this into a request object, which we will see later. Once we have dealt with those bytes, most likely we are going to want to respond to the request with our own stream of bytes so we create our writer_buffer.

Both buffers are undefined, the zig docs have the clearest explanation of how undefined differs from null 'undefined means "Not a meaningful value. Using this value would be a bug. The value will be unused, or overwritten before being used."'

Both buffers are 8KiB in size which is a good starting point for our web server. We can accept the entire HTTP request head in this size. It also doesn't limit the total response size. Larger responses are chunked.

    ...
    var reader_buffer: [4096]u8 = undefined;
    var writer_buffer: [4096]u8 = undefined;

    var stream_reader = stream.reader(io, &reader_buffer);
    var stream_writer = stream.writer(io, &writer_buffer);
    ...

std.http.Server{} is a HTTP implementation that accepts the reader and writer interfaces, parsing the stream of bytes into something we can use. In the future this is something I would like to delve deeper into, creating my own implementation, to understand how HTTP requests should be parsed.

    ...
    var server = http.Server.init(
        &stream_reader.interface,
        &stream_writer.interface,
    );
    ...

Above we can see Zig's implementation of intrusive interfaces. In a language like Go an interface is a wrapper struct (iface{}) containing 2 values. A pointer to a struct that describes both the interface type (methods) and the type of data. The other value is a pointer to the interface value itself. With these 2 values the Go Runtime can derive the correct type.

An intrusive interface, Zig's implementation, inverts this pattern. Using the inbuilt function @fieldParentPtr() at runtime is able to take a pointer to struct (inner interface) and return a pointer to a struct (outer parent). You can then call the methods on that structure.

Using this server we can now parse the incoming requests. Much of this will seem familiar to anyone that has built HTTP servers before. You can see how much something like Go's ServeMux{} can make this easier to read. But that's something we can build towards.

    ...
    var r = try server.receiveHead();
    if (route(.GET, "/", r)) {
      const html: []const u8 = try render(gpa, indexHTML);
      defer gpa.free(html);

      try r.respond(html, .{
          .keep_alive = false,
          .extra_headers = &.{
              .{ .name = "content-type", .value = "text/html" },
          },
      });
    }
    ...

We are skipping over a bit of code here. But things like try and Zig strings ([]const u8) deserve their own deep dives.

Finally what is interesting is what the render() function returns which is a concatenation of the below constants (indexHTML, baseHTML, headerHTML etc) which call @embedFile().

const indexHTML: []const u8 = @embedFile("template/index.html");

Again, this can be done in Go with //go:embed taking a file and embedding it in the final compiled binary. This is such a good tool because it makes deployments much easier and most importantly less complex.

With languages like Python or NodeJS whole repos of code need to be copied into containers or onto VMs for applications to run. With a single binary you aren't copying folders or making sure file paths are correct.

And with respond() we return the HTML as part of the response handling the request.