Skip to content

v0.3.0 — the release a real port wrote

Latest

Choose a tag to compare

@nevindra nevindra released this 09 Sep 11:17
· 15 commits to main since this release

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 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 asks c.overdue() or c.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 a Retry-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 /metrics with 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_hops one short and clientIp() quietly wrong (ADR 0129).
  • app.spawn(f, args) — a ticker or a batching exporter registered before listen() 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 own operationId. 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 of without. Matched on the pattern and the method, so a DELETE guard does not cover the GET beside 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) ?Self and fn show(id: sql.Uuid) !?User is 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, .unsaid or .refused, because a client that sent no Accept has 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.json writes a union one way and most REST APIs use the other, which meant a hand-written jsonStringify and jsonParse per 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. .tag is the type saying it.

  • Upload.saveTo(dir, name) — the four lines of std.fs every upload handler ended in, without blocking the executor thread and without resolving ../../etc/cron.d/anything out of u.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 no Content-Length, so a browser showed no progress and a Range could 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 before listen(), 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. f is called with a sql.Sent after 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.Problem carries the message, the SQLSTATE code, severity, detail, hint and the constraint that was violated. message is 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_bytes is 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 leaves testing.Client nothing 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.Client can 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 posts on, so newsletter: bool = false was 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 announced Content-Length before 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.md carrying 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.