Releases: nevindra/nilo
Release list
v0.3.0 — the release a real port wrote
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-continuewas 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_agesaid. The only bound wasMax-Ageon the cookie, which is an instruction to a browser — so a session copied out of a proxy log went on opening forever. cors.within 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 runningSession(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.originis noworiginsand 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 calledcors.with—cors.permissiveis unchanged (ADR 0099).db.rawandtx.rawtake acomptimestatement. Text assembled at run time cannot be passed any more, and there is no replacement call. What you get for it: theSELECTlist 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 aswitchover the orderings the application actually supports, which is also the shape that stops an injection nobody meant to allow (ADR 0148).db.execis unchanged and still takes its text at run time.db.nilo_start(io)is nowdb.nilo_start(io, limits). Only a program that starts aDbitself — a CLI, a migration, a test — writes that line at all; pass.off, which is whatnilo_fetchandnilo_s3already take.app.listen()is unchanged and passes the Engine's.- A Wire of your own takes one more argument.
runandexec, on the Wire and on itsTx, end inproblem: ?*?sql.Problem. Passnullfrom 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.Dbis closed whenlisten()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 theDbafterlisten()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_agelasts 24 hours rather than forever. - A WebSocket served to a page on another host needs
.originsnaming that page, or the handshake is a 403. A request with noOriginat 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 = 0turns it off. - Four request shapes that used to be answered are now refused: no
Hostor two of them, aTransfer-Encodingnot ending inchunked, a body framed twice, and a body under aContent-Encodingnilo 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 ofnilo_start, andlisten()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...
v0.2.0
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)andsocket.json(value), and the same pair on aRoom. 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
saywas 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. -
receiveends when the server is stopping, after telling the client so with a 1001. A message loop no longer needsif (!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...
v0.1.0
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)andBound(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 aCtxlayer 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
Ctxfunctions, 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 anX-Request-Idon 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_bytesis not refused but opened per request and sent withsendfile, 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.FileBodyserves one out of a directory opened on purpose, with ranges,If-Range, conditional requests andHEADhandled 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,0waits forever). -
Broadcast —
nilo.Room. Saying something to sockets a handler does not hold. Provide aRoomlike any other service,joinon the way in,defer leaveon the way out, andsayreaches 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.spawnfor 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
recovermiddleware. 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.