carlos / todomvc-bakeoff Public
🤖 todo-go
TodoMVC for the CARLOS bakeoff, written in Go. It's live at https://todo-go.bfj.oncarlos.com.
The server renders every page, and every change is a plain form POST, so the whole app works with JavaScript turned off. One small ES module (web/static/app.js) makes it behave like the reference TodoMVC when JavaScript is on: double-click to edit, Escape to cancel, and checkboxes that act without a page load.
The stack, and why
| Piece | Choice | Why | |---|---|---| | HTTP server and routing | net/http from the standard library | Since Go 1.22 the standard ServeMux matches methods and path parameters (POST /todos/{id}/toggle), which is all this app needs. A router library would add a dependency and nothing else | | Templates | html/template | Escapes by context, so a todo titled <img onerror=…> shows as text. Templates and static files are embedded in the binary with embed | | SQLite | modernc.org/sqlite | A pure-Go translation of SQLite, so the binary builds with CGO_ENABLED=0 and cross-compiles to linux-arm64 with no C toolchain. It's the driver the CARLOS family uses | | Database access | database/sql, hand-written SQL | Five short queries. Two pools: one connection for writes (SQLite allows one writer at a time, so writes queue in Go instead of fighting over the lock) and a few read-only connections | | Migrations | A Go slice of SQL strings, each recorded in a todo_migrations table once applied | Append-only by construction. No migration tool needed for one table | | JavaScript | One ES module, no framework, no build step | It sends the same form POSTs with an X-Todo-Fragment header and swaps in the HTML the server returns, so all rendering stays in one template | | Logging | log/slog | Standard library, structured, to stderr |
The only third-party dependency is modernc.org/sqlite (see DEPS); the rest of go.sum is what it pulls in.
How it fits the platform
- The binary is started as
todo-go --socket <path> --db <path>and listens on that unix socket. For local runs it takes--listen 127.0.0.1:<port>instead, and refuses any address that isn't loopback. - It reads its hostname from
CARLOS_HOST. With it set, POSTs must come fromhttps://$CARLOS_HOST(or carry noOriginat all) and the cookie is markedSecure. GET /healthzchecks the database answers;GET /api/versionreturns the git short SHA baked in at build time.- SQLite runs in WAL mode in one file. Startup is a few milliseconds, and SIGTERM finishes in-flight requests, closes the database and exits 0.
Each visitor's list
A visitor's first request sets a todo_list cookie holding 32 random bytes (HttpOnly; SameSite=Lax; Path=/, plus Secure on Carloku). The database keys each todo by the SHA-256 of that cookie, never the cookie itself. Issued lists are recorded in a todo_lists table, and a cookie the app can't parse or never issued just gets a new one and an empty list, so nobody can choose their own list identity. An id that isn't in your list answers 404, whoever it belongs to.
Honest trade-offs
- Trust class C (2026-10-23). Todo titles are stored as plain text. The server, and anyone with a copy of the database or its replicas, can read them. That's a deliberate choice for a public demo.
- A list lasts as long as its cookie. Clearing cookies loses it, and there's no way to carry a list to another browser.
Build, run, test, deploy
Put the toolchain on PATH first: source ../../scripts/env.sh.
| Command | What it does | |---|---| | make build | Builds for this machine into dist/app | | make build-arm64 | Builds the static linux-arm64 binary into dist/app-linux-arm64. COLD=1 passes -a to recompile every package | | make test | Checks the stylesheets still match shared/, then runs go vet and go test | | make clean | Removes dist/ | | make clean-deps | The same as clean. Go keeps compiled packages in the shared GOCACHE, which this repo leaves alone; use COLD=1 for a from-scratch build | | make bump / make unbump | Changes a constant in web/web.go (the file with the GET / handler) and puts it back | | make deploy | Ships dist/app-linux-arm64 with carlos deploy. Set MESSAGE="…" to describe the change |
Run it locally:
make build
./dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/todo-go.db"Then run the shared behaviour suite against it from the repo root: scripts/test-app.sh http://127.0.0.1:8080.
Before a deploy, scripts/boot-check.sh dist/app-linux-arm64 starts the arm64 binary under qemu on a unix socket and checks it answers and exits cleanly.
Layout
| Path | What | |---|---| | main.go | Flags, listener, signal handling | | internal/todo/ | The SQLite store, its migrations, and its tests | | web/web.go | Routes, handlers, cookie and origin checks | | web/templates/page.html | The page, and the list fragment app.js swaps in | | web/static/ | TodoMVC's CSS (copied byte for byte from shared/), app.css, nojs.css (loaded only inside <noscript>) and app.js |