feat: ocaml builder output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 7a66369df71289fc88afb0c0b569350a1e15b060 parent 1c17308parent d803224
27 files changed, +1650 −0
  • apps/ocaml/.gitignore +3 −0
  • apps/ocaml/AGENTS.md +31 −0
  • apps/ocaml/DEPS +9 −0
  • apps/ocaml/Makefile +49 −0
  • apps/ocaml/README.md +124 −0
  • apps/ocaml/STATUS.md +50 −0
  • apps/ocaml/bin/dune +14 −0
  • apps/ocaml/bin/main.ml +147 −0
  • apps/ocaml/dune-project +24 −0
  • apps/ocaml/gen/dune +2 −0
  • apps/ocaml/gen/embed.ml +14 −0
  • apps/ocaml/src/app.ml +167 −0
  • apps/ocaml/src/dune +28 −0
  • apps/ocaml/src/form.ml +71 −0
  • apps/ocaml/src/rand.ml +6 −0
  • apps/ocaml/src/rand_stubs.c +26 −0
  • apps/ocaml/src/store.ml +117 −0
  • apps/ocaml/src/view.ml +123 −0
  • apps/ocaml/static/app.css +62 −0
  • apps/ocaml/static/app.js +115 −0
  • apps/ocaml/test/dune +4 −0
  • apps/ocaml/test/test_todo.ml +235 −0
  • apps/ocaml/todo_ocaml.opam +32 −0
  • apps/ocaml/todo_ocaml.opam.locked +80 −0
  • apps/ocaml/tools/arm64.sh +29 −0
  • apps/ocaml/tools/sqlite.sh +45 −0
  • apps/ocaml/tools/x86.sh +43 −0
diff --git a/apps/ocaml/.gitignore b/apps/ocaml/.gitignore
new file mode 100644
index 0000000..ef0307c
--- /dev/null
+++ b/apps/ocaml/.gitignore
@@ -0,0 +1,3 @@
+/_build/
+/static/base.css
+/static/index.css
diff --git a/apps/ocaml/AGENTS.md b/apps/ocaml/AGENTS.md
new file mode 100644
index 0000000..5060829
--- /dev/null
+++ b/apps/ocaml/AGENTS.md
@@ -0,0 +1,31 @@
+# 🤖 AGENTS.md: todo-ocaml
+
+The one rule: **the page works without JavaScript, and JavaScript only
+makes it nicer.** Every action is a plain form POST that answers 303. The
+script in `static/app.js` posts the same forms with `X-Fragment: todoapp`
+and swaps in the HTML the server renders; it never holds state of its own.
+
+## Tenancy
+
+- **One instance for the whole app** (`todo-ocaml.bfj.oncarlos.com`), one
+ SQLite file.
+- **Each visitor's list is kept separately by an anonymous cookie**
+ (`todomvc_list`, 128 random bits from `getrandom(2)`, `HttpOnly;
+ SameSite=Lax; Path=/`, plus `Secure` over https). An unknown or garbled
+ cookie gets a fresh, empty list. Every query that reads or writes a todo
+ is scoped by that list id; an id outside the caller's list is a 404.
+- **No sign-in.** Losing the cookie loses the list. That's accepted.
+- **Trust class C.** Todo titles are stored as plain text in SQLite, not
+ encrypted. That's a deliberate choice for a demo app holding nothing
+ sensitive.
+
+## Working here
+
+- Work only in `apps/ocaml/`. `BRIEF.md` and `CONTRACT.md` at the repo root
+ are binding.
+- Build with `make build` (x86, local) and `make build-arm64` (shipped,
+ inside the emulated arm64 root). Both use opam roots under `.cache/`.
+- `make test` runs the unit and handler tests. Run the shared suite with
+ `scripts/test-app.sh` before every deploy.
+- Migrations only add. Never drop or rewrite data.
+- Bind `127.0.0.1` only. Scratch goes in `mktemp -d -p "$TMPDIR"`.
diff --git a/apps/ocaml/DEPS b/apps/ocaml/DEPS
new file mode 100644
index 0000000..8c7fdd4
--- /dev/null
+++ b/apps/ocaml/DEPS
@@ -0,0 +1,9 @@
+eio
+eio_posix
+cohttp-eio
+http
+uri
+cstruct
+sqlite3
+sqlite (3.54.1 amalgamation, built by tools/sqlite.sh)
+alcotest
diff --git a/apps/ocaml/Makefile b/apps/ocaml/Makefile
new file mode 100644
index 0000000..d1b90dc
--- /dev/null
+++ b/apps/ocaml/Makefile
@@ -0,0 +1,49 @@
+# todo-ocaml: TodoMVC for the CARLOS bakeoff. See README.md.
+SHELL := /bin/bash
+VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
+MESSAGE ?= Deploy todo-ocaml
+CSS := static/base.css static/index.css
+DUNE := dune build --root . --profile release
+
+.PHONY: build build-arm64 test clean clean-deps bump unbump deploy
+
+# The shared TodoMVC styles are served byte for byte, compiled in.
+static/%.css: ../../shared/%.css
+ cp $< $@
+
+build: $(CSS)
+ mkdir -p _build
+ APP_VERSION=$(VERSION) tools/x86.sh $(DUNE) --build-dir $(CURDIR)/_build/x86 ./bin/main.exe
+ mkdir -p dist && install -m 755 _build/x86/default/bin/main.exe dist/app
+
+build-arm64: $(CSS)
+ mkdir -p _build
+ APP_VERSION=$(VERSION) LINK_FLAGS='-ccopt -static' \
+ tools/arm64.sh $(DUNE) --build-dir $(CURDIR)/_build/arm64 ./bin/main.exe
+ mkdir -p dist && install -m 755 _build/arm64/default/bin/main.exe dist/app-linux-arm64
+ echo $(VERSION) > dist/app-linux-arm64.version
+
+test: $(CSS)
+ mkdir -p _build
+ tools/x86.sh dune test --root . --build-dir $(CURDIR)/_build/x86 --force
+
+clean:
+ rm -rf dist _build $(CSS)
+
+# Deletes both opam switches and both SQLite builds (every compiled
+# library) but keeps the opam repositories, opam's download cache and the
+# SQLite tarball in .cache/src, so the next build compiles every library
+# from its already-downloaded source.
+clean-deps: clean
+ rm -rf .cache/opam-arm64/default .cache/opam-x86/default .cache/sqlite-arm64 .cache/sqlite-x86
+
+bump:
+ sed -i 's/^let build_marker = "todomvc"$$/let build_marker = "todomvc-bumped"/' src/app.ml
+
+unbump:
+ sed -i 's/^let build_marker = "todomvc-bumped"$$/let build_marker = "todomvc"/' src/app.ml
+
+# Ships the version the binary was built with, which it reports at /api/version.
+deploy:
+ carlos deploy --account bfj --app todo-ocaml --target linux-arm64 --no-prompt \
+ --version "$$(cat dist/app-linux-arm64.version)" --message "$(MESSAGE)" dist/app-linux-arm64
diff --git a/apps/ocaml/README.md b/apps/ocaml/README.md
new file mode 100644
index 0000000..645e1dd
--- /dev/null
+++ b/apps/ocaml/README.md
@@ -0,0 +1,124 @@
+# 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`.
diff --git a/apps/ocaml/STATUS.md b/apps/ocaml/STATUS.md
new file mode 100644
index 0000000..4e9ea01
--- /dev/null
+++ b/apps/ocaml/STATUS.md
@@ -0,0 +1,50 @@
+LIVE https://todo-ocaml.bfj.oncarlos.com a24ca85
+
+## Checks
+
+- `make test`: 16 Alcotest tests pass (form parsing, Unicode trimming,
+ escaping, and the request handler against an in-memory database).
+- Shared suite against `dist/app --listen 127.0.0.1`: 67 passed, 3 skipped
+ (the two JS-only edit tests with JS off, and the screenshot with JS off).
+- `scripts/boot-check.sh dist/app-linux-arm64`: /healthz and /api/version
+ 200, clean exit on SIGTERM.
+- Deployed with `make deploy`; `/api/version` reports `a24ca85`.
+- Shared suite against https://todo-ocaml.bfj.oncarlos.com: 67 passed,
+ 3 skipped.
+
+## Review fixes (a24ca85)
+
+1. **Unbounded request heads.** `bin/main.ml` now runs cohttp-eio's
+ per-connection loop itself instead of `Cohttp_eio.Server.run` (which
+ hard-codes `max_size:max_int`). Each connection reads through a byte
+ budget: 64 KiB for a request line plus headers, then 1 MiB + 64 KiB
+ per request after that, with at most 256 connections. Checked locally:
+ a 10 MB header line and 100,000 small headers both get
+ `431 Request Header Fields Too Large`, and the process stays at about
+ 17 MB RSS.
+2. **Edits lost on re-render.** `app.js` keeps an open editor across a
+ response: the same item stays in edit mode with its unsaved text,
+ focus and selection, and losing focus during the swap doesn't count
+ as a save. Checked in Chromium with a toggle response held back while
+ another item was being edited.
+3. **Unicode whitespace.** `Form.trim` strips Unicode White_Space (plus
+ U+FEFF) and is used for both adding and editing. A title of only
+ non-breaking spaces adds nothing, and editing to one deletes the item.
+4. **SQLite from source.** `tools/sqlite.sh` downloads the SQLite 3.54.1
+ amalgamation once into `.cache/src` (checked against sqlite.org's
+ SHA3-256) and compiles it into `.cache/sqlite-<arch>`. The `sqlite3`
+ opam package finds it through `pkg-config`. `make clean-deps` deletes
+ those builds and keeps the tarball. The shipped binary contains SQLite
+ 3.54.1 (source id 2026-10-16), not the arm64 root's 3.46.1.
+
+## Notes
+
+- The shipped binary is static glibc (aarch64), built in the emulated
+ arm64 root with Debian's OCaml 5.3.0 as `ocaml-system`. The linker
+ warns about `getpwnam`, `gethostbyname` and the like (pulled in by
+ OCaml's `unix` library). The app never calls them.
+- `make clean-deps && make build-arm64` took 20.5 minutes here: SQLite
+ and every OCaml library compiled under qemu. After that, a rebuild of
+ the app alone takes about 15 seconds.
+- docs-t1 has no `pkg-config`, so the x86 build unpacks Debian's
+ `pkgconf` into `.cache/` with `apt-get download` (see README).
diff --git a/apps/ocaml/bin/dune b/apps/ocaml/bin/dune
new file mode 100644
index 0000000..646ba82
--- /dev/null
+++ b/apps/ocaml/bin/dune
@@ -0,0 +1,14 @@
+(executable
+ (name main)
+ (public_name todo-ocaml)
+ (package todo_ocaml)
+ (libraries todo eio eio_posix cohttp-eio http uri cstruct)
+ (link_flags (:standard (:include link_flags.sexp))))
+
+; LINK_FLAGS="-ccopt -static" links statically (the shipped arm64 build).
+(rule
+ (target link_flags.sexp)
+ (action
+ (with-stdout-to
+ %{target}
+ (echo "(%{env:LINK_FLAGS=})"))))
diff --git a/apps/ocaml/bin/main.ml b/apps/ocaml/bin/main.ml
new file mode 100644
index 0000000..4d3523e
--- /dev/null
+++ b/apps/ocaml/bin/main.ml
@@ -0,0 +1,147 @@
+(* todo-ocaml: <bin> --socket <path> --db <path>, or --listen 127.0.0.1:<port>
+ in place of --socket for local runs. *)
+
+let usage = "app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>"
+
+(* Bodies are form posts; 1 MiB is far more than any title needs. *)
+let max_body = 1 lsl 20
+
+(* The request line and headers of one request, together. *)
+let max_head = 64 * 1024
+
+(* Each connection costs at most about max_head + max_body of buffers. *)
+let max_connections = 256
+
+(* A socket that refuses to read more than [remaining] bytes. cohttp-eio
+ parses headers from an unbounded buffer, so every read goes through one
+ of these: a connection starts with max_head bytes, and each request it
+ carries gets max_body + max_head more (its body, then the next request's
+ head). Running out raises [Head_too_large]. *)
+module Budget = struct
+ exception Head_too_large
+
+ type t = { flow : Eio.Flow.source_ty Eio.Resource.t; mutable remaining : int }
+
+ let read_methods = []
+
+ let single_read t buf =
+ if t.remaining <= 0 then raise Head_too_large;
+ let buf = if Cstruct.length buf > t.remaining then Cstruct.sub buf 0 t.remaining else buf in
+ let n = Eio.Flow.single_read t.flow buf in
+ t.remaining <- t.remaining - n;
+ n
+
+ let handler = Eio.Flow.Pi.source (module struct
+ type nonrec t = t
+ let read_methods = read_methods
+ let single_read = single_read
+ end)
+
+ let source t : Eio.Flow.source_ty Eio.Resource.t = Eio.Resource.T (t, handler)
+
+ (* The budget of the connection whose request is being handled. *)
+ let key : t Eio.Fiber.key = Eio.Fiber.create_key ()
+
+ let next_request () =
+ Option.iter (fun t -> t.remaining <- max_body + max_head) (Eio.Fiber.get key)
+end
+
+let fail fmt = Printf.ksprintf (fun s -> prerr_endline ("app: " ^ s); exit 2) fmt
+
+let parse_listen s =
+ match String.rindex_opt s ':' with
+ | None -> fail "--listen wants 127.0.0.1:<port>, got %S" s
+ | Some i -> (
+ let host = String.sub s 0 i and port = String.sub s (i + 1) (String.length s - i - 1) in
+ match (host, int_of_string_opt port) with
+ | "127.0.0.1", Some port when port > 0 && port < 65536 ->
+ (`Tcp (Eio.Net.Ipaddr.V4.loopback, port), "http://" ^ s)
+ | "127.0.0.1", _ -> fail "bad port in --listen %S" s
+ | _ -> fail "--listen only binds 127.0.0.1, got %S" host)
+
+let to_app_request req body =
+ let headers = Http.Request.headers req in
+ let uri = Uri.of_string (Http.Request.resource req) in
+ {
+ Todo.App.meth = Http.Method.to_string (Http.Request.meth req);
+ path = Uri.path uri;
+ query = List.map (fun (k, v) -> (k, String.concat "," v)) (Uri.query uri);
+ header = Http.Header.get headers;
+ body;
+ }
+
+let callback app _conn req body =
+ Budget.next_request ();
+ let body =
+ match Http.Request.meth req with
+ | `POST -> Eio.Buf_read.(parse take_all) ~max_size:max_body body
+ | _ -> Ok ""
+ in
+ let (res : Todo.App.response) =
+ match body with
+ | Ok body -> Todo.App.handle app (to_app_request req body)
+ | Error _ -> Todo.App.text 413 "Request body too large\n"
+ in
+ let headers = Http.Header.of_list res.headers in
+ let status = Http.Status.of_int res.status in
+ match Http.Request.meth req with
+ | `HEAD ->
+ let headers = Http.Header.add headers "content-length" (string_of_int (String.length res.body)) in
+ Cohttp_eio.Server.respond ~headers ~status ~body:(Eio.Flow.string_source "") ()
+ | _ -> Cohttp_eio.Server.respond_string ~headers ~status ~body:res.body ()
+
+(* One connection: cohttp-eio's own loop over keep-alive requests, reading
+ through a Budget. A head too large for it gets 431 and the connection
+ closes. *)
+let connection server socket peer =
+ Eio.Switch.run @@ fun sw ->
+ let budget = { Budget.flow = (socket :> Eio.Flow.source_ty Eio.Resource.t); remaining = max_head } in
+ let input = Eio.Buf_read.of_flow ~max_size:max_head (Budget.source budget) in
+ Eio.Fiber.with_binding Budget.key budget @@ fun () ->
+ try
+ Eio.Buf_write.with_flow socket @@ fun output ->
+ try Cohttp_eio.Server.callback server (sw, peer) input output
+ with Budget.Head_too_large | Eio.Buf_read.Buffer_limit_exceeded ->
+ Eio.Buf_write.string output
+ "HTTP/1.1 431 Request Header Fields Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
+ with Eio.Io (Eio.Net.E (Connection_reset _), _) -> ()
+
+let () =
+ let socket = ref "" and listen = ref "" and db = ref "" in
+ Arg.parse
+ [ ("--socket", Arg.Set_string socket, "<path> unix socket to listen on");
+ ("--listen", Arg.Set_string listen, "<127.0.0.1:port> TCP address, for local runs");
+ ("--db", Arg.Set_string db, "<path> SQLite database file") ]
+ (fun a -> fail "unexpected argument %S" a)
+ usage;
+ if !db = "" || (!socket = "") = (!listen = "") then (prerr_endline ("usage: " ^ usage); exit 2);
+ let addr, origin =
+ if !listen <> "" then parse_listen !listen
+ else
+ ( `Unix !socket,
+ match Sys.getenv_opt "CARLOS_HOST" with
+ | Some h when h <> "" -> "https://" ^ h
+ | _ -> "http://localhost" )
+ in
+ let store = Todo.Store.open_ !db in
+ let app = Todo.App.create ~store ~origin ~version:Todo.Version.v in
+ (* SIGTERM and SIGINT stop the server; Condition.broadcast is signal-safe. *)
+ let stop = Eio.Condition.create () in
+ let on_signal _ = Eio.Condition.broadcast stop in
+ Sys.set_signal Sys.sigterm (Sys.Signal_handle on_signal);
+ Sys.set_signal Sys.sigint (Sys.Signal_handle on_signal);
+ Eio_posix.run (fun env ->
+ Eio.Switch.run (fun sw ->
+ (match addr with `Unix path when Sys.file_exists path -> Sys.remove path | _ -> ());
+ let sock = Eio.Net.listen ~sw ~backlog:128 ~reuse_addr:true (Eio.Stdenv.net env) addr in
+ Printf.eprintf "app: %s listening on %s\n%!" Todo.Version.v
+ (if !listen <> "" then !listen else !socket);
+ let server = Cohttp_eio.Server.make ~callback:(callback app) () in
+ Eio.Fiber.first
+ (fun () -> Eio.Condition.await_no_mutex stop)
+ (fun () ->
+ Eio.Net.run_server sock (connection server) ~max_connections ~on_error:(fun e ->
+ Printf.eprintf "app: %s\n%!" (Printexc.to_string e)))));
+ (match addr with `Unix path -> (try Sys.remove path with Sys_error _ -> ()) | _ -> ());
+ Todo.Store.close store;
+ prerr_endline "app: stopped"
diff --git a/apps/ocaml/dune-project b/apps/ocaml/dune-project
new file mode 100644
index 0000000..eaf42de
--- /dev/null
+++ b/apps/ocaml/dune-project
@@ -0,0 +1,24 @@
+(lang dune 3.17)
+
+(name todo_ocaml)
+
+(generate_opam_files true)
+
+(maintainers "Paul Campbell <paul@keymail.dev>")
+
+(authors "Paul Campbell")
+
+(license MIT)
+
+(package
+ (name todo_ocaml)
+ (synopsis "TodoMVC for the CARLOS bakeoff, server-rendered in OCaml")
+ (depends
+ (ocaml (= 5.3.0))
+ (eio (>= 1.2))
+ (eio_posix (>= 1.2))
+ (cohttp-eio (>= 6.0))
+ (http (>= 6.0))
+ (uri (>= 4.4))
+ (sqlite3 (and (>= 5.3) (< 5.4)))
+ (alcotest :with-test)))
diff --git a/apps/ocaml/gen/dune b/apps/ocaml/gen/dune
new file mode 100644
index 0000000..bf8ec1f
--- /dev/null
+++ b/apps/ocaml/gen/dune
@@ -0,0 +1,2 @@
+(executable
+ (name embed))
diff --git a/apps/ocaml/gen/embed.ml b/apps/ocaml/gen/embed.ml
new file mode 100644
index 0000000..2e3a106
--- /dev/null
+++ b/apps/ocaml/gen/embed.ml
@@ -0,0 +1,14 @@
+(* Prints an OCaml module with one string per file: static/base.css
+ becomes [let base_css = "..."]. *)
+
+let ident path =
+ String.map (function 'a' .. 'z' | '0' .. '9' as c -> c | _ -> '_')
+ (String.lowercase_ascii (Filename.basename path))
+
+let () =
+ Array.iteri
+ (fun i path ->
+ if i > 0 then
+ Printf.printf "let %s = %S\n" (ident path)
+ (In_channel.with_open_bin path In_channel.input_all))
+ Sys.argv
diff --git a/apps/ocaml/src/app.ml b/apps/ocaml/src/app.ml
new file mode 100644
index 0000000..12abd69
--- /dev/null
+++ b/apps/ocaml/src/app.ml
@@ -0,0 +1,167 @@
+(* Routes and actions, independent of the HTTP server: a request in, a
+ response out. bin/main.ml adapts this to cohttp-eio, and the tests call
+ it directly. *)
+
+type request = {
+ meth : string;
+ path : string;
+ query : (string * string) list;
+ header : string -> string option; (** by lower-case name *)
+ body : string;
+}
+
+type response = { status : int; headers : (string * string) list; body : string }
+
+type t = {
+ store : Store.t;
+ origin : string; (** this app's own origin, e.g. https://todo-ocaml.bfj.oncarlos.com *)
+ version : string;
+}
+
+(* `make bump` edits this constant so the next build must recompile this file. *)
+let build_marker = "todomvc"
+
+let create ~store ~origin ~version = { store; origin; version }
+
+let cookie_name = "todomvc_list"
+
+(* Kept for 400 days, the longest a browser will keep a cookie. *)
+let cookie_max_age = 400 * 24 * 60 * 60
+
+(* The inline script that marks the page as having JavaScript, and its hash
+ for the Content-Security-Policy. *)
+let csp =
+ "default-src 'self'; script-src 'self' 'sha256-qOhFsq0QMV2REqNwk5hGH5bZE9SkX6gt6yeyKdYPv+U='; \
+ img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
+
+let respond ?(headers = []) status body = { status; headers; body }
+let text ?(headers = []) status body =
+ respond status body ~headers:(("content-type", "text/plain; charset=utf-8") :: headers)
+
+let html ?(headers = []) status body =
+ respond status body
+ ~headers:
+ ([ ("content-type", "text/html; charset=utf-8");
+ ("cache-control", "private, no-cache");
+ ("content-security-policy", csp);
+ ("x-content-type-options", "nosniff");
+ ("referrer-policy", "same-origin") ]
+ @ headers)
+
+let is_token s =
+ String.length s = 32 && String.for_all (function '0' .. '9' | 'a' .. 'f' -> true | _ -> false) s
+
+(* The caller's list id. An unknown or garbled cookie gets a fresh list,
+ and the response sets the cookie for it. *)
+let visitor t req =
+ let known =
+ Option.bind (req.header "cookie") (fun h -> Form.cookie h cookie_name)
+ |> Option.map String.trim
+ |> Option.to_list
+ |> List.find_opt (fun id -> is_token id && Store.list_exists t.store id)
+ in
+ match known with
+ | Some id -> (id, [])
+ | None ->
+ let id = Rand.token () in
+ Store.create_list t.store id;
+ let secure = String.starts_with ~prefix:"https:" t.origin in
+ ( id,
+ [ ( "set-cookie",
+ Printf.sprintf "%s=%s; Max-Age=%d; Path=/; HttpOnly; SameSite=Lax%s" cookie_name id
+ cookie_max_age (if secure then "; Secure" else "") ) ] )
+
+let static name =
+ let asset body ctype =
+ respond 200 body
+ ~headers:[ ("content-type", ctype); ("cache-control", "public, max-age=86400");
+ ("x-content-type-options", "nosniff") ]
+ in
+ match name with
+ | "base.css" -> Some (asset Assets.base_css "text/css; charset=utf-8")
+ | "index.css" -> Some (asset Assets.index_css "text/css; charset=utf-8")
+ | "app.css" -> Some (asset Assets.app_css "text/css; charset=utf-8")
+ | "app.js" -> Some (asset Assets.app_js "text/javascript; charset=utf-8")
+ | _ -> None
+
+(* GET /, /active and /completed. *)
+let page t req filter =
+ let list, cookie = visitor t req in
+ let editing = Option.bind (List.assoc_opt "edit" req.query) int_of_string_opt in
+ html 200 ~headers:cookie (View.page ~version:t.version ~filter ?editing (Store.todos t.store ~list))
+
+type action =
+ | Add
+ | Toggle_all
+ | Clear_completed
+ | Toggle of int
+ | Edit of int
+ | Destroy of int
+
+let action_of_path path =
+ let id s =
+ if s <> "" && String.for_all (function '0' .. '9' -> true | _ -> false) s then int_of_string_opt s
+ else None
+ in
+ match String.split_on_char '/' path with
+ | [ ""; "todos" ] -> Some Add
+ | [ ""; "todos"; "toggle-all" ] -> Some Toggle_all
+ | [ ""; "todos"; "clear-completed" ] -> Some Clear_completed
+ | [ ""; "todos"; n; verb ] -> (
+ match (id n, verb) with
+ | Some n, "toggle" -> Some (Toggle n)
+ | Some n, "edit" -> Some (Edit n)
+ | Some n, "destroy" -> Some (Destroy n)
+ | _ -> None)
+ | _ -> None
+
+let perform store ~list fields = function
+ | Add ->
+ let title = Form.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) in
+ if title <> "" then Store.add store ~list title;
+ true
+ | Toggle_all -> Store.toggle_all store ~list; true
+ | Clear_completed -> Store.clear_completed store ~list; true
+ | Toggle id -> Store.toggle store ~list id
+ | Destroy id -> Store.delete store ~list id
+ | Edit id -> (
+ match Form.trim (Option.value ~default:"" (List.assoc_opt "title" fields)) with
+ | "" -> Store.delete store ~list id
+ | title -> Store.rename store ~list id title)
+
+(* Every action is a form POST answering 303 back to the filter. With
+ X-Fragment: todoapp (sent by app.js) it answers section.todoapp instead. *)
+let post t req action =
+ match req.header "origin" with
+ | Some o when o <> t.origin -> text 403 "Cross-origin request refused\n"
+ | _ when not (String.is_valid_utf_8 req.body) -> text 400 "Bad form body\n"
+ | _ ->
+ let fields = Form.parse req.body in
+ if not (List.for_all (fun (_, v) -> String.is_valid_utf_8 v) fields) then text 400 "Bad form body\n"
+ else
+ let list, cookie = visitor t req in
+ let filter = View.filter_of_string (Option.value ~default:"" (List.assoc_opt "filter" fields)) in
+ if not (perform t.store ~list fields action) then text 404 ~headers:cookie "No such todo\n"
+ else if req.header "x-fragment" = Some "todoapp" then
+ html 200 ~headers:cookie (View.fragment ~filter (Store.todos t.store ~list))
+ else respond 303 "" ~headers:(("location", View.filter_path filter) :: cookie)
+
+let handle t req =
+ let filter = function
+ | "/" -> Some View.All
+ | "/active" -> Some View.Active
+ | "/completed" -> Some View.Completed
+ | _ -> None
+ in
+ match (req.meth, req.path) with
+ | ("GET" | "HEAD"), "/healthz" -> text 200 "ok\n"
+ | ("GET" | "HEAD"), "/api/version" -> text 200 t.version
+ | ("GET" | "HEAD"), p when String.starts_with ~prefix:"/static/" p -> (
+ match static (String.sub p 8 (String.length p - 8)) with
+ | Some r -> r
+ | None -> text 404 "Not found\n")
+ | ("GET" | "HEAD"), p when filter p <> None -> page t req (Option.get (filter p))
+ | "POST", p when action_of_path p <> None -> post t req (Option.get (action_of_path p))
+ | _, p when filter p <> None || action_of_path p <> None ->
+ text 405 "Method not allowed\n" ~headers:[ ("allow", if filter p <> None then "GET, HEAD" else "POST") ]
+ | _ -> text 404 "Not found\n"
diff --git a/apps/ocaml/src/dune b/apps/ocaml/src/dune
new file mode 100644
index 0000000..1712e66
--- /dev/null
+++ b/apps/ocaml/src/dune
@@ -0,0 +1,28 @@
+(library
+ (name todo)
+ (libraries sqlite3 uri)
+ (foreign_stubs
+ (language c)
+ (names rand_stubs)))
+
+; The CSS and JS are compiled into the binary.
+(rule
+ (target assets.ml)
+ (deps
+ (:files
+ %{project_root}/static/base.css
+ %{project_root}/static/index.css
+ %{project_root}/static/app.css
+ %{project_root}/static/app.js))
+ (action
+ (with-stdout-to
+ %{target}
+ (run %{exe:../gen/embed.exe} %{files}))))
+
+; The build version, from APP_VERSION (the Makefile passes the git short SHA).
+(rule
+ (target version.ml)
+ (action
+ (with-stdout-to
+ %{target}
+ (echo "let v = \"%{env:APP_VERSION=dev}\"\n"))))
diff --git a/apps/ocaml/src/form.ml b/apps/ocaml/src/form.ml
new file mode 100644
index 0000000..d828438
--- /dev/null
+++ b/apps/ocaml/src/form.ml
@@ -0,0 +1,71 @@
+(* application/x-www-form-urlencoded bodies and Cookie headers. *)
+
+let hex c =
+ match c with
+ | '0' .. '9' -> Some (Char.code c - 48)
+ | 'a' .. 'f' -> Some (Char.code c - 87)
+ | 'A' .. 'F' -> Some (Char.code c - 55)
+ | _ -> None
+
+(* Decodes '+' as a space and %XX as a byte. A malformed escape is kept
+ as it is, the way browsers treat it. *)
+let decode s =
+ let n = String.length s in
+ let b = Buffer.create n in
+ let rec go i =
+ if i < n then
+ match s.[i] with
+ | '+' -> Buffer.add_char b ' '; go (i + 1)
+ | '%' when i + 2 < n -> (
+ match (hex s.[i + 1], hex s.[i + 2]) with
+ | Some h, Some l -> Buffer.add_char b (Char.chr ((h lsl 4) lor l)); go (i + 3)
+ | _ -> Buffer.add_char b '%'; go (i + 1))
+ | c -> Buffer.add_char b c; go (i + 1)
+ in
+ go 0;
+ Buffer.contents b
+
+let parse body =
+ String.split_on_char '&' body
+ |> List.filter_map (fun pair ->
+ if pair = "" then None
+ else
+ match String.index_opt pair '=' with
+ | Some i ->
+ Some (decode (String.sub pair 0 i),
+ decode (String.sub pair (i + 1) (String.length pair - i - 1)))
+ | None -> Some (decode pair, ""))
+
+(* The value of the first cookie called [name] in a Cookie header. *)
+let cookie header name =
+ String.split_on_char ';' header
+ |> List.find_map (fun part ->
+ match String.index_opt part '=' with
+ | Some i when String.trim (String.sub part 0 i) = name ->
+ Some (String.trim (String.sub part (i + 1) (String.length part - i - 1)))
+ | _ -> None)
+
+(* Unicode White_Space, plus U+FEFF (byte order mark), the set JavaScript's
+ String.prototype.trim removes. *)
+let is_space u =
+ match Uchar.to_int u with
+ | 0x09 | 0x0A | 0x0B | 0x0C | 0x0D | 0x20 | 0x85 | 0xA0 | 0x1680 | 0x2028 | 0x2029 | 0x202F
+ | 0x205F | 0x3000 | 0xFEFF ->
+ true
+ | n -> n >= 0x2000 && n <= 0x200A
+
+(* Removes leading and trailing whitespace by that definition, so a title
+ of only non-breaking spaces trims to nothing. [s] is valid UTF-8. *)
+let trim s =
+ let n = String.length s in
+ let rec go i first last =
+ if i >= n then (first, last)
+ else
+ let d = String.get_utf_8_uchar s i in
+ let next = i + Uchar.utf_decode_length d in
+ if is_space (Uchar.utf_decode_uchar d) then go next first last
+ else go next (if first < 0 then i else first) next
+ in
+ match go 0 (-1) 0 with
+ | -1, _ -> ""
+ | first, last -> String.sub s first (last - first)
diff --git a/apps/ocaml/src/rand.ml b/apps/ocaml/src/rand.ml
new file mode 100644
index 0000000..8c75d7a
--- /dev/null
+++ b/apps/ocaml/src/rand.ml
@@ -0,0 +1,6 @@
+external bytes : int -> string = "todo_getrandom"
+
+(* 128 random bits as 32 lowercase hex digits. *)
+let token () =
+ let b = bytes 16 in
+ String.concat "" (List.init 16 (fun i -> Printf.sprintf "%02x" (Char.code b.[i])))
diff --git a/apps/ocaml/src/rand_stubs.c b/apps/ocaml/src/rand_stubs.c
new file mode 100644
index 0000000..930f14f
--- /dev/null
+++ b/apps/ocaml/src/rand_stubs.c
@@ -0,0 +1,26 @@
+/* Random bytes straight from the kernel, so the app needs no /dev/urandom
+ inside its sandbox. */
+#include <errno.h>
+#include <string.h>
+#include <sys/random.h>
+#include <caml/alloc.h>
+#include <caml/fail.h>
+#include <caml/memory.h>
+#include <caml/mlvalues.h>
+
+CAMLprim value todo_getrandom(value len)
+{
+ CAMLparam1(len);
+ CAMLlocal1(buf);
+ size_t n = Long_val(len), got = 0;
+ buf = caml_alloc_string(n);
+ while (got < n) {
+ ssize_t r = getrandom((char *)Bytes_val(buf) + got, n - got, 0);
+ if (r < 0) {
+ if (errno == EINTR) continue;
+ caml_failwith(strerror(errno));
+ }
+ got += r;
+ }
+ CAMLreturn(buf);
+}
diff --git a/apps/ocaml/src/store.ml b/apps/ocaml/src/store.ml
new file mode 100644
index 0000000..4c6be07
--- /dev/null
+++ b/apps/ocaml/src/store.ml
@@ -0,0 +1,117 @@
+(* The SQLite store: one file, WAL mode, every todo scoped to a list. *)
+
+type todo = { id : int; title : string; completed : bool }
+type t = { db : Sqlite3.db; stmts : (string, Sqlite3.stmt) Hashtbl.t }
+
+(* Each entry runs once, in order, and is recorded in user_version.
+ Migrations only ever add: never edit or remove an entry. *)
+let migrations =
+ [
+ {|CREATE TABLE lists (
+ id TEXT PRIMARY KEY,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+ ) STRICT;
+ CREATE TABLE todos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ list_id TEXT NOT NULL REFERENCES lists (id),
+ title TEXT NOT NULL,
+ completed INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+ ) STRICT;
+ CREATE INDEX todos_by_list ON todos (list_id, id);|};
+ ]
+
+let exec db sql =
+ match Sqlite3.exec db sql with
+ | Sqlite3.Rc.OK -> ()
+ | rc -> failwith (Printf.sprintf "sqlite: %s: %s" (Sqlite3.Rc.to_string rc) (Sqlite3.errmsg db))
+
+let user_version db =
+ let v = ref 0 in
+ ignore (Sqlite3.exec_no_headers db "PRAGMA user_version" ~cb:(fun row ->
+ v := Option.fold ~none:0 ~some:int_of_string row.(0)));
+ !v
+
+let migrate db =
+ List.iteri
+ (fun i sql ->
+ if i >= user_version db then begin
+ exec db "BEGIN IMMEDIATE";
+ exec db sql;
+ exec db (Printf.sprintf "PRAGMA user_version = %d" (i + 1));
+ exec db "COMMIT"
+ end)
+ migrations
+
+let open_ path =
+ let db = Sqlite3.db_open path in
+ exec db "PRAGMA journal_mode = WAL";
+ exec db "PRAGMA synchronous = NORMAL";
+ exec db "PRAGMA busy_timeout = 5000";
+ exec db "PRAGMA foreign_keys = ON";
+ migrate db;
+ { db; stmts = Hashtbl.create 16 }
+
+let close t =
+ Hashtbl.iter (fun _ s -> ignore (Sqlite3.finalize s)) t.stmts;
+ Hashtbl.reset t.stmts;
+ ignore (Sqlite3.db_close t.db)
+
+(* Prepares [sql] once, binds [params], and calls [row] for each result. *)
+let query t sql params ~row =
+ let stmt =
+ match Hashtbl.find_opt t.stmts sql with
+ | Some s -> s
+ | None ->
+ let s = Sqlite3.prepare t.db sql in
+ Hashtbl.add t.stmts sql s;
+ s
+ in
+ Fun.protect ~finally:(fun () -> ignore (Sqlite3.reset stmt)) @@ fun () ->
+ Sqlite3.Rc.check (Sqlite3.bind_values stmt params);
+ let rec loop acc =
+ match Sqlite3.step stmt with
+ | Sqlite3.Rc.ROW -> loop (row stmt :: acc)
+ | Sqlite3.Rc.DONE -> List.rev acc
+ | rc -> failwith (Printf.sprintf "sqlite: %s: %s" (Sqlite3.Rc.to_string rc) (Sqlite3.errmsg t.db))
+ in
+ loop []
+
+(* Runs a statement and answers whether it changed any row. *)
+let change t sql params =
+ ignore (query t sql params ~row:ignore);
+ Sqlite3.changes t.db > 0
+
+let text s = Sqlite3.Data.TEXT s
+let int i = Sqlite3.Data.INT (Int64.of_int i)
+
+let list_exists t id = query t "SELECT 1 FROM lists WHERE id = ?" [ text id ] ~row:ignore <> []
+let create_list t id = ignore (change t "INSERT INTO lists (id) VALUES (?)" [ text id ])
+
+let todos t ~list =
+ query t "SELECT id, title, completed FROM todos WHERE list_id = ? ORDER BY id" [ text list ]
+ ~row:(fun s ->
+ { id = Sqlite3.column_int s 0; title = Sqlite3.column_text s 1; completed = Sqlite3.column_bool s 2 })
+
+let add t ~list title =
+ ignore (change t "INSERT INTO todos (list_id, title) VALUES (?, ?)" [ text list; text title ])
+
+let toggle t ~list id =
+ change t "UPDATE todos SET completed = NOT completed WHERE id = ? AND list_id = ?" [ int id; text list ]
+
+let rename t ~list id title =
+ change t "UPDATE todos SET title = ? WHERE id = ? AND list_id = ?" [ text title; int id; text list ]
+
+let delete t ~list id = change t "DELETE FROM todos WHERE id = ? AND list_id = ?" [ int id; text list ]
+
+(* Completes every item, or reopens them all if every one already was. *)
+let toggle_all t ~list =
+ ignore
+ (change t
+ {|UPDATE todos SET completed =
+ EXISTS (SELECT 1 FROM todos WHERE list_id = ?1 AND NOT completed)
+ WHERE list_id = ?1|}
+ [ text list ])
+
+let clear_completed t ~list =
+ ignore (change t "DELETE FROM todos WHERE list_id = ? AND completed" [ text list ])
diff --git a/apps/ocaml/src/view.ml b/apps/ocaml/src/view.ml
new file mode 100644
index 0000000..f500bf5
--- /dev/null
+++ b/apps/ocaml/src/view.ml
@@ -0,0 +1,123 @@
+(* The TodoMVC page, rendered from shared/reference.html. The no-JS controls
+ carry the class "nojs"; app.css hides them once app.js has loaded. *)
+
+type filter = All | Active | Completed
+
+let filter_of_string = function "active" -> Active | "completed" -> Completed | _ -> All
+let filter_name = function All -> "all" | Active -> "active" | Completed -> "completed"
+let filter_path = function All -> "/" | Active -> "/active" | Completed -> "/completed"
+
+let shows filter (t : Store.todo) =
+ match filter with All -> true | Active -> not t.completed | Completed -> t.completed
+
+let escape s =
+ let b = Buffer.create (String.length s + 16) in
+ String.iter
+ (function
+ | '&' -> Buffer.add_string b "&amp;"
+ | '<' -> Buffer.add_string b "&lt;"
+ | '>' -> Buffer.add_string b "&gt;"
+ | '"' -> Buffer.add_string b "&quot;"
+ | '\'' -> Buffer.add_string b "&#39;"
+ | c -> Buffer.add_char b c)
+ s;
+ Buffer.contents b
+
+(* A hidden form that a control elsewhere submits with form="<id>". *)
+let action_form b ~id ~action filter =
+ Printf.bprintf b
+ {|<form id="%s" method="post" action="%s" hidden><input type="hidden" name="filter" value="%s"></form>|}
+ id action (filter_name filter)
+
+let item b filter ~editing (t : Store.todo) =
+ let title = escape t.title in
+ let classes = List.filter_map Fun.id
+ [ (if t.completed then Some "completed" else None); (if editing then Some "editing" else None) ] in
+ Printf.bprintf b {|<li data-id="%d"%s><div class="view">|} t.id
+ (if classes = [] then "" else Printf.sprintf {| class="%s"|} (String.concat " " classes));
+ Printf.bprintf b {|<input class="toggle" type="checkbox" form="toggle-%d"%s><label>%s</label>|}
+ t.id (if t.completed then " checked" else "") title;
+ Printf.bprintf b {|<button class="destroy" form="destroy-%d" aria-label="Delete"></button>|} t.id;
+ Printf.bprintf b
+ {|<button class="nojs toggle-button" data-action="toggle" form="toggle-%d">%s</button>|}
+ t.id (if t.completed then "Mark as active" else "Mark as completed");
+ Printf.bprintf b {|<a class="nojs edit-link" data-action="edit" href="%s?edit=%d">Edit</a></div>|}
+ (filter_path filter) t.id;
+ Printf.bprintf b
+ {|<form class="edit-form" method="post" action="/todos/%d/edit"><input type="hidden" name="filter" value="%s"><input class="edit" name="title" value="%s" autocomplete="off"%s></form>|}
+ t.id (filter_name filter) title (if editing then " autofocus" else "");
+ action_form b ~id:(Printf.sprintf "toggle-%d" t.id) ~action:(Printf.sprintf "/todos/%d/toggle" t.id) filter;
+ action_form b ~id:(Printf.sprintf "destroy-%d" t.id) ~action:(Printf.sprintf "/todos/%d/destroy" t.id) filter;
+ Buffer.add_string b "</li>"
+
+(* section.todoapp: the part app.js swaps after each action. *)
+let todoapp b ~filter ?editing (todos : Store.todo list) =
+ let left = List.length (List.filter (fun (t : Store.todo) -> not t.completed) todos) in
+ let done_ = List.length todos - left in
+ Printf.bprintf b
+ {|<section class="todoapp"><header class="header"><h1>todos</h1><form method="post" action="/todos"><input type="hidden" name="filter" value="%s"><input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"%s></form></header>|}
+ (filter_name filter) (if editing = None then " autofocus" else "");
+ if todos <> [] then begin
+ Buffer.add_string b {|<section class="main">|};
+ action_form b ~id:"toggle-all-form" ~action:"/todos/toggle-all" filter;
+ Printf.bprintf b
+ {|<input id="toggle-all" class="toggle-all" type="checkbox" form="toggle-all-form"%s><label for="toggle-all">Mark all as complete</label>|}
+ (if left = 0 then " checked" else "");
+ Buffer.add_string b
+ {|<button class="nojs toggle-all-button" data-action="toggle-all" form="toggle-all-form">Mark all as complete</button><ul class="todo-list">|};
+ List.iter
+ (fun (t : Store.todo) -> if shows filter t then item b filter ~editing:(editing = Some t.id) t)
+ todos;
+ Buffer.add_string b {|</ul></section><footer class="footer">|};
+ Printf.bprintf b {|<span class="todo-count"><strong>%d</strong> %s left</span><ul class="filters">|}
+ left (if left = 1 then "item" else "items");
+ List.iter
+ (fun (f, label) ->
+ Printf.bprintf b {|<li><a%s href="%s">%s</a></li>|}
+ (if f = filter then {| class="selected"|} else "") (filter_path f) label)
+ [ (All, "All"); (Active, "Active"); (Completed, "Completed") ];
+ Buffer.add_string b "</ul>";
+ if done_ > 0 then begin
+ action_form b ~id:"clear-completed-form" ~action:"/todos/clear-completed" filter;
+ Buffer.add_string b
+ {|<button class="clear-completed" form="clear-completed-form">Clear completed</button>|}
+ end;
+ Buffer.add_string b "</footer>"
+ end;
+ Buffer.add_string b "</section>"
+
+let fragment ~filter todos =
+ let b = Buffer.create 4096 in
+ todoapp b ~filter todos;
+ Buffer.contents b
+
+let page ~version ~filter ?editing todos =
+ let b = Buffer.create 8192 in
+ Printf.bprintf b
+ {|<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>TodoMVC</title>
+<script>document.documentElement.classList.add("js")</script>
+<link rel="stylesheet" href="/static/base.css?v=%s">
+<link rel="stylesheet" href="/static/index.css?v=%s">
+<link rel="stylesheet" href="/static/app.css?v=%s">
+<script type="module" src="/static/app.js?v=%s"></script>
+</head>
+<body>
+|}
+ version version version version;
+ todoapp b ~filter ?editing todos;
+ Buffer.add_string b
+ {|
+<footer class="info">
+<p>Double-click to edit a todo</p>
+<p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · OCaml</p>
+<p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+</footer>
+</body>
+</html>
+|};
+ Buffer.contents b
diff --git a/apps/ocaml/static/app.css b/apps/ocaml/static/app.css
new file mode 100644
index 0000000..4046ead
--- /dev/null
+++ b/apps/ocaml/static/app.css
@@ -0,0 +1,62 @@
+/* Controls for visitors without JavaScript. An inline script puts the
+ class "js" on <html> before the page paints, which hides them, so with
+ JavaScript on the page is TodoMVC's own. */
+.js .nojs {
+ display: none !important;
+}
+
+/* The toggle buttons lie, invisible, over the circle and the chevron they
+ stand for, so the page looks the same either way. */
+.todo-list li .toggle-button,
+.toggle-all-button {
+ position: absolute;
+ z-index: 1;
+ font-size: 0;
+ color: transparent;
+ cursor: pointer;
+}
+
+.todo-list li .toggle-button {
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 50px;
+}
+
+.toggle-all-button {
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+}
+
+.todo-list li .toggle-button:focus-visible,
+.toggle-all-button:focus-visible {
+ outline: 2px solid #b83f45;
+ outline-offset: -6px;
+}
+
+/* Without double-click, each item gets an Edit link beside the delete
+ button. */
+html:not(.js) .todo-list li label {
+ padding-right: 100px;
+}
+
+.todo-list li .edit-link {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 55px;
+ height: 20px;
+ margin: auto 0;
+ font-size: 14px;
+ line-height: 20px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.todo-list li .edit-link:hover,
+.todo-list li .edit-link:focus {
+ color: #484848;
+ text-decoration: underline;
+}
diff --git a/apps/ocaml/static/app.js b/apps/ocaml/static/app.js
new file mode 100644
index 0000000..78bcbda
--- /dev/null
+++ b/apps/ocaml/static/app.js
@@ -0,0 +1,115 @@
+// With JavaScript on, the page behaves like TodoMVC's own apps. Every
+// change still goes through the same form POSTs as without JavaScript,
+// sent with X-Fragment: todoapp so the server answers with its rendering
+// of section.todoapp; the list and footer are swapped in from that. The
+// server is the only place the list lives.
+
+const app = document.querySelector('.todoapp');
+
+// Requests run one at a time, so responses can't land out of order.
+let queue = Promise.resolve();
+
+function post(form) {
+ const body = new URLSearchParams(new FormData(form));
+ queue = queue
+ .then(async () => {
+ const res = await fetch(form.action, {
+ method: 'POST',
+ body,
+ headers: { 'X-Fragment': 'todoapp' },
+ });
+ if (!res.ok) throw new Error(`${res.status} from ${form.action}`);
+ render(await res.text());
+ })
+ .catch((err) => {
+ console.error(err);
+ location.reload();
+ });
+}
+
+// True while render() swaps the list, so that losing focus to the swap
+// doesn't count as finishing an edit.
+let swapping = false;
+
+// Replaces section.main and footer.footer with the server's; either may be
+// absent when the list is empty. The header, and what's typed in it, stays,
+// and so does an edit in progress: the item being edited stays open in the
+// new list, with its unsaved text, focus and cursor.
+function render(html) {
+ const next = new DOMParser().parseFromString(html, 'text/html').querySelector('.todoapp');
+ const editing = app.querySelector('.todo-list li.editing');
+ const input = editing?.querySelector('.edit');
+ const focused = input && document.activeElement === input;
+ const [start, end] = input ? [input.selectionStart, input.selectionEnd] : [];
+
+ swapping = true;
+ try {
+ for (const part of ['.main', '.footer']) app.querySelector(`:scope > ${part}`)?.remove();
+ for (const part of ['.main', '.footer']) {
+ const el = next.querySelector(`:scope > ${part}`);
+ if (el) app.append(el);
+ }
+ } finally {
+ swapping = false;
+ }
+
+ const li = editing && app.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`);
+ if (!li) return;
+ li.classList.add('editing');
+ const fresh = li.querySelector('.edit');
+ fresh.value = input.value;
+ if (focused) {
+ fresh.focus();
+ fresh.setSelectionRange(start, end);
+ }
+}
+
+function startEditing(li) {
+ li.classList.add('editing');
+ const input = li.querySelector('.edit');
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+}
+
+function save(li) {
+ if (!li.classList.contains('editing')) return;
+ li.classList.remove('editing');
+ const input = li.querySelector('.edit');
+ li.querySelector('label').textContent = input.value.trim();
+ post(input.form);
+}
+
+function cancel(li) {
+ const input = li.querySelector('.edit');
+ input.value = input.defaultValue;
+ li.classList.remove('editing');
+}
+
+app.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const form = e.target;
+ if (form.classList.contains('edit-form')) {
+ save(form.closest('li'));
+ } else {
+ post(form);
+ const input = form.querySelector('.new-todo');
+ if (input) input.value = '';
+ }
+});
+
+app.addEventListener('change', (e) => {
+ if (e.target.matches('.toggle, .toggle-all')) post(e.target.form);
+});
+
+app.addEventListener('dblclick', (e) => {
+ const label = e.target.closest('.todo-list label');
+ if (label) startEditing(label.closest('li'));
+});
+
+app.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && e.target.matches('.edit')) cancel(e.target.closest('li'));
+});
+
+app.addEventListener('focusout', (e) => {
+ if (!swapping && e.target.matches('.edit')) save(e.target.closest('li'));
+});
diff --git a/apps/ocaml/test/dune b/apps/ocaml/test/dune
new file mode 100644
index 0000000..50e30f5
--- /dev/null
+++ b/apps/ocaml/test/dune
@@ -0,0 +1,4 @@
+(test
+ (name test_todo)
+ (libraries todo alcotest)
+ (deps %{project_root}/static/base.css %{project_root}/static/index.css))
diff --git a/apps/ocaml/test/test_todo.ml b/apps/ocaml/test/test_todo.ml
new file mode 100644
index 0000000..b02d6b5
--- /dev/null
+++ b/apps/ocaml/test/test_todo.ml
@@ -0,0 +1,235 @@
+open Todo
+
+let contains s sub =
+ let n = String.length sub in
+ let rec go i = i + n <= String.length s && (String.sub s i n = sub || go (i + 1)) in
+ go 0
+
+let count s sub =
+ let n = String.length sub in
+ let rec go i acc =
+ if i + n > String.length s then acc
+ else if String.sub s i n = sub then go (i + n) (acc + 1)
+ else go (i + 1) acc
+ in
+ go 0 0
+
+(* --- Form ---------------------------------------------------------------- *)
+
+let form_decoding () =
+ Alcotest.(check (list (pair string string)))
+ "plus, escapes, utf-8, empty, bare key"
+ [ ("title", "buy milk & eggs"); ("filter", "active"); ("u", "naïve 🚀"); ("e", ""); ("k", "") ]
+ (Form.parse "title=buy+milk+%26+eggs&filter=active&u=na%C3%AFve%20%F0%9F%9A%80&e=&k");
+ Alcotest.(check string) "malformed escapes are kept" "100%" (Form.decode "100%");
+ Alcotest.(check string) "bad hex is kept" "%zz" (Form.decode "%zz")
+
+let trimming () =
+ Alcotest.(check string) "ascii" "a b" (Form.trim " \t a b \n");
+ Alcotest.(check string) "nbsp, ideographic, em space, bom" "naïve 🚀"
+ (Form.trim "\u{00A0}\u{3000}\u{2003}\u{FEFF}naïve 🚀\u{00A0}\u{202F}");
+ Alcotest.(check string) "only nbsp" "" (Form.trim "\u{00A0}\u{00A0}");
+ Alcotest.(check string) "inner kept" "a\u{00A0}b" (Form.trim "a\u{00A0}b");
+ Alcotest.(check string) "empty" "" (Form.trim "")
+
+let cookies () =
+ Alcotest.(check (option string)) "finds by name" (Some "abc")
+ (Form.cookie "a=1; todomvc_list=abc; b=2" "todomvc_list");
+ Alcotest.(check (option string)) "missing" None (Form.cookie "a=1" "todomvc_list");
+ Alcotest.(check (option string)) "garbled" None (Form.cookie "%%%" "todomvc_list")
+
+let escaping () =
+ Alcotest.(check string) "html" "&lt;img src=x&gt; &amp; &quot;q&quot; &#39;"
+ (View.escape {|<img src=x> & "q" '|})
+
+(* --- App ----------------------------------------------------------------- *)
+
+let origin = "http://127.0.0.1:8080"
+
+let fresh () = App.create ~store:(Store.open_ ":memory:") ~origin ~version:"test-version"
+
+let request ?(meth = "GET") ?(headers = []) ?(body = "") ?cookie target =
+ let uri = Uri.of_string target in
+ let headers = match cookie with Some c -> ("cookie", "todomvc_list=" ^ c) :: headers | None -> headers in
+ { App.meth; path = Uri.path uri;
+ query = List.map (fun (k, v) -> (k, String.concat "," v)) (Uri.query uri);
+ header = (fun h -> List.assoc_opt h headers); body }
+
+let header (r : App.response) name = List.assoc_opt name r.headers
+
+let set_cookie (r : App.response) =
+ match header r "set-cookie" with
+ | Some c -> String.sub c (String.length "todomvc_list=") 32
+ | None -> Alcotest.fail "no set-cookie"
+
+(* A visitor: their cookie, from a first GET. *)
+let visit app = set_cookie (App.handle app (request "/"))
+
+let post app cookie ?headers target body =
+ App.handle app (request ~meth:"POST" ?headers ~cookie ~body target)
+
+let page app cookie target = (App.handle app (request ~cookie target)).body
+
+let first_cookie () =
+ let app = fresh () in
+ let r = App.handle app (request "/") in
+ Alcotest.(check int) "status" 200 r.status;
+ let c = Option.get (header r "set-cookie") in
+ Alcotest.(check bool) "attributes" true
+ (contains c "; Path=/" && contains c "; HttpOnly" && contains c "; SameSite=Lax"
+ && not (contains c "Secure"));
+ Alcotest.(check bool) "empty list hides main and footer" false
+ (contains r.body {|class="main"|} || contains r.body {|class="footer"|});
+ let again = App.handle app (request ~cookie:(set_cookie r) "/") in
+ Alcotest.(check (option string)) "known cookie kept" None (header again "set-cookie")
+
+let secure_cookie () =
+ let app = App.create ~store:(Store.open_ ":memory:") ~origin:"https://x.example" ~version:"v" in
+ let c = Option.get (header (App.handle app (request "/")) "set-cookie") in
+ Alcotest.(check bool) "Secure over https" true (contains c "; Secure")
+
+let garbled_cookie () =
+ let app = fresh () in
+ List.iter
+ (fun bad ->
+ let r = App.handle app (request ~cookie:bad "/") in
+ Alcotest.(check int) "status" 200 r.status;
+ Alcotest.(check bool) "fresh cookie" true (header r "set-cookie" <> None))
+ [ "%%%"; ""; String.make 32 'a'; "not-a-token" ]
+
+let add_and_redirect () =
+ let app = fresh () in
+ let c = visit app in
+ let r = post app c "/todos" "title=++buy+milk++&filter=active" in
+ Alcotest.(check int) "303" 303 r.status;
+ Alcotest.(check (option string)) "back to filter" (Some "/active") (header r "location");
+ let r = post app c "/todos" "title=x&filter=bogus" in
+ Alcotest.(check (option string)) "unknown filter" (Some "/") (header r "location");
+ let r = post app c "/todos" "title=y" in
+ Alcotest.(check (option string)) "missing filter" (Some "/") (header r "location");
+ ignore (post app c "/todos" "title=+++&filter=all");
+ ignore (post app c "/todos" "title=%C2%A0%C2%A0&filter=all");
+ let body = page app c "/" in
+ Alcotest.(check int) "three items, blank skipped" 3 (count body "<li data-id=");
+ Alcotest.(check bool) "trimmed" true (contains body "<label>buy milk</label>");
+ Alcotest.(check bool) "counter" true (contains body "<strong>3</strong> items left")
+
+let escapes_titles () =
+ let app = fresh () in
+ let c = visit app in
+ ignore (post app c "/todos" "title=%3Cimg+src%3Dx+onerror%3Dalert(1)%3E&filter=all");
+ let body = page app c "/" in
+ Alcotest.(check bool) "escaped" true (contains body "&lt;img src=x onerror=alert(1)&gt;");
+ Alcotest.(check bool) "no tag" false (contains body "<img")
+
+let toggle_and_filters () =
+ let app = fresh () in
+ let c = visit app in
+ ignore (post app c "/todos" "title=a");
+ ignore (post app c "/todos" "title=b");
+ ignore (post app c "/todos/1/toggle" "filter=all");
+ let all = page app c "/" in
+ Alcotest.(check bool) "completed class" true (contains all {|<li data-id="1" class="completed">|});
+ Alcotest.(check bool) "singular counter" true (contains all "<strong>1</strong> item left");
+ Alcotest.(check bool) "clear completed shown" true (contains all "clear-completed");
+ Alcotest.(check bool) "all selected" true (contains all {|<a class="selected" href="/">All</a>|});
+ let active = page app c "/active" in
+ Alcotest.(check bool) "active hides done" false (contains active {|data-id="1"|});
+ Alcotest.(check bool) "active selected" true (contains active {|<a class="selected" href="/active">|});
+ let completed = page app c "/completed" in
+ Alcotest.(check bool) "completed only" true
+ (contains completed {|data-id="1"|} && not (contains completed {|data-id="2"|}))
+
+let toggle_all_and_clear () =
+ let app = fresh () in
+ let c = visit app in
+ List.iter (fun t -> ignore (post app c "/todos" ("title=" ^ t))) [ "a"; "b"; "c" ];
+ ignore (post app c "/todos/2/toggle" "");
+ ignore (post app c "/todos/toggle-all" "filter=all");
+ let body = page app c "/" in
+ Alcotest.(check int) "all completed" 3 (count body {|class="completed"|});
+ Alcotest.(check bool) "toggle-all checked" true (contains body {|form="toggle-all-form" checked|});
+ ignore (post app c "/todos/toggle-all" "filter=all");
+ Alcotest.(check int) "all active again" 0 (count (page app c "/") {|class="completed"|});
+ ignore (post app c "/todos/1/toggle" "");
+ ignore (post app c "/todos/clear-completed" "filter=completed");
+ let body = page app c "/" in
+ Alcotest.(check int) "one cleared" 2 (count body "<li data-id=");
+ Alcotest.(check bool) "clear hidden" false (contains body "clear-completed")
+
+let edit_and_destroy () =
+ let app = fresh () in
+ let c = visit app in
+ ignore (post app c "/todos" "title=a");
+ ignore (post app c "/todos" "title=b");
+ let editing = page app c "/?edit=2" in
+ Alcotest.(check bool) "editing class" true (contains editing {|<li data-id="2" class="editing">|});
+ Alcotest.(check bool) "edit form" true (contains editing {|action="/todos/2/edit"|});
+ ignore (post app c "/todos/2/edit" "title=++renamed++&filter=all");
+ Alcotest.(check bool) "renamed" true (contains (page app c "/") "<label>renamed</label>");
+ ignore (post app c "/todos/2/edit" "title=%C2%A0%E3%80%80&filter=all");
+ Alcotest.(check int) "empty edit deletes" 1 (count (page app c "/") "<li data-id=");
+ ignore (post app c "/todos/1/destroy" "filter=all");
+ Alcotest.(check int) "destroyed" 0 (count (page app c "/") "<li data-id=")
+
+let isolation () =
+ let app = fresh () in
+ let mine = visit app and theirs = visit app in
+ ignore (post app mine "/todos" "title=mine");
+ Alcotest.(check int) "not in theirs" 0 (count (page app theirs "/") "<li data-id=");
+ List.iter
+ (fun target ->
+ Alcotest.(check int) target 404 (post app theirs target "title=x&filter=all").status)
+ [ "/todos/1/toggle"; "/todos/1/edit"; "/todos/1/destroy"; "/todos/99/toggle"; "/todos/x/toggle" ];
+ let body = page app mine "/" in
+ Alcotest.(check bool) "unchanged" true
+ (contains body "<label>mine</label>" && not (contains body {|class="completed"|}))
+
+let origins () =
+ let app = fresh () in
+ let c = visit app in
+ let r = post app c ~headers:[ ("origin", "https://evil.example") ] "/todos" "title=evil" in
+ Alcotest.(check int) "cross-site refused" 403 r.status;
+ let r = post app c ~headers:[ ("origin", origin) ] "/todos" "title=ok" in
+ Alcotest.(check int) "own origin" 303 r.status;
+ Alcotest.(check int) "one item" 1 (count (page app c "/") "<li data-id=")
+
+let fragments () =
+ let app = fresh () in
+ let c = visit app in
+ let r = post app c ~headers:[ ("x-fragment", "todoapp") ] "/todos" "title=a&filter=all" in
+ Alcotest.(check int) "200" 200 r.status;
+ Alcotest.(check bool) "section only" true
+ (String.starts_with ~prefix:{|<section class="todoapp">|} r.body && not (contains r.body "<html"))
+
+let platform_routes () =
+ let app = fresh () in
+ Alcotest.(check int) "healthz" 200 (App.handle app (request "/healthz")).status;
+ Alcotest.(check string) "version" "test-version" (App.handle app (request "/api/version")).body;
+ let read f = In_channel.with_open_bin f In_channel.input_all in
+ Alcotest.(check bool) "base.css byte for byte" true
+ ((App.handle app (request "/static/base.css")).body = read "../static/base.css");
+ Alcotest.(check bool) "index.css byte for byte" true
+ ((App.handle app (request "/static/index.css?v=1")).body = read "../static/index.css");
+ Alcotest.(check int) "unknown static" 404 (App.handle app (request "/static/nope")).status;
+ Alcotest.(check int) "unknown page" 404 (App.handle app (request "/nope")).status;
+ Alcotest.(check int) "GET on an action" 405 (App.handle app (request "/todos")).status
+
+let () =
+ Alcotest.run "todo"
+ [ ("form", [ Alcotest.test_case "decoding" `Quick form_decoding;
+ Alcotest.test_case "trimming" `Quick trimming;
+ Alcotest.test_case "cookies" `Quick cookies;
+ Alcotest.test_case "escaping" `Quick escaping ]);
+ ("app", [ Alcotest.test_case "first cookie" `Quick first_cookie;
+ Alcotest.test_case "secure cookie" `Quick secure_cookie;
+ Alcotest.test_case "garbled cookie" `Quick garbled_cookie;
+ Alcotest.test_case "add and redirect" `Quick add_and_redirect;
+ Alcotest.test_case "escapes titles" `Quick escapes_titles;
+ Alcotest.test_case "toggle and filters" `Quick toggle_and_filters;
+ Alcotest.test_case "toggle all and clear" `Quick toggle_all_and_clear;
+ Alcotest.test_case "edit and destroy" `Quick edit_and_destroy;
+ Alcotest.test_case "isolation" `Quick isolation;
+ Alcotest.test_case "origins" `Quick origins;
+ Alcotest.test_case "fragments" `Quick fragments;
+ Alcotest.test_case "platform routes" `Quick platform_routes ]) ]
diff --git a/apps/ocaml/todo_ocaml.opam b/apps/ocaml/todo_ocaml.opam
new file mode 100644
index 0000000..3bef470
--- /dev/null
+++ b/apps/ocaml/todo_ocaml.opam
@@ -0,0 +1,32 @@
+# This file is generated by dune, edit dune-project instead
+opam-version: "2.0"
+synopsis: "TodoMVC for the CARLOS bakeoff, server-rendered in OCaml"
+maintainer: ["Paul Campbell <paul@keymail.dev>"]
+authors: ["Paul Campbell"]
+license: "MIT"
+depends: [
+ "dune" {>= "3.17"}
+ "ocaml" {= "5.3.0"}
+ "eio" {>= "1.2"}
+ "eio_posix" {>= "1.2"}
+ "cohttp-eio" {>= "6.0"}
+ "http" {>= "6.0"}
+ "uri" {>= "4.4"}
+ "sqlite3" {>= "5.3" & < "5.4"}
+ "alcotest" {with-test}
+ "odoc" {with-doc}
+]
+build: [
+ ["dune" "subst"] {dev}
+ [
+ "dune"
+ "build"
+ "-p"
+ name
+ "-j"
+ jobs
+ "@install"
+ "@runtest" {with-test}
+ "@doc" {with-doc}
+ ]
+]
diff --git a/apps/ocaml/todo_ocaml.opam.locked b/apps/ocaml/todo_ocaml.opam.locked
new file mode 100644
index 0000000..4d8ec27
--- /dev/null
+++ b/apps/ocaml/todo_ocaml.opam.locked
@@ -0,0 +1,80 @@
+opam-version: "2.0"
+name: "todo_ocaml"
+version: "dev"
+synopsis: "TodoMVC for the CARLOS bakeoff, server-rendered in OCaml"
+depends: [
+ "alcotest" {= "1.9.1" & with-test}
+ "angstrom" {= "0.16.1"}
+ "astring" {= "0.8.5" & with-test}
+ "base" {= "v0.17.3"}
+ "base-bigarray" {= "base"}
+ "base-domains" {= "base"}
+ "base-effects" {= "base"}
+ "base-nnp" {= "base"}
+ "base-threads" {= "base"}
+ "base-unix" {= "base"}
+ "base64" {= "3.5.2"}
+ "bigstringaf" {= "0.10.0"}
+ "cmdliner" {= "2.1.1"}
+ "cohttp" {= "6.3.0"}
+ "cohttp-eio" {= "6.3.0"}
+ "conf-pkg-config" {= "5"}
+ "conf-sqlite3" {= "1"}
+ "csexp" {= "1.5.2"}
+ "cstruct" {= "6.3.0"}
+ "domain-local-await" {= "1.0.1"}
+ "domain-name" {= "0.5.0"}
+ "dune" {= "3.24.2"}
+ "dune-configurator" {= "3.24.2"}
+ "eio" {= "1.6"}
+ "eio_posix" {= "1.6"}
+ "fmt" {= "0.11.0"}
+ "hmap" {= "0.8.1"}
+ "http" {= "6.3.0"}
+ "iomux" {= "0.4"}
+ "ipaddr" {= "5.6.2"}
+ "logs" {= "0.10.0"}
+ "lwt-dllist" {= "1.1.0"}
+ "macaddr" {= "5.6.2"}
+ "mtime" {= "2.2.0"}
+ "ocaml" {= "5.3.0"}
+ "ocaml-compiler-libs" {= "v0.17.0"}
+ "ocaml-config" {= "3"}
+ "ocaml-syntax-shims" {= "1.0.0"}
+ "ocaml-system" {= "5.3.0"}
+ "ocaml_intrinsics_kernel" {= "v0.17.2"}
+ "ocamlbuild" {= "0.16.1"}
+ "ocamlfind" {= "1.9.8"}
+ "optint" {= "0.3.0"}
+ "ppx_derivers" {= "1.2.1"}
+ "ppx_sexp_conv" {= "v0.17.1"}
+ "ppxlib" {= "0.39.0"}
+ "ppxlib_jane" {= "v0.17.4"}
+ "psq" {= "0.2.1"}
+ "ptime" {= "1.2.0"}
+ "re" {= "1.14.0"}
+ "seq" {= "base"}
+ "sexplib0" {= "v0.17.0"}
+ "sqlite3" {= "5.3.1"}
+ "stdlib-shims" {= "0.3.0" & with-test}
+ "stringext" {= "1.6.0"}
+ "thread-table" {= "1.0.0"}
+ "topkg" {= "1.1.1"}
+ "uri" {= "4.4.0"}
+ "uri-sexp" {= "4.4.0"}
+ "uutf" {= "1.0.4" & with-test}
+]
+build: [
+ ["dune" "subst"] {dev}
+ [
+ "dune"
+ "build"
+ "-p"
+ name
+ "-j"
+ jobs
+ "@install"
+ "@runtest" {with-test}
+ "@doc" {with-doc}
+ ]
+]
\ No newline at end of file
diff --git a/apps/ocaml/tools/arm64.sh b/apps/ocaml/tools/arm64.sh
new file mode 100755
index 0000000..20bb937
--- /dev/null
+++ b/apps/ocaml/tools/arm64.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# Runs a command in this app's arm64 opam switch (.cache/opam-arm64) inside
+# the emulated Debian arm64 root, creating the switch on first use. The
+# compiler is the root's Debian OCaml 5.3.0, used as ocaml-system; every
+# library, SQLite included, is compiled from source into .cache, from the
+# versions in todo_ocaml.opam.locked and tools/sqlite.sh.
+# tools/arm64.sh <command> [args...]
+set -euo pipefail
+app=$(cd "$(dirname "$0")/.." && pwd)
+if [ -z "${IN_ARM64_ROOT:-}" ]; then
+ "$app/tools/sqlite.sh" fetch
+ exec "$app/../../scripts/arm64.sh" /usr/bin/env IN_ARM64_ROOT=1 \
+ APP_VERSION="${APP_VERSION:-dev}" LINK_FLAGS="${LINK_FLAGS:-}" "$app/tools/arm64.sh" "$@"
+fi
+# The root is shared: keep dune's cache off so nothing lands in its /root.
+export OPAMROOT=$app/.cache/opam-arm64 OPAMYES=1 OPAMCOLOR=never DUNE_CACHE=disabled
+# SQLite is compiled here too, not taken from the root's libsqlite3-dev.
+"$app/tools/sqlite.sh" build "$app/.cache/sqlite-arm64"
+export PKG_CONFIG_PATH=$app/.cache/sqlite-arm64/lib/pkgconfig
+if [ ! -d "$OPAMROOT/default" ]; then
+ [ -f "$OPAMROOT/config" ] || opam init -q --bare -n --disable-sandboxing default https://opam.ocaml.org
+ opam switch remove -q default >/dev/null 2>&1 || true # forget one make clean-deps deleted
+ opam switch create -q default ocaml-system.5.3.0
+fi
+if [ ! -f "$OPAMROOT/default/.deps-installed" ] || [ "$app/todo_ocaml.opam.locked" -nt "$OPAMROOT/default/.deps-installed" ]; then
+ (cd "$app" && opam install -q --switch default --deps-only --locked .)
+ touch "$OPAMROOT/default/.deps-installed"
+fi
+exec opam exec --switch default -- "$@"
diff --git a/apps/ocaml/tools/sqlite.sh b/apps/ocaml/tools/sqlite.sh
new file mode 100755
index 0000000..dfdbd73
--- /dev/null
+++ b/apps/ocaml/tools/sqlite.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Builds SQLite from its amalgamation into a prefix: <prefix>/lib/libsqlite3.a,
+# <prefix>/include/sqlite3.h and a pkg-config file, which the sqlite3 opam
+# package then finds. The source tarball is downloaded once into .cache/src,
+# checked against sqlite.org's SHA3-256, and kept by make clean-deps.
+# tools/sqlite.sh fetch (on the host)
+# tools/sqlite.sh build <prefix> (with the target's C compiler)
+set -euo pipefail
+app=$(cd "$(dirname "$0")/.." && pwd)
+version=3540100
+sha3=b6ced585c750916b1052667239a12c25a7ff34e7f06e1e6e01ecb0427657d9c4
+tarball=$app/.cache/src/sqlite-autoconf-$version.tar.gz
+
+case $1 in
+fetch)
+ [ -f "$tarball" ] && exit 0
+ mkdir -p "$(dirname "$tarball")"
+ curl -sSfo "$tarball.part" "https://sqlite.org/2026/sqlite-autoconf-$version.tar.gz"
+ got=$(python3 -c 'import hashlib,sys; print(hashlib.sha3_256(open(sys.argv[1],"rb").read()).hexdigest())' "$tarball.part")
+ [ "$got" = "$sha3" ] || { echo "sqlite: SHA3-256 mismatch: $got" >&2; rm -f "$tarball.part"; exit 1; }
+ mv "$tarball.part" "$tarball"
+ ;;
+build)
+ prefix=$2
+ [ -f "$prefix/lib/libsqlite3.a" ] && exit 0
+ rm -rf "$prefix" && mkdir -p "$prefix/build" "$prefix/lib/pkgconfig" "$prefix/include"
+ tar xzf "$tarball" -C "$prefix/build" --strip-components=1 \
+ "sqlite-autoconf-$version/sqlite3.c" "sqlite-autoconf-$version/sqlite3.h" "sqlite-autoconf-$version/sqlite3ext.h"
+ # -fPIC: the x86 build also links it into the OCaml stubs' shared library.
+ cc -c -O2 -fPIC -DSQLITE_THREADSAFE=1 -DSQLITE_DQS=0 -DSQLITE_DEFAULT_MEMSTATUS=0 \
+ -DSQLITE_OMIT_DEPRECATED -DSQLITE_OMIT_SHARED_CACHE \
+ -o "$prefix/build/sqlite3.o" "$prefix/build/sqlite3.c"
+ ar rcs "$prefix/lib/libsqlite3.a" "$prefix/build/sqlite3.o"
+ cp "$prefix/build/sqlite3.h" "$prefix/build/sqlite3ext.h" "$prefix/include/"
+ rm -rf "$prefix/build"
+ cat > "$prefix/lib/pkgconfig/sqlite3.pc" <<EOS
+Name: SQLite
+Description: SQL database engine, built by tools/sqlite.sh
+Version: ${version:0:1}.${version:1:2}.${version:4:1}
+Cflags: -I$prefix/include
+Libs: -L$prefix/lib -lsqlite3
+Libs.private: -lm -lpthread
+EOS
+ ;;
+esac
diff --git a/apps/ocaml/tools/x86.sh b/apps/ocaml/tools/x86.sh
new file mode 100755
index 0000000..2ec0b47
--- /dev/null
+++ b/apps/ocaml/tools/x86.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Runs a command in this app's x86 opam switch (.cache/opam-x86), creating
+# it on first use. The compiler is the bakeoff's OCaml 5.3.0, used as
+# ocaml-system; libraries, SQLite included, are compiled into .cache.
+# tools/x86.sh <command> [args...]
+set -euo pipefail
+app=$(cd "$(dirname "$0")/.." && pwd)
+cache=$app/.cache
+source "$app/../../scripts/env.sh"
+ocaml_bin=$(dirname "$(readlink -f "$(command -v ocamlopt)")")
+unset OPAMSWITCH OPAM_SWITCH_PREFIX OPAM_LAST_ENV CAML_LD_LIBRARY_PATH OCAML_TOPLEVEL_PATH OCAMLTOP_INCLUDE_PATH MANPATH
+export OPAMROOT=$cache/opam-x86 OPAMYES=1 OPAMCOLOR=never DUNE_CACHE=disabled
+
+# opam ignores compilers inside another opam root, so expose this one by
+# symlink. docs-t1 has no pkg-config; Debian's is unpacked into .cache.
+bin=$cache/x86-bin
+if [ ! -x "$bin/pkg-config" ]; then
+ mkdir -p "$bin" "$cache/debs"
+ for f in "$ocaml_bin"/ocaml*; do ln -sf "$f" "$bin/"; done
+ (cd "$cache/debs" && apt-get download -q pkgconf-bin libpkgconf3 >/dev/null)
+ for d in "$cache"/debs/*.deb; do dpkg-deb -x "$d" "$cache/x86-sysroot"; done
+ cat > "$bin/pkg-config" <<EOS
+#!/bin/sh
+root=$cache/x86-sysroot
+LD_LIBRARY_PATH=\$root/usr/lib/x86_64-linux-gnu PKG_CONFIG_LIBDIR=$cache/sqlite-x86/lib/pkgconfig \\
+ exec \$root/usr/bin/pkgconf "\$@"
+EOS
+ chmod +x "$bin/pkg-config"
+fi
+export PATH=$bin:/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin
+
+"$app/tools/sqlite.sh" fetch
+"$app/tools/sqlite.sh" build "$cache/sqlite-x86"
+if [ ! -d "$OPAMROOT/default" ]; then
+ [ -f "$OPAMROOT/config" ] || opam init -q --bare -n --disable-sandboxing default https://opam.ocaml.org
+ opam switch remove -q default >/dev/null 2>&1 || true # forget one make clean-deps deleted
+ opam switch create -q default ocaml-system.5.3.0
+fi
+if [ ! -f "$OPAMROOT/default/.deps-installed" ] || [ "$app/todo_ocaml.opam.locked" -nt "$OPAMROOT/default/.deps-installed" ]; then
+ (cd "$app" && opam install -q --switch default --deps-only --with-test $([ -f todo_ocaml.opam.locked ] && echo --locked) --assume-depexts .)
+ touch "$OPAMROOT/default/.deps-installed"
+fi
+exec opam exec --switch default -- "$@"