feat: zig builder output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 1c1730855291c68b8b59c7d3d799224924c96e16 parent 906035dparent 73268e0
14 files changed, +1595 −0
  • apps/zig/AGENTS.md +37 −0
  • apps/zig/DEPS +1 −0
  • apps/zig/Makefile +45 −0
  • apps/zig/README.md +96 −0
  • apps/zig/STATUS.md +80 −0
  • apps/zig/build.zig +58 −0
  • apps/zig/build.zig.zon +18 −0
  • apps/zig/src/app.zig +385 −0
  • apps/zig/src/db.zig +234 −0
  • apps/zig/src/db_test.zig +71 −0
  • apps/zig/src/main.zig +154 −0
  • apps/zig/src/view.zig +239 −0
  • apps/zig/static/app.css +71 −0
  • apps/zig/static/app.js +106 −0
diff --git a/apps/zig/AGENTS.md b/apps/zig/AGENTS.md
new file mode 100644
index 0000000..93aa2ff
--- /dev/null
+++ b/apps/zig/AGENTS.md
@@ -0,0 +1,37 @@
+# 🤖 AGENTS.md: todo-zig
+
+TodoMVC for the CARLOS bakeoff, written in Zig. The brief and contract at
+the repo root (`BRIEF.md`, `CONTRACT.md`) are binding; this file adds what
+is particular to this app.
+
+## The one rule
+
+**The page works without JavaScript.** Every action is a plain HTML form
+that posts and gets a 303 back. The script in `static/app.js` only
+intercepts those same forms and swaps in the fresh markup the server
+renders. If a feature can't be done with a form and a redirect, it isn't
+done.
+
+## Tenancy
+
+- **One instance for the whole app.** Every visitor is served by the same
+ process and the same SQLite file.
+- **Each visitor's list is kept separately by an anonymous cookie.** The
+ cookie holds 256 random bits; the database stores only its SHA-256, so
+ the database alone can't be used to take over a list. A cookie the app
+ doesn't recognise gets a fresh, empty list.
+- **No sign-in.** There are no accounts, passwords or email addresses.
+- **Trust class C.** Todo text is stored as plain text in SQLite. That is
+ a deliberate choice: the data is low-value, anonymous and typed by the
+ visitor, and nothing is gained by sealing it.
+
+## Working here
+
+- Zig 0.17.0 from `scripts/env.sh`; never reinstall it. SQLite comes in
+ as the official amalgamation through `build.zig.zon` and is compiled by
+ Zig as C.
+- All Zig caches live in `.cache/` (see the `Makefile`), so
+ `make clean-deps` can drop compiled artifacts without touching anything
+ outside this directory.
+- Migrations in `src/db.zig` only ever add. Never drop or rewrite data.
+- Bind `127.0.0.1` only; scratch goes in `mktemp -d -p "$TMPDIR"`.
diff --git a/apps/zig/DEPS b/apps/zig/DEPS
new file mode 100644
index 0000000..6a329e3
--- /dev/null
+++ b/apps/zig/DEPS
@@ -0,0 +1 @@
+sqlite 3.54.1
diff --git a/apps/zig/Makefile b/apps/zig/Makefile
new file mode 100644
index 0000000..f430b8c
--- /dev/null
+++ b/apps/zig/Makefile
@@ -0,0 +1,45 @@
+# todo-zig. Zig comes from the repo's scripts/env.sh; every Zig cache is
+# kept under .cache/ so clean-deps can remove compiled artifacts without
+# touching anything outside this directory.
+
+SHELL := bash
+ZIG ?= zig
+VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
+
+export ZIG_GLOBAL_CACHE_DIR := $(CURDIR)/.cache/zig
+export ZIG_LOCAL_CACHE_DIR := $(CURDIR)/.cache/zig-local
+export ZIG_LOCAL_PKG_DIR := $(CURDIR)/.cache/pkg
+
+BUILD = $(ZIG) build -Doptimize=ReleaseSafe -Dversion=$(VERSION)
+SRC = build.zig build.zig.zon $(wildcard src/*.zig static/*) ../../shared/base.css ../../shared/index.css
+
+.PHONY: build build-arm64 test clean clean-deps bump unbump deploy
+
+build: $(SRC)
+ $(BUILD) -Dtarget=native-linux-musl --prefix .cache/out-native
+ install -D .cache/out-native/bin/app dist/app
+
+build-arm64: $(SRC)
+ $(BUILD) -Dtarget=aarch64-linux-musl --prefix .cache/out-arm64
+ install -D .cache/out-arm64/bin/app dist/app-linux-arm64
+
+test:
+ $(ZIG) build test -Dtarget=native-linux-musl --summary all
+
+clean:
+ rm -rf dist .cache/out-native .cache/out-arm64
+
+# Keeps the downloaded SQLite sources (.cache/zig/p and .cache/pkg) and
+# drops everything compiled from them.
+clean-deps: clean
+ rm -rf .cache/zig-local .cache/zig/o .cache/zig/h .cache/zig/z .cache/zig/b .cache/zig/tmp
+
+bump:
+ sed -i 's/^const health_body = "ok";$$/const health_body = "ok (bumped)";/' src/app.zig
+
+unbump:
+ sed -i 's/^const health_body = "ok (bumped)";$$/const health_body = "ok";/' src/app.zig
+
+deploy:
+ carlos deploy --account bfj --app todo-zig --target linux-arm64 --version $(VERSION) \
+ --message "$${MESSAGE:-todo-zig $(VERSION)}" dist/app-linux-arm64
diff --git a/apps/zig/README.md b/apps/zig/README.md
new file mode 100644
index 0000000..6b03a31
--- /dev/null
+++ b/apps/zig/README.md
@@ -0,0 +1,96 @@
+# 🤖 todo-zig
+
+TodoMVC for the CARLOS bakeoff, in Zig. It's server-rendered and works
+with JavaScript turned off; a small script makes it feel like the
+reference TodoMVC when JavaScript is on. Live at
+[https://todo-zig.bfj.oncarlos.com](https://todo-zig.bfj.oncarlos.com).
+
+## The stack, and why
+
+| Piece | Choice | Why |
+|---|---|---|
+| Language | Zig 0.17.0 | The setup under test |
+| HTTP | `std.http.Server` on `std.Io.net` | Zig's standard library has a complete HTTP/1.1 server. A todo app needs no router or framework, and every outside dependency is one more thing to keep building on a language that still changes between releases |
+| Concurrency | `std.Io.Threaded`, one task per connection | It's what `std.process.Init` hands `main` by default. Blocking code, no event loop to reason about |
+| SQLite | The official amalgamation, fetched by `build.zig.zon` and compiled by Zig as C | Zig compiles C natively, so SQLite needs no binding library: `addTranslateC` turns `sqlite3.h` into a Zig module and `src/db.zig` wraps the dozen calls the app uses |
+| Templates | Hand-written functions in `src/view.zig` writing to a `std.Io.Writer` | One page with one loop. A template engine would be more code than the template |
+| Front end | `static/app.js`, a plain ES module, about 80 lines | No framework and no build step. It sends the page's own forms with `fetch` and swaps in the server's re-rendered list |
+
+The result is one static binary (musl, about 2 MB) with the CSS and
+JavaScript embedded.
+
+### How it fits together
+
+- `src/main.zig` parses the flags, opens the database, listens on the unix
+ socket (or `127.0.0.1:<port>`), and hands each connection to its own
+ task. SIGTERM shuts the listening socket down; the accept loop ends,
+ open connections are cancelled, and the database is closed.
+- `src/app.zig` routes requests, reads the visitor's cookie, checks the
+ `Origin` of every POST, and answers 303 (or, for the script, 200 with
+ the fresh page).
+- `src/db.zig` is the store: one SQLite connection in WAL mode behind a
+ mutex, with migrations that only add. Every query that touches a todo is
+ scoped by list.
+- `src/view.zig` renders TodoMVC's own markup. Each action is a real
+ form; the extra controls a page needs without JavaScript have a
+ `nojs-` class, and `static/app.css` hides them once the page is marked
+ `html.js`.
+
+### Visitors and lists
+
+The first request without a recognised cookie mints a 256-bit random
+token, stores its SHA-256 as a new list, and sets the cookie `todo_list`
+(`HttpOnly; SameSite=Lax; Path=/`, plus `Secure` on Carloku). A cookie
+the app doesn't recognise, garbled or not, gets a fresh empty list. Item
+ids are global, but every update is `WHERE id = ? AND list_id = ?`, so a
+POST naming someone else's item answers 404 and changes nothing.
+
+## Build, run, test, deploy
+
+```
+source ../../scripts/env.sh # Zig 0.17.0 on PATH
+make build # dist/app for this machine (static, musl)
+make build-arm64 # dist/app-linux-arm64, the shipped binary
+make test # Zig unit tests (view, routing, store)
+```
+
+Run it locally:
+
+```
+dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/zig.db"
+```
+
+The shared behaviour suite, from the repo root, with the app running:
+
+```
+scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/zig-suite.json"
+```
+
+Before deploying, check the arm64 binary boots on a unix socket under qemu:
+
+```
+scripts/boot-check.sh apps/zig/dist/app-linux-arm64
+```
+
+Deploy (the version is the commit's short SHA):
+
+```
+make build-arm64 deploy MESSAGE="what changed"
+```
+
+The first deploy needed the app and its instance created once:
+
+```
+carlos apps create --account bfj --app todo-zig --build-probe /api/version
+carlos instances enable --account bfj --app todo-zig
+carlos instances create --account bfj --app todo-zig --host todo-zig.bfj.oncarlos.com --channel edge
+```
+
+### Caches
+
+Every Zig cache lives under `.cache/`: compiled objects in
+`.cache/zig` and `.cache/zig-local`, the downloaded SQLite archive in
+`.cache/zig/p`, and its unpacked sources in `.cache/pkg`. `make clean`
+removes build output; `make clean-deps` also removes every compiled
+object but keeps the downloaded sources, so the next build compiles
+SQLite and Zig's musl libc from scratch.
diff --git a/apps/zig/STATUS.md b/apps/zig/STATUS.md
new file mode 100644
index 0000000..e5e83c2
--- /dev/null
+++ b/apps/zig/STATUS.md
@@ -0,0 +1,80 @@
+LIVE https://todo-zig.bfj.oncarlos.com e5042a9
+
+## What's live
+
+- `todo-zig` on account `bfj`, one instance on channel `edge`, build probe
+ `/api/version`. The deployed version is `e5042a9`; `/api/version` and
+ `X-Carlos-Version` both report it.
+- The shared behaviour suite passed locally (67 passed, 3 skipped) and
+ again against [https://todo-zig.bfj.oncarlos.com](https://todo-zig.bfj.oncarlos.com)
+ (67 passed, 3 skipped). The skips are the suite's own: blur and Escape
+ with JavaScript off, and the screenshot comparison with JavaScript off.
+- `scripts/boot-check.sh dist/app-linux-arm64` passed: `/healthz` and
+ `/api/version` answer 200 on a unix socket under qemu, and SIGTERM exits 0.
+- `make test`: 11 Zig unit tests pass (view, routing, form and cookie
+ parsing, title trimming, origin check, store scoping).
+
+## Notes
+
+- Binary: static aarch64 musl, 1.9 MB, stripped, CSS and JS embedded.
+- Cold `make build-arm64` after `make clean-deps` took 8m06s on docs-t1,
+ with Zig compiling its build runner, musl and the SQLite amalgamation
+ from scratch (other builders were compiling at the same time).
+- Zig 0.17 unpacks fetched packages into a project-local directory; the
+ Makefile points it (and both Zig caches) into `.cache/`, so
+ `clean-deps` keeps the downloaded SQLite sources (`.cache/zig/p`,
+ `.cache/pkg`) and drops everything compiled.
+- Nothing in the contract seemed wrong.
+
+## Review fixes (576c3b8)
+
+Both findings are fixed. After the fixes, the shared suite passed
+locally and against the live site (67 passed, 3 skipped each time), and
+`boot-check.sh` passed on the new arm64 binary.
+
+1. **An open editor was lost when an earlier response arrived.**
+ `static/app.js` now leaves an item being edited in place when it applies
+ a response. It never detaches that item, so the editor keeps its focus
+ and unsaved text. It replaces the items around it and refreshes the
+ item's checkbox, label and classes. If the response no longer contains
+ that item (deleted, or filtered out), the list is replaced as before.
+ I checked this in a browser: I delayed a toggle by 1 s and opened
+ another item's editor while it was pending. When the answer arrived,
+ the editor kept its text and focus and the counter updated. Enter then
+ saved the edited text.
+2. **Titles were trimmed of ASCII whitespace only.** Adding and editing
+ now trim Unicode White_Space characters (U+00A0, U+2003, U+3000 and
+ the rest) and U+FEFF from both ends (`trimTitle` in `src/app.zig`, with
+ a unit test). A whitespace-only add creates nothing, and editing an
+ item to whitespace only deletes it. Both were checked with curl.
+
+## Review findings not fixed
+
+None.
+
+## Second review round
+
+The second review request listed no findings, so the code is unchanged
+and nothing was redeployed: rebuilding and redeploying the same commit
+would change nothing. The live site still reports `576c3b8`, and the
+shared suite run against it again passed (67 passed, 3 skipped).
+
+## Third review round (e5042a9)
+
+1. **[security] Malformed UTF-8 in a title aborted the server.** Fixed.
+ The backward scan in `trimTitle` (`src/app.zig`) passed every byte
+ after a lead byte to `std.unicode.utf8Decode`. That function asserts
+ it gets exactly one sequence, so `title=a%80%80%80%80` reached
+ `unreachable` and the ReleaseSafe build aborted. I reproduced the
+ abort on the previous binary. The scan now checks that the lead
+ byte's sequence length matches the bytes found before it decodes;
+ anything else is treated as non-whitespace and ends the trim, so a
+ malformed title is stored as sent, untrimmed at that end.
+ Regression cases are in the `trimTitle` unit test.
+ Verified: locally the four malformed payloads answer 303 and
+ `/healthz` stays 200, and the live site answers 303 to the reported
+ payload. The shared suite passed locally and against the live site
+ (67 passed, 3 skipped each time), and `boot-check.sh` passed on the
+ new arm64 binary.
+
+No findings from this round were left unfixed.
diff --git a/apps/zig/build.zig b/apps/zig/build.zig
new file mode 100644
index 0000000..61cf83f
--- /dev/null
+++ b/apps/zig/build.zig
@@ -0,0 +1,58 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+ const strip = b.option(bool, "strip", "Omit debug info (default: on unless Debug)") orelse (optimize != .debug);
+ const version = b.option([]const u8, "version", "Build version reported by /api/version") orelse "dev";
+
+ const options = b.addOptions();
+ options.addOption([]const u8, "version", version);
+
+ // SQLite is the official amalgamation, compiled here as C.
+ const sqlite_dep = b.dependency("sqlite", .{});
+ const sqlite_h = b.addTranslateC(.{
+ .root_source_file = sqlite_dep.path("sqlite3.h"),
+ .target = target,
+ .optimize = optimize,
+ });
+
+ const mod = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ .strip = strip,
+ .link_libc = true,
+ .imports = &.{
+ .{ .name = "sqlite", .module = sqlite_h.createModule() },
+ },
+ });
+ mod.addOptions("build_options", options);
+ mod.addCSourceFile(.{
+ .file = sqlite_dep.path("sqlite3.c"),
+ .flags = &.{
+ "-DSQLITE_DQS=0",
+ "-DSQLITE_DEFAULT_MEMSTATUS=0",
+ "-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1",
+ "-DSQLITE_LIKE_DOESNT_MATCH_BLOBS",
+ "-DSQLITE_OMIT_DEPRECATED",
+ "-DSQLITE_OMIT_LOAD_EXTENSION",
+ "-DSQLITE_OMIT_SHARED_CACHE",
+ "-DSQLITE_USE_ALLOCA",
+ "-DSQLITE_THREADSAFE=1",
+ },
+ });
+ // The TodoMVC stylesheets are served byte for byte from the repo's
+ // shared/ directory, so they are embedded from there, not copied.
+ mod.addAnonymousImport("base.css", .{ .root_source_file = b.path("../../shared/base.css") });
+ mod.addAnonymousImport("index.css", .{ .root_source_file = b.path("../../shared/index.css") });
+ mod.addAnonymousImport("app.css", .{ .root_source_file = b.path("static/app.css") });
+ mod.addAnonymousImport("app.js", .{ .root_source_file = b.path("static/app.js") });
+
+ const exe = b.addExecutable(.{ .name = "app", .root_module = mod, .linkage = .static });
+ b.installArtifact(exe);
+
+ const tests = b.addTest(.{ .root_module = mod });
+ const run_tests = b.addRunArtifact(tests);
+ b.step("test", "Run unit tests").dependOn(&run_tests.step);
+}
diff --git a/apps/zig/build.zig.zon b/apps/zig/build.zig.zon
new file mode 100644
index 0000000..503c547
--- /dev/null
+++ b/apps/zig/build.zig.zon
@@ -0,0 +1,18 @@
+.{
+ .name = .todo_zig,
+ .version = "0.1.0",
+ .fingerprint = 0x152007b84cc41fbe,
+ .minimum_zig_version = "0.17.0",
+ .dependencies = .{
+ .sqlite = .{
+ .url = "https://sqlite.org/2026/sqlite-amalgamation-3540100.zip",
+ .hash = "N-V-__8AAETTsABEodUZZFAyfdAm9hEyqBXuLw9pRSO0vFh9",
+ },
+ },
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "src",
+ "static",
+ },
+}
diff --git a/apps/zig/src/app.zig b/apps/zig/src/app.zig
new file mode 100644
index 0000000..26eed99
--- /dev/null
+++ b/apps/zig/src/app.zig
@@ -0,0 +1,385 @@
+//! Routes and handlers. One `handle` call answers one HTTP request.
+
+const std = @import("std");
+const http = std.http;
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+const build_options = @import("build_options");
+const db = @import("db.zig");
+const view = @import("view.zig");
+const Filter = view.Filter;
+
+/// The body of `GET /healthz`. `make bump` edits this string.
+const health_body = "ok";
+
+const cookie_name = "todo_list";
+/// Raw token bytes; the cookie carries them hex-encoded (256 bits).
+const token_len = 32;
+const max_body = 1 << 20;
+
+/// The request header app.js sends; with it, actions answer 200 with the
+/// re-rendered page instead of a 303.
+const fetch_header = "x-requested-with";
+
+pub const App = struct {
+ store: *db.Store,
+ io: Io,
+ /// Origins a POST may come from. Empty means "match the Host header".
+ origins: []const []const u8,
+ /// Whether the app is served over https, so its cookie is `Secure`.
+ https: bool,
+};
+
+/// The headers this app reads, copied out before the body is read
+/// (reading the body invalidates the request's header memory).
+const Headers = struct {
+ cookie: ?[]const u8 = null,
+ origin: ?[]const u8 = null,
+ host: ?[]const u8 = null,
+ content_type: ?[]const u8 = null,
+ forwarded_proto: ?[]const u8 = null,
+ from_script: bool = false,
+
+ fn read(req: *const http.Server.Request) Headers {
+ var h: Headers = .{ .content_type = req.head.content_type };
+ var it = req.iterateHeaders();
+ while (it.next()) |field| {
+ if (std.ascii.eqlIgnoreCase(field.name, "cookie")) {
+ h.cookie = field.value;
+ } else if (std.ascii.eqlIgnoreCase(field.name, "origin")) {
+ h.origin = field.value;
+ } else if (std.ascii.eqlIgnoreCase(field.name, "host")) {
+ h.host = field.value;
+ } else if (std.ascii.eqlIgnoreCase(field.name, "x-forwarded-proto")) {
+ h.forwarded_proto = field.value;
+ } else if (std.ascii.eqlIgnoreCase(field.name, fetch_header)) {
+ h.from_script = true;
+ }
+ }
+ return h;
+ }
+};
+
+/// What a handler decided; `handle` turns it into bytes on the wire.
+const Response = struct {
+ status: http.Status = .ok,
+ content_type: []const u8 = "text/plain; charset=utf-8",
+ body: []const u8 = "",
+ location: ?[]const u8 = null,
+ cache: []const u8 = "no-store",
+};
+
+pub fn handle(app: *const App, req: *http.Server.Request, arena: Allocator) !void {
+ // Copy what we need before reading the body invalidates it.
+ const target = try arena.dupe(u8, req.head.target);
+ const method = req.head.method;
+ var headers = Headers.read(req);
+ inline for (.{ "cookie", "origin", "host", "content_type", "forwarded_proto" }) |name| {
+ if (@field(headers, name)) |v| @field(headers, name) = try arena.dupe(u8, v);
+ }
+
+ const query_start = std.mem.findScalar(u8, target, '?');
+ const path = target[0 .. query_start orelse target.len];
+ const query = if (query_start) |q| target[q + 1 ..] else "";
+
+ var visitor = Visitor{ .app = app, .headers = &headers };
+ const res: Response = switch (method) {
+ .GET, .HEAD => try get(app, &visitor, arena, path, query),
+ .POST => post: {
+ // A body we can't size can't be read safely on a kept-alive
+ // connection; refuse it rather than guess.
+ if (req.head.content_length == null and req.head.transfer_encoding == .none) {
+ break :post .{ .status = .length_required, .body = "length required\n" };
+ }
+ if (!originAllowed(app, &headers)) break :post .{ .status = .forbidden, .body = "cross-origin request refused\n" };
+ const reader = req.readerExpectContinue(&.{}) catch return error.WriteFailed;
+ const body = reader.allocRemaining(arena, .limited(max_body)) catch |err| switch (err) {
+ error.StreamTooLong => break :post .{ .status = .payload_too_large, .body = "too large\n" },
+ else => return err,
+ };
+ break :post try post(app, &visitor, arena, path, body, headers.from_script);
+ },
+ else => .{ .status = .method_not_allowed, .body = "method not allowed\n" },
+ };
+
+ var extra: [4]http.Header = undefined;
+ var n: usize = 0;
+ extra[n] = .{ .name = "content-type", .value = res.content_type };
+ n += 1;
+ extra[n] = .{ .name = "cache-control", .value = res.cache };
+ n += 1;
+ if (res.location) |loc| {
+ extra[n] = .{ .name = "location", .value = loc };
+ n += 1;
+ }
+ if (visitor.set_cookie) |cookie| {
+ extra[n] = .{ .name = "set-cookie", .value = cookie };
+ n += 1;
+ }
+ // After a refused or oversized body, the rest of it may still be on
+ // the wire, so the connection can't carry another request.
+ const keep_alive = res.status != .length_required and res.status != .payload_too_large;
+ try req.respond(res.body, .{
+ .status = res.status,
+ .extra_headers = extra[0..n],
+ .keep_alive = keep_alive,
+ });
+}
+
+fn get(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, query: []const u8) !Response {
+ if (std.mem.eql(u8, path, "/healthz")) return .{ .body = health_body };
+ if (std.mem.eql(u8, path, "/api/version")) return .{ .body = build_options.version };
+ if (std.mem.startsWith(u8, path, "/static/")) return static(path["/static/".len..]);
+
+ const filter = Filter.fromPath(path) orelse return .{ .status = .not_found, .body = "not found\n" };
+ const list = try visitor.list();
+ const todos = try app.store.todos(arena, list);
+ var editing: ?i64 = null;
+ var params = std.mem.splitScalar(u8, query, '&');
+ while (params.next()) |param| {
+ if (std.mem.startsWith(u8, param, "edit=")) editing = std.fmt.parseInt(i64, param["edit=".len..], 10) catch null;
+ }
+ return page(arena, .{ .todos = todos, .filter = filter, .editing = editing });
+}
+
+fn page(arena: Allocator, p: view.Page) !Response {
+ var out: Io.Writer.Allocating = try .initCapacity(arena, 4096 + 512 * p.todos.len);
+ try view.render(&out.writer, p);
+ return .{ .content_type = "text/html; charset=utf-8", .body = out.written() };
+}
+
+fn static(name: []const u8) Response {
+ const files = .{
+ .{ "base.css", "text/css; charset=utf-8", @embedFile("base.css") },
+ .{ "index.css", "text/css; charset=utf-8", @embedFile("index.css") },
+ .{ "app.css", "text/css; charset=utf-8", @embedFile("app.css") },
+ .{ "app.js", "text/javascript; charset=utf-8", @embedFile("app.js") },
+ };
+ inline for (files) |f| {
+ if (std.mem.eql(u8, name, f[0])) return .{ .content_type = f[1], .body = f[2], .cache = "public, max-age=300" };
+ }
+ return .{ .status = .not_found, .body = "not found\n" };
+}
+
+fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, body: []u8, from_script: bool) !Response {
+ var form: Form = .{};
+ form.parse(body);
+ const filter = Filter.fromField(form.filter orelse "");
+ const store = app.store;
+
+ const not_found: Response = .{ .status = .not_found, .body = "no such todo\n" };
+ if (std.mem.eql(u8, path, "/todos")) {
+ const title = trimTitle(form.title orelse "");
+ const list = try visitor.list();
+ if (title.len > 0) try store.add(list, title);
+ } else if (std.mem.eql(u8, path, "/todos/toggle-all")) {
+ try store.toggleAll(try visitor.list());
+ } else if (std.mem.eql(u8, path, "/todos/clear-completed")) {
+ try store.clearCompleted(try visitor.list());
+ } else if (itemAction(path)) |action| {
+ const list = try visitor.list();
+ const found = switch (action.verb) {
+ .toggle => try store.toggle(list, action.id),
+ .destroy => try store.destroy(list, action.id),
+ .edit => edit: {
+ const title = trimTitle(form.title orelse "");
+ break :edit if (title.len == 0)
+ try store.destroy(list, action.id)
+ else
+ try store.rename(list, action.id, title);
+ },
+ };
+ if (!found) return not_found;
+ } else {
+ return .{ .status = .not_found, .body = "not found\n" };
+ }
+
+ if (from_script) {
+ return page(arena, .{ .todos = try store.todos(arena, try visitor.list()), .filter = filter });
+ }
+ return .{ .status = .see_other, .location = filter.path() };
+}
+
+/// Trims Unicode whitespace (the White_Space property, plus U+FEFF as
+/// JavaScript's `trim` does) from both ends of a UTF-8 title.
+fn trimTitle(s: []const u8) []const u8 {
+ var start: usize = 0;
+ while (start < s.len) {
+ const len = std.unicode.utf8ByteSequenceLength(s[start]) catch break;
+ if (start + len > s.len) break;
+ const cp = std.unicode.utf8Decode(s[start..][0..len]) catch break;
+ if (!isSpace(cp)) break;
+ start += len;
+ }
+ var end = s.len;
+ while (end > start) {
+ var i = end - 1;
+ while (i > start and s[i] & 0xC0 == 0x80) i -= 1;
+ // utf8Decode asserts its input is one whole sequence, so check the
+ // lead byte claims exactly the bytes found; anything else is
+ // invalid UTF-8 and ends the trim.
+ const len = std.unicode.utf8ByteSequenceLength(s[i]) catch break;
+ if (len != end - i) break;
+ const cp = std.unicode.utf8Decode(s[i..end]) catch break;
+ if (!isSpace(cp)) break;
+ end = i;
+ }
+ return s[start..end];
+}
+
+fn isSpace(cp: u21) bool {
+ return switch (cp) {
+ 0x09...0x0D, 0x20, 0x85, 0xA0, 0x1680, 0x2000...0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF => true,
+ else => false,
+ };
+}
+
+const ItemAction = struct {
+ id: i64,
+ verb: enum { toggle, edit, destroy },
+};
+
+/// Parses `/todos/<id>/<verb>`.
+fn itemAction(path: []const u8) ?ItemAction {
+ const rest = std.mem.cutPrefix(u8, path, "/todos/") orelse return null;
+ const slash = std.mem.findScalar(u8, rest, '/') orelse return null;
+ const id = std.fmt.parseInt(i64, rest[0..slash], 10) catch return null;
+ const verb = std.meta.stringToEnum(@FieldType(ItemAction, "verb"), rest[slash + 1 ..]) orelse return null;
+ return .{ .id = id, .verb = verb };
+}
+
+/// The fields of an `application/x-www-form-urlencoded` body this app reads.
+const Form = struct {
+ title: ?[]const u8 = null,
+ filter: ?[]const u8 = null,
+
+ fn parse(form: *Form, body: []u8) void {
+ var pairs = std.mem.splitScalar(u8, body, '&');
+ while (pairs.next()) |pair_const| {
+ // The split hands back slices of `body`, which is ours to decode in place.
+ const pair = @constCast(pair_const);
+ const eq = std.mem.findScalar(u8, pair, '=') orelse continue;
+ const key = decode(pair[0..eq]);
+ const value = decode(pair[eq + 1 ..]);
+ if (std.mem.eql(u8, key, "title")) form.title = value;
+ if (std.mem.eql(u8, key, "filter")) form.filter = value;
+ }
+ }
+
+ fn decode(s: []u8) []u8 {
+ for (s) |*ch| {
+ if (ch.* == '+') ch.* = ' ';
+ }
+ return std.Uri.percentDecodeInPlace(s);
+ }
+};
+
+/// The visitor's list, found from (or minted into) their cookie on first use.
+const Visitor = struct {
+ app: *const App,
+ headers: *const Headers,
+ list_id: ?i64 = null,
+ set_cookie: ?[]const u8 = null,
+ cookie_buf: [160]u8 = undefined,
+
+ fn list(v: *Visitor) !i64 {
+ if (v.list_id) |id| return id;
+ if (v.headers.cookie) |header| {
+ if (cookieValue(header, cookie_name)) |token_hex| {
+ if (token_hex.len == token_len * 2) {
+ if (try v.app.store.findList(&hashToken(token_hex))) |id| {
+ v.list_id = id;
+ return id;
+ }
+ }
+ }
+ }
+ // No cookie, or one we don't recognise: a fresh, empty list.
+ var token: [token_len]u8 = undefined;
+ try v.app.io.randomSecure(&token);
+ const token_hex = std.fmt.bytesToHex(token, .lower);
+ const id = try v.app.store.createList(&hashToken(&token_hex));
+ const secure = v.app.https or
+ (if (v.headers.forwarded_proto) |p| std.ascii.eqlIgnoreCase(p, "https") else false);
+ v.set_cookie = try std.fmt.bufPrint(&v.cookie_buf, "{s}={s}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000{s}", .{
+ cookie_name, token_hex, if (secure) "; Secure" else "",
+ });
+ v.list_id = id;
+ return id;
+ }
+};
+
+/// The database keeps only a hash of each token, hex-encoded.
+fn hashToken(token_hex: []const u8) [64]u8 {
+ var digest: [32]u8 = undefined;
+ std.crypto.hash.sha2.Sha256.hash(token_hex, &digest, .{});
+ return std.fmt.bytesToHex(digest, .lower);
+}
+
+fn cookieValue(header: []const u8, name: []const u8) ?[]const u8 {
+ var it = std.mem.splitScalar(u8, header, ';');
+ while (it.next()) |part| {
+ const kv = std.mem.trim(u8, part, " ");
+ const eq = std.mem.findScalar(u8, kv, '=') orelse continue;
+ if (std.mem.eql(u8, kv[0..eq], name)) return kv[eq + 1 ..];
+ }
+ return null;
+}
+
+fn originAllowed(app: *const App, h: *const Headers) bool {
+ const origin = h.origin orelse return true;
+ for (app.origins) |allowed| {
+ if (std.mem.eql(u8, origin, allowed)) return true;
+ }
+ if (app.origins.len > 0) return false;
+ // No configured origin: accept the request's own host, either scheme.
+ const host = h.host orelse return false;
+ const after_scheme = std.mem.cutPrefix(u8, origin, "https://") orelse
+ std.mem.cutPrefix(u8, origin, "http://") orelse return false;
+ return std.mem.eql(u8, after_scheme, host);
+}
+
+test trimTitle {
+ try std.testing.expectEqualStrings("", trimTitle("\u{a0}\u{2003} \t\u{3000}"));
+ try std.testing.expectEqualStrings("café ✓", trimTitle("\u{feff} café ✓\u{a0}\n"));
+ try std.testing.expectEqualStrings("a\u{a0}b", trimTitle("a\u{a0}b"));
+ try std.testing.expectEqualStrings("日本語", trimTitle("日本語"));
+ try std.testing.expectEqualStrings("\xff x", trimTitle("\xff x"));
+ // Malformed UTF-8 is left alone, never decoded.
+ try std.testing.expectEqualStrings("a\x80\x80\x80\x80", trimTitle("a\x80\x80\x80\x80"));
+ try std.testing.expectEqualStrings("\x80\x80", trimTitle(" \x80\x80 "));
+ try std.testing.expectEqualStrings("x\xe2\x80", trimTitle("x\xe2\x80"));
+ try std.testing.expectEqualStrings("x\xc3", trimTitle("x\xc3"));
+ try std.testing.expectEqualStrings("\xf0\x80\x80\x80\x80", trimTitle("\xf0\x80\x80\x80\x80"));
+}
+
+test itemAction {
+ try std.testing.expectEqual(ItemAction{ .id = 12, .verb = .toggle }, itemAction("/todos/12/toggle").?);
+ try std.testing.expectEqual(ItemAction{ .id = 3, .verb = .edit }, itemAction("/todos/3/edit").?);
+ try std.testing.expectEqual(null, itemAction("/todos/x/toggle"));
+ try std.testing.expectEqual(null, itemAction("/todos/3/explode"));
+ try std.testing.expectEqual(null, itemAction("/todos/toggle-all"));
+}
+
+test "form decoding" {
+ var body = "title=+caf%C3%A9+%26+%3Cb%3E+&filter=active".*;
+ var form: Form = .{};
+ form.parse(&body);
+ try std.testing.expectEqualStrings(" café & <b> ", form.title.?);
+ try std.testing.expectEqualStrings("active", form.filter.?);
+}
+
+test cookieValue {
+ try std.testing.expectEqualStrings("abc", cookieValue("a=1; todo_list=abc; b=2", "todo_list").?);
+ try std.testing.expectEqual(null, cookieValue("a=1", "todo_list"));
+}
+
+test originAllowed {
+ const configured: App = .{ .store = undefined, .io = undefined, .origins = &.{"https://todo.example"}, .https = true };
+ try std.testing.expect(originAllowed(&configured, &.{}));
+ try std.testing.expect(originAllowed(&configured, &.{ .origin = "https://todo.example" }));
+ try std.testing.expect(!originAllowed(&configured, &.{ .origin = "https://evil.example" }));
+ const unconfigured: App = .{ .store = undefined, .io = undefined, .origins = &.{}, .https = false };
+ try std.testing.expect(originAllowed(&unconfigured, &.{ .origin = "http://h:1", .host = "h:1" }));
+ try std.testing.expect(!originAllowed(&unconfigured, &.{ .origin = "http://evil", .host = "h:1" }));
+}
diff --git a/apps/zig/src/db.zig b/apps/zig/src/db.zig
new file mode 100644
index 0000000..119f6aa
--- /dev/null
+++ b/apps/zig/src/db.zig
@@ -0,0 +1,234 @@
+//! The todo store: one SQLite file in WAL mode, one connection shared by
+//! every request behind a mutex. A todo app's queries take microseconds,
+//! so a single serialised connection is simpler than a pool and plenty fast.
+
+const std = @import("std");
+const c = @import("sqlite");
+const Io = std.Io;
+const Allocator = std.mem.Allocator;
+
+pub const Error = error{Sqlite};
+
+pub const Todo = struct {
+ id: i64,
+ title: []const u8,
+ completed: bool,
+};
+
+/// Migrations only ever add. `PRAGMA user_version` records how many have run.
+const migrations = [_][:0]const u8{
+ \\CREATE TABLE lists (
+ \\ id INTEGER PRIMARY KEY,
+ \\ token_hash TEXT NOT NULL UNIQUE,
+ \\ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+ \\);
+ \\CREATE TABLE todos (
+ \\ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ \\ list_id INTEGER NOT NULL REFERENCES lists(id),
+ \\ title TEXT NOT NULL,
+ \\ completed INTEGER NOT NULL DEFAULT 0
+ \\);
+ \\CREATE INDEX todos_by_list ON todos(list_id, id);
+ ,
+};
+
+pub const Store = struct {
+ db: *c.sqlite3,
+ mutex: Io.Mutex = .init,
+ io: Io,
+
+ pub fn open(io: Io, path: [:0]const u8) Error!Store {
+ var db: ?*c.sqlite3 = null;
+ const flags = c.SQLITE_OPEN_READWRITE | c.SQLITE_OPEN_CREATE | c.SQLITE_OPEN_FULLMUTEX;
+ if (c.sqlite3_open_v2(path.ptr, &db, flags, null) != c.SQLITE_OK) {
+ if (db) |d| {
+ std.log.err("sqlite open {s}: {s}", .{ path, c.sqlite3_errmsg(d) });
+ _ = c.sqlite3_close(d);
+ }
+ return error.Sqlite;
+ }
+ var store: Store = .{ .db = db.?, .io = io };
+ errdefer store.close();
+ _ = c.sqlite3_busy_timeout(store.db, 5000);
+ try store.exec("PRAGMA journal_mode = WAL");
+ try store.exec("PRAGMA synchronous = NORMAL");
+ try store.exec("PRAGMA foreign_keys = ON");
+ try store.migrate();
+ return store;
+ }
+
+ pub fn close(s: *Store) void {
+ _ = c.sqlite3_close(s.db);
+ }
+
+ fn migrate(s: *Store) Error!void {
+ var st = try s.prepare("PRAGMA user_version");
+ const current: usize = if (try st.step()) @intCast(st.int(0)) else 0;
+ st.finalize();
+ for (migrations[@min(current, migrations.len)..], current..) |sql, i| {
+ try s.exec("BEGIN");
+ errdefer s.exec("ROLLBACK") catch {};
+ try s.exec(sql);
+ var buf: [64]u8 = undefined;
+ const set = std.fmt.bufPrintSentinel(&buf, "PRAGMA user_version = {d}", .{i + 1}, 0) catch unreachable;
+ try s.exec(set);
+ try s.exec("COMMIT");
+ }
+ }
+
+ fn exec(s: *Store, sql: [:0]const u8) Error!void {
+ var err: [*c]u8 = null;
+ if (c.sqlite3_exec(s.db, sql.ptr, null, null, &err) != c.SQLITE_OK) {
+ std.log.err("sqlite: {s}", .{err});
+ c.sqlite3_free(err);
+ return error.Sqlite;
+ }
+ }
+
+ fn prepare(s: *Store, sql: [:0]const u8) Error!Stmt {
+ var stmt: ?*c.sqlite3_stmt = null;
+ if (c.sqlite3_prepare_v2(s.db, sql.ptr, @intCast(sql.len + 1), &stmt, null) != c.SQLITE_OK) {
+ std.log.err("sqlite prepare: {s}", .{c.sqlite3_errmsg(s.db)});
+ return error.Sqlite;
+ }
+ return .{ .stmt = stmt.?, .db = s.db };
+ }
+
+ /// Runs one statement that returns no rows; answers how many rows it changed.
+ fn run(s: *Store, sql: [:0]const u8, args: anytype) Error!usize {
+ var st = try s.prepare(sql);
+ defer st.finalize();
+ try st.bindAll(args);
+ while (try st.step()) {}
+ return @intCast(c.sqlite3_changes(s.db));
+ }
+
+ fn lock(s: *Store) void {
+ s.mutex.lockUncancelable(s.io);
+ }
+
+ fn unlock(s: *Store) void {
+ s.mutex.unlock(s.io);
+ }
+
+ // The queries the app runs. Every one that touches a todo is scoped
+ // by list, so one visitor can never reach another's items.
+
+ pub fn findList(s: *Store, token_hash: []const u8) Error!?i64 {
+ s.lock();
+ defer s.unlock();
+ var st = try s.prepare("SELECT id FROM lists WHERE token_hash = ?");
+ defer st.finalize();
+ try st.bindAll(.{token_hash});
+ return if (try st.step()) st.int(0) else null;
+ }
+
+ pub fn createList(s: *Store, token_hash: []const u8) Error!i64 {
+ s.lock();
+ defer s.unlock();
+ _ = try s.run("INSERT INTO lists (token_hash) VALUES (?)", .{token_hash});
+ return c.sqlite3_last_insert_rowid(s.db);
+ }
+
+ /// Every todo in the list, oldest first, copied into `arena`.
+ pub fn todos(s: *Store, arena: Allocator, list: i64) (Error || Allocator.Error)![]Todo {
+ s.lock();
+ defer s.unlock();
+ var st = try s.prepare("SELECT id, title, completed FROM todos WHERE list_id = ? ORDER BY id");
+ defer st.finalize();
+ try st.bindAll(.{list});
+ var out: std.ArrayList(Todo) = .empty;
+ while (try st.step()) {
+ try out.append(arena, .{
+ .id = st.int(0),
+ .title = try arena.dupe(u8, st.text(1)),
+ .completed = st.int(2) != 0,
+ });
+ }
+ return out.items;
+ }
+
+ pub fn add(s: *Store, list: i64, title: []const u8) Error!void {
+ s.lock();
+ defer s.unlock();
+ _ = try s.run("INSERT INTO todos (list_id, title) VALUES (?, ?)", .{ list, title });
+ }
+
+ /// Each of these answers false when `id` isn't in `list`.
+ pub fn toggle(s: *Store, list: i64, id: i64) Error!bool {
+ s.lock();
+ defer s.unlock();
+ return try s.run("UPDATE todos SET completed = 1 - completed WHERE id = ? AND list_id = ?", .{ id, list }) > 0;
+ }
+
+ pub fn rename(s: *Store, list: i64, id: i64, title: []const u8) Error!bool {
+ s.lock();
+ defer s.unlock();
+ return try s.run("UPDATE todos SET title = ? WHERE id = ? AND list_id = ?", .{ title, id, list }) > 0;
+ }
+
+ pub fn destroy(s: *Store, list: i64, id: i64) Error!bool {
+ s.lock();
+ defer s.unlock();
+ return try s.run("DELETE FROM todos WHERE id = ? AND list_id = ?", .{ id, list }) > 0;
+ }
+
+ /// Completes every item, or, if all already were, makes them all active.
+ pub fn toggleAll(s: *Store, list: i64) Error!void {
+ s.lock();
+ defer s.unlock();
+ _ = try s.run(
+ \\UPDATE todos SET completed =
+ \\ EXISTS (SELECT 1 FROM todos WHERE list_id = ?1 AND completed = 0)
+ \\WHERE list_id = ?1
+ , .{list});
+ }
+
+ pub fn clearCompleted(s: *Store, list: i64) Error!void {
+ s.lock();
+ defer s.unlock();
+ _ = try s.run("DELETE FROM todos WHERE list_id = ? AND completed = 1", .{list});
+ }
+};
+
+const Stmt = struct {
+ stmt: *c.sqlite3_stmt,
+ db: *c.sqlite3,
+
+ fn finalize(st: *Stmt) void {
+ _ = c.sqlite3_finalize(st.stmt);
+ }
+
+ /// Binds `args` in order. Text is bound without copying, so it must
+ /// outlive the statement, which every caller here guarantees.
+ fn bindAll(st: *Stmt, args: anytype) Error!void {
+ inline for (args, 1..) |arg, i| {
+ const rc = switch (@TypeOf(arg)) {
+ i64 => c.sqlite3_bind_int64(st.stmt, i, arg),
+ []const u8, []u8 => c.sqlite3_bind_text(st.stmt, i, arg.ptr, @intCast(arg.len), null),
+ else => @compileError("can't bind " ++ @typeName(@TypeOf(arg))),
+ };
+ if (rc != c.SQLITE_OK) return error.Sqlite;
+ }
+ }
+
+ fn step(st: *Stmt) Error!bool {
+ return switch (c.sqlite3_step(st.stmt)) {
+ c.SQLITE_ROW => true,
+ c.SQLITE_DONE => false,
+ else => {
+ std.log.err("sqlite step: {s}", .{c.sqlite3_errmsg(st.db)});
+ return error.Sqlite;
+ },
+ };
+ }
+
+ fn int(st: *Stmt, col: c_int) i64 {
+ return c.sqlite3_column_int64(st.stmt, col);
+ }
+
+ fn text(st: *Stmt, col: c_int) []const u8 {
+ const ptr = c.sqlite3_column_text(st.stmt, col) orelse return "";
+ return ptr[0..@intCast(c.sqlite3_column_bytes(st.stmt, col))];
+ }
+};
diff --git a/apps/zig/src/db_test.zig b/apps/zig/src/db_test.zig
new file mode 100644
index 0000000..8457317
--- /dev/null
+++ b/apps/zig/src/db_test.zig
@@ -0,0 +1,71 @@
+//! Store tests against an in-memory SQLite database.
+
+const std = @import("std");
+const testing = std.testing;
+const Store = @import("db.zig").Store;
+
+fn titles(todos: anytype) ![]const u8 {
+ var out: std.Io.Writer.Allocating = .init(testing.allocator);
+ errdefer out.deinit();
+ for (todos) |t| try out.writer.print("{s}{s};", .{ if (t.completed) "x " else "", t.title });
+ return out.toOwnedSlice();
+}
+
+fn expectList(store: *Store, list: i64, want: []const u8) !void {
+ var arena: std.heap.ArenaAllocator = .init(testing.allocator);
+ defer arena.deinit();
+ const got = try titles(try store.todos(arena.allocator(), list));
+ defer testing.allocator.free(got);
+ try testing.expectEqualStrings(want, got);
+}
+
+test "lists are separate and every action is scoped to its list" {
+ var store = try Store.open(testing.io, ":memory:");
+ defer store.close();
+
+ const a = try store.createList("hash-a");
+ const b = try store.createList("hash-b");
+ try testing.expectEqual(a, (try store.findList("hash-a")).?);
+ try testing.expectEqual(null, try store.findList("hash-c"));
+
+ try store.add(a, "one");
+ try store.add(a, "two");
+ try store.add(b, "theirs");
+ try expectList(&store, a, "one;two;");
+
+ var arena: std.heap.ArenaAllocator = .init(testing.allocator);
+ defer arena.deinit();
+ const theirs = (try store.todos(arena.allocator(), b))[0].id;
+ try testing.expect(!try store.toggle(a, theirs));
+ try testing.expect(!try store.rename(a, theirs, "mine now"));
+ try testing.expect(!try store.destroy(a, theirs));
+ try expectList(&store, b, "theirs;");
+
+ const two = (try store.todos(arena.allocator(), a))[1].id;
+ try testing.expect(try store.toggle(a, two));
+ try expectList(&store, a, "one;x two;");
+ try testing.expect(try store.rename(a, two, "2 ✓"));
+ try expectList(&store, a, "one;x 2 ✓;");
+}
+
+test "toggle all, then back, then clear completed" {
+ var store = try Store.open(testing.io, ":memory:");
+ defer store.close();
+ const a = try store.createList("hash-a");
+ try store.add(a, "one");
+ try store.add(a, "two");
+
+ try store.toggleAll(a);
+ try expectList(&store, a, "x one;x two;");
+ try store.toggleAll(a);
+ try expectList(&store, a, "one;two;");
+
+ var arena: std.heap.ArenaAllocator = .init(testing.allocator);
+ defer arena.deinit();
+ _ = try store.toggle(a, (try store.todos(arena.allocator(), a))[0].id);
+ try store.toggleAll(a);
+ try expectList(&store, a, "x one;x two;");
+
+ try store.clearCompleted(a);
+ try expectList(&store, a, "");
+}
diff --git a/apps/zig/src/main.zig b/apps/zig/src/main.zig
new file mode 100644
index 0000000..56e6a0f
--- /dev/null
+++ b/apps/zig/src/main.zig
@@ -0,0 +1,154 @@
+//! todo-zig: TodoMVC for the CARLOS bakeoff.
+//!
+//! app --socket <path> --db <path> (on Carloku)
+//! app --listen 127.0.0.1:<port> --db <path> (locally)
+//!
+//! Each connection runs as its own task on `std.Io`'s threaded backend.
+//! SIGTERM shuts the listening socket down, which ends the accept loop;
+//! open connections are cancelled and the database is closed before exit.
+
+const std = @import("std");
+const Io = std.Io;
+const net = Io.net;
+const db = @import("db.zig");
+const app_mod = @import("app.zig");
+
+const usage = "usage: app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>\n";
+
+pub fn main(init: std.process.Init) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+ const arena = init.arena.allocator();
+
+ var socket_path: ?[]const u8 = null;
+ var listen_addr: ?[]const u8 = null;
+ var db_path: ?[:0]const u8 = null;
+ var args = init.minimal.args.iterate();
+ _ = args.skip();
+ while (args.next()) |arg| {
+ if (std.mem.eql(u8, arg, "--socket")) {
+ socket_path = args.next() orelse return fail(usage);
+ } else if (std.mem.eql(u8, arg, "--listen")) {
+ listen_addr = args.next() orelse return fail(usage);
+ } else if (std.mem.eql(u8, arg, "--db")) {
+ db_path = args.next() orelse return fail(usage);
+ } else return fail(usage);
+ }
+ const db_file = db_path orelse return fail(usage);
+ if ((socket_path == null) == (listen_addr == null)) return fail(usage);
+
+ var store = db.Store.open(io, db_file) catch return fail("cannot open the database\n");
+ defer store.close();
+
+ // A POST's Origin must be this app's own: https://$CARLOS_HOST on the
+ // platform, http://<listen address> locally.
+ var origins: std.ArrayList([]const u8) = .empty;
+ if (init.environ_map.get("CARLOS_HOST")) |host| {
+ if (host.len > 0) try origins.append(arena, try std.fmt.allocPrint(arena, "https://{s}", .{host}));
+ }
+ if (listen_addr) |addr| try origins.append(arena, try std.fmt.allocPrint(arena, "http://{s}", .{addr}));
+
+ const app: app_mod.App = .{
+ .store = &store,
+ .io = io,
+ .origins = origins.items,
+ .https = socket_path != null,
+ };
+
+ var server = if (socket_path) |path| unix: {
+ Io.Dir.cwd().deleteFile(io, path) catch |err| switch (err) {
+ error.FileNotFound => {},
+ else => return err,
+ };
+ const ua = try net.UnixAddress.init(path);
+ break :unix try ua.listen(io, .{});
+ } else tcp: {
+ const ip = net.IpAddress.parseLiteral(listen_addr.?) catch return fail("--listen wants 127.0.0.1:<port>\n");
+ if (ip != .ip4 or !std.mem.eql(u8, &ip.ip4.bytes, &.{ 127, 0, 0, 1 })) {
+ return fail("--listen binds 127.0.0.1 only\n");
+ }
+ break :tcp try ip.listen(io, .{ .reuse_address = true });
+ };
+ defer {
+ server.deinit(io);
+ if (socket_path) |path| Io.Dir.cwd().deleteFile(io, path) catch {};
+ }
+
+ listen_fd.store(server.socket.handle, .release);
+ const on_stop: std.posix.Sigaction = .{
+ .handler = .{ .handler = stop },
+ .mask = std.posix.sigemptyset(),
+ .flags = 0,
+ };
+ std.posix.sigaction(.TERM, &on_stop, null);
+ std.posix.sigaction(.INT, &on_stop, null);
+
+ std.log.info("todo-zig {s} listening on {s}", .{
+ @import("build_options").version,
+ socket_path orelse listen_addr.?,
+ });
+
+ var connections: Io.Group = .init;
+ defer connections.cancel(io);
+ while (true) {
+ const stream = server.accept(io) catch |err| switch (err) {
+ error.SocketNotListening => break,
+ error.ConnectionAborted => continue,
+ else => {
+ std.log.err("accept: {t}", .{err});
+ continue;
+ },
+ };
+ connections.concurrent(io, serve, .{ &app, gpa, stream }) catch {
+ // No thread to spare: serve it here rather than drop it.
+ serve(&app, gpa, stream);
+ };
+ }
+ std.log.info("shutting down", .{});
+}
+
+/// Answers requests on one connection until the client or server closes it.
+fn serve(app: *const app_mod.App, gpa: std.mem.Allocator, stream: net.Stream) void {
+ const io = app.io;
+ defer stream.close(io);
+ var read_buf: [16 * 1024]u8 = undefined;
+ var write_buf: [16 * 1024]u8 = undefined;
+ var reader = stream.reader(io, &read_buf);
+ var writer = stream.writer(io, &write_buf);
+ var http: std.http.Server = .init(&reader.interface, &writer.interface);
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+
+ while (true) {
+ var req = http.receiveHead() catch return;
+ const keep_alive = req.head.keep_alive;
+ app_mod.handle(app, &req, arena.allocator()) catch |err| {
+ std.log.err("{s}: {t}", .{ @tagName(req.head.method), err });
+ req.respond("internal error\n", .{ .status = .internal_server_error, .keep_alive = false }) catch {};
+ return;
+ };
+ _ = arena.reset(.retain_capacity);
+ if (!keep_alive or http.reader.state != .ready) return;
+ }
+}
+
+var listen_fd: std.atomic.Value(std.posix.fd_t) = .init(-1);
+
+/// Signal handler: shutting the listening socket down wakes `accept` with
+/// `error.SocketNotListening`. shutdown(2) is async-signal-safe.
+fn stop(_: std.posix.SIG) callconv(.c) void {
+ const fd = listen_fd.load(.acquire);
+ if (fd >= 0) _ = std.c.shutdown(fd, std.posix.SHUT.RDWR);
+}
+
+fn fail(msg: []const u8) error{InvalidArguments} {
+ std.debug.print("{s}", .{msg});
+ return error.InvalidArguments;
+}
+
+test {
+ _ = @import("app.zig");
+ _ = @import("view.zig");
+ _ = @import("db.zig");
+ _ = @import("db_test.zig");
+}
diff --git a/apps/zig/src/view.zig b/apps/zig/src/view.zig
new file mode 100644
index 0000000..17e120e
--- /dev/null
+++ b/apps/zig/src/view.zig
@@ -0,0 +1,239 @@
+//! The TodoMVC page, written straight to a `std.Io.Writer`.
+//!
+//! The markup follows TodoMVC's own template (shared/reference.html). Each
+//! action is a real form, so the page works with JavaScript off; the extra
+//! no-JS controls carry a `nojs-` class that static/app.css hides once the
+//! script has marked the page with `html.js`.
+
+const std = @import("std");
+const Writer = std.Io.Writer;
+const Todo = @import("db.zig").Todo;
+
+pub const Filter = enum {
+ all,
+ active,
+ completed,
+
+ pub fn fromField(value: []const u8) Filter {
+ return std.meta.stringToEnum(Filter, value) orelse .all;
+ }
+
+ pub fn fromPath(p: []const u8) ?Filter {
+ if (std.mem.eql(u8, p, "/")) return .all;
+ if (std.mem.eql(u8, p, "/active")) return .active;
+ if (std.mem.eql(u8, p, "/completed")) return .completed;
+ return null;
+ }
+
+ pub fn path(f: Filter) []const u8 {
+ return switch (f) {
+ .all => "/",
+ .active => "/active",
+ .completed => "/completed",
+ };
+ }
+
+ fn shows(f: Filter, t: Todo) bool {
+ return switch (f) {
+ .all => true,
+ .active => !t.completed,
+ .completed => t.completed,
+ };
+ }
+};
+
+pub const Page = struct {
+ todos: []const Todo,
+ filter: Filter,
+ /// The item whose editor is open, from `?edit=<id>`.
+ editing: ?i64 = null,
+};
+
+pub fn render(w: *Writer, page: Page) Writer.Error!void {
+ var completed: usize = 0;
+ for (page.todos) |t| completed += @intFromBool(t.completed);
+ const left = page.todos.len - completed;
+ const empty = page.todos.len == 0;
+ const filter = @tagName(page.filter);
+
+ try w.writeAll(
+ \\<!doctype html>
+ \\<html lang="en">
+ \\<head>
+ \\<meta charset="utf-8">
+ \\<meta name="viewport" content="width=device-width, initial-scale=1">
+ \\<title>TodoMVC</title>
+ \\<link rel="stylesheet" href="/static/base.css">
+ \\<link rel="stylesheet" href="/static/index.css">
+ \\<link rel="stylesheet" href="/static/app.css">
+ \\<script>document.documentElement.classList.add("js")</script>
+ \\<script type="module" src="/static/app.js"></script>
+ \\</head>
+ \\<body>
+ \\<section class="todoapp">
+ \\<header class="header">
+ \\<h1>todos</h1>
+ \\<form method="post" action="/todos">
+ \\
+ );
+ try hiddenFilter(w, filter);
+ try w.print(
+ \\<input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"{s}>
+ \\</form>
+ \\</header>
+ \\<section class="main"{s}>
+ \\<form method="post" action="/todos/toggle-all">
+ \\
+ , .{
+ if (page.editing == null) " autofocus" else "",
+ if (empty) " hidden" else "",
+ });
+ try hiddenFilter(w, filter);
+ try w.print(
+ \\<input id="toggle-all" class="toggle-all" type="checkbox"{s}>
+ \\<label for="toggle-all">Mark all as complete</label>
+ \\<button class="nojs-toggle-all" data-action="toggle-all">Mark all as complete</button>
+ \\</form>
+ \\<ul class="todo-list">
+ \\
+ , .{if (!empty and left == 0) " checked" else ""});
+
+ for (page.todos) |t| {
+ if (page.filter.shows(t)) try item(w, t, page.filter, page.editing == t.id);
+ }
+
+ try w.print(
+ \\</ul>
+ \\</section>
+ \\<footer class="footer"{s}>
+ \\<span class="todo-count"><strong>{d}</strong> {s} left</span>
+ \\<ul class="filters">
+ \\
+ , .{ if (empty) " hidden" else "", left, if (left == 1) "item" else "items" });
+ inline for (.{ .{ Filter.all, "All" }, .{ Filter.active, "Active" }, .{ Filter.completed, "Completed" } }) |link| {
+ try w.print("<li><a{s} href=\"{s}\">{s}</a></li>\n", .{
+ if (page.filter == link[0]) " class=\"selected\"" else "",
+ link[0].path(),
+ link[1],
+ });
+ }
+ try w.writeAll("</ul>\n");
+ if (completed > 0) {
+ try w.writeAll("<form method=\"post\" action=\"/todos/clear-completed\">\n");
+ try hiddenFilter(w, filter);
+ try w.writeAll("<button class=\"clear-completed\">Clear completed</button>\n</form>\n");
+ }
+ try w.writeAll(
+ \\</footer>
+ \\</section>
+ \\<footer class="info">
+ \\<p>Double-click to edit a todo</p>
+ \\<p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · Zig</p>
+ \\<p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+ \\</footer>
+ \\</body>
+ \\</html>
+ \\
+ );
+}
+
+fn item(w: *Writer, t: Todo, filter: Filter, editing: bool) Writer.Error!void {
+ const f = @tagName(filter);
+ const classes: []const u8 = if (t.completed and editing)
+ " class=\"completed editing\""
+ else if (t.completed)
+ " class=\"completed\""
+ else if (editing)
+ " class=\"editing\""
+ else
+ "";
+ try w.print("<li data-id=\"{d}\"{s}>\n<div class=\"view\">\n", .{ t.id, classes });
+ try w.print("<input class=\"toggle\" type=\"checkbox\"{s}>\n<label>", .{if (t.completed) " checked" else ""});
+ try escape(w, t.title);
+ try w.print("</label>\n<form method=\"post\" action=\"/todos/{d}/toggle\">\n", .{t.id});
+ try hiddenFilter(w, f);
+ try w.print(
+ \\<button class="nojs-toggle" data-action="toggle">{s}</button>
+ \\</form>
+ \\<a class="nojs-edit" data-action="edit" href="{s}?edit={d}">Edit</a>
+ \\<form method="post" action="/todos/{d}/destroy">
+ \\
+ , .{ if (t.completed) "Mark as active" else "Mark as complete", filter.path(), t.id, t.id });
+ try hiddenFilter(w, f);
+ try w.print(
+ \\<button class="destroy" aria-label="Delete"></button>
+ \\</form>
+ \\</div>
+ \\<form method="post" action="/todos/{d}/edit">
+ \\
+ , .{t.id});
+ try hiddenFilter(w, f);
+ try w.writeAll("<input class=\"edit\" name=\"title\" value=\"");
+ try escape(w, t.title);
+ try w.print("\"{s}>\n</form>\n</li>\n", .{if (editing) " autofocus" else ""});
+}
+
+fn hiddenFilter(w: *Writer, filter: []const u8) Writer.Error!void {
+ try w.print("<input type=\"hidden\" name=\"filter\" value=\"{s}\">\n", .{filter});
+}
+
+/// Escapes text for an HTML text node or a double-quoted attribute.
+pub fn escape(w: *Writer, s: []const u8) Writer.Error!void {
+ var start: usize = 0;
+ for (s, 0..) |ch, i| {
+ const entity: []const u8 = switch (ch) {
+ '&' => "&amp;",
+ '<' => "&lt;",
+ '>' => "&gt;",
+ '"' => "&quot;",
+ '\'' => "&#39;",
+ else => continue,
+ };
+ try w.writeAll(s[start..i]);
+ try w.writeAll(entity);
+ start = i + 1;
+ }
+ try w.writeAll(s[start..]);
+}
+
+test escape {
+ var buf: [128]u8 = undefined;
+ var w: Writer = .fixed(&buf);
+ try escape(&w, "<img src=x onerror=\"alert('1')\"> & naïve 🚀");
+ try std.testing.expectEqualStrings(
+ "&lt;img src=x onerror=&quot;alert(&#39;1&#39;)&quot;&gt; &amp; naïve 🚀",
+ w.buffered(),
+ );
+}
+
+fn renderToString(gpa: std.mem.Allocator, page: Page) ![]u8 {
+ var out: Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try render(&out.writer, page);
+ return out.toOwnedSlice();
+}
+
+test "empty list hides main and footer" {
+ const html = try renderToString(std.testing.allocator, .{ .todos = &.{}, .filter = .all });
+ defer std.testing.allocator.free(html);
+ try std.testing.expect(std.mem.indexOf(u8, html, "<section class=\"main\" hidden>") != null);
+ try std.testing.expect(std.mem.indexOf(u8, html, "<footer class=\"footer\" hidden>") != null);
+ try std.testing.expect(std.mem.indexOf(u8, html, "clear-completed\"") == null);
+}
+
+test "filters, counter and editing" {
+ const todos = [_]Todo{
+ .{ .id = 1, .title = "Buy milk", .completed = true },
+ .{ .id = 2, .title = "Walk <the> dog", .completed = false },
+ };
+ const html = try renderToString(std.testing.allocator, .{ .todos = &todos, .filter = .active, .editing = 2 });
+ defer std.testing.allocator.free(html);
+ try std.testing.expect(std.mem.indexOf(u8, html, "<strong>1</strong> item left") != null);
+ try std.testing.expect(std.mem.indexOf(u8, html, "data-id=\"1\"") == null);
+ try std.testing.expect(std.mem.indexOf(u8, html, "<li data-id=\"2\" class=\"editing\">") != null);
+ try std.testing.expect(std.mem.indexOf(u8, html, "<label>Walk &lt;the&gt; dog</label>") != null);
+ try std.testing.expect(std.mem.indexOf(u8, html, "<a class=\"selected\" href=\"/active\">") != null);
+ try std.testing.expect(std.mem.indexOf(u8, html, "href=\"/active?edit=2\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, html, "<button class=\"clear-completed\">") != null);
+ try std.testing.expect(std.mem.indexOf(u8, html, "class=\"new-todo\" name=\"title\" placeholder=\"What needs to be done?\" autocomplete=\"off\">") != null);
+}
diff --git a/apps/zig/static/app.css b/apps/zig/static/app.css
new file mode 100644
index 0000000..82eb957
--- /dev/null
+++ b/apps/zig/static/app.css
@@ -0,0 +1,71 @@
+/* Additions to TodoMVC's stylesheets for this server-rendered version. */
+
+/* Forms only group fields; they take no space of their own, so the
+ TodoMVC layout is unchanged. */
+.todoapp form {
+ display: contents;
+}
+
+/* Without JavaScript, each checkbox is covered by a transparent submit
+ button, and each item gets an Edit link. With JavaScript (html.js),
+ they're hidden and the page is exactly TodoMVC's. */
+.nojs-toggle-all,
+.todo-list li .nojs-toggle {
+ position: absolute;
+ z-index: 1;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 0;
+ color: transparent;
+ cursor: pointer;
+}
+
+.nojs-toggle-all {
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+}
+
+.todo-list li .nojs-toggle {
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 60px;
+}
+
+.nojs-toggle-all:focus-visible,
+.todo-list li .nojs-toggle:focus-visible {
+ outline: 1px solid #CF7D7D;
+}
+
+.todo-list li .nojs-edit {
+ position: absolute;
+ top: 0;
+ right: 60px;
+ bottom: 0;
+ height: 20px;
+ margin: auto 0;
+ font-size: 14px;
+ line-height: 20px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.todo-list li .nojs-edit:hover,
+.todo-list li .nojs-edit:focus {
+ color: #484848;
+ text-decoration: underline;
+}
+
+html:not(.js) .todo-list li label {
+ padding-right: 110px;
+}
+
+.js .nojs-toggle-all,
+.js .todo-list li .nojs-toggle,
+.js .todo-list li .nojs-edit {
+ display: none;
+}
diff --git a/apps/zig/static/app.js b/apps/zig/static/app.js
new file mode 100644
index 0000000..e3c6782
--- /dev/null
+++ b/apps/zig/static/app.js
@@ -0,0 +1,106 @@
+// Progressive enhancement for the server-rendered TodoMVC.
+//
+// Every action is already a working HTML form. This script sends those same
+// forms with fetch, asks for the re-rendered page instead of a redirect, and
+// swaps in the new list and footer. It adds TodoMVC's own gestures on top:
+// click a checkbox to toggle, double-click a label to edit, Enter or blur to
+// save, Escape to cancel.
+
+const app = document.querySelector('.todoapp');
+
+// Requests go out one at a time, so the page always shows the last answer.
+let queue = Promise.resolve();
+
+function send(form) {
+ const body = new URLSearchParams(new FormData(form));
+ queue = queue.then(async () => {
+ const res = await fetch(form.action, {
+ method: 'POST',
+ headers: { 'X-Requested-With': 'fetch' },
+ body,
+ });
+ if (!res.ok) throw new Error(`${res.status} from ${form.action}`);
+ apply(new DOMParser().parseFromString(await res.text(), 'text/html'));
+ }).catch((err) => {
+ console.error(err);
+ location.reload();
+ });
+}
+
+// Swaps in the server's fresh list and footer. An editor that's open when
+// the answer arrives stays open with its unsaved text: its item is kept in
+// place (never detached, so it keeps focus) and only refreshed around it.
+function apply(fresh) {
+ app.querySelector('.footer').replaceWith(fresh.querySelector('.footer'));
+ const editing = app.querySelector('.todo-list li.editing');
+ const update = editing && fresh.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`);
+ if (!update) {
+ app.querySelector('.main').replaceWith(fresh.querySelector('.main'));
+ return;
+ }
+
+ const main = app.querySelector('.main');
+ const freshMain = fresh.querySelector('.main');
+ main.hidden = freshMain.hidden;
+ main.querySelector('form').replaceWith(freshMain.querySelector('form'));
+
+ for (const item of [...main.querySelectorAll('.todo-list > li')]) {
+ if (item !== editing) item.remove();
+ }
+ let before = true;
+ for (const item of [...freshMain.querySelectorAll('.todo-list > li')]) {
+ if (item === update) {
+ before = false;
+ } else if (before) {
+ editing.before(item);
+ } else {
+ editing.parentElement.append(item);
+ }
+ }
+
+ // Bring the kept item's view up to date, leaving the editor's text alone.
+ editing.className = `${update.className} editing`;
+ editing.querySelector('.view').replaceWith(update.querySelector('.view'));
+ editing.querySelector('.edit').defaultValue = update.querySelector('.edit').defaultValue;
+}
+
+app.addEventListener('submit', (event) => {
+ event.preventDefault();
+ const form = event.target;
+ const editing = form.closest('li.editing');
+ if (editing) editing.classList.remove('editing');
+ send(form);
+ if (form.closest('.header')) form.querySelector('.new-todo').value = '';
+});
+
+app.addEventListener('change', (event) => {
+ const box = event.target;
+ if (box.matches('.toggle')) {
+ send(box.closest('li').querySelector('form[action$="/toggle"]'));
+ } else if (box.matches('.toggle-all')) {
+ send(box.form);
+ }
+});
+
+app.addEventListener('dblclick', (event) => {
+ const label = event.target.closest('.todo-list label');
+ if (!label) return;
+ const item = label.closest('li');
+ const input = item.querySelector('.edit');
+ item.classList.add('editing');
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+});
+
+app.addEventListener('keydown', (event) => {
+ if (event.key !== 'Escape' || !event.target.matches('.edit')) return;
+ const input = event.target;
+ input.value = input.defaultValue;
+ input.closest('li').classList.remove('editing');
+});
+
+// Leaving the editor saves it, unless Enter or Escape already closed it.
+app.addEventListener('focusout', (event) => {
+ const input = event.target;
+ if (input.matches('.edit') && input.closest('li.editing')) input.form.requestSubmit();
+});