HTTP server (zzz) ᴾᴴᴾ - chung-leong/zigar GitHub Wiki

JavaScript | PHP


In this example we're going to build a simple HTTP server utilitizing the zzz library. The library is advertised as capable of serving static contents very, very fast. We're going to see how we can use it to serve up dynamic contents generated using PHP.

Running the basic example

We begin by creating the basic directory structure:

mkdir zzz
cd zzz
mkdir src zig

Go into the zig sub-directory and create an empty build.zig:

cd zig
touch build.zig

After that, go to zzz's Github page. Select the tag "v0.3.2", then Click the "Code" button and copy the URL for the zip package. Finally, fetch it using zig:

zig fetch --save https://github.com/tardy-org/zzz/archive/refs/tags/v0.3.2.zip

That'll fetch the package and create a build.zig.zon listing it as a dependency. The empty build.zig only exists to enable the fetch --save command. It won't be used. The import of the package happens in build.extra.zig:

const std = @import("std");

pub fn getImports(b: *std.Build, args: anytype) []const std.Build.Module.Import {
    const zzz = b.dependency("zzz", .{
        .target = args.target,
        .optimize = args.optimize,
    }).module("zzz");
    return &.{
        .{ .name = "zzz", .module = zzz },
    };
}

Since we know nothing about the library, we'll first make use of its Getting Started sample code, reproduced below for your convenience:

const std = @import("std");
const log = std.log.scoped(.@"examples/basic");

const zzz = @import("zzz");
const http = zzz.HTTP;

const tardy = zzz.tardy;
const Tardy = tardy.Tardy(.auto);
const Runtime = tardy.Runtime;
const Socket = tardy.Socket;

const Server = http.Server;
const Router = http.Router;
const Context = http.Context;
const Route = http.Route;
const Respond = http.Respond;

fn base_handler(ctx: *const Context, _: void) !Respond {
    return ctx.response.apply(.{
        .status = .OK,
        .mime = http.Mime.HTML,
        .body = "Hello, world!",
    });
}

pub fn main() !void {
    const host: []const u8 = "0.0.0.0";
    const port: u16 = 9862;

    var gpa: std.heap.DebugAllocator(.{ .thread_safe = true }) = .init;
    const allocator = gpa.allocator();
    defer _ = gpa.deinit();

    var t: Tardy = try .init(allocator, .{ .threading = .auto });
    defer t.deinit();

    var router: Router = try .init(allocator, &.{
        Route.init("/").get({}, base_handler).layer(),
    }, .{});
    defer router.deinit(allocator);

    // create socket for tardy
    var socket: Socket = try .init(.{
        .tcp = .{ .host = host, .port = port },
    });
    defer socket.close_blocking();
    try socket.bind();
    try socket.listen(4096);

    const EntryParams = struct {
        router: *const Router,
        socket: Socket,
    };

    try t.entry(
        EntryParams{ .router = &router, .socket = socket },
        struct {
            fn entry(rt: *Runtime, p: EntryParams) !void {
                var server: Server = .init(.{
                    .stack_size = 1024 * 1024 * 4,
                    .socket_buffer_bytes = 1024 * 2,
                    .keepalive_count_max = null,
                    .connection_count_max = 1024,
                });
                try server.serve(rt, p.router, .{ .normal = p.socket });
            }
        }.entry,
    );
}

Save the code as server.zig in zig.

The example is actually out-of-date for Zig 0.16.0. The init functions of Tardy and Socket now require an extra std.Io argument. So we first have to get an instance of it from std.Io.Threaded:

    var threaded_io = std.Io.Threaded.init(allocator, .{});
    const io = threaded_io.io();

Then add it to the argument lists:

    var t: Tardy = try .init(allocator, io, .{ .threading = .auto });
    var socket: Socket = try .init(io, .{

Time for the PHP part. In src, create index.php:

<?php

$m = zigar_use(__DIR__ . '/../zig/server.zig');
$m->main();

Then run it:

php src/index.php

It'll take a moment for the Zig code to be compiled. When that's done, you'll see the following:

info(tardy): aio backend: io_uring
info(tardy): thread count: 1
info(zzz/http/server): security mode: normal

Open a browser and go to http://localhost:9862/. You should see the server's response:

Browser

Well, that was easy!

Moving HTTP handling to a thread

Our next step is to move main() into a different thread, freeing up PHP so that it can handle incoming requests. Add the following code to server.zig:

const zigar = @import("zigar");

pub fn startServer() !void {
    try zigar.thread.use();
    const thread = try std.Thread.spawn(.{}, main, .{});
    thread.detach();
}

Now we need to set up an event loop on the PHP side. Unlike Node.js, PHP doesn't have a built-in event loop. We need to install Revolt with the help of Composer. In the terminal, run the following command at the project root:

composer require revolt/event-loop

And then add the code for the event loop in index.php:

<?php

require __DIR__ . '/../vendor/autoload.php';

ini_set('zigar.event_loop', 'revolt');

use Revolt\EventLoop;

EventLoop::defer(function() {
    $m = zigar_use(__DIR__ . '/../zig/server.zig');
    $m->startServer();
    echo "Server running\n";
});
EventLoop::run();

When you start the app again, you should see "Server running" in the console. This confirms that PHP code execution is not being blocked.

Making function async

Now let us turn startServer() into an async function so that it can return any error encountered during start up. We'll first make host and port arguments of startServer():

pub fn startServer(host: []const u8, port: u16) !void {
    try zigar.thread.use(); 
    const thread = try std.Thread.spawn(.{}, main, .{ host, port });
    thread.detach();
}
fn main(host: []const u8, port: u16) !void {
    // remove hardcoded host and port
    $m = zigar_use(__DIR__ . '/../zig/server.zig');
    $m->startServer('0.0.0.0', 9862);
    echo "Server running\n";

Then we add a Promise argument. Its presence in the argument list makes the function async on the PHP side:

pub fn startServer(host: []const u8, port: u16, promise: zigar.function.PromiseOf(main)) !void {
    // ...

We use PromiseOf() to define the promise struct since the error set of main() is inferred. The function conveniently calls Promise() for us with main()'s return type.

main() needs to receive this promise struct too, but we can't use PromiseOf(main) here, since that'd lead to a circular reference. Instead, we declare it as Promise(anyerror!void):

fn main(host: []const u8, port: u16, promise: zigar.function.Promise(anyerror!void)) !void {
    // ...

Then in startServer(), we use any() to cast the promise into this type:

    const thread = try std.Thread.spawn(.{}, main, .{ host, port, promise.any() });

Now we need to call the promise's resolve method. An errdefer statement at the top of main() will receive any error prior to it being returned:

fn main(host: []const u8, port: u16, promise: zigar.function.Promise(anyerror!void)) !void {
    errdefer |err| promise.resolve(err);

When do we know if sever initialization is successful? When do we call resolve() with a void? If you study the code for a moment you will learn that the server is ready after the call to server.serve() in entry() has succeeded:

                try server.serve(rt, p.router, .{ .normal = p.socket });
                p.promise.resolve();

One last thing to adjust is how we handle the return value from t.entry(). That's zzz's event loop function. It doesn't return until server shutdown. By then promise has long been fulfilled already. We don't want any error to reach our errdefer statement, so we remove try and add catch {} at the end:

    t.entry(

        // ...

    ) catch {};

To verify that our error handling is correct, change the port in index.php to 80 and run the script. You should get the following error:

PHP Fatal error:  Uncaught ZigException: access denied in /home/rwiggum/zzz/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php:621
Stack trace:
#0 /home/rwiggum/zzz/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(621): onReadable('b', Resource id #20)
#1 [internal function]: Revolt\EventLoop\Internal\AbstractDriver->{closure:Revolt\EventLoop\Internal\AbstractDriver::createCallbackFiber():593}()
#2 /home/rwiggum/zzz/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(525): Fiber->resume()
#3 /home/rwiggum/zzz/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(586): Revolt\EventLoop\Internal\AbstractDriver->invokeCallbacks()
#4 [internal function]: Revolt\EventLoop\Internal\AbstractDriver->{closure:Revolt\EventLoop\Internal\AbstractDriver::createLoopFiber():566}()
#5 /home/rwiggum/zzz/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(96): Fiber->start()
#6 /home/rwiggum/zzz/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(117): Revolt\EventLoop\Internal\AbstractDriver->{closure:Revolt\EventLoop\Internal\AbstractDriver::__construct():90}()
#7 /home/rwiggum/zzz/vendor/revolt/event-loop/src/EventLoop.php(411): Revolt\EventLoop\Internal\AbstractDriver->run()
#8 /home/rwiggum/zzz/src/index.php(29): Revolt\EventLoop::run()
#9 {main}

Changing it back to 9862 would allow the server to start up again.

Handling page requests in PHP

At the moment our server is very, very rudimentary. Let us make it a bit more sophisticated. We will change the base handler so that it retrieves the document body from PHP. Thanks to garbage collection, text generation is far easier in PHP than in Zig.

const ContentFn = fn (std.mem.Allocator, []const u8) error{Unexpected}![]u8;
var base_content_fn: ?*ContentFn = null;

fn base_handler(ctx: *Context, _: void) !Respond {
    if (base_content_fn) |f| {
        if (f(ctx.allocator, ctx.request.uri orelse "")) |body| {
            return ctx.respond(.{
                .status = .OK,
                .mime = http.Mime.HTML,
                .body = body,
            });
        } else |_| {}
    } 
    return ctx.respond(.{ 
        .status = .@"Service Unavailable",
        .mime = http.Mime.TEXT,
        .body = "Service Unavailable",
    });
}

pub fn setBaseHandler(f: ?*const ContentFn) void {
    if (base_content_fn) |ex_f| zigar.function.release(ex_f);
    base_content_fn = f;
}
    $m = zigar_use(__DIR__ . '/../zig/server.zig');
    $m->setBaseHandler(function ($url) {
        return <<<HTML
            <!DOCTYPE html>    
            <html>
            <title>Hello world</title>
            <body>
                <h1>Hello world!</h1>
                <p>You have accessed $url</p>
            </body>
            </html>
        HTML;
    });
    $m->startServer('0.0.0.0', 9862);

error.Unexpected is the "catch-all" error. We make the callback return it so that exceptions thrown by the PHP handler would get translated to error.Unexpected instead of triggering a panic.

The last thing we need to do is add a route that matches all URLs:

        Route.init("/%r").get({}, base_handler).layer(),

%r means capturing what remains of the path at that point. %s would capture only up to the next slash.

After restarting the server, you should see the following in the browser.

Browser

When a PHP function is provided as a function pointer argument, Zigar creates for it a native-code "trampoline". When called, the trampoline function adds an entry in the Revolt event loop and waits for the main thread to perform the actual call. release() is used to free the trampoline and the associated PHP function when they're no longer needed.

PHP memory cannot be returned to Zig, since it can be garbage-collected at inopportune times. Pointers contained by the return value can only point to Zig memory. The caller is expected to provide an allocator for this purpose. The caller is also responsible for freeing any allocated memory. We don't need to do that here, since the allocator in ctx is an arena allocator.

Zigar treats std.mem.Allocator as an optional argument. It's passed by name and so must come after the other arguments. You don't need it in most cases since Zigar will automatically handle scenarios involving optional arguments. For instance, the string returned by the PHP base handler is automatically copied into new Zig memory from the allocator.

The handler above is equivalent to the following:

    $m->setBaseHandler(function ($url, $allocator) {
        return $allocator->dupe(<<<HTML
            <!DOCTYPE html>    
            <html>
            <title>Hello world</title>
            <body>
                <h1>Hello world!</h1>
                <p>You have accessed $url</p>
            </body>
            </html>
        HTML);
    });

Using native functions

Naturally, Zig function pointers can point to regular Zig functions. Let us now add a second route, this one handled by Zig code in a separate module.

First, create cat.zig:

const std = @import("std");

pub fn handleCat(allocator: std.mem.Allocator, _: []const u8) error{Unexpected}![]u8 {
    const html =
        \\ <!DOCTYPE html>
        \\ <html>
        \\ <body>
        \\ <h1>Meow!</h1>
        \\ </body>
        \\ </html>
    ;
    return allocator.dupe(u8, html) catch error.Unexpected;
}

Then add a new route:

    var router = try Router.init(allocator, &.{
        Route.init("/cat").get({}, cat_handler).layer(),
        Route.init("/").get({}, base_handler).layer(),
        Route.init("/%r").get({}, base_handler).layer(),
    }, .{});

And a new page handler:

var cat_content_fn: ?*const ContentFn = null;

fn cat_handler(ctx: *const Context, _: void) !Respond {
    if (cat_content_fn) |f| {
        if (f(ctx.allocator, ctx.request.uri orelse "")) |body| {
            return ctx.response.apply(.{
                .status = .OK,
                .mime = http.Mime.HTML,
                .body = body,
            });
        } else |_| {}
    }
    return ctx.response.apply(.{
        .status = .@"Service Unavailable",
        .mime = http.Mime.TEXT,
        .body = "Service Unavailable",
    });
}

pub fn setCatHandler(f: ?*const ContentFn) void {
    if (cat_content_fn) |ex_f| zigar.function.release(ex_f);
    cat_content_fn = f;
}

In index.php, load the new module and set the new handler:

    $c = zigar_use(__DIR__ . '/../zig/cat.zig');
    $m->setCatHandler($c->handleCat);

When you restart the server and go to http://localhost:9862/cat, you'll see this:

Browser

Okay, the effect is rather underwhelming. But now we have a web server that generates contents using two very different programming languages. That's kidna neat.

Setting meta-type

One more thing we can do is to make the url argument passed to the callback functions a regular string:

pub const @"meta(zigar)" = struct {
    pub fn isArgumentString(T: type, _: usize) bool {
        return switch (T) {
            ContentFn => true,
            else => false,
        };
    }
};

This matters more when the same code is used in JavaScript/Node.js, where stringifying []const u8 yields a common-delimited list of numbers.

Configuring the app for deployment

Follow the same steps as described in the the hello world example. First, create a build script and run it:

<?php

$targets = [
    [ 'platform' => 'linux', 'arch' => 'x64' ],
    [ 'platform' => 'linux', 'arch' => 'arm64' ],
    [ 'platform' => 'darwin', 'arch' => 'arm64' ],
    [ 'platform' => 'win32', 'arch' => 'x64' ],
];
foreach ($targets as $options) {
    $options['optimize'] = 'ReleaseSmall';
    zigar_compile(__DIR__ . '/../zig/server.zig', $options);
    zigar_compile(__DIR__ . '/../zig/cat.zig', $options);
}
php src/build.php

Then change the path given to zigar_use() so that the .zigar module directory is referenced instead of the Zig file:

    $m = zigar_use(__DIR__ . '/../lib/server.zigar');
    $c = zigar_use(__DIR__ . '/../lib/cat.zigar');

When you run the script again, you'll notice that zzz's debug output is gone.

Source code

You can find the complete source code for this example here.

Conclusion

I hope this example gave you some ideas of what can be done with Zigar. It's simply a tech demo, not a serious attempt at building a web server. A real server would certainly function differently. Instead of contacting PHP on every request, a real server would probably employ some kind of caching mechanism. It would probably provide an API for selectively invalidating cached pages. Perhaps in the future we'll build something like this.

⚠️ **GitHub.com Fallback** ⚠️