carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

đŸ¤– 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.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.