Skip to content

v0.8.0: Zig 0.17, and HTTP/2 for every request

Latest

Choose a tag to compare

@nevindra nevindra released this 10 Oct 03:00
· 2 commits to main since this release

0.8.0 moves nilo to Zig 0.17 and makes HTTP/2 a framing of every request, where 0.7.0 used it only for gRPC. Build with .http2 = true and every listener answers HTTP/1.1 and HTTP/2 on one port: a plain one tells them apart by the client's first bytes, a TLS one by ALPN. Every route works on both, streams, event streams, files and trailers included. Most of the forty breaking entries are mistakes the compiler now refuses, and a few change what a running program does without a word, so read the first section below before you deploy.

Needs Zig 0.17.0. v0.7.0 and every tag before it stay on Zig 0.16.0; a project upgrading nilo upgrades Zig in the same change.

zig fetch --save 'git+https://github.com/nevindra/nilo?ref=v0.8.0#d3ab2f33bbb8fc2281605c33a12fe3619c63f42d'

Keep the #commit: the tag is annotated, and zig fetch does not peel it (still true on 0.17), so ?ref=v0.8.0 alone is whatever main is that day.


Read this before you deploy

Upgrading takes three passes. First, the changes that compile and then behave differently, below, because nothing will point at them for you. Then build with Zig 0.17, and let the compiler walk you through the rest. Then, if you wrote your own Dialect or built a Stream by hand, the last section.

1. Changes nothing will warn you about

Every program: a nilo_* hook without pub is now skipped. Zig 0.17's @hasDecl sees only pub declarations, so a nilo_start, nilo_stop, nilo_ready or nilo_check on a Service, a nilo_table marker, or any other nilo_* declaration written without pub compiles and is never called, where 0.16 refused it. Search your code for nilo_ and make each one pub.

Every server: a connection is ended after about 1,000 requests. The answer to the last one carries Connection: close (900 to 1,000 a connection, spread so they do not all close at once), so a long-lived client moves to another instance and another executor after a scale-out. Browsers, curl, Go's net/http and Python's requests reconnect and notice nothing; a pipelining client resends what was behind the last answer. A client that cannot reconnect needs listen(.{ .max_requests_per_connection = 0 }). HTTP/2 connections are not counted unless you set max_requests_per_h2_connection.

If you serve static files with .br or .gz beside them. app.static and app.embedded now serve app.js.br and app.js.gz as encodings of app.js to a client that asks for them, and /app.js.br itself answers 404. A tree that publishes notes.txt.gz as a download beside notes.txt passes .precompressed = false. A sibling older than its file, not smaller, or not a gzip of it is ignored with a warning.

If a middleware sets a header after next.run(c). That header was dropped without a word, because the answer had already been written; it is now an error. Call next.hold(c) before next.run(c) to hold the answer until you return, set the header before next, or use defer c.setHeader(...) catch {}; for one that must cover failures too.

If your JSON bodies have ?T fields with no default. Leaving one out now reads as null, as it already did in a query and a form, where it was a 400 naming the field. The API document no longer lists it as required. If you relied on the 400, make the field a type that is not optional; to tell "not sent" from "sent as null", use Patch(T).

If a form takes files. A single Upload field that receives several files is now a 400 naming the field, where it kept the first and dropped the rest. Declare it []const nilo.Upload to take them all.

If a route has a nilo.deadline(ms). The deadline now bounds the nilo_fetch, nilo_s3 and nilo_sql calls the route makes with its *Ctx: each takes the shorter of its own bound and what the route has left, and a call made after the time is up is error.TimedOut without being sent. A call that has to outlive the route passes a nilo.Run. Postgres is sent no cancel: the connection is closed, so a write the deadline cut off may still have committed, and a retried request needs an idempotency key.

If you read nilo's log lines with a program. nilo.std_options now carries nilo.logFn: a line starts with the time and has no colours (2026-10-09T12:00:00.123Z info message request=…), a JSON line is one object with the access line's fields at its top level, and a line a handler logs names its request. The format and the level come from listen(.{ .log = .{ .format = .json, .level = .info } }), so logger.with no longer takes .format. A root that sets its own logFn keeps it.

If you serve gRPC. A call that fails with error.AlreadyExists answers ALREADY_EXISTS (6) and one with error.RolledBack answers ABORTED (10), where they were ABORTED and UNAVAILABLE; a client that retries on those codes now sees the right ones. c.setHeader("grpc-status", …) and "grpc-message" are refused: use c.setTrailer. On HTTP/2 a request whose content-type is not gRPC is served as HTTP, where it was a 415.

If you generate a client from the API document. Every integer now states both ends of its type (minimum: 0, maximum: 255 for a u8), every field of a returned struct is required (a ?T is sent as null), and a type whose request and response halves differ is two components, T and TInput. A regenerated client changes.

If you use nilo_s3. A bucket that does not exist is error.Rejected, where it was error.NotFound: a handler that matched NotFound to mean "no such bucket" matches Rejected, and a missing key is still NotFound. presign* with seconds = 0 and getRange with from > to are error.Rejected; a ranged read answered with anything but a 206, and a get, stream or head with no content-length, are error.Failed.

If you run migrations from CI. db check now exits 1 over a standing Problem and prints it with the --accept command, where it said "Up to date": write the step, then db generate --accept <name>. db.checking called twice on one Db fails the boot with error.CheckedTwice: join the schemas (.tables = a.tables ++ b.tables) and call it once. Replicas migrating at once poll the advisory lock every 200 ms instead of waiting on it, so one may start up to 200 ms after the lock is free.

If you use nilo-dev. It builds incrementally by default, a save served in under a second; zig build dev -- --no-incremental is the old loop.

2. Build with Zig 0.17, and let the compiler find the rest

These no longer compile, each with a message naming the fix:

  • The build: the dependency flag .grpc = true is .http2 = true (-Dhttp2), the listener option .grpc is gone (delete the line; a TLS listener now offers h2 beside http/1.1 without it), and the dev step in your build.zig is written differently, because 0.17 removed b.args and b.getInstallPath (Restarting on every save has the lines). nilo.app(b, …) can now write the whole build for you.
  • Two or more path params are read by name, through nilo.Path(struct { org: u32, id: u32 }): bare arguments were matched by position, so swapping two ids compiled and ran the wrong query. The error writes the struct for you. One path param is unchanged.
  • nilo_jwt's issuer and audience have no default: .issuer = .{ .is = "x" }, or .unchecked to skip a check. Leaving one out used to accept a token minted for any application the same keys sign.
  • app.spawn and nilo.spawn refuse a Str or a Ctx in their arguments, however deep: .keep() the text first.
  • Shapes refused while compiling: .x = null on a column that is not optional, .now on a sql.AsText("timestamp") column (use timestamptz), sql.Ordering(…).by with no terms or too many (.fromTerms for terms chosen at run time), job.every(0) or a runtime period (write it as a pub const), and an exponential backoff from 0 (.{ .fixed_ms = 0 } means no wait).
  • Renamed or moved: c.connection() is c.keepAlive(); Within(…).Int is Within(…).Number; Date.days_from_epoch_to_y2k is sql.types.date_days_from_epoch_to_y2k; allowance.with, allowance.keyed, secure.api and secure.pages take their options as a literal (a value built first is .{ .value = n }), and Limited.Limit is nilo.Late(usize); App.grpcHost needs -Dhttp2.
  • Error sets that grew, for an exhaustive switch: job.Jobs(…).Error and serveOn's gain PoolTooSmall, returned when a queue with a transactional kind has as many workers as the Db's pool.

3. If you wrote your own

  • A caller of Dialect.advisoryLock(key) reads its answer (true when granted) and asks again, because the lock is now polled.
  • A test that builds a nilo.Stream by hand passes a Framing around its buffers (.{ .http1 = .{ .in = &reader, .out = &writer, .minor_version = 1 } }) where it passed the writer.

What is new

  • HTTP/2 for every request, on the port HTTP/1.1 is on, behind .http2 = true, and over TLS by ALPN with .tls = true as well. A route that never waits runs on its connection's own fiber (baseline-h2c from 2.45 to 1.25 µs of server CPU a request, a unary gRPC call from 5.7 to 2.4), and a burst of answers leaves in writes of 32 KiB.
  • RPC as plain functions. A protobuf message is a handler's argument and answer, read as JSON or protobuf by the request's Content-Type; app.rpc(T) serves a struct's functions as a service reachable by gRPC, Connect and JSON, and a Connect client is told a failure in its own codes. An answer can carry trailers on every protocol (c.setTrailer).
  • Typed middleware. A middleware takes what it needs after Next, as a handler does: services, resolved values, nilo.Path(T).
  • Bodies and answers. nilo.Many(Item, .{ .min = 1, .max = 5 }) bounds a list, nilo_json takes .skip, .omit_null and .omit_empty, a renamed struct is a request body, []const nilo.Upload takes every file under a name, and nilo.Timestamp and nilo.Date work without -Dsql.
  • Signing in and limits. nilo.Bearer(T) signs in a client with no cookie jar, and a rate limit or a CSP can come from the environment (nilo.Late(T)).
  • Static files. Files your build compressed are served as encodings of the original, and staticWith(.{ .follow = true }) keeps a directory in memory and in step with the disk.
  • nilo_fetch opens a WebSocket, calls a service on a unix socket, goes out through an egress proxy with a private CA, writes a form, and a Target can retry with an idempotency key.
  • nilo_job: transactional completion (a run that takes *Db.Tx commits its writes and its done together), schedules in a time zone (job.cron("0 3 * * *").in("Asia/Jakarta")), jitter on a backoff, and sweepDead.
  • nilo_sql: sql.Replays answers an idempotency key once across every instance on one database, db.poolStats() says how full a Postgres pool is, db generate --concurrently builds an index without stopping writes, and --accept records a Problem as handled.
  • nilo_s3: a streamed read from an offset, copy and compose, retries on Throttled and Unavailable, and failures that say what they were.
  • Twenty-three fixes, among them a call redirected to another origin no longer carrying its credentials, two replicas migrating at once under REPEATABLE READ, and an idle connection costing 512 bytes less.

The full list, every entry with its ADR, is CHANGELOG.md at v0.8.0.