| 1 | # đŸ¤– todo-nim |
| 2 | |
| 3 | TodoMVC in Nim for the CARLOS bakeoff: server-rendered, works with |
| 4 | JavaScript off, and keeps each visitor's list in SQLite. It runs at |
| 5 | [https://todo-nim.bfj.oncarlos.com](https://todo-nim.bfj.oncarlos.com). |
| 6 | |
| 7 | ## The stack, and why |
| 8 | |
| 9 | - **Nim 2.2, compiled to C.** Nim builds a small native binary, and its C |
| 10 | backend makes the static linux-arm64 build easy: `zig cc` targeting |
| 11 | musl acts as the C compiler and linker, and the result is a 1.7 MB |
| 12 | static executable that starts in milliseconds. |
| 13 | - **HTTP: the standard library's `asynchttpserver`**, on `asyncdispatch`. |
| 14 | A todo list doesn't need more than a single-threaded event loop, and the |
| 15 | standard library means no framework to pin. The popular alternatives |
| 16 | don't fit this platform: Mummy and httpbeast (Jester's server) only |
| 17 | listen on TCP, and Carloku hands the app a unix socket. |
| 18 | `asynchttpserver` doesn't take a unix socket either, but it's a small |
| 19 | gap: `unixServer` in `src/app.nim` binds the socket itself and gives it |
| 20 | to the server through `std/importutils.privateAccess`. The server is a |
| 21 | copy of the standard library's (`src/httpserver.nim`) with one fix: |
| 22 | upstream doesn't limit chunked request bodies, so one POST could |
| 23 | exhaust the instance's memory. The copy holds chunked bodies to the same |
| 24 | 1 MiB limit as `Content-Length` ones. The standard library's docs call |
| 25 | this server basic; it has no request timeouts or keep-alive limits. |
| 26 | Carloku's edge sits in front of it, and that's an acceptable trade for |
| 27 | an app this size. |
| 28 | - **Routing: a `case` on the path.** Six actions and four pages don't need |
| 29 | a router. |
| 30 | - **Templates: Nim's built-in `stdtmpl` source filter** (`src/page.nimf`). |
| 31 | It compiles the template into an ordinary Nim proc, so the markup is |
| 32 | type-checked and there's nothing to load at run time. Titles go through |
| 33 | `xmltree.escape`. |
| 34 | - **SQLite: `db_connector`'s `sqlite3` binding** (the database module that |
| 35 | moved out of Nim's standard library) over the **SQLite 3.54.1 |
| 36 | amalgamation, compiled into the binary.** The binding normally loads |
| 37 | `libsqlite3.so`; `--dynlibOverride:sqlite3` turns that off so it links |
| 38 | against the compiled-in copy. `src/store.nim` prepares every statement |
| 39 | once and binds parameters, so no user input is ever spliced into SQL. |
| 40 | WAL mode, `synchronous = NORMAL`, and numbered migrations that only add |
| 41 | (`PRAGMA user_version` records how far the file has got). |
| 42 | - **JavaScript: one plain ES module, no framework** (`static/app.js`). It |
| 43 | sends the same form POSTs with an `X-Fragment: 1` header, and the server |
| 44 | answers with the re-rendered list section instead of a 303. Rendering |
| 45 | stays in one place, the server. |
| 46 | - **The CSS** is `shared/base.css` and `shared/index.css`, embedded at |
| 47 | compile time with `staticRead` and served byte for byte. |
| 48 | `static/app.css` adds the no-JavaScript controls and hides them once |
| 49 | JavaScript is running. |
| 50 | |
| 51 | Lists are anonymous: the first visit gets a cookie holding 128 random bits |
| 52 | from the OS (`std/sysrand`), and every query is scoped to it. See |
| 53 | `AGENTS.md` for the tenancy declaration. |
| 54 | |
| 55 | ## Build, run, test, deploy |
| 56 | |
| 57 | Put the toolchains on `PATH` first: `source scripts/env.sh` from the repo |
| 58 | root. The first build fetches `db_connector` into `.cache/nimble` and the |
| 59 | SQLite amalgamation into `.cache/dl`, checking it against a pinned SHA-256. |
| 60 | |
| 61 | ``` |
| 62 | make build # dist/app for this machine |
| 63 | make build-arm64 # dist/app-linux-arm64, static (zig cc, musl) |
| 64 | make test # unit tests: the store, and the routes with fake requests |
| 65 | make clean # removes dist/ |
| 66 | make clean-deps # also removes compiled C: .cache/nimcache-* and zig's caches |
| 67 | ``` |
| 68 | |
| 69 | Run it locally and drive the shared behaviour suite against it: |
| 70 | |
| 71 | ``` |
| 72 | dist/app --listen 127.0.0.1:8080 --db $TMPDIR/nim.db |
| 73 | scripts/test-app.sh http://127.0.0.1:8080 $TMPDIR/nim-suite.json |
| 74 | ``` |
| 75 | |
| 76 | On Carloku it runs as `app --socket <path> --db <path>`, reads |
| 77 | `CARLOS_HOST` for its origin, and sets `Secure` on its cookie. It exits |
| 78 | cleanly on SIGTERM. |
| 79 | |
| 80 | `/api/version` reports the git short SHA the binary was built from, so |
| 81 | commit before building what you deploy. Then: |
| 82 | |
| 83 | ``` |
| 84 | scripts/boot-check.sh dist/app-linux-arm64 |
| 85 | make deploy MESSAGE="what changed" |
| 86 | ``` |
| 87 | |
| 88 | ## Layout |
| 89 | |
| 90 | | Path | What | |
| 91 | |---|---| |
| 92 | | `src/app.nim` | Flags, the server, routes and cookies | |
| 93 | | `src/httpserver.nim` | The standard library's HTTP server, with chunked bodies limited | |
| 94 | | `src/store.nim` | SQLite: schema, migrations, every query | |
| 95 | | `src/views.nim`, `src/page.nimf` | The page and the list fragment | |
| 96 | | `static/app.js`, `static/app.css` | The JavaScript layer and the no-JS controls | |
| 97 | | `tests/test_app.nim` | Unit tests | |
| 98 | |