# 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), 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](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. 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`.
