Skip to content

Responses

Most handlers answer by returning a value — see Handlers. This page is the layer underneath: what a *Ctx can send, and the rules that apply to both.

Sending from a Ctx

fn handler(c: *nilo.Ctx) !void { … }
c.sendText(200, "hi") text/plain
c.sendJson(201, value) serialised and sent
c.send(200, "text/csv", bytes) a content type of your own
c.sendFile(.{ .file = f, … }) an open file, closed here — see Files
c.stream(200, "text/csv") a response written in pieces — Streaming
c.events() a stream of server-sent events
c.upgrade(loop, state) turn the connection into a WebSocket and hand it to loop

Headers

c.setHeader(name, value) copied into the request arena
c.setStaticHeader(name, value) for text that already outlives the request (a literal), so nothing is copied

Set them before sending. A response is finished the moment it is sent, so there is nothing left to change afterwards.

Content-Type, Content-Length, Transfer-Encoding and Connection are the framework's to write, and setting them is refused — a response carrying two of any of those is malformed. Pass the content type to send instead.

Set-Cookie and Vary are the two that add rather than replace. Two cookies are two lines because they cannot be folded into one; two Vary lines happen because the CORS middleware and a gzipped static file each name a different axis of the same response, and replacing lost one of them (ADR 029). Setting either with a name and value already there adds nothing.

A value may not hold a control byte, and a name has to be a token. A header is name: value\r\n with no escaping in it, so a value carrying a newline does not make a broken header — it makes a second one, and two of them start a second response. Both are refused with a 500 naming the header (ADR 029). This is worth knowing about for the values that did not come from you: a Location read out of a database, a filename off an upload. Percent-encode those, or strip them.

For a handler that returns a value, the same headers are set through .headers, which is copied rather than borrowed:

// The status is part of the signature — `!Status(201, User)` — so the API
// description names it. `Response(T)` is the same thing with the status as
// a runtime field, for when it depends on what the handler found.
return .{ .headers = .of(&.{
    .{ .name = "Location", .value = url },
}), .value = created };

Eight per response there, and a ninth is a compile error pointing you at c.setHeader, which has no limit (ADR 018).

Redirects

Redirect(status) carries its status in the type, so the API description names it and says the answer carries a Location:

fn shortLink(db: *Db, code: nilo.Str) !nilo.Redirect(302) {
    return .to(try db.target(code.view()));
}

Which one to use is the only difficulty, and it is worth getting right:

301 moved for good
302 found — temporary
303 see other. What a form POST answers with, because it turns the follow-up into a GET, so the reload button re-reads the page instead of posting the form again
307 temporary, and the method is kept
308 permanent, and the method is kept

Anything else is a compile error — a Location on a status that does not carry one means nothing to a client.

A redirect can carry headers, which is how a sign-in answers:

return .with("/", .of(&.{.{ .name = "Set-Cookie", .value = session }}));

c.redirect(status, location) is the same response from a *Ctx, for a status only known while the request is running.

There is no body. Browsers follow the header and never look (ADR 031).

Files

FileBody is a file as a return value: the handler names it, nilo opens it, and the bytes go from the disk to the socket without passing through your process (ADR 009).

fn invoice(files: *Files, id: u32) !?nilo.FileBody {
    const name = try files.nameOf(id) orelse return null;
    return .{ .dir = files.dir, .name = name, .content_type = "application/pdf" };
}

A return type rather than a call, for the reason a redirect is one: the signature is the contract, so the API description says the endpoint answers with bytes — and the ? says it answers 404, exactly as it does for a ?User.

dir the directory to open the file in — a nilo.Dir
name the name inside it
content_type default "application/octet-stream"
cache_control empty leaves the header off
headers up to eight, the same list a Redirect carries

The dir is not decoration. It is opened once, at startup, and held as a service:

var files: Files = .{ .dir = try nilo.Dir.open("uploads") };
defer files.dir.close();
try app.provide(&files);

A name is opened relative to that descriptor rather than joined onto a path, so nothing a request carries is ever resolved as one. What is left — a .. segment, an absolute path, a NUL byte, and on Windows a backslash or a drive letter — is refused before anything is opened, and answers the same 404 a missing file does, word for word, so a probe cannot tell the two apart. The log line says which it was.

A download's filename is a header, and goes where the other headers go:

return .{
    .dir = files.dir,
    .name = name,
    .content_type = "application/pdf",
    .headers = .of(&.{.{
        .name = "Content-Disposition",
        .value = "attachment; filename=\"invoice-42.pdf\"",
    }}),
};

There is deliberately no download_as field. Quoting a filename properly is RFC 6266 rather than one line, and attachment is not the only answer — a PDF opening in a browser tab wants inline with a filename.

Range, If-Range, If-None-Match and HEAD work here exactly as they do for a static file. The API description says the body is application/octet-stream with format: binary rather than the content type you set, because that one is a runtime field and the document does not guess.

c.sendFile(.{ .file = f, .content_type = … }) is the same response from a *Ctx, for a handler that already holds an open file and has its own etag, size or cache_control to give it. It closes the file, on every way out.

Bytes already in hand

A proxy that downloads a bundle from another service and hands it to the browser with that service's Content-Type has no file and no Dir, and its content type is a value it learns per request. nilo.Bytes is FileBody's shape with the bytes in memory (ADR 173):

fn bundle(licences: *Licences, c: *nilo.Ctx, number: u32) !?nilo.Bytes {
    const got = try licences.download(c, number) orelse return null;
    return .{
        .body = got.body,
        .content_type = got.content_type,
        .headers = .of(&.{.{ .name = "Content-Disposition", .value = "attachment" }}),
    };
}

Nothing is copied — the body is the handler's, in the request arena or a response it still holds — and ?Bytes is the 404 it is everywhere else. Unlike a FileBody it takes a wrapper's status, so Status(201, Bytes) is what it says. The document describes it as format: binary, for FileBody's reason. Before this the choices were c.send from a *Ctx handler, which the document could not see, or a nilo_write type naming a content type it did not know.

A body the client already holds

A list a dashboard polls every five seconds is the same list nearly every time, and a handler can say so: nilo.Versioned(T) is T with a version on it, which goes out as a weak ETag. A client that sends the tag back as If-None-Match gets a 304 and no body — and, if the handler asks first, no query either (ADR 189):

const Order = struct {
    pub const nilo_table = .{ .name = "orders", .key = .id };

    id: i64,
    total: i64,
    revision: i64,
};

fn listOrders(c: *nilo.Ctx, db: *Db) !nilo.Versioned([]Order) {
    const revision = try db.rawOne(i64, c, "select coalesce(max(revision), 0) from orders", .{}) orelse 0;
    const version: u64 = @intCast(revision);
    if (c.clientHas(version)) return .unchanged(version);
    return .{ .version = version, .value = try db.select(Order, c, .{ .order = .{ .id = .asc } }) };
}

The version is yours to name, because you are the only one who knows what it is: a revision column, a max(updated_at), a counter the writer bumps. What matters is that it is known before the body is built, which is what lets c.clientHas save the query and not just the bytes. A handler that never asks still answers 304 — nilo compares on the way out — but has done the work.

It is a u64. A timestamp in milliseconds fits; text — an updated_at kept as a string, say — is one hash away:

const version = std.hash.Wyhash.hash(0, row.updated_at.view());

The tag is weak, W/"1a", because a version says the representation is the same and promises nothing about the bytes — the same value goes out gzipped to one client and plain to another. Weak is all If-None-Match ever compares by. headers on the value go out on the 200 and the 304 both, which is where a Cache-Control belongs — .unchangedWith(version, headers) is the 304 with them on. .unchanged(version) to a client that did not send the version is a 500 naming the route: the handler skipped the work without asking.

Versioned(?T) is a compile error — null would mean a 404 and "you have it" both — so a thing that is not there is nilo.fail.notFound. So is a Versioned inside a Status or a Response, and one under a Cached or an Idempotent, where a 304 decided for the first client would be replayed to the rest. The API description puts the ETag on the 200 and a 304 beside it.

JSON shapes of your own

A struct is its JSON and an enum is its tag name, and that covers nearly everything. Two shapes it doesn't cover are the ones a REST API tends to be full of, and a type says which it wants with one declaration (ADR 016).

A union is externally tagged by default — {"metrics":{…}}, one object with one key — which is what std.json writes and what nilo sends if you say nothing. .tag asks for the other encoding, with the variant's name beside its own fields:

const nilo = @import("nilo_http");

const Condition = union(enum) {
    pub const nilo_json = .{ .tag = "signal" };
    pub const jsonParse = nilo.jsonParseFor(@This());

    metrics: struct { metric_name: []const u8, threshold: f64 },
    logs: struct { query: []const u8, count_over: u32 = 1 },
    disabled,
};

const Severity = enum {
    pub const nilo_json = .{ .rename_all = .SCREAMING_SNAKE_CASE };
    pub const jsonParse = nilo.jsonParseFor(@This());

    info,
    needs_attention,
};

const Rule = struct { id: u32, severity: Severity, condition: Condition };
{"id":3,"severity":"NEEDS_ATTENTION","condition":{"signal":"logs","query":"level:error","count_over":5}}

A variant carrying nothing is the tag on its own — {"signal":"disabled"}.

rename_all spells a name the way the wire wants it. An enum's tags, a union's variant names, and a struct's field names.

not_found becomes
.lowercase notfound
.UPPERCASE NOTFOUND
.camelCase notFound
.PascalCase NotFound
.SCREAMING_SNAKE_CASE NOT_FOUND
.@"kebab-case" not-found

The first two join the words rather than keeping the underscore, which is what serde does and what the names literally say. .SCREAMING_SNAKE_CASE is the one that keeps it. There is no .snake_case, because that is what a Zig field name already is. Two names that land on one is a compile error — it would put the same key in an object twice.

A response whose keys are camelCase

Your Rows are snake_case because Postgres is, and your wire is camelCase because the browser is. Saying so once beats a mapping function written out field by field, which is what a DTO layer is — and which nothing holds against the Row it came from, so a column added to the Row reaches the wire only if somebody remembers the second file (ADR 148).

const Contact = struct {
    pub const nilo_json = .{ .rename_all = .camelCase };

    id: u32,
    full_name: []const u8,   // goes out as "fullName"
    partner_id: u32,         // and "partnerId"
};

The API description says the same keys, so a generated client reads what the server sends. It costs nothing per request: the name is settled while compiling either way.

One field that no case reaches is spelled on its own. A column called estimated_cost_amount_minor that the frontend knows as estimatedCostMinor is one .rename entry, and the entry wins over the case for that field alone (ADR 168):

const Summary = struct {
    pub const nilo_json = .{
        .rename_all = .camelCase,
        .rename = .{ .estimated_cost_amount_minor = "estimatedCostMinor" },
    };

    id: u32,
    estimated_cost_amount_minor: i64,   // goes out as "estimatedCostMinor"
    due_at: ?[]const u8,                // and "dueAt", by the case
};

.rename on its own, with no case, is fine too. A name that is not a field, a spelling that is the field's own name, and an entry that lands on the key another field already takes are each a compile error where the marker is written.

It is a spelling for what goes out. std.json picks the parser for a body and reads it into the field names as they are written, so a struct with rename_all or .rename used as a request body, a form or a query string is a compile error naming the route — that route would document fullName and answer 400 to a client that sent it. Give what comes in a struct of its own, spelled the way the wire spells it. One direction that works beats two that can disagree about one field.

A renamed struct nilo's own writer cannot reach is refused too. It errs narrow on purpose, so one shape it does not recognise — a tuple, an array of bytes, an untagged union, a type that writes its own JSON and says nothing about it, anything past eight deep — sends the whole value to std.json, which does not read the marker.

A type that writes its own JSON and says what it looks like is not one of those. sql.Uuid, sql.Timestamp, sql.AsText and id.Uuid all carry a nilo_openapi beside their jsonStringify, and a marker may only name a scalar — so nilo knows the value is one string or one number and keeps writing the object around it (ADR 148). A Row holding uuids can rename its fields, which is the ordinary case and was the whole reason this reopened.

That also makes such a response faster whether or not it renames anything, and by more than it sounds: the writer is chosen for the whole value, so one field it would not touch used to send every string beside it to std.json too. 250ns → 165ns on a 305-byte row with three uuids in it. Your own type gets the same by writing the same two declarations.

The marker is per type, not inherited. A struct renames its own fields; a union renames its variants and leaves a payload struct's fields to that struct's own marker; a nested struct that says nothing keeps its own spelling.

Why the second line. Writing needs no jsonParse — nilo makes the call, so it reads the marker itself. Reading does, because std.json is what picks a parser for a type and nothing can add a declaration to a type you wrote. So the type hands over a reader nilo supplies. Leave the line off if the type is only ever sent and never received; nilo will say so if you add it to a type that never said its JSON was spelled differently.

The generated API description follows either encoding, so a client generated from it reads what the server actually sends (the API description).

One request, one response

A response is written in one go. There is no "start the response, change your mind" — that state doesn't exist, so neither do the bugs where a header set too late silently vanishes. If you need to decide as you go, that's what a stream is for, and even there the head goes out first and is fixed once written.

It is on the wire before the connection next waits for the client. For a client that sends a request and waits for the answer, which is every browser, that is the moment send returns. A client that pipelines, sending its next request before reading this answer, gets the answers in one write rather than one each; it was not waiting, and the batch is bounded by write_buffer (ADR 201).

Sending twice is an assertion failure rather than two responses on the wire. A handler that fails after sending gets its connection closed, because a half-sent response can't be taken back and the next request on that connection would read bytes of unclear provenance. It is logged:

warning: handler GET /report failed after answering: WriteFailed

Keep-alive

nilo decides. HTTP/1.1 keeps the connection open unless the client says Connection: close; HTTP/1.0 closes unless it asks otherwise; a failed stream or an unreadable body closes. c.keepAlive() reports what will happen. Nothing a handler does has to think about it — a 404 is a normal thing to answer, not a reason to hang up.

The response says so only when there is something to say: Connection: close when it is closing, Connection: keep-alive to an HTTP/1.0 client being kept, and nothing at all on an HTTP/1.1 connection staying open, because that is what HTTP/1.1 means (ADR 197). Every response also carries a Date, which is what a cache in front reads to decide how old the answer is.

Compression

Off unless asked for. One line asks:

try app.compress(.{});

From then on every answer that is text, at least a kilobyte long and going to a client whose Accept-Encoding takes gzip goes out gzipped, whether it came from sendJson, sendText, send or a typed handler returning a value. The response carries Content-Encoding: gzip, Vary: Accept-Encoding and the compressed length. A client that sent no Accept-Encoding, or gzip;q=0, gets the body as it is and no Content-Encoding at all.

Default
min_bytes 1024: bodies shorter than this go out as they are; compressing a hundred bytes makes them longer
level .default, zlib's level 6. .fastest is level 1, roughly a fifth larger and a little quicker; .best is level 9, under one percent smaller and five to nine percent slower

What counts as text is the list static files use: text/*, JSON, JavaScript, XML, WASM, the +json and +xml structured types. A PNG, a woff2 or an application/octet-stream is left alone, as is a body under min_bytes, a 204, and an answer whose handler set Content-Encoding itself: a body you gzipped is not gzipped twice. A HEAD carries the length its GET would have.

Three things are never compressed here. A static file, because it was gzipped once when the App was built and that copy costs nothing per request (Static files). A stream, because it has no whole body to compress and would hold a compressor across every write. An event stream, because it must never be buffered at all (ADR 211).

What it costs, stated because every feature here states it. One compressor per thread, about 288 KB each, allocated once when the chains are resolved and never on a connection's stack: 4.6 MB on sixteen threads. One arena allocation on a request that is compressed, for the compressed body, and none on a request that is not. And the gzip itself, which for a 4 KB JSON answer at .default is about 37 µs on one core, of which 6 µs is the compressor being reset; zig build bench-compress prints the table for the machine you are on. Nothing per connection, and nothing on a request under the threshold, which a test holds.

Content types

Returned Sent as
void no body, and no Content-Type either
Str, []const u8 text/plain
FileBody its content_type, application/octet-stream by default
a type with nilo_content_type that, and the bytes its nilo_write wrote — below
anything else application/json

A failure — from a fail.* function, from an error, from nilo refusing a request — is always application/json. See Errors.

For anything else, c.send(status, content_type, bytes), or c.stream(status, content_type) when the length isn't known yet.

A type that writes its own answer

nilo answers JSON, and it is not going to learn XML, CSV or a template language (ADR 157 says why). What it will do is send bytes a type of yours wrote, under a label the type names — which is what a consumer that only reads XML needs, and what a *Ctx handler calling c.send used to be the only way to get:

const Invoice = struct {
    number: u32,
    total: i64,

    pub const nilo_content_type = "application/xml";

    pub fn nilo_write(self: Invoice, w: *std.Io.Writer) !void {
        try w.print("<invoice><number>{d}</number><total>{d}</total></invoice>", .{ self.number, self.total });
    }
};

fn showInvoice(number: u32) ?Invoice {
    if (number == 0) return null;
    return .{ .number = number, .total = 1500 };
}

Return it the way you would return a struct — bare, in a ?, in a Status(201, …) or a Response(…) — and the wrappers mean what they always mean. The difference from c.send is that the route is described: the document names application/xml, and says what the body looks like if the type adds pub const nilo_openapi = .{ .type = "string" };.

Both declarations, or neither: a content type with no nilo_write, or the other way round, is a compile error naming the route.

Static files get their type from the file extension — see Static files.