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

In this example we're going to build a simple HTTP server, utilitizing the zzz library. It's advertised as being 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 JavaScript.

We begin by creating the basic app skeleton:

mkdir zzz
cd zzz
npm init -y
npm install node-zigar
mkdir src zig

Create a customizable copy of build.zig by running the following command:

cd zig
npx node-zigar build-custom

Then install zzz. The following command fetches the latest version (as of writing) from GitHub:

zig fetch --save https://github.com/tardy-org/zzz/archive/90cc62494644e7234efd85ab1df5d65440f9eead.zip

Open build.zig and add zzz as a dependency:

    const zigar = b.createModule(.{
        .root_source_file = .{ .cwd_relative = zig_path ++ "zigar.zig" },
    });
    const zzz = b.dependency("zzz", .{
        .target = target,
        .optimize = optimize,
    }).module("zzz");

And insert it into the list of imports:

    const imports = [_]std.Build.Module.Import{
        .{ .name = "zigar", .module = zigar },
        .{ .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.GeneralPurposeAllocator(.{ .thread_safe = true }){};
    const allocator = gpa.allocator();
    defer _ = gpa.deinit();

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

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

    // create socket for tardy
    var socket = try Socket.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 above to server.zig.

In src, we create index.js:

import { main } from '../zig/server.zig';

main();

Before we can run the app, we need to enable ESM and the node-zigar loader in package.json:

  "type": "module",
  "scripts": {
    "start": "node --loader=node-zigar --no-warnings src/index.js"
  },

Now we're ready:

npm run start

It'll take a moment for the Zig code to be compiled. When the compilation indicator goes away, open a browser and go to http://localhost:9862/. You should see the server's response:

Browser

In the console, you will see debug output from zzz:

Console

Well, that was easy!

Our next step is to move main() into a different thread so it doesn't block Node.js's event loop. 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();
}

And make the necessary change to index.js:

import { startServer } from '../zig/server.zig';

startServer();
console.log(`Server running`);

When you start the app again, you should see the log message in the console. This confirms that JavaScript code execution is no longer 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
import { startServer } from '../zig/server.zig';

await startServer('0.0.0.0', 9862);
console.log(`Server running`);

Then we add a Promise argument. Its presence in the argument list makes the function async on the JavaScript 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);

What we do sever initialization is successful? When do 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. Since the function happens to return !void, we can simply use that as the argument to resolve():

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

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.js to 80 and start up the app. You should get the following error:

node:internal/process/esm_loader:40
      internalBinding('errors').triggerUncaughtException(
                                ^

[Error: Access denied] { number: 13 }

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 Node.js. Thanks to garbage collection, text generation is far easier in JavaScript 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;
}
import { startServer, setBaseHandler } from '../zig/server.zig';

setBaseHandler(async (url) => {
    return `
<html>
    <title>Hello world</title>
    <body>
        <h1>Hello world!</h1>
        <p>You have accessed ${url.string}</p>
    </body>
</html>
    `
});
await startServer('0.0.0.0', 9862);
console.log(`Server running`);

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

Browser

When a JavaScript 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 Node.js's event loop and waits for the main thread to perform the actual call. The JavaScript function can be sync or async. release() is used to free the trampoline and the associated JavaScript function when they're no longer needed.

JavaScript 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. If there's one, it's placed in an options object, which is always the last argument. In most cases Zigar will automatically handle scenarios involving optional arguments. For instance, the JavaScript string returned by the JavaScript base handler is automatically copied into new Zig memory from the allocator.

The handler above is equivalent to the following:

setBaseHandler(async (url, { allocator }) => {
    return allocator.dupe(`
<!DOCTYPE html>    
<html>
    <title>Hello world</title>
    <body>
        <h1>Hello world!</h1>
        <p>You have accessed ${url.string}</p>
    </body>
</html>
    `);
});

Naturally, Zig function pointers can point to regular Zig functions. Let us now add a second page, 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("/").get({}, base_handler).layer(),
        Route.init("/cat").get({}, cat_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.js, import the new function and the handle from the new module:

import { startServer, setBaseHandler, setCatHandler } from '../zig/server.zig';
import { handleCat } from '../zig/cat.zig';

And set it:

setCatHandler(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.

Configuring the app for deployment

Follow the same steps as described in the the hello world example. First change the import statements:

import { startServer, setBaseHandler, setCatHandler } from '../lib/server.zigar';
import { handleCat } from '../lib/cat.zigar';

Then create node-zigar.config.json:

{
  "optimize": "ReleaseSmall",
  "sourceFiles": {
    "lib/server.zigar": "zig/server.zig",
    "lib/cat.zigar": "zig/cat.zig"
  },
  "targets": [
    { "platform": "linux", "arch": "x64" },
    { "platform": "linux", "arch": "arm64" }
  ]
}

And build the libraries:

npx node-zigar build

When you run the app 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 Node.js 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** ⚠️