carlos / todomvc-bakeoff Public
| bin/ | ||
| gen/ | ||
| src/ | ||
| static/ | ||
| test/ | ||
| tools/ | ||
| .gitignore | ||
| AGENTS.md | ||
| DEPS | ||
| Makefile | ||
| README.md | ||
| STATUS.md | ||
| dune-project | ||
| todo_ocaml.opam | ||
| todo_ocaml.opam.locked |
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 thesection.todoappfragment.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 stayThe 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, sotools/x86.shlinks it into.cache/x86-bin. - docs-t1 has no
pkg-configand nosudo, sotools/x86.shunpacks Debian'spkgconf(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 rootDeploy
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.