Skip to content

v0.1.0

Choose a tag to compare

@nevindra nevindra released this 07 Sep 01:27
· 137 commits to main since this release

The first release, published as zfast. Needs Zig 0.16.

Install it pinned — zig fetch --save git+https://github.com/nevindra/zfast?ref=v0.1.0. Without the ?ref= you get whatever main is that day.

What is in it

  • Handlers are ordinary functions. What each argument means is worked out while compiling, by one rule: a pointer is a service, a value is request data. A test calls the function directly — no server, no fake request.

  • Routing — path params, wildcards, groups, plugins. The most specific route wins and duplicates are refused (ADR 0013).

  • Requests — path params, query strings and JSON bodies as structs of your own; bodies too big to hold, read as a stream.

  • HTML forms and file uploads, url-encoded and multipart.

  • Bindings that name the field that broke. Bound(Form(T)), Bound(T) and Bound(Query(T)) hand the handler every field that would not bind, by name, with the text that arrived — a 422 listing them is one line, and a page showing the form again with one box marked is a few more (ADR 0036).

  • Responses — a status in the type (Status(201, T)), typed redirects, response headers, and a Ctx layer underneath for full control.

  • Cookies, and sessions sealed into one with XChaCha20Poly1305 — no server store, no expiry sweep, nothing added to what an idle connection costs (ADR 0035).

  • Middleware as an onion of Ctx functions, and resolved values declared by their type. A group prefix may carry a param — app.group("/orgs/:org") — and middleware scoped to it matches whole segments.

  • Request ids and JSON log lines. logger.with(.{ .format = .json, .request_id = true }) writes one JSON object per line and puts an X-Request-Id on every response, adopting the proxy's id when it sent a usable one. c.requestId() reaches the same id from a handler.

  • Static files held in memory, gzipped once at startup, with ETags and range requests. A file over max_file_bytes is not refused but opened per request and sent with sendfile, so a directory with a video in it still starts and the memory figure still holds (ADR 0037).

  • A handler can answer with a file. ?nilo.FileBody serves one out of a directory opened on purpose, with ranges, If-Range, conditional requests and HEAD handled for it — and null still meaning 404. The name is checked a segment at a time, and the path handed to the kernel never comes from a request.

  • Streamed responses and server-sent events.

  • WebSocket — handshake, framing, masking, pings, closing handshake. A connection that goes quiet is asked whether it is still there and closed with 1001 if it does not answer; a quiet WebSocket is a working one, so this is a ping rather than a deadline (.idle_ms, 30 seconds, 0 waits forever).

  • Broadcast — nilo.Room. Saying something to sockets a handler does not hold. Provide a Room like any other service, join on the way in, defer leave on the way out, and say reaches everybody in it:

    fn chat(c: *nilo.Ctx, room: *nilo.Room) !void {
        var socket = try c.upgrade();
        try room.join(&socket);
        defer room.leave(&socket);
    
        var buf: [16 * 1024]u8 = undefined;
        while (try socket.receive(&buf)) |message| {
            try room.say(message.kind, message.data);
        }
    }

    That loop is the one an echo server writes. A post arriving while a connection is quiet is written out by that connection's own fiber, inside receive, so a handler never sees one — and one client that stops reading costs that client and nobody else. It adds 4 measured bytes per idle connection, with throughput and p99 unmoved (ADR 0038).

  • A generated OpenAPI document, written from the signatures rather than from annotations (ADR 0017).

  • Failure in nilo's own words. Get a handler wrong and compilation stops with a sentence naming your route, your argument and the fix; refusals/ is 56 programs written wrong on purpose that keep it that way (ADR 0027).

  • nilo.spawn for work that is not a request, owned by the server so shutdown counts it (ADR 0029).

A full Room backlog drops the oldest post by default, or the newest if you say so, and room.missed(&socket) says how many were dropped. That amends ADR 0020, which refused to have such a queue at all.

What it holds itself to

One allocation per request and 8,767 bytes per idle connection, both hard invariants held by tests rather than by intent (ADR 0018). Measured numbers and the method behind them are in bench/result/http.md, with eight other servers through the same harness in docs/comparison.md.

What is not in it

  • Templates — a refusal rather than a backlog item. nilo is for building APIs and services; rendering pages is not what it is for, and the reasoning is in the roadmap.
  • Counters. Requests carry an id and lines can be JSON, but how many requests, at what statuses, and how long is not collected anywhere.
  • TLS, and with it HTTP/2 and a gRPC server. This is a refusal rather than a gap — terminate in front (ADR 0028).
  • A recover middleware. Zig cannot recover from a panic, so there is nothing to build (ADR 0008).
  • Compressing a handler's response, permessage-deflate, and streamed multipart. Static files under the spill threshold are compressed, once, at startup; one above it is sent as it lies on disk.

zfast was a working name, and it changed in 0.2.0.