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 header value with a control byte in it gets a 500 rather than a split response.
What else is new
Serving
nilo.deadline(ms)— how long a route gets, clamping every wait nilo owns (the body, the write, a stream's pieces, a WebSocket's silence) to whichever comes first. A running handler is not interrupted; it asksc.overdue()orc.timeLeftMs()itself (ADR 0133).allowance.with(.{ .per_window = 100, .window_s = 60 })— the hundred-and-first request from one address inside the minute is a 429 with aRetry-After, and the handler never runs. The window slides, the table is sized while compiling and lives in.bss, and an IPv6 client is a/64. Behind a proxy set.trusted_hops, or every request looks like it came from the proxy.allowance.keyed(f, …)keys the same table on what the application knows instead, because an address gave ten accounts behind one office NAT a single allowance (ADR 0114, ADR 0131).app.metrics(.{})— counters, which nilo has never had: a Prometheus page on/metricswith requests per route, status class, duration, and how many are in flight. Counted per route, not per path, so a crawler cannot make you a million series, and a counted request still allocates nothing. Throughput cost is inside the noise; the binary pays 17,416 bytes if you call it. Metrics..address = "unix:/run/nilo.sock"— a path instead of a port, so the proxy in front no longer reaches the server over loopback TCP and "who may connect" becomes "who may write to this directory" (ADR 0130)..trusted_proxies = &.{"private"}— which machine is in front rather than how many hops, so adding a CDN does not leave.trusted_hopsone short andclientIp()quietly wrong (ADR 0129).app.spawn(f, args)— a ticker or a batching exporter registered beforelisten()and started once there is a server, owned by it exactly as a connection is. This work used to be reachable only from inside a handler (ADR 0086).app.named("addPartnerCapability")— a route says its ownoperationId. The derived name is a good default and a poor key: it is not a word anybody chose, and it changes when the route moves path (ADR 0149).app.with(mw)— a middleware on one route, the other direction ofwithout. Matched on the pattern and the method, so aDELETEguard does not cover theGETbeside it (ADR 0126).listen(.{ .arena_keep = 1 << 20 })— a response larger than the arena keeps was a page fault per 4 KiB, every request: 257 of them on a route answering a megabyte, and 7,908 req/s where setting this gives 11,069. The default is unchanged at 16 KiB, because the memory is held per connection (ADR 0096).
Reading a request
- A path param can be a type that parses itself. Give a type
pub fn nilo_parse(text: []const u8) ?Selfandfn show(id: sql.Uuid) !?Useris a route: a malformed id is a 400 before the handler runs, and the generated document says{"type":"string","format":"uuid"}rather than a bare string (ADR 0142). c.queries(),c.queryString(),c.host(),c.scheme(),c.headers()— every parameter in arrival order, the bytes still encoded for a signature, and what a handler writes a URL to its own service with (ADR 0112, ADR 0107).nilo.accept.asks(c.header("Accept"), "text/html")—.named,.anything,.unsaidor.refused, because a client that sent noAccepthas neither asked for HTML nor ruled it out. Nothing allocated (ADR 0109).
Responses and files
-
nilo_json— a type can say how its JSON is spelled.std.jsonwrites a union one way and most REST APIs use the other, which meant a hand-writtenjsonStringifyandjsonParseper type (ADR 0085):const Condition = union(enum) { pub const nilo_json = .{ .tag = "signal", .rename_all = .lowercase }; pub const jsonParse = nilo.jsonParseFor(@This()); // only if it arrives metrics: MetricCondition, logs: LogCondition, };
A
union(enum)can be a request body now, which used to be a compile error on the grounds that nothing in the type said which arm arrived..tagis the type saying it. -
Upload.saveTo(dir, name)— the four lines ofstd.fsevery upload handler ended in, without blocking the executor thread and without resolving../../etc/cron.d/anythingout ofu.filename. Bytes go to a temporary name and one rename puts them in place (ADR 0123). -
c.streamWith(…, .{ .length = n })— bytes out of something that had already counted them went with noContent-Length, so a browser showed no progress and aRangecould not be answered. Writing past the promise is refused before a byte of the overrun goes out (ADR 0128). -
c.url(pattern, args)— the pattern is the name, so there is no route name to keep in step with it. A missing param, a spare value and a*catch-all are compile errors naming the field, and every value is percent-encoded so a form value cannot pick the route (ADR 0127). -
cors.reading(&origins, .{ … })— the same middleware reading its list from a variable you fill beforelisten(), because the front end's address is a fact about the deployment. The list is borrowed rather than copied, so a cross-origin response still allocates nothing (ADR 0110). -
staticWith(.{ .reload = true })— every file left on disk and opened per request, so editing one under a running server works.
nilo_sql and nilo_s3
db.watching(f)— the statements a request sent. One line per request says a page is slow; nothing said what was slow in it.fis called with asql.Sentafter every statement: the text, the plan name, how long the database took, how many rows moved, and whether it failed.db.watching(sql.logging)is the whole of the common case. Not the values it bound, which are as often a password as an id — that is the decision rather than the first version (ADR 0137).- A statement that failed says what the database said.
sql.Problemcarries the message, the SQLSTATEcode,severity,detail,hintand theconstraintthat was violated.messageis never empty — a driver refusal reports the Zig error's name, which is the missing word this was built for. It lives in the request's arena and never reaches the client (ADR 0146). bucket.presignPost(c, key, .{ .seconds = 900 })gives a browser a form it posts straight to the bucket, so a receipt or an attachment never passes through your server..max_bytesis clamped to the bucket's and defaults to it — a form with no ceiling is not something this call hands out (ADR 0141).
Testing
testing.Conversation— a WebSocket route driven through the public API, where a handler that upgrades leavestesting.Clientnothing to read. Frames are queued before the server runs, and what came back is decoded by a reader sharing no code with the encoder it checks (ADR 0113).testing.Clientcan be a client.Client.init(gpa, .{ .cookies = true })keeps what the answers set and sends it back, so a sign-in followed by a request as that user is two calls. The jar is off by default, so an existing suite keeps asserting what it asserted (ADR 0108).
The rest of what a real port found
Beyond the five at the top, and the forty-two fixes in full in the changelog, four are worth reading on their own terms.
/users/{id}in a route pattern was five literal characters and nothing said so.{}is what OpenAPI writes, what nilo's own document prints, and what every framework a porter is arriving from spells — so a path copied out of an existing document registered a route that answered nothing. It is refused while compiling now, naming:name(ADR 0147).- A ticked checkbox did not bind to a
bool. HTML postson, sonewsletter: bool = falsewas a 400 the first time somebody ticked the box, while the unticked half worked perfectly (ADR 0092). - A single-page fallback answered every path under its prefix, so a build whose hash had moved on handed a browser HTML where it asked for
app.abc123.js— a syntax error on line 1, with the missing file named nowhere. A navigation still gets the page; everything else gets a 404 saying which path (ADR 0109). c.body()no longer commits the announcedContent-Lengthbefore reading a byte. A client that promised a megabyte and sent one byte a minute held 1,852,080 bytes per stuck connection; now 316,080 (ADR 0105).
And two numbers that were simply wrong in the documentation:
- "About 9 KB a connection" was still quoted in six places, and the number is 4,669 (5,183 for an idle WebSocket), with
deploying.mdcarrying an older ~21 KB from two rounds before that. It is a floor rather than a total: a suspended fiber holds its stack at its high-water mark, so a handler adds every byte of stack it ever touched (ADR 0063). - What a held-open stream costs, measured: 21,058 bytes against 4,674 for an idle connection, plus your handler's stack byte for byte. The streaming guide carries the number now instead of a warning that it was unmeasured.
And Autobahn
The WebSocket has been run against the suite for the first time: 294 OK, 4 NON-STRICT, 0 FAILED of 301 cases. Nothing in the framework changed for it — the framing rules have now simply been seen by something that did not write them. bash bench/autobahn/run.sh, bench/result/http.md.
The full list, every entry with its ADR, is in CHANGELOG.md at v0.3.0. What was measured and what turned out false on the way is in docs/history.md; the whole public API on one page is docs/reference.md.