# todo-spinel

TodoMVC for the CARLOS bakeoff, written in Ruby and compiled ahead of time
by [Spinel](https://github.com/matz/spinel), matz's Ruby-to-C compiler. The
result is one static linux-arm64 binary with SQLite inside it and no Ruby at
runtime. It's live at
[https://todo-spinel.bfj.oncarlos.com](https://todo-spinel.bfj.oncarlos.com).

## The stack

**[tep](https://github.com/OriPekelman/tep)** is the web layer. You write a
Sinatra-style `app.rb`; tep translates it into plain Ruby that Spinel can
compile, inlines its own HTTP server, and drives Spinel to a binary. It's the
only web framework that targets Spinel, and it already powers a CARLOS app
(lpay), so it was the obvious pick. Spinel compiles a subset of Ruby, which is
why `app.rb` reads a little old-fashioned: `while` loops, string
concatenation, small top-level functions, no metaprogramming.

**SQLite** comes from the official amalgamation, compiled by the Makefile and
linked statically. tep's own SQLite shim talks to it. Lists live in one
`todos` table keyed by the visitor's cookie, and a `visitors` table records
every token the server has issued, so a cookie it didn't issue gets a fresh
one. Migrations are numbered by
`PRAGMA user_version` and only ever add.

**HTML** is built in Ruby functions in `app.rb`, escaped by hand. tep has
build-time ERB, but its templates can only be called from a route block and
every value has to go through a per-request string bag, which made the
item and fragment renderers clumsier than plain functions.

**JavaScript** is one ES module, `assets/static/app.js`, with no framework.
The page works fully without it: every action is a real form POST that
answers 303. With JS on, the script sends the same forms with `fetch` and an
`X-Fragment: 1` header, and swaps in the `section.main` and `footer.footer`
the server sends back. The no-JS controls sit invisibly over their TodoMVC
counterparts and disappear when the script adds the `js` class.

tep is copied into `.cache/` and patched rather than used as-is.
`patches/1-tep-carlos.patch` is lpay's: the `--socket` contract and a
`Content-Length` that counts bytes. `patches/2-tep-bakeoff.patch` adds
`--listen 127.0.0.1:<port>`, which refuses anything but a loopback address,
and removes tep's fallback to listening on every interface: with neither
`--socket` nor `--listen` the binary exits. It gives each request a
10-second deadline for the whole exchange, so a slow or idle client can't
hold the single worker. It reads bodies whole up to 256 KiB (tep stopped at
64 KiB and carried on with a truncated title), answers 413 above that, and
answers 400 without running any handler when a body is short of its
`Content-Length` or chunked. Smaller changes: the
`303 See Other` reason phrase, and a return-type fix in `Tep::Assets._add`
that otherwise breaks the C compile. Last, it replaces tep's form decoding.
spinelkit's `Url.parse_query` keeps a decoded key in an unrooted temporary
while it decodes the value, so a garbage collection in between loses the
key. On arm64 that dropped any todo title over a thousand or so
characters; on x86-64 it only shows under `SPINEL_GC_STRESS=1`. The patch
decodes in C and keeps every intermediate in a local, and `make test` runs
a second pass under GC stress to catch anything like it. Edit
`.cache/x86/tep` and run `scripts/regen-patch` to change the second patch.

## Build, run, test

```
source ../../scripts/env.sh
make build                 # dist/app, for this machine
dist/app --listen 127.0.0.1:8080 --db $TMPDIR/todo.db
make test                  # test/run.sh: curl checks of the contract
../../scripts/test-app.sh http://127.0.0.1:8080   # the shared browser suite
```

`make build` takes about a minute and a half, nearly all of it Spinel.

`make build-arm64` makes `dist/app-linux-arm64`. The host Spinel turns Ruby
into C natively, then `scripts/arm64-cc` compiles that C inside the emulated
arm64 root (`scripts/arm64.sh`) and links it statically against the root's
glibc, OpenSSL (tep's HTTP layer links it) and our SQLite. Running all of
Spinel under emulation also works but takes three times as long. Expect five
minutes warm, and another four the first time while SQLite compiles.

`make clean-deps` removes the compiled SQLite and tep copies; the downloaded
SQLite zip stays in `.cache/src`.

## Deploy

```
make deploy MESSAGE="what changed"
```

This ships `dist/app-linux-arm64` to the `todo-spinel` app on account `bfj`.
The version is the commit's short SHA, so commit before you build. Before a
first deploy, run `../../scripts/boot-check.sh dist/app-linux-arm64`.
