feat(spinel): TodoMVC in Ruby compiled by Spinel via tep

Server-rendered TodoMVC with a small fetch-based script, SQLite in WAL
mode, per-visitor lists by anonymous cookie. tep is copied into .cache
and patched for the CARLOS socket contract, --listen on 127.0.0.1, 303
reasons and an Assets._add return type. arm64 builds run the host Spinel
for Ruby -> C and the arm64 root's gcc for a static link.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 13cbb62e06e7a4ec42a7f9cf23032a642406e73b parent 2eeda2a
14 files changed, +1775 −0
  • apps/spinel/.gitignore +2 −0
  • apps/spinel/AGENTS.md +30 −0
  • apps/spinel/DEPS +0 −0
  • apps/spinel/Makefile +144 −0
  • apps/spinel/app.rb +526 −0
  • apps/spinel/assets/static/app.css +64 −0
  • apps/spinel/assets/static/app.js +80 −0
  • apps/spinel/assets/static/base.css +141 −0
  • apps/spinel/assets/static/index.css +393 −0
  • apps/spinel/patches/1-tep-carlos.patch +198 −0
  • apps/spinel/patches/2-tep-bakeoff.patch +152 −0
  • apps/spinel/scripts/arm64-cc +21 −0
  • apps/spinel/scripts/regen-patch +16 −0
  • apps/spinel/scripts/spinel-cross +8 −0
diff --git a/apps/spinel/.gitignore b/apps/spinel/.gitignore
new file mode 100644
index 0000000..4ff8203
--- /dev/null
+++ b/apps/spinel/.gitignore
@@ -0,0 +1,2 @@
+.app.tep.rb
+version.rb
diff --git a/apps/spinel/AGENTS.md b/apps/spinel/AGENTS.md
new file mode 100644
index 0000000..95d5864
--- /dev/null
+++ b/apps/spinel/AGENTS.md
@@ -0,0 +1,30 @@
+# 🤖 AGENTS.md: todo-spinel
+
+The one rule: **this app is Ruby compiled ahead of time, and nothing else
+runs in production.** Everything the server does is written in the
+Spinel-compilable subset of Ruby (`app.rb`, via tep) or in the small C
+shims tep already ships. No Ruby interpreter, no shared library beyond
+libc, no client framework, no JS build step.
+
+## Tenancy
+
+- **One instance for the whole app.** A single process and a single
+ SQLite file (`--db`) serve every visitor.
+- **Each visitor's list is kept separately by an anonymous cookie.** The
+ first visit sets `tid=<22 base64url characters>` (128 bits from the
+ kernel's `getrandom`),
+ `HttpOnly; SameSite=Lax; Path=/`, plus `Secure` over https. Every query
+ is scoped by that value; an id from someone else's list is a 404.
+- **No sign-in.** There are no accounts, passwords or e-mail addresses.
+- **Trust class C.** Todo text is stored as plain text in SQLite. That's
+ a deliberate choice for a demo app: nothing in it is meant to be
+ secret, and the platform's at-rest protection of the database is the
+ only protection it gets.
+
+## Working here
+
+- Work only in `apps/spinel/`. `BRIEF.md` and `CONTRACT.md` at the repo
+ root are binding.
+- The toolchain checkouts (spinel, tep, spinelkit) are read-only. The
+ Makefile copies tep into `.cache/` and applies `patches/*.patch` there.
+- Servers bind `127.0.0.1` only. Scratch goes in `$TMPDIR`. No `sudo`.
diff --git a/apps/spinel/DEPS b/apps/spinel/DEPS
new file mode 100644
index 0000000..e69de29
diff --git a/apps/spinel/Makefile b/apps/spinel/Makefile
new file mode 100644
index 0000000..56bf18b
--- /dev/null
+++ b/apps/spinel/Makefile
@@ -0,0 +1,144 @@
+# todo-spinel. See README.md for what each piece is.
+#
+# make build dist/app for this machine (x86-64, dynamic glibc)
+# make build-arm64 dist/app-linux-arm64 (static, aarch64)
+# make test test/run.sh against dist/app
+# make deploy ship dist/app-linux-arm64 to Carloku
+
+SHELL := bash
+.SHELLFLAGS := -euo pipefail -c
+.DELETE_ON_ERROR:
+
+TC ?= $(HOME)/.local/share/bakeoff-toolchains
+SPINEL_HOST ?= $(TC)/spinel-src/spinel/bin/spinel
+SPINEL_ARM64?= $(TC)/spinel-arm64/bin/spinel
+TEP_SRC ?= $(TC)/spinel-src/tep
+SPINELKIT ?= $(TC)/spinel-src/spinelkit
+RUBY ?= $(shell PATH="$(HOME)/.pixi/bin:$$PATH" command -v ruby)
+ARM64 := $(abspath ../../scripts/arm64.sh)
+
+SQLITE_VER := 3500400
+SQLITE_ZIP := .cache/src/sqlite-amalgamation-$(SQLITE_VER).zip
+SQLITE_URL := https://www.sqlite.org/2025/sqlite-amalgamation-$(SQLITE_VER).zip
+SQLITE_SHA := 1d3049dd0f830a025a53105fc79fd2ab9431aea99e137809d064d8ee8356b032
+SQLITE_DIR := .cache/src/sqlite-amalgamation-$(SQLITE_VER)
+# One connection, used from one thread; no extension loading (static glibc
+# can't dlopen anyway).
+SQLITE_DEFS := -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_DQS=0 \
+ -DSQLITE_DEFAULT_MEMSTATUS=0 -DSQLITE_LIKE_DOESNT_MATCH_BLOBS
+
+PATCHES := $(sort $(wildcard patches/*.patch))
+SOURCES := app.rb $(wildcard assets/static/*)
+VERSION := $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
+
+export LANG := C.UTF-8
+export LC_ALL := C.UTF-8
+export SPINELKIT_DIR := $(SPINELKIT)
+export TEP_QUIET := 1
+
+.PHONY: build build-arm64 test clean clean-deps bump unbump deploy deps FORCE
+
+build: dist/app
+build-arm64: dist/app-linux-arm64
+
+# ---- version stamp ---------------------------------------------------------
+# Rewritten only when the commit changes, so it doesn't force rebuilds.
+version.rb: FORCE
+ @printf '# Written by make; not checked in.\nAPP_VERSION = "%s"\n' '$(VERSION)' > $@.tmp
+ @if cmp -s $@.tmp $@; then rm $@.tmp; else mv $@.tmp $@; fi
+
+# ---- third-party sources (kept by clean-deps) -------------------------------
+$(SQLITE_ZIP):
+ mkdir -p $(@D)
+ curl -sSfL -o $@.part $(SQLITE_URL)
+ echo "$(SQLITE_SHA) $@.part" | sha256sum -c --quiet
+ mv $@.part $@
+
+$(SQLITE_DIR)/sqlite3.c: $(SQLITE_ZIP)
+ python3 -I -m zipfile -e $< .cache/src
+ touch $@
+
+# ---- per-target compiled deps (removed by clean-deps) -----------------------
+# A patched copy of tep per target: tep bakes the paths of its C shims
+# (sphttp.o, tep_sqlite.o) into the translated source, so each target's
+# copy holds that target's objects.
+.cache/%/tep/.patched: $(PATCHES)
+ rm -rf .cache/$*/tep
+ mkdir -p .cache/$*
+ cp -a $(TEP_SRC) .cache/$*/tep
+ rm -rf .cache/$*/tep/.git
+ for p in $(PATCHES); do patch -s -p1 -d .cache/$*/tep < $$p; done
+ touch $@
+
+# Host (x86-64): the system cc.
+.cache/x86/libsqlite3.a: $(SQLITE_DIR)/sqlite3.c
+ mkdir -p $(@D)
+ cc -O2 $(SQLITE_DEFS) -c $< -o .cache/x86/sqlite3.o
+ ar rcs $@ .cache/x86/sqlite3.o
+
+.cache/x86/tep/tep/sphttp.o: .cache/x86/tep/.patched
+ cc -O2 -c .cache/x86/tep/tep/sphttp.c -o $@
+
+.cache/x86/tep/native/sqlite/tep_sqlite.o: .cache/x86/tep/.patched $(SQLITE_DIR)/sqlite3.c
+ cc -O2 -I$(SQLITE_DIR) -c .cache/x86/tep/native/sqlite/tep_sqlite.c -o $@
+
+# arm64: gcc inside the emulated arm64 root (slow: SQLite takes minutes).
+.cache/arm64/libsqlite3.a: $(SQLITE_DIR)/sqlite3.c
+ mkdir -p $(@D)
+ $(ARM64) cc -O2 $(SQLITE_DEFS) -c $< -o .cache/arm64/sqlite3.o
+ $(ARM64) ar rcs $@ .cache/arm64/sqlite3.o
+
+.cache/arm64/tep/tep/sphttp.o: .cache/arm64/tep/.patched
+ $(ARM64) cc -O2 -c .cache/arm64/tep/tep/sphttp.c -o $@
+
+.cache/arm64/tep/native/sqlite/tep_sqlite.o: .cache/arm64/tep/.patched $(SQLITE_DIR)/sqlite3.c
+ $(ARM64) cc -O2 -I$(SQLITE_DIR) -c .cache/arm64/tep/native/sqlite/tep_sqlite.c -o $@
+
+X86_DEPS := .cache/x86/libsqlite3.a .cache/x86/tep/tep/sphttp.o .cache/x86/tep/native/sqlite/tep_sqlite.o
+ARM64_DEPS := .cache/arm64/libsqlite3.a .cache/arm64/tep/tep/sphttp.o .cache/arm64/tep/native/sqlite/tep_sqlite.o
+
+deps: $(X86_DEPS) $(ARM64_DEPS)
+
+# ---- the app ---------------------------------------------------------------
+# tep translates app.rb (writing .app.tep.rb beside it) and runs Spinel.
+dist/app: $(SOURCES) version.rb $(X86_DEPS)
+ mkdir -p dist .cache/x86/tmp
+ TMPDIR=$(abspath .cache/x86/tmp) SPINEL=$(SPINEL_HOST) LIBRARY_PATH=$(abspath .cache/x86) \
+ $(RUBY) .cache/x86/tep/bin/tep build app.rb -o $@
+
+# The host Spinel turns Ruby into C (fast, native); scripts/arm64-cc then
+# compiles and links that C statically inside the arm64 root.
+dist/app-linux-arm64: $(SOURCES) version.rb $(ARM64_DEPS)
+ mkdir -p dist .cache/arm64/tmp
+ TMPDIR=$(abspath .cache/arm64/tmp) SPINEL=$(abspath scripts/spinel-cross) \
+ SPINEL_HOST=$(SPINEL_HOST) SPINEL_ARM64=$(SPINEL_ARM64) \
+ ARM64_WORK=$(abspath .cache/arm64/work) ARM64_LIB_DIR=$(abspath .cache/arm64) \
+ $(RUBY) .cache/arm64/tep/bin/tep build app.rb -o $@
+ file $@ | grep -q 'ARM aarch64.*statically linked'
+
+# ---- the rest ----------------------------------------------------------------
+test: dist/app
+ test/run.sh dist/app
+
+clean:
+ rm -rf dist .app.tep.rb version.rb .cache/x86/tmp .cache/arm64/tmp .cache/arm64/work
+
+# Every compiled third-party artifact goes; the downloaded SQLite source stays.
+clean-deps: clean
+ rm -rf .cache/x86 .cache/arm64
+
+BUMP_FROM := BUILD_NOTE = "todo-spinel"
+BUMP_TO := BUILD_NOTE = "todo-spinel (bumped)"
+
+bump:
+ grep -qF '$(BUMP_FROM)' app.rb
+ sed -i 's/^$(BUMP_FROM)$$/$(BUMP_TO)/' app.rb
+
+unbump:
+ grep -qF '$(BUMP_TO)' app.rb
+ sed -i 's/^$(BUMP_TO)$$/$(BUMP_FROM)/' app.rb
+
+MESSAGE ?= Deploy todo-spinel $(VERSION)
+
+deploy: dist/app-linux-arm64
+ carlos deploy --account bfj --app todo-spinel --target linux-arm64 --message "$(MESSAGE)" dist/app-linux-arm64
diff --git a/apps/spinel/app.rb b/apps/spinel/app.rb
new file mode 100644
index 0000000..7919e8f
--- /dev/null
+++ b/apps/spinel/app.rb
@@ -0,0 +1,526 @@
+# todo-spinel: TodoMVC, server-rendered, compiled to one native binary.
+#
+# tep (`bin/tep build app.rb`) translates this Sinatra-style source and
+# drives Spinel, matz's ahead-of-time Ruby compiler, to produce a static
+# executable. There is no Ruby at runtime, so this file sticks to the
+# subset Spinel compiles: explicit loops, String/Integer values, and
+# tep's request/response objects.
+#
+# app --socket <path> --db <path> (on Carloku)
+# app --listen 127.0.0.1:<port> --db <path>
+require 'sinatra' # documentation only; the tep translator ignores it
+
+require_relative "version" # APP_VERSION, written by the Makefile
+
+# `make bump` rewrites this string so the next build has to recompile the
+# file holding the GET / handler. It ends up in an HTML comment.
+BUILD_NOTE = "todo-spinel"
+
+COOKIE_NAME = "tid"
+
+# ---------------------------------------------------------------------------
+# Command line and environment.
+
+# The value after `flag` in ARGV, or "" when it isn't there.
+def arg_value(flag)
+ v = ""
+ i = 0
+ while i < ARGV.length
+ if ARGV[i] == flag && i + 1 < ARGV.length
+ v = ARGV[i + 1]
+ end
+ i += 1
+ end
+ v
+end
+
+# This app's own origin, which a POST's Origin header must match:
+# https://$CARLOS_HOST on Carloku, http://<listen address> locally.
+def own_origin
+ host = ENV["CARLOS_HOST"].to_s
+ if host.length > 0
+ return "https://" + host
+ end
+ listen = arg_value("--listen")
+ if listen.length > 0
+ return "http://" + listen
+ end
+ ""
+end
+
+def served_over_https(req)
+ if ENV["CARLOS_HOST"].to_s.length > 0
+ return true
+ end
+ req.headers["x-forwarded-proto"].to_s == "https"
+end
+
+# ---------------------------------------------------------------------------
+# Storage. One SQLite file in WAL mode. Migrations only ever add.
+
+DB = Tep::SQLite.new
+
+MIGRATIONS = [
+ "CREATE TABLE todos (id INTEGER PRIMARY KEY, list TEXT NOT NULL, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()))",
+ "CREATE INDEX todos_list ON todos (list, id)"
+]
+
+def open_db(path)
+ if path.length == 0
+ puts "todo-spinel: --db <path> is required"
+ exit(2)
+ end
+ if !DB.open(path)
+ puts "todo-spinel: cannot open " + path
+ exit(1)
+ end
+ DB.exec("PRAGMA journal_mode=WAL")
+ DB.exec("PRAGMA synchronous=NORMAL")
+ DB.exec("PRAGMA busy_timeout=5000")
+ version = DB.first_int("PRAGMA user_version", "")
+ while version < MIGRATIONS.length
+ DB.exec("BEGIN")
+ if !DB.exec(MIGRATIONS[version])
+ DB.exec("ROLLBACK")
+ puts "todo-spinel: migration " + (version + 1).to_s + " failed"
+ exit(1)
+ end
+ version += 1
+ DB.exec("PRAGMA user_version=" + version.to_s)
+ DB.exec("COMMIT")
+ end
+end
+
+configure do
+ open_db(arg_value("--db"))
+end
+
+# One visitor's todos, read in id order into parallel arrays (tep's
+# SQLite shim has a single cursor, so rows are copied out before any
+# other query runs).
+class TodoList
+ attr_accessor :ids, :titles, :done
+
+ def initialize
+ @ids = [0]
+ @ids.delete_at(0)
+ @titles = [""]
+ @titles.delete_at(0)
+ @done = [0]
+ @done.delete_at(0)
+ end
+
+ def self.load(list)
+ l = TodoList.new
+ DB.prepare("SELECT id, title, completed FROM todos WHERE list = ? ORDER BY id")
+ DB.bind_str(1, list)
+ while DB.step == 1
+ l.ids.push(DB.col_int(0))
+ l.titles.push(DB.col_str(1) + "")
+ l.done.push(DB.col_int(2))
+ end
+ DB.finalize
+ l
+ end
+
+ def size
+ @ids.length
+ end
+
+ def completed_count
+ n = 0
+ i = 0
+ while i < @done.length
+ if @done[i] != 0
+ n += 1
+ end
+ i += 1
+ end
+ n
+ end
+end
+
+def run_sql(sql, list, id)
+ DB.prepare(sql)
+ DB.bind_str(1, list)
+ if id > 0
+ DB.bind_int(2, id)
+ end
+ DB.step
+ DB.finalize
+end
+
+def todo_exists(list, id)
+ DB.prepare("SELECT 1 FROM todos WHERE list = ? AND id = ?")
+ DB.bind_str(1, list)
+ DB.bind_int(2, id)
+ found = DB.step == 1
+ DB.finalize
+ found
+end
+
+def add_todo(list, title)
+ DB.prepare("INSERT INTO todos (list, title) VALUES (?, ?)")
+ DB.bind_str(1, list)
+ DB.bind_str(2, title)
+ DB.step
+ DB.finalize
+end
+
+def rename_todo(list, id, title)
+ DB.prepare("UPDATE todos SET title = ? WHERE list = ? AND id = ?")
+ DB.bind_str(1, title)
+ DB.bind_str(2, list)
+ DB.bind_int(3, id)
+ DB.step
+ DB.finalize
+end
+
+# ---------------------------------------------------------------------------
+# Visitors. Each one's list is keyed by an anonymous cookie holding 128
+# random bits (22 base64url characters from getrandom).
+
+def valid_token(t)
+ if t.length != 22
+ return false
+ end
+ i = 0
+ while i < t.length
+ c = t[i]
+ ok = (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "-" || c == "_"
+ if !ok
+ return false
+ end
+ i += 1
+ end
+ true
+end
+
+# The caller's list token. A missing or unrecognised cookie gets a fresh
+# token (and so a fresh, empty list), set on this response.
+def visitor(req, res)
+ t = req.cookies[COOKIE_NAME].to_s
+ if valid_token(t)
+ return t
+ end
+ t = Crypto.sp_crypto_random_b64url(16).to_s
+ opts = Tep.str_hash
+ opts["Path"] = "/"
+ opts["Max-Age"] = "31536000"
+ opts["HttpOnly"] = ""
+ opts["SameSite"] = "Lax"
+ if served_over_https(req)
+ opts["Secure"] = ""
+ end
+ res.set_cookie(COOKIE_NAME, t, opts)
+ t
+end
+
+# ---------------------------------------------------------------------------
+# Rendering.
+
+def h(s)
+ s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub("\"", "&quot;").gsub("'", "&#39;")
+end
+
+def filter_name(f)
+ if f == "active" || f == "completed"
+ return f
+ end
+ "all"
+end
+
+def filter_path(f)
+ if f == "active"
+ return "/active"
+ end
+ if f == "completed"
+ return "/completed"
+ end
+ "/"
+end
+
+def filter_hidden(f)
+ "<input type=\"hidden\" name=\"filter\" value=\"" + f + "\">"
+end
+
+def shown(filter, done)
+ if filter == "active"
+ return done == 0
+ end
+ if filter == "completed"
+ return done != 0
+ end
+ true
+end
+
+def render_item(id, title, done, filter, editing)
+ sid = id.to_s
+ et = h(title)
+ cls = ""
+ if done != 0
+ cls = "completed"
+ end
+ if editing
+ cls = (cls + " editing").strip
+ end
+ out = "<li data-id=\"" + sid + "\""
+ if cls.length > 0
+ out = out + " class=\"" + cls + "\""
+ end
+ out = out + ">\n<div class=\"view\">\n"
+ out = out + "<input class=\"toggle\" type=\"checkbox\" form=\"toggle-" + sid + "\""
+ if done != 0
+ out = out + " checked"
+ end
+ out = out + ">\n<label>" + et + "</label>\n"
+ out = out + "<button class=\"nojs toggle-submit\" data-action=\"toggle\" form=\"toggle-" + sid + "\">Toggle</button>\n"
+ out = out + "<a class=\"nojs edit-link\" data-action=\"edit\" href=\"" + filter_path(filter) + "?edit=" + sid + "\">edit</a>\n"
+ out = out + "<button class=\"destroy\" form=\"destroy-" + sid + "\" title=\"Delete\"></button>\n</div>\n"
+ out = out + "<form class=\"edit-form\" method=\"post\" action=\"/todos/" + sid + "/edit\">"
+ out = out + "<input class=\"edit\" name=\"title\" value=\"" + et + "\""
+ if editing
+ out = out + " autofocus"
+ end
+ out = out + ">" + filter_hidden(filter) + "</form>\n"
+ out = out + "<form id=\"toggle-" + sid + "\" method=\"post\" action=\"/todos/" + sid + "/toggle\" hidden>" + filter_hidden(filter) + "</form>\n"
+ out = out + "<form id=\"destroy-" + sid + "\" method=\"post\" action=\"/todos/" + sid + "/destroy\" hidden>" + filter_hidden(filter) + "</form>\n"
+ out + "</li>\n"
+end
+
+def filter_link(f, label, current)
+ sel = ""
+ if f == current
+ sel = " class=\"selected\""
+ end
+ "<li>\n<a" + sel + " href=\"" + filter_path(f) + "\">" + label + "</a>\n</li>\n"
+end
+
+# section.main and footer.footer for one filter: the part of the page an
+# action changes, and what the JavaScript swaps in after each POST.
+# Empty when the list is.
+def render_lists(list, filter, edit_id)
+ l = TodoList.load(list)
+ if l.size == 0
+ return ""
+ end
+ done = l.completed_count
+ left = l.size - done
+ out = "<section class=\"main\">\n"
+ out = out + "<form id=\"toggle-all-form\" method=\"post\" action=\"/todos/toggle-all\" hidden>" + filter_hidden(filter) + "</form>\n"
+ out = out + "<input id=\"toggle-all\" class=\"toggle-all\" type=\"checkbox\" form=\"toggle-all-form\""
+ if left == 0
+ out = out + " checked"
+ end
+ out = out + ">\n<label for=\"toggle-all\">Mark all as complete</label>\n"
+ out = out + "<button class=\"nojs toggle-all-submit\" data-action=\"toggle-all\" form=\"toggle-all-form\">Mark all as complete</button>\n"
+ out = out + "<ul class=\"todo-list\">\n"
+ i = 0
+ while i < l.size
+ if shown(filter, l.done[i])
+ out = out + render_item(l.ids[i], l.titles[i], l.done[i], filter, l.ids[i] == edit_id)
+ end
+ i += 1
+ end
+ out = out + "</ul>\n</section>\n<footer class=\"footer\">\n"
+ word = "items"
+ if left == 1
+ word = "item"
+ end
+ out = out + "<span class=\"todo-count\"><strong>" + left.to_s + "</strong> " + word + " left</span>\n"
+ out = out + "<ul class=\"filters\">\n" + filter_link("all", "All", filter) + filter_link("active", "Active", filter) + filter_link("completed", "Completed", filter) + "</ul>\n"
+ if done > 0
+ out = out + "<form id=\"clear-completed-form\" method=\"post\" action=\"/todos/clear-completed\" hidden>" + filter_hidden(filter) + "</form>\n"
+ out = out + "<button class=\"clear-completed\" form=\"clear-completed-form\">Clear completed</button>\n"
+ end
+ out + "</footer>\n"
+end
+
+# ---------------------------------------------------------------------------
+# Actions. Every POST answers 303 back to its filter, or, when the page's
+# own script asks with `X-Fragment: 1`, 200 with the fresh fragment.
+
+def origin_allowed(req)
+ o = req.headers["origin"].to_s
+ if o.length == 0
+ return true
+ end
+ o == own_origin
+end
+
+def finish_post(req, res, list)
+ f = filter_name(req.params["filter"].to_s)
+ if req.headers["x-fragment"].to_s == "1"
+ res.headers["Cache-Control"] = "no-store"
+ return render_lists(list, f, 0)
+ end
+ res.set_status(303)
+ res.headers["Location"] = filter_path(f)
+ ""
+end
+
+def refuse(res, code, msg)
+ res.set_status(code)
+ res.headers["Content-Type"] = "text/plain; charset=utf-8"
+ msg
+end
+
+# The numeric id in the path, or 0 when it isn't one.
+def path_id(req)
+ s = req.params["id"].to_s
+ if s.length == 0 || s.length > 18
+ return 0
+ end
+ i = 0
+ while i < s.length
+ if s[i] < "0" || s[i] > "9"
+ return 0
+ end
+ i += 1
+ end
+ s.to_i
+end
+
+# The whole page. Its markup follows TodoMVC's own template
+# (shared/reference.html); render_lists fills in the middle.
+def layout(filter, lists, autofocus)
+ out = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
+ out = out + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
+ out = out + "<title>TodoMVC</title>\n"
+ out = out + "<link rel=\"stylesheet\" href=\"/static/base.css\">\n"
+ out = out + "<link rel=\"stylesheet\" href=\"/static/index.css\">\n"
+ out = out + "<link rel=\"stylesheet\" href=\"/static/app.css\">\n"
+ out = out + "<script>document.documentElement.classList.add(\"js\")</script>\n"
+ out = out + "<script type=\"module\" src=\"/static/app.js\"></script>\n"
+ out = out + "<!-- " + BUILD_NOTE + " -->\n</head>\n<body>\n"
+ out = out + "<section class=\"todoapp\">\n<header class=\"header\">\n<h1>todos</h1>\n"
+ out = out + "<form method=\"post\" action=\"/todos\">\n"
+ out = out + "<input class=\"new-todo\" name=\"title\" placeholder=\"What needs to be done?\" autocomplete=\"off\""
+ if autofocus
+ out = out + " autofocus"
+ end
+ out = out + ">\n" + filter_hidden(filter) + "\n</form>\n</header>\n"
+ out = out + lists
+ out = out + "</section>\n<footer class=\"info\">\n<p>Double-click to edit a todo</p>\n"
+ out = out + "<p>Created by <a href=\"https://bakeoff.bfj.oncarlos.com\">the CARLOS bakeoff</a> \u00b7 Spinel</p>\n"
+ out = out + "<p>Part of <a href=\"http://todomvc.com\">TodoMVC</a></p>\n</footer>\n</body>\n</html>\n"
+ out
+end
+
+def page(req, res, filter)
+ list = visitor(req, res)
+ edit_id = 0
+ e = req.params["edit"].to_s
+ if e.length > 0 && e.length < 19
+ edit_id = e.to_i
+ end
+ res.headers["Cache-Control"] = "no-store"
+ lists = render_lists(list, filter, edit_id)
+ # The item being edited takes the focus; otherwise the new-todo box.
+ layout(filter, lists, !lists.include?(" autofocus>"))
+end
+
+# ---------------------------------------------------------------------------
+# Routes.
+
+get '/' do
+ page(request, response, "all")
+end
+
+get '/active' do
+ page(request, response, "active")
+end
+
+get '/completed' do
+ page(request, response, "completed")
+end
+
+get '/healthz' do
+ content_type 'text/plain; charset=utf-8'
+ "ok"
+end
+
+get '/api/version' do
+ content_type 'text/plain; charset=utf-8'
+ res.headers["Cache-Control"] = "no-store"
+ APP_VERSION
+end
+
+post '/todos' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin request refused")
+ end
+ list = visitor(request, response)
+ title = request.params["title"].to_s.strip
+ if title.length > 0
+ add_todo(list, title)
+ end
+ finish_post(request, response, list)
+end
+
+post '/todos/toggle-all' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin request refused")
+ end
+ list = visitor(request, response)
+ l = TodoList.load(list)
+ if l.size > 0 && l.completed_count == l.size
+ run_sql("UPDATE todos SET completed = 0 WHERE list = ?", list, 0)
+ else
+ run_sql("UPDATE todos SET completed = 1 WHERE list = ?", list, 0)
+ end
+ finish_post(request, response, list)
+end
+
+post '/todos/clear-completed' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin request refused")
+ end
+ list = visitor(request, response)
+ run_sql("DELETE FROM todos WHERE list = ? AND completed != 0", list, 0)
+ finish_post(request, response, list)
+end
+
+post '/todos/:id/toggle' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin request refused")
+ end
+ list = visitor(request, response)
+ id = path_id(request)
+ if id == 0 || !todo_exists(list, id)
+ return refuse(response, 404, "no such todo")
+ end
+ run_sql("UPDATE todos SET completed = 1 - completed WHERE list = ? AND id = ?", list, id)
+ finish_post(request, response, list)
+end
+
+post '/todos/:id/edit' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin request refused")
+ end
+ list = visitor(request, response)
+ id = path_id(request)
+ if id == 0 || !todo_exists(list, id)
+ return refuse(response, 404, "no such todo")
+ end
+ title = request.params["title"].to_s.strip
+ if title.length > 0
+ rename_todo(list, id, title)
+ else
+ run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id)
+ end
+ finish_post(request, response, list)
+end
+
+post '/todos/:id/destroy' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin request refused")
+ end
+ list = visitor(request, response)
+ id = path_id(request)
+ if id == 0 || !todo_exists(list, id)
+ return refuse(response, 404, "no such todo")
+ end
+ run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id)
+ finish_post(request, response, list)
+end
diff --git a/apps/spinel/assets/static/app.css b/apps/spinel/assets/static/app.css
new file mode 100644
index 0000000..7cb340c
--- /dev/null
+++ b/apps/spinel/assets/static/app.css
@@ -0,0 +1,64 @@
+/* Controls for when JavaScript is off. Each sits where its TodoMVC
+ counterpart is, so the page looks the same either way; the script
+ adds the `js` class to <html> and these disappear. */
+.js .nojs {
+ display: none;
+}
+
+.nojs {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+}
+
+/* Over the "mark all" chevron. */
+.toggle-all-submit {
+ position: absolute;
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+ opacity: 0;
+}
+
+/* Over the round checkbox at the start of each item. */
+.toggle-submit {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+ opacity: 0;
+}
+
+/* A small "edit" link, left of where the delete cross appears. */
+.edit-link {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 50px;
+ height: 20px;
+ margin: auto 0;
+ font-size: 14px;
+ line-height: 20px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.edit-link:hover,
+.edit-link:focus {
+ color: #4d4d4d;
+ text-decoration: underline;
+}
+
+html:not(.js) .todo-list li label {
+ padding-right: 100px;
+}
+
+.edit-form {
+ margin: 0;
+}
diff --git a/apps/spinel/assets/static/app.js b/apps/spinel/assets/static/app.js
new file mode 100644
index 0000000..3648464
--- /dev/null
+++ b/apps/spinel/assets/static/app.js
@@ -0,0 +1,80 @@
+// TodoMVC behaviour on top of the server-rendered page. Every change is
+// still one of the page's own form POSTs; this script sends it with
+// fetch and an `X-Fragment: 1` header, and the server answers with the
+// fresh section.main and footer.footer, which replace the old ones.
+// Without this script the same forms submit normally.
+
+const app = document.querySelector('.todoapp');
+const header = app.querySelector('.header');
+
+let queue = Promise.resolve();
+
+function render(html) {
+ for (const el of app.querySelectorAll(':scope > .main, :scope > .footer')) el.remove();
+ header.insertAdjacentHTML('afterend', html);
+}
+
+// POSTs run one after another, in the order they were made.
+function send(form) {
+ const body = new URLSearchParams(new FormData(form));
+ queue = queue.then(async () => {
+ try {
+ const res = await fetch(form.action, {
+ method: 'POST',
+ body,
+ headers: { 'X-Fragment': '1' },
+ credentials: 'same-origin',
+ });
+ if (!res.ok) throw new Error(`${res.status}`);
+ render(await res.text());
+ } catch {
+ location.reload();
+ }
+ });
+ return queue;
+}
+
+app.addEventListener('submit', (e) => {
+ const form = e.target;
+ e.preventDefault();
+ if (form.classList.contains('edit-form')) {
+ const li = form.closest('li');
+ if (!li.classList.contains('editing')) return;
+ li.classList.remove('editing');
+ }
+ send(form);
+ const input = form.querySelector('.new-todo');
+ if (input) input.value = '';
+});
+
+// The checkboxes belong to their item's toggle form (and the toggle-all
+// form) through the `form` attribute; a change submits that form.
+app.addEventListener('change', (e) => {
+ const box = e.target;
+ if (box.matches('.toggle, .toggle-all') && box.form) box.form.requestSubmit();
+});
+
+app.addEventListener('dblclick', (e) => {
+ const label = e.target.closest('.todo-list li label');
+ if (!label) return;
+ const li = label.closest('li');
+ const input = li.querySelector('.edit');
+ input.value = label.textContent;
+ li.classList.add('editing');
+ 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');
+ e.target.value = li.querySelector('label').textContent;
+ li.classList.remove('editing');
+});
+
+// Leaving the editor saves, as in TodoMVC. focusout bubbles; blur doesn't.
+app.addEventListener('focusout', (e) => {
+ if (!e.target.matches('.edit')) return;
+ const li = e.target.closest('li');
+ if (li && li.classList.contains('editing')) e.target.form.requestSubmit();
+});
diff --git a/apps/spinel/assets/static/base.css b/apps/spinel/assets/static/base.css
new file mode 100644
index 0000000..da65968
--- /dev/null
+++ b/apps/spinel/assets/static/base.css
@@ -0,0 +1,141 @@
+hr {
+ margin: 20px 0;
+ border: 0;
+ border-top: 1px dashed #c5c5c5;
+ border-bottom: 1px dashed #f7f7f7;
+}
+
+.learn a {
+ font-weight: normal;
+ text-decoration: none;
+ color: #b83f45;
+}
+
+.learn a:hover {
+ text-decoration: underline;
+ color: #787e7e;
+}
+
+.learn h3,
+.learn h4,
+.learn h5 {
+ margin: 10px 0;
+ font-weight: 500;
+ line-height: 1.2;
+ color: #000;
+}
+
+.learn h3 {
+ font-size: 24px;
+}
+
+.learn h4 {
+ font-size: 18px;
+}
+
+.learn h5 {
+ margin-bottom: 0;
+ font-size: 14px;
+}
+
+.learn ul {
+ padding: 0;
+ margin: 0 0 30px 25px;
+}
+
+.learn li {
+ line-height: 20px;
+}
+
+.learn p {
+ font-size: 15px;
+ font-weight: 300;
+ line-height: 1.3;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+#issue-count {
+ display: none;
+}
+
+.quote {
+ border: none;
+ margin: 20px 0 60px 0;
+}
+
+.quote p {
+ font-style: italic;
+}
+
+.quote p:before {
+ content: '“';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ top: -20px;
+ left: 3px;
+}
+
+.quote p:after {
+ content: '”';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ bottom: -42px;
+ right: 3px;
+}
+
+.quote footer {
+ position: absolute;
+ bottom: -40px;
+ right: 0;
+}
+
+.quote footer img {
+ border-radius: 3px;
+}
+
+.quote footer a {
+ margin-left: 5px;
+ vertical-align: middle;
+}
+
+.speech-bubble {
+ position: relative;
+ padding: 10px;
+ background: rgba(0, 0, 0, .04);
+ border-radius: 5px;
+}
+
+.speech-bubble:after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ right: 30px;
+ border: 13px solid transparent;
+ border-top-color: rgba(0, 0, 0, .04);
+}
+
+.learn-bar > .learn {
+ position: absolute;
+ width: 272px;
+ top: 8px;
+ left: -300px;
+ padding: 10px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, .6);
+ transition-property: left;
+ transition-duration: 500ms;
+}
+
+@media (min-width: 899px) {
+ .learn-bar {
+ width: auto;
+ padding-left: 300px;
+ }
+
+ .learn-bar > .learn {
+ left: 8px;
+ }
+}
diff --git a/apps/spinel/assets/static/index.css b/apps/spinel/assets/static/index.css
new file mode 100644
index 0000000..2c0b4b6
--- /dev/null
+++ b/apps/spinel/assets/static/index.css
@@ -0,0 +1,393 @@
+@charset 'utf-8';
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #111111;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 300;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -140px;
+ width: 100%;
+ font-size: 80px;
+ font-weight: 200;
+ text-align: center;
+ color: #b83f45;
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ height: 65px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+.toggle-all {
+ width: 1px;
+ height: 1px;
+ border: none; /* Mobile Safari */
+ opacity: 0;
+ position: absolute;
+ right: 100%;
+ bottom: 100%;
+}
+
+.toggle-all + label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ position: absolute;
+ top: -65px;
+ left: -0;
+}
+
+.toggle-all + label:before {
+ content: '❯';
+ display: inline-block;
+ font-size: 22px;
+ color: #949494;
+ padding: 10px 27px 10px 27px;
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+
+.toggle-all:checked + label:before {
+ color: #484848;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none; /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle + label {
+ /*
+ Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
+ IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
+ */
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked + label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.todo-list li label {
+ overflow-wrap: break-word;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+ font-weight: 400;
+ color: #484848;
+}
+
+.todo-list li.completed label {
+ color: #949494;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #949494;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover,
+.todo-list li .destroy:focus {
+ color: #C18585;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+ display: block;
+ height: 100%;
+ line-height: 1.1;
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ font-size: 15px;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: #DB7676;
+}
+
+.filters li a.selected {
+ border-color: #CE4646;
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 19px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #4d4d4d;
+ font-size: 11px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/*
+ Hack to remove background from Mobile Safari.
+ Can't use it globally since it destroys checkboxes in Firefox
+*/
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
+
+:focus,
+.toggle:focus + label,
+.toggle-all:focus + label {
+ box-shadow: 0 0 2px 2px #CF7D7D;
+ outline: 0;
+}
diff --git a/apps/spinel/patches/1-tep-carlos.patch b/apps/spinel/patches/1-tep-carlos.patch
new file mode 100644
index 0000000..ef334f5
--- /dev/null
+++ b/apps/spinel/patches/1-tep-carlos.patch
@@ -0,0 +1,198 @@
+diff --git a/bin/tep b/bin/tep
+index a66fcb1..1c34e08 100755
+--- a/bin/tep
++++ b/bin/tep
+@@ -898,6 +898,7 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1")
+ out << <<~RB
+ __port = #{config[:port] || 4567}
+ __workers = #{config[:workers] || 1}
++ __sockpath = ""
+ __quiet = false
+ __scheduled = #{scheduled ? 'true' : 'false'}
+ __i = 0
+@@ -909,6 +910,18 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1")
+ elsif __a == "-w" && __i + 1 < ARGV.length
+ __workers = ARGV[__i + 1].to_i
+ __i += 2
++ elsif __a == "--socket" && __i + 1 < ARGV.length
++ # Serve over this unix-domain socket path instead of TCP (the
++ # argv deployment platforms like CARLOS spawn instances with).
++ __sockpath = ARGV[__i + 1]
++ __i += 2
++ elsif __a == "--db" && __i + 1 < ARGV.length
++ # Platform-supplied DB path; tolerated so a platform can pass it
++ # unconditionally. Apps that want it read it from ARGV themselves.
++ __i += 2
++ elsif __a == "serve"
++ # Tolerated no-op: platform unit templates default to `<bin> serve`.
++ __i += 1
+ elsif __a == "-q"
+ __quiet = true
+ __i += 1
+@@ -921,7 +934,11 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1")
+ __i += 1
+ end
+ end
+- Tep.run!(__port, __workers, __quiet, __scheduled)
++ if __sockpath.length > 0
++ Tep.run_unix!(__sockpath, __quiet)
++ else
++ Tep.run!(__port, __workers, __quiet, __scheduled)
++ end
+ RB
+ out << ""
+
+diff --git a/tep.rb b/tep.rb
+index 92a8ae4..7fb112a 100644
+--- a/tep.rb
++++ b/tep.rb
+@@ -903,6 +903,11 @@ module Tep
+ # accepts the call without the 4th arg only because it supports
+ # default-value params; without this, the 3-arg call silently
+ # miscompiled (matz/spinel arity-warning shape, tep#13).
++ # Unix-socket twin of run! -- see Server#run_unix.
++ def self.run_unix!(path, quiet)
++ Server.new(APP).run_unix(path, quiet)
++ end
++
+ def self.run!(port, workers, quiet, scheduled = false)
+ if scheduled
+ Server::Scheduled.new(APP).run(port, workers, quiet)
+diff --git a/tep/app.rb b/tep/app.rb
+index 7668be8..500da09 100644
+--- a/tep/app.rb
++++ b/tep/app.rb
+@@ -63,6 +63,13 @@ module Tep
+ # Late-seeded for the same reason as openai_backend.
+ attr_accessor :openai_events
+ attr_accessor :asset_bodies, :asset_mimes, :asset_etags
++ # Serve every response with Connection: close (no keep-alive reuse).
++ # Set by Server#run_unix: that mode is a single blocking worker, and
++ # a reverse proxy's idle keep-alive connection would wedge it — the
++ # worker blocks reading the idle socket while every other connection
++ # queues behind it (observed live behind CARLOS's activator proxy:
++ # intermittent hangs + dropped POSTs until Go's 90s idle timeout).
++ attr_accessor :close_per_request
+ attr_accessor :sched_fibers, :sched_wake_at, :sched_current
+ attr_accessor :sched_io_fd, :sched_io_mode, :sched_io_ready
+ # Tep::Job background-worker idempotency flag. App-level so a
+@@ -113,6 +120,7 @@ module Tep
+ @asset_mimes = Tep.str_hash # by Tep::Assets._add lines
+ # the bin/tep translator emits)
+ @asset_etags = Tep.str_hash # path -> content-hash ETag (#152)
++ @close_per_request = false
+ # FiberSlot array for the cooperative scheduler. Initialise
+ # with a noop-bodied slot to pin the array element type, then
+ # drop it. Each slot holds one Fiber + a timer entry in the
+diff --git a/tep/net.rb b/tep/net.rb
+index 45d22e9..7a20724 100644
+--- a/tep/net.rb
++++ b/tep/net.rb
+@@ -24,6 +24,9 @@ module Sock
+ ffi_lib "crypto"
+
+ ffi_func :sphttp_listen, [:int, :int], :int
++ # Unix-domain listener: bind+listen on a filesystem path (see
++ # sphttp.c). -1 on failure; accept/read/write reuse the same fd paths.
++ ffi_func :sphttp_listen_unix, [:str], :int
+ ffi_func :sphttp_accept, [:int], :int
+ # Non-blocking accept variant used by Tep::Server::Scheduled.
+ # Listen fd must be in non-blocking mode (sphttp_set_nonblock).
+diff --git a/tep/server.rb b/tep/server.rb
+index 1bec928..8e81dea 100644
+--- a/tep/server.rb
++++ b/tep/server.rb
+@@ -96,6 +96,31 @@ module Tep
+ end
+ end
+
++
++ # Serve over a unix-domain socket path: the shape deployment
++ # platforms hand an instance app (`--socket <path>`). One process,
++ # one listener -- prefork/SO_REUSEPORT does not apply to AF_UNIX,
++ # and socket-path apps are typically hibernated single instances.
++ def run_unix(path, quiet)
++ if !quiet
++ puts "[tep " + VERSION + "] listening on unix:" + path
++ end
++ # One blocking worker serves this socket: an idle kept-alive proxy
++ # connection would starve every other connection. Close per request;
++ # re-dialing a unix socket is effectively free.
++ Tep::APP.close_per_request = true
++ Sock.sphttp_install_term_handlers
++ sfd = Sock.sphttp_listen_unix(path)
++ if sfd < 0
++ puts "tep: bind failed on " + path
++ exit(1)
++ end
++ worker_loop(sfd)
++ if Sock.sphttp_shutdown_requested != 0
++ Tep.on_shutdown
++ end
++ end
++
+ def worker_loop(sfd)
+ loop do
+ client = Sock.sphttp_accept(sfd)
+@@ -151,7 +176,7 @@ module Tep
+ res = Response.new
+ @app.dispatch(req, res)
+
+- keep_alive = req.keep_alive? && !res.halted_close?
++ keep_alive = req.keep_alive? && !res.halted_close? && !Tep::APP.close_per_request
+ write_response(client, req, res, keep_alive)
+ keep_going = keep_alive
+ end
+@@ -240,7 +265,12 @@ module Tep
+ if !res.headers.key?("Content-Type") && res.status != 204 && res.status != 304
+ res.headers["Content-Type"] = "text/html; charset=utf-8"
+ end
+- res.headers["Content-Length"] = res.body.length.to_s
++ # bytesize, not length: Content-Length is an OCTET count, and a
++ # body with multi-byte UTF-8 (an embedded asset, upstream API
++ # text) otherwise declares fewer bytes than are written -- the
++ # client truncates the tail (seen live: a bundled .js losing its
++ # closing braces, killing the whole script).
++ res.headers["Content-Length"] = res.body.bytesize.to_s
+ if keep_alive
+ res.headers["Connection"] = "keep-alive"
+ else
+diff --git a/tep/sphttp.c b/tep/sphttp.c
+index 6b7863e..7af1b04 100644
+--- a/tep/sphttp.c
++++ b/tep/sphttp.c
+@@ -24,6 +24,7 @@
+ #include <sys/wait.h>
+ #include <sys/stat.h>
+ #include <netinet/in.h>
++#include <sys/un.h>
+ #include <netinet/tcp.h>
+ #include <arpa/inet.h>
+ #include <signal.h>
+@@ -126,6 +127,26 @@ int sphttp_sleep_ms(int ms) {
+ * term-flag-aware pre-check + EINTR handling as the old body; the
+ * shutdown flag is now sp_net's (set via sp_net_install_term_handlers,
+ * which sphttp_install_term_handlers delegates to). */
++
++/* Unix-domain listener (deployment platforms that hand an app a socket
++ * path -- e.g. CARLOS spawns instances as `<bin> --socket <path>` and
++ * proxies HTTP over it). Unlinks a stale path, binds, listens. Single
++ * fd, no SO_REUSEPORT (kernel load-balancing is a TCP concern; unix
++ * instance apps run one process). Returns the listening fd or -1. */
++int sphttp_listen_unix(const char *path) {
++ struct sockaddr_un addr;
++ if (strlen(path) >= sizeof(addr.sun_path)) return -1;
++ int fd = socket(AF_UNIX, SOCK_STREAM, 0);
++ if (fd < 0) return -1;
++ memset(&addr, 0, sizeof(addr));
++ addr.sun_family = AF_UNIX;
++ strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
++ unlink(path);
++ if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { close(fd); return -1; }
++ if (listen(fd, 128) < 0) { close(fd); unlink(path); return -1; }
++ return fd;
++}
++
+ int sphttp_listen(int port, int reuseport) { return sp_net_listen(port, reuseport); }
+ int sphttp_accept(int sfd) { return sp_net_accept(sfd); }
+ int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); }
diff --git a/apps/spinel/patches/2-tep-bakeoff.patch b/apps/spinel/patches/2-tep-bakeoff.patch
new file mode 100644
index 0000000..d6c95ac
--- /dev/null
+++ b/apps/spinel/patches/2-tep-bakeoff.patch
@@ -0,0 +1,152 @@
+--- a/bin/tep
++++ b/bin/tep
+@@ -899,6 +899,7 @@
+ __port = #{config[:port] || 4567}
+ __workers = #{config[:workers] || 1}
+ __sockpath = ""
++ __listen_host = ""
+ __quiet = false
+ __scheduled = #{scheduled ? 'true' : 'false'}
+ __i = 0
+@@ -915,6 +916,16 @@
+ # argv deployment platforms like CARLOS spawn instances with).
+ __sockpath = ARGV[__i + 1]
+ __i += 2
++ elsif __a == "--listen" && __i + 1 < ARGV.length
++ # host:port -- bind TCP on exactly that address (127.0.0.1 for
++ # local runs) instead of -p's INADDR_ANY.
++ __hp = ARGV[__i + 1]
++ __colon = __hp.rindex(":")
++ if __colon != nil
++ __listen_host = __hp[0, __colon]
++ __port = __hp[__colon + 1, __hp.length - __colon - 1].to_i
++ end
++ __i += 2
+ elsif __a == "--db" && __i + 1 < ARGV.length
+ # Platform-supplied DB path; tolerated so a platform can pass it
+ # unconditionally. Apps that want it read it from ARGV themselves.
+@@ -936,6 +947,8 @@
+ end
+ if __sockpath.length > 0
+ Tep.run_unix!(__sockpath, __quiet)
++ elsif __listen_host.length > 0
++ Tep.run_host!(__listen_host, __port, __quiet)
+ else
+ Tep.run!(__port, __workers, __quiet, __scheduled)
+ end
+--- a/tep/assets.rb
++++ b/tep/assets.rb
+@@ -25,8 +25,11 @@
+ # `Tep.public_dir` instead.
+ module Tep
+ class Assets
++ # Returns 0, as sig/tep/assets.rbs declares (add_asset returns the
++ # ETag String, which otherwise leaks out as this method's value).
+ def self._add(path, body, mime)
+ Tep::APP.add_asset(path, body, mime)
++ 0
+ end
+
+ def self.has?(path)
+--- a/tep/net.rb
++++ b/tep/net.rb
+@@ -27,6 +27,8 @@
+ # Unix-domain listener: bind+listen on a filesystem path (see
+ # sphttp.c). -1 on failure; accept/read/write reuse the same fd paths.
+ ffi_func :sphttp_listen_unix, [:str], :int
++ # TCP listener on one address (`--listen 127.0.0.1:<port>`).
++ ffi_func :sphttp_listen_host, [:str, :int], :int
+ ffi_func :sphttp_accept, [:int], :int
+ # Non-blocking accept variant used by Tep::Server::Scheduled.
+ # Listen fd must be in non-blocking mode (sphttp_set_nonblock).
+--- a/tep/server.rb
++++ b/tep/server.rb
+@@ -8,6 +8,7 @@
+ if status == 204; return "No Content"; end
+ if status == 301; return "Moved Permanently"; end
+ if status == 302; return "Found"; end
++ if status == 303; return "See Other"; end
+ if status == 304; return "Not Modified"; end
+ if status == 400; return "Bad Request"; end
+ if status == 401; return "Unauthorized"; end
+@@ -121,6 +122,25 @@
+ end
+ end
+
++ # Serve TCP on one address (`--listen 127.0.0.1:<port>`), with the
++ # same single-worker, close-per-request shape as run_unix.
++ def run_host(host, port, quiet)
++ if !quiet
++ puts "[tep " + VERSION + "] listening on http://" + host + ":" + port.to_s
++ end
++ Tep::APP.close_per_request = true
++ Sock.sphttp_install_term_handlers
++ sfd = Sock.sphttp_listen_host(host, port)
++ if sfd < 0
++ puts "tep: bind failed on " + host + ":" + port.to_s
++ exit(1)
++ end
++ worker_loop(sfd)
++ if Sock.sphttp_shutdown_requested != 0
++ Tep.on_shutdown
++ end
++ end
++
+ def worker_loop(sfd)
+ loop do
+ client = Sock.sphttp_accept(sfd)
+@@ -152,6 +172,12 @@
+ end
+ Sock.sphttp_set_recv_timeout(client, 0)
+ end
++ # Close-per-request mode serves one connection at a time, so a
++ # client that connects and then sends nothing (a browser's
++ # speculative preconnect) must not hold the worker: bound reads.
++ if Tep::APP.close_per_request
++ Sock.sphttp_set_recv_timeout(client, 5000)
++ end
+ handle_connection(client)
+ end
+ # Explicit Integer return: the `loop` falls through as nil (boxed),
+--- a/tep/sphttp.c
++++ b/tep/sphttp.c
+@@ -85,6 +85,7 @@
+ extern int sp_net_install_term_handlers(void);
+ extern int sp_net_shutdown_requested(void);
+ extern int sp_net_listen(int port, int reuseport);
++extern int sp_net_listen_host(const char *host, int port, int backlog);
+ extern int sp_net_accept(int sfd);
+ extern int sp_net_accept_nb(int sfd);
+ extern int sp_net_connect(const char *host, int port);
+@@ -147,6 +148,17 @@
+ return fd;
+ }
+
++/* TCP listener bound to one address (e.g. 127.0.0.1) rather than
++ * INADDR_ANY, for `--listen host:port`. -1 on failure. */
++int sphttp_listen_host(const char *host, int port) {
++ int fd = sp_net_listen_host(host, port, 1024);
++ if (fd >= 0) {
++ int one = 1;
++ setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
++ }
++ return fd;
++}
++
+ int sphttp_listen(int port, int reuseport) { return sp_net_listen(port, reuseport); }
+ int sphttp_accept(int sfd) { return sp_net_accept(sfd); }
+ int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); }
+--- a/tep.rb
++++ b/tep.rb
+@@ -300,6 +300,11 @@
+ Server.new(APP).run_unix(path, quiet)
+ end
+
++ # TCP twin bound to one host address -- see Server#run_host.
++ def self.run_host!(host, port, quiet)
++ Server.new(APP).run_host(host, port, quiet)
++ end
++
+ def self.run!(port, workers, quiet, scheduled = false)
+ if scheduled
+ Server::Scheduled.new(APP).run(port, workers, quiet)
diff --git a/apps/spinel/scripts/arm64-cc b/apps/spinel/scripts/arm64-cc
new file mode 100755
index 0000000..dc1e21f
--- /dev/null
+++ b/apps/spinel/scripts/arm64-cc
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# The C compiler Spinel calls for an arm64 build (see spinel-cross).
+# Rewrites the host Spinel's runtime paths to the arm64 Spinel's, copies
+# the generated C into the work directory (which the arm64 root can see),
+# and runs a static link with the arm64 root's gcc and glibc.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+repo=$(cd "$here/../../.." && pwd)
+host_lib="$(dirname "${SPINEL_HOST:?}")/../lib"
+arm_lib="$(dirname "${SPINEL_ARM64:?}")/../lib"
+work=${ARM64_WORK:?}
+mkdir -p "$work"
+args=()
+for a in "$@"; do
+ case "$a" in
+ *"$host_lib"*) a="${a//"$host_lib"/"$arm_lib"}" ;;
+ */spinel_out_*.c) cp "$a" "$work/app.c"; a="$work/app.c" ;;
+ esac
+ args+=("$a")
+done
+exec "$repo/scripts/arm64.sh" cc -static -s -L"${ARM64_LIB_DIR:?}" "${args[@]}" -lz -lzstd
diff --git a/apps/spinel/scripts/regen-patch b/apps/spinel/scripts/regen-patch
new file mode 100755
index 0000000..74793dd
--- /dev/null
+++ b/apps/spinel/scripts/regen-patch
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Regenerates patches/2-tep-bakeoff.patch from the edited tep copy in
+# .cache/x86/tep (maintainer tool; the build only applies patches).
+set -euo pipefail
+cd "$(dirname "$0")/.."
+src=${TEP_SRC:-$HOME/.local/share/bakeoff-toolchains/spinel-src/tep}
+base=$(mktemp -d -p "${TMPDIR:-/tmp}")
+trap 'rm -rf "$base"' EXIT
+mkdir -p "$base/a"
+cp -a "$src/." "$base/a/"
+rm -rf "$base/a/.git"
+patch -s -p1 -d "$base/a" < patches/1-tep-carlos.patch
+cp -a .cache/x86/tep "$base/b"
+find "$base/b" -name '*.o' -delete
+rm -f "$base/b/.patched"
+(cd "$base" && diff -ruN a b || true) | sed -E 's#^(---|\+\+\+) ([^\t]*)\t.*#\1 \2#; /^diff -ruN /d' > patches/2-tep-bakeoff.patch
diff --git a/apps/spinel/scripts/spinel-cross b/apps/spinel/scripts/spinel-cross
new file mode 100755
index 0000000..425fcb7
--- /dev/null
+++ b/apps/spinel/scripts/spinel-cross
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Stand-in for `spinel` that tep calls when building for linux-arm64.
+# The host (x86-64) Spinel does the slow part, Ruby -> C, natively; the
+# C compiler it then calls is scripts/arm64-cc, which compiles and links
+# that C inside the emulated arm64 root. Both Spinels are the same commit.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+exec "${SPINEL_HOST:?}" "$@" "--cc=$here/arm64-cc"