carlos / todomvc-bakeoff Public
feat: mojo builder output
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 files changed,
+2004
−0
apps/mojo/.gitignore+1 −0apps/mojo/AGENTS.md+28 −0apps/mojo/DEPS+1 −0apps/mojo/Makefile+123 −0apps/mojo/README.md+109 −0apps/mojo/STATUS.md+57 −0apps/mojo/scripts/embed.py+38 −0apps/mojo/src/app.mojo+179 −0apps/mojo/src/db.mojo+189 −0apps/mojo/src/http.mojo+237 −0apps/mojo/src/libc.mojo+183 −0apps/mojo/src/main.mojo+196 −0apps/mojo/src/render.mojo+190 −0apps/mojo/static/app.css+59 −0apps/mojo/static/app.js+69 −0apps/mojo/tests/smoke.sh+33 −0apps/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 = "&" |
| + elif c == 60: |
| + rep = "<" |
| + elif c == 62: |
| + rep = ">" |
| + elif c == 34: |
| + rep = """ |
| + elif c == 39: |
| + rep = "'" |
| + 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 <b></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'"), "<img src=x onerror="a"> & 'q'") |
| + 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><img src=x> & "q"</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") |