carlos / todomvc-bakeoff Public
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 thetodo_listcookie. 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
Originheader that isn't the app's own answers 403. The own origin ishttps://$CARLOS_HOSTwith--socket, andhttp://<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 usedisplay: contents, so TodoMVC's CSS lays the page out unchanged. - With JavaScript.
app.jsadds thejsclass to<html>, which hides the stand-in buttons. Actions post the same forms with anX-Fragment: 1header, and the server answers with a freshsection.mainandfooter.footerinstead of a redirect. - Storage. WAL mode,
synchronous = NORMAL. Migrations are a list inTodo.Db, applied in order and counted inPRAGMA 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.