{"id":51402032,"url":"https://github.com/sirhco/zax","last_synced_at":"2026-07-04T07:33:48.010Z","repository":{"id":365615628,"uuid":"1269795251","full_name":"sirhco/zax","owner":"sirhco","description":"▎ A fast, dependency-free HTTP web framework for Zig 0.16 —   ▎ Axum-style comptime extractors, composable middleware,   ▎ WebSocket, and SSE, on both threaded and evented   ▎ (epoll/kqueue) backends.","archived":false,"fork":false,"pushed_at":"2026-06-25T18:55:53.000Z","size":1227,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-25T20:13:00.064Z","etag":null,"topics":["backend","comptime","epoll","http","http-server","kqueue","middleware","networking","rest-api","web-framework","websocket","zero-dependency","zig","zig-library","zig-package"],"latest_commit_sha":null,"homepage":"","language":"Zig","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sirhco.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-15T05:28:04.000Z","updated_at":"2026-06-25T18:55:58.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/sirhco/zax","commit_stats":null,"previous_names":["sirhco/zax"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/sirhco/zax","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sirhco%2Fzax","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sirhco%2Fzax/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sirhco%2Fzax/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sirhco%2Fzax/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sirhco","download_url":"https://codeload.github.com/sirhco/zax/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sirhco%2Fzax/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35114172,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-04T02:00:05.987Z","response_time":113,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["backend","comptime","epoll","http","http-server","kqueue","middleware","networking","rest-api","web-framework","websocket","zero-dependency","zig","zig-library","zig-package"],"created_at":"2026-07-04T07:33:46.946Z","updated_at":"2026-07-04T07:33:48.004Z","avatar_url":"https://github.com/sirhco.png","language":"Zig","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cpicture\u003e\n    \u003csource media=\"(prefers-color-scheme: dark)\" srcset=\"assets/zax-icon-velocity-dark.svg\"\u003e\n    \u003cimg src=\"assets/zax-icon-velocity-light.svg\" height=\"96\" alt=\"zax\"\u003e\n  \u003c/picture\u003e\n\u003c/p\u003e\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"assets/zax-wordmark.svg\" height=\"40\" alt=\"zax\"\u003e\n\u003c/p\u003e\n\n[![CI](https://github.com/sirhco/zax/actions/workflows/ci.yml/badge.svg)](https://github.com/sirhco/zax/actions/workflows/ci.yml)\n\nAn [Axum](https://github.com/tokio-rs/axum)-style HTTP web framework for **Zig 0.16.0**.\nTyped handlers, comptime extractors, a radix router, read-only shared state, and\ngraceful shutdown — built from scratch on the new `std.Io` interface.\n\n**New here?** Read [`docs/getting-started.md`](docs/getting-started.md) and run the\nstandalone consumer in [`examples/hello-service/`](examples/hello-service).\n\n## Examples\n\nRunnable, self-contained apps under [`examples/`](examples) — each is its own package\nthat depends on this repo, so `cd examples/\u003cname\u003e \u0026\u0026 zig build run` just works. See the\n[examples cookbook](docs/examples.md) for walkthroughs.\n\n| Example | Demonstrates |\n| --- | --- |\n| [`hello-service`](examples/hello-service) | Minimal app: `State`, `Path`, `Query`, `Json`, middleware |\n| [`todo-api`](examples/todo-api) | REST/CRUD JSON API, mutable `State` + atomic spinlock, metrics + access log |\n| [`auth-sessions`](examples/auth-sessions) | Cookie sessions + a guard middleware (401 on missing/invalid) |\n| [`file-upload`](examples/file-upload) | `multipart/form-data` uploads + static file serving |\n| [`websocket-live`](examples/websocket-live) | WebSocket echo on both `serve` and `serveEvented` |\n\n```zig\nconst zax = @import(\"zax\");\n\nfn index() zax.Response {\n    return zax.Response.text(\"Hello from Zax\\n\");\n}\n\nfn getUser(p: zax.Path(struct { id: u64 }), a: zax.Alloc) !zax.Response {\n    const body = try std.fmt.allocPrint(a.value, \"user {d}\\n\", .{p.value.id});\n    return zax.Response.text(body);\n}\n\npub fn main(init: std.process.Init) !void {\n    var app = try zax.App(*const Db).init(init.gpa, \u0026db, .{});\n    defer app.deinit();\n    try app.get(\"/\", index);\n    try app.get(\"/users/:id\", getUser);\n    try app.serve(init.io, .{ .ip4 = .loopback(8080) });\n}\n```\n\n## Why 0.16.0\n\nZax is built on genuinely-new 0.16.0 capabilities (verified against the shipped\ncompiler — see [`docs/zig016-api-notes.md`](docs/zig016-api-notes.md)):\n\n- **`std.Io` as an interface.** The server takes a `std.Io` value the same way\n  other code takes an `Allocator`. It names no concrete backend, so the same\n  framework code runs on `Io.Threaded` (thread pool) today and a future\n  `Io.Evented` (io_uring/kqueue) unchanged — your middleware and handlers are\n  portable across the concurrency model.\n- **\"Juicy Main.\"** `pub fn main(init: std.process.Init)` hands you a ready\n  allocator (`init.gpa`) and Io (`init.io`) — no manual GPA/event-loop setup.\n- **Comptime signature reflection.** Handlers are plain functions; Zax inspects\n  their parameter types at compile time (`@typeInfo(...).@\"fn\".params` +\n  `std.meta.ArgsTuple`) and wires each to an extractor. Zero runtime dispatch.\n\n## Extractors\n\nA handler is any function whose parameters are extractor types. They are filled\nin declaration order; the order maps to nothing magic except that a **body\nextractor must come last** (enforced at compile time).\n\n| Extractor | Binds |\n|---|---|\n| `Path(T)` | path params (`/users/:id`) → struct fields or a scalar (percent-decoded) |\n| `Query(T)` | query string → struct fields (`?T` = optional, percent-decoded) |\n| `Json(T)` | request body parsed as JSON (arena-allocated) — must be last |\n| `State(T)` | the app's read-only shared state (no locks, no refcount) |\n| `Alloc` | the per-request arena allocator, for building response bodies |\n| `Forwarded` | proxied connection info (`scheme`/`host`/`client_ip`) from `X-Forwarded-*` |\n| `Form(T)` | urlencoded request body → struct fields (must be last) |\n| `Cookies` | request cookies via `.get(name)` |\n| `Bytes` | the raw request body (`[]const u8`, must be last) |\n| `Multipart` | parse `multipart/form-data` request bodies (file uploads) → a zero-copy parts list; `mp.field(name)` (text), `mp.file(name)` (file), `mp.part(name)` (either); must be last |\n| `Headers` | access all request headers — `.get(name)` (first match, case-insensitive), `.has(name)`, `.getAll(arena, name)` (all matches), `.all()`, `.count()` |\n| `Files` | serve files: `files.file(path)` / `files.dir(root, requested)` (traversal-safe) |\n| `RequestId` | the request's correlation id (validated incoming `X-Request-Id` or generated) — opt-in via `Options.request_id`; see [Request IDs](#request-ids) |\n| `WebSocket` | upgrade a connection to WebSocket via `.onUpgrade(handler)`; see [WebSocket](#websocket) |\n\nHandlers return anything that satisfies `IntoResponse`: a `Response`, a `Status`,\na byte-string, or a custom type with `pub fn intoResponse(self) Response`. A\nreturned error becomes a `500`.\n\n### Multipart form data (file uploads)\n\nThe `Multipart` extractor parses `multipart/form-data` request bodies into a\nzero-copy parts list, reading directly from the request buffer:\n\n```zig\nfn upload(mp: zax.Multipart, a: zax.Alloc) !zax.Response {\n    const desc = mp.field(\"desc\") orelse \"untitled\";\n    if (mp.file(\"upload\")) |file| {\n        // file is a zax.MultipartPart{ name, filename, content_type, data }\n        // where data is a []const u8 slice into the request body\n        const body = try std.fmt.allocPrint(a.value, \"uploaded {s} ({d} bytes): {s}\\n\",\n            .{ file.filename, file.data.len, desc });\n        return zax.Response.text(body);\n    }\n    return .{ .status = .bad_request };\n}\n// curl -F \"desc=my file\" -F \"[email protected]\" localhost:8080/upload\n```\n\nErrors: malformed multipart framing → `400`, exceeding 1024 parts or `max_body_size` → `413`.\nEach `Part` is valid for the request's lifetime (borrowed slices into the request body).\n\n### Request headers\n\nThe `Headers` extractor gives zero-copy, case-insensitive access to every request header:\n\n```zig\nfn echo(h: zax.Headers, a: zax.Alloc) !zax.Response {\n    const accept = h.get(\"accept\") orelse \"*/*\";\n    // all values for a repeated header (allocates only the result slice)\n    const fwds = try h.getAll(a.value, \"x-forwarded-for\");\n    _ = fwds; // []const []const u8, in order\n    return zax.Response.text(accept);\n}\n```\n\n`.get(name)` returns the first match; `.has(name)` tests existence; `.getAll(arena, name)`\ncollects every value into an arena-allocated slice; `.all()` / `.count()` expose the full list.\n\n### Cookies\n\nThe `Cookies` extractor reads cookies from the `Cookie` request header —\n`.get(name)` returns the first matching value (raw; not percent-decoded):\n\n```zig\nfn handler(c: zax.Cookies) zax.Response {\n    const sid = c.get(\"sid\") orelse return zax.Response.fromStatus(.unauthorized);\n    _ = sid;\n    return zax.Response.text(\"ok\\n\");\n}\n```\n\nTo **set** cookies on the response, use `zax.SetCookie` with\n`Response.withCookie(arena, cookie)` or `Response.expireCookie(arena, name, path)`.\n\n`zax.SetCookie` fields:\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n| `name` | `[]const u8` | — | Cookie name (RFC 6265 token) |\n| `value` | `[]const u8` | — | Cookie value (raw, validated cookie-octet) |\n| `max_age` | `?i64` | `null` | `Max-Age` in seconds; `0` expires immediately |\n| `domain` | `?[]const u8` | `null` | `Domain` attribute |\n| `path` | `?[]const u8` | `null` | `Path` attribute |\n| `secure` | `bool` | `false` | Adds `; Secure` |\n| `http_only` | `bool` | `false` | Adds `; HttpOnly` |\n| `same_site` | `?zax.SameSite` | `null` | `.strict` → `Strict`, `.lax` → `Lax`, `.none` → `None` |\n\n`withCookie` appends a `set-cookie` header; multiple calls emit multiple lines.\n`serialize` validates the name (RFC 6265 token) and value (cookie-octet: rejects\nCTL, space, `\"`, `,`, `;`, `\\`); empty value is allowed. The value is emitted\n**raw** (symmetric with the `Cookies` read extractor, which does not percent-decode).\n`domain` and `path` are emitted as-is — only CR/LF are rejected (to prevent\nheader injection); do not interpolate untrusted data into these attributes.\n\n\u003e **Note:** Browsers require `Secure` when `SameSite=None` — set `.secure = true`\n\u003e explicitly; it is not auto-enforced.\n\n```zig\nfn login(a: zax.Alloc) !zax.Response {\n    return (try zax.Response.text(\"welcome\").withCookie(a.value, .{\n        .name = \"sid\",\n        .value = \"abc123\",\n        .max_age = 3600,\n        .path = \"/\",\n        .http_only = true,\n        .same_site = .lax,\n    }));\n}\n\nfn logout(a: zax.Alloc) !zax.Response {\n    // clear the cookie: empty value, Max-Age=0\n    return zax.Response.text(\"bye\").expireCookie(a.value, \"sid\", \"/\");\n}\n```\n\n`expireCookie(arena, name, path)` is shorthand for `.withCookie` with an empty\nvalue, `Max-Age=0`, and the given path (pass `null` to omit `Path`).\n\n## Middleware\n\nRegister an ordered chain wrapping matched route handlers. A middleware gets the\ncontext and a `*Next` cursor; calling `next.run()` continues the chain. This\ncovers pass-through, **short-circuit** (return without `next` — e.g. auth), and\n**post-processing** (call `next`, then mutate the response).\n\n```zig\nconst Api = zax.App(*const Db);\n\nfn requestId(ctx: *const Api.Context, next: *Api.Next) anyerror!zax.Response {\n    const r = try next.run();\n    return r.withHeader(ctx.arena, \"x-request-id\", \"…\");\n}\n\ntry app.use(\u0026requestId);\n```\n\nMiddleware run after routing, so `404`/`405` short-circuit before the chain.\n\n### Per-route middleware\n\n`getWith` / `postWith` / `putWith` / `deleteWith` (and the generic `routeWith`)\nattach middleware to a single route. They run after the global chain and before\nthe handler, in tuple order:\n\n```zig\nfn requireAuth(ctx: *const Api.Context, next: *Api.Next) anyerror!zax.Response {\n    if (ctx.req.header(\"authorization\") == null) return zax.Response.fromStatus(.unauthorized);\n    return next.run();\n}\n\ntry app.getWith(\"/admin\", .{\u0026requireAuth}, adminHandler);\ntry app.get(\"/\", homeHandler); // unchanged, no per-route middleware\n```\n\n### Route groups\n\n`app.group(prefix, .{ ...middleware })` returns a group that shares a path prefix\nand middleware across its routes. Groups nest and reuse the same verbs\n(`get`/`post`/… and `getWith`/…). Order is global → group → route → handler:\n\n```zig\nconst api = app.group(\"/api\", .{\u0026requireAuth});\ntry api.get(\"/users\", listUsers);      // GET /api/users (global -\u003e requireAuth -\u003e handler)\n\nconst v1 = api.group(\"/v1\", .{\u0026requestId});\ntry v1.post(\"/items\", createItem);     // POST /api/v1/items\n```\n\n### Built-in: CORS\n\n`zax.cors(comptime Ctx: type, comptime config: zax.Cors)` returns a global\nmiddleware that attaches `Access-Control-*` headers and answers `OPTIONS`\npreflight requests with `204 No Content`.  Register it with `app.use`:\n\n```zig\nconst Api = zax.App(*const Db);\n\nvar app = try Api.init(init.gpa, \u0026db, .{});\ntry app.use(zax.cors(Api.Context, .{\n    .origins = .{ .list = \u0026.{ \"https://example.com\", \"https://app.example.com\" } },\n    .credentials = true,\n    .max_age = 86400,\n}));\n```\n\n**`zax.Cors` config fields** (all comptime, all have defaults):\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n| `origins` | `Origins` | `.any` | `.any` → wildcard; `.list` → exact-match allowlist |\n| `methods` | `[]const u8` | `\"GET, POST, PUT, DELETE, OPTIONS\"` | `Access-Control-Allow-Methods` |\n| `allow_headers` | `[]const u8` | `\"Content-Type\"` | `Access-Control-Allow-Headers` |\n| `expose_headers` | `?[]const u8` | `null` | `Access-Control-Expose-Headers` (omitted when null) |\n| `credentials` | `bool` | `false` | emit `Access-Control-Allow-Credentials: true` |\n| `max_age` | `?u32` | `null` | `Access-Control-Max-Age` in seconds (omitted when null) |\n\n**Origin policies:**\n\n- `.any` — emits `Access-Control-Allow-Origin: *`.  When `credentials = true`,\n  the concrete request `Origin` is reflected instead (browsers require a specific\n  origin with credentialed requests), and `Vary: Origin` is added.\n- `.list = \u0026.{ \"https://a.com\", … }` — exact-matches the request `Origin`\n  against the list.  On match, the origin is reflected and `Vary: Origin` is\n  added.  On miss (or no `Origin` header), no CORS headers are emitted.\n\n**Preflight:** `OPTIONS` requests that carry `Access-Control-Request-Method` are\nanswered with `204` and the appropriate `Allow-*` headers; the handler is not\ncalled.  Because zax runs the global middleware chain even when no `OPTIONS`\nroute is registered, preflight **just works** — no need to add `OPTIONS` routes.\n\n### Built-in: gzip compression\n\n`zax.compress(comptime Ctx: type, comptime config: zax.Compress)` returns a\nglobal middleware that gzip-compresses eligible buffered responses.  Register it\nwith `app.use`:\n\n```zig\nconst App = zax.App(*const Db);\n\nvar app = try App.init(init.gpa, \u0026db, .{});\ntry app.use(zax.compress(App.Context, .{}));\n```\n\n**`zax.Compress` config fields** (all comptime, all have defaults):\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n| `level` | `Level` | `.default` | Compression level: `.fastest`, `.default`, `.best` |\n| `min_length` | `usize` | `1024` | Skip bodies smaller than this many bytes |\n\n**A response is compressed only when all of the following hold:**\n\n- The response is buffered (not a streamed/SSE response).\n- The body is at least `min_length` bytes.\n- The client `Accept-Encoding` header includes `gzip` and does not disable it (`gzip;q=0`).\n- No `Content-Encoding` header is already present on the response.\n- The `Content-Type` is text-like: `text/*`, `application/json`,\n  `application/javascript`, `application/xml`, `image/svg+xml`, or any type\n  ending in `+xml`.\n- Compression actually reduces the body size (no-gain responses are passed through unmodified).\n\nOn success, `Content-Encoding: gzip` and `Vary: Accept-Encoding` are added to\nthe response.  gzip is the only supported encoding.\n\n### Built-in: rate limiting\n\n`zax.rateLimit(comptime Ctx: type, comptime config: zax.RateLimit)` returns a\nglobal middleware that enforces a token-bucket rate limit per client.  Register\nit with `app.use`:\n\n```zig\nconst App = zax.App(*const Db);\n\nvar app = try App.init(init.gpa, \u0026db, .{});\ntry app.use(zax.rateLimit(App.Context, .{ .capacity = 60, .refill_per_sec = 1.0 }));\n```\n\n**`zax.RateLimit` config fields** (all comptime, all have defaults):\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n| `capacity` | `u32` | `60` | Bucket ceiling and burst limit; reported as `x-ratelimit-limit` |\n| `refill_per_sec` | `f64` | `1.0` | Sustained refill rate in tokens per second |\n| `max_keys` | `usize` | `1024` | Static slot-table size; when full, the lowest-tokens key is evicted |\n| `key_max_len` | `usize` | `64` | Keys longer than this are truncated (64 covers IPv6) |\n| `header` | `[]const u8` | `\"x-forwarded-for\"` | Primary header for client IP; first comma-separated hop is used |\n| `fallback_header` | `[]const u8` | `\"x-real-ip\"` | Fallback header when `header` is absent |\n| `on_missing` | `enum { shared, bypass }` | `.shared` | When no key is derivable: `.shared` = one coarse shared bucket; `.bypass` = no limiting |\n\n**Behavior:**\n\n- **Token bucket:** each client starts with `capacity` tokens (the burst ceiling).  One token is consumed per request.  Tokens refill at `refill_per_sec` per second up to `capacity`.\n- **Response headers on every allowed request:** `x-ratelimit-limit` (capacity), `x-ratelimit-remaining` (tokens left after this request), `x-ratelimit-reset` (seconds until the bucket refills to capacity).\n- **Throttled requests** return `429 Too Many Requests` with those three headers plus `retry-after` (seconds to wait).\n- **Key derivation** uses `header` (first hop before the first comma, whitespace-trimmed) then `fallback_header`, but **only when `ctx.trust_forwarded` is `true`**.  When forwarded headers are not trusted, or when neither header yields a usable value, `on_missing` governs: `.shared` applies a single shared bucket to all such requests; `.bypass` passes them through without limiting.\n- **Static storage:** the slot table (`max_keys` entries) is baked into the comptime type — zero heap allocation.  Access is guarded by an atomic spinlock.  When the table is full the slot with the fewest tokens is evicted to make room.\n- **Key truncation:** keys longer than `key_max_len` are silently truncated before lookup.  The default of 64 bytes is sufficient for any IPv6 address.\n- **Comptime memoization caveat:** two `rateLimit` calls with identical `(Ctx, config)` arguments may share one static store.  To guarantee independent buckets per mount point (e.g., different limits for `/api` vs `/admin`), use distinct `config` values.\n\n### Built-in: ETag / conditional requests\n\n`zax.etag(comptime Ctx: type, comptime config: zax.Etag)` returns a global\nmiddleware that adds an `ETag` to buffered `200` GET/HEAD responses and answers\nmatching `If-None-Match` requests with `304 Not Modified`.  Register it with\n`app.use`:\n\n```zig\nconst App = zax.App(*const Db);\n\nvar app = try App.init(init.gpa, \u0026db, .{});\ntry app.use(zax.etag(App.Context, .{}));\n```\n\n**`zax.Etag` config fields** (all comptime, all have defaults):\n\n| Field | Type | Default | Description |\n|---|---|---|---|\n| `weak` | `bool` | `false` | Emit a weak validator `W/\"…\"` instead of a strong `\"…\"` |\n\n**Behavior:**\n\n- **Tagged responses:** only buffered `200 OK` replies to `GET` or `HEAD`\n  requests receive an `ETag` header.  Streaming responses\n  (`streamer`/`pull_streamer`), WebSocket upgrades, non-`200` status codes, and\n  unsafe methods (e.g. `POST`, `PUT`, `DELETE`) pass through untouched — no\n  `ETag` is added and `If-None-Match` is never consulted.\n- **Content hash:** the ETag value is a 16-hex-digit Wyhash of the response body\n  (e.g. `\"a3f2c8e1b047d95e\"`).  Set `weak = true` to prefix it with `W/` for a\n  weak validator.\n- **If-None-Match → 304:** the header is evaluated using RFC 7232 weak\n  comparison — the `W/` prefix is stripped before comparing opaque tags — so a\n  strong `\"abc\"` matches a weak `W/\"abc\"` and vice versa.  The wildcard `*`\n  matches any tag.  Comma-separated lists are supported.  On a match the\n  middleware returns `304 Not Modified` with an empty body; the `etag`,\n  `cache-control`, and `vary` headers from the original response are preserved\n  on the 304.\n- **Handler-set ETag respected:** if the handler already set an `etag` response\n  header, that value is used for both the `ETag` response header and the\n  `If-None-Match` comparison — the body is not re-hashed.\n- **Registration order:** register `etag` **before** `compress` so the hash\n  covers the compressed bytes.  (`compress` sets `Vary: Accept-Encoding`,\n  ensuring each encoding gets its own cache entry.)\n- **Zero heap:** all allocations use the per-request arena — no additional heap\n  usage.\n\n## Observability\n\n`app.observe(obs)` registers an `zax.Observer` that fires after every routed\nrequest — matched routes, 404, 405, and handler errors — including after streamed\nresponses. (Parse/transport-level failures that close the connection before\nrouting — malformed head, read timeout, oversized or chunked body — have no\nparsed request and are not observed.)\nMultiple observers may be registered; they run in registration order. Zero\noverhead when none are registered.\n\nEach observer receives a `zax.AccessRecord`:\n\n| Field | Type | Description |\n|---|---|---|\n| `method` | `zax.Method` | HTTP method |\n| `path` | `[]const u8` | request path (slice into the read buffer) |\n| `status` | `u16` | response status code |\n| `duration_ns` | `u64` | dispatch + write time in nanoseconds |\n| `bytes` | `usize` | buffered response body length (0 for streamed responses) |\n\nThe built-in `zax.AccessLogger` is thread-safe and writes one line per request.\nDefault format is `.text` (`GET /users/42 200 0.412ms 18b`); set `.json` for\nnewline-delimited JSON (`{\"method\":\"GET\",\"path\":\"/users/42\",\"status\":200,\"dur_us\":412,\"bytes\":18}`).\nCall `logger.observer()` to get the `Observer` to pass to `app.observe`.\n\n```zig\npub fn main(init: std.process.Init) !void {\n    var app = try zax.App(*const Db).init(init.gpa, \u0026db, .{});\n    defer app.deinit();\n\n    var stderr_writer = init.io.stderr(); // std.Io.Writer\n    var logger = zax.AccessLogger{ .writer = \u0026stderr_writer, .format = .text };\n    try app.observe(logger.observer());\n\n    try app.get(\"/users/:id\", getUser);\n    try app.serve(init.io, .{ .ip4 = .loopback(8080) });\n}\n```\n\n\u003e **Streamed-bytes caveat:** `bytes` is the buffered response body length; it is\n\u003e `0` for streamed responses (`Response.stream` / `Response.sse`) because the\n\u003e streamed body bytes are not counted.\n\n### Request IDs\n\nEnable per-request correlation IDs with `.{ .request_id = true }`:\n\n```zig\nvar app = try zax.App(*const Db).init(init.gpa, \u0026db, .{ .request_id = true });\n```\n\nWhen enabled, each request is assigned an ID: a validated incoming `x-request-id`\nheader is accepted if it is 1–128 characters with charset `[A-Za-z0-9._-]`; an\nabsent or unsafe header causes a 16-hex-digit ID to be generated instead (per-app\natomic counter). The ID is:\n\n- accessible in handlers via the `zax.RequestId` extractor (`rid.value`) or\n  directly as `ctx.request_id`;\n- echoed on the response as the `x-request-id` header;\n- included in access-log records (`id=…` text / `\"request_id\":\"…\"` JSON) when an\n  `AccessLogger` is registered.\n\nIncoming IDs are validated against a safe charset before being echoed or logged,\npreventing CRLF response-header injection and log-injection attacks.\n\n```zig\nfn echoId(rid: zax.RequestId, a: zax.Alloc) !zax.Response {\n    const body = try std.fmt.allocPrint(a.value, \"request id: {s}\\n\", .{rid.value});\n    return zax.Response.text(body);\n}\n```\n\nOff by default — zero overhead and identical behavior when disabled.\n\n### Metrics\n\n`zax.Metrics` is a built-in observer that aggregates request outcomes into\nlock-free atomic counters. Wire it the same way as `AccessLogger`:\n\n```zig\nvar metrics = zax.Metrics{};\ntry app.observe(metrics.observer());\n```\n\nIt tracks (thread-safely, from the post-response hook):\n\n- **Total requests** and **per-status-class counters** (`1xx`–`5xx`)\n- **Total response bytes** (buffered; `0` for streamed responses)\n- **Request-latency histogram** using the Prometheus default buckets\n  (0.005 s, 0.01 s, 0.025 s … 10 s)\n\n\u003e In-flight request count is **not** tracked — the hook fires after the\n\u003e response is written.\n\n**Point-in-time snapshot** (plain `u64`s, no atomics):\n\n```zig\nconst snap: zax.MetricsSnapshot = metrics.snapshot();\n// snap.total, snap.class[2], snap.bytes_total, snap.duration_sum_ns, snap.buckets[…]\n```\n\n**Prometheus text exposition** — call `metrics.writePrometheus(writer)` where\n`writer` is a `*std.Io.Writer`. It emits:\n\n- `zax_requests_total{class=\"Nxx\"} N` (one line per class)\n- `zax_response_bytes_total N`\n- `zax_request_duration_seconds` histogram (`_bucket{le=\"…\"}` cumulative,\n  `_sum`, `_count`)\n\nThere is **no built-in `/metrics` route** — serve it yourself with a small\nhandler. Access `metrics` via app state or a module-level variable:\n\n```zig\nvar METRICS = zax.Metrics{};\n\nfn metricsHandler(a: zax.Alloc) !zax.Response {\n    var w = std.Io.Writer.Allocating.init(a.value);\n    try METRICS.writePrometheus(\u0026w.writer);\n    return .{ .status = .ok, .content_type = \"text/plain; version=0.0.4\", .body = w.written() };\n}\n\n// in main, after creating app:\ntry app.observe(METRICS.observer());\ntry app.get(\"/metrics\", metricsHandler);\n```\n\n## Fallback\n\nRegister a handler for requests that match no route — a custom 404 or an SPA\nindex fallback. It runs through the global middleware chain (not-found only;\nmethod-not-allowed still returns 405):\n\n```zig\nfn notFound() zax.Response { return zax.Response.fromStatus(.not_found); }\ntry app.fallback(notFound);\n\n// SPA: serve index.html for any unknown path\nfn spa(files: zax.Files) !zax.Response { return files.file(\"static/index.html\"); }\ntry app.fallback(spa);\n```\n\n## Wildcard routes\n\nA `*name` segment is a catch-all: it matches one or more remaining path\nsegments and captures the tail (slashes included) into `name`. It must be the\nlast segment, and does not match the bare prefix (`/assets/*path` matches\n`/assets/a/b` but not `/assets`). Static and `:param` routes take priority.\n\n```zig\nfn serveAsset(p: zax.Path(struct { path: []const u8 }), files: zax.Files) !zax.Response {\n    return files.dir(\"static\", p.value.path);\n}\ntry app.get(\"/assets/*path\", serveAsset);\n// GET /assets/css/app.css -\u003e path = \"css/app.css\"\n```\n\n## Design notes\n\n- **Zero-copy.** The HTTP/1.1 parser and router return `[]const u8` slices\n  pointing into the connection read buffer — no heap copies for methods,\n  headers, path params, or query values. `Json` is the only allocating\n  extractor. Borrowed slices are valid for the request's lifetime only.\n- **Per-request arena.** Each connection gets its own `ArenaAllocator` over a\n  backing allocator, freed wholesale at end of request. Because each request\n  owns its arena, there is no cross-thread arena sharing.\n- **Per-connection keep-alive.** Persistent HTTP/1.1 connections (Content-Length\n  framing); the arena is reset between requests and the read buffer reused via\n  `toss`/`rebase`. Honors `Connection`. Inbound `Transfer-Encoding: chunked`\n  request bodies are decoded (bounded by `max_body_size`); streamed responses use\n  chunked framing to keep the connection alive on HTTP/1.1 persistent clients.\n- **`TCP_NODELAY` on every connection.** Nagle's algorithm is disabled so small\n  responses are sent immediately instead of being held for the peer's delayed\n  ACK (~40 ms) — standard for low-latency HTTP servers.\n- **Graceful drain.** `app.requestShutdown(io)` stops accepting (it closes the\n  listening socket, which unblocks `accept`) and the accept loop then awaits an\n  `Io.Group` of in-flight connections before returning.\n\n## HTTPS\n\nstd 0.16 has no server-side TLS, so terminate TLS at a reverse proxy\n(nginx/Caddy/Cloudflare) and run Zax plaintext on localhost. Enable\n`.trust_forwarded = true` and read `zax.Forwarded` for the real scheme/host/IP.\nSee [`docs/deploy-https.md`](docs/deploy-https.md).\n\n## Run\n\n```sh\nzig build test     # full unit + integration suite\nzig build run      # demo server on :8080\nzig build bench    # micro + loopback load benchmarks (ReleaseFast); warmup + multi-sample\n```\n\n```sh\ncurl localhost:8080/                              # Hello from Zax\ncurl localhost:8080/users/42                      # user 42\ncurl -X POST localhost:8080/users -d '{\"name\":\"ada\"}'   # zax-demo: created user ada\n```\n\n## Error handling\n\nExtractor failures and handler errors map to real HTTP statuses (not a blanket\n500). Handlers raise typed statuses with the canonical `zax.Error` set:\n\n```zig\nfn getUser(s: zax.State(*const Db), p: zax.Path(struct { id: u64 })) !zax.Response {\n    const user = s.value.lookup(p.value.id) orelse return error.NotFound; // -\u003e 404\n    return zax.Response.text(user.name);\n}\n```\n\nA non-numeric `:id` becomes `400`, a malformed `Json` body `422`, `error.Conflict`\n`409`, and any unrecognized error `500`. Customize rendering (e.g. JSON bodies)\nwith one hook:\n\n```zig\nfn renderError(e: anyerror, info: zax.ErrorInfo, ctx: *const Api.Context) zax.Response {\n    _ = e;\n    const body = std.fmt.allocPrint(ctx.arena, \"{{\\\"error\\\":\\\"{s}\\\"}}\", .{info.reason}) catch\n        return zax.Response.fromStatus(info.status);\n    var r = zax.Response.jsonRaw(body);\n    r.status = info.status;\n    return r;\n}\n\napp.onError(\u0026renderError); // applies to extractor, handler, 404, and 405 responses\n```\n\nNote: classification keys off the error value, so handlers should use the\ncanonical `zax.Error` set; an unrecognized error is treated as `500`, and\n`on_error` can re-classify by inspecting the raw error. The full standard HTTP\nstatus set is supported as `Status` variants (`.gone`, `.unsupported_media_type`,\n`.not_acceptable`, `.precondition_failed`, `.bad_gateway`, `.gateway_timeout`,\nand more). For arbitrary or non-standard codes, use `Response.fromCode(u16)`.\nThe expanded `zax.Error` set covers all common handler-facing statuses; each\nvariant maps to a canonical status via `classify`.\n\n## Responses\n\nBuild responses with the `Response` constructors:\n\n| Constructor | Result |\n|---|---|\n| `Response.text(s)` | `text/plain` body |\n| `Response.html(s)` | `text/html` body |\n| `Response.json(arena, value)` | JSON-serialized body (`application/json`) |\n| `Response.jsonRaw(s)` | pre-serialized JSON string |\n| `Response.stream(Ctx, ctx, fn, ct)` | push-streamed body written by `fn` |\n| `Response.sse(Ctx, ctx, fn)` | push-streamed Server-Sent Events |\n| `Response.streamPull(Ctx, ctx, nextFn)` | pull-streamed body (backpressure-aware; both backends) |\n| `Response.ssePull(Ctx, ctx, nextFn)` | pull-streamed Server-Sent Events (both backends) |\n| `Response.redirect(status, loc)` | redirect with a `Location` header |\n| `Response.seeOther/temporaryRedirect/permanentRedirect(loc)` | 303 / 307 / 308 redirects |\n| `Response.fromStatus(s)` | bare status |\n| `r.withHeader(arena, name, value)` | add a response header |\n| `r.withCookie(arena, SetCookie)` | append a `set-cookie` header (see [Cookies](#cookies)) |\n| `r.expireCookie(arena, name, path)` | clear a cookie (empty value, `Max-Age=0`) |\n\nA streamed response writes its body incrementally to the connection; the `ctx`\nmust be arena-allocated. Useful for large or generated bodies. On HTTP/1.1\npersistent clients the body is framed with **`Transfer-Encoding: chunked`** and\nthe connection is kept alive; HTTP/1.0, `Connection: close`, or a keep-alive-\ndisabled server fall back to connection-close framing.\n\n```zig\nconst Lines = struct { n: usize };\nfn writeLines(c: *const Lines, w: *zax.Writer) anyerror!void {\n    var i: usize = 0;\n    while (i \u003c c.n) : (i += 1) try w.print(\"line {d}\\n\", .{i});\n}\nfn handler(a: zax.Alloc) !zax.Response {\n    const c = try a.value.create(Lines);\n    c.* = .{ .n = 100 };\n    return zax.Response.stream(Lines, c, writeLines, \"text/plain\");\n}\n```\n\nFor Server-Sent Events, `Response.sse(Ctx, ctx, fn)` sets `text/event-stream` and\nhands the handler an `Sse` writer (each `send` is flushed):\n\n```zig\nconst Feed = struct { n: usize };\nfn feed(f: *const Feed, s: *zax.Sse) anyerror!void {\n    var i: usize = 0;\n    while (i \u003c f.n) : (i += 1) try s.send(.{ .event = \"tick\", .data = \"hi\" });\n}\nfn handler(a: zax.Alloc) !zax.Response {\n    const f = try a.value.create(Feed);\n    f.* = .{ .n = 10 };\n    return zax.Response.sse(Feed, f, feed);\n}\n```\n\n`stream` and `sse` are **push** streamers — the handler drives a `Writer` and\nblocks the worker while producing. For non-blocking, backpressure-aware streaming\nthat runs on **both backends** (threaded and the evented reactor), use the\n**pull** model: `Response.streamPull(Ctx, ctx, nextFn)` and\n`Response.ssePull(Ctx, ctx, nextFn)`. `nextFn` is called whenever the connection\ncan accept more bytes and returns the next chunk, `not_ready` (no data yet), or\n`done`:\n\n```zig\nconst Feed = struct { i: usize, n: usize };\nfn next(f: *Feed, buf: []u8) zax.PullResult {\n    if (f.i \u003e= f.n) return .done;\n    if (!ready()) return .{ .chunk = 0 }; // not ready yet — no busy-spin\n    const w = std.fmt.bufPrint(buf, \"row {d}\\n\", .{f.i}) catch return .err;\n    f.i += 1;\n    return .{ .chunk = w.len };\n}\nfn handler(a: zax.Alloc) !zax.Response {\n    const f = try a.value.create(Feed);\n    f.* = .{ .i = 0, .n = 100 };\n    return zax.Response.streamPull(Feed, f, next);\n}\n```\n\nA `chunk = 0` (`not_ready`) producer does **not** busy-spin: the evented backend\nparks the connection on its timer wheel and the threaded backend sleeps, both\nre-polling every `stream_repoll_ms` (default 5 ms; `0` = legacy busy behavior).\nSet `stream_idle_timeout_ms` (default `0` = off) to hard-close a stream that\nproduces no data for that long (truncated — no chunked terminator). These knobs\nlive on `Options` (threaded) and `EventedOptions` (evented).\n\nServe static files with the `Files` extractor — `files.file(\"static/index.html\")`\nfor an explicit path, or `files.dir(\"static\", requested)` to safely serve a\nrequest-derived path under a root (rejects `..`/absolute → 404). Files are\nbuffered (Content-Length set), content-type inferred by extension.\n\n## Limits \u0026 timeouts\n\nConfigurable via `ServerOptions`:\n\n| Option | Default | Effect |\n|---|---|---|\n| `max_body_size` | `0` (buffer-bound) | Content-Length over the limit → `413` |\n| `read_timeout_ms` | `30000` | full head+body must arrive within this once started → `408` |\n| `idle_timeout_ms` | `60000` | max wait for the next keep-alive request → connection closed |\n\nRequest bodies are buffered in the read buffer, so they are bounded by\n`read_buffer_size`; oversized header blocks return `431`. Set a timeout to `0` to\ndisable it.\n\n## Performance\n\nZax aims for low per-request overhead, but treat that as a design goal backed by\nspecific measurements — not a benchmarked claim of being \"fast\" relative to\nanything else. What is actually validated:\n\n- **Zero-copy parsing** — unit tests assert parsed method/path/header/param\n  slices alias the read buffer (pointer-range checks in `parser.zig`,\n  `radix.zig`).\n- **Zero heap allocation on the hot path** — a deterministic test runs\n  parse → route → extract → handler → serialize with the request arena backed by\n  a counting `FailingAllocator` and asserts **zero** backing allocations for a\n  handler that uses no allocating extractor (`server.zig`). `Json` is the only\n  extractor that allocates, and a contrast test confirms it does.\n- **Reproducible micro + load benchmarks** — `zig build bench` (ReleaseFast)\n  runs a discarded warmup pass then N timed samples, reporting\n  `median ns/op +/- stddev` for micro-benchmarks and median throughput with\n  latency percentiles for the end-to-end loopback run.\n  Micro-benchmarks cover: HTTP head parse, radix match (static+param), response\n  serialize, middleware chain (3 pass-throughs), wildcard and nested routing,\n  and the `Path`/`Query`/`Json` extractors.\n  The e2e section runs three named scenarios — static `GET /bench`, param\n  `GET /users/:id`, and JSON `POST /echo` — each with throughput and latency\n  percentiles.\n  Configurable via flags forwarded after `--`:\n\n  | Flag | Default | Meaning |\n  |------|---------|---------|\n  | `--iters N` | 2 000 000 | micro-benchmark loop size |\n  | `--samples N` | 5 | timed passes (median is taken across these) |\n  | `--warmup N` | 1 | discarded warmup passes (0 = skip) |\n  | `--conns N` | 8 | keep-alive connections for e2e load |\n  | `--reqs N` | 5 000 | requests per connection |\n\n  Example: `zig build bench -- --conns 64 --reqs 2000 --samples 5 --warmup 1`\n\n  `iters`, `conns`, `reqs`, and `samples` must be ≥ 1; a bad or zero value\n  prints a usage line and exits nonzero.\n\n- **Memory section** — after the throughput/latency output, a `-- memory\n  (loopback, N conns x M reqs) --` section reports two figures:\n  - `bytes/req` — cumulative bytes the server's allocator requested per\n    request, reported per scenario (static GET / param GET / JSON POST).\n    Includes amortized per-connection buffers; measured by wrapping the app\n    allocator in a counting allocator; the loopback client is not counted.\n    **Interpretability caveat:** at small request counts the per-connection\n    buffer amortization dominates, so the three scenarios read nearly\n    identical — cross-scenario differences only become meaningful at higher\n    request counts.\n  - `peak RSS` — process lifetime high-water mark (whole process, across all\n    bench sections) in MB, via `getrusage`.\n\n  These numbers are self-relative — not comparative against other frameworks\n  or servers.\n\n- **Regression check** — capture a baseline with `zig build bench -- --json \u003e src/bench/baseline.json`\n  (then recommit), then gate future runs with `zig build bench -- --check` (optionally\n  `--tolerance 0.2` to widen the default 15% band). The check gates the stable metrics only —\n  micro `ns/op` and per-scenario `bytes/req` — and exits nonzero on any regression. Throughput\n  and latency are emitted in `--json` but not gated (loopback noise makes them environment-sensitive).\n  The baseline encodes the numbers from the machine that generated it; use a stable CI runner or\n  a local before/after on the same machine for meaningful results. Because the numbers are\n  self-relative, a generous default tolerance (15%) is intentional.\n\n**Read the benchmark caveats.** The e2e numbers are **loopback, in-process,\nsingle-machine, and not comparative** — the client shares the process and Io\nwith the server, so throughput is inflated and sub-microsecond latency is below\nthe monotonic clock's resolution (p50 may print `0.0 us`). The micro ns/op\nfigures (amortized over millions of iterations) are the trustworthy ones. No\ncomparison against `std.http.Server`, http.zig, or non-Zig servers exists yet.\n\n## WebSocket\n\nWebSocket runs on **both** server backends — threaded (`app.serve`) and evented\n(`app.serveEvented`). Declare an endpoint with the `WebSocket` extractor and supply\na handler: `on_message` is required; `on_open` and `on_close` are optional.\n\n```zig\nfn echo(conn: *zax.WsConn, frame: zax.WsFrame) void {\n    conn.send(frame.opcode, frame.payload) catch {};\n}\n\nfn handler(ws: zax.WebSocket) zax.Response {\n    return ws.onUpgrade(.{ .on_message = echo }); // zax.WsHandler{}: on_message required, on_open/on_close optional\n}\n// app.get(\"/echo\", handler);  -\u003e identical under app.serve and app.serveEvented\n```\n\nThe server performs the RFC 6455 handshake, then calls your `on_message` once per\n**whole message** — continuation frames are reassembled for you, so `msg.payload` is\nthe complete message (`msg.opcode` is `.text` or `.binary`). The framework handles\ncontrol frames itself: it replies to pings with pongs and performs the close\nhandshake; those never reach `on_message`. Reassembled messages are bounded by\n`ws_max_message_size` (default 1 MiB); a larger message is rejected with a `1009`\nclose. `conn.send(opcode, payload)` writes a message back; `conn.state(T)` reaches\napp state; `conn.close()` ends the connection. WebSocket is feature-complete for the\ncore protocol; cross-connection broadcast is a future addition.\n\n## Status \u0026 limitations\n\nA focused HTTP/1.1 framework. **Shipped:** routing, comptime extractors,\nkeep-alive, middleware, graceful drain, HTTPS via reverse-proxy termination\n(forwarded-header trust), request size limits, read/idle timeouts, and full\nstandard HTTP status support (the `Status` enum covers all IANA-registered codes;\n`Response.fromCode(u16)` handles non-standard codes; the `zax.Error` set covers\nall common handler-facing statuses).\n\nCI runs `zig build test` on Linux (epoll) and macOS (kqueue) plus a bench compile-check on every push and PR.\n\n**Fuzzing.** `zig build test --fuzz` fuzzes the request-head parser (`parseHead`) and the inbound chunked-body decoder (`decodeInPlace`) with Zig's native fuzzer (no external deps). The same fuzz tests run as a seed-corpus smoke under plain `zig build test`, so CI exercises the harness on every push.\n\nStreaming is full-featured: push (`stream`/`sse`) and pull\n(`streamPull`/`ssePull`) bodies, `Transfer-Encoding: chunked` with keep-alive,\ninbound chunked request-body decoding, and a not-ready backoff + idle cap — on\nboth the threaded and evented backends.\n\n**Not yet built:** in-process TLS (blocked on std — use a proxy) and HTTP/2. The evented reactor (`App.serveEvented`, epoll/kqueue) is\nshipped and opt-in; the default backend remains `Io.Threaded`. (Zax's reactor is\nits own epoll/kqueue loop — std's `Io.Evented` still can't serve TCP in 0.16.0.)\n\nA `SIGINT`/`SIGTERM` handler is not auto-installed (`Io.Threaded` uses signals\nfor cancellation) — wire one to call `app.requestShutdown(io)`.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsirhco%2Fzax","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsirhco%2Fzax","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsirhco%2Fzax/lists"}