carlos / todomvc-bakeoff Public
feat: ocaml builder output
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
27 files changed,
+1650
−0
apps/ocaml/.gitignore+3 −0apps/ocaml/AGENTS.md+31 −0apps/ocaml/DEPS+9 −0apps/ocaml/Makefile+49 −0apps/ocaml/README.md+124 −0apps/ocaml/STATUS.md+50 −0apps/ocaml/bin/dune+14 −0apps/ocaml/bin/main.ml+147 −0apps/ocaml/dune-project+24 −0apps/ocaml/gen/dune+2 −0apps/ocaml/gen/embed.ml+14 −0apps/ocaml/src/app.ml+167 −0apps/ocaml/src/dune+28 −0apps/ocaml/src/form.ml+71 −0apps/ocaml/src/rand.ml+6 −0apps/ocaml/src/rand_stubs.c+26 −0apps/ocaml/src/store.ml+117 −0apps/ocaml/src/view.ml+123 −0apps/ocaml/static/app.css+62 −0apps/ocaml/static/app.js+115 −0apps/ocaml/test/dune+4 −0apps/ocaml/test/test_todo.ml+235 −0apps/ocaml/todo_ocaml.opam+32 −0apps/ocaml/todo_ocaml.opam.locked+80 −0apps/ocaml/tools/arm64.sh+29 −0apps/ocaml/tools/sqlite.sh+45 −0apps/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 "&" |
| + | '<' -> Buffer.add_string b "<" |
| + | '>' -> Buffer.add_string b ">" |
| + | '"' -> Buffer.add_string b """ |
| + | '\'' -> Buffer.add_string b "'" |
| + | 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" "<img src=x> & "q" '" |
| + (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 "<img src=x onerror=alert(1)>"); |
| + 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 -- "$@" |