| 1 | # TodoMVC: Go + Rastrillo |
| 2 | |
| 3 | The bakeoff's TodoMVC built the way Rastrillo v0.28 recommends: a |
| 4 | server-rendered app that works with JavaScript off, plus one small ES |
| 5 | module that makes it behave like the reference TodoMVC when JavaScript is |
| 6 | on. Live at |
| 7 | [https://todo-go-rastrillo.bfj.oncarlos.com](https://todo-go-rastrillo.bfj.oncarlos.com). |
| 8 | |
| 9 | ## The stack, and why |
| 10 | |
| 11 | | Piece | Choice | Why | |
| 12 | |---|---|---| |
| 13 | | Framework | [Rastrillo](https://rastrillo.org) 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 | |
| 14 | | Router | `github.com/go-chi/chi/v5` | Rastrillo's router: `net/http` handlers, middleware per group | |
| 15 | | 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 | |
| 16 | | Migrations | `rastrillo/migrate`, generated by `rastrillo migration generate` | Ledgered, applied once at boot, additive only. `make test` runs `rastrillo migration check` | |
| 17 | | Visitors | `rastrillo/sessions` | Each browser's list is an anonymous session (below) | |
| 18 | | Scoping | `rastrillo/scope` | Every todo query goes through one `owned(r)` seam that adds `WHERE user_id = ?` | |
| 19 | | Forms | `rastrillo/form` | `form.Parse` reads and trims each field by name; nothing is bound onto a model | |
| 20 | | Templates | `html/template`, embedded | Titles are always escaped text | |
| 21 | | Static files | `rastrillo.NewAssets` | Pages link fingerprinted, cache-forever URLs; the bare names (`/static/base.css`) are served too, byte for byte | |
| 22 | | 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 | |
| 23 | |
| 24 | The UI is TodoMVC's own markup and CSS (`base.css`, `index.css`), not |
| 25 | Rastrillo's design system: the bakeoff requires it. `static/app.css` adds |
| 26 | one rule that matters, `.todoapp form { display: contents }`, so the forms |
| 27 | that make every action work without JavaScript don't change the layout. |
| 28 | |
| 29 | ### How the anonymous lists work |
| 30 | |
| 31 | Rastrillo's `sessions` stores signed-in state as SQLite rows keyed by |
| 32 | the SHA-256 of a 256-bit random cookie token. This app treats "being |
| 33 | given a list" as the sign-in: the first request with no live session |
| 34 | creates a `visitors` row and calls `sessions.SignIn` with that row's id as |
| 35 | the subject (method `anonymous`). From then on `sessions.UserID(r)` is |
| 36 | the visitor's id and `scope.Owned` filters on it, exactly as it would for |
| 37 | a signed-in user. An unknown or garbled cookie resolves to no session, so |
| 38 | that browser gets a fresh, empty list. The cookie is |
| 39 | `HttpOnly; SameSite=Lax; Path=/`, and on https it's `Secure` with the |
| 40 | `__Host-` prefix. Lists live 400 days from the first visit instead of |
| 41 | sessions' default of 30. |
| 42 | |
| 43 | ### Cross-site protection: the contract's Origin check, not `csrf.Protect` |
| 44 | |
| 45 | Rastrillo's `csrf.Protect` refuses any POST that carries none of |
| 46 | `Sec-Fetch-Site`, `Origin` or `Referer`. The bakeoff's shared tests and |
| 47 | load tests post exactly like that, and the contract allows it. Protect |
| 48 | also trusts `Sec-Fetch-Site` ahead of `Origin`, so it would let through a |
| 49 | request claiming `same-origin` that carries a foreign `Origin`. So |
| 50 | `origin.go` does the contract's check instead: a POST whose `Origin` |
| 51 | header is not exactly this app's origin gets 403, and a POST with no |
| 52 | `Origin` header is allowed. There's no CSRF token, as the contract says. |
| 53 | |
| 54 | Form posts are capped at 1 MiB and parsed before any handler runs. A |
| 55 | body that's too big gets 413 and a malformed one gets 400, and neither |
| 56 | changes anything. |
| 57 | |
| 58 | ### JavaScript on and off |
| 59 | |
| 60 | Every action is a plain form post that answers 303. With JavaScript off, |
| 61 | `<noscript>` loads `static/nojs.css`, which shows the contract's submit |
| 62 | controls (`[data-action="toggle"]`, `[data-action="toggle-all"]` and the |
| 63 | `a[data-action="edit"]` link) as transparent buttons laid over the circle, |
| 64 | the ❯ and the title. The page looks the same, and clicking where you'd |
| 65 | click with JavaScript does the same thing through a page load. |
| 66 | |
| 67 | With JavaScript on, `app.js` sends the same forms with an |
| 68 | `X-Todo-Fragment` header, and the server answers with the new contents |
| 69 | of `section.todoapp` instead of a redirect. The checkboxes, double-click |
| 70 | editing, Enter, blur and Escape behave as in TodoMVC. Without that header, |
| 71 | nothing changes. |
| 72 | |
| 73 | ## Build, run, test, deploy |
| 74 | |
| 75 | Go 1.25 or newer. On docs-t1, `source ../../scripts/env.sh` first. |
| 76 | |
| 77 | ```sh |
| 78 | make build # dist/app for this machine |
| 79 | make build-arm64 # dist/app-linux-arm64, static, what ships |
| 80 | make test # vet, gofmt, go test with Rastrillo's time budgets, migration check |
| 81 | make perf # every GET screen under 150ms to first byte, cold boot under 500ms |
| 82 | ``` |
| 83 | |
| 84 | Run it locally. It refuses any TCP address not on `127.0.0.1`, and |
| 85 | defaults to `127.0.0.1:8080`: |
| 86 | |
| 87 | ```sh |
| 88 | dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/go-rastrillo.db" |
| 89 | ../../scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/go-rastrillo-suite.json" |
| 90 | ``` |
| 91 | |
| 92 | On Carloku the platform starts it as `app --socket <path> --db <path>` and |
| 93 | sets `CARLOS_HOST`; the app's origin is then `https://$CARLOS_HOST`. |
| 94 | `--listen` is the bakeoff's name for Rastrillo's `--addr`, and `main.go` |
| 95 | translates it before `rastrillo.Resolve` reads the arguments. |
| 96 | |
| 97 | ```sh |
| 98 | make build-arm64 |
| 99 | ../../scripts/boot-check.sh dist/app-linux-arm64 |
| 100 | make deploy MESSAGE="What changed" |
| 101 | ``` |
| 102 | |
| 103 | The version `/api/version` reports is the git short SHA, stamped into |
| 104 | `rastrillo.BuildVersion` at build time. |
| 105 | |
| 106 | `make clean` removes `dist/`. `make clean-deps` does the same, because |
| 107 | Go keeps compiled dependencies in the shared `GOCACHE`, which stays |
| 108 | untouched on this machine: `make build-arm64 COLD=1` rebuilds everything |
| 109 | from scratch with `-a` instead. |
| 110 | |
| 111 | ## Honest trade-offs |
| 112 | |
| 113 | - **Trust class C (2026-10-23).** Todo titles are stored as plain text in |
| 114 | SQLite, and the server can read them. |
| 115 | - **No sign-in.** A list belongs to a cookie. Clear it, or use another |
| 116 | browser, and that list is gone for you. |
| 117 | - **GORM costs some speed.** Rastrillo's own measurements put GORM reads |
| 118 | at about twice the time of plain `database/sql`. Rastrillo recommends it |
| 119 | for CRUD apps like this one, so this entry uses it. |
| 120 | |