| 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`. |