carlos / todomvc-bakeoff Public

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

Plain git — no account needed to clone.

Download

Download this file

TodoMVC: Go + Rastrillo

The bakeoff's TodoMVC built the way Rastrillo v0.28 recommends: a server-rendered app that works with JavaScript off, plus one small ES module that makes it behave like the reference TodoMVC when JavaScript is on. Live at https://todo-go-rastrillo.bfj.oncarlos.com.

The stack, and why

| Piece | Choice | Why | |---|---|---| | Framework | Rastrillo v0.28.0 | The CARLOS framework. rastrillo.Resolve + Serve speak the platform contract (unix socket, /healthz, /api/version, SIGTERM drain, baseline security headers), so the app doesn't hand-roll any of it | | Router | github.com/go-chi/chi/v5 | Rastrillo's router: net/http handlers, middleware per group | | Data | GORM over rastrillo/db | Rastrillo's default data layer. db.Open sets up SQLite (modernc, no cgo) in WAL mode with one writer connection and a reader pool | | Migrations | rastrillo/migrate, generated by rastrillo migration generate | Ledgered, applied once at boot, additive only. make test runs rastrillo migration check | | Visitors | rastrillo/sessions | Each browser's list is an anonymous session (below) | | Scoping | rastrillo/scope | Every todo query goes through one owned(r) seam that adds WHERE user_id = ? | | Forms | rastrillo/form | form.Parse reads and trims each field by name; nothing is bound onto a model | | Templates | html/template, embedded | Titles are always escaped text | | Static files | rastrillo.NewAssets | Pages link fingerprinted, cache-forever URLs; the bare names (/static/base.css) are served too, byte for byte | | JavaScript | static/app.js, one ES module, no build step | Posts the page's own forms with fetch and swaps in the server's markup |

The UI is TodoMVC's own markup and CSS (base.css, index.css), not Rastrillo's design system: the bakeoff requires it. static/app.css adds one rule that matters, .todoapp form { display: contents }, so the forms that make every action work without JavaScript don't change the layout.

How the anonymous lists work

Rastrillo's sessions stores signed-in state as SQLite rows keyed by the SHA-256 of a 256-bit random cookie token. This app treats "being given a list" as the sign-in: the first request with no live session creates a visitors row and calls sessions.SignIn with that row's id as the subject (method anonymous). From then on sessions.UserID(r) is the visitor's id and scope.Owned filters on it, exactly as it would for a signed-in user. An unknown or garbled cookie resolves to no session, so that browser gets a fresh, empty list. The cookie is HttpOnly; SameSite=Lax; Path=/, and on https it's Secure with the __Host- prefix. Lists live 400 days from the first visit instead of sessions' default of 30.

Cross-site protection: the contract's Origin check, not csrf.Protect

Rastrillo's csrf.Protect refuses any POST that carries none of Sec-Fetch-Site, Origin or Referer. The bakeoff's shared tests and load tests post exactly like that, and the contract allows it. Protect also trusts Sec-Fetch-Site ahead of Origin, so it would let through a request claiming same-origin that carries a foreign Origin. So origin.go does the contract's check instead: a POST whose Origin header is not exactly this app's origin gets 403, and a POST with no Origin header is allowed. There's no CSRF token, as the contract says.

Form posts are capped at 1 MiB and parsed before any handler runs. A body that's too big gets 413 and a malformed one gets 400, and neither changes anything.

JavaScript on and off

Every action is a plain form post that answers 303. With JavaScript off, <noscript> loads static/nojs.css, which shows the contract's submit controls ([data-action="toggle"], [data-action="toggle-all"] and the a[data-action="edit"] link) as transparent buttons laid over the circle, the ❯ and the title. The page looks the same, and clicking where you'd click with JavaScript does the same thing through a page load.

With JavaScript on, app.js sends the same forms with an X-Todo-Fragment header, and the server answers with the new contents of section.todoapp instead of a redirect. The checkboxes, double-click editing, Enter, blur and Escape behave as in TodoMVC. Without that header, nothing changes.

Build, run, test, deploy

Go 1.25 or newer. On docs-t1, source ../../scripts/env.sh first.

make build          # dist/app for this machine
make build-arm64    # dist/app-linux-arm64, static, what ships
make test           # vet, gofmt, go test with Rastrillo's time budgets, migration check
make perf           # every GET screen under 150ms to first byte, cold boot under 500ms

Run it locally. It refuses any TCP address not on 127.0.0.1, and defaults to 127.0.0.1:8080:

dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/go-rastrillo.db"
../../scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/go-rastrillo-suite.json"

On Carloku the platform starts it as app --socket <path> --db <path> and sets CARLOS_HOST; the app's origin is then https://$CARLOS_HOST. --listen is the bakeoff's name for Rastrillo's --addr, and main.go translates it before rastrillo.Resolve reads the arguments.

make build-arm64
../../scripts/boot-check.sh dist/app-linux-arm64
make deploy MESSAGE="What changed"

The version /api/version reports is the git short SHA, stamped into rastrillo.BuildVersion at build time.

make clean removes dist/. make clean-deps does the same, because Go keeps compiled dependencies in the shared GOCACHE, which stays untouched on this machine: make build-arm64 COLD=1 rebuilds everything from scratch with -a instead.

Honest trade-offs

  • Trust class C (2026-10-23). Todo titles are stored as plain text in SQLite, and the server can read them.
  • No sign-in. A list belongs to a cookie. Clear it, or use another browser, and that list is gone for you.
  • GORM costs some speed. Rastrillo's own measurements put GORM reads at about twice the time of plain database/sql. Rastrillo recommends it for CRUD apps like this one, so this entry uses it.