| 1 | # todo-spinel |
| 2 | |
| 3 | TodoMVC for the CARLOS bakeoff, written in Ruby and compiled ahead of time |
| 4 | by [Spinel](https://github.com/matz/spinel), matz's Ruby-to-C compiler. The |
| 5 | result is one static linux-arm64 binary with SQLite inside it and no Ruby at |
| 6 | runtime. It's live at |
| 7 | [https://todo-spinel.bfj.oncarlos.com](https://todo-spinel.bfj.oncarlos.com). |
| 8 | |
| 9 | ## The stack |
| 10 | |
| 11 | **[tep](https://github.com/OriPekelman/tep)** is the web layer. You write a |
| 12 | Sinatra-style `app.rb`; tep translates it into plain Ruby that Spinel can |
| 13 | compile, inlines its own HTTP server, and drives Spinel to a binary. It's the |
| 14 | only web framework that targets Spinel, and it already powers a CARLOS app |
| 15 | (lpay), so it was the obvious pick. Spinel compiles a subset of Ruby, which is |
| 16 | why `app.rb` reads a little old-fashioned: `while` loops, string |
| 17 | concatenation, small top-level functions, no metaprogramming. |
| 18 | |
| 19 | **SQLite** comes from the official amalgamation, compiled by the Makefile and |
| 20 | linked statically. tep's own SQLite shim talks to it. Lists live in one |
| 21 | `todos` table keyed by the visitor's cookie, and a `visitors` table records |
| 22 | every token the server has issued, so a cookie it didn't issue gets a fresh |
| 23 | one. Migrations are numbered by |
| 24 | `PRAGMA user_version` and only ever add. |
| 25 | |
| 26 | **HTML** is built in Ruby functions in `app.rb`, escaped by hand. tep has |
| 27 | build-time ERB, but its templates can only be called from a route block and |
| 28 | every value has to go through a per-request string bag, which made the |
| 29 | item and fragment renderers clumsier than plain functions. |
| 30 | |
| 31 | **JavaScript** is one ES module, `assets/static/app.js`, with no framework. |
| 32 | The page works fully without it: every action is a real form POST that |
| 33 | answers 303. With JS on, the script sends the same forms with `fetch` and an |
| 34 | `X-Fragment: 1` header, and swaps in the `section.main` and `footer.footer` |
| 35 | the server sends back. The no-JS controls sit invisibly over their TodoMVC |
| 36 | counterparts and disappear when the script adds the `js` class. |
| 37 | |
| 38 | tep is copied into `.cache/` and patched rather than used as-is. |
| 39 | `patches/1-tep-carlos.patch` is lpay's: the `--socket` contract and a |
| 40 | `Content-Length` that counts bytes. `patches/2-tep-bakeoff.patch` adds |
| 41 | `--listen 127.0.0.1:<port>`, which refuses anything but a loopback address, |
| 42 | and removes tep's fallback to listening on every interface: with neither |
| 43 | `--socket` nor `--listen` the binary exits. It gives each request a |
| 44 | 10-second deadline for the whole exchange, so a slow or idle client can't |
| 45 | hold the single worker. It reads bodies whole up to 256 KiB (tep stopped at |
| 46 | 64 KiB and carried on with a truncated title), answers 413 above that, and |
| 47 | answers 400 without running any handler when a body is short of its |
| 48 | `Content-Length` or chunked. Smaller changes: the |
| 49 | `303 See Other` reason phrase, and a return-type fix in `Tep::Assets._add` |
| 50 | that otherwise breaks the C compile. Last, it replaces tep's form decoding. |
| 51 | spinelkit's `Url.parse_query` keeps a decoded key in an unrooted temporary |
| 52 | while it decodes the value, so a garbage collection in between loses the |
| 53 | key. On arm64 that dropped any todo title over a thousand or so |
| 54 | characters; on x86-64 it only shows under `SPINEL_GC_STRESS=1`. The patch |
| 55 | decodes in C and keeps every intermediate in a local, and `make test` runs |
| 56 | a second pass under GC stress to catch anything like it. Edit |
| 57 | `.cache/x86/tep` and run `scripts/regen-patch` to change the second patch. |
| 58 | |
| 59 | ## Build, run, test |
| 60 | |
| 61 | ``` |
| 62 | source ../../scripts/env.sh |
| 63 | make build # dist/app, for this machine |
| 64 | dist/app --listen 127.0.0.1:8080 --db $TMPDIR/todo.db |
| 65 | make test # test/run.sh: curl checks of the contract |
| 66 | ../../scripts/test-app.sh http://127.0.0.1:8080 # the shared browser suite |
| 67 | ``` |
| 68 | |
| 69 | `make build` takes about a minute and a half, nearly all of it Spinel. |
| 70 | |
| 71 | `make build-arm64` makes `dist/app-linux-arm64`. The host Spinel turns Ruby |
| 72 | into C natively, then `scripts/arm64-cc` compiles that C inside the emulated |
| 73 | arm64 root (`scripts/arm64.sh`) and links it statically against the root's |
| 74 | glibc, OpenSSL (tep's HTTP layer links it) and our SQLite. Running all of |
| 75 | Spinel under emulation also works but takes three times as long. Expect five |
| 76 | minutes warm, and another four the first time while SQLite compiles. |
| 77 | |
| 78 | `make clean-deps` removes the compiled SQLite and tep copies; the downloaded |
| 79 | SQLite zip stays in `.cache/src`. |
| 80 | |
| 81 | ## Deploy |
| 82 | |
| 83 | ``` |
| 84 | make deploy MESSAGE="what changed" |
| 85 | ``` |
| 86 | |
| 87 | This ships `dist/app-linux-arm64` to the `todo-spinel` app on account `bfj`. |
| 88 | The version is the commit's short SHA, so commit before you build. Before a |
| 89 | first deploy, run `../../scripts/boot-check.sh dist/app-linux-arm64`. |
| 90 | |