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-ocaml

TodoMVC for the CARLOS bakeoff, written in OCaml 5. The server renders every page, so the app works fully with JavaScript turned off. A small script (static/app.js, no framework, no build step) makes it behave like TodoMVC's own apps when JavaScript is on.

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

The stack, and why

| Piece | Choice | Why | |---|---|---| | Concurrency | Eio on the eio_posix backend | Eio is OCaml 5's direct-style IO library: plain functions, no monads. The posix backend uses poll, which works everywhere; eio_linux needs io_uring, which sandboxes and qemu often refuse | | HTTP | cohttp-eio | The long-standing OCaml HTTP library, on Eio. It serves a listening socket you hand it, so a unix socket (--socket) and TCP (--listen) are the same code | | SQLite | sqlite3-ocaml, over SQLite 3.54.1 built from its amalgamation by tools/sqlite.sh | Thin bindings to the C library. SQLite is compiled into .cache/ for each target and linked statically, so no system SQLite is involved. Prepared statements are cached per connection | | HTML | Hand-written Buffer rendering with one escape function | It's one page. A template library would be more code than the page, and the escaping rule stays in one place you can read | | Assets | Compiled into the binary by a dune rule (gen/embed.ml) | One file to ship. shared/base.css and shared/index.css are copied in at build time and served byte for byte | | Randomness | getrandom(2) through a ten-line C stub | Cookie values need a CSPRNG, and this needs no /dev/urandom inside the sandbox and no crypto library | | Tests | Alcotest | The usual OCaml test runner |

The code is in four small modules under src/:

  • app.ml: routes and actions, as a plain function from a request record to a response record. The tests call it directly, with no server.
  • view.ml: the page and the section.todoapp fragment.
  • store.ml: the SQLite schema, migrations and queries.
  • form.ml: form bodies and cookies.

bin/main.ml parses the flags, opens the database, adapts App.handle to cohttp-eio, and stops cleanly on SIGTERM. It runs cohttp-eio's per-connection loop itself rather than Cohttp_eio.Server.run, because run reads headers into an unbounded buffer. Here every connection reads through a byte budget: 64 KiB for a request's line and headers (more gets 431), 1 MiB for a body (more gets 413), and at most 256 connections at once.

How the page works

Every action is an HTML form POST that answers 303 back to the current filter. Controls that only exist for visitors without JavaScript (an invisible submit button over each checkbox and over the toggle-all chevron, and an Edit link) carry the class nojs. An inline script adds js to <html> before the page paints, and app.css hides .nojs under it.

With JavaScript on, app.js posts the same forms with the header X-Fragment: todoapp. The server answers with its rendering of section.todoapp, and the script swaps in the list and footer. The server is the only place a list lives. Requests go one at a time, so a slow answer can't overwrite a newer one.

Lists and safety

Each visitor's list is keyed by an HttpOnly; SameSite=Lax cookie holding 128 random bits (Secure on https). A cookie the app doesn't know gets a fresh list. Every todo query is scoped by list id, so an id from someone else's list answers 404. A POST whose Origin isn't the app's own answers 403. Titles are stored as plain text (trust class C, see AGENTS.md) and always rendered escaped. Pages carry a strict Content-Security-Policy that allows only the app's own script plus the one inline line, by hash.

Build

The toolchains come from scripts/env.sh. Both builds keep their opam switch, and every compiled library, under .cache/, created on first use from the exact versions in todo_ocaml.opam.locked.

make build          # dist/app, for this machine (x86-64)
make build-arm64    # dist/app-linux-arm64, static, built in the emulated arm64 root
make test           # unit and handler tests
make clean          # build output only
make clean-deps     # also the compiled libraries; downloads stay

The first make build-arm64 takes a long time: every library compiles under qemu. After that, a rebuild only recompiles what changed.

SQLite is built from the amalgamation by tools/sqlite.sh: the tarball is downloaded once into .cache/src and checked against sqlite.org's SHA3-256, then compiled into .cache/sqlite-x86 or .cache/sqlite-arm64, where the sqlite3 opam package finds it through pkg-config. make clean-deps deletes those builds and keeps the tarball.

Two things about docs-t1 shape the scripts in tools/:

  • The x86 compiler is the bakeoff's opam switch. opam won't treat a compiler from another opam root as ocaml-system, so tools/x86.sh links it into .cache/x86-bin.
  • docs-t1 has no pkg-config and no sudo, so tools/x86.sh unpacks Debian's pkgconf (apt-get download, no root) into .cache/x86-sysroot.

Run

dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/ocaml.db"

On Carloku it runs as app --socket <path> --db <path> and reads its hostname from CARLOS_HOST.

Test

make test
scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/ocaml-suite.json"   # from the repo root

Deploy

make build-arm64
scripts/boot-check.sh apps/ocaml/dist/app-linux-arm64    # from the repo root
make deploy MESSAGE="what changed"

make deploy runs carlos deploy for app todo-ocaml on account bfj. The version is the git short SHA, compiled in and served at /api/version.