docs(ocaml): README and DEPS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev fc299552b883785b9214852cca0446e55b20eeb4 parent 07f4de3
2 files changed, +121 −0
  • apps/ocaml/DEPS +7 −0
  • apps/ocaml/README.md +114 −0
diff --git a/apps/ocaml/DEPS b/apps/ocaml/DEPS
new file mode 100644
index 0000000..9832193
--- /dev/null
+++ b/apps/ocaml/DEPS
@@ -0,0 +1,7 @@
+eio
+eio_posix
+cohttp-eio
+http
+uri
+sqlite3
+alcotest
diff --git a/apps/ocaml/README.md b/apps/ocaml/README.md
new file mode 100644
index 0000000..b59f2fb
--- /dev/null
+++ b/apps/ocaml/README.md
@@ -0,0 +1,114 @@
+# 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](https://todo-ocaml.bfj.oncarlos.com).
+
+## The stack, and why
+
+| Piece | Choice | Why |
+|---|---|---|
+| 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 |
+| 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 |
+| SQLite | [sqlite3-ocaml](https://github.com/mmottl/sqlite3-ocaml) | Thin bindings to the C library, which links statically. 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](https://github.com/mirage/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.
+
+### 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.
+
+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 SQLite headers or `pkg-config`, and no `sudo`. So
+ `tools/x86.sh` unpacks the Debian packages (`apt-get download`, no
+ root) into `.cache/x86-sysroot`. The local build links the system
+ `libsqlite3.so.0`; the shipped arm64 build links SQLite statically.
+
+## 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`.