Skip to content

Releases: nevindra/nilo

v0.3.0 — the release a real port wrote

Choose a tag to compare

@nevindra nevindra released this 09 Sep 11:17

For most of its life nilo's HTTP behaviour was checked against a pair of in-memory buffers. App.handleRequest takes a *std.Io.Reader and a *std.Io.Writer and nothing else — which is a good design, and a fast suite, and the reason almost every behaviour in this framework can be asserted with no server in the process.

It also cannot see a single thing a client actually does.

0.3.0 is the release where something that had never read the source got pointed at nilo: a real socket at both ends, curl, a browser, wstest, and 301 Autobahn cases. A sample of what came back.

  • A server that had served any WebSocket usually did not come back from a SIGTERM. 23 of 25 hung, with one executor thread at 100% — so a deploy got a container that would not stop.
  • Expect: 100-continue was never answered, so curl waited out its own one-second fallback timer before every upload, for the life of the project.
  • A session never expired, whatever max_age said. The only bound was Max-Age on the cookie, which is an instruction to a browser — so a session copied out of a proxy log went on opening forever.
  • cors.with in front of an upgrade route set headers nobody enforces: a browser applies no CORS to a WebSocket. Any page on any origin could open a socket to an application running Session(T), carrying the cookie.
  • GET http://example.com/users/7 HTTP/1.1 — the absolute form RFC 9112 §3.2.2 says a server must accept, and the form a client that thinks it is talking to a proxy sends — was a 404 on a route that plainly exists.

Not one of those five is reachable from a buffer. All five are fixed, along with forty-two more below.

The other half of the release is what got built on purpose: migrations out of the Rows you already wrote, and two new modules.

Needs Zig 0.16, as 0.2.0 does.

zig fetch --save git+https://github.com/nevindra/nilo?ref=v0.3.0

Ten entries ask something of you. Read this before you deploy is all of them, with the fix next to each.


Your Rows were already a schema

You wrote User and Org to get the SQL. They also describe, exactly, what the database should look like — nilo just could not read that far. Four more markers (.unique, .index, .references, .was) close the gap, and that is the whole schema language. Everything else is SQL you write, and nilo never touches a table it did not create (ADR 0153).

sql.cli.Tool(Db, &.{ User, Org }) turns those types into five commands out of a main of ten lines:

$ db check                       # do the Rows and the migrations agree?
$ db generate --name add_nickname
$ db status
$ db migrate
$ db verify                      # has an applied version been edited since?

The part worth the paragraph: generate and check open no database. Both halves of the diff are files — your types against migrations/snapshot.zon — so CI needs no service container, and two people adding a column on the same afternoon conflict in git like everything else rather than at deploy. The exit code is the whole API for a pipeline: 0 did it, 1 you have something to do, 2 the command line was wrong.

A version is one .zig file holding a list of steps, and it is exactly what runs: those steps, in one transaction, behind an advisory lock, so ten replicas booting together run it once. Forward-only — there is no down, and --drop is required before anything that loses data is even written down. sql.migrate is the library under all of it, including createMissing for a fixture and expect for a server that must refuse a database older than its binary.

Nothing to change, and nothing to pay: a program that never names sql.migrate links none of it. The two stripped zig build size-sql probes are byte for byte what they were before this landed (bench/result/sql.md §10).

Two new modules, taking the count to ten

nilo_jwt — checking somebody else's signed token. A tool module: imports nothing, needs no event loop, and zig test jwt/jwt.zig runs the whole of it. jwt.parseKeys(gpa, jwks_bytes) reads a JWKS document; jwt.verify(Claims, gpa, token, .{ … }) checks an RS256 signature and reads the payload into a struct of your own.

The three things easiest to get wrong here are not options. The algorithm is nilo's constant rather than the token's alg, so {"alg":"none"} and an HMAC signed with your own published modulus are both refused. Nothing in the payload is read until the signature has passed. And exp is required.

Fetching the key set is still yours — it is an HTTPS GET, which nilo_fetch already sends, and holding it is nilo_cache (ADR 0140).

nilo_cache — an expiring cache in this process. Also a tool module, so a program that is not a server can take it on its own. cache.Space("cart", Cart, .{ .ttl_s = 300 }) is a keyspace as a type; the value type decides whether get hands back a value or fills an array you declared, and a value with a pointer in it is a compile error naming the field. One number is the whole memory budget and it is a ceiling — nothing is allocated after open, and nothing grows (ADR 0138, ADR 0139).

Nothing imports either unless you do, and a program that does not links no RSA.


Read this before you deploy

Four of these are signature changes and the compiler will find them for you. The other six change what a running server does, and those are the ones to read.

The compiler will catch these

  • cors.Options.origin is now origins and takes a list. A single compile-time string meant an application with a production front end and a staging one could not use the middleware at all. Nothing to do if you never called cors.withcors.permissive is unchanged (ADR 0099).
  • db.raw and tx.raw take a comptime statement. Text assembled at run time cannot be passed any more, and there is no replacement call. What you get for it: the SELECT list is counted against the Row's fields while compiling, each column that plainly has a name is checked against the field in its position, and the statement is kept prepared like every other one — worth about 12 µs a query. A statement built at run time becomes a switch over the orderings the application actually supports, which is also the shape that stops an injection nobody meant to allow (ADR 0148). db.exec is unchanged and still takes its text at run time.
  • db.nilo_start(io) is now db.nilo_start(io, limits). Only a program that starts a Db itself — a CLI, a migration, a test — writes that line at all; pass .off, which is what nilo_fetch and nilo_s3 already take. app.listen() is unchanged and passes the Engine's.
  • A Wire of your own takes one more argument. run and exec, on the Wire and on its Tx, end in problem: ?*?sql.Problem. Pass null from a caller that does not want the text, and fill it from a driver that has some (ADR 0146).

These change what a running server does

  • A sql.Db is closed when listen() returns. That is the only moment it can let go of the event loop it was built on, and it is what stops a server whose database never came up from panicking on the way out. A program that used the Db after listen() came back has to stop doing that; defer db.deinit() is unchanged and still correct (ADR 0151, ADR 0152).
  • Sessions expire now. Everybody holding one signs in again on the deploy that picks this up, and a session cookie with no max_age lasts 24 hours rather than forever.
  • A WebSocket served to a page on another host needs .origins naming that page, or the handshake is a 403. A request with no Origin at all — curl, wstest, a native client — is unaffected. Say &.{"*"} for a public socket.
  • A slow upload can be refused. A body nilo buffers has to arrive at 8 KiB/s once ten seconds of grace have gone, or the request is a 408 — which will also refuse an honest client on a bad link. body_min_rate = 0 turns it off.
  • Four request shapes that used to be answered are now refused: no Host or two of them, a Transfer-Encoding not ending in chunked, a body framed twice, and a body under a Content-Encoding nilo cannot read. Nothing a browser, a proxy or an HTTP library sends changes.
  • A Service of your own that puts work on the event loop should declare pub fn nilo_stop(self: *T) void. It is the mirror of nilo_start, and listen() calls it on the way out. Nothing to do if your service only holds data, or never touches the loop.

Three more answers change with nothing at all for you to do: a client sending Expect: 100-continue gets one and stops waiting out its own timer, an If-Range carrying a weak tag or a * gets the whole file rather than a range, and a handler setting a hea...

Read more

v0.2.0

Choose a tag to compare

@nevindra nevindra released this 07 Sep 01:27

0.1.0 was an HTTP server called zfast. 0.2.0 is a toolkit called nilo, and that server is one of its eight modules.

The other seven are the parts a service needs in an ordinary week: Postgres, SQLite, object storage, calling somebody else's API, settings, password hashing, UUIDs, and the vocabulary the rest of them share. You import the ones you use, and Zig never compiles the rest.

Needs Zig 0.16. Install it pinned:

zig fetch --save git+https://github.com/nevindra/nilo?ref=v0.2.0

Five things break. Upgrading from 0.1.0 is all of them, with the fix next to each.

The eight modules

Module What it is In 0.1.0
nilo_http the server: routing, typed handlers, middleware, sessions, static files, streaming, WebSocket, OpenAPI this was the whole library, and it was called zfast
nilo_sql Postgres and SQLite. Your struct is the table wrote the SQL while compiling, and could not send it
nilo_s3 object storage: S3, MinIO, R2. Your bucket is a type new
nilo_fetch calling somebody else's HTTP API from inside a request new
nilo_config settings out of the environment, every bad one named at once new
nilo_pw password hashing: argon2id, stored as PHC new
nilo_id UUIDs, v4 and v7 new
nilo_core Str, the Scope, the clock, percent coding new

Which module a file belongs in is decided by one question: does it need the event loop? A module imports downward only, and never sideways (ADR 0041, ADR 0042). That is a build step rather than a paragraph. zig build layering reads the imports and refuses one that goes the wrong way.

None of the seven knows nilo_http exists. nilo_sql asks for a Scope, which is arena() and str() and nothing else, so the same query runs inside a handler, inside a CLI, or inside a test with no server in the process. Where there is no request, hand it a nilo.Run. Handing over something that is neither is a Refusal naming the call.

Upgrading from 0.1.0

1. Everything spelled zfast is spelled nilo

The server's module is nilo_http, not nilo. The bare name belongs to the project, which is eight modules now rather than one.

Was Is
@import("zfast") @import("nilo_http")
zfast_table, zfast_resolve, zfast_query, zfast_response nilo_table, nilo_resolve, …
.zfast in build.zig.zon, zfast_sql .nilo, nilo_sql
nilo.module("nilo") in your build.zig nilo.module("nilo_http")

The markers are the ones worth knowing about, because they sit in your structs rather than behind the import line. They are also the ones that do not move again, because they are named after the project rather than after a module. Alias the import back and the rest of your code is unchanged:

const nilo = @import("nilo_http");

2. nilo_sql has to be asked for

Add .sql = true to your b.dependency("nilo", …). Nothing else changes. Leave it out and importing nilo_sql is a compile error that says this in one sentence.

const nilo = b.dependency("nilo", .{
    .target = target,
    .optimize = optimize,
    .sql = true,
});

The flag is what fetches the drivers, and it exists because the old arrangement never worked: every dependent was downloading 11 MB of Postgres driver, including ones with no database in them at all. b.lazyDependency is a request rather than a conditional, and the manifest had said otherwise for a year (ADR 0075). zig build fetch-check -Dnetwork is the measurement, run against an empty package cache.

3. A WebSocket handler hands its loop back

c.upgrade() no longer returns a Socket for the handler to loop over. It takes the loop as a function, answers the handshake, and returns.

// was
fn chat(c: *nilo.Ctx, room: *nilo.Room) !void {
    var socket = try c.upgrade();
    var buf: [4096]u8 = undefined;
    while (try socket.receive(&buf)) |m| try room.say(m.kind, m.data);
}

// now
fn chat(c: *nilo.Ctx, room: *nilo.Room) !void {
    return c.upgrade(chatLoop, room);
}

fn chatLoop(socket: *nilo.Socket, room: *nilo.Room) !void {
    while (try socket.receive()) |m| try room.say(m.kind, m.data);
}

Three things move at once and they are all one change. The loop is a named function. receive takes no buffer, because the message arrives in one the executor lends the socket while the message is in flight and takes back when the conversation goes quiet. And anything the handler knows that the loop needs is the second argument to upgrade, up to 128 bytes: a Str, a pointer, a service, or {} when there is nothing. The long form is c.upgradeWith(loop, state, .{ .protocol = …, .idle_ms = …, .max_message = … }).

This is worth 16 KB per open socket, and it is why the shape changed. A handler that keeps the loop is a suspended fiber holding the request's whole frame (the Ctx, the parsed head, the route match) plus its own receive buffer, for as long as the tab is open. An idle WebSocket cost 21,561 bytes and now costs 5,183. One that had received a single 60 KiB message cost 87,101 and now costs 5,186, which is the same socket either way (ADR 0071).

One consequence to know about: an open WebSocket no longer counts as a request in flight, so a shutdown is not held for the grace period by every idle chat tab.

4. A type with its own jsonStringify says what it looks like

The OpenAPI generator used to describe such a type by its fields, so a UUID appeared in the document as { bytes: [16]integer } while the wire carried a string. That is a document contradicting the endpoint it describes. Say what it looks like instead:

pub const nilo_openapi = .{ .type = "string", .format = "uuid" };

Missing it is a compile error naming the type (ADR 0076). nilo's own types carry theirs.

5. Removed: nilo.websocket.Handshake

A struct wrapping the array accept() already returns. Nothing had ever used it.

nilo_http: the server

Memory per idle connection is 4,669 bytes

Down from 8,767, for HTTP and WebSocket alike, and nothing in your code has to change to get it. Ten thousand idle keep-alive connections is 47 MB rather than 88 MB.

The connection's read and write buffers already went back to the kernel when it went quiet. What was left was two pages of fiber stack, and one of them was there only because the connection then suspended itself four kilobytes deeper than it needed to. The idle wait now happens at the connection loop's own frame, the request's machinery is a frame of its own that unwinds before it, and the cold half of a request (the log lines nobody hits, which cost stack whether or not they print) is out of line.

Throughput neither paid for it nor gained from it. Four interleaved 30-second runs against a same-machine baseline average 1,429,293 req/s against 1,420,424, which is +0.6% and less than the spread of either column. Read that row as unchanged.

The floor is still a floor. A handler that touches 64 KiB of stack still holds 64 KiB per connection, one byte for one byte. An ordinary route reading one row and answering JSON holds 17,022 bytes; a handler that only touches an 8 KiB stack array holds 17,932, which is more, so the database was never the cause. In this framework the arena is cheaper than the stack (ADR 0063).

A WebSocket message, once through

Receiving one used to copy every byte into your buffer and then walk the same bytes again to unmask them. It is one pass now, unmasked on the way across, and a message too big to have arrived whole is read straight into your buffer, past the connection's read buffer entirely (ADR 0052).

zig build profile was is
websocket: frame overhead 9ns 6ns 1.5×
websocket: receive 48 B 15ns 12ns 1.25×
websocket: receive 16 KiB 196ns, 88.1 GB/s 73ns, 244.5 GB/s 2.7×
room: say to 8 of 1,000 seats 494ns 161ns 3.1×

Nothing about the API changed to get any of that. What did change:

  • socket.print(fmt, args) and socket.json(value), and the same pair on a Room. One text message, formatted or serialised straight onto the wire, with no stack buffer of yours to size:

    try room.print("welcome, {d} here", .{room.count()});
    try socket.json(.{ .kind = "joined", .who = name });

    Neither allocates on a Socket; on a Room they reuse the allocation say was going to make. Both run the format twice, once to size the frame and once to write it, because a frame states its length before its bytes.

  • receive ends when the server is stopping, after telling the client so with a 1001. A message loop no longer needs if (!socket.live()) break; in it, which was a rule ADR 0020 stated and every handler had to remember. live() stays, for a handler doing work of its own between messages.

  • **Sending on a socket that has already closed writ...

Read more

v0.1.0

Choose a tag to compare

@nevindra nevindra released this 07 Sep 01:27

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.