feat: haskell builder output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 8e6f284d013dab988ca05ddc11a5f6c43657374c parent 7a66369parent b9fbdff
19 files changed, +1499 −0
  • apps/haskell/.gitignore +6 −0
  • apps/haskell/AGENTS.md +31 −0
  • apps/haskell/DEPS +10 −0
  • apps/haskell/Makefile +68 −0
  • apps/haskell/README.md +116 −0
  • apps/haskell/STATUS.md +58 −0
  • apps/haskell/app/Main.hs +118 −0
  • apps/haskell/cabal.project +12 −0
  • apps/haskell/src/Todo/App.hs +199 −0
  • apps/haskell/src/Todo/Assets.hs +20 −0
  • apps/haskell/src/Todo/Cookie.hs +73 −0
  • apps/haskell/src/Todo/Db.hs +135 −0
  • apps/haskell/src/Todo/Form.hs +32 −0
  • apps/haskell/src/Todo/Html.hs +123 −0
  • apps/haskell/src/Todo/Model.hs +72 −0
  • apps/haskell/static/app.css +45 −0
  • apps/haskell/static/app.js +125 −0
  • apps/haskell/test/Spec.hs +178 −0
  • apps/haskell/todo-haskell.cabal +78 −0
diff --git a/apps/haskell/.gitignore b/apps/haskell/.gitignore
new file mode 100644
index 0000000..9ba9ea5
--- /dev/null
+++ b/apps/haskell/.gitignore
@@ -0,0 +1,6 @@
+.cache/
+dist/
+dist-newstyle/
+gen/
+*.db
+*.db-*
diff --git a/apps/haskell/AGENTS.md b/apps/haskell/AGENTS.md
new file mode 100644
index 0000000..1591269
--- /dev/null
+++ b/apps/haskell/AGENTS.md
@@ -0,0 +1,31 @@
+# 🤖 AGENTS.md: todo-haskell
+
+The one rule: **every visitor's list is theirs alone.** Every query that
+reads or changes a todo is scoped to the list named by the caller's own
+cookie, and a request naming anything outside that list changes nothing
+and answers 404.
+
+## Tenancy
+
+- **One instance for the whole app.** `todo-haskell` runs as a single
+ Carloku instance on `todo-haskell.bfj.oncarlos.com`, with one SQLite
+ file.
+- **Each visitor's list is kept separately by an anonymous cookie.** The
+ first visit mints a random 256-bit value, stores it as a row in
+ `lists`, and sets it as the `todo_list` cookie (`HttpOnly;
+ SameSite=Lax; Path=/`, plus `Secure` over https). A value the app
+ doesn't hold gets a fresh empty list, never an error.
+- **No sign-in.** Losing the cookie loses the list; that's accepted.
+- **Trust class C.** The todo text is stored in SQLite as plain text. The
+ operator can read it. That's a deliberate choice for a demo app, not an
+ oversight, and nobody should store anything secret in it.
+
+## Rules for changing this app
+
+- Work only in `apps/haskell/`. The bakeoff's `BRIEF.md` and
+ `CONTRACT.md` are binding.
+- Migrations only ever add. Never drop or rewrite a table or a column.
+- Servers bind `127.0.0.1` or a unix socket, nothing else.
+- Cabal's store lives under `.cache/`, set by the `Makefile`. Don't
+ point it anywhere shared.
+- Commits end with a `Co-Authored-By:` trailer naming the model.
diff --git a/apps/haskell/DEPS b/apps/haskell/DEPS
new file mode 100644
index 0000000..722e083
--- /dev/null
+++ b/apps/haskell/DEPS
@@ -0,0 +1,10 @@
+cookie
+file-embed
+hspec
+http-types
+lucid2
+network
+sqlite-simple
+wai
+wai-extra
+warp
diff --git a/apps/haskell/Makefile b/apps/haskell/Makefile
new file mode 100644
index 0000000..8b1b9f1
--- /dev/null
+++ b/apps/haskell/Makefile
@@ -0,0 +1,68 @@
+# todo-haskell. Every cabal store and build directory lives under .cache/,
+# so nothing here touches ~/.cabal or the arm64 root's /root.
+#
+# The x86 build uses ghcup's GHC 9.10.3. The shipped linux-arm64 binary is
+# built inside the emulated arm64 root (scripts/arm64.sh) with Debian's
+# GHC 9.6.6, linked fully static.
+
+ROOT := $(abspath ../..)
+CACHE := $(abspath .cache)
+ARM64 := $(ROOT)/scripts/arm64.sh
+VERSION := $(shell git rev-parse --short HEAD)
+MESSAGE ?= Deploy $(VERSION)
+
+export PATH := $(HOME)/.ghcup/bin:$(PATH)
+
+# ghcup's GHC links -lgmp, and this machine has only libgmp.so.10, so
+# .cache/x86-lib holds the libgmp.so link the linker looks for.
+X86_CABAL := CABAL_DIR=$(CACHE)/cabal-x86 LIBRARY_PATH=$(CACHE)/x86-lib cabal
+ARM64_CABAL := $(ARM64) env CABAL_DIR=$(CACHE)/cabal-arm64 cabal
+
+X86_BIN := $(CACHE)/dist-x86/build/x86_64-linux/ghc-9.10.3/todo-haskell-0.1.0.0/x/todo-haskell/build/todo-haskell/todo-haskell
+ARM64_BIN := $(CACHE)/dist-arm64/build/aarch64-linux/ghc-9.6.6/todo-haskell-0.1.0.0/x/todo-haskell/build/todo-haskell/todo-haskell
+
+.PHONY: build build-arm64 test clean clean-deps bump unbump deploy FORCE
+
+build: gen/BuildVersion.hs $(CACHE)/x86-lib/libgmp.so
+ $(X86_CABAL) build -j --builddir=$(CACHE)/dist-x86 exe:todo-haskell
+ @mkdir -p dist
+ cp $(X86_BIN) dist/app.new && mv dist/app.new dist/app
+
+build-arm64: gen/BuildVersion.hs
+ @[ -d $(CACHE)/cabal-arm64/packages ] || $(ARM64_CABAL) update
+ $(ARM64_CABAL) build -j --builddir=$(CACHE)/dist-arm64 exe:todo-haskell
+ @mkdir -p dist
+ cp $(ARM64_BIN) dist/app-linux-arm64.new && mv dist/app-linux-arm64.new dist/app-linux-arm64
+
+test: gen/BuildVersion.hs $(CACHE)/x86-lib/libgmp.so
+ $(X86_CABAL) test -j --enable-tests --builddir=$(CACHE)/dist-x86-test --test-show-details=direct
+
+clean:
+ rm -rf dist $(CACHE)/dist-x86 $(CACHE)/dist-x86-test $(CACHE)/dist-arm64 gen
+
+# Compiled libraries go; downloaded sources (.cache/cabal-*/packages) stay.
+clean-deps: clean
+ rm -rf $(CACHE)/cabal-x86/store $(CACHE)/cabal-arm64/store
+
+# The string is Todo.App's serverName, sent as the Server header.
+bump:
+ sed -i 's/^serverName = "todo-haskell"$$/serverName = "todo-haskell (bumped)"/' src/Todo/App.hs
+
+unbump:
+ sed -i 's/^serverName = "todo-haskell (bumped)"$$/serverName = "todo-haskell"/' src/Todo/App.hs
+
+deploy:
+ carlos deploy --account bfj --app todo-haskell --target linux-arm64 \
+ --version $(VERSION) --no-prompt --message "$(MESSAGE)" dist/app-linux-arm64
+
+# The build version is the git short SHA, compiled in from a generated
+# module. It's rewritten only when the SHA changes, so an unchanged tree
+# doesn't recompile.
+gen/BuildVersion.hs: FORCE
+ @mkdir -p gen
+ @printf 'module BuildVersion (buildVersion) where\n\nbuildVersion :: String\nbuildVersion = "%s"\n' '$(VERSION)' > gen/BuildVersion.hs.new
+ @if cmp -s gen/BuildVersion.hs.new $@; then rm gen/BuildVersion.hs.new; else mv gen/BuildVersion.hs.new $@; fi
+
+$(CACHE)/x86-lib/libgmp.so:
+ @mkdir -p $(dir $@)
+ ln -sf /usr/lib/x86_64-linux-gnu/libgmp.so.10 $@
diff --git a/apps/haskell/README.md b/apps/haskell/README.md
new file mode 100644
index 0000000..64d163b
--- /dev/null
+++ b/apps/haskell/README.md
@@ -0,0 +1,116 @@
+# 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](https://todo-haskell.bfj.oncarlos.com).
+
+## The stack
+
+| Piece | Choice | Why |
+|---|---|---|
+| HTTP server | [warp](https://hackage.haskell.org/package/warp) + [wai](https://hackage.haskell.org/package/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](https://hackage.haskell.org/package/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](https://hackage.haskell.org/package/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](https://hackage.haskell.org/package/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](https://hackage.haskell.org/package/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](https://hackage.haskell.org/package/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.
diff --git a/apps/haskell/STATUS.md b/apps/haskell/STATUS.md
new file mode 100644
index 0000000..c65f3e9
--- /dev/null
+++ b/apps/haskell/STATUS.md
@@ -0,0 +1,58 @@
+LIVE https://todo-haskell.bfj.oncarlos.com 7d40789
+
+# todo-haskell: status
+
+Deployed build `7d40789` to `todo-haskell.bfj.oncarlos.com` (channel
+`edge`). `/api/version` reports `7d40789`. The first deploy was
+`7881a7e`; `7d40789` fixes the review findings below.
+
+## Checks
+
+| Check | Result |
+|---|---|
+| `make test` (hspec, 18 examples) | pass |
+| Shared suite, local x86 build | 67 passed, 3 skipped (the suite's own JS-off skips) |
+| `scripts/boot-check.sh dist/app-linux-arm64` | `/healthz` 200, `/api/version` 200, clean exit on SIGTERM |
+| Shared suite against https://todo-haskell.bfj.oncarlos.com | 67 passed, 3 skipped |
+
+## Notes
+
+- Stack: warp + wai, lucid2, sqlite-simple, cookie, file-embed. Reasons
+ are in `README.md`.
+- The arm64 binary is built inside the arm64 root with Debian's GHC
+ 9.6.6, statically linked against glibc, stripped: 28 MB. The x86 build
+ uses ghcup's GHC 9.10.3. `cabal.project` pins the Hackage index so both
+ resolve the same library versions.
+- A cold arm64 dependency build (about fifty libraries, `-j8`, emulated)
+ took roughly 40 minutes on a busy docs-t1. After that, rebuilding the
+ app takes about 90 seconds.
+- docs-t1 has `libgmp.so.10` but no `libgmp.so`, which ghcup's x86 GHC
+ needs to link. The Makefile makes a link to it in `.cache/x86-lib` and
+ points `LIBRARY_PATH` there. No system change.
+- Warp's `x509` flag is off (it would pull in a crypto library for TLS
+ client certificates, which the edge already handles).
+- Static glibc warns about `getaddrinfo`, `dlopen` and `getpw*`. The app
+ calls none of them at runtime.
+
+## Review findings fixed (7d40789)
+
+1. **An edit in progress was lost when a response arrived.** `app.js`
+ replaced the whole list with each action's answer, which dropped any
+ editor opened while that request was pending. It now records the open
+ edit (item, typed value, selection, focus) before swapping in the new
+ list and puts it back on the same item afterwards. It also ignores the
+ `focusout` caused by the swap, so the old input isn't saved by
+ mistake. Checked in Chromium with a toggle response delayed 1.5 s:
+ double-clicking another item and typing during the delay leaves the
+ editor open, focused and holding the typed text, and Enter saves it.
+2. **`--listen` accepted any `127.x.x.x` address.** It now accepts exactly
+ `127.0.0.1:<port>`. `127.0.0.2:8080` and `0.0.0.0:8080` are refused
+ with a usage error.
+
+After the fixes: `make test` passes, the shared suite passes locally and
+against https://todo-haskell.bfj.oncarlos.com (67 passed, 3 skipped),
+and `boot-check.sh` passes.
+
+## Review findings not fixed
+
+None.
diff --git a/apps/haskell/app/Main.hs b/apps/haskell/app/Main.hs
new file mode 100644
index 0000000..d2dd394
--- /dev/null
+++ b/apps/haskell/app/Main.hs
@@ -0,0 +1,118 @@
+-- | @todo-haskell --socket <path> --db <path>@ on Carloku, or
+-- @todo-haskell --listen 127.0.0.1:<port> --db <path>@ locally.
+module Main (main) where
+
+import Control.Exception (bracket, catch, throwIO)
+import Control.Monad (forM_, unless)
+import Data.ByteString.Char8 qualified as B8
+import Data.Text qualified as T
+import Data.Word (Word8)
+import Network.Socket
+import Network.Wai.Handler.Warp
+import System.Environment (getArgs, lookupEnv)
+import System.Exit (exitWith, ExitCode (..))
+import System.IO (hPutStrLn, stderr)
+import System.IO.Error (isDoesNotExistError)
+import System.Posix.Files (removeLink)
+import System.Posix.Signals (Handler (CatchOnce), installHandler, sigINT, sigTERM)
+import Text.Read (readMaybe)
+
+import Todo.App
+import Todo.Db qualified as Db
+
+data Bind
+ = UnixSocket FilePath
+ | Loopback (Word8, Word8, Word8, Word8) PortNumber
+
+data Options = Options
+ { optBind :: Maybe Bind
+ , optDb :: Maybe FilePath
+ }
+
+main :: IO ()
+main = do
+ args <- getArgs
+ (target, dbPath) <- case parseArgs args (Options Nothing Nothing) of
+ Right (Options (Just b) (Just d)) -> pure (b, d)
+ Right _ -> usage "both --db and one of --socket or --listen are required"
+ Left err -> usage err
+ host <- lookupEnv "CARLOS_HOST"
+ let config = case target of
+ -- On Carloku the edge serves https://$CARLOS_HOST.
+ UnixSocket _ -> Config (B8.pack . ("https://" <>) <$> host) True
+ Loopback addr port -> Config (Just (B8.pack ("http://" <> showAddr addr port))) False
+ bracket (Db.open dbPath) Db.close $ \db ->
+ bracket (listenOn target) (cleanUp target) $ \sock -> do
+ hPutStrLn stderr ("todo-haskell " <> T.unpack version <> " listening on " <> describe target)
+ runSettingsSocket settings sock (app config db)
+ hPutStrLn stderr "todo-haskell stopped"
+
+-- | Warp closes the listening socket on SIGTERM or SIGINT, lets requests
+-- in flight finish (for up to five seconds), and then 'runSettingsSocket'
+-- returns, so 'main' closes the database and exits 0.
+settings :: Settings
+settings =
+ setInstallShutdownHandler onSignal
+ . setGracefulShutdownTimeout (Just 5)
+ $ defaultSettings
+ where
+ onSignal closeSocket =
+ forM_ [sigTERM, sigINT] $ \sig -> installHandler sig (CatchOnce closeSocket) Nothing
+
+listenOn :: Bind -> IO Socket
+listenOn = \case
+ UnixSocket path -> do
+ removeIfExists path
+ sock <- socket AF_UNIX Stream defaultProtocol
+ bind sock (SockAddrUnix path)
+ listen sock 1024
+ pure sock
+ Loopback addr port -> do
+ sock <- socket AF_INET Stream defaultProtocol
+ setSocketOption sock ReuseAddr 1
+ bind sock (SockAddrInet port (tupleToHostAddress addr))
+ listen sock 1024
+ pure sock
+
+cleanUp :: Bind -> Socket -> IO ()
+cleanUp target sock = do
+ close sock
+ case target of
+ UnixSocket path -> removeIfExists path
+ Loopback _ _ -> pure ()
+
+removeIfExists :: FilePath -> IO ()
+removeIfExists path = removeLink path `catch` \e -> unless (isDoesNotExistError e) (throwIO e)
+
+parseArgs :: [String] -> Options -> Either String Options
+parseArgs args o = case args of
+ [] -> Right o
+ "--socket" : path : rest -> parseArgs rest o {optBind = Just (UnixSocket path)}
+ "--listen" : addr : rest -> do
+ b <- parseListen addr
+ parseArgs rest o {optBind = Just b}
+ "--db" : path : rest -> parseArgs rest o {optDb = Just path}
+ flag : _ -> Left ("unexpected argument " <> show flag)
+
+-- | Only 127.0.0.1: this app never listens anywhere else.
+parseListen :: String -> Either String Bind
+parseListen s = case break (== ':') s of
+ ("127.0.0.1", ':' : portText)
+ | Just port <- readMaybe portText
+ , port > 0 && port < (65536 :: Int) ->
+ Right (Loopback (127, 0, 0, 1) (fromIntegral port))
+ _ -> Left ("--listen wants 127.0.0.1:<port>, not " <> show s)
+
+showAddr :: (Word8, Word8, Word8, Word8) -> PortNumber -> String
+showAddr (a, b, c, d) port = concat [show a, ".", show b, ".", show c, ".", show d, ":", show port]
+
+describe :: Bind -> String
+describe = \case
+ UnixSocket path -> path
+ Loopback addr port -> showAddr addr port
+
+usage :: String -> IO a
+usage err = do
+ hPutStrLn stderr ("todo-haskell: " <> err)
+ hPutStrLn stderr "usage: todo-haskell (--socket <path> | --listen 127.0.0.1:<port>) --db <path>"
+ exitWith (ExitFailure 2)
diff --git a/apps/haskell/cabal.project b/apps/haskell/cabal.project
new file mode 100644
index 0000000..7ff92d1
--- /dev/null
+++ b/apps/haskell/cabal.project
@@ -0,0 +1,12 @@
+packages: .
+-- Pinned so the x86 build and the arm64 build resolve the same versions.
+index-state: hackage.haskell.org 2026-10-01T00:00:00Z
+constraints:
+ -- Build network from source like every other library, rather than
+ -- picking up a copy someone installed into the arm64 root's GHC.
+ network source,
+ -- Warp's x509 flag only exposes TLS client certificates, and costs a
+ -- whole crypto library. The edge terminates TLS, so leave it out.
+ warp -x509,
+ -- Compile zlib's C into the binary instead of looking for a system one.
+ zlib +bundled-c-zlib
diff --git a/apps/haskell/src/Todo/App.hs b/apps/haskell/src/Todo/App.hs
new file mode 100644
index 0000000..fc74990
--- /dev/null
+++ b/apps/haskell/src/Todo/App.hs
@@ -0,0 +1,199 @@
+-- | The WAI application: routing, the visitor's cookie, the origin
+-- check, and what each route answers.
+module Todo.App
+ ( Config (..)
+ , app
+ , version
+ , fragmentHeader
+ ) where
+
+import Control.Monad (join)
+import Data.ByteString (ByteString)
+import Data.ByteString.Lazy qualified as BL
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as T
+import Lucid (Html, renderBS)
+import Network.HTTP.Types
+import Network.Wai
+
+import BuildVersion (buildVersion)
+import Todo.Assets qualified as Assets
+import Todo.Cookie
+import Todo.Db (Db)
+import Todo.Db qualified as Db
+import Todo.Form
+import Todo.Html
+import Todo.Model
+
+data Config = Config
+ { configOrigin :: Maybe ByteString
+ -- ^ This app's own origin, e.g. @https://todo-haskell.bfj.oncarlos.com@.
+ -- A POST whose @Origin@ header names any other is refused.
+ , configSecure :: Bool
+ -- ^ Served over https, so the cookie gets @Secure@.
+ }
+
+-- | The build, as @GET /api/version@ reports it.
+version :: Text
+version = T.pack buildVersion
+
+-- | Sent on every response. @make bump@ edits this string to force a
+-- recompile of this module.
+serverName :: ByteString
+serverName = "todo-haskell"
+
+-- | The request header app.js sends to get 'fragment' back from an
+-- action instead of a redirect.
+fragmentHeader :: HeaderName
+fragmentHeader = "X-Fragment"
+
+-- | Form bodies past this are refused. A 10,000-character title of
+-- four-byte characters, percent-encoded, is about 120 KB.
+bodyLimit :: Int
+bodyLimit = 1024 * 1024
+
+app :: Config -> Db -> Application
+app config db req respond = case pathInfo req of
+ [] -> view All
+ ["active"] -> view Active
+ ["completed"] -> view Completed
+ ["static", "base.css"] -> get $ asset "text/css; charset=utf-8" Assets.baseCss
+ ["static", "index.css"] -> get $ asset "text/css; charset=utf-8" Assets.indexCss
+ ["static", "app.css"] -> get $ asset "text/css; charset=utf-8" Assets.appCss
+ ["static", "app.js"] -> get $ asset "text/javascript; charset=utf-8" Assets.appJs
+ ["healthz"] -> get $ respond (plain status200 "ok")
+ ["api", "version"] -> get $ respond (plain status200 (T.encodeUtf8 version))
+ ["todos"] -> post $ \list fields -> do
+ let title = T.strip (fieldText "title" fields)
+ if T.null title then pure () else Db.addTodo db list title
+ pure True
+ ["todos", "toggle-all"] -> post $ \list _ -> True <$ Db.toggleAll db list
+ ["todos", "clear-completed"] -> post $ \list _ -> True <$ Db.clearCompleted db list
+ ["todos", raw, verb]
+ | Just todo <- parseTodoId raw -> case verb of
+ "toggle" -> post $ \list _ -> Db.toggleTodo db list todo
+ "destroy" -> post $ \list _ -> Db.deleteTodo db list todo
+ "edit" -> post $ \list fields -> do
+ let title = T.strip (fieldText "title" fields)
+ if T.null title
+ then Db.deleteTodo db list todo
+ else Db.renameTodo db list todo title
+ _ -> respond notFound
+ _ -> respond notFound
+ where
+ method = requestMethod req
+
+ get action
+ | method == methodGet || method == methodHead = action
+ | otherwise = respond (allow "GET, HEAD")
+
+ -- GET /, /active, /completed, optionally with ?edit=<id>.
+ view f = get $ do
+ (list, cookie) <- visitor
+ todos <- Db.listTodos db list
+ let editing = do
+ raw <- join (lookup "edit" (queryString req))
+ todo <- parseTodoId (T.decodeUtf8Lenient raw)
+ if any (\t -> todoId t == todo && visible f t) todos then Just todo else Nothing
+ respond . withHeaders cookie $ html status200 (page (View f todos editing version))
+
+ -- An action: check the origin, read the form, run it against the
+ -- caller's own list. @run@ answers False when the todo it names isn't
+ -- in that list.
+ post run
+ | method /= methodPost = respond (allow "POST")
+ | not sameOrigin = respond (plain status403 "cross-origin request refused")
+ | otherwise =
+ readFields bodyLimit req >>= \case
+ Nothing -> respond (plain status413 "form too large")
+ Just fields -> do
+ (list, cookie) <- visitor
+ found <- run list fields
+ let f = parseFilter (field "filter" fields)
+ respond . withHeaders cookie =<< if
+ | not found -> pure notFound
+ | wantsFragment -> do
+ todos <- Db.listTodos db list
+ pure $ html status200 (fragment (View f todos Nothing version))
+ | otherwise -> pure $ responseLBS status303 [(hLocation, T.encodeUtf8 (filterPath f)), common] ""
+
+ sameOrigin = case lookup "Origin" (requestHeaders req) of
+ Nothing -> True
+ Just origin -> Just origin == configOrigin config
+
+ wantsFragment = lookup fragmentHeader (requestHeaders req) == Just "1"
+
+ -- The caller's list, from their cookie, or a new empty one (and the
+ -- header that hands them its cookie) if the cookie is missing or names
+ -- nothing this app holds.
+ visitor = do
+ known <- maybe (pure Nothing) (Db.findList db) (readToken (lookup hCookie (requestHeaders req)))
+ case known of
+ Just list -> pure (list, [])
+ Nothing -> do
+ token <- newToken
+ list <- Db.createList db token
+ pure (list, [(hSetCookie, setCookie (configSecure config) token)])
+
+ asset contentType body =
+ respond $
+ responseLBS
+ status200
+ [ (hContentType, contentType)
+ , (hContentLength, bytes (BL.fromStrict body))
+ , (hCacheControl, "public, max-age=86400")
+ , common
+ ]
+ (BL.fromStrict body)
+
+fieldText :: Text -> Fields -> Text
+fieldText name = fromMaybe "" . field name
+
+html :: Status -> Html () -> Response
+html status doc =
+ responseLBS
+ status
+ [ (hContentType, "text/html; charset=utf-8")
+ , (hContentLength, bytes body)
+ , (hCacheControl, "no-store")
+ , ("Content-Security-Policy", csp)
+ , ("X-Content-Type-Options", "nosniff")
+ , ("Referrer-Policy", "same-origin")
+ , common
+ ]
+ body
+ where
+ body = renderBS doc
+
+plain :: Status -> ByteString -> Response
+plain status body =
+ responseLBS
+ status
+ [ (hContentType, "text/plain; charset=utf-8")
+ , (hContentLength, bytes (BL.fromStrict body))
+ , (hCacheControl, "no-store")
+ , common
+ ]
+ (BL.fromStrict body)
+
+notFound :: Response
+notFound = plain status404 "not found"
+
+allow :: ByteString -> Response
+allow methods = mapResponseHeaders (("Allow", methods) :) (plain status405 "method not allowed")
+
+withHeaders :: ResponseHeaders -> Response -> Response
+withHeaders extra = mapResponseHeaders (<> extra)
+
+common :: Header
+common = (hServer, serverName)
+
+bytes :: BL.ByteString -> ByteString
+bytes = T.encodeUtf8 . T.pack . show . BL.length
+
+csp :: ByteString
+csp =
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; \
+ \form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
diff --git a/apps/haskell/src/Todo/Assets.hs b/apps/haskell/src/Todo/Assets.hs
new file mode 100644
index 0000000..469ef93
--- /dev/null
+++ b/apps/haskell/src/Todo/Assets.hs
@@ -0,0 +1,20 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+-- | Files compiled into the binary. The two TodoMVC stylesheets come
+-- straight from the bakeoff's @shared/@ directory, so they're served byte
+-- for byte.
+module Todo.Assets
+ ( baseCss
+ , indexCss
+ , appCss
+ , appJs
+ ) where
+
+import Data.ByteString (ByteString)
+import Data.FileEmbed (embedFile, makeRelativeToProject)
+
+baseCss, indexCss, appCss, appJs :: ByteString
+baseCss = $(makeRelativeToProject "../../shared/base.css" >>= embedFile)
+indexCss = $(makeRelativeToProject "../../shared/index.css" >>= embedFile)
+appCss = $(makeRelativeToProject "static/app.css" >>= embedFile)
+appJs = $(makeRelativeToProject "static/app.js" >>= embedFile)
diff --git a/apps/haskell/src/Todo/Cookie.hs b/apps/haskell/src/Todo/Cookie.hs
new file mode 100644
index 0000000..daff65d
--- /dev/null
+++ b/apps/haskell/src/Todo/Cookie.hs
@@ -0,0 +1,73 @@
+{-# LANGUAGE CApiFFI #-}
+
+-- | The anonymous cookie that names a visitor's list.
+module Todo.Cookie
+ ( cookieName
+ , newToken
+ , readToken
+ , setCookie
+ ) where
+
+import Control.Monad (when)
+import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
+import Data.ByteString.Builder qualified as B
+import Data.ByteString.Internal qualified as BSI
+import Data.ByteString.Lazy qualified as BL
+import Data.Char (isHexDigit, isLower, isDigit)
+import Data.Text (Text)
+import Data.Text.Encoding qualified as T
+import Data.Word (Word8)
+import Foreign.C.Types (CSize (..), CUInt (..))
+import Foreign.Ptr (Ptr, plusPtr)
+import System.Posix.Types (CSsize (..))
+import Web.Cookie
+
+cookieName :: ByteString
+cookieName = "todo_list"
+
+-- | Bytes of randomness in a token: 256 bits, written as 64 hex digits.
+tokenBytes :: Int
+tokenBytes = 32
+
+foreign import capi unsafe "sys/random.h getrandom"
+ c_getrandom :: Ptr Word8 -> CSize -> CUInt -> IO CSsize
+
+-- | A fresh token from the kernel's CSPRNG (getrandom(2), so no file
+-- under @/dev@ has to be visible to a sandboxed instance).
+newToken :: IO Text
+newToken = do
+ bytes <- BSI.create tokenBytes (fill tokenBytes)
+ pure . T.decodeLatin1 . BL.toStrict . B.toLazyByteString $ B.byteStringHex bytes
+ where
+ fill 0 _ = pure ()
+ fill n p = do
+ got <- c_getrandom p (fromIntegral n) 0
+ -- A negative answer is EINTR or similar: just ask again.
+ when (got < 0) $ fill n p
+ when (got > 0) $ fill (n - fromIntegral got) (p `plusPtr` fromIntegral got)
+
+-- | The token in a request's @Cookie@ header, if it has the shape of one
+-- this app mints. Anything else, garbled or not, is treated as absent.
+readToken :: Maybe ByteString -> Maybe Text
+readToken header = do
+ value <- lookup cookieName . parseCookies =<< header
+ let ok c = isHexDigit c && (isDigit c || isLower c)
+ if BS.length value == 2 * tokenBytes && BS.all (ok . BSI.w2c) value
+ then Just (T.decodeLatin1 value)
+ else Nothing
+
+-- | The @Set-Cookie@ value for a token. @secure@ is set when the app is
+-- served over https.
+setCookie :: Bool -> Text -> ByteString
+setCookie secure token =
+ BL.toStrict . B.toLazyByteString . renderSetCookie $
+ defaultSetCookie
+ { setCookieName = cookieName
+ , setCookieValue = T.encodeUtf8 token
+ , setCookiePath = Just "/"
+ , setCookieMaxAge = Just (400 * 24 * 60 * 60)
+ , setCookieHttpOnly = True
+ , setCookieSecure = secure
+ , setCookieSameSite = Just sameSiteLax
+ }
diff --git a/apps/haskell/src/Todo/Db.hs b/apps/haskell/src/Todo/Db.hs
new file mode 100644
index 0000000..2c2ac2a
--- /dev/null
+++ b/apps/haskell/src/Todo/Db.hs
@@ -0,0 +1,135 @@
+-- | SQLite storage: one file, WAL mode, one connection shared behind a
+-- lock. SQLite runs one writer at a time anyway, and a TodoMVC request
+-- is a handful of indexed statements, so a pool would buy nothing.
+module Todo.Db
+ ( Db
+ , open
+ , close
+ , findList
+ , createList
+ , listTodos
+ , addTodo
+ , toggleTodo
+ , renameTodo
+ , deleteTodo
+ , toggleAll
+ , clearCompleted
+ ) where
+
+import Control.Concurrent.MVar
+import Control.Monad (forM_)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Database.SQLite.Simple (Connection, Only (..), Query (..), execute, execute_, query, query_, withTransaction)
+import Database.SQLite.Simple qualified as SQL
+import Todo.Model
+
+newtype Db = Db (MVar Connection)
+
+-- | Opens (creating if needed) the database and brings its schema up to
+-- date.
+open :: FilePath -> IO Db
+open path = do
+ conn <- SQL.open path
+ -- journal_mode answers with a row, so it goes through query_.
+ _ <- query_ conn "PRAGMA journal_mode = WAL" :: IO [Only Text]
+ execute_ conn "PRAGMA synchronous = NORMAL"
+ execute_ conn "PRAGMA foreign_keys = ON"
+ execute_ conn "PRAGMA busy_timeout = 5000"
+ migrate conn
+ Db <$> newMVar conn
+
+-- | Folds the WAL back into the main file and closes the connection.
+close :: Db -> IO ()
+close (Db var) = withMVar var $ \conn -> do
+ _ <- query_ conn "PRAGMA wal_checkpoint(TRUNCATE)" :: IO [(Int, Int, Int)]
+ SQL.close conn
+
+-- | The schema's history. Each entry runs once, in order, and the number
+-- applied is kept in @user_version@. Entries are only ever appended, and
+-- they only ever add: no drops, no rewrites.
+migrations :: [[Query]]
+migrations =
+ [ [ "CREATE TABLE lists (\
+ \ id INTEGER PRIMARY KEY,\
+ \ token TEXT NOT NULL UNIQUE,\
+ \ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"
+ , "CREATE TABLE todos (\
+ \ id INTEGER PRIMARY KEY,\
+ \ list_id INTEGER NOT NULL REFERENCES lists (id),\
+ \ title TEXT NOT NULL,\
+ \ completed INTEGER NOT NULL DEFAULT 0,\
+ \ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')))"
+ , "CREATE INDEX todos_by_list ON todos (list_id, id)"
+ ]
+ ]
+
+migrate :: Connection -> IO ()
+migrate conn = do
+ applied <- query_ conn "PRAGMA user_version"
+ let done = case applied of
+ [Only n] -> n
+ _ -> 0
+ forM_ (drop done (zip [1 :: Int ..] migrations)) $ \(n, statements) ->
+ withTransaction conn $ do
+ mapM_ (execute_ conn) statements
+ -- PRAGMA takes no parameters, so the number is written in.
+ execute_ conn (Query ("PRAGMA user_version = " <> T.pack (show n)))
+
+with :: Db -> (Connection -> IO a) -> IO a
+with (Db var) = withMVar var
+
+findList :: Db -> Text -> IO (Maybe ListId)
+findList db token = with db $ \conn -> do
+ rows <- query conn "SELECT id FROM lists WHERE token = ?" (Only token)
+ pure $ case rows of
+ [Only n] -> Just (ListId n)
+ _ -> Nothing
+
+createList :: Db -> Text -> IO ListId
+createList db token = with db $ \conn -> do
+ execute conn "INSERT INTO lists (token) VALUES (?)" (Only token)
+ ListId <$> SQL.lastInsertRowId conn
+
+listTodos :: Db -> ListId -> IO [Todo]
+listTodos db (ListId list) = with db $ \conn -> do
+ rows <- query conn "SELECT id, title, completed FROM todos WHERE list_id = ? ORDER BY id" (Only list)
+ pure [Todo (TodoId i) t c | (i, t, c) <- rows]
+
+addTodo :: Db -> ListId -> Text -> IO ()
+addTodo db (ListId list) title = with db $ \conn ->
+ execute conn "INSERT INTO todos (list_id, title) VALUES (?, ?)" (list, title)
+
+-- The functions that change one todo answer whether it was in that list.
+
+toggleTodo :: Db -> ListId -> TodoId -> IO Bool
+toggleTodo db (ListId list) (TodoId todo) = with db $ \conn -> do
+ execute conn "UPDATE todos SET completed = NOT completed WHERE id = ? AND list_id = ?" (todo, list)
+ changed conn
+
+renameTodo :: Db -> ListId -> TodoId -> Text -> IO Bool
+renameTodo db (ListId list) (TodoId todo) title = with db $ \conn -> do
+ execute conn "UPDATE todos SET title = ? WHERE id = ? AND list_id = ?" (title, todo, list)
+ changed conn
+
+deleteTodo :: Db -> ListId -> TodoId -> IO Bool
+deleteTodo db (ListId list) (TodoId todo) = with db $ \conn -> do
+ execute conn "DELETE FROM todos WHERE id = ? AND list_id = ?" (todo, list)
+ changed conn
+
+changed :: Connection -> IO Bool
+changed conn = (> 0) <$> SQL.changes conn
+
+-- | Completes everything, unless everything already is, in which case it
+-- makes everything active again.
+toggleAll :: Db -> ListId -> IO ()
+toggleAll db (ListId list) = with db $ \conn -> withTransaction conn $ do
+ rows <- query conn "SELECT EXISTS (SELECT 1 FROM todos WHERE list_id = ? AND NOT completed)" (Only list)
+ let anyActive = case rows of
+ [Only b] -> b
+ _ -> False
+ execute conn "UPDATE todos SET completed = ? WHERE list_id = ?" (anyActive, list)
+
+clearCompleted :: Db -> ListId -> IO ()
+clearCompleted db (ListId list) = with db $ \conn ->
+ execute conn "DELETE FROM todos WHERE list_id = ? AND completed" (Only list)
diff --git a/apps/haskell/src/Todo/Form.hs b/apps/haskell/src/Todo/Form.hs
new file mode 100644
index 0000000..4b89e7c
--- /dev/null
+++ b/apps/haskell/src/Todo/Form.hs
@@ -0,0 +1,32 @@
+-- | Reading @application/x-www-form-urlencoded@ request bodies.
+module Todo.Form
+ ( Fields
+ , readFields
+ , field
+ ) where
+
+import Data.ByteString qualified as BS
+import Data.Text (Text)
+import Data.Text.Encoding qualified as T
+import Network.HTTP.Types.URI (parseSimpleQuery)
+import Network.Wai (Request, getRequestBodyChunk)
+
+type Fields = [(Text, Text)]
+
+-- | The body's fields, or 'Nothing' if it's bigger than @limit@ bytes.
+-- Bytes that aren't UTF-8 become U+FFFD rather than an error.
+readFields :: Int -> Request -> IO (Maybe Fields)
+readFields limit req = go 0 []
+ where
+ go size chunks = do
+ chunk <- getRequestBodyChunk req
+ let size' = size + BS.length chunk
+ if
+ | BS.null chunk -> pure (Just (decode (BS.concat (reverse chunks))))
+ | size' > limit -> pure Nothing
+ | otherwise -> go size' (chunk : chunks)
+ decode body = [(text k, text v) | (k, v) <- parseSimpleQuery body]
+ text = T.decodeUtf8Lenient
+
+field :: Text -> Fields -> Maybe Text
+field = lookup
diff --git a/apps/haskell/src/Todo/Html.hs b/apps/haskell/src/Todo/Html.hs
new file mode 100644
index 0000000..ce543cd
--- /dev/null
+++ b/apps/haskell/src/Todo/Html.hs
@@ -0,0 +1,123 @@
+-- | The TodoMVC page, rendered on the server. It follows TodoMVC's own
+-- template, plus the plain forms that make every action work without
+-- JavaScript. @static/app.css@ lays those forms out and @static/app.js@
+-- takes over from them when it runs.
+module Todo.Html
+ ( View (..)
+ , page
+ , fragment
+ ) where
+
+import Control.Monad (forM_, unless)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Lucid
+import Lucid.Base (makeAttributes)
+import Todo.Model
+
+-- | Everything one render needs.
+data View = View
+ { viewFilter :: Filter
+ , viewTodos :: [Todo]
+ -- ^ The whole list, whatever the filter.
+ , viewEditing :: Maybe TodoId
+ , viewAssetVersion :: Text
+ -- ^ Appended to this app's own asset URLs, so a new build is fetched.
+ }
+
+page :: View -> Html ()
+page v = doctype_ >> html_ [lang_ "en"] (do
+ head_ $ do
+ meta_ [charset_ "utf-8"]
+ meta_ [name_ "viewport", content_ "width=device-width, initial-scale=1"]
+ title_ "TodoMVC"
+ link_ [rel_ "stylesheet", href_ "/static/base.css"]
+ link_ [rel_ "stylesheet", href_ "/static/index.css"]
+ link_ [rel_ "stylesheet", href_ ("/static/app.css?v=" <> viewAssetVersion v)]
+ script_ [type_ "module", src_ ("/static/app.js?v=" <> viewAssetVersion v)] ("" :: Text)
+ body_ $ do
+ section_ [class_ "todoapp"] $ do
+ header_ [class_ "header"] $ do
+ h1_ "todos"
+ postForm v "/todos" $
+ input_ $
+ [class_ "new-todo", name_ "title", placeholder_ "What needs to be done?", autocomplete_ "off"]
+ <> [autofocus_ | null (viewEditing v)]
+ fragment v
+ footer_ [class_ "info"] $ do
+ p_ "Double-click to edit a todo"
+ p_ $ do
+ "Created by "
+ a_ [href_ "https://bakeoff.bfj.oncarlos.com"] "the CARLOS bakeoff"
+ " · Haskell"
+ p_ $ do
+ "Part of "
+ a_ [href_ "http://todomvc.com"] "TodoMVC")
+
+-- | @section.main@ and @footer.footer@: the part of the page every
+-- action changes. With JavaScript on, actions answer with just this.
+fragment :: View -> Html ()
+fragment v = do
+ section_ ([class_ "main"] <> hiddenIf (null todos)) $ do
+ input_ ([id_ "toggle-all", class_ "toggle-all", type_ "checkbox"] <> [checked_ | allDone])
+ label_ [for_ "toggle-all"] "Mark all as complete"
+ postForm v "/todos/toggle-all" $
+ button_ [class_ "toggle-all-submit", data_ "action" "toggle-all"] "Mark all as complete"
+ ul_ [class_ "todo-list"] $
+ forM_ (filter (visible (viewFilter v)) todos) (item v)
+ footer_ ([class_ "footer"] <> hiddenIf (null todos)) $ do
+ span_ [class_ "todo-count"] $ do
+ strong_ (toHtml (show left))
+ if left == 1 then " item left" else " items left"
+ ul_ [class_ "filters"] $
+ forM_ [minBound .. maxBound] $ \f -> do
+ unless (f == minBound) " "
+ li_ $ a_ ([href_ (filterPath f)] <> [class_ "selected" | f == viewFilter v]) (filterLabel f)
+ postForm v "/todos/clear-completed" $
+ button_ ([class_ "clear-completed"] <> hiddenIf (left == length todos)) "Clear completed"
+ where
+ todos = viewTodos v
+ left = length (filter (not . todoCompleted) todos)
+ allDone = not (null todos) && left == 0
+
+item :: View -> Todo -> Html ()
+item v t =
+ li_ ([data_ "id" tid] <> [class_ (T.unwords classes) | not (null classes)]) $ do
+ div_ [class_ "view"] $ do
+ input_ ([class_ "toggle", type_ "checkbox"] <> [checked_ | todoCompleted t])
+ label_ $ a_ [data_ "action" "edit", href_ editHref] (toHtml (todoTitle t))
+ postForm v (base <> "/toggle") $
+ button_
+ [ class_ "toggle-submit"
+ , data_ "action" "toggle"
+ , makeAttributes "aria-label" (if todoCompleted t then "Mark as active" else "Mark as complete")
+ ]
+ ""
+ postForm v (base <> "/destroy") $
+ button_ [class_ "destroy", makeAttributes "aria-label" "Delete"] ""
+ postForm v (base <> "/edit") $
+ input_ ([class_ "edit", name_ "title", value_ (todoTitle t)] <> [autofocus_ | editing])
+ where
+ tid = todoIdText (todoId t)
+ base = "/todos/" <> tid
+ editing = viewEditing v == Just (todoId t)
+ classes = ["completed" | todoCompleted t] <> ["editing" | editing]
+ editHref = filterPath (viewFilter v) <> "?edit=" <> tid
+
+-- | A form that posts to one action, carrying the current filter so the
+-- redirect comes back to it. Laid out with @display: contents@, so it
+-- doesn't disturb TodoMVC's CSS.
+postForm :: View -> Text -> Html () -> Html ()
+postForm v action body =
+ form_ [method_ "post", action_ action, class_ "action"] $ do
+ input_ [type_ "hidden", name_ "filter", value_ (filterName (viewFilter v))]
+ body
+
+hiddenIf :: Bool -> [Attributes]
+hiddenIf b = [hidden_ "" | b]
+
+filterLabel :: Filter -> Html ()
+filterLabel = \case
+ All -> "All"
+ Active -> "Active"
+ Completed -> "Completed"
diff --git a/apps/haskell/src/Todo/Model.hs b/apps/haskell/src/Todo/Model.hs
new file mode 100644
index 0000000..afb3cd0
--- /dev/null
+++ b/apps/haskell/src/Todo/Model.hs
@@ -0,0 +1,72 @@
+-- | The shapes the rest of the app talks in.
+module Todo.Model
+ ( ListId (..)
+ , TodoId (..)
+ , Todo (..)
+ , Filter (..)
+ , parseFilter
+ , filterName
+ , filterPath
+ , visible
+ , parseTodoId
+ , todoIdText
+ ) where
+
+import Data.Int (Int64)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.Read qualified as T
+
+-- | One visitor's list. Every query about todos is scoped by one.
+newtype ListId = ListId Int64
+ deriving (Eq, Show)
+
+newtype TodoId = TodoId Int64
+ deriving (Eq, Show)
+
+data Todo = Todo
+ { todoId :: !TodoId
+ , todoTitle :: !Text
+ , todoCompleted :: !Bool
+ }
+ deriving (Eq, Show)
+
+data Filter = All | Active | Completed
+ deriving (Eq, Show, Enum, Bounded)
+
+-- | The @filter@ form field. Anything missing or unknown means 'All'.
+parseFilter :: Maybe Text -> Filter
+parseFilter = \case
+ Just "active" -> Active
+ Just "completed" -> Completed
+ _ -> All
+
+filterName :: Filter -> Text
+filterName = \case
+ All -> "all"
+ Active -> "active"
+ Completed -> "completed"
+
+filterPath :: Filter -> Text
+filterPath = \case
+ All -> "/"
+ Active -> "/active"
+ Completed -> "/completed"
+
+visible :: Filter -> Todo -> Bool
+visible = \case
+ All -> const True
+ Active -> not . todoCompleted
+ Completed -> todoCompleted
+
+-- | A todo id as it appears in a path or a query: plain decimal digits,
+-- short enough to fit an 'Int64'.
+parseTodoId :: Text -> Maybe TodoId
+parseTodoId t
+ | T.null t || T.length t > 18 || not (T.all (\c -> c >= '0' && c <= '9') t) = Nothing
+ | otherwise = case T.decimal t of
+ Right (n, rest) | T.null rest -> Just (TodoId n)
+ _ -> Nothing
+
+todoIdText :: TodoId -> Text
+todoIdText (TodoId n) = T.pack (show n)
diff --git a/apps/haskell/static/app.css b/apps/haskell/static/app.css
new file mode 100644
index 0000000..0b6c6a8
--- /dev/null
+++ b/apps/haskell/static/app.css
@@ -0,0 +1,45 @@
+/*
+ * The controls that make every action work without JavaScript. Each sits
+ * exactly over the TodoMVC control it stands in for, and is invisible, so
+ * the page looks the same either way. app.js puts the "js" class on
+ * <html> and they go.
+ */
+
+form.action {
+ display: contents;
+}
+
+.todo-list li label a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.todo-list li .toggle-submit {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+ cursor: pointer;
+}
+
+.toggle-all-submit {
+ position: absolute;
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ cursor: pointer;
+}
+
+.js .todo-list li .toggle-submit,
+.js .toggle-all-submit {
+ display: none;
+}
+
+/* With JavaScript a double-click on the label edits, so the link that
+ does the same without it stops taking clicks. */
+.js .todo-list li label a {
+ pointer-events: none;
+}
diff --git a/apps/haskell/static/app.js b/apps/haskell/static/app.js
new file mode 100644
index 0000000..d92cf6e
--- /dev/null
+++ b/apps/haskell/static/app.js
@@ -0,0 +1,125 @@
+// Layered over the server-rendered page, which already works as plain
+// forms. With this running it behaves like TodoMVC's own apps: actions
+// happen without a page load, and a double-click edits.
+//
+// Every action posts the same form it would without JavaScript, with an
+// X-Fragment header. The server answers with fresh section.main and
+// footer.footer, which replace the old ones. Requests go one at a time,
+// in order.
+
+document.documentElement.classList.add('js');
+
+const app = document.querySelector('.todoapp');
+let queue = Promise.resolve();
+// True while a response replaces the list, so the edit input it removes
+// isn't taken for one the user left.
+let swapping = false;
+
+function send(action, body) {
+ queue = queue
+ .then(async () => {
+ const res = await fetch(action, { method: 'POST', headers: { 'X-Fragment': '1' }, body });
+ if (!res.ok) throw new Error(`${action}: ${res.status}`);
+ const next = document.createElement('template');
+ next.innerHTML = await res.text();
+ const edit = pendingEdit();
+ swapping = true;
+ try {
+ for (const part of ['.main', '.footer']) {
+ app.querySelector(part).replaceWith(next.content.querySelector(part));
+ }
+ } finally {
+ swapping = false;
+ }
+ if (edit) resumeEdit(edit);
+ })
+ // The page is the source of truth: if anything went wrong, show it.
+ .catch(() => location.reload());
+ return queue;
+}
+
+// An edit the user is in the middle of, captured before a response
+// replaces the list, and put back into the fresh copy of that item.
+function pendingEdit() {
+ const li = app.querySelector('.todo-list li.editing');
+ if (!li) return null;
+ const input = li.querySelector('.edit');
+ return {
+ id: li.dataset.id,
+ value: input.value,
+ start: input.selectionStart,
+ end: input.selectionEnd,
+ focused: document.activeElement === input,
+ };
+}
+
+function resumeEdit(edit) {
+ const li = app.querySelector(`.todo-list li[data-id="${CSS.escape(edit.id)}"]`);
+ if (!li) return;
+ const input = li.querySelector('.edit');
+ li.classList.add('editing');
+ input.value = edit.value;
+ if (edit.focused) {
+ input.focus();
+ input.setSelectionRange(edit.start, edit.end);
+ }
+}
+
+const fields = (form) => new URLSearchParams(new FormData(form));
+const filter = () => app.querySelector('input[name="filter"]').value;
+
+app.addEventListener('submit', (e) => {
+ const form = e.target;
+ e.preventDefault();
+ if (form.querySelector('.edit')) {
+ finishEditing(form.closest('li'), true);
+ return;
+ }
+ const body = fields(form);
+ send(form.action, body);
+ const input = form.querySelector('.new-todo');
+ if (input) input.value = '';
+});
+
+app.addEventListener('change', (e) => {
+ const box = e.target;
+ if (box.matches('.toggle')) {
+ send(`/todos/${box.closest('li').dataset.id}/toggle`, new URLSearchParams({ filter: filter() }));
+ } else if (box.matches('.toggle-all')) {
+ send('/todos/toggle-all', new URLSearchParams({ filter: filter() }));
+ }
+});
+
+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);
+});
+
+app.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && e.target.matches('.edit')) finishEditing(e.target.closest('li'), false);
+});
+
+app.addEventListener('focusout', (e) => {
+ if (!swapping && e.target.matches('.edit')) finishEditing(e.target.closest('li'), true);
+});
+
+// Ends an edit once: Enter submits and then blurs, and Escape blurs too.
+function finishEditing(li, save) {
+ if (!li.classList.contains('editing')) return;
+ const input = li.querySelector('.edit');
+ li.classList.remove('editing');
+ if (save) {
+ // Show the new title straight away; the server's answer follows.
+ const title = input.value.trim();
+ if (title) li.querySelector('label a').textContent = title;
+ else li.hidden = true;
+ send(input.form.action, fields(input.form));
+ } else {
+ input.value = input.defaultValue;
+ }
+}
diff --git a/apps/haskell/test/Spec.hs b/apps/haskell/test/Spec.hs
new file mode 100644
index 0000000..a2437e5
--- /dev/null
+++ b/apps/haskell/test/Spec.hs
@@ -0,0 +1,178 @@
+-- | Drives the WAI application in-process, against a fresh in-memory
+-- database for each test.
+module Main (main) where
+
+import Control.Exception (bracket)
+import Control.Monad.IO.Class (liftIO)
+import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as BL
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as T
+import Network.HTTP.Types
+import Network.Wai (Application, requestHeaders, requestMethod)
+import Network.Wai.Test
+import Test.Hspec
+
+import Todo.App
+import Todo.Db qualified as Db
+
+origin :: ByteString
+origin = "http://127.0.0.1:8080"
+
+withApp :: (Application -> IO a) -> IO a
+withApp run = bracket (Db.open ":memory:") Db.close $ \db ->
+ run (app (Config (Just origin) False) db)
+
+get :: ByteString -> Session SResponse
+get path = request (setPath defaultRequest path)
+
+post :: ByteString -> [(ByteString, ByteString)] -> [Header] -> Session SResponse
+post path form headers =
+ srequest $
+ SRequest
+ (setPath defaultRequest {requestMethod = methodPost, requestHeaders = (hContentType, "application/x-www-form-urlencoded") : headers} path)
+ (BL.fromStrict (BS.drop 1 (renderSimpleQuery True form)))
+
+body :: SResponse -> T.Text
+body = T.decodeUtf8 . BL.toStrict . simpleBody
+
+-- | Labels on the page, in order.
+labels :: SResponse -> [T.Text]
+labels r =
+ [ T.takeWhile (/= '<') (T.drop 1 (T.dropWhile (/= '>') chunk))
+ | chunk <- drop 1 (T.splitOn "data-action=\"edit\"" (body r))
+ ]
+
+main :: IO ()
+main = hspec $ around withApp $ do
+ describe "pages" $ do
+ it "serves an empty list and hands out a cookie" $ runSession $ do
+ r <- get "/"
+ assertStatus 200 r
+ let cookie = lookup hSetCookie (simpleHeaders r)
+ liftIO $ do
+ cookie `shouldSatisfy` maybe False ("todo_list=" `BS.isPrefixOf`)
+ cookie `shouldSatisfy` maybe False ("HttpOnly" `BS.isInfixOf`)
+ cookie `shouldSatisfy` maybe False ("SameSite=Lax" `BS.isInfixOf`)
+ cookie `shouldSatisfy` maybe False ("Path=/" `BS.isInfixOf`)
+ assertBodyContains "<section class=\"main\" hidden=\"\">" r
+ assertBodyContains "· Haskell" r
+
+ it "keeps the cookie it handed out" $ runSession $ do
+ _ <- get "/"
+ r <- get "/"
+ assertNoHeader hSetCookie r
+
+ it "answers health and version checks" $ runSession $ do
+ get "/healthz" >>= assertStatus 200
+ r <- get "/api/version"
+ assertStatus 200 r
+ assertBody (BL.fromStrict (T.encodeUtf8 version)) r
+
+ it "serves the shared stylesheets byte for byte" $ runSession $ do
+ r <- get "/static/index.css"
+ want <- liftIO (BS.readFile "../../shared/index.css")
+ assertBody (BL.fromStrict want) r
+
+ it "says 404 for anything else" $ runSession $
+ get "/nope" >>= assertStatus 404
+
+ describe "actions" $ do
+ it "adds a trimmed todo and redirects to the filter" $ runSession $ do
+ r <- post "/todos" [("title", " buy milk "), ("filter", "active")] []
+ assertStatus 303 r
+ assertHeader hLocation "/active" r
+ page <- get "/"
+ liftIO $ labels page `shouldBe` ["buy milk"]
+
+ it "ignores a blank title" $ runSession $ do
+ _ <- post "/todos" [("title", " ")] []
+ page <- get "/"
+ liftIO $ labels page `shouldBe` []
+
+ it "sends an unknown filter back to /" $ runSession $ do
+ r <- post "/todos" [("title", "x"), ("filter", "bogus")] []
+ assertHeader hLocation "/" r
+
+ it "toggles, renames and deletes" $ runSession $ do
+ mapM_ (\t -> post "/todos" [("title", t)] []) ["a", "b", "c"]
+ post "/todos/1/toggle" [("filter", "all")] [] >>= assertStatus 303
+ active <- get "/active"
+ liftIO $ labels active `shouldBe` ["b", "c"]
+ completed <- get "/completed"
+ liftIO $ labels completed `shouldBe` ["a"]
+ _ <- post "/todos/2/edit" [("title", " bee ")] []
+ _ <- post "/todos/3/edit" [("title", " ")] []
+ page <- get "/"
+ liftIO $ labels page `shouldBe` ["a", "bee"]
+ _ <- post "/todos/1/destroy" [] []
+ page' <- get "/"
+ liftIO $ labels page' `shouldBe` ["bee"]
+
+ it "toggles all, and back when all were done" $ runSession $ do
+ mapM_ (\t -> post "/todos" [("title", t)] []) ["a", "b"]
+ _ <- post "/todos/1/toggle" [] []
+ _ <- post "/todos/toggle-all" [] []
+ done <- get "/completed"
+ liftIO $ labels done `shouldBe` ["a", "b"]
+ _ <- post "/todos/toggle-all" [] []
+ active <- get "/active"
+ liftIO $ labels active `shouldBe` ["a", "b"]
+
+ it "clears completed todos" $ runSession $ do
+ mapM_ (\t -> post "/todos" [("title", t)] []) ["a", "b"]
+ _ <- post "/todos/2/toggle" [] []
+ _ <- post "/todos/clear-completed" [] []
+ page <- get "/"
+ liftIO $ labels page `shouldBe` ["a"]
+
+ it "answers a fragment when app.js asks for one" $ runSession $ do
+ r <- post "/todos" [("title", "x")] [(fragmentHeader, "1")]
+ assertStatus 200 r
+ liftIO $ body r `shouldSatisfy` T.isPrefixOf "<section class=\"main\">"
+
+ it "opens the editor with ?edit=" $ runSession $ do
+ _ <- post "/todos" [("title", "x")] []
+ r <- get "/?edit=1"
+ assertBodyContains "<li data-id=\"1\" class=\"editing\">" r
+ assertBodyContains "action=\"/todos/1/edit\"" r
+
+ describe "safety" $ do
+ it "refuses a cross-origin post and accepts its own origin" $ runSession $ do
+ post "/todos" [("title", "evil")] [("Origin", "https://evil.example")] >>= assertStatus 403
+ post "/todos" [("title", "fine")] [("Origin", origin)] >>= assertStatus 303
+ page <- get "/"
+ liftIO $ labels page `shouldBe` ["fine"]
+
+ it "keeps lists apart" $ \application -> do
+ runSession (post "/todos" [("title", "mine")] [] >> pure ()) application
+ runSession
+ ( do
+ post "/todos/1/toggle" [] [] >>= assertStatus 404
+ post "/todos/1/edit" [("title", "stolen")] [] >>= assertStatus 404
+ post "/todos/1/destroy" [] [] >>= assertStatus 404
+ page <- get "/"
+ liftIO $ labels page `shouldBe` []
+ )
+ application
+
+ it "gives a garbled cookie a fresh list" $ runSession $ do
+ r <- request (setPath defaultRequest {requestHeaders = [(hCookie, "todo_list=%%%")]} "/")
+ assertStatus 200 r
+ assertHeaderContains' hSetCookie "todo_list=" r
+
+ it "escapes titles and keeps Unicode" $ runSession $ do
+ _ <- post "/todos" [("title", T.encodeUtf8 "<img src=x> & \"q\" naïve 🚀")] []
+ r <- get "/"
+ assertBodyContains (BL.fromStrict (T.encodeUtf8 "&lt;img src=x&gt; &amp; &quot;q&quot; naïve 🚀")) r
+ assertHeader hContentLength (T.encodeUtf8 (T.pack (show (BL.length (simpleBody r))))) r
+
+ it "takes a 10,000 character title" $ runSession $ do
+ let long = T.replicate 10000 "æ—¥"
+ post "/todos" [("title", T.encodeUtf8 long)] [] >>= assertStatus 303
+ r <- get "/"
+ liftIO $ labels r `shouldBe` [long]
+ where
+ assertHeaderContains' name needle r =
+ liftIO $ lookup name (simpleHeaders r) `shouldSatisfy` maybe False (needle `BS.isPrefixOf`)
diff --git a/apps/haskell/todo-haskell.cabal b/apps/haskell/todo-haskell.cabal
new file mode 100644
index 0000000..3edbd2f
--- /dev/null
+++ b/apps/haskell/todo-haskell.cabal
@@ -0,0 +1,78 @@
+cabal-version: 3.0
+name: todo-haskell
+version: 0.1.0.0
+synopsis: Server-rendered TodoMVC for the CARLOS bakeoff
+license: NONE
+build-type: Simple
+extra-source-files:
+ ../../shared/base.css
+ ../../shared/index.css
+ static/app.css
+ static/app.js
+
+common shared
+ default-language: GHC2021
+ default-extensions:
+ OverloadedStrings
+ LambdaCase
+ MultiWayIf
+ ghc-options: -Wall -Wcompat
+
+library
+ import: shared
+ hs-source-dirs: src gen
+ exposed-modules:
+ Todo.App
+ Todo.Assets
+ Todo.Cookie
+ Todo.Db
+ Todo.Form
+ Todo.Html
+ Todo.Model
+ other-modules:
+ BuildVersion
+ build-depends:
+ , base >=4.18 && <5
+ , bytestring >=0.11 && <0.13
+ , cookie >=0.4.6 && <0.6
+ , file-embed >=0.0.15 && <0.1
+ , http-types >=0.12 && <0.13
+ , lucid2 >=0.0.20230706 && <0.1
+ , sqlite-simple >=0.4.18 && <0.5
+ , text >=2.0 && <2.2
+ , wai >=3.2.3 && <3.3
+
+executable todo-haskell
+ import: shared
+ hs-source-dirs: app
+ main-is: Main.hs
+ build-depends:
+ , base
+ , bytestring
+ , network >=3.1 && <3.3
+ , text
+ , todo-haskell
+ , unix >=2.8 && <2.9
+ , wai
+ , warp >=3.3.25 && <3.5
+ ghc-options: -threaded -rtsopts "-with-rtsopts=-N2"
+ -- The shipped linux-arm64 binary is one static file: glibc and every
+ -- library linked in, symbols stripped.
+ if os(linux) && arch(aarch64)
+ ghc-options: -static -optl-static -optl-pthread -optl-s
+
+test-suite spec
+ import: shared
+ type: exitcode-stdio-1.0
+ hs-source-dirs: test
+ main-is: Spec.hs
+ build-depends:
+ , base
+ , bytestring
+ , http-types
+ , text
+ , todo-haskell
+ , wai
+ , wai-extra >=3.1 && <3.2
+ , hspec >=2.10 && <2.12
+ ghc-options: -threaded