| 1 | # todo-ocaml |
| 2 | |
| 3 | TodoMVC for the CARLOS bakeoff, written in OCaml 5. The server renders |
| 4 | every page, so the app works fully with JavaScript turned off. A small |
| 5 | script (`static/app.js`, no framework, no build step) makes it behave |
| 6 | like TodoMVC's own apps when JavaScript is on. |
| 7 | |
| 8 | Live at |
| 9 | [https://todo-ocaml.bfj.oncarlos.com](https://todo-ocaml.bfj.oncarlos.com). |
| 10 | |
| 11 | ## The stack, and why |
| 12 | |
| 13 | | Piece | Choice | Why | |
| 14 | |---|---|---| |
| 15 | | Concurrency | [Eio](https://github.com/ocaml-multicore/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 | |
| 16 | | HTTP | [cohttp-eio](https://github.com/mirage/ocaml-cohttp) | 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 | |
| 17 | | SQLite | [sqlite3-ocaml](https://github.com/mmottl/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 | |
| 18 | | 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 | |
| 19 | | 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 | |
| 20 | | 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 | |
| 21 | | Tests | [Alcotest](https://github.com/mirage/alcotest) | The usual OCaml test runner | |
| 22 | |
| 23 | The code is in four small modules under `src/`: |
| 24 | |
| 25 | - `app.ml`: routes and actions, as a plain function from a request |
| 26 | record to a response record. The tests call it directly, with no |
| 27 | server. |
| 28 | - `view.ml`: the page and the `section.todoapp` fragment. |
| 29 | - `store.ml`: the SQLite schema, migrations and queries. |
| 30 | - `form.ml`: form bodies and cookies. |
| 31 | |
| 32 | `bin/main.ml` parses the flags, opens the database, adapts `App.handle` |
| 33 | to cohttp-eio, and stops cleanly on SIGTERM. It runs cohttp-eio's |
| 34 | per-connection loop itself rather than `Cohttp_eio.Server.run`, because |
| 35 | `run` reads headers into an unbounded buffer. Here every connection reads |
| 36 | through a byte budget: 64 KiB for a request's line and headers (more gets |
| 37 | 431), 1 MiB for a body (more gets 413), and at most 256 connections at |
| 38 | once. |
| 39 | |
| 40 | ### How the page works |
| 41 | |
| 42 | Every action is an HTML form POST that answers 303 back to the current |
| 43 | filter. Controls that only exist for visitors without JavaScript (an |
| 44 | invisible submit button over each checkbox and over the toggle-all |
| 45 | chevron, and an Edit link) carry the class `nojs`. An inline script adds |
| 46 | `js` to `<html>` before the page paints, and `app.css` hides `.nojs` |
| 47 | under it. |
| 48 | |
| 49 | With JavaScript on, `app.js` posts the same forms with the header |
| 50 | `X-Fragment: todoapp`. The server answers with its rendering of |
| 51 | `section.todoapp`, and the script swaps in the list and footer. The |
| 52 | server is the only place a list lives. Requests go one at a time, so a |
| 53 | slow answer can't overwrite a newer one. |
| 54 | |
| 55 | ### Lists and safety |
| 56 | |
| 57 | Each visitor's list is keyed by an `HttpOnly; SameSite=Lax` cookie |
| 58 | holding 128 random bits (`Secure` on https). A cookie the app doesn't |
| 59 | know gets a fresh list. Every todo query is scoped by list id, so an id |
| 60 | from someone else's list answers 404. A POST whose `Origin` isn't the |
| 61 | app's own answers 403. Titles are stored as plain text (trust class C, |
| 62 | see `AGENTS.md`) and always rendered escaped. Pages carry a strict |
| 63 | Content-Security-Policy that allows only the app's own script plus the |
| 64 | one inline line, by hash. |
| 65 | |
| 66 | ## Build |
| 67 | |
| 68 | The toolchains come from `scripts/env.sh`. Both builds keep their opam |
| 69 | switch, and every compiled library, under `.cache/`, created on first |
| 70 | use from the exact versions in `todo_ocaml.opam.locked`. |
| 71 | |
| 72 | ``` |
| 73 | make build # dist/app, for this machine (x86-64) |
| 74 | make build-arm64 # dist/app-linux-arm64, static, built in the emulated arm64 root |
| 75 | make test # unit and handler tests |
| 76 | make clean # build output only |
| 77 | make clean-deps # also the compiled libraries; downloads stay |
| 78 | ``` |
| 79 | |
| 80 | The first `make build-arm64` takes a long time: every library compiles |
| 81 | under qemu. After that, a rebuild only recompiles what changed. |
| 82 | |
| 83 | SQLite is built from the amalgamation by `tools/sqlite.sh`: the tarball is |
| 84 | downloaded once into `.cache/src` and checked against sqlite.org's |
| 85 | SHA3-256, then compiled into `.cache/sqlite-x86` or `.cache/sqlite-arm64`, |
| 86 | where the `sqlite3` opam package finds it through `pkg-config`. |
| 87 | `make clean-deps` deletes those builds and keeps the tarball. |
| 88 | |
| 89 | Two things about docs-t1 shape the scripts in `tools/`: |
| 90 | |
| 91 | - The x86 compiler is the bakeoff's opam switch. opam won't treat a |
| 92 | compiler from another opam root as `ocaml-system`, so `tools/x86.sh` |
| 93 | links it into `.cache/x86-bin`. |
| 94 | - docs-t1 has no `pkg-config` and no `sudo`, so `tools/x86.sh` unpacks |
| 95 | Debian's `pkgconf` (`apt-get download`, no root) into |
| 96 | `.cache/x86-sysroot`. |
| 97 | |
| 98 | ## Run |
| 99 | |
| 100 | ``` |
| 101 | dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/ocaml.db" |
| 102 | ``` |
| 103 | |
| 104 | On Carloku it runs as `app --socket <path> --db <path>` and reads its |
| 105 | hostname from `CARLOS_HOST`. |
| 106 | |
| 107 | ## Test |
| 108 | |
| 109 | ``` |
| 110 | make test |
| 111 | scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/ocaml-suite.json" # from the repo root |
| 112 | ``` |
| 113 | |
| 114 | ## Deploy |
| 115 | |
| 116 | ``` |
| 117 | make build-arm64 |
| 118 | scripts/boot-check.sh apps/ocaml/dist/app-linux-arm64 # from the repo root |
| 119 | make deploy MESSAGE="what changed" |
| 120 | ``` |
| 121 | |
| 122 | `make deploy` runs `carlos deploy` for app `todo-ocaml` on account |
| 123 | `bfj`. The version is the git short SHA, compiled in and served at |
| 124 | `/api/version`. |
| 125 | |