carlos / todomvc-bakeoff Public

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

Plain git — no account needed to clone.

Download

Download this file

todo-haskell

TodoMVC for the CARLOS bakeoff, in Haskell. The server renders every page, and every action is a plain HTML form, so the app works with JavaScript turned off. A small ES module (static/app.js) takes over when JavaScript is on, so it behaves like TodoMVC's own apps.

Live at https://todo-haskell.bfj.oncarlos.com.

The stack

| Piece | Choice | Why | |---|---|---| | HTTP server | warp + wai | The standard Haskell server. It serves a socket you hand it, so a unix socket is no different from TCP, and it has a graceful-shutdown hook for SIGTERM | | Routing | Pattern matching on pathInfo | Eleven routes. A case expression over the path segments is shorter and clearer than a routing library | | HTML | lucid2 | Templates are ordinary Haskell functions, and text is escaped unless you ask otherwise. It depends on nothing outside GHC's own libraries | | SQLite | sqlite-simple | The usual binding. Its direct-sqlite dependency compiles SQLite's C source into the binary, so there's no system library to find | | Cookies | cookie | Parses Cookie and renders Set-Cookie correctly | | Forms | parseSimpleQuery from http-types | Every body is application/x-www-form-urlencoded, so the query parser is all it takes | | Static files | file-embed | The CSS and JS are compiled into the binary. The two TodoMVC stylesheets are embedded straight from shared/, so they're served byte for byte | | Randomness | getrandom(2) through the FFI | One libc call, no library, and it doesn't need /dev/urandom inside the sandbox | | Tests | hspec with wai-extra's Network.Wai.Test | Drives the WAI application in-process against an in-memory database, cookies and all |

The one SQLite connection sits behind an MVar. SQLite allows one writer at a time anyway, and a request here is a few indexed statements, so a pool would add code without adding speed.

Warp's x509 flag is turned off in cabal.project. It only exposes TLS client certificates, and it would pull in a whole crypto library; the Carloku edge terminates TLS.

How it fits the contract

  • Lists. The first visit mints a 256-bit random token, stores it in lists, and sets it as the todo_list cookie. Every query about todos is scoped by that list's id, so a POST naming someone else's todo matches nothing and answers 404. A cookie the app doesn't hold gets a fresh list.
  • Origin check. A POST with an Origin header that isn't the app's own answers 403. The own origin is https://$CARLOS_HOST with --socket, and http://<address> with --listen.
  • Without JavaScript. Each todo's toggle is a transparent submit button laid exactly over TodoMVC's checkbox, and the toggle-all button sits over the chevron. The title is a link to ?edit=<id>. The forms use display: contents, so TodoMVC's CSS lays the page out unchanged.
  • With JavaScript. app.js adds the js class to <html>, which hides the stand-in buttons. Actions post the same forms with an X-Fragment: 1 header, and the server answers with a fresh section.main and footer.footer instead of a redirect.
  • Storage. WAL mode, synchronous = NORMAL. Migrations are a list in Todo.Db, applied in order and counted in PRAGMA user_version. They only ever add.
  • Shutdown. SIGTERM closes the listening socket, requests in flight get up to five seconds, the WAL is checkpointed, and the process exits 0.

Layout

| Path | What | |---|---| | app/Main.hs | Flags, the socket, signals | | src/Todo/App.hs | Routes and handlers, including GET / | | src/Todo/Html.hs | The page and the fragment | | src/Todo/Db.hs | Schema, migrations, queries | | src/Todo/Cookie.hs | Token minting and the cookie | | src/Todo/Form.hs, Model.hs, Assets.hs | Form bodies, types, embedded files | | static/app.js, static/app.css | The JavaScript layer and the no-JS controls | | test/Spec.hs | Tests |

Building, running, testing

The x86 build uses ghcup's GHC 9.10.3 and cabal 3.16 (source ../../scripts/env.sh). The shipped binary is built inside the bakeoff's emulated arm64 root, which has Debian's GHC 9.6.6 and cabal 3.10, and linked fully static. cabal.project pins the Hackage index so both resolve the same library versions.

make build          # dist/app, for this machine
make build-arm64    # dist/app-linux-arm64, static, built under qemu
make test           # the hspec suite
make clean          # build output only
make clean-deps     # also every compiled library (sources stay downloaded)

Cabal's stores live in .cache/cabal-x86 and .cache/cabal-arm64, so make clean-deps can remove them without touching anything shared. A cold make build-arm64 compiles about fifty libraries under emulation and takes a long while; after that, a change to one module rebuilds in a minute or two.

Run it locally and point the shared suite at it:

dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/haskell.db"
../../scripts/test-app.sh http://127.0.0.1:8080

--listen takes only a 127.x.x.x address.

Deploying

Once the app and its instance exist on Carloku (see BRIEF.md), commit, then:

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

The version is the commit's short SHA, compiled in via a generated gen/BuildVersion.hs, and make deploy passes the same SHA to carlos deploy. The app's build probe is /api/version, so the deploy only reports live once the new process says it's running that build.