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:
In the console, you will see debug output from zzz:
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.
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:
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.
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.
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 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.