| 1 | # đŸ¤– todo-zig |
| 2 | |
| 3 | TodoMVC for the CARLOS bakeoff, in Zig. It's server-rendered and works |
| 4 | with JavaScript turned off; a small script makes it feel like the |
| 5 | reference TodoMVC when JavaScript is on. Live at |
| 6 | [https://todo-zig.bfj.oncarlos.com](https://todo-zig.bfj.oncarlos.com). |
| 7 | |
| 8 | ## The stack, and why |
| 9 | |
| 10 | | Piece | Choice | Why | |
| 11 | |---|---|---| |
| 12 | | Language | Zig 0.17.0 | The setup under test | |
| 13 | | 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 | |
| 14 | | 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 | |
| 15 | | 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 | |
| 16 | | 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 | |
| 17 | | 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 | |
| 18 | |
| 19 | The result is one static binary (musl, about 2 MB) with the CSS and |
| 20 | JavaScript embedded. |
| 21 | |
| 22 | ### How it fits together |
| 23 | |
| 24 | - `src/main.zig` parses the flags, opens the database, listens on the unix |
| 25 | socket (or `127.0.0.1:<port>`), and hands each connection to its own |
| 26 | task. SIGTERM shuts the listening socket down; the accept loop ends, |
| 27 | open connections are cancelled, and the database is closed. |
| 28 | - `src/app.zig` routes requests, reads the visitor's cookie, checks the |
| 29 | `Origin` of every POST, and answers 303 (or, for the script, 200 with |
| 30 | the fresh page). |
| 31 | - `src/db.zig` is the store: one SQLite connection in WAL mode behind a |
| 32 | mutex, with migrations that only add. Every query that touches a todo is |
| 33 | scoped by list. |
| 34 | - `src/view.zig` renders TodoMVC's own markup. Each action is a real |
| 35 | form; the extra controls a page needs without JavaScript have a |
| 36 | `nojs-` class, and `static/app.css` hides them once the page is marked |
| 37 | `html.js`. |
| 38 | |
| 39 | ### Visitors and lists |
| 40 | |
| 41 | The first request without a recognised cookie mints a 256-bit random |
| 42 | token, stores its SHA-256 as a new list, and sets the cookie `todo_list` |
| 43 | (`HttpOnly; SameSite=Lax; Path=/`, plus `Secure` on Carloku). A cookie |
| 44 | the app doesn't recognise, garbled or not, gets a fresh empty list. Item |
| 45 | ids are global, but every update is `WHERE id = ? AND list_id = ?`, so a |
| 46 | POST naming someone else's item answers 404 and changes nothing. |
| 47 | |
| 48 | ## Build, run, test, deploy |
| 49 | |
| 50 | ``` |
| 51 | source ../../scripts/env.sh # Zig 0.17.0 on PATH |
| 52 | make build # dist/app for this machine (static, musl) |
| 53 | make build-arm64 # dist/app-linux-arm64, the shipped binary |
| 54 | make test # Zig unit tests (view, routing, store) |
| 55 | ``` |
| 56 | |
| 57 | Run it locally: |
| 58 | |
| 59 | ``` |
| 60 | dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/zig.db" |
| 61 | ``` |
| 62 | |
| 63 | The shared behaviour suite, from the repo root, with the app running: |
| 64 | |
| 65 | ``` |
| 66 | scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/zig-suite.json" |
| 67 | ``` |
| 68 | |
| 69 | Before deploying, check the arm64 binary boots on a unix socket under qemu: |
| 70 | |
| 71 | ``` |
| 72 | scripts/boot-check.sh apps/zig/dist/app-linux-arm64 |
| 73 | ``` |
| 74 | |
| 75 | Deploy (the version is the commit's short SHA): |
| 76 | |
| 77 | ``` |
| 78 | make build-arm64 deploy MESSAGE="what changed" |
| 79 | ``` |
| 80 | |
| 81 | The first deploy needed the app and its instance created once: |
| 82 | |
| 83 | ``` |
| 84 | carlos apps create --account bfj --app todo-zig --build-probe /api/version |
| 85 | carlos instances enable --account bfj --app todo-zig |
| 86 | carlos instances create --account bfj --app todo-zig --host todo-zig.bfj.oncarlos.com --channel edge |
| 87 | ``` |
| 88 | |
| 89 | ### Caches |
| 90 | |
| 91 | Every Zig cache lives under `.cache/`: compiled objects in |
| 92 | `.cache/zig` and `.cache/zig-local`, the downloaded SQLite archive in |
| 93 | `.cache/zig/p`, and its unpacked sources in `.cache/pkg`. `make clean` |
| 94 | removes build output; `make clean-deps` also removes every compiled |
| 95 | object but keeps the downloaded sources, so the next build compiles |
| 96 | SQLite and Zig's musl libc from scratch. |
| 97 | |