carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1# todo-ocaml
2
3TodoMVC for the CARLOS bakeoff, written in OCaml 5. The server renders
4every page, so the app works fully with JavaScript turned off. A small
5script (`static/app.js`, no framework, no build step) makes it behave
6like TodoMVC's own apps when JavaScript is on.
7
8Live 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
23The 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`
33to cohttp-eio, and stops cleanly on SIGTERM. It runs cohttp-eio's
34per-connection loop itself rather than `Cohttp_eio.Server.run`, because
35`run` reads headers into an unbounded buffer. Here every connection reads
36through a byte budget: 64 KiB for a request's line and headers (more gets
37431), 1 MiB for a body (more gets 413), and at most 256 connections at
38once.
39
40### How the page works
41
42Every action is an HTML form POST that answers 303 back to the current
43filter. Controls that only exist for visitors without JavaScript (an
44invisible submit button over each checkbox and over the toggle-all
45chevron, 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`
47under it.
48
49With 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
52server is the only place a list lives. Requests go one at a time, so a
53slow answer can't overwrite a newer one.
54
55### Lists and safety
56
57Each visitor's list is keyed by an `HttpOnly; SameSite=Lax` cookie
58holding 128 random bits (`Secure` on https). A cookie the app doesn't
59know gets a fresh list. Every todo query is scoped by list id, so an id
60from someone else's list answers 404. A POST whose `Origin` isn't the
61app's own answers 403. Titles are stored as plain text (trust class C,
62see `AGENTS.md`) and always rendered escaped. Pages carry a strict
63Content-Security-Policy that allows only the app's own script plus the
64one inline line, by hash.
65
66## Build
67
68The toolchains come from `scripts/env.sh`. Both builds keep their opam
69switch, and every compiled library, under `.cache/`, created on first
70use from the exact versions in `todo_ocaml.opam.locked`.
71
72```
73make build # dist/app, for this machine (x86-64)
74make build-arm64 # dist/app-linux-arm64, static, built in the emulated arm64 root
75make test # unit and handler tests
76make clean # build output only
77make clean-deps # also the compiled libraries; downloads stay
78```
79
80The first `make build-arm64` takes a long time: every library compiles
81under qemu. After that, a rebuild only recompiles what changed.
82
83SQLite is built from the amalgamation by `tools/sqlite.sh`: the tarball is
84downloaded once into `.cache/src` and checked against sqlite.org's
85SHA3-256, then compiled into `.cache/sqlite-x86` or `.cache/sqlite-arm64`,
86where the `sqlite3` opam package finds it through `pkg-config`.
87`make clean-deps` deletes those builds and keeps the tarball.
88
89Two 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```
101dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/ocaml.db"
102```
103
104On Carloku it runs as `app --socket <path> --db <path>` and reads its
105hostname from `CARLOS_HOST`.
106
107## Test
108
109```
110make test
111scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/ocaml-suite.json" # from the repo root
112```
113
114## Deploy
115
116```
117make build-arm64
118scripts/boot-check.sh apps/ocaml/dist/app-linux-arm64 # from the repo root
119make 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