feat: go builder output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 5d651dec9d1e50e12f8f1da5785bba658d17847e parent 9bb16b2parent de2b146
19 files changed, +2416 −0
  • apps/go/AGENTS.md +39 −0
  • apps/go/DEPS +1 −0
  • apps/go/Makefile +53 −0
  • apps/go/README.md +95 −0
  • apps/go/STATUS.md +44 −0
  • apps/go/go.mod +17 −0
  • apps/go/go.sum +50 −0
  • apps/go/internal/todo/store.go +233 −0
  • apps/go/internal/todo/store_test.go +228 −0
  • apps/go/main.go +118 −0
  • apps/go/main_test.go +19 −0
  • apps/go/web/static/app.css +22 −0
  • apps/go/web/static/app.js +143 −0
  • apps/go/web/static/base.css +141 −0
  • apps/go/web/static/index.css +393 −0
  • apps/go/web/static/nojs.css +51 −0
  • apps/go/web/templates/page.html +83 −0
  • apps/go/web/web.go +377 −0
  • apps/go/web/web_test.go +309 −0
diff --git a/apps/go/AGENTS.md b/apps/go/AGENTS.md
new file mode 100644
index 0000000..e942adc
--- /dev/null
+++ b/apps/go/AGENTS.md
@@ -0,0 +1,39 @@
+# 🤖 AGENTS.md: todo-go
+
+The CARLOS bakeoff's TodoMVC, in Go. Live at
+`https://todo-go.bfj.oncarlos.com` (Carloku app `todo-go`, account `bfj`).
+
+## The one rule
+
+**Every page works as plain HTML forms first.** The server renders the
+whole app, and every change is a form POST answered with a 303. The
+JavaScript in `web/static/app.js` only sends those same POSTs in the
+background and swaps in the HTML the server sends back. If a feature
+can't be done with JavaScript off, it is done differently, not skipped.
+
+`BRIEF.md` and `CONTRACT.md` at the repo root are binding; this file
+adds what is specific to the Go build.
+
+## Tenancy declaration
+
+| Question | Answer |
+|---|---|
+| What is one instance? | The whole app. One process, one SQLite file, one unix socket |
+| How do people reach it? | Anonymously. A visitor's first request sets a random cookie (256 bits), and that cookie is their list. No sign-in, no accounts |
+| Where is each list kept? | In the one SQLite file, each row keyed by the SHA-256 of the visitor's cookie. The app never stores the cookie itself, so a copy of the database can't be used to open anyone's list |
+| Trust class | **C**, chosen 2026-10-23: todo titles are stored as plain text, readable by the server and by anyone holding the database or its replicas. A todo list for a public demo doesn't justify server-blind encryption |
+| Benefits claimed | Hibernation (the instance sleeps when idle and starts in milliseconds), replication of the one file by the platform |
+| Costs accepted | A list lives as long as its cookie: clear cookies and the list is gone, and there is no way to move a list between browsers. Every list shares one file, so there is no per-visitor export or erasure beyond "delete by cookie hash" |
+
+## Rules
+
+- One static binary, `CGO_ENABLED=0`. SQLite through `modernc.org/sqlite`.
+- Migrations in `internal/todo/store.go` only ever add. Never edit or
+ remove a shipped migration; append a new one.
+- Every query that touches a todo names the caller's list. An id that
+ isn't in the caller's list is a 404.
+- Titles are rendered by `html/template`, never as raw HTML.
+- JavaScript is one small ES module with no build step. Keep it under
+ 300 lines.
+- Servers bind `127.0.0.1` only. Don't change `GOCACHE`; `make
+ build-arm64 COLD=1` passes `-a` instead.
diff --git a/apps/go/DEPS b/apps/go/DEPS
new file mode 100644
index 0000000..345662d
--- /dev/null
+++ b/apps/go/DEPS
@@ -0,0 +1 @@
+modernc.org/sqlite
diff --git a/apps/go/Makefile b/apps/go/Makefile
new file mode 100644
index 0000000..3e68811
--- /dev/null
+++ b/apps/go/Makefile
@@ -0,0 +1,53 @@
+# todo-go: the CARLOS bakeoff's TodoMVC in Go. See README.md.
+
+VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
+LDFLAGS := -s -w -X main.version=$(VERSION)
+# make build-arm64 COLD=1 rebuilds every package, dependencies included,
+# without touching the shared GOCACHE.
+GOFLAGS_COLD := $(if $(COLD),-a,)
+
+.PHONY: build build-arm64 test clean clean-deps bump unbump deploy css-check
+
+build: dist/app
+build-arm64: dist/app-linux-arm64
+
+# Both binaries are always handed to `go build`, which decides from its own
+# cache what needs compiling, so a new VERSION or COLD=1 always takes effect.
+dist/app: FORCE
+ CGO_ENABLED=0 go build $(GOFLAGS_COLD) -trimpath -ldflags '$(LDFLAGS)' -o $@ .
+
+dist/app-linux-arm64: FORCE
+ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(GOFLAGS_COLD) -trimpath -ldflags '$(LDFLAGS)' -o $@ .
+
+FORCE:
+
+test: css-check
+ go vet ./...
+ CGO_ENABLED=0 go test ./...
+
+# The served stylesheets must stay byte-for-byte copies of shared/.
+css-check:
+ cmp ../../shared/base.css web/static/base.css
+ cmp ../../shared/index.css web/static/index.css
+
+clean:
+ rm -rf dist
+
+# Go keeps compiled packages in the shared GOCACHE, which this repo never
+# changes or clears. Downloaded sources stay in the module cache; use
+# `make build-arm64 COLD=1` to force every package to recompile.
+clean-deps: clean
+
+bump:
+ sed -i 's/^const bumpMarker = "steady"$$/const bumpMarker = "bumped"/' web/web.go
+ grep -q '^const bumpMarker = "bumped"$$' web/web.go
+
+unbump:
+ sed -i 's/^const bumpMarker = "bumped"$$/const bumpMarker = "steady"/' web/web.go
+ grep -q '^const bumpMarker = "steady"$$' web/web.go
+
+# Ships the binary `make build-arm64` made; it doesn't rebuild.
+deploy:
+ test -f dist/app-linux-arm64 || { echo 'run make build-arm64 first' >&2; exit 1; }
+ carlos deploy --account bfj --app todo-go --target linux-arm64 --no-prompt \
+ --version $(VERSION) --message "$(or $(MESSAGE),Deploy todo-go $(VERSION))" dist/app-linux-arm64
diff --git a/apps/go/README.md b/apps/go/README.md
new file mode 100644
index 0000000..57ceba0
--- /dev/null
+++ b/apps/go/README.md
@@ -0,0 +1,95 @@
+# 🤖 todo-go
+
+TodoMVC for the CARLOS bakeoff, written in Go. It's live at
+[https://todo-go.bfj.oncarlos.com](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
+ from `https://$CARLOS_HOST` (or carry no `Origin` at all) and the
+ cookie is marked `Secure`.
+- `GET /healthz` checks the database answers; `GET /api/version` returns
+ 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:
+
+```sh
+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` |
diff --git a/apps/go/STATUS.md b/apps/go/STATUS.md
new file mode 100644
index 0000000..82fe3c6
--- /dev/null
+++ b/apps/go/STATUS.md
@@ -0,0 +1,44 @@
+LIVE https://todo-go.bfj.oncarlos.com 8a42c8d
+
+## Checks (review round, 8a42c8d)
+
+- `make test` passes, including new tests for unissued cookies and the lists migration.
+- Shared suite locally: 67 passed, 3 skipped. Boot-check: `/healthz` 200, `/api/version` 200 `8a42c8d`, clean exit on SIGTERM.
+- `make deploy` swapped to `8a42c8d`; `/api/version` reads `8a42c8d`. Shared suite against the live host: 67 passed, 3 skipped. No errors in `carlos errors`.
+
+## Review findings fixed
+
+1. **Unissued cookies accepted.** Issued lists are now recorded in a new
+ `todo_lists` table (migration 2, additive). A cookie whose hash isn't
+ there gets a fresh random cookie and an empty list. The migration
+ copies in every list that already held todos, so existing visitors
+ keep theirs.
+2. **Mid-edit redraw discarded the draft.** `swap()` in `app.js` now
+ carries any open, not-yet-saving edit (its draft, focus and cursor)
+ into the redrawn list, and ignores the focusout that removing the
+ old field can fire. Checked in a browser: with a toggle's response
+ held back, editing another item and then letting the response land
+ keeps the edit open with its draft, and Enter saves it.
+
+## Review findings not fixed
+
+None.
+
+## Checks (first build, 475f799)
+
+- `make test`: passes (stylesheet byte check, `go vet`, Go tests for the store and the handlers).
+- Shared suite on a local build at 475f799: 67 passed, 3 skipped (the two JS-only edit tests with JS off, and the screenshot test with JS off).
+- `scripts/boot-check.sh dist/app-linux-arm64`: `/healthz` 200, `/api/version` 200 `475f799`, clean exit on SIGTERM.
+- `make deploy`: shipped, promoted to `edge` and swapped. `/api/version` and `X-Carlos-Version` both read `475f799`.
+- Shared suite against the live host: 67 passed, 3 skipped. `carlos errors` and `carlos availability` show nothing in the last hour.
+
+## Notes
+
+- The app and its instance already existed from a superseded pilot,
+ serving `d9bf9fc` with a 2.2 MB database. That database wasn't
+ touched. This build's tables are named `todo_items` and
+ `todo_migrations` so they can't collide with whatever the pilot
+ created, and a test covers opening beside foreign tables.
+- `carlos apps build-probe /api/version` was set again (it's
+ idempotent). `apps create` and `instances enable/create` weren't
+ needed.
diff --git a/apps/go/go.mod b/apps/go/go.mod
new file mode 100644
index 0000000..15dbdda
--- /dev/null
+++ b/apps/go/go.mod
@@ -0,0 +1,17 @@
+module todo-go
+
+go 1.26.10
+
+require modernc.org/sqlite v1.61.0
+
+require (
+ github.com/dustin/go-humanize v1.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/ncruces/go-strftime v1.1.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.49.0 // indirect
+ modernc.org/libc v1.79.0 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.12.1 // indirect
+)
diff --git a/apps/go/go.sum b/apps/go/go.sum
new file mode 100644
index 0000000..f39acd7
--- /dev/null
+++ b/apps/go/go.sum
@@ -0,0 +1,50 @@
+github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
+github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
+golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
+golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
+golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
+golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
+modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
+modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
+modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
+modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
+modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
+modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/apps/go/internal/todo/store.go b/apps/go/internal/todo/store.go
new file mode 100644
index 0000000..da857f7
--- /dev/null
+++ b/apps/go/internal/todo/store.go
@@ -0,0 +1,233 @@
+// Package todo keeps every visitor's todo list in one SQLite file.
+//
+// A list is identified by a ListKey: the SHA-256 of the visitor's cookie.
+// Every query that reads or changes a todo names the list, so an id that
+// belongs to someone else behaves exactly like an id that doesn't exist.
+package todo
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "errors"
+ "fmt"
+ "net/url"
+ "runtime"
+ "strings"
+
+ _ "modernc.org/sqlite" // registers the "sqlite" driver; pure Go, no cgo
+)
+
+// ErrNotFound means the id isn't in the caller's list.
+var ErrNotFound = errors.New("todo: not found")
+
+// ListKey names one visitor's list.
+type ListKey [sha256.Size]byte
+
+// KeyFor derives the list key from the visitor's secret cookie bytes. The
+// database only ever sees the hash, never the cookie.
+func KeyFor(secret []byte) ListKey { return sha256.Sum256(secret) }
+
+// Todo is one item in a list.
+type Todo struct {
+ ID int64
+ Title string
+ Completed bool
+}
+
+// migrations only ever grow. Each entry runs once, in order, recorded in
+// todo_migrations. Never edit or remove one that has shipped; append a
+// new one. Names are prefixed so they can't collide with tables an
+// earlier build may have left in the same file.
+var migrations = []string{
+ `CREATE TABLE todo_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ list BLOB NOT NULL,
+ title TEXT NOT NULL,
+ completed INTEGER NOT NULL DEFAULT 0,
+ created INTEGER NOT NULL DEFAULT (unixepoch())
+ );
+ CREATE INDEX todo_items_by_list ON todo_items (list, id);`,
+ // Lists the server has issued. A cookie whose hash isn't here is
+ // replaced, so nobody can pick their own list identity. Lists that
+ // already hold todos were issued by an earlier build, so they count.
+ `CREATE TABLE todo_lists (
+ key BLOB PRIMARY KEY,
+ created INTEGER NOT NULL DEFAULT (unixepoch())
+ ) WITHOUT ROWID;
+ INSERT INTO todo_lists (key) SELECT DISTINCT list FROM todo_items;`,
+}
+
+// Store is the todo database: one connection for writes, a small pool
+// for reads. SQLite allows a single writer at a time, so funnelling
+// writes through one connection turns lock contention into a queue.
+type Store struct {
+ w, r *sql.DB
+}
+
+// Open opens (creating if needed) the database at path in WAL mode and
+// brings its schema up to date.
+func Open(ctx context.Context, path string) (*Store, error) {
+ // Every connection the pools open runs these pragmas first.
+ dsn := func(pragmas ...string) string {
+ q := url.Values{}
+ for _, p := range append([]string{"busy_timeout(5000)", "synchronous(NORMAL)"}, pragmas...) {
+ q.Add("_pragma", p)
+ }
+ return "file:" + path + "?" + q.Encode()
+ }
+ w, err := sql.Open("sqlite", dsn("journal_mode(WAL)")+"&_txlock=immediate")
+ if err != nil {
+ return nil, err
+ }
+ w.SetMaxOpenConns(1)
+ r, err := sql.Open("sqlite", dsn("query_only(1)"))
+ if err != nil {
+ w.Close()
+ return nil, err
+ }
+ r.SetMaxOpenConns(max(4, runtime.NumCPU()))
+ s := &Store{w: w, r: r}
+ if err := s.migrate(ctx); err != nil {
+ s.Close()
+ return nil, err
+ }
+ return s, nil
+}
+
+func (s *Store) migrate(ctx context.Context) error {
+ tx, err := s.w.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS todo_migrations (
+ version INTEGER PRIMARY KEY,
+ applied INTEGER NOT NULL DEFAULT (unixepoch())
+ )`); err != nil {
+ return err
+ }
+ var have int
+ if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM todo_migrations`).Scan(&have); err != nil {
+ return err
+ }
+ if have > len(migrations) {
+ return fmt.Errorf("todo: database is at schema %d, this build knows %d", have, len(migrations))
+ }
+ for i := have; i < len(migrations); i++ {
+ if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
+ return fmt.Errorf("todo: migration %d: %w", i+1, err)
+ }
+ if _, err := tx.ExecContext(ctx, `INSERT INTO todo_migrations (version) VALUES (?)`, i+1); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// Close checkpoints and closes the database.
+func (s *Store) Close() error {
+ return errors.Join(s.r.Close(), s.w.Close())
+}
+
+// Ping checks that the database answers.
+func (s *Store) Ping(ctx context.Context) error { return s.r.PingContext(ctx) }
+
+// CreateList records a newly issued list.
+func (s *Store) CreateList(ctx context.Context, k ListKey) error {
+ _, err := s.w.ExecContext(ctx, `INSERT INTO todo_lists (key) VALUES (?)`, k[:])
+ return err
+}
+
+// ListExists reports whether the server issued this list.
+func (s *Store) ListExists(ctx context.Context, k ListKey) (bool, error) {
+ var n int
+ err := s.r.QueryRowContext(ctx, `SELECT count(*) FROM todo_lists WHERE key = ?`, k[:]).Scan(&n)
+ return n > 0, err
+}
+
+// List returns every todo in the list, oldest first.
+func (s *Store) List(ctx context.Context, k ListKey) ([]Todo, error) {
+ rows, err := s.r.QueryContext(ctx,
+ `SELECT id, title, completed FROM todo_items WHERE list = ? ORDER BY id`, k[:])
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Todo
+ for rows.Next() {
+ var t Todo
+ if err := rows.Scan(&t.ID, &t.Title, &t.Completed); err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// CleanTitle trims a submitted title and replaces any invalid UTF-8.
+// An empty result means "no title".
+func CleanTitle(title string) string {
+ return strings.TrimSpace(strings.ToValidUTF8(title, "�"))
+}
+
+// Add appends a todo. A title that is empty after cleaning adds nothing.
+func (s *Store) Add(ctx context.Context, k ListKey, title string) error {
+ if title = CleanTitle(title); title == "" {
+ return nil
+ }
+ _, err := s.w.ExecContext(ctx, `INSERT INTO todo_items (list, title) VALUES (?, ?)`, k[:], title)
+ return err
+}
+
+// Toggle flips one todo between active and completed.
+func (s *Store) Toggle(ctx context.Context, k ListKey, id int64) error {
+ return one(s.w.ExecContext(ctx,
+ `UPDATE todo_items SET completed = 1 - completed WHERE id = ? AND list = ?`, id, k[:]))
+}
+
+// Rename sets a todo's title. A title that is empty after cleaning
+// deletes the todo instead, as TodoMVC does.
+func (s *Store) Rename(ctx context.Context, k ListKey, id int64, title string) error {
+ if title = CleanTitle(title); title == "" {
+ return s.Delete(ctx, k, id)
+ }
+ return one(s.w.ExecContext(ctx,
+ `UPDATE todo_items SET title = ? WHERE id = ? AND list = ?`, title, id, k[:]))
+}
+
+// Delete removes one todo.
+func (s *Store) Delete(ctx context.Context, k ListKey, id int64) error {
+ return one(s.w.ExecContext(ctx, `DELETE FROM todo_items WHERE id = ? AND list = ?`, id, k[:]))
+}
+
+// ToggleAll marks every todo completed, or, if every one already is,
+// marks them all active.
+func (s *Store) ToggleAll(ctx context.Context, k ListKey) error {
+ _, err := s.w.ExecContext(ctx,
+ `UPDATE todo_items
+ SET completed = EXISTS (SELECT 1 FROM todo_items WHERE list = ?1 AND completed = 0)
+ WHERE list = ?1`, k[:])
+ return err
+}
+
+// ClearCompleted deletes every completed todo.
+func (s *Store) ClearCompleted(ctx context.Context, k ListKey) error {
+ _, err := s.w.ExecContext(ctx, `DELETE FROM todo_items WHERE list = ? AND completed = 1`, k[:])
+ return err
+}
+
+// one turns "no row matched" into ErrNotFound.
+func one(res sql.Result, err error) error {
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
diff --git a/apps/go/internal/todo/store_test.go b/apps/go/internal/todo/store_test.go
new file mode 100644
index 0000000..fa20643
--- /dev/null
+++ b/apps/go/internal/todo/store_test.go
@@ -0,0 +1,228 @@
+package todo
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func open(t *testing.T) *Store {
+ t.Helper()
+ s, err := Open(context.Background(), filepath.Join(t.TempDir(), "todo.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+func titles(t *testing.T, s *Store, k ListKey) string {
+ t.Helper()
+ items, err := s.List(context.Background(), k)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var b strings.Builder
+ for _, it := range items {
+ if it.Completed {
+ b.WriteString("x:")
+ }
+ b.WriteString(it.Title + ";")
+ }
+ return b.String()
+}
+
+func TestAddTrimsAndSkipsBlank(t *testing.T) {
+ s, ctx, k := open(t), context.Background(), KeyFor([]byte("a"))
+ for _, title := range []string{" one ", " ", "", "two"} {
+ if err := s.Add(ctx, k, title); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := titles(t, s, k); got != "one;two;" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+func TestToggleAllAndClear(t *testing.T) {
+ s, ctx, k := open(t), context.Background(), KeyFor([]byte("a"))
+ for _, title := range []string{"a", "b", "c"} {
+ s.Add(ctx, k, title)
+ }
+ items, _ := s.List(ctx, k)
+ if err := s.Toggle(ctx, k, items[0].ID); err != nil {
+ t.Fatal(err)
+ }
+ s.ToggleAll(ctx, k) // some active: all become completed
+ if got := titles(t, s, k); got != "x:a;x:b;x:c;" {
+ t.Fatalf("after first toggle-all: %q", got)
+ }
+ s.ToggleAll(ctx, k) // all completed: all become active
+ if got := titles(t, s, k); got != "a;b;c;" {
+ t.Fatalf("after second toggle-all: %q", got)
+ }
+ s.Toggle(ctx, k, items[1].ID)
+ s.ClearCompleted(ctx, k)
+ if got := titles(t, s, k); got != "a;c;" {
+ t.Fatalf("after clear: %q", got)
+ }
+}
+
+func TestRenameEmptyDeletes(t *testing.T) {
+ s, ctx, k := open(t), context.Background(), KeyFor([]byte("a"))
+ s.Add(ctx, k, "a")
+ s.Add(ctx, k, "b")
+ items, _ := s.List(ctx, k)
+ if err := s.Rename(ctx, k, items[0].ID, " renamed "); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Rename(ctx, k, items[1].ID, " "); err != nil {
+ t.Fatal(err)
+ }
+ if got := titles(t, s, k); got != "renamed;" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+// Another list's id must look exactly like a missing one, and change nothing.
+func TestListsAreIsolated(t *testing.T) {
+ s, ctx := open(t), context.Background()
+ mine, theirs := KeyFor([]byte("mine")), KeyFor([]byte("theirs"))
+ s.Add(ctx, mine, "private")
+ items, _ := s.List(ctx, mine)
+ id := items[0].ID
+
+ for name, err := range map[string]error{
+ "toggle": s.Toggle(ctx, theirs, id),
+ "rename": s.Rename(ctx, theirs, id, "hacked"),
+ "empty": s.Rename(ctx, theirs, id, ""),
+ "delete": s.Delete(ctx, theirs, id),
+ } {
+ if !errors.Is(err, ErrNotFound) {
+ t.Errorf("%s: got %v, want ErrNotFound", name, err)
+ }
+ }
+ s.ToggleAll(ctx, theirs)
+ s.ClearCompleted(ctx, theirs)
+ if got := titles(t, s, mine); got != "private;" {
+ t.Fatalf("my list changed: %q", got)
+ }
+ if got := titles(t, s, theirs); got != "" {
+ t.Fatalf("their list sees: %q", got)
+ }
+}
+
+func TestKeepsUnicodeAndLongTitles(t *testing.T) {
+ s, ctx, k := open(t), context.Background(), KeyFor([]byte("a"))
+ long := strings.Repeat("x", 10_000)
+ s.Add(ctx, k, "naïve 🚀 日本語")
+ s.Add(ctx, k, long)
+ items, _ := s.List(ctx, k)
+ if items[0].Title != "naïve 🚀 日本語" || items[1].Title != long {
+ t.Fatalf("titles changed: %q, %d chars", items[0].Title, len(items[1].Title))
+ }
+}
+
+// Reopening an existing database runs no migration twice and keeps data.
+func TestReopenKeepsData(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "todo.db")
+ ctx, k := context.Background(), KeyFor([]byte("a"))
+ s, err := Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.Add(ctx, k, "kept")
+ s.Close()
+ s, err = Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ if got := titles(t, s, k); got != "kept;" {
+ t.Fatalf("got %q", got)
+ }
+ var mode string
+ s.r.QueryRow(`PRAGMA journal_mode`).Scan(&mode)
+ if mode != "wal" {
+ t.Fatalf("journal_mode = %q, want wal", mode)
+ }
+}
+
+// A file an earlier build left behind, with its own tables and
+// user_version, opens cleanly and keeps that data untouched.
+func TestOpensBesideForeignTables(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "todo.db")
+ ctx := context.Background()
+ s, err := Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, q := range []string{
+ `DROP TABLE todo_items`, `DROP TABLE todo_lists`, `DROP TABLE todo_migrations`,
+ `CREATE TABLE todos (id INTEGER PRIMARY KEY, text TEXT)`,
+ `INSERT INTO todos (text) VALUES ('old')`,
+ `PRAGMA user_version = 7`,
+ } {
+ if _, err := s.w.Exec(q); err != nil {
+ t.Fatal(err)
+ }
+ }
+ s.Close()
+
+ s, err = Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ k := KeyFor([]byte("a"))
+ s.Add(ctx, k, "new")
+ if got := titles(t, s, k); got != "new;" {
+ t.Fatalf("got %q", got)
+ }
+ var old string
+ s.r.QueryRow(`SELECT text FROM todos`).Scan(&old)
+ if old != "old" {
+ t.Fatalf("earlier data lost: %q", old)
+ }
+}
+
+func TestOnlyIssuedListsExist(t *testing.T) {
+ s, ctx := open(t), context.Background()
+ k := KeyFor([]byte("issued"))
+ if ok, _ := s.ListExists(ctx, k); ok {
+ t.Fatal("unissued list exists")
+ }
+ if err := s.CreateList(ctx, k); err != nil {
+ t.Fatal(err)
+ }
+ if ok, _ := s.ListExists(ctx, k); !ok {
+ t.Fatal("issued list missing")
+ }
+}
+
+// Lists that already held todos before the lists table existed keep working.
+func TestListsMigrationKeepsExistingLists(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "todo.db")
+ ctx, k := context.Background(), KeyFor([]byte("old visitor"))
+ s, err := Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.Add(ctx, k, "kept")
+ for _, q := range []string{`DROP TABLE todo_lists`, `DELETE FROM todo_migrations WHERE version = 2`} {
+ if _, err := s.w.Exec(q); err != nil {
+ t.Fatal(err)
+ }
+ }
+ s.Close()
+ s, err = Open(ctx, path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ if ok, _ := s.ListExists(ctx, k); !ok {
+ t.Fatal("existing list not carried into todo_lists")
+ }
+}
diff --git a/apps/go/main.go b/apps/go/main.go
new file mode 100644
index 0000000..f527963
--- /dev/null
+++ b/apps/go/main.go
@@ -0,0 +1,118 @@
+// Command todo-go is the CARLOS bakeoff's TodoMVC, in Go.
+//
+// todo-go --socket <path> --db <path> on Carloku
+// todo-go --listen 127.0.0.1:<port> --db <path> locally
+//
+// It serves until SIGTERM or SIGINT, then finishes in-flight requests,
+// closes the database and exits 0.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "todo-go/internal/todo"
+ "todo-go/web"
+)
+
+// version is the build version, set with -ldflags "-X main.version=<sha>".
+var version = "dev"
+
+func main() {
+ log := slog.New(slog.NewTextHandler(os.Stderr, nil))
+ if err := run(os.Args[1:], log); err != nil {
+ log.Error("exiting", "err", err)
+ os.Exit(1)
+ }
+}
+
+func run(args []string, log *slog.Logger) error {
+ fset := flag.NewFlagSet("todo-go", flag.ContinueOnError)
+ socket := fset.String("socket", "", "unix socket to listen on (Carloku)")
+ listen := fset.String("listen", "", "127.0.0.1:<port> to listen on instead of a socket (local runs)")
+ dbPath := fset.String("db", "", "SQLite database file")
+ if err := fset.Parse(args); err != nil {
+ return err
+ }
+ if *dbPath == "" || (*socket == "") == (*listen == "") {
+ return errors.New("usage: todo-go (--socket <path> | --listen 127.0.0.1:<port>) --db <path>")
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
+ defer stop()
+
+ cfg := web.Config{Version: version, Log: log}
+ var ln net.Listener
+ var err error
+ if *socket != "" {
+ // The platform hands us our hostname; never derive it from the socket.
+ if host := os.Getenv("CARLOS_HOST"); host != "" {
+ cfg.Origin = "https://" + host
+ cfg.Secure = true
+ }
+ os.Remove(*socket) // a stale socket from a previous run
+ ln, err = net.Listen("unix", *socket)
+ } else {
+ if err := checkLoopback(*listen); err != nil {
+ return err
+ }
+ ln, err = net.Listen("tcp", *listen)
+ }
+ if err != nil {
+ return err
+ }
+ if *listen != "" {
+ cfg.Origin = "http://" + ln.Addr().String() // the real port, even for :0
+ }
+
+ store, err := todo.Open(ctx, *dbPath)
+ if err != nil {
+ ln.Close()
+ return err
+ }
+ defer store.Close()
+
+ srv := &http.Server{
+ Handler: web.New(store, cfg),
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 2 * time.Minute,
+ ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
+ }
+ served := make(chan error, 1)
+ go func() { served <- srv.Serve(ln) }()
+ log.Info("serving", "addr", ln.Addr().String(), "version", version)
+
+ select {
+ case err := <-served:
+ return err
+ case <-ctx.Done():
+ }
+ log.Info("shutting down")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+}
+
+// checkLoopback refuses any --listen address that isn't a loopback IP,
+// so a local run can never be reached from the network.
+func checkLoopback(addr string) error {
+ host, _, err := net.SplitHostPort(addr)
+ if err != nil {
+ return fmt.Errorf("--listen %q: %w", addr, err)
+ }
+ if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
+ return fmt.Errorf("--listen %q: only a loopback address such as 127.0.0.1 is allowed", addr)
+ }
+ return nil
+}
diff --git a/apps/go/main_test.go b/apps/go/main_test.go
new file mode 100644
index 0000000..b748bbb
--- /dev/null
+++ b/apps/go/main_test.go
@@ -0,0 +1,19 @@
+package main
+
+import "testing"
+
+func TestCheckLoopback(t *testing.T) {
+ for addr, ok := range map[string]bool{
+ "127.0.0.1:8080": true,
+ "[::1]:8080": true,
+ "0.0.0.0:8080": false,
+ ":8080": false,
+ "10.0.0.5:8080": false,
+ "localhost:8080": false, // a name could resolve anywhere
+ "127.0.0.1": false,
+ } {
+ if err := checkLoopback(addr); (err == nil) != ok {
+ t.Errorf("checkLoopback(%q) = %v, want ok=%v", addr, err, ok)
+ }
+ }
+}
diff --git a/apps/go/web/static/app.css b/apps/go/web/static/app.css
new file mode 100644
index 0000000..6c5cb33
--- /dev/null
+++ b/apps/go/web/static/app.css
@@ -0,0 +1,22 @@
+/* Layered on TodoMVC's own base.css and index.css. Nothing here changes how
+ the page looks with JavaScript on; nojs.css, loaded only inside
+ <noscript>, shows the plain-form controls. */
+
+/* Forms wrap the controls but take no box of their own, so the markup
+ lays out exactly like TodoMVC's template. */
+form.inline {
+ display: contents;
+}
+
+/* The submit buttons and edit link that drive the page without
+ JavaScript. app.js drives the checkboxes and double-click instead. */
+.toggle-submit,
+.toggle-all-submit,
+.edit-link {
+ display: none;
+}
+
+/* Keyboard users can reach the delete button, not just mouse users. */
+.todo-list li:focus-within .destroy {
+ display: block;
+}
diff --git a/apps/go/web/static/app.js b/apps/go/web/static/app.js
new file mode 100644
index 0000000..26e8590
--- /dev/null
+++ b/apps/go/web/static/app.js
@@ -0,0 +1,143 @@
+// Makes the server-rendered TodoMVC behave like the reference app.
+//
+// Every change is still the form POST the page uses with JavaScript off.
+// This module sends that same POST in the background with an
+// X-Todo-Fragment header, and the server answers with fresh HTML for the
+// list and footer instead of a redirect. The server stays the only place
+// that decides what the page shows.
+
+const app = document.querySelector('.todoapp');
+const newTodo = app.querySelector('.new-todo');
+
+// Requests run one at a time, so their answers land in the order they
+// were made.
+let queue = Promise.resolve();
+let swapping = false;
+
+function send(form) {
+ const body = new URLSearchParams(new FormData(form));
+ const url = form.action;
+ queue = queue
+ .then(async () => {
+ const res = await fetch(url, {
+ method: 'POST',
+ body,
+ headers: { 'X-Todo-Fragment': '1' },
+ });
+ if (!res.ok) throw new Error(`${res.status} from ${url}`);
+ swap(await res.text());
+ })
+ .catch((err) => {
+ // Something is out of step (an expired list, a lost connection).
+ // The server's page is the truth, so load it.
+ console.error(err);
+ location.reload();
+ });
+}
+
+// Replaces section.main and footer.footer with the server's HTML, keeping
+// keyboard focus on the same control where it still exists, and keeping
+// any edit still in progress (one not yet being saved) open with its
+// draft intact.
+function swap(html) {
+ const again = focusSelector(document.activeElement);
+ const drafts = [...app.querySelectorAll('.todo-list li.editing:not([data-saving])')].map((li) => {
+ const input = li.querySelector('.edit');
+ return {
+ id: li.dataset.id,
+ value: input.value,
+ focused: input === document.activeElement,
+ start: input.selectionStart,
+ end: input.selectionEnd,
+ };
+ });
+ // Removing a focused field can fire focusout; that isn't the visitor
+ // leaving the field, so it mustn't save.
+ swapping = true;
+ for (const el of app.querySelectorAll(':scope > .main, :scope > .footer')) el.remove();
+ swapping = false;
+ const t = document.createElement('template');
+ t.innerHTML = html;
+ app.append(t.content);
+ for (const d of drafts) {
+ const li = app.querySelector(`.todo-list li[data-id="${CSS.escape(d.id)}"]`);
+ if (!li) continue; // the item is gone, so is its edit
+ const input = li.querySelector('.edit');
+ li.classList.add('editing');
+ input.value = d.value;
+ if (d.focused) {
+ input.focus();
+ input.setSelectionRange(d.start, d.end);
+ }
+ }
+ if (again && !drafts.some((d) => d.focused)) app.querySelector(again)?.focus();
+}
+
+function focusSelector(el) {
+ if (!el || !app.contains(el) || el === newTodo) return null;
+ if (el.id) return `#${CSS.escape(el.id)}`;
+ const li = el.closest('li[data-id]');
+ if (li && el.classList.length) {
+ return `li[data-id="${CSS.escape(li.dataset.id)}"] .${CSS.escape(el.classList[0])}`;
+ }
+ return null;
+}
+
+// Adding, deleting, clearing completed, and saving an edit with Enter all
+// arrive here as ordinary form submissions.
+app.addEventListener('submit', (e) => {
+ const form = e.target;
+ e.preventDefault();
+ const edit = form.querySelector('.edit');
+ if (edit) {
+ saveEdit(edit);
+ return;
+ }
+ send(form);
+ if (form.contains(newTodo)) newTodo.value = '';
+});
+
+// The checkboxes have no submit button of their own: a change submits the
+// form they sit in.
+app.addEventListener('change', (e) => {
+ if (e.target.matches('.toggle, .toggle-all')) send(e.target.form);
+});
+
+// Double-click a title to edit it.
+app.addEventListener('dblclick', (e) => {
+ const label = e.target.closest('.todo-list label');
+ if (!label) return;
+ const li = label.closest('li');
+ const input = li.querySelector('.edit');
+ li.classList.add('editing');
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+});
+
+// Escape cancels the edit; leaving the field saves it.
+app.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape' || !e.target.matches('.edit')) return;
+ const input = e.target;
+ input.value = input.defaultValue;
+ input.closest('li').classList.remove('editing');
+});
+
+app.addEventListener('focusout', (e) => {
+ if (!swapping && e.target.matches('.edit')) saveEdit(e.target);
+});
+
+// Saves an edit once, however many ways it was finished (Enter, then the
+// blur that follows when the item is redrawn).
+function saveEdit(input) {
+ const li = input.closest('li');
+ if (!li.classList.contains('editing') || li.dataset.saving) return;
+ li.dataset.saving = '1';
+ if (input.value.trim() === input.defaultValue) {
+ // Unchanged: nothing to send, just leave edit mode.
+ input.value = input.defaultValue;
+ li.classList.remove('editing');
+ delete li.dataset.saving;
+ return;
+ }
+ send(input.form);
+}
diff --git a/apps/go/web/static/base.css b/apps/go/web/static/base.css
new file mode 100644
index 0000000..da65968
--- /dev/null
+++ b/apps/go/web/static/base.css
@@ -0,0 +1,141 @@
+hr {
+ margin: 20px 0;
+ border: 0;
+ border-top: 1px dashed #c5c5c5;
+ border-bottom: 1px dashed #f7f7f7;
+}
+
+.learn a {
+ font-weight: normal;
+ text-decoration: none;
+ color: #b83f45;
+}
+
+.learn a:hover {
+ text-decoration: underline;
+ color: #787e7e;
+}
+
+.learn h3,
+.learn h4,
+.learn h5 {
+ margin: 10px 0;
+ font-weight: 500;
+ line-height: 1.2;
+ color: #000;
+}
+
+.learn h3 {
+ font-size: 24px;
+}
+
+.learn h4 {
+ font-size: 18px;
+}
+
+.learn h5 {
+ margin-bottom: 0;
+ font-size: 14px;
+}
+
+.learn ul {
+ padding: 0;
+ margin: 0 0 30px 25px;
+}
+
+.learn li {
+ line-height: 20px;
+}
+
+.learn p {
+ font-size: 15px;
+ font-weight: 300;
+ line-height: 1.3;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+#issue-count {
+ display: none;
+}
+
+.quote {
+ border: none;
+ margin: 20px 0 60px 0;
+}
+
+.quote p {
+ font-style: italic;
+}
+
+.quote p:before {
+ content: '“';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ top: -20px;
+ left: 3px;
+}
+
+.quote p:after {
+ content: '”';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ bottom: -42px;
+ right: 3px;
+}
+
+.quote footer {
+ position: absolute;
+ bottom: -40px;
+ right: 0;
+}
+
+.quote footer img {
+ border-radius: 3px;
+}
+
+.quote footer a {
+ margin-left: 5px;
+ vertical-align: middle;
+}
+
+.speech-bubble {
+ position: relative;
+ padding: 10px;
+ background: rgba(0, 0, 0, .04);
+ border-radius: 5px;
+}
+
+.speech-bubble:after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ right: 30px;
+ border: 13px solid transparent;
+ border-top-color: rgba(0, 0, 0, .04);
+}
+
+.learn-bar > .learn {
+ position: absolute;
+ width: 272px;
+ top: 8px;
+ left: -300px;
+ padding: 10px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, .6);
+ transition-property: left;
+ transition-duration: 500ms;
+}
+
+@media (min-width: 899px) {
+ .learn-bar {
+ width: auto;
+ padding-left: 300px;
+ }
+
+ .learn-bar > .learn {
+ left: 8px;
+ }
+}
diff --git a/apps/go/web/static/index.css b/apps/go/web/static/index.css
new file mode 100644
index 0000000..2c0b4b6
--- /dev/null
+++ b/apps/go/web/static/index.css
@@ -0,0 +1,393 @@
+@charset 'utf-8';
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #111111;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 300;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -140px;
+ width: 100%;
+ font-size: 80px;
+ font-weight: 200;
+ text-align: center;
+ color: #b83f45;
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ height: 65px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+.toggle-all {
+ width: 1px;
+ height: 1px;
+ border: none; /* Mobile Safari */
+ opacity: 0;
+ position: absolute;
+ right: 100%;
+ bottom: 100%;
+}
+
+.toggle-all + label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ position: absolute;
+ top: -65px;
+ left: -0;
+}
+
+.toggle-all + label:before {
+ content: '❯';
+ display: inline-block;
+ font-size: 22px;
+ color: #949494;
+ padding: 10px 27px 10px 27px;
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+
+.toggle-all:checked + label:before {
+ color: #484848;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none; /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle + label {
+ /*
+ Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
+ IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
+ */
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked + label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.todo-list li label {
+ overflow-wrap: break-word;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+ font-weight: 400;
+ color: #484848;
+}
+
+.todo-list li.completed label {
+ color: #949494;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #949494;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover,
+.todo-list li .destroy:focus {
+ color: #C18585;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+ display: block;
+ height: 100%;
+ line-height: 1.1;
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ font-size: 15px;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: #DB7676;
+}
+
+.filters li a.selected {
+ border-color: #CE4646;
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 19px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #4d4d4d;
+ font-size: 11px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/*
+ Hack to remove background from Mobile Safari.
+ Can't use it globally since it destroys checkboxes in Firefox
+*/
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
+
+:focus,
+.toggle:focus + label,
+.toggle-all:focus + label {
+ box-shadow: 0 0 2px 2px #CF7D7D;
+ outline: 0;
+}
diff --git a/apps/go/web/static/nojs.css b/apps/go/web/static/nojs.css
new file mode 100644
index 0000000..c1971c4
--- /dev/null
+++ b/apps/go/web/static/nojs.css
@@ -0,0 +1,51 @@
+/* Loaded only when JavaScript is off (inside <noscript>). The checkboxes
+ can't submit anything by themselves, so transparent submit buttons sit
+ over them, and each item gets a visible "edit" link in place of
+ double-click. */
+
+.todo-list li .toggle {
+ display: none;
+}
+
+.todo-list li .toggle-submit {
+ display: block;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+ cursor: pointer;
+}
+
+.toggle-all-submit {
+ display: block;
+ position: absolute;
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+ cursor: pointer;
+}
+
+.todo-list li label {
+ padding-right: 100px;
+}
+
+.todo-list li .edit-link {
+ display: block;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 55px;
+ height: 20px;
+ margin: auto 0;
+ font-size: 14px;
+ line-height: 20px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.todo-list li .edit-link:hover {
+ color: #484848;
+ text-decoration: underline;
+}
diff --git a/apps/go/web/templates/page.html b/apps/go/web/templates/page.html
new file mode 100644
index 0000000..e5bc3a2
--- /dev/null
+++ b/apps/go/web/templates/page.html
@@ -0,0 +1,83 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>TodoMVC</title>
+ <link rel="stylesheet" href="/static/base.css?v={{.Version}}">
+ <link rel="stylesheet" href="/static/index.css?v={{.Version}}">
+ <link rel="stylesheet" href="/static/app.css?v={{.Version}}">
+ <noscript><link rel="stylesheet" href="/static/nojs.css?v={{.Version}}"></noscript>
+ <script type="module" src="/static/app.js?v={{.Version}}"></script>
+ </head>
+ <body>
+ <section class="todoapp">
+ <header class="header">
+ <h1>todos</h1>
+ <form class="inline" method="post" action="/todos">
+ <input type="hidden" name="filter" value="{{.Filter}}">
+ <input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"{{if not .EditID}} autofocus{{end}}>
+ </form>
+ </header>
+{{template "list" .}}
+ </section>
+ <footer class="info">
+ <p>Double-click to edit a todo</p>
+ <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · Go</p>
+ <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+ </footer>
+ </body>
+</html>
+{{define "list"}}{{if .Total}}
+ <section class="main">
+ <form class="inline" method="post" action="/todos/toggle-all">
+ <input type="hidden" name="filter" value="{{.Filter}}">
+ <input id="toggle-all" class="toggle-all" type="checkbox"{{if not .Active}} checked{{end}}>
+ <label for="toggle-all">Mark all as complete</label>
+ <button class="toggle-all-submit" data-action="toggle-all" title="{{if .Active}}Mark all as complete{{else}}Mark all as active{{end}}"></button>
+ </form>
+ <ul class="todo-list">
+{{- range .Items}}
+ <li{{if .Completed}} class="completed{{if eq .ID $.EditID}} editing{{end}}"{{else if eq .ID $.EditID}} class="editing"{{end}} data-id="{{.ID}}">
+ <div class="view">
+ <form class="inline" method="post" action="/todos/{{.ID}}/toggle">
+ <input type="hidden" name="filter" value="{{$.Filter}}">
+ <input class="toggle" type="checkbox"{{if .Completed}} checked{{end}}>
+ <label>{{.Title}}</label>
+ <button class="toggle-submit" data-action="toggle" title="{{if .Completed}}Mark as active{{else}}Mark as complete{{end}}"></button>
+ </form>
+ <a class="edit-link" data-action="edit" href="{{$.Path}}?edit={{.ID}}">edit</a>
+ <form class="inline" method="post" action="/todos/{{.ID}}/destroy">
+ <input type="hidden" name="filter" value="{{$.Filter}}">
+ <button class="destroy" aria-label="Delete"></button>
+ </form>
+ </div>
+ <form class="inline" method="post" action="/todos/{{.ID}}/edit">
+ <input type="hidden" name="filter" value="{{$.Filter}}">
+ <input class="edit" name="title" value="{{.Title}}" aria-label="Edit todo"{{if eq .ID $.EditID}} autofocus{{end}}>
+ </form>
+ </li>
+{{- end}}
+ </ul>
+ </section>
+ <footer class="footer">
+ <span class="todo-count"><strong>{{.Active}}</strong> {{if eq .Active 1}}item{{else}}items{{end}} left</span>
+ <ul class="filters">
+ <li>
+ <a{{if eq .Filter "all"}} class="selected"{{end}} href="/">All</a>
+ </li>
+ <li>
+ <a{{if eq .Filter "active"}} class="selected"{{end}} href="/active">Active</a>
+ </li>
+ <li>
+ <a{{if eq .Filter "completed"}} class="selected"{{end}} href="/completed">Completed</a>
+ </li>
+ </ul>
+{{- if .Completed}}
+ <form class="inline" method="post" action="/todos/clear-completed">
+ <input type="hidden" name="filter" value="{{.Filter}}">
+ <button class="clear-completed">Clear completed</button>
+ </form>
+{{- end}}
+ </footer>
+{{- end}}{{end}}
diff --git a/apps/go/web/web.go b/apps/go/web/web.go
new file mode 100644
index 0000000..43b4a51
--- /dev/null
+++ b/apps/go/web/web.go
@@ -0,0 +1,377 @@
+// Package web serves the TodoMVC pages and form actions.
+//
+// Every action is an HTML form POST answered with 303 See Other, so the
+// app works with JavaScript off. The page's JavaScript sends the same
+// POSTs with an X-Todo-Fragment header and gets the list's HTML back
+// instead of a redirect.
+package web
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "embed"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "html/template"
+ "io/fs"
+ "log/slog"
+ "mime"
+ "net/http"
+ "path"
+ "strconv"
+
+ "todo-go/internal/todo"
+)
+
+// bumpMarker exists so `make bump` has a constant in this file to change,
+// forcing the next build to recompile it. It has no effect at run time.
+const bumpMarker = "steady"
+
+const (
+ cookieName = "todo_list"
+ // Browsers cap cookie lifetimes at 400 days; the page view renews it.
+ cookieMaxAge = 400 * 24 * 60 * 60
+ secretBytes = 32 // 256 bits of randomness per visitor
+ fragmentHeader = "X-Todo-Fragment"
+ maxFormBytes = 1 << 20
+)
+
+//go:embed templates/page.html
+var templateFS embed.FS
+
+//go:embed static
+var staticFS embed.FS
+
+var page = template.Must(template.ParseFS(templateFS, "templates/page.html"))
+
+// Config is what a Server needs to know about where it runs.
+type Config struct {
+ // Origin is this app's own origin, e.g. "https://todo-go.example".
+ // A POST whose Origin header is set to anything else is refused.
+ // Empty means "whatever origin the request's Host names".
+ Origin string
+ // Secure marks the list cookie Secure (the app is served over https).
+ Secure bool
+ // Version is the build version, served at /api/version.
+ Version string
+ Log *slog.Logger
+}
+
+// Server is the app's http.Handler.
+type Server struct {
+ cfg Config
+ store *todo.Store
+ mux *http.ServeMux
+ static map[string]asset
+}
+
+type asset struct {
+ body []byte
+ ctype string
+ etag string
+}
+
+// New builds the handler.
+func New(store *todo.Store, cfg Config) *Server {
+ if cfg.Log == nil {
+ cfg.Log = slog.Default()
+ }
+ s := &Server{cfg: cfg, store: store, mux: http.NewServeMux(), static: loadStatic()}
+
+ s.mux.HandleFunc("GET /{$}", s.page("all"))
+ s.mux.HandleFunc("GET /active", s.page("active"))
+ s.mux.HandleFunc("GET /completed", s.page("completed"))
+ s.mux.HandleFunc("GET /static/{name}", s.serveStatic)
+ s.mux.HandleFunc("GET /healthz", s.healthz)
+ s.mux.HandleFunc("GET /api/version", s.version)
+
+ s.mux.HandleFunc("POST /todos", s.action(func(r *http.Request, k todo.ListKey) error {
+ return s.store.Add(r.Context(), k, r.PostFormValue("title"))
+ }))
+ s.mux.HandleFunc("POST /todos/toggle-all", s.action(func(r *http.Request, k todo.ListKey) error {
+ return s.store.ToggleAll(r.Context(), k)
+ }))
+ s.mux.HandleFunc("POST /todos/clear-completed", s.action(func(r *http.Request, k todo.ListKey) error {
+ return s.store.ClearCompleted(r.Context(), k)
+ }))
+ s.mux.HandleFunc("POST /todos/{id}/toggle", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error {
+ return s.store.Toggle(ctx, k, id)
+ }))
+ s.mux.HandleFunc("POST /todos/{id}/edit", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error {
+ return s.store.Rename(ctx, k, id, r.PostFormValue("title"))
+ }))
+ s.mux.HandleFunc("POST /todos/{id}/destroy", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error {
+ return s.store.Delete(ctx, k, id)
+ }))
+ return s
+}
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("Referrer-Policy", "same-origin")
+ s.mux.ServeHTTP(w, r)
+}
+
+// filterPath maps a filter name to its page. Anything unknown is "all".
+func filterPath(filter string) (string, string) {
+ switch filter {
+ case "active":
+ return "active", "/active"
+ case "completed":
+ return "completed", "/completed"
+ }
+ return "all", "/"
+}
+
+// view is everything the page template shows.
+type view struct {
+ Filter, Path string
+ Items []todo.Todo // the items this filter shows
+ Total int
+ Active int
+ Completed int
+ EditID int64
+ Version string
+}
+
+func (s *Server) view(ctx context.Context, k todo.ListKey, filter string) (view, error) {
+ all, err := s.store.List(ctx, k)
+ if err != nil {
+ return view{}, err
+ }
+ v := view{Version: s.cfg.Version, Total: len(all)}
+ v.Filter, v.Path = filterPath(filter)
+ for _, t := range all {
+ if t.Completed {
+ v.Completed++
+ } else {
+ v.Active++
+ }
+ if v.Filter == "all" || (v.Filter == "completed") == t.Completed {
+ v.Items = append(v.Items, t)
+ }
+ }
+ return v, nil
+}
+
+func (s *Server) page(filter string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ k, err := s.listKey(w, r, true)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ v, err := s.view(r.Context(), k, filter)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ if id, err := strconv.ParseInt(r.URL.Query().Get("edit"), 10, 64); err == nil {
+ v.EditID = id
+ }
+ s.render(w, r, "page.html", v)
+ }
+}
+
+// render executes a template into a buffer first, so a failure can still
+// become a clean 500 and Content-Length is exact.
+func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, v view) {
+ var buf bytes.Buffer
+ if err := page.ExecuteTemplate(&buf, name, v); err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ h := w.Header()
+ h.Set("Content-Type", "text/html; charset=utf-8")
+ h.Set("Content-Length", strconv.Itoa(buf.Len()))
+ h.Set("Cache-Control", "no-store")
+ w.Write(buf.Bytes())
+}
+
+// action wraps a list-wide form POST: origin check, cookie, form parsing,
+// then a 303 back to the filter (or the list's HTML for app.js).
+func (s *Server) action(do func(r *http.Request, k todo.ListKey) error) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !s.sameOrigin(r) {
+ http.Error(w, "cross-origin request refused", http.StatusForbidden)
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
+ if err := r.ParseForm(); err != nil {
+ status := http.StatusBadRequest
+ if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
+ status = http.StatusRequestEntityTooLarge
+ }
+ http.Error(w, http.StatusText(status), status)
+ return
+ }
+ k, err := s.listKey(w, r, false)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ if err := do(r, k); err != nil {
+ if errors.Is(err, todo.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ s.fail(w, r, err)
+ return
+ }
+ filter, to := filterPath(r.PostFormValue("filter"))
+ if r.Header.Get(fragmentHeader) == "" {
+ http.Redirect(w, r, to, http.StatusSeeOther)
+ return
+ }
+ v, err := s.view(r.Context(), k, filter)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ s.render(w, r, "list", v)
+ }
+}
+
+// itemAction is action for a route naming one todo. An id that doesn't
+// parse is as unknown as one that isn't in the caller's list.
+func (s *Server) itemAction(do func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error) http.HandlerFunc {
+ return s.action(func(r *http.Request, k todo.ListKey) error {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ return todo.ErrNotFound
+ }
+ return do(r.Context(), k, id, r)
+ })
+}
+
+// sameOrigin allows a POST with no Origin header (the contract says so;
+// SameSite=Lax already keeps cross-site POSTs from carrying the cookie)
+// or one naming this app's own origin.
+func (s *Server) sameOrigin(r *http.Request) bool {
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ return true
+ }
+ if s.cfg.Origin != "" {
+ return origin == s.cfg.Origin
+ }
+ scheme := "http"
+ if r.TLS != nil {
+ scheme = "https"
+ }
+ return origin == scheme+"://"+r.Host
+}
+
+// listKey reads the visitor's list cookie. A missing cookie, or one the
+// server never issued, gets a fresh random one, which is a fresh empty
+// list. When renew is set, a valid cookie is sent again to push its
+// expiry forward.
+func (s *Server) listKey(w http.ResponseWriter, r *http.Request, renew bool) (todo.ListKey, error) {
+ ctx := r.Context()
+ if c, err := r.Cookie(cookieName); err == nil {
+ secret, err := base64.RawURLEncoding.DecodeString(c.Value)
+ if err == nil && len(secret) == secretBytes {
+ k := todo.KeyFor(secret)
+ ok, err := s.store.ListExists(ctx, k)
+ if err != nil {
+ return k, err
+ }
+ if ok {
+ if renew {
+ s.setCookie(w, c.Value)
+ }
+ return k, nil
+ }
+ }
+ }
+ secret := make([]byte, secretBytes)
+ rand.Read(secret) // never fails (crypto/rand panics rather than return an error)
+ k := todo.KeyFor(secret)
+ if err := s.store.CreateList(ctx, k); err != nil {
+ return k, err
+ }
+ s.setCookie(w, base64.RawURLEncoding.EncodeToString(secret))
+ return k, nil
+}
+
+func (s *Server) setCookie(w http.ResponseWriter, value string) {
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: value,
+ Path: "/",
+ MaxAge: cookieMaxAge,
+ HttpOnly: true,
+ Secure: s.cfg.Secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
+ if errors.Is(err, context.Canceled) {
+ return // the client went away
+ }
+ s.cfg.Log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err)
+ http.Error(w, "Something went wrong. Please try again.", http.StatusInternalServerError)
+}
+
+func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
+ if err := s.store.Ping(r.Context()); err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Write([]byte("ok\n"))
+}
+
+func (s *Server) version(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Write([]byte(s.cfg.Version))
+}
+
+// loadStatic reads the embedded assets once, with an ETag for each.
+func loadStatic() map[string]asset {
+ out := map[string]asset{}
+ sub, _ := fs.Sub(staticFS, "static")
+ fs.WalkDir(sub, ".", func(name string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ body, err := fs.ReadFile(sub, name)
+ if err != nil {
+ return err
+ }
+ sum := sha256.Sum256(body)
+ out[name] = asset{
+ body: body,
+ ctype: mime.TypeByExtension(path.Ext(name)),
+ etag: `"` + hex.EncodeToString(sum[:8]) + `"`,
+ }
+ return nil
+ })
+ return out
+}
+
+func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
+ a, ok := s.static[r.PathValue("name")]
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ h := w.Header()
+ h.Set("Content-Type", a.ctype)
+ h.Set("ETag", a.etag)
+ // Pages link assets with ?v=<build>, so a new build fetches afresh.
+ h.Set("Cache-Control", "public, max-age=86400")
+ if r.Header.Get("If-None-Match") == a.etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ h.Set("Content-Length", strconv.Itoa(len(a.body)))
+ w.Write(a.body)
+}
diff --git a/apps/go/web/web_test.go b/apps/go/web/web_test.go
new file mode 100644
index 0000000..2b42e55
--- /dev/null
+++ b/apps/go/web/web_test.go
@@ -0,0 +1,309 @@
+package web
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+
+ "todo-go/internal/todo"
+)
+
+// client is one visitor: a cookie jar that doesn't follow redirects.
+type client struct {
+ t *testing.T
+ srv *httptest.Server
+ hc *http.Client
+}
+
+func newServer(t *testing.T) *httptest.Server {
+ t.Helper()
+ store, err := todo.Open(context.Background(), filepath.Join(t.TempDir(), "todo.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { store.Close() })
+ srv := httptest.NewUnstartedServer(nil)
+ srv.Config.Handler = New(store, Config{Origin: "http://" + srv.Listener.Addr().String(), Version: "test"})
+ srv.Start()
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func visitor(t *testing.T, srv *httptest.Server) *client {
+ jar, _ := cookiejar.New(nil)
+ hc := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+ return &client{t: t, srv: srv, hc: hc}
+}
+
+func (c *client) get(path string) (*http.Response, string) {
+ c.t.Helper()
+ res, err := c.hc.Get(c.srv.URL + path)
+ if err != nil {
+ c.t.Fatal(err)
+ }
+ defer res.Body.Close()
+ var b bytes.Buffer
+ b.ReadFrom(res.Body)
+ return res, b.String()
+}
+
+func (c *client) post(path string, form url.Values, header map[string]string) (*http.Response, string) {
+ c.t.Helper()
+ req, _ := http.NewRequest("POST", c.srv.URL+path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for k, v := range header {
+ req.Header.Set(k, v)
+ }
+ res, err := c.hc.Do(req)
+ if err != nil {
+ c.t.Fatal(err)
+ }
+ defer res.Body.Close()
+ var b bytes.Buffer
+ b.ReadFrom(res.Body)
+ return res, b.String()
+}
+
+var idRE = regexp.MustCompile(`data-id="(\d+)"`)
+
+func ids(body string) []string {
+ var out []string
+ for _, m := range idRE.FindAllStringSubmatch(body, -1) {
+ out = append(out, m[1])
+ }
+ return out
+}
+
+func TestActionsRedirectToTheirFilter(t *testing.T) {
+ c := visitor(t, newServer(t))
+ for filter, want := range map[string]string{"all": "/", "active": "/active", "completed": "/completed", "": "/", "bogus": "/"} {
+ res, _ := c.post("/todos", url.Values{"title": {"x"}, "filter": {filter}}, nil)
+ if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != want {
+ t.Errorf("filter %q: %d to %q, want 303 to %q", filter, res.StatusCode, res.Header.Get("Location"), want)
+ }
+ }
+}
+
+func TestPageShowsFilterAndCounts(t *testing.T) {
+ c := visitor(t, newServer(t))
+ _, body := c.get("/")
+ if strings.Contains(body, `class="main"`) || strings.Contains(body, `class="footer"`) {
+ t.Fatal("empty list should hide .main and .footer")
+ }
+ c.post("/todos", url.Values{"title": {"a"}}, nil)
+ c.post("/todos", url.Values{"title": {"b"}}, nil)
+ _, body = c.get("/")
+ id := ids(body)[0]
+ c.post("/todos/"+id+"/toggle", url.Values{"filter": {"all"}}, nil)
+
+ _, body = c.get("/active")
+ if got := ids(body); len(got) != 1 {
+ t.Fatalf("/active shows %v", got)
+ }
+ for _, want := range []string{
+ `<strong>1</strong> item left`,
+ `<a class="selected" href="/active">Active</a>`,
+ `class="clear-completed"`,
+ `href="/active?edit=`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("/active is missing %q", want)
+ }
+ }
+ _, body = c.get("/completed?edit=" + id)
+ if !strings.Contains(body, `class="completed editing" data-id="`+id+`"`) ||
+ !strings.Contains(body, `action="/todos/`+id+`/edit"`) {
+ t.Errorf("edit view not rendered:\n%s", body)
+ }
+}
+
+func TestEscapesTitles(t *testing.T) {
+ c := visitor(t, newServer(t))
+ c.post("/todos", url.Values{"title": {`<img src=x onerror=alert(1)> & "q"`}}, nil)
+ _, body := c.get("/")
+ if strings.Contains(body, "<img") {
+ t.Fatal("title rendered as HTML")
+ }
+ if !strings.Contains(body, "&lt;img src=x onerror=alert(1)&gt; &amp; &#34;q&#34;") {
+ t.Fatal("escaped title not found")
+ }
+}
+
+func TestContentLengthIsBytes(t *testing.T) {
+ c := visitor(t, newServer(t))
+ c.post("/todos", url.Values{"title": {"naïve 🚀 日本語"}}, nil)
+ res, body := c.get("/")
+ if n, _ := strconv.Atoi(res.Header.Get("Content-Length")); n != len(body) {
+ t.Fatalf("Content-Length %d, body %d bytes", n, len(body))
+ }
+}
+
+func TestCrossOriginPostIsRefused(t *testing.T) {
+ srv := newServer(t)
+ c := visitor(t, srv)
+ res, _ := c.post("/todos", url.Values{"title": {"evil"}}, map[string]string{"Origin": "https://evil.example"})
+ if res.StatusCode != http.StatusForbidden {
+ t.Fatalf("got %d, want 403", res.StatusCode)
+ }
+ res, _ = c.post("/todos", url.Values{"title": {"mine"}}, map[string]string{"Origin": srv.URL})
+ if res.StatusCode != http.StatusSeeOther {
+ t.Fatalf("own origin: got %d, want 303", res.StatusCode)
+ }
+ if _, body := c.get("/"); len(ids(body)) != 1 {
+ t.Fatal("want exactly the one same-origin todo")
+ }
+}
+
+func TestOtherListsIdsAre404(t *testing.T) {
+ srv := newServer(t)
+ alice, bob := visitor(t, srv), visitor(t, srv)
+ alice.post("/todos", url.Values{"title": {"alice's"}}, nil)
+ _, body := alice.get("/")
+ id := ids(body)[0]
+ bob.get("/")
+ for _, action := range []string{"toggle", "edit", "destroy"} {
+ res, _ := bob.post("/todos/"+id+"/"+action, url.Values{"title": {"x"}}, nil)
+ if res.StatusCode != http.StatusNotFound {
+ t.Errorf("%s: got %d, want 404", action, res.StatusCode)
+ }
+ }
+ for _, bad := range []string{"abc", "99999", "-1"} {
+ if res, _ := alice.post("/todos/"+bad+"/toggle", nil, nil); res.StatusCode != http.StatusNotFound {
+ t.Errorf("id %q: got %d, want 404", bad, res.StatusCode)
+ }
+ }
+ if _, body := alice.get("/"); !strings.Contains(body, "alice&#39;s") || strings.Contains(body, `class="completed"`) {
+ t.Fatal("alice's list changed")
+ }
+}
+
+func TestGarbledCookieGetsFreshList(t *testing.T) {
+ srv := newServer(t)
+ for _, value := range []string{"%%%", "short", strings.Repeat("A", 43)} {
+ req, _ := http.NewRequest("GET", srv.URL+"/", nil)
+ req.Header.Set("Cookie", cookieName+"="+value)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("cookie %q: status %d", value, res.StatusCode)
+ }
+ }
+}
+
+func TestCookieAttributes(t *testing.T) {
+ c := visitor(t, newServer(t))
+ res, _ := c.get("/")
+ sc := res.Header.Get("Set-Cookie")
+ for _, want := range []string{"HttpOnly", "SameSite=Lax", "Path=/"} {
+ if !strings.Contains(sc, want) {
+ t.Errorf("Set-Cookie %q lacks %s", sc, want)
+ }
+ }
+ if strings.Contains(sc, "Secure") {
+ t.Error("cookie is Secure over plain http")
+ }
+}
+
+func TestFragmentForScripts(t *testing.T) {
+ c := visitor(t, newServer(t))
+ res, body := c.post("/todos", url.Values{"title": {"a"}, "filter": {"all"}}, map[string]string{fragmentHeader: "1"})
+ if res.StatusCode != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(body), `<section class="main">`) ||
+ strings.Contains(body, "<html") {
+ t.Fatalf("got %d:\n%s", res.StatusCode, body)
+ }
+}
+
+// The contract wants TodoMVC's stylesheets byte for byte.
+func TestStaticCSSMatchesShared(t *testing.T) {
+ c := visitor(t, newServer(t))
+ for _, name := range []string{"base.css", "index.css"} {
+ want, err := os.ReadFile(filepath.Join("..", "..", "..", "shared", name))
+ if err != nil {
+ t.Skipf("shared/ not available: %v", err)
+ }
+ res, got := c.get("/static/" + name)
+ if res.StatusCode != 200 || got != string(want) {
+ t.Errorf("%s differs from shared/%s", name, name)
+ }
+ if !strings.HasPrefix(res.Header.Get("Content-Type"), "text/css") {
+ t.Errorf("%s served as %q", name, res.Header.Get("Content-Type"))
+ }
+ }
+}
+
+func TestHealthAndVersion(t *testing.T) {
+ c := visitor(t, newServer(t))
+ if res, _ := c.get("/healthz"); res.StatusCode != 200 {
+ t.Errorf("/healthz: %d", res.StatusCode)
+ }
+ if res, body := c.get("/api/version"); res.StatusCode != 200 || body != "test" {
+ t.Errorf("/api/version: %d %q", res.StatusCode, body)
+ }
+}
+
+// AGENTS.md caps the page's JavaScript at 300 lines.
+func TestScriptStaysSmall(t *testing.T) {
+ src, err := staticFS.ReadFile("static/app.js")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n := bytes.Count(src, []byte("\n")); n > 300 {
+ t.Fatalf("app.js is %d lines; the cap is 300", n)
+ }
+}
+
+// A well-formed cookie the server never issued is replaced, so two
+// visitors can't share a list by choosing the same value.
+func TestUnissuedCookieIsReplaced(t *testing.T) {
+ srv := newServer(t)
+ forged := strings.Repeat("A", 43) // decodes to 32 bytes
+ post := func() *http.Response {
+ req, _ := http.NewRequest("POST", srv.URL+"/todos", strings.NewReader("title=shared"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Cookie", cookieName+"="+forged)
+ res, err := http.DefaultTransport.RoundTrip(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ return res
+ }
+ for range 2 {
+ res := post()
+ var got string
+ for _, c := range res.Cookies() {
+ if c.Name == cookieName {
+ got = c.Value
+ }
+ }
+ if got == "" || got == forged {
+ t.Fatalf("forged cookie kept (Set-Cookie %q)", res.Header.Get("Set-Cookie"))
+ }
+ }
+ req, _ := http.NewRequest("GET", srv.URL+"/", nil)
+ req.Header.Set("Cookie", cookieName+"="+forged)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var b bytes.Buffer
+ b.ReadFrom(res.Body)
+ res.Body.Close()
+ if len(ids(b.String())) != 0 {
+ t.Fatal("forged cookie sees a shared list")
+ }
+}