Repository navigation
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 = trueis.http2 = true(-Dhttp2), the listener option.grpcis gone (delete the line; a TLS listener now offersh2besidehttp/1.1without it), and thedevstep in yourbuild.zigis written differently, because 0.17 removedb.argsandb.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'sissuerandaudiencehave no default:.issuer = .{ .is = "x" }, or.uncheckedto skip a check. Leaving one out used to accept a token minted for any application the same keys sign.app.spawnandnilo.spawnrefuse aStror aCtxin their arguments, however deep:.keep()the text first.- Shapes refused while compiling:
.x = nullon a column that is not optional,.nowon asql.AsText("timestamp")column (usetimestamptz),sql.Ordering(…).bywith no terms or too many (.fromTermsfor terms chosen at run time),job.every(0)or a runtime period (write it as apub const), and an exponential backoff from0(.{ .fixed_ms = 0 }means no wait). - Renamed or moved:
c.connection()isc.keepAlive();Within(…).IntisWithin(…).Number;Date.days_from_epoch_to_y2kissql.types.date_days_from_epoch_to_y2k;allowance.with,allowance.keyed,secure.apiandsecure.pagestake their options as a literal (a value built first is.{ .value = n }), andLimited.Limitisnilo.Late(usize);App.grpcHostneeds-Dhttp2. - Error sets that grew, for an exhaustive
switch:job.Jobs(…).ErrorandserveOn's gainPoolTooSmall, 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 (truewhen granted) and asks again, because the lock is now polled. - A test that builds a
nilo.Streamby hand passes aFramingaround 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 = trueas 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_jsontakes.skip,.omit_nulland.omit_empty, a renamed struct is a request body,[]const nilo.Uploadtakes every file under a name, andnilo.Timestampandnilo.Datework 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_fetchopens a WebSocket, calls a service on a unix socket, goes out through an egress proxy with a private CA, writes a form, and aTargetcan retry with an idempotency key.nilo_job: transactional completion (arunthat takes*Db.Txcommits its writes and itsdonetogether), schedules in a time zone (job.cron("0 3 * * *").in("Asia/Jakarta")), jitter on a backoff, andsweepDead.nilo_sql:sql.Replaysanswers an idempotency key once across every instance on one database,db.poolStats()says how full a Postgres pool is,db generate --concurrentlybuilds an index without stopping writes, and--acceptrecords a Problem as handled.nilo_s3: a streamed read from an offset,copyandcompose, retries onThrottledandUnavailable, 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.