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.
We begin by creating the basic directory structure:
mkdir zzz
cd zzz
mkdir src zigGo into the zig sub-directory and create an empty build.zig:
cd zig
touch build.zigAfter 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.zipThat'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.phpIt'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:

Well, that was easy!
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-loopAnd 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.
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.
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.

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);
});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:

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.
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.
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.phpThen 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.
You can find the complete source code for this example here.
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.