feat: mojo builder output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 2a07621552fe6782bb330aa32c32554fbac905f9 parent e4d6a6eparent cff9f8c
17 files changed, +2004 −0
  • apps/mojo/.gitignore +1 −0
  • apps/mojo/AGENTS.md +28 −0
  • apps/mojo/DEPS +1 −0
  • apps/mojo/Makefile +123 −0
  • apps/mojo/README.md +109 −0
  • apps/mojo/STATUS.md +57 −0
  • apps/mojo/scripts/embed.py +38 −0
  • apps/mojo/src/app.mojo +179 −0
  • apps/mojo/src/db.mojo +189 −0
  • apps/mojo/src/http.mojo +237 −0
  • apps/mojo/src/libc.mojo +183 −0
  • apps/mojo/src/main.mojo +196 −0
  • apps/mojo/src/render.mojo +190 −0
  • apps/mojo/static/app.css +59 −0
  • apps/mojo/static/app.js +69 −0
  • apps/mojo/tests/smoke.sh +33 −0
  • apps/mojo/tests/test_main.mojo +312 −0
diff --git a/apps/mojo/.gitignore b/apps/mojo/.gitignore
new file mode 100644
index 0000000..9a4d45b
--- /dev/null
+++ b/apps/mojo/.gitignore
@@ -0,0 +1 @@
+src/assets.mojo
diff --git a/apps/mojo/AGENTS.md b/apps/mojo/AGENTS.md
new file mode 100644
index 0000000..47d9a9a
--- /dev/null
+++ b/apps/mojo/AGENTS.md
@@ -0,0 +1,28 @@
+# 🤖 AGENTS.md: todo-mojo
+
+The one rule: **the server owns the truth, and the page must work without
+JavaScript.** Every change is a plain form POST answered with a 303; the
+JavaScript only replays those same POSTs in the background and swaps in
+the server's own rendering. Nothing lives only in the browser.
+
+## Tenancy and trust
+
+- **One instance for the whole app.** It runs as `todo-mojo` on account
+ `bfj`, host `todo-mojo.bfj.oncarlos.com`, channel `edge`.
+- **Each visitor's list is kept separately by an anonymous cookie.** The
+ cookie holds 256 random bits from `getrandom(2)`, and is `HttpOnly;
+ SameSite=Lax; Path=/`, plus `Secure` over https. A value the app
+ doesn't recognise gets a fresh, empty list. Every query is scoped to
+ the caller's list, so an id from someone else's list answers 404.
+- **No sign-in.** There are no accounts; losing the cookie loses the list.
+- **Trust class C, chosen on 2026-10-23.** Todo text is stored as plain
+ text in SQLite, readable by the server and its operator. That's
+ acceptable for a demo todo list and is written down here and in the
+ README so nobody mistakes it for private storage.
+
+## Working here
+
+- Build, run, test and deploy with the `Makefile` targets (see README).
+- SQLite stays in WAL mode, one file at `--db`. Migrations only add.
+- Bind `127.0.0.1` only when running with `--listen`.
+- Commits end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
diff --git a/apps/mojo/DEPS b/apps/mojo/DEPS
new file mode 100644
index 0000000..a82556c
--- /dev/null
+++ b/apps/mojo/DEPS
@@ -0,0 +1 @@
+sqlite (https://www.sqlite.org/2026/sqlite-amalgamation-3540100.zip, 3.54.1, compiled from source and linked statically)
diff --git a/apps/mojo/Makefile b/apps/mojo/Makefile
new file mode 100644
index 0000000..6286538
--- /dev/null
+++ b/apps/mojo/Makefile
@@ -0,0 +1,123 @@
+# todo-mojo. See README.md.
+#
+# The host build uses the x86 Mojo from pixi. The shipped linux-arm64 build
+# runs Mojo inside the emulated arm64 root (scripts/arm64.sh), because Mojo
+# can't cross-link. SQLite is compiled from its amalgamation, once per
+# target, into .cache/ and linked in statically.
+
+SHELL := bash
+.SHELLFLAGS := -eu -o pipefail -c
+.DEFAULT_GOAL := build
+
+ROOT := $(abspath ../..)
+HERE := $(abspath .)
+export PATH := $(HOME)/.pixi/bin:$(PATH)
+
+VERSION ?= $(shell git rev-parse --short HEAD)
+MESSAGE ?= $(shell git log -1 --format=%s)
+
+ARM64 := $(ROOT)/scripts/arm64.sh
+ARM64_MOJO := /root/.pixi/bin/mojo
+
+CACHE := $(HERE)/.cache
+# Mojo's compilation cache is per user by default; keep ours in .cache/.
+HOST_MOJO_CACHE := $(CACHE)/mojo-cache/host
+ARM64_MOJO_CACHE := $(CACHE)/mojo-cache/arm64
+
+SQLITE_VERSION := 3540100
+SQLITE_ZIP := sqlite-amalgamation-$(SQLITE_VERSION).zip
+SQLITE_URL := https://www.sqlite.org/2026/$(SQLITE_ZIP)
+SQLITE_SHA3 := 92281c86e2957f9b9305890f9ac04204caa7b677cc868dc326d942689df28e30
+SQLITE_SRC := $(CACHE)/src/sqlite-amalgamation-$(SQLITE_VERSION)/sqlite3.c
+SQLITE_CFLAGS := -O2 -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION \
+ -DSQLITE_DQS=0 -DSQLITE_DEFAULT_MEMSTATUS=0 -DSQLITE_OMIT_DEPRECATED \
+ -DSQLITE_OMIT_SHARED_CACHE -DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1
+HOST_SQLITE := $(CACHE)/build/host/libsqlite3.a
+ARM64_SQLITE := $(CACHE)/build/arm64/libsqlite3.a
+
+SOURCES := $(wildcard src/*.mojo)
+ASSETS := src/assets.mojo
+STATIC := $(ROOT)/shared/base.css $(ROOT)/shared/index.css static/app.css static/app.js
+VERSION_STAMP := $(CACHE)/version-$(VERSION)
+
+.PHONY: build build-arm64 test clean clean-deps bump unbump deploy run boot-check
+
+build: dist/app
+build-arm64: dist/app-linux-arm64
+
+dist/app: $(SOURCES) $(ASSETS) $(HOST_SQLITE) $(VERSION_STAMP)
+ @mkdir -p dist $(HOST_MOJO_CACHE)
+ MODULAR_CACHE_DIR=$(HOST_MOJO_CACHE) mojo build src/main.mojo -I src -D VERSION=$(VERSION) \
+ -Xlinker $(HOST_SQLITE) -o $@
+
+dist/app-linux-arm64: $(SOURCES) $(ASSETS) $(ARM64_SQLITE) $(VERSION_STAMP)
+ @mkdir -p dist $(ARM64_MOJO_CACHE)
+ $(ARM64) /usr/bin/env MODULAR_CACHE_DIR=$(ARM64_MOJO_CACHE) $(ARM64_MOJO) build \
+ $(HERE)/src/main.mojo -I $(HERE)/src -D VERSION=$(VERSION) \
+ -Xlinker $(ARM64_SQLITE) -o $(HERE)/$@
+
+$(ASSETS): scripts/embed.py $(STATIC)
+ python3 scripts/embed.py $@ BASE_CSS=$(ROOT)/shared/base.css INDEX_CSS=$(ROOT)/shared/index.css \
+ APP_CSS=static/app.css APP_JS=static/app.js
+
+# The version is compiled in, so a new commit means a new build.
+$(VERSION_STAMP):
+ @mkdir -p $(CACHE)
+ @rm -f $(CACHE)/version-*
+ @touch $@
+
+$(CACHE)/src/$(SQLITE_ZIP):
+ @mkdir -p $(dir $@)
+ curl -fsSL -o $@.part $(SQLITE_URL)
+ @test "$$(openssl dgst -sha3-256 -r $@.part | cut -d' ' -f1)" = $(SQLITE_SHA3) \
+ || { echo "sqlite: checksum mismatch" >&2; rm -f $@.part; exit 1; }
+ mv $@.part $@
+
+$(SQLITE_SRC): $(CACHE)/src/$(SQLITE_ZIP)
+ cd $(CACHE)/src && unzip -oq $(SQLITE_ZIP)
+ @touch $@
+
+$(HOST_SQLITE): $(SQLITE_SRC)
+ @mkdir -p $(dir $@)
+ cc $(SQLITE_CFLAGS) -c $< -o $(dir $@)sqlite3.o
+ ar rcs $@ $(dir $@)sqlite3.o
+
+$(ARM64_SQLITE): $(SQLITE_SRC)
+ @mkdir -p $(dir $@)
+ $(ARM64) cc $(SQLITE_CFLAGS) -c $< -o $(dir $@)sqlite3.o
+ $(ARM64) ar rcs $@ $(dir $@)sqlite3.o
+
+test: dist/app $(SOURCES) $(ASSETS) tests/test_main.mojo $(HOST_SQLITE)
+ @mkdir -p $(CACHE)/test $(HOST_MOJO_CACHE)
+ MODULAR_CACHE_DIR=$(HOST_MOJO_CACHE) mojo build tests/test_main.mojo -I src \
+ -Xlinker $(HOST_SQLITE) -o $(CACHE)/test/test_main
+ $(CACHE)/test/test_main
+ tests/smoke.sh dist/app
+
+# Runs the host build on 127.0.0.1 for the shared suite.
+PORT ?= 39517
+DB ?= $(TMPDIR)/mojo.db
+run: dist/app
+ dist/app --listen 127.0.0.1:$(PORT) --db $(DB)
+
+boot-check: dist/app-linux-arm64
+ $(ROOT)/scripts/boot-check.sh dist/app-linux-arm64
+
+clean:
+ rm -rf dist $(ASSETS) $(CACHE)/test $(CACHE)/version-*
+
+# Keeps the downloaded SQLite source; drops everything compiled from it,
+# and Mojo's compilation caches.
+clean-deps: clean
+ rm -rf $(CACHE)/build $(CACHE)/mojo-cache
+
+BUMP_FROM := served by todo-mojo -->
+BUMP_TO := served by todo-mojo (bumped) -->
+bump:
+ sed -i 's/$(BUMP_FROM)/$(BUMP_TO)/' src/app.mojo
+unbump:
+ sed -i 's/$(BUMP_TO)/$(BUMP_FROM)/' src/app.mojo
+
+deploy: dist/app-linux-arm64
+ carlos deploy --account bfj --app todo-mojo --target linux-arm64 --no-prompt \
+ --message "$(MESSAGE)" dist/app-linux-arm64
diff --git a/apps/mojo/README.md b/apps/mojo/README.md
new file mode 100644
index 0000000..55c5069
--- /dev/null
+++ b/apps/mojo/README.md
@@ -0,0 +1,109 @@
+# 🤖 todo-mojo
+
+TodoMVC for the CARLOS bakeoff, written in [Mojo](https://mojolang.org)
+1.2.0. The server renders every page, every change is a plain form POST,
+and the whole app works with JavaScript turned off. A small script makes
+it behave like the reference TodoMVC when JavaScript is on.
+
+It builds and passes the shared suite, but it isn't deployed: see
+`STATUS.md`. In short, every Mojo binary loads
+`libKGENCompilerRTShared.so` at start-up, Mojo ships no static version of
+it, and Carloku's binary kind takes exactly one file.
+
+## The stack, and why
+
+Mojo's standard library has no networking, no SQLite binding and no
+templating, and Mojo 1.2.0 has no package that covers them and builds for
+linux-arm64 without other shared libraries. So the app is small, plain
+Mojo on top of two C interfaces it already has:
+
+- **libc, through `external_call`** (`src/libc.mojo`). The server is one
+ thread running a `poll(2)` loop over non-blocking sockets, so a slow or
+ idle browser connection never holds up anyone else. It speaks
+ HTTP/1.1 with keep-alive, which is enough for browsers and the
+ Carloku edge. The Mojo runtime starts its own worker threads and
+ catches SIGTERM, so the app installs a small C-ABI handler that writes
+ to a pipe the loop watches, and shutdown is an ordinary return from the
+ loop: close the sockets, close SQLite (which
+ checkpoints the WAL), remove the socket file, exit 0.
+- **SQLite's C API** (`src/db.mojo`), from the 3.54.1 amalgamation,
+ compiled by the Makefile for each target and linked in statically.
+ It runs in WAL mode with `synchronous=NORMAL`, and migrations are
+ numbered in `PRAGMA user_version` and only ever add.
+- **HTML is built with string appends** (`src/render.mojo`), with one
+ `escape` function for every piece of user text. The page is TodoMVC's
+ own template from `shared/reference.html`.
+- **The static files are compiled in.** `scripts/embed.py` turns
+ `base.css`, `index.css`, `app.css` and `app.js` into Mojo string
+ constants, so the binary is the whole app and the CSS is served byte
+ for byte.
+
+### Without JavaScript, and with it
+
+Each control the contract asks for is a real form or link, laid over the
+spot you'd click with JavaScript on, so the page looks the same either
+way: an invisible submit button over each checkbox, another over the
+"mark all" chevron, and an edit link over each title. The forms use
+`display: contents` so they add no boxes to TodoMVC's layout.
+
+`static/app.js` (one ES module, no build step) marks the page with
+`html.js`, which hides those controls, and then sends the same form POSTs
+with `fetch`. It adds the header `X-Requested-With: fetch`, and for that
+header the server answers 200 with the updated page instead of a 303; the
+script swaps in the new `section.main` and `footer.footer`. Requests go
+one at a time, in order. Double-click, Enter, blur and Escape work as in
+TodoMVC.
+
+### Lists and cookies
+
+Each visitor's list is keyed by a cookie, `todo_list`, holding 256 random
+bits from `getrandom(2)` as hex. A missing or malformed value gets a new
+one, and so a new, empty list. Every query includes the list key, so an
+id from another list answers 404 and changes nothing. A POST whose
+`Origin` isn't `https://$CARLOS_HOST` (or `http://<listen address>`
+locally) answers 403.
+
+## Honest trade-offs
+
+- **Trust class C** (2026-10-23): todo text is stored as plain text and
+ the server can read it. The cookie value is also stored as is, so a
+ copy of the database is enough to open anyone's list.
+- **One thread.** Requests are handled one after another. Each takes well
+ under a millisecond, so this is fine for a todo list, and it means one
+ SQLite connection with no locking to think about.
+- **A deliberately small HTTP server.** No chunked request bodies (a 400),
+ no compression, request heads up to 64 KiB and bodies up to 1 MiB.
+- **Not one file.** The binary depends on Mojo's runtime libraries, which
+ is why it isn't on Carloku.
+
+## Build, run, test, deploy
+
+Toolchains come from `source ../../scripts/env.sh` (the Makefile also
+puts pixi's `mojo` on `PATH`).
+
+| Command | What it does |
+|---|---|
+| `make build` | Host build into `dist/app` |
+| `make build-arm64` | linux-arm64 build into `dist/app-linux-arm64`, run inside the emulated arm64 root (`scripts/arm64.sh`); slow, since everything is emulated |
+| `make test` | Builds and runs `tests/test_main.mojo`: parsing, escaping, the store, and every route through `App.handle` |
+| `make run` | Runs the host build on `127.0.0.1:39517` (`PORT=`, `DB=` to change) |
+| `make boot-check` | `scripts/boot-check.sh` on the arm64 binary, under qemu |
+| `make clean` | Removes `dist/` and generated files |
+| `make clean-deps` | Also removes the compiled SQLite and Mojo's compilation caches (kept in `.cache/`); the downloaded SQLite source stays |
+| `make bump` / `make unbump` | Changes / restores the page comment in `src/app.mojo` |
+| `make deploy` | `carlos deploy` of `dist/app-linux-arm64` to `todo-mojo` on account `bfj` |
+
+The first build downloads the SQLite amalgamation into `.cache/src/` and
+checks its SHA3-256.
+
+The shared behaviour suite:
+
+```sh
+make build
+dist/app --listen 127.0.0.1:39517 --db "$TMPDIR/mojo.db" &
+../../scripts/test-app.sh http://127.0.0.1:39517 "$TMPDIR/mojo-suite.json"
+```
+
+The binary takes `--socket <path> --db <path>` on Carloku, or
+`--listen 127.0.0.1:<port> --db <path>` locally, and refuses to listen on
+any other address.
diff --git a/apps/mojo/STATUS.md b/apps/mojo/STATUS.md
new file mode 100644
index 0000000..b8cb175
--- /dev/null
+++ b/apps/mojo/STATUS.md
@@ -0,0 +1,57 @@
+BLOCKED platform: single-file binaries only (Mojo needs libKGENCompilerRTShared.so)
+
+## Where it got stuck
+
+The app is finished and tested; it can't be shipped as Carloku's binary
+kind, which takes a single file (`carlos deploy --target`: "binary kind,
+single file").
+
+What works, at commit `a874c81` (2026-10-23):
+
+- `make test`: 19 unit tests plus a process-level smoke test (unix
+ socket, keep-alive, cookie round trip, clean exit on SIGTERM) pass.
+- Shared behaviour suite against the host build
+ (`dist/app --listen 127.0.0.1:39517`): **67 passed, 3 skipped**, the
+ same as the reference fixture.
+- `make build-arm64` produces `dist/app-linux-arm64` inside the arm64
+ root (about 45 s for the app once SQLite is compiled; SQLite itself
+ takes about 15 minutes emulated, once).
+- The shared suite against that arm64 binary under qemu: **67 passed,
+ 3 skipped**. `scripts/boot-check.sh dist/app-linux-arm64`: `/healthz`
+ 200, `/api/version` 200 `a874c81`, clean exit on SIGTERM.
+
+Why it isn't deployed:
+
+- `readelf -d dist/app-linux-arm64` shows `NEEDED
+ libKGENCompilerRTShared.so` (besides `libc.so.6`) and `RUNPATH
+ /root/.pixi/envs/mojo/lib`. That library in turn needs `libstdc++`,
+ `libMSupportGlobals.so` and `libAsyncRTRuntimeGlobals.so`. Boot-check
+ and the qemu run only pass because `qemu-aarch64-static -L <arm64 root>`
+ finds them in the root's pixi install; a Carloku instance sees only its
+ own build.
+- I looked for a supported static route and found none: Mojo 1.2.0 ships
+ no static archive of its runtime (no `libKGEN*.a` in either the x86 or
+ the arm64 pixi env, only the `.so`), and `mojo build` has no static or
+ standalone option (`--emit` offers `exe`, `object`, `shared-lib` and
+ IR formats; `--help-hidden` adds nothing for linking the runtime). The
+ runtime also starts 16 worker threads before `main`, so even
+ `--emit object` output depends on it.
+- As the brief says, I didn't bundle, embed or self-extract the
+ libraries to get around the single-file rule, and I didn't create the
+ Carloku app or instance, since nothing could be deployed to it.
+
+What would unblock it: either Carloku's binary kind accepting a binary
+plus its shared libraries (a directory or archive release), or Modular
+shipping a static `libKGENCompilerRTShared` (and its dependencies). With
+either, `make build-arm64 && make deploy` after the three first-time
+`carlos` commands in BRIEF.md should be all that's left.
+
+Notes for the report:
+
+- SIGTERM: the Mojo runtime installs its own SIGTERM handler, which ends
+ the process with status 143, and its threads exist before `main`, so a
+ signal mask or `signalfd` can't catch it. The app installs a C-ABI
+ handler that writes to a pipe its poll loop watches (`src/libc.mojo`).
+- Mojo's standard library has no sockets or SQLite binding, so both go
+ through `external_call` to libc and to SQLite compiled from the
+ amalgamation.
diff --git a/apps/mojo/scripts/embed.py b/apps/mojo/scripts/embed.py
new file mode 100644
index 0000000..76055d8
--- /dev/null
+++ b/apps/mojo/scripts/embed.py
@@ -0,0 +1,38 @@
+"""Writes src/assets.mojo: the static files as Mojo string constants.
+
+ python3 scripts/embed.py src/assets.mojo NAME=path [NAME=path ...]
+
+The files are served byte for byte, so they must be UTF-8. Quotes,
+backslashes and control characters are escaped; everything else,
+non-ASCII included, is written as is, because Mojo's \\x escapes name
+code points, not bytes.
+"""
+import sys
+
+
+def literal(data: bytes) -> str:
+ out = []
+ for c in data.decode("utf-8"):
+ if c == "\\":
+ out.append("\\\\")
+ elif c == '"':
+ out.append('\\"')
+ elif ord(c) < 0x20 or ord(c) == 0x7F:
+ out.append("\\x%02x" % ord(c))
+ else:
+ out.append(c)
+ return '"' + "".join(out) + '"'
+
+
+def main() -> None:
+ dest, pairs = sys.argv[1], sys.argv[2:]
+ lines = ['"""Generated by scripts/embed.py from the static files. Do not edit."""', ""]
+ for pair in pairs:
+ name, path = pair.split("=", 1)
+ with open(path, "rb") as f:
+ lines.append(f"comptime {name} = {literal(f.read())}")
+ with open(dest, "w", encoding="utf-8") as f:
+ f.write("\n".join(lines) + "\n")
+
+
+main()
diff --git a/apps/mojo/src/app.mojo b/apps/mojo/src/app.mojo
new file mode 100644
index 0000000..5d803c4
--- /dev/null
+++ b/apps/mojo/src/app.mojo
@@ -0,0 +1,179 @@
+"""Routes: the pages and the form actions from CONTRACT.md."""
+
+from std.sys import get_defined_string
+from db import Store
+from http import Request, Response, text, redirect
+from render import page, filter_path
+from libc import random_bytes
+import assets
+
+comptime VERSION = get_defined_string["VERSION", "dev"]()
+comptime LANGUAGE = "Mojo"
+# `make bump` edits this string so the next build has to recompile this file.
+comptime PAGE_COMMENT = "<!-- served by todo-mojo -->\n"
+comptime COOKIE = "todo_list"
+comptime TOKEN_BYTES = 32
+
+
+struct App(Movable):
+ var store: Store
+ # The origin a browser sends for our own pages, e.g. https://<CARLOS_HOST>.
+ # A POST from any other origin is refused.
+ var origins: List[String]
+ var secure_cookie: Bool
+
+ def __init__(out self, var store: Store, var origins: List[String], secure_cookie: Bool):
+ self.store = store^
+ self.origins = origins^
+ self.secure_cookie = secure_cookie
+
+ def handle(self, req: Request) -> Response:
+ try:
+ return self._route(req)
+ except e:
+ print("error:", req.method, req.path, e)
+ return text(500, "Internal Server Error")
+
+ def _route(self, req: Request) raises -> Response:
+ var get = req.method == "GET" or req.method == "HEAD"
+ var p = req.path
+ if p == "/healthz":
+ return text(200, "ok") if get else _not_allowed()
+ if p == "/api/version":
+ return text(200, VERSION) if get else _not_allowed()
+ if p.startswith("/static/"):
+ return _static(p) if get else _not_allowed()
+ if p == "/" or p == "/active" or p == "/completed":
+ if not get:
+ return _not_allowed()
+ var filter = "all" if p == "/" else String(p[byte=1:])
+ return self._page(req, filter)
+ if p.startswith("/todos"):
+ if req.method != "POST":
+ return _not_allowed()
+ return self._action(req)
+ return text(404, "Not Found")
+
+ def _list(self, req: Request, mut resp: Response) raises -> String:
+ """The caller's list token, issuing a fresh one (and list) if theirs is missing or garbled."""
+ var token = req.cookie(COOKIE)
+ if _valid_token(token):
+ return token^
+ token = _new_token()
+ var cookie = COOKIE + "=" + token + "; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax"
+ if self.secure_cookie or req.header("x-forwarded-proto") == "https":
+ cookie += "; Secure"
+ resp.set("Set-Cookie", cookie)
+ return token^
+
+ def _page(self, req: Request, filter: String) raises -> Response:
+ var resp = Response(200, "text/html; charset=utf-8", String())
+ var list = self._list(req, resp)
+ var edit_id = -1
+ var edit = req.query_value("edit")
+ if edit:
+ try:
+ edit_id = Int(edit)
+ except:
+ pass
+ resp.body = page(self.store.todos(list), filter, edit_id, LANGUAGE, VERSION) + PAGE_COMMENT
+ resp.set("Cache-Control", "no-store")
+ _page_headers(resp)
+ return resp^
+
+ def _action(self, req: Request) raises -> Response:
+ var origin = req.header("origin")
+ if req.has_header("origin") and origin not in self.origins:
+ return text(403, "Forbidden: cross-origin request")
+ var filter = req.form("filter")
+ if filter != "active" and filter != "completed":
+ filter = "all"
+ var resp = redirect(String(filter_path(filter)))
+ var list = self._list(req, resp)
+ var parts = req.path.split("/") # "", "todos", <id or verb>, <verb>
+ var found = True
+ if len(parts) == 2:
+ var title = String(req.form("title").strip())
+ if title:
+ self.store.add(list, title)
+ elif len(parts) == 3 and parts[2] == "toggle-all":
+ self.store.toggle_all(list)
+ elif len(parts) == 3 and parts[2] == "clear-completed":
+ self.store.clear_completed(list)
+ elif len(parts) == 4:
+ var id: Int
+ try:
+ id = Int(parts[2])
+ except:
+ return text(404, "Not Found")
+ var verb = parts[3]
+ if verb == "toggle":
+ found = self.store.toggle(list, id)
+ elif verb == "destroy":
+ found = self.store.destroy(list, id)
+ elif verb == "edit":
+ var title = String(req.form("title").strip())
+ found = self.store.rename(list, id, title) if title else self.store.destroy(list, id)
+ else:
+ return text(404, "Not Found")
+ else:
+ return text(404, "Not Found")
+ if not found:
+ return text(404, "Not Found")
+ # The page's own script asks for the updated page in the same round trip.
+ if req.header("x-requested-with") == "fetch":
+ var page_resp = self._page(req, filter)
+ for h in resp.headers:
+ if h.name == "Set-Cookie":
+ page_resp.set(h.name, h.value)
+ return page_resp^
+ return resp^
+
+
+def _valid_token(t: StringSlice) -> Bool:
+ if t.byte_length() != TOKEN_BYTES * 2:
+ return False
+ for c in t.as_bytes():
+ if not ((c >= 48 and c <= 57) or (c >= 97 and c <= 102)):
+ return False
+ return True
+
+
+def _new_token() raises -> String:
+ comptime digits = "0123456789abcdef"
+ var out = String(capacity_bytes=TOKEN_BYTES * 2)
+ for b in random_bytes(TOKEN_BYTES):
+ out += digits[byte = Int(b >> 4)]
+ out += digits[byte = Int(b & 15)]
+ return out^
+
+
+def _page_headers(mut resp: Response):
+ resp.set("X-Content-Type-Options", "nosniff")
+ resp.set("Referrer-Policy", "same-origin")
+ resp.set(
+ "Content-Security-Policy",
+ "default-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
+ )
+
+
+def _not_allowed() -> Response:
+ return text(405, "Method Not Allowed")
+
+
+def _static(path: StringSlice) -> Response:
+ var name = path[byte=8:]
+ var r: Response
+ if name == "base.css":
+ r = Response(200, "text/css; charset=utf-8", String(assets.BASE_CSS))
+ elif name == "index.css":
+ r = Response(200, "text/css; charset=utf-8", String(assets.INDEX_CSS))
+ elif name == "app.css":
+ r = Response(200, "text/css; charset=utf-8", String(assets.APP_CSS))
+ elif name == "app.js":
+ r = Response(200, "text/javascript; charset=utf-8", String(assets.APP_JS))
+ else:
+ return text(404, "Not Found")
+ r.set("Cache-Control", "public, max-age=3600")
+ r.set("X-Content-Type-Options", "nosniff")
+ return r^
diff --git a/apps/mojo/src/db.mojo b/apps/mojo/src/db.mojo
new file mode 100644
index 0000000..deb88bd
--- /dev/null
+++ b/apps/mojo/src/db.mojo
@@ -0,0 +1,189 @@
+"""The todo store: SQLite, linked in statically and called through its C API."""
+
+from std.ffi import external_call, c_int
+from libc import cstring
+
+comptime SQLITE_OK = 0
+comptime SQLITE_ROW = 100
+comptime SQLITE_DONE = 101
+comptime SQLITE_OPEN_READWRITE = 0x2
+comptime SQLITE_OPEN_CREATE = 0x4
+comptime SQLITE_OPEN_NOMUTEX = 0x8000
+# SQLITE_TRANSIENT: SQLite copies bound text before the call returns.
+comptime SQLITE_TRANSIENT = -1
+
+def migrations() -> List[StaticString]:
+ """Migrations only ever add. Each runs once, in order, inside a
+ transaction, and its index + 1 is recorded in PRAGMA user_version."""
+ return [
+ """
+ CREATE TABLE todos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ list TEXT NOT NULL,
+ title TEXT NOT NULL,
+ completed INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+ );
+ CREATE INDEX todos_by_list ON todos (list, id);
+ """,
+ ]
+
+
+@fieldwise_init
+struct Todo(Copyable, Movable):
+ var id: Int
+ var title: String
+ var completed: Bool
+
+
+struct Stmt(Movable):
+ """One prepared statement, finalized when it goes out of scope."""
+
+ var handle: Int
+ var db: Int
+
+ def __init__(out self, db: Int, sql: StringSlice) raises:
+ self.db = db
+ self.handle = 0
+ var rc = external_call["sqlite3_prepare_v2", c_int](
+ db, sql.unsafe_ptr(), c_int(sql.byte_length()), Pointer(to=self.handle), Int(0)
+ )
+ if rc != SQLITE_OK:
+ raise Error("prepare: " + _errmsg(db))
+
+ def __deinit__(deinit self):
+ _ = external_call["sqlite3_finalize", c_int](self.handle)
+
+ def bind(mut self, i: Int, v: Int) raises:
+ if external_call["sqlite3_bind_int64", c_int](self.handle, c_int(i), Int64(v)) != SQLITE_OK:
+ raise Error("bind: " + _errmsg(self.db))
+
+ def bind(mut self, i: Int, v: StringSlice) raises:
+ var rc = external_call["sqlite3_bind_text", c_int](
+ self.handle, c_int(i), v.unsafe_ptr(), c_int(v.byte_length()), Int(SQLITE_TRANSIENT)
+ )
+ if rc != SQLITE_OK:
+ raise Error("bind: " + _errmsg(self.db))
+
+ def step(mut self) raises -> Bool:
+ """Advances one row. True while there is a row to read."""
+ var rc = external_call["sqlite3_step", c_int](self.handle)
+ if rc == SQLITE_ROW:
+ return True
+ if rc == SQLITE_DONE:
+ return False
+ raise Error("step: " + _errmsg(self.db))
+
+ def int(self, col: Int) -> Int:
+ return Int(external_call["sqlite3_column_int64", Int64](self.handle, c_int(col)))
+
+ def text(self, col: Int) -> String:
+ var p = external_call["sqlite3_column_text", Pointer[UInt8, MutAnyOrigin]](self.handle, c_int(col))
+ var n = Int(external_call["sqlite3_column_bytes", c_int](self.handle, c_int(col)))
+ if n == 0:
+ return String()
+ return String(from_utf8_lossy=Span[UInt8](unsafe_ptr=p, length=n))
+
+
+def _errmsg(db: Int) -> String:
+ var p = external_call["sqlite3_errmsg", Pointer[UInt8, MutAnyOrigin]](db)
+ return String(unsafe_from_utf8_ptr=p)
+
+
+struct Store(Movable):
+ """Every query takes the caller's list token, so one list never sees another."""
+
+ var db: Int
+
+ def __init__(out self, path: String) raises:
+ self.db = 0
+ var p = cstring(path)
+ var flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX
+ var rc = external_call["sqlite3_open_v2", c_int](p.unsafe_ptr(), Pointer(to=self.db), c_int(flags), Int(0))
+ if rc != SQLITE_OK:
+ var msg = _errmsg(self.db) if self.db != 0 else String("out of memory")
+ _ = external_call["sqlite3_close", c_int](self.db)
+ raise Error("open " + path + ": " + msg)
+ _ = external_call["sqlite3_busy_timeout", c_int](self.db, c_int(5000))
+ self.exec("PRAGMA journal_mode = WAL")
+ self.exec("PRAGMA synchronous = NORMAL")
+ self.migrate()
+
+ def __deinit__(deinit self):
+ _ = external_call["sqlite3_close", c_int](self.db)
+
+ def exec(self, sql: StringSlice) raises:
+ var s = cstring(String(sql))
+ var rc = external_call["sqlite3_exec", c_int](self.db, s.unsafe_ptr(), Int(0), Int(0), Int(0))
+ if rc != SQLITE_OK:
+ raise Error("exec: " + _errmsg(self.db))
+
+ def migrate(self) raises:
+ var st = Stmt(self.db, "PRAGMA user_version")
+ _ = st.step()
+ var have = st.int(0)
+ var all = migrations()
+ for i in range(have, len(all)):
+ self.exec("BEGIN IMMEDIATE")
+ try:
+ self.exec(all[i])
+ self.exec("PRAGMA user_version = " + String(i + 1))
+ self.exec("COMMIT")
+ except e:
+ self.exec("ROLLBACK")
+ raise e^
+
+ def changes(self) -> Int:
+ return Int(external_call["sqlite3_changes", c_int](self.db))
+
+ def todos(self, list: String) raises -> List[Todo]:
+ var st = Stmt(self.db, "SELECT id, title, completed FROM todos WHERE list = ? ORDER BY id")
+ st.bind(1, list)
+ var out = List[Todo]()
+ while st.step():
+ out.append(Todo(st.int(0), st.text(1), st.int(2) != 0))
+ return out^
+
+ def add(self, list: String, title: String) raises:
+ var st = Stmt(self.db, "INSERT INTO todos (list, title) VALUES (?, ?)")
+ st.bind(1, list)
+ st.bind(2, title)
+ _ = st.step()
+
+ def toggle(self, list: String, id: Int) raises -> Bool:
+ var st = Stmt(self.db, "UPDATE todos SET completed = 1 - completed WHERE id = ? AND list = ?")
+ st.bind(1, id)
+ st.bind(2, list)
+ _ = st.step()
+ return self.changes() > 0
+
+ def rename(self, list: String, id: Int, title: String) raises -> Bool:
+ var st = Stmt(self.db, "UPDATE todos SET title = ? WHERE id = ? AND list = ?")
+ st.bind(1, title)
+ st.bind(2, id)
+ st.bind(3, list)
+ _ = st.step()
+ return self.changes() > 0
+
+ def destroy(self, list: String, id: Int) raises -> Bool:
+ var st = Stmt(self.db, "DELETE FROM todos WHERE id = ? AND list = ?")
+ st.bind(1, id)
+ st.bind(2, list)
+ _ = st.step()
+ return self.changes() > 0
+
+ def toggle_all(self, list: String) raises:
+ """Completes every item, or reopens them all if all were completed."""
+ var st = Stmt(
+ self.db,
+ """UPDATE todos SET completed = NOT (
+ SELECT coalesce(min(completed), 0) FROM todos WHERE list = ?1
+ ) WHERE list = ?1""",
+ )
+ st.bind(1, list)
+ _ = st.step()
+
+ def clear_completed(self, list: String) raises:
+ var st = Stmt(self.db, "DELETE FROM todos WHERE list = ? AND completed = 1")
+ st.bind(1, list)
+ _ = st.step()
diff --git a/apps/mojo/src/http.mojo b/apps/mojo/src/http.mojo
new file mode 100644
index 0000000..d5b5502
--- /dev/null
+++ b/apps/mojo/src/http.mojo
@@ -0,0 +1,237 @@
+"""HTTP/1.1 requests and responses: just enough for a browser and a proxy."""
+
+comptime MAX_HEAD = 64 * 1024
+comptime MAX_BODY = 1024 * 1024
+
+
+@fieldwise_init
+struct Header(Copyable, Movable):
+ var name: String # lower-cased
+ var value: String
+
+
+struct Request(Movable):
+ var method: String
+ var path: String
+ var query: String
+ var headers: List[Header]
+ var body: String
+ var keep_alive: Bool
+
+ def __init__(out self, method: String, target: String):
+ self.method = method
+ var q = target.find("?")
+ if q >= 0:
+ self.path = String(target[byte=:q])
+ self.query = String(target[byte = q + 1 :])
+ else:
+ self.path = target
+ self.query = String()
+ self.headers = List[Header]()
+ self.body = String()
+ self.keep_alive = True
+
+ def header(self, name: StringSlice) -> String:
+ """The first header called `name` (lower-case), or empty."""
+ for h in self.headers:
+ if h.name == name:
+ return h.value
+ return String()
+
+ def has_header(self, name: StringSlice) -> Bool:
+ for h in self.headers:
+ if h.name == name:
+ return True
+ return False
+
+ def cookie(self, name: StringSlice) -> String:
+ for h in self.headers:
+ if h.name != "cookie":
+ continue
+ for part in h.value.split(";"):
+ var kv = part.strip()
+ var eq = kv.find("=")
+ if eq > 0 and kv[byte=:eq] == name:
+ return String(kv[byte = eq + 1 :])
+ return String()
+
+ def form(self, name: StringSlice) -> String:
+ return form_value(self.body, name)
+
+ def query_value(self, name: StringSlice) -> String:
+ return form_value(self.query, name)
+
+
+struct Response(Movable):
+ var status: Int
+ var headers: List[Header]
+ var body: String
+
+ def __init__(out self, status: Int, content_type: String, var body: String):
+ self.status = status
+ self.headers = List[Header]()
+ self.body = body^
+ if content_type:
+ self.set("Content-Type", content_type)
+
+ def set(mut self, name: String, value: String):
+ self.headers.append(Header(name, value))
+
+ def header(self, name: StringSlice) -> String:
+ for h in self.headers:
+ if h.name == name:
+ return h.value
+ return String()
+
+ def write_to(self, mut out: List[UInt8], head_only: Bool, keep_alive: Bool):
+ var head = String("HTTP/1.1 ", self.status, " ", reason(self.status), "\r\n")
+ for h in self.headers:
+ head += h.name + ": " + h.value + "\r\n"
+ head += String("Content-Length: ", self.body.byte_length(), "\r\n")
+ if not keep_alive:
+ head += "Connection: close\r\n"
+ head += "\r\n"
+ out.extend(head.as_bytes())
+ if not head_only:
+ out.extend(self.body.as_bytes())
+
+
+def text(status: Int, body: String) -> Response:
+ return Response(status, "text/plain; charset=utf-8", body + "\n")
+
+
+def redirect(location: String) -> Response:
+ var r = Response(303, "", String())
+ r.set("Location", location)
+ return r^
+
+
+def reason(status: Int) -> StaticString:
+ if status == 200:
+ return "OK"
+ if status == 303:
+ return "See Other"
+ if status == 400:
+ return "Bad Request"
+ if status == 403:
+ return "Forbidden"
+ if status == 404:
+ return "Not Found"
+ if status == 405:
+ return "Method Not Allowed"
+ if status == 413:
+ return "Content Too Large"
+ if status == 501:
+ return "Not Implemented"
+ return "Internal Server Error"
+
+
+# Parsing --------------------------------------------------------------------
+
+comptime NEED_MORE = 0
+comptime PARSED = 1
+comptime BAD = 2
+comptime TOO_LARGE = 3
+
+
+def find_head_end(buf: List[UInt8]) -> Int:
+ """Index just past the blank line ending the head, or -1."""
+ var n = len(buf)
+ var i = 3
+ while i < n:
+ if buf[i] == 10 and buf[i - 1] == 13 and buf[i - 2] == 10 and buf[i - 3] == 13:
+ return i + 1
+ i += 1
+ return -1
+
+
+def parse_request(buf: List[UInt8], mut consumed: Int, mut req: Request) -> Int:
+ """Parses one request from the front of `buf`.
+
+ Returns PARSED (with `consumed` bytes used and `req` filled), NEED_MORE,
+ BAD or TOO_LARGE.
+ """
+ var end = find_head_end(buf)
+ if end < 0:
+ return TOO_LARGE if len(buf) > MAX_HEAD else NEED_MORE
+ if end > MAX_HEAD:
+ return TOO_LARGE
+ var head = String(from_utf8_lossy=Span(buf)[:end - 4])
+ var lines = head.split("\r\n")
+ var first = lines[0].split(" ")
+ if len(first) != 3 or not first[2].startswith("HTTP/1."):
+ return BAD
+ req = Request(String(first[0]), String(first[1]))
+ req.keep_alive = first[2] == "HTTP/1.1"
+ var length = 0
+ for i in range(1, len(lines)):
+ var line = lines[i]
+ var colon = line.find(":")
+ if colon <= 0:
+ return BAD
+ var name = String(line[byte=:colon]).lower()
+ var value = String(line[byte = colon + 1 :].strip())
+ if name == "content-length":
+ try:
+ length = Int(value)
+ except:
+ return BAD
+ if length < 0:
+ return BAD
+ elif name == "transfer-encoding":
+ return BAD
+ elif name == "connection":
+ var v = value.lower()
+ if v == "close":
+ req.keep_alive = False
+ elif v == "keep-alive":
+ req.keep_alive = True
+ req.headers.append(Header(name^, value^))
+ if length > MAX_BODY:
+ return TOO_LARGE
+ if len(buf) < end + length:
+ return NEED_MORE
+ req.body = String(from_utf8_lossy=Span(buf)[end : end + length])
+ consumed = end + length
+ return PARSED
+
+
+# Forms ----------------------------------------------------------------------
+
+
+def _hex(c: UInt8) -> Int:
+ if c >= 48 and c <= 57:
+ return Int(c) - 48
+ if c >= 97 and c <= 102:
+ return Int(c) - 87
+ if c >= 65 and c <= 70:
+ return Int(c) - 55
+ return -1
+
+
+def url_decode(s: StringSlice) -> String:
+ """Decodes application/x-www-form-urlencoded text: `+` and `%XX`."""
+ var b = s.as_bytes()
+ var out = List[UInt8](capacity=len(b))
+ var i = 0
+ while i < len(b):
+ var c = b[i]
+ if c == 43: # '+'
+ out.append(32)
+ elif c == 37 and i + 2 < len(b) and _hex(b[i + 1]) >= 0 and _hex(b[i + 2]) >= 0:
+ out.append(UInt8(_hex(b[i + 1]) * 16 + _hex(b[i + 2])))
+ i += 2
+ else:
+ out.append(c)
+ i += 1
+ return String(from_utf8_lossy=out)
+
+
+def form_value(encoded: StringSlice, name: StringSlice) -> String:
+ """The first value of `name` in an urlencoded string, decoded, or empty."""
+ for pair in encoded.split("&"):
+ var eq = pair.find("=")
+ var key = pair if eq < 0 else pair[byte=:eq]
+ if url_decode(key) == name:
+ return url_decode(pair[byte = eq + 1 :]) if eq >= 0 else String()
+ return String()
diff --git a/apps/mojo/src/libc.mojo b/apps/mojo/src/libc.mojo
new file mode 100644
index 0000000..dfadd9d
--- /dev/null
+++ b/apps/mojo/src/libc.mojo
@@ -0,0 +1,183 @@
+"""The few libc calls the server needs, declared once.
+
+Mojo's standard library has no sockets, so the server talks to the kernel
+through these thin wrappers. Every constant here has the same value on
+linux-x86_64 and linux-arm64.
+"""
+
+from std.ffi import external_call, c_int
+
+comptime AF_UNIX = 1
+comptime AF_INET = 2
+comptime SOCK_STREAM = 1
+comptime SOCK_NONBLOCK = 0o4000
+comptime SOCK_CLOEXEC = 0o2000000
+comptime SOL_SOCKET = 1
+comptime SO_REUSEADDR = 2
+comptime MSG_NOSIGNAL = 0x4000
+
+comptime POLLIN: Int16 = 1
+comptime POLLOUT: Int16 = 4
+comptime POLLERR: Int16 = 8
+comptime POLLHUP: Int16 = 16
+
+comptime EINTR = 4
+comptime EAGAIN = 11
+
+comptime SIGINT = 2
+comptime SIGTERM = 15
+
+
+def errno() -> Int:
+ var p = external_call["__errno_location", Pointer[Int32, MutAnyOrigin]]()
+ return Int(p[])
+
+
+def cstring(s: String) -> List[UInt8]:
+ """Returns the bytes of `s` followed by a NUL, for C APIs that want one."""
+ var b = List[UInt8](capacity=s.byte_length() + 1)
+ for c in s.as_bytes():
+ b.append(c)
+ b.append(0)
+ return b^
+
+
+def close_fd(fd: Int):
+ _ = external_call["close", c_int](c_int(fd))
+
+
+def unlink(path: String):
+ var p = cstring(path)
+ _ = external_call["unlink", c_int](p.unsafe_ptr())
+
+
+def now() -> Int:
+ """Seconds since the epoch."""
+ return external_call["time", Int](Int(0))
+
+
+def random_bytes(n: Int) raises -> List[UInt8]:
+ var buf = List[UInt8](length=n, fill=0)
+ var got = 0
+ while got < n:
+ var r = external_call["getrandom", Int](buf.unsafe_ptr().unsafe_offset(got), n - got, c_int(0))
+ if r < 0:
+ if errno() == EINTR:
+ continue
+ raise Error("getrandom failed")
+ got += r
+ return buf^
+
+
+def _set_reuseaddr(fd: Int):
+ var one = Int32(1)
+ _ = external_call["setsockopt", c_int](
+ c_int(fd), c_int(SOL_SOCKET), c_int(SO_REUSEADDR), Pointer(to=one), c_int(4)
+ )
+
+
+def _bind_listen(fd: Int, addr: List[UInt8], what: String) raises:
+ if external_call["bind", c_int](c_int(fd), addr.unsafe_ptr(), c_int(len(addr))) != 0:
+ var e = errno()
+ close_fd(fd)
+ raise Error("bind " + what + ": errno " + String(e))
+ if external_call["listen", c_int](c_int(fd), c_int(512)) != 0:
+ var e = errno()
+ close_fd(fd)
+ raise Error("listen " + what + ": errno " + String(e))
+
+
+def listen_unix(path: String) raises -> Int:
+ """Listens on a unix socket at `path`, replacing a stale one."""
+ if path.byte_length() > 107:
+ raise Error("socket path too long: " + path)
+ var fd = Int(external_call["socket", c_int](c_int(AF_UNIX), c_int(SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC), c_int(0)))
+ if fd < 0:
+ raise Error("socket: errno " + String(errno()))
+ unlink(path)
+ # struct sockaddr_un: sa_family_t, then a NUL-terminated path.
+ var addr = List[UInt8](length=110, fill=0)
+ addr[0] = UInt8(AF_UNIX)
+ var i = 2
+ for c in path.as_bytes():
+ addr[i] = c
+ i += 1
+ _bind_listen(fd, addr, path)
+ return fd
+
+
+def listen_tcp(host: String, port: Int) raises -> Int:
+ """Listens on an IPv4 address such as 127.0.0.1."""
+ var fd = Int(external_call["socket", c_int](c_int(AF_INET), c_int(SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC), c_int(0)))
+ if fd < 0:
+ raise Error("socket: errno " + String(errno()))
+ _set_reuseaddr(fd)
+ # struct sockaddr_in: family, port (big-endian), address, padding.
+ var addr = List[UInt8](length=16, fill=0)
+ addr[0] = UInt8(AF_INET)
+ addr[2] = UInt8((port >> 8) & 0xFF)
+ addr[3] = UInt8(port & 0xFF)
+ var h = cstring(host)
+ if external_call["inet_pton", c_int](c_int(AF_INET), h.unsafe_ptr(), addr.unsafe_ptr().unsafe_offset(4)) != 1:
+ close_fd(fd)
+ raise Error("not an IPv4 address: " + host)
+ _bind_listen(fd, addr, host + ":" + String(port))
+ return fd
+
+
+def accept(listen_fd: Int) -> Int:
+ """Accepts one pending connection, non-blocking. Returns -1 if none."""
+ return Int(external_call["accept4", c_int](c_int(listen_fd), Int(0), Int(0), c_int(SOCK_NONBLOCK | SOCK_CLOEXEC)))
+
+
+def read_into(fd: Int, mut buf: List[UInt8], limit: Int) -> Int:
+ """Appends up to `limit` bytes read from `fd` to `buf`. Returns read(2)'s result."""
+ var start = len(buf)
+ buf.resize(start + limit, 0)
+ var n = external_call["read", Int](c_int(fd), buf.unsafe_ptr().unsafe_offset(start), limit)
+ buf.resize(start + (n if n > 0 else 0), 0)
+ return n
+
+
+def send_some(fd: Int, buf: List[UInt8], offset: Int) -> Int:
+ """Sends as much of `buf[offset:]` as the socket takes, without SIGPIPE."""
+ return external_call["send", Int](c_int(fd), buf.unsafe_ptr().unsafe_offset(offset), len(buf) - offset, c_int(MSG_NOSIGNAL))
+
+
+def poll(mut fds: List[Int32], timeout_ms: Int) -> Int:
+ """`fds` is a flat array of struct pollfd: fd, then events | revents << 16."""
+ return Int(external_call["poll", c_int](fds.unsafe_ptr(), Int(len(fds) // 2), c_int(timeout_ms)))
+
+
+# The write end of the shutdown pipe sits at this fixed descriptor, because
+# a signal handler can't capture state and Mojo has no global variables.
+comptime TERM_PIPE_FD = 100
+comptime F_GETFD = 1
+
+
+def _on_termination(sig: c_int):
+ # Runs in a signal handler, on whichever thread the kernel picks: only
+ # async-signal-safe calls here.
+ var b = UInt8(sig)
+ _ = external_call["write", Int](c_int(TERM_PIPE_FD), Pointer(to=b), Int(1))
+
+
+def termination_fd() raises -> Int:
+ """Returns a descriptor that becomes readable on SIGTERM or SIGINT.
+
+ The Mojo runtime starts worker threads before `main` and catches SIGTERM
+ itself, so blocking the signal in this thread (or using a signalfd)
+ can't work. Instead a handler writes a byte to a pipe the poll loop
+ watches.
+ """
+ var fds = List[Int32](length=2, fill=0)
+ if external_call["pipe2", c_int](fds.unsafe_ptr(), c_int(SOCK_NONBLOCK | SOCK_CLOEXEC)) != 0:
+ raise Error("pipe2: errno " + String(errno()))
+ if external_call["fcntl", c_int](c_int(TERM_PIPE_FD), c_int(F_GETFD)) != -1:
+ raise Error("descriptor " + String(TERM_PIPE_FD) + " is already in use")
+ if external_call["dup3", c_int](fds[1], c_int(TERM_PIPE_FD), c_int(SOCK_CLOEXEC)) < 0:
+ raise Error("dup3: errno " + String(errno()))
+ close_fd(Int(fds[1]))
+ _ = external_call["signal", Int](c_int(SIGTERM), _on_termination)
+ _ = external_call["signal", Int](c_int(SIGINT), _on_termination)
+ return Int(fds[0])
diff --git a/apps/mojo/src/main.mojo b/apps/mojo/src/main.mojo
new file mode 100644
index 0000000..a308630
--- /dev/null
+++ b/apps/mojo/src/main.mojo
@@ -0,0 +1,196 @@
+"""Todo-mojo: TodoMVC served from one process, one SQLite file.
+
+ app --socket <path> --db <path> (on Carloku)
+ app --listen 127.0.0.1:<port> --db <path> (locally)
+"""
+
+from std.os import getenv
+from std.sys import argv
+from app import App
+from db import Store
+from http import Request, Response, text, parse_request, PARSED, NEED_MORE, TOO_LARGE
+import libc
+from libc import POLLIN, POLLOUT, POLLERR, POLLHUP, EAGAIN, EINTR
+
+comptime IDLE_SECONDS = 30
+comptime READ_CHUNK = 64 * 1024
+
+
+struct Conn(Movable):
+ var fd: Int
+ var inbuf: List[UInt8]
+ var outbuf: List[UInt8]
+ var sent: Int
+ var closing: Bool # close once outbuf is sent
+ var last_active: Int
+
+ def __init__(out self, fd: Int):
+ self.fd = fd
+ self.inbuf = List[UInt8]()
+ self.outbuf = List[UInt8]()
+ self.sent = 0
+ self.closing = False
+ self.last_active = libc.now()
+
+
+def usage() raises:
+ raise Error("usage: app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>")
+
+
+def main() raises:
+ var socket_path = String()
+ var listen = String()
+ var db_path = String()
+ var args = argv()
+ var i = 1
+ while i < len(args):
+ var a = args[i]
+ if i + 1 >= len(args):
+ usage()
+ if a == "--socket":
+ socket_path = String(args[i + 1])
+ elif a == "--listen":
+ listen = String(args[i + 1])
+ elif a == "--db":
+ db_path = String(args[i + 1])
+ else:
+ usage()
+ i += 2
+ if not db_path or (not socket_path) == (not listen):
+ usage()
+ var term_fd = libc.termination_fd()
+
+ var origins = List[String]()
+ var host = getenv("CARLOS_HOST", "")
+ if host:
+ origins.append("https://" + host)
+ var listen_fd: Int
+ if socket_path:
+ listen_fd = libc.listen_unix(socket_path)
+ else:
+ var colon = listen.rfind(":")
+ if colon < 0:
+ usage()
+ var addr = String(listen[byte=:colon])
+ if addr != "127.0.0.1":
+ raise Error("--listen must be 127.0.0.1:<port>; refusing to bind " + addr)
+ listen_fd = libc.listen_tcp(addr, Int(listen[byte = colon + 1 :]))
+ origins.append("http://" + listen)
+
+ var app = App(Store(db_path), origins^, secure_cookie=Bool(socket_path))
+ print("todo-mojo listening on", socket_path if socket_path else listen)
+ serve(app, listen_fd, term_fd)
+
+ libc.close_fd(listen_fd)
+ if socket_path:
+ libc.unlink(socket_path)
+ print("todo-mojo stopped")
+
+
+def serve(app: App, listen_fd: Int, term_fd: Int):
+ """One thread, non-blocking sockets, poll(2). Returns on SIGTERM or SIGINT."""
+ var conns = List[Conn]()
+ var fds = List[Int32]()
+ while True:
+ fds.clear()
+ fds.append(Int32(listen_fd))
+ fds.append(Int32(POLLIN))
+ fds.append(Int32(term_fd))
+ fds.append(Int32(POLLIN))
+ for idx in range(len(conns)):
+ fds.append(Int32(conns[idx].fd))
+ fds.append(Int32(POLLOUT if len(conns[idx].outbuf) > conns[idx].sent else POLLIN))
+ var n = libc.poll(fds, 1000)
+ if n < 0 and libc.errno() != EINTR:
+ print("poll failed: errno", libc.errno())
+ break
+ if _revents(fds, 1) != 0:
+ break
+
+ var now = libc.now()
+ # Service existing connections first; new ones join at the end.
+ for idx in range(len(conns)):
+ ref c = conns[idx]
+ var ev = _revents(fds, idx + 2) if n > 0 else Int16(0)
+ if ev & (POLLIN | POLLHUP | POLLERR) and len(c.outbuf) == c.sent:
+ _on_readable(app, c)
+ c.last_active = now
+ if len(c.outbuf) > c.sent and (ev & POLLOUT or ev == 0):
+ _flush(c)
+ var done = c.closing and len(c.outbuf) == c.sent
+ if done or now - c.last_active > IDLE_SECONDS:
+ libc.close_fd(c.fd)
+ c.fd = -1
+ # Drop closed connections.
+ var j = len(conns) - 1
+ while j >= 0:
+ if conns[j].fd < 0:
+ _ = conns.pop(j)
+ j -= 1
+
+ if _revents(fds, 0) & POLLIN:
+ while True:
+ var fd = libc.accept(listen_fd)
+ if fd < 0:
+ break
+ conns.append(Conn(fd))
+ _on_readable(app, conns[len(conns) - 1])
+
+ for idx in range(len(conns)):
+ libc.close_fd(conns[idx].fd)
+
+
+def _revents(fds: List[Int32], slot: Int) -> Int16:
+ return Int16(fds[slot * 2 + 1] >> 16)
+
+
+def _on_readable(app: App, mut c: Conn):
+ """Reads what has arrived, answers every complete request, and starts sending."""
+ while True:
+ var r = libc.read_into(c.fd, c.inbuf, READ_CHUNK)
+ if r > 0:
+ continue
+ if r == 0:
+ c.closing = True # peer finished sending
+ elif libc.errno() != EAGAIN and libc.errno() != EINTR:
+ c.closing = True
+ c.inbuf.clear()
+ break
+ while len(c.inbuf) > 0:
+ var consumed = 0
+ var req = Request("GET", "/")
+ var st = parse_request(c.inbuf, consumed, req)
+ if st == NEED_MORE:
+ break
+ if st != PARSED:
+ var resp = text(413 if st == TOO_LARGE else 400, "Bad Request" if st != TOO_LARGE else "Content Too Large")
+ resp.write_to(c.outbuf, False, False)
+ c.inbuf.clear()
+ c.closing = True
+ break
+ var resp = app.handle(req)
+ resp.write_to(c.outbuf, req.method == "HEAD", req.keep_alive)
+ if not req.keep_alive:
+ c.inbuf.clear()
+ c.closing = True
+ break
+ var rest = List[UInt8](capacity=len(c.inbuf) - consumed)
+ rest.extend(Span(c.inbuf)[consumed:])
+ c.inbuf = rest^
+ _flush(c)
+
+
+def _flush(mut c: Conn):
+ while c.sent < len(c.outbuf):
+ var w = libc.send_some(c.fd, c.outbuf, c.sent)
+ if w > 0:
+ c.sent += w
+ continue
+ if w < 0 and (libc.errno() == EAGAIN or libc.errno() == EINTR):
+ return
+ c.closing = True
+ c.outbuf.clear()
+ c.sent = 0
+ return
+ c.outbuf.clear()
+ c.sent = 0
diff --git a/apps/mojo/src/render.mojo b/apps/mojo/src/render.mojo
new file mode 100644
index 0000000..a7b94fc
--- /dev/null
+++ b/apps/mojo/src/render.mojo
@@ -0,0 +1,190 @@
+"""Server-side HTML: the TodoMVC template from shared/reference.html."""
+
+from db import Todo
+
+
+def escape(s: StringSlice) -> String:
+ """Escapes text for HTML element content and quoted attribute values."""
+ var out = String(capacity_bytes=s.byte_length() + 16)
+ var start = 0
+ var b = s.as_bytes()
+ for i in range(len(b)):
+ var c = b[i]
+ var rep: StaticString
+ if c == 38:
+ rep = "&amp;"
+ elif c == 60:
+ rep = "&lt;"
+ elif c == 62:
+ rep = "&gt;"
+ elif c == 34:
+ rep = "&quot;"
+ elif c == 39:
+ rep = "&#39;"
+ else:
+ continue
+ out += s[byte=start:i]
+ out += rep
+ start = i + 1
+ out += s[byte=start:]
+ return out^
+
+
+def filter_path(filter: StringSlice) -> StaticString:
+ if filter == "active":
+ return "/active"
+ if filter == "completed":
+ return "/completed"
+ return "/"
+
+
+def _filter_link(mut out: String, filter: StringSlice, name: StringSlice, label: StaticString):
+ out += '<li><a href="'
+ out += filter_path(name)
+ out += '"'
+ if filter == name:
+ out += ' class="selected"'
+ out += ">"
+ out += label
+ out += "</a></li>"
+
+
+def _hidden_filter(mut out: String, filter: StringSlice):
+ out += '<input type="hidden" name="filter" value="'
+ out += filter
+ out += '">'
+
+
+def _item(mut out: String, t: Todo, filter: StringSlice, editing: Bool):
+ var id = String(t.id)
+ var title = escape(t.title)
+ out += '<li data-id="'
+ out += id
+ out += '"'
+ if t.completed or editing:
+ out += ' class="'
+ if t.completed:
+ out += "completed"
+ if t.completed and editing:
+ out += " "
+ if editing:
+ out += "editing"
+ out += '"'
+ out += '><div class="view"><input class="toggle" type="checkbox" aria-label="Toggle"'
+ if t.completed:
+ out += " checked"
+ out += "><label>"
+ out += title
+ out += '</label><a class="nojs edit-link" data-action="edit" href="'
+ out += filter_path(filter)
+ out += "?edit="
+ out += id
+ out += '">Edit</a><form method="post" action="/todos/'
+ out += id
+ out += '/toggle">'
+ _hidden_filter(out, filter)
+ out += '<button class="nojs toggle-button" data-action="toggle">Toggle</button></form>'
+ out += '<form method="post" action="/todos/'
+ out += id
+ out += '/destroy">'
+ _hidden_filter(out, filter)
+ out += '<button class="destroy" aria-label="Delete"></button></form></div>'
+ out += '<form method="post" action="/todos/'
+ out += id
+ out += '/edit">'
+ _hidden_filter(out, filter)
+ out += '<input class="edit" name="title" value="'
+ out += title
+ out += '" aria-label="Edit"'
+ if editing:
+ out += " autofocus"
+ out += "></form></li>"
+
+
+def page(todos: List[Todo], filter: StringSlice, edit_id: Int, language: StaticString, version: StaticString) -> String:
+ """The whole page for one filter, with item `edit_id` (or none, -1) being edited."""
+ var active = 0
+ for t in todos:
+ if not t.completed:
+ active += 1
+ var completed = len(todos) - active
+ var editing_shown = False
+ for t in todos:
+ if t.id == edit_id:
+ editing_shown = True
+
+ var out = String(capacity_bytes=4096 + len(todos) * 700)
+ out += """<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>TodoMVC</title>
+<link rel="stylesheet" href="/static/base.css">
+<link rel="stylesheet" href="/static/index.css">
+<link rel="stylesheet" href="/static/app.css?v="""
+ out += version
+ out += """">
+<script type="module" src="/static/app.js?v="""
+ out += version
+ out += """"></script>
+</head>
+<body>
+<section class="todoapp">
+<header class="header">
+<h1>todos</h1>
+<form method="post" action="/todos">"""
+ _hidden_filter(out, filter)
+ out += '<input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off" aria-label="New todo"'
+ if not editing_shown:
+ out += " autofocus"
+ out += "></form>\n</header>\n"
+
+ out += '<section class="main"'
+ if len(todos) == 0:
+ out += " hidden"
+ out += '>\n<input id="toggle-all" class="toggle-all" type="checkbox"'
+ if len(todos) > 0 and active == 0:
+ out += " checked"
+ out += '>\n<label for="toggle-all">Mark all as complete</label>\n'
+ out += '<form method="post" action="/todos/toggle-all">'
+ _hidden_filter(out, filter)
+ out += '<button class="nojs toggle-all-button" data-action="toggle-all">Mark all as complete</button></form>\n'
+ out += '<ul class="todo-list">\n'
+ for t in todos:
+ if filter == "active" and t.completed:
+ continue
+ if filter == "completed" and not t.completed:
+ continue
+ _item(out, t, filter, t.id == edit_id)
+ out += "\n"
+ out += "</ul>\n</section>\n"
+
+ out += '<footer class="footer"'
+ if len(todos) == 0:
+ out += " hidden"
+ out += '>\n<span class="todo-count"><strong>'
+ out += String(active)
+ out += "</strong> "
+ out += "item left" if active == 1 else "items left"
+ out += '</span>\n<ul class="filters">'
+ _filter_link(out, filter, "all", "All")
+ _filter_link(out, filter, "active", "Active")
+ _filter_link(out, filter, "completed", "Completed")
+ out += '</ul>\n<form method="post" action="/todos/clear-completed">'
+ _hidden_filter(out, filter)
+ out += '<button class="clear-completed"'
+ if completed == 0:
+ out += " hidden"
+ out += ">Clear completed</button></form>\n</footer>\n</section>\n"
+ out += """<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> · """
+ out += language
+ out += """</p>
+<p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+</footer>
+</body>
+</html>
+"""
+ return out^
diff --git a/apps/mojo/static/app.css b/apps/mojo/static/app.css
new file mode 100644
index 0000000..3f65520
--- /dev/null
+++ b/apps/mojo/static/app.css
@@ -0,0 +1,59 @@
+/* What the TodoMVC stylesheets don't cover: the no-JavaScript controls. */
+
+[hidden] {
+ display: none !important;
+}
+
+/* Forms wrap controls without adding boxes, so the layout is TodoMVC's own. */
+.todoapp form {
+ display: contents;
+}
+
+/* With JavaScript on, the page's script takes over and these controls go. */
+html.js .nojs {
+ display: none !important;
+}
+
+/* Without JavaScript, each control below sits invisibly over the spot a
+ person would click with JavaScript on, and submits the matching form. */
+.todo-list li .toggle-button,
+.toggle-all-button {
+ position: absolute;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 0;
+ color: transparent;
+ cursor: pointer;
+}
+
+.todo-list li .toggle-button {
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+}
+
+.toggle-all-button {
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+}
+
+.todo-list li .edit-link {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 60px;
+ right: 60px;
+ font-size: 0;
+ color: transparent;
+}
+
+.todo-list li .toggle-button:focus-visible,
+.toggle-all-button:focus-visible,
+.todo-list li .edit-link:focus-visible {
+ outline: 1px solid #CF7D7D;
+}
diff --git a/apps/mojo/static/app.js b/apps/mojo/static/app.js
new file mode 100644
index 0000000..fc3ce74
--- /dev/null
+++ b/apps/mojo/static/app.js
@@ -0,0 +1,69 @@
+// Progressive enhancement for the server-rendered TodoMVC page. Every
+// change is the same form POST the page makes without JavaScript; this
+// script sends it in the background, asks for the updated page in the same
+// response, and swaps in the server's list and footer.
+document.documentElement.classList.add('js');
+
+const app = document.querySelector('.todoapp');
+let queue = Promise.resolve();
+
+function send(form) {
+ const body = new URLSearchParams(new FormData(form));
+ queue = queue
+ .then(async () => {
+ const res = await fetch(form.action, {
+ method: 'POST',
+ body,
+ headers: { 'X-Requested-With': 'fetch' },
+ });
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+ const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
+ for (const sel of ['.main', '.footer']) {
+ app.querySelector(sel).replaceWith(document.adoptNode(doc.querySelector(sel)));
+ }
+ })
+ .catch(() => location.reload());
+ return queue;
+}
+
+app.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const edit = form.querySelector('.edit');
+ if (edit) edit.closest('li').dataset.done = '1';
+ send(form);
+ const input = form.querySelector('.new-todo');
+ if (input) input.value = '';
+});
+
+app.addEventListener('change', (e) => {
+ const t = e.target;
+ if (t.matches('.toggle')) send(t.closest('li').querySelector('form[action$="/toggle"]'));
+ else if (t.matches('.toggle-all')) send(app.querySelector('form[action="/todos/toggle-all"]'));
+});
+
+app.addEventListener('dblclick', (e) => {
+ const label = e.target.closest('.todo-list label');
+ if (!label) return;
+ const li = label.closest('li');
+ const input = li.querySelector('.edit');
+ delete li.dataset.done;
+ li.classList.add('editing');
+ input.value = label.textContent;
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+});
+
+app.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape' || !e.target.matches('.edit')) return;
+ const li = e.target.closest('li');
+ li.dataset.done = '1';
+ li.classList.remove('editing');
+ e.target.value = li.querySelector('label').textContent;
+});
+
+app.addEventListener('focusout', (e) => {
+ if (!e.target.matches('.edit')) return;
+ const li = e.target.closest('li');
+ if (li.classList.contains('editing') && !li.dataset.done) e.target.form.requestSubmit();
+});
diff --git a/apps/mojo/tests/smoke.sh b/apps/mojo/tests/smoke.sh
new file mode 100755
index 0000000..6a7b33f
--- /dev/null
+++ b/apps/mojo/tests/smoke.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Runs the real binary on a unix socket and checks what the unit tests
+# can't: the poll loop, keep-alive, the cookie round trip, and a clean
+# exit on SIGTERM.
+# tests/smoke.sh <binary>
+set -euo pipefail
+bin=$1
+work=$(mktemp -d -p "${TMPDIR:-$HOME/.cache}")
+pid=
+trap '[ -n "$pid" ] && kill "$pid" 2>/dev/null; rm -rf "$work"' EXIT
+CARLOS_HOST=smoke.invalid "$bin" --socket "$work/app.sock" --db "$work/app.db" > "$work/log" 2>&1 &
+pid=$!
+for _ in $(seq 1 50); do [ -S "$work/app.sock" ] && break; sleep 0.1; done
+# The cookie is Secure in socket mode, so curl wouldn't send it back over http.
+c() { curl -s --unix-socket "$work/app.sock" -H "Cookie: todo_list=$(printf "5%.0s" {1..64})" "$@"; }
+fail() { echo "smoke: $*" >&2; cat "$work/log" >&2; exit 1; }
+
+[ "$(c http://smoke.invalid/healthz)" = ok ] || fail "healthz"
+# Two requests on one connection.
+n=$(c -o /dev/null -w '%{num_connects}\n' http://smoke.invalid/healthz -o /dev/null http://smoke.invalid/api/version | awk '{s+=$1} END {print s}')
+[ "$n" = 1 ] || fail "keep-alive: $n connections"
+code=$(c -o /dev/null -w '%{http_code}' -H 'Origin: https://smoke.invalid' --data-urlencode 'title=naïve <b>' http://smoke.invalid/todos)
+[ "$code" = 303 ] || fail "add answered $code"
+c http://smoke.invalid/ | grep -q '<label>naïve &lt;b&gt;</label>' || fail "item not shown"
+code=$(c -o /dev/null -w '%{http_code}' -H 'Origin: https://evil.example' -d title=x http://smoke.invalid/todos)
+[ "$code" = 403 ] || fail "cross-origin POST answered $code"
+
+kill -TERM "$pid"
+set +e; wait "$pid"; status=$?; set -e
+pid=
+[ "$status" = 0 ] || fail "exit status $status after SIGTERM"
+[ ! -e "$work/app.sock" ] || fail "socket left behind"
+echo "smoke: ok"
diff --git a/apps/mojo/tests/test_main.mojo b/apps/mojo/tests/test_main.mojo
new file mode 100644
index 0000000..edd65e8
--- /dev/null
+++ b/apps/mojo/tests/test_main.mojo
@@ -0,0 +1,312 @@
+"""Unit tests: parsing, rendering, the store, and the routes end to end.
+
+Built and run by `make test`. Each test is a function; main runs them all
+and exits non-zero on the first failure.
+"""
+
+from std.testing import assert_equal, assert_true, assert_false
+from app import App
+from db import Store
+from http import Request, Response, Header, parse_request, url_decode, form_value, PARSED, NEED_MORE, BAD, TOO_LARGE
+from render import escape
+
+comptime ORIGIN = "http://127.0.0.1:9999"
+comptime TOKEN_A = "a" * 64
+comptime TOKEN_B = "b" * 64
+
+
+def bytes_of(s: String) -> List[UInt8]:
+ var b = List[UInt8]()
+ b.extend(s.as_bytes())
+ return b^
+
+
+def new_app() raises -> App:
+ var origins = List[String]()
+ origins.append(ORIGIN)
+ return App(Store(":memory:"), origins^, secure_cookie=False)
+
+
+def get(app: App, target: String, token: String = TOKEN_A) -> Response:
+ var req = Request("GET", target)
+ if token:
+ req.headers.append(Header("cookie", "todo_list=" + token))
+ return app.handle(req)
+
+
+def post(app: App, target: String, body: String, token: String = TOKEN_A, origin: String = ORIGIN) -> Response:
+ var req = Request("POST", target)
+ req.headers.append(Header("content-type", "application/x-www-form-urlencoded"))
+ if token:
+ req.headers.append(Header("cookie", "todo_list=" + token))
+ if origin:
+ req.headers.append(Header("origin", origin))
+ req.body = body
+ return app.handle(req)
+
+
+def count(haystack: String, needle: String) -> Int:
+ return haystack.count(needle)
+
+
+# http ------------------------------------------------------------------------
+
+
+def test_url_decode() raises:
+ assert_equal(url_decode("a+b%20c"), "a b c")
+ assert_equal(url_decode("na%C3%AFve+%F0%9F%9A%80"), "naïve 🚀")
+ assert_equal(url_decode("100%"), "100%")
+ assert_equal(url_decode("%zz"), "%zz")
+
+
+def test_form_value() raises:
+ var body = "filter=active&title=%3Cb%3E+x&empty="
+ assert_equal(form_value(body, "title"), "<b> x")
+ assert_equal(form_value(body, "filter"), "active")
+ assert_equal(form_value(body, "empty"), "")
+ assert_equal(form_value(body, "missing"), "")
+
+
+def test_parse_request() raises:
+ var raw = "POST /todos?x=1 HTTP/1.1\r\nHost: h\r\nContent-Length: 7\r\nCookie: a=1; todo_list=zz\r\n\r\ntitle=x"
+ var buf = bytes_of(raw + "GET / HTTP/1.1\r\n\r\n")
+ var req = Request("GET", "/")
+ var used = 0
+ assert_equal(parse_request(buf, used, req), PARSED)
+ assert_equal(req.method, "POST")
+ assert_equal(req.path, "/todos")
+ assert_equal(req.query, "x=1")
+ assert_equal(req.body, "title=x")
+ assert_equal(req.cookie("todo_list"), "zz")
+ assert_equal(used, raw.byte_length())
+ assert_true(req.keep_alive)
+
+
+def test_parse_request_incomplete_and_bad() raises:
+ var req = Request("GET", "/")
+ var used = 0
+ var partial = bytes_of("POST / HTTP/1.1\r\nContent-Length: 10\r\n\r\nabc")
+ assert_equal(parse_request(partial, used, req), NEED_MORE)
+ var no_end = bytes_of("GET / HTTP/1.1\r\nHost: h\r\n")
+ assert_equal(parse_request(no_end, used, req), NEED_MORE)
+ var garbage = bytes_of("hello\r\n\r\n")
+ assert_equal(parse_request(garbage, used, req), BAD)
+ var chunked = bytes_of("POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n")
+ assert_equal(parse_request(chunked, used, req), BAD)
+ var huge = bytes_of("POST / HTTP/1.1\r\nContent-Length: 99999999\r\n\r\n")
+ assert_equal(parse_request(huge, used, req), TOO_LARGE)
+
+
+def test_http10_closes() raises:
+ var req = Request("GET", "/")
+ var used = 0
+ var buf = bytes_of("GET / HTTP/1.0\r\n\r\n")
+ assert_equal(parse_request(buf, used, req), PARSED)
+ assert_false(req.keep_alive)
+
+
+def test_content_length_counts_bytes() raises:
+ var r = Response(200, "text/plain", "日本")
+ var out = List[UInt8]()
+ r.write_to(out, False, True)
+ var s = String(from_utf8_lossy=out)
+ assert_true("Content-Length: 6\r\n" in s)
+
+
+# render ------------------------------------------------------------------------
+
+
+def test_escape() raises:
+ assert_equal(escape("<img src=x onerror=\"a\"> & 'q'"), "&lt;img src=x onerror=&quot;a&quot;&gt; &amp; &#39;q&#39;")
+ assert_equal(escape("naïve"), "naïve")
+
+
+# store ---------------------------------------------------------------------------
+
+
+def test_store() raises:
+ var s = Store(":memory:")
+ s.add("A", "one")
+ s.add("A", "two")
+ s.add("B", "theirs")
+ var a = s.todos("A")
+ assert_equal(len(a), 2)
+ assert_equal(a[0].title, "one")
+ assert_true(s.toggle("A", a[0].id))
+ assert_false(s.toggle("B", a[0].id)) # someone else's id
+ assert_true(s.todos("A")[0].completed)
+ s.toggle_all("A")
+ assert_true(s.todos("A")[1].completed)
+ s.toggle_all("A")
+ assert_false(s.todos("A")[0].completed)
+ assert_false(s.todos("A")[1].completed)
+ assert_true(s.rename("A", a[1].id, "deux"))
+ assert_equal(s.todos("A")[1].title, "deux")
+ _ = s.toggle("A", a[1].id)
+ s.clear_completed("A")
+ assert_equal(len(s.todos("A")), 1)
+ assert_equal(len(s.todos("B")), 1)
+ assert_true(s.destroy("A", a[0].id))
+ assert_equal(len(s.todos("A")), 0)
+
+
+# routes --------------------------------------------------------------------------
+
+
+def test_health_and_version() raises:
+ var app = new_app()
+ assert_equal(get(app, "/healthz", "").status, 200)
+ var v = get(app, "/api/version", "")
+ assert_equal(v.status, 200)
+ assert_equal(v.header("Set-Cookie"), "")
+
+
+def test_first_visit_sets_cookie() raises:
+ var app = new_app()
+ var r = get(app, "/", "")
+ assert_equal(r.status, 200)
+ var c = r.header("Set-Cookie")
+ assert_true(c.startswith("todo_list="))
+ assert_true("; HttpOnly" in c and "; SameSite=Lax" in c and "; Path=/" in c)
+ assert_false("Secure" in c)
+ assert_equal(c[byte=10:].find(";"), 64)
+ assert_true('<section class="main" hidden>' in r.body)
+ assert_true('<footer class="footer" hidden>' in r.body)
+
+
+def test_garbled_cookie_gets_fresh_list() raises:
+ var app = new_app()
+ var r = get(app, "/", "%%%")
+ assert_equal(r.status, 200)
+ assert_true(r.header("Set-Cookie").startswith("todo_list="))
+ assert_equal(get(app, "/", TOKEN_A).header("Set-Cookie"), "")
+
+
+def test_add_and_filters() raises:
+ var app = new_app()
+ var r = post(app, "/todos", "title=++milk++&filter=active")
+ assert_equal(r.status, 303)
+ assert_equal(r.header("Location"), "/active")
+ _ = post(app, "/todos", "title=+++&filter=all") # blank: nothing added
+ _ = post(app, "/todos", "title=eggs&filter=bogus")
+ var page = get(app, "/").body
+ assert_equal(count(page, "<li data-id="), 2)
+ assert_true("<label>milk</label>" in page)
+ assert_true("<strong>2</strong> items left" in page)
+ assert_true('<a href="/" class="selected">All</a>' in page)
+ assert_true("<button class=\"clear-completed\" hidden>" in page)
+ _ = post(app, "/todos/1/toggle", "filter=completed")
+ page = get(app, "/").body
+ assert_true('<li data-id="1" class="completed">' in page)
+ assert_true("<strong>1</strong> item left" in page)
+ assert_equal(count(get(app, "/active").body, "<li data-id="), 1)
+ var done = get(app, "/completed").body
+ assert_equal(count(done, "<li data-id="), 1)
+ assert_true('<a href="/completed" class="selected">Completed</a>' in done)
+
+
+def test_edit_view_and_rename() raises:
+ var app = new_app()
+ _ = post(app, "/todos", "title=a&filter=all")
+ var page = get(app, "/active?edit=1").body
+ assert_true('<li data-id="1" class="editing">' in page)
+ assert_true('<form method="post" action="/todos/1/edit">' in page)
+ assert_true('href="/active?edit=1"' in page)
+ var r = post(app, "/todos/1/edit", "title=+b+&filter=active")
+ assert_equal(r.status, 303)
+ assert_equal(r.header("Location"), "/active")
+ assert_true("<label>b</label>" in get(app, "/").body)
+ _ = post(app, "/todos/1/edit", "title=+&filter=all") # empty: deleted
+ assert_equal(count(get(app, "/").body, "<li data-id="), 0)
+
+
+def test_toggle_all_and_clear() raises:
+ var app = new_app()
+ _ = post(app, "/todos", "title=a")
+ _ = post(app, "/todos", "title=b")
+ assert_equal(post(app, "/todos/toggle-all", "filter=all").status, 303)
+ var page = get(app, "/").body
+ assert_equal(count(page, 'class="completed"'), 2)
+ assert_true('class="toggle-all" type="checkbox" checked' in page)
+ _ = post(app, "/todos/toggle-all", "")
+ assert_equal(count(get(app, "/").body, 'class="completed"'), 0)
+ _ = post(app, "/todos/2/toggle", "")
+ assert_equal(post(app, "/todos/clear-completed", "filter=all").status, 303)
+ page = get(app, "/").body
+ assert_equal(count(page, "<li data-id="), 1)
+ assert_true("<label>a</label>" in page)
+
+
+def test_html_is_escaped() raises:
+ var app = new_app()
+ _ = post(app, "/todos", "title=%3Cimg+src%3Dx%3E+%26+%22q%22")
+ var page = get(app, "/").body
+ assert_true("<label>&lt;img src=x&gt; &amp; &quot;q&quot;</label>" in page)
+ assert_false("<img" in page)
+
+
+def test_foreign_origin_is_refused() raises:
+ var app = new_app()
+ assert_equal(post(app, "/todos", "title=evil", origin="https://evil.example").status, 403)
+ assert_equal(post(app, "/todos", "title=evil", origin="null").status, 403)
+ assert_equal(count(get(app, "/").body, "<li data-id="), 0)
+ assert_equal(post(app, "/todos", "title=fine", origin="").status, 303)
+
+
+def test_other_lists_ids_are_404() raises:
+ var app = new_app()
+ _ = post(app, "/todos", "title=mine")
+ assert_equal(post(app, "/todos/1/toggle", "", token=TOKEN_B).status, 404)
+ assert_equal(post(app, "/todos/1/edit", "title=x", token=TOKEN_B).status, 404)
+ assert_equal(post(app, "/todos/1/destroy", "", token=TOKEN_B).status, 404)
+ assert_equal(post(app, "/todos/99/toggle", "").status, 404)
+ assert_equal(post(app, "/todos/abc/toggle", "").status, 404)
+ var page = get(app, "/").body
+ assert_true('<li data-id="1">' in page)
+ assert_true("<label>mine</label>" in page)
+
+
+def test_fetch_header_returns_page() raises:
+ var app = new_app()
+ var req = Request("POST", "/todos")
+ req.headers.append(Header("cookie", "todo_list=" + TOKEN_A))
+ req.headers.append(Header("x-requested-with", "fetch"))
+ req.body = "title=x&filter=active"
+ var r = app.handle(req)
+ assert_equal(r.status, 200)
+ assert_true("<label>x</label>" in r.body)
+ assert_true('<a href="/active" class="selected">Active</a>' in r.body)
+
+
+def test_static_and_unknown() raises:
+ var app = new_app()
+ var css = get(app, "/static/index.css", "")
+ assert_equal(css.status, 200)
+ assert_true(css.header("Content-Type").startswith("text/css"))
+ assert_equal(get(app, "/static/nope.css", "").status, 404)
+ assert_equal(get(app, "/nope").status, 404)
+ assert_equal(post(app, "/", "").status, 405)
+ assert_equal(get(app, "/todos").status, 405)
+
+
+def main() raises:
+ test_url_decode()
+ test_form_value()
+ test_parse_request()
+ test_parse_request_incomplete_and_bad()
+ test_http10_closes()
+ test_content_length_counts_bytes()
+ test_escape()
+ test_store()
+ test_health_and_version()
+ test_first_visit_sets_cookie()
+ test_garbled_cookie_gets_fresh_list()
+ test_add_and_filters()
+ test_edit_view_and_rename()
+ test_toggle_all_and_clear()
+ test_html_is_escaped()
+ test_foreign_origin_is_refused()
+ test_other_lists_ids_are_404()
+ test_fetch_header_returns_page()
+ test_static_and_unknown()
+ print("ok: 19 tests passed")