carlos / todomvc-bakeoff Public
đŸ¤– 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.
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.zigparses the flags, opens the database, listens on the unix socket (or127.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.zigroutes requests, reads the visitor's cookie, checks theOriginof every POST, and answers 303 (or, for the script, 200 with the fresh page).src/db.zigis 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.zigrenders TodoMVC's own markup. Each action is a real form; the extra controls a page needs without JavaScript have anojs-class, andstatic/app.csshides them once the page is markedhtml.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-arm64Deploy (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 edgeCaches
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.