carlos / todomvc-bakeoff Public
feat: spinel builder output
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 files changed,
+2558
−0
apps/spinel/.gitignore+2 −0apps/spinel/AGENTS.md+32 −0apps/spinel/DEPS+2 −0apps/spinel/Makefile+147 −0apps/spinel/README.md+89 −0apps/spinel/STATUS.md+64 −0apps/spinel/app.rb+547 −0apps/spinel/assets/static/app.css+64 −0apps/spinel/assets/static/app.js+80 −0apps/spinel/assets/static/base.css+141 −0apps/spinel/assets/static/index.css+393 −0apps/spinel/patches/1-tep-carlos.patch+198 −0apps/spinel/patches/2-tep-bakeoff.patch+529 −0apps/spinel/scripts/arm64-cc+21 −0apps/spinel/scripts/regen-patch+16 −0apps/spinel/scripts/spinel-cross+8 −0apps/spinel/test/run.sh+225 −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..6f5ed76 |
| --- /dev/null |
| +++ b/apps/spinel/AGENTS.md |
| @@ -0,0 +1,32 @@ |
| +# 🤖 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. The |
| + `visitors` table holds every token the server has issued; any other |
| + cookie value is replaced with a fresh one. |
| +- **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..a18bd24 |
| --- /dev/null |
| +++ b/apps/spinel/DEPS |
| @@ -0,0 +1,2 @@ |
| +tep https://github.com/OriPekelman/tep a27bdcb (patched: patches/) |
| +sqlite 3.50.4 https://www.sqlite.org/2025/sqlite-amalgamation-3500400.zip |
diff --git a/apps/spinel/Makefile b/apps/spinel/Makefile| new file mode 100644 |
| index 0000000..990e717 |
| --- /dev/null |
| +++ b/apps/spinel/Makefile |
| @@ -0,0 +1,147 @@ |
| +# 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, plain and under GC stress |
| +# 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 ---------------------------------------------------------------- |
| +# The second run collects garbage every few KB, which shakes out values |
| +# the generated code forgot to root (one such bug is why tep is patched). |
| +test: dist/app |
| + test/run.sh dist/app |
| + SPINEL_GC_STRESS=1 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/README.md b/apps/spinel/README.md| new file mode 100644 |
| index 0000000..4a43c70 |
| --- /dev/null |
| +++ b/apps/spinel/README.md |
| @@ -0,0 +1,89 @@ |
| +# 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`. |
diff --git a/apps/spinel/STATUS.md b/apps/spinel/STATUS.md| new file mode 100644 |
| index 0000000..7dd8f21 |
| --- /dev/null |
| +++ b/apps/spinel/STATUS.md |
| @@ -0,0 +1,64 @@ |
| +LIVE https://todo-spinel.bfj.oncarlos.com 6692def |
| + |
| +## Where it stands |
| + |
| +- The shared suite passes on the live host: 67 passed, 3 skipped (the two |
| + JS-only edit tests with JS off, and the screenshot test with JS off). |
| + It passes locally too, against the x86-64 build and against the arm64 |
| + binary under qemu, both with `SPINEL_GC_STRESS=1`. |
| +- `make test` (test/run.sh, 86 checks of the contract over curl and raw |
| + sockets, run plain and again under GC stress) passes, against the x86-64 |
| + build and against the arm64 binary under qemu. |
| +- `scripts/boot-check.sh dist/app-linux-arm64` passes. |
| +- `dist/app-linux-arm64` is a static aarch64 ELF, 8.8 MB, no shared |
| + libraries. |
| + |
| +## Worth knowing |
| + |
| +- **Build route.** The host Spinel compiles Ruby to C; only the C compile |
| + and static link run in the emulated arm64 root (`scripts/spinel-cross`, |
| + `scripts/arm64-cc`). Running the arm64 Spinel itself under emulation |
| + also works but took 14 minutes for hello-world, against about 3 to 5 |
| + minutes this way. Both Spinels are the same commit (`abf54280`). |
| +- **A GC bug, fixed in the tep patch.** spinelkit's `Url.parse_query` |
| + holds a decoded key in an unrooted temporary while it decodes the |
| + value. On arm64 a collection in between dropped every todo title longer |
| + than about a thousand characters (the first live deploy, 947e5fe, |
| + failed the long-title test for this reason). On x86-64 it only shows |
| + under `SPINEL_GC_STRESS=1`. `patches/2-tep-bakeoff.patch` decodes forms |
| + in C instead. Worth reporting upstream to spinelkit/tep. |
| +- **tep rewrites words inside string literals.** Its translator turns |
| + `request` into `req` even inside strings, so a 403 message reading |
| + "cross-origin request refused" came out as "cross-origin req refused". |
| + The app's strings avoid those words. |
| +- **The arm64 root** now has `libssl-dev` installed (tep's HTTP layer |
| + links OpenSSL, and the static link needs `libssl.a`; it also pulls in |
| + `-lz -lzstd`). |
| +- **One worker.** tep's server here is a single blocking process that |
| + closes each connection after its response. Each request has a 10-second |
| + deadline for the whole exchange, so a slow or idle client can't hold it. |
| + |
| +## Review findings (all four fixed, deployed as 6692def) |
| + |
| +1. **Per-read timeout.** Replaced by one 10-second deadline per request: |
| + before every read and write, the socket timeouts are set to the time |
| + left. A test trickles one byte a second and is cut off within 12 s. |
| +2. **Bodies capped at 64 KiB.** tep's C shim now reads bodies whole, up to |
| + 256 KiB, into a growing buffer. Above that the answer is 413. A body |
| + short of its `Content-Length`, carrying a NUL, or sent chunked gets 400 |
| + before any handler runs, so nothing changes. tep's SQLite column |
| + buffer grew from 64 KiB to 256 KiB to match. A 10,000-emoji title |
| + (about 120 KB encoded) round-trips locally and on the live host. |
| +3. **Unissued cookies accepted.** A new `visitors` table records every |
| + token the server issues. A cookie that isn't in it gets a fresh random |
| + token. A migration backfills the tokens of lists that already existed, |
| + so current visitors keep their lists (checked against a database at the |
| + old schema). |
| +4. **`--listen` on any address.** The binary now accepts only loopback |
| + (`127.0.0.0/8`, `::1`, `localhost`) and exits with status 2 for any |
| + other address, a missing port, or neither `--socket` nor `--listen`. |
| + tep's every-interface fallback is gone. |
| + |
| +## Review findings not fixed |
| + |
| +None. |
diff --git a/apps/spinel/app.rb b/apps/spinel/app.rb| new file mode 100644 |
| index 0000000..cfe3735 |
| --- /dev/null |
| +++ b/apps/spinel/app.rb |
| @@ -0,0 +1,547 @@ |
| +# 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)", |
| + # Tokens this server has issued; a cookie not in here is not a list. |
| + "CREATE TABLE visitors (token TEXT PRIMARY KEY, created_at INTEGER NOT NULL DEFAULT (unixepoch())) WITHOUT ROWID", |
| + # Lists that existed before the table did were all issued by us. |
| + "INSERT OR IGNORE INTO visitors (token) SELECT DISTINCT list FROM todos" |
| +] |
| + |
| +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 |
| + |
| +def issued_token(t) |
| + DB.prepare("SELECT 1 FROM visitors WHERE token = ?") |
| + DB.bind_str(1, t) |
| + found = DB.step == 1 |
| + DB.finalize |
| + found |
| +end |
| + |
| +def record_token(t) |
| + DB.prepare("INSERT INTO visitors (token) VALUES (?)") |
| + DB.bind_str(1, t) |
| + DB.step |
| + DB.finalize |
| +end |
| + |
| +# The caller's list token. A missing cookie, or one this server never |
| +# issued (garbled, made up, or from another deployment), gets a fresh |
| +# random 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) && issued_token(t) |
| + return t |
| + end |
| + t = Crypto.sp_crypto_random_b64url(16).to_s |
| + record_token(t) |
| + 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("&", "&").gsub("<", "<").gsub(">", ">").gsub("\"", """).gsub("'", "'") |
| +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 POST 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 POST 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 POST 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 POST 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 POST 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 POST 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..61a08a4 |
| --- /dev/null |
| +++ b/apps/spinel/patches/2-tep-bakeoff.patch |
| @@ -0,0 +1,529 @@ |
| +--- a/bin/tep |
| ++++ b/bin/tep |
| +@@ -899,6 +899,8 @@ |
| + __port = #{config[:port] || 4567} |
| + __workers = #{config[:workers] || 1} |
| + __sockpath = "" |
| ++ __listen_host = "" |
| ++ __listen_bad = false |
| + __quiet = false |
| + __scheduled = #{scheduled ? 'true' : 'false'} |
| + __i = 0 |
| +@@ -915,6 +917,21 @@ |
| + # 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. Loopback only |
| ++ # (127.0.0.0/8, ::1, localhost): anything else is refused below. |
| ++ __hp = ARGV[__i + 1] |
| ++ __colon = __hp.rindex(":") |
| ++ if __colon == nil |
| ++ __listen_bad = true |
| ++ else |
| ++ __listen_host = __hp[0, __colon] |
| ++ __port = __hp[__colon + 1, __hp.length - __colon - 1].to_i |
| ++ if !Tep.loopback_host?(__listen_host) || __port < 1 || __port > 65535 |
| ++ __listen_bad = true |
| ++ end |
| ++ 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. |
| +@@ -934,10 +951,19 @@ |
| + __i += 1 |
| + end |
| + end |
| ++ # Never fall back to tep's every-interface listener: the process |
| ++ # serves a unix socket (--socket) or a loopback address (--listen). |
| ++ if __listen_bad |
| ++ puts "tep: --listen needs a loopback address and a port, e.g. 127.0.0.1:8080" |
| ++ exit(2) |
| ++ 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) |
| ++ puts "tep: give --socket <path> or --listen 127.0.0.1:<port>" |
| ++ exit(2) |
| + end |
| + RB |
| + out << "" |
| +--- a/native/sqlite/tep_sqlite.c |
| ++++ b/native/sqlite/tep_sqlite.c |
| +@@ -44,7 +44,7 @@ |
| + #include <sqlite3.h> |
| + |
| + #define TEP_SQLITE_MAX_HANDLES 16 |
| +-#define TEP_SQLITE_COL_BUFSIZE 65536 |
| ++#define TEP_SQLITE_COL_BUFSIZE (256 * 1024 + 1) /* a whole request body (SPHTTP_BODY_MAX) fits */ |
| + #define TEP_SQLITE_COL_BUF_SLOTS 16 |
| + #define TEP_SQLITE_CACHE_SLOTS 64 /* prepare-statement cache */ |
| + #define TEP_SQLITE_CACHE_SQL_MAX 512 /* longest cached SQL */ |
| +--- a/sig/tep/request.rbs |
| ++++ b/sig/tep/request.rbs |
| +@@ -14,6 +14,7 @@ |
| + # an ivar slot with Response. |
| + attr_accessor req_headers: Hash[String, String] |
| + attr_accessor raw_body: String |
| ++ attr_accessor body_status: Integer |
| + attr_accessor cookies: Hash[String, String] |
| + attr_accessor session: Tep::Session |
| + attr_accessor remote_host: String |
| +--- 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,14 @@ |
| + # 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 |
| ++ # Form-rules URL decoding in C (see sphttp.c and Tep.parse_form). |
| ++ ffi_func :sphttp_url_unescape, [:str], :str |
| ++ # Whole-request deadline + complete-body reads (see sphttp.c). |
| ++ ffi_func :sphttp_set_deadline, [:int, :int], :int |
| ++ ffi_func :sphttp_body_max, [], :int |
| ++ ffi_func :sphttp_body_incomplete, [], :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/parser.rb |
| ++++ b/tep/parser.rb |
| +@@ -33,7 +33,7 @@ |
| + else |
| + req.path = req.raw_path[0, qmark] |
| + qstring = req.raw_path[qmark + 1, req.raw_path.length - qmark - 1] |
| +- req.query = SpinelKit::Url.parse_query(qstring) |
| ++ req.query = Tep.parse_form(qstring) |
| + end |
| + |
| + i = 1 |
| +@@ -66,7 +66,8 @@ |
| + if eq > 0 |
| + cname = pair[0, eq].strip |
| + cvalue = pair[eq + 1, pair.length - eq - 1].strip |
| +- req.cookies[cname] = SpinelKit::Url.unescape(cvalue) |
| ++ cdecoded = Sock.sphttp_url_unescape(cvalue) |
| ++ req.cookies[cname] = cdecoded |
| + end |
| + end |
| + end |
| +--- a/tep/request.rb |
| ++++ b/tep/request.rb |
| +@@ -4,6 +4,8 @@ |
| + attr_accessor :verb, :path, :raw_path, :http_version |
| + attr_accessor :params, :query, :req_headers, :raw_body, :cookies, :session |
| + attr_accessor :remote_host |
| ++ # 0 when the body was read whole; else the status to refuse it with. |
| ++ attr_accessor :body_status |
| + attr_accessor :ivars |
| + # Set by the auth-filter (Tep::AuthFilter, run before the user's |
| + # before-filter -- see Tep::App#auth_filter). Always populated: |
| +@@ -29,6 +31,7 @@ |
| + @session = Session.new # signed cookie store |
| + @raw_body = "" # same reasoning as req_headers |
| + @remote_host = "" |
| ++ @body_status = 0 |
| + @passed = false # `pass` flag: skip to the next matching route |
| + @ivars = Tep.str_hash # per-request bag for `@name = ...` |
| + # set by handlers and `before` filters, |
| +@@ -136,12 +139,33 @@ |
| + # Other content types leave @raw_body intact for handlers that |
| + # want to consume it directly. |
| + def consume_body(client_fd) |
| ++ if @req_headers.key?("transfer-encoding") |
| ++ @body_status = 400 |
| ++ return 0 |
| ++ end |
| + cl = content_length |
| +- already = @raw_body.length |
| ++ if cl < 0 |
| ++ @body_status = 400 |
| ++ return 0 |
| ++ end |
| ++ if cl > Sock.sphttp_body_max |
| ++ @body_status = 413 |
| ++ return 0 |
| ++ end |
| ++ already = @raw_body.bytesize |
| + if cl > already |
| + rest = Sock.sphttp_drain_body(client_fd, cl - already) |
| ++ if Sock.sphttp_body_incomplete != 0 |
| ++ @body_status = 400 |
| ++ return 0 |
| ++ end |
| + @raw_body = @raw_body + rest |
| + end |
| ++ # Short (or carrying a NUL, which the C string boundary cuts off). |
| ++ if @raw_body.bytesize < cl |
| ++ @body_status = 400 |
| ++ return 0 |
| ++ end |
| + parse_form_body |
| + 0 |
| + end |
| +@@ -190,7 +214,7 @@ |
| + # @raw_body to Content-Length. |
| + def parse_form_body |
| + if form? |
| +- SpinelKit::Url.parse_query(@raw_body).each do |k, v| |
| ++ Tep.parse_form(@raw_body).each do |k, v| |
| + @params[k] = v |
| + end |
| + elsif multipart? |
| +--- a/tep/router.rb |
| ++++ b/tep/router.rb |
| +@@ -77,7 +77,8 @@ |
| + pp = pat[i] |
| + if pp.length > 0 && pp[0] == ":" |
| + name = @r_params[pi] |
| +- req.params[name] = SpinelKit::Url.unescape(rp[i]) |
| ++ pdecoded = Sock.sphttp_url_unescape(rp[i]) |
| ++ req.params[name] = pdecoded |
| + pi += 1 |
| + end |
| + i += 1 |
| +--- a/tep/server.rb |
| ++++ b/tep/server.rb |
| +@@ -8,11 +8,13 @@ |
| + 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 |
| + if status == 403; return "Forbidden"; end |
| + if status == 404; return "Not Found"; end |
| ++ if status == 413; return "Content Too Large"; end |
| + if status == 500; return "Internal Server Error"; end |
| + "OK" |
| + end |
| +@@ -121,6 +123,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 +173,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, or trickles bytes, |
| ++ # must not hold the worker: the whole request gets 10 seconds. |
| ++ if Tep::APP.close_per_request |
| ++ Sock.sphttp_set_deadline(client, 10000) |
| ++ end |
| + handle_connection(client) |
| + end |
| + # Explicit Integer return: the `loop` falls through as nil (boxed), |
| +@@ -178,6 +205,12 @@ |
| + end |
| + |
| + req.consume_body(client) |
| ++ # A body that is too large, short of its Content-Length, or chunked |
| ++ # (unsupported here) never reaches a handler, so nothing mutates. |
| ++ if req.body_status != 0 |
| ++ send_simple(client, req.body_status, "the body was refused") |
| ++ break |
| ++ end |
| + |
| + res = Response.new |
| + @app.dispatch(req, res) |
| +--- 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,19 +148,111 @@ |
| + 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); } |
| ++ |
| ++/* URL-decode with form rules ('+' is a space). Decoding byte by byte in |
| ++ * Ruby allocates a string per input byte, and SpinelKit::Url.parse_query |
| ++ * holds the decoded key in an unrooted temporary while it decodes the |
| ++ * value, so a collection in between loses the key (seen on aarch64 with |
| ++ * long form values, and on x86-64 under SPINEL_GC_STRESS). Returns a |
| ++ * static buffer; the FFI copies it into a Ruby string. */ |
| ++static char *sphttp_unesc_buf = NULL; |
| ++static size_t sphttp_unesc_cap = 0; |
| ++ |
| ++static int sphttp_hexval(int c) { |
| ++ if (c >= '0' && c <= '9') return c - '0'; |
| ++ if (c >= 'a' && c <= 'f') return c - 'a' + 10; |
| ++ if (c >= 'A' && c <= 'F') return c - 'A' + 10; |
| ++ return -1; |
| ++} |
| ++ |
| ++const char *sphttp_url_unescape(const char *s) { |
| ++ size_t n = strlen(s); |
| ++ if (n + 1 > sphttp_unesc_cap) { |
| ++ char *nb = (char *)realloc(sphttp_unesc_buf, n + 1); |
| ++ if (!nb) return ""; |
| ++ sphttp_unesc_buf = nb; |
| ++ sphttp_unesc_cap = n + 1; |
| ++ } |
| ++ size_t o = 0; |
| ++ for (size_t i = 0; i < n; i++) { |
| ++ unsigned char c = (unsigned char)s[i]; |
| ++ if (c == '+') { |
| ++ sphttp_unesc_buf[o++] = ' '; |
| ++ } else if (c == '%' && i + 2 < n && |
| ++ sphttp_hexval((unsigned char)s[i + 1]) >= 0 && |
| ++ sphttp_hexval((unsigned char)s[i + 2]) >= 0) { |
| ++ sphttp_unesc_buf[o++] = (char)(sphttp_hexval((unsigned char)s[i + 1]) * 16 + |
| ++ sphttp_hexval((unsigned char)s[i + 2])); |
| ++ i += 2; |
| ++ } else { |
| ++ sphttp_unesc_buf[o++] = (char)c; |
| ++ } |
| ++ } |
| ++ sphttp_unesc_buf[o] = '\0'; |
| ++ return sphttp_unesc_buf; |
| ++} |
| + int sphttp_accept(int sfd) { return sp_net_accept(sfd); } |
| + int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); } |
| + |
| + /* Read until end-of-headers ("\r\n\r\n") or the buffer fills. Subsequent |
| + * recv()s for the body are the caller's job (we expose a length helper). |
| + * Returns the parsed length (>0), 0 on clean EOF, -1 on error. */ |
| ++/* Whole-request deadline (close-per-request mode). Before every blocking |
| ++ * read or write on the connection, SO_RCVTIMEO/SO_SNDTIMEO are set to the |
| ++ * time left, so a client trickling one byte at a time can hold the single |
| ++ * worker for at most the deadline, not a timeout per read. */ |
| ++static struct timespec sphttp_deadline; |
| ++static int sphttp_deadline_on = 0; |
| ++ |
| ++int sphttp_set_deadline(int fd, int ms) { |
| ++ (void)fd; |
| ++ if (ms <= 0) { sphttp_deadline_on = 0; return 0; } |
| ++ clock_gettime(CLOCK_MONOTONIC, &sphttp_deadline); |
| ++ sphttp_deadline.tv_sec += ms / 1000; |
| ++ sphttp_deadline.tv_nsec += (long)(ms % 1000) * 1000000L; |
| ++ if (sphttp_deadline.tv_nsec >= 1000000000L) { |
| ++ sphttp_deadline.tv_sec += 1; |
| ++ sphttp_deadline.tv_nsec -= 1000000000L; |
| ++ } |
| ++ sphttp_deadline_on = 1; |
| ++ return 0; |
| ++} |
| ++ |
| ++/* Arms the socket timeouts with the time left; -1 once it has run out. */ |
| ++static int sphttp_arm_deadline(int fd) { |
| ++ if (!sphttp_deadline_on) return 0; |
| ++ struct timespec now; |
| ++ clock_gettime(CLOCK_MONOTONIC, &now); |
| ++ long long left_ms = (long long)(sphttp_deadline.tv_sec - now.tv_sec) * 1000LL + |
| ++ (sphttp_deadline.tv_nsec - now.tv_nsec) / 1000000L; |
| ++ if (left_ms <= 0) { errno = ETIMEDOUT; return -1; } |
| ++ struct timeval tv; |
| ++ tv.tv_sec = (time_t)(left_ms / 1000); |
| ++ tv.tv_usec = (suseconds_t)((left_ms % 1000) * 1000); |
| ++ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); |
| ++ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); |
| ++ return 0; |
| ++} |
| ++ |
| + int sphttp_read_request(int fd) { |
| + SSL *ssl = sphttp_ssl_for(fd); /* inbound TLS: decrypt via SSL_read */ |
| + sphttp_req_len = 0; |
| + sphttp_req_buf[0] = '\0'; |
| + while (sphttp_req_len < SPHTTP_BUFSIZE - 1) { |
| + int want = SPHTTP_BUFSIZE - 1 - sphttp_req_len; |
| ++ if (!ssl && sphttp_arm_deadline(fd) < 0) return -1; |
| + ssize_t n = ssl |
| + ? (ssize_t)SSL_read(ssl, sphttp_req_buf + sphttp_req_len, want) |
| + : recv(fd, sphttp_req_buf + sphttp_req_len, (size_t)want, 0); |
| +@@ -190,23 +283,42 @@ |
| + * computes remaining = content_length - already_in_buf; this gulps |
| + * those into a Ruby-visible string buffer. We round-trip via a |
| + * static buffer to avoid hand-rolling write_str FFI. */ |
| +-static char sphttp_body_buf[SPHTTP_BUFSIZE]; |
| ++/* Bodies are read whole, up to SPHTTP_BODY_MAX, into a growing buffer |
| ++ * (this used to stop at 64 KiB, silently truncating longer bodies). |
| ++ * sphttp_body_incomplete() reports whether the last drain came up short: |
| ++ * the peer closed or the deadline passed before total_len bytes arrived. */ |
| ++#define SPHTTP_BODY_MAX (256 * 1024) |
| ++static char *sphttp_body_buf = NULL; |
| ++static size_t sphttp_body_cap = 0; |
| ++static int sphttp_body_short = 0; |
| ++ |
| ++int sphttp_body_max(void) { return SPHTTP_BODY_MAX; } |
| ++int sphttp_body_incomplete(void) { return sphttp_body_short; } |
| + |
| + const char *sphttp_drain_body(int fd, int total_len) { |
| + SSL *ssl = sphttp_ssl_for(fd); |
| + int n = total_len; |
| ++ sphttp_body_short = 0; |
| + if (n < 0) n = 0; |
| +- if (n >= SPHTTP_BUFSIZE) n = SPHTTP_BUFSIZE - 1; |
| ++ if (n > SPHTTP_BODY_MAX) { sphttp_body_short = 1; return ""; } |
| ++ if ((size_t)n + 1 > sphttp_body_cap) { |
| ++ char *nb = (char *)realloc(sphttp_body_buf, (size_t)n + 1); |
| ++ if (!nb) { sphttp_body_short = 1; return ""; } |
| ++ sphttp_body_buf = nb; |
| ++ sphttp_body_cap = (size_t)n + 1; |
| ++ } |
| + int got = 0; |
| + while (got < n) { |
| ++ if (!ssl && sphttp_arm_deadline(fd) < 0) break; |
| + ssize_t r = ssl ? (ssize_t)SSL_read(ssl, sphttp_body_buf + got, n - got) |
| + : recv(fd, sphttp_body_buf + got, n - got, 0); |
| + if (r <= 0) { |
| +- if (!ssl && errno == EINTR) continue; |
| ++ if (!ssl && r < 0 && errno == EINTR) continue; |
| + break; |
| + } |
| + got += (int)r; |
| + } |
| ++ if (got < n) sphttp_body_short = 1; |
| + sphttp_body_buf[got] = '\0'; |
| + return sphttp_body_buf; |
| + } |
| +@@ -221,6 +333,7 @@ |
| + if (w <= 0) return -1; |
| + off += (size_t)w; |
| + } else { |
| ++ if (sphttp_arm_deadline(fd) < 0) return -1; |
| + ssize_t n = send(fd, s + off, len - off, 0); |
| + if (n <= 0) { |
| + if (errno == EINTR) continue; |
| +--- a/tep.rb |
| ++++ b/tep.rb |
| +@@ -166,6 +166,68 @@ |
| + # narrowing model). Removing it would require a mechanical |
| + # `< 0` -> `.nil?` refactor across http.rb / parser.rb / url.rb / |
| + # jwt.rb / app.rb. Worth doing eventually; not urgent. |
| ++ # Parse an application/x-www-form-urlencoded string (a body or a query |
| ++ # string). Replaces SpinelKit::Url.parse_query, which can lose keys to |
| ++ # the GC (see sphttp_url_unescape); every intermediate here is a local. |
| ++ def self.parse_form(s) |
| ++ h = Tep.str_hash |
| ++ if s.length == 0 |
| ++ return h |
| ++ end |
| ++ pairs = s.split("&") |
| ++ i = 0 |
| ++ while i < pairs.length |
| ++ pair = pairs[i] |
| ++ if pair.length > 0 |
| ++ k = "" |
| ++ v = "" |
| ++ eq = Tep.str_find(pair, "=", 0) |
| ++ if eq < 0 |
| ++ k = Sock.sphttp_url_unescape(pair) |
| ++ else |
| ++ kraw = pair[0, eq] |
| ++ vraw = pair[eq + 1, pair.length - eq - 1] |
| ++ k = Sock.sphttp_url_unescape(kraw) |
| ++ v = Sock.sphttp_url_unescape(vraw) |
| ++ end |
| ++ h[k] = v |
| ++ end |
| ++ i += 1 |
| ++ end |
| ++ h |
| ++ end |
| ++ |
| ++ # True for the addresses `--listen` may bind: localhost, ::1 and |
| ++ # dotted-quad 127.0.0.0/8. |
| ++ def self.loopback_host?(h) |
| ++ if h == "localhost" || h == "::1" |
| ++ return true |
| ++ end |
| ++ parts = h.split(".") |
| ++ if parts.length != 4 || parts[0] != "127" |
| ++ return false |
| ++ end |
| ++ i = 0 |
| ++ while i < 4 |
| ++ p = parts[i] |
| ++ if p.length == 0 || p.length > 3 |
| ++ return false |
| ++ end |
| ++ j = 0 |
| ++ while j < p.length |
| ++ if p[j] < "0" || p[j] > "9" |
| ++ return false |
| ++ end |
| ++ j += 1 |
| ++ end |
| ++ if p.to_i > 255 |
| ++ return false |
| ++ end |
| ++ i += 1 |
| ++ end |
| ++ true |
| ++ end |
| ++ |
| + def self.str_find(s, needle, start) |
| + nlen = needle.length |
| + slen = s.length |
| +@@ -300,6 +362,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" |
diff --git a/apps/spinel/test/run.sh b/apps/spinel/test/run.sh| new file mode 100755 |
| index 0000000..607422d |
| --- /dev/null |
| +++ b/apps/spinel/test/run.sh |
| @@ -0,0 +1,225 @@ |
| +#!/usr/bin/env bash |
| +# The app's own tests: start the compiled binary, drive it with curl, and |
| +# check the contract's wire-level rules (status codes, redirects, cookies, |
| +# escaping, Content-Length, static files, isolation, Origin, the unix |
| +# socket and a clean SIGTERM exit). The browser-level behaviour is the |
| +# shared suite's job (scripts/test-app.sh at the repo root). |
| +# |
| +# test/run.sh dist/app |
| +set -uo pipefail |
| + |
| +bin=$(cd "$(dirname "${1:?usage: test/run.sh <binary>}")" && pwd)/$(basename "$1") |
| +here=$(cd "$(dirname "$0")/.." && pwd) |
| +work=$(mktemp -d -p "${TMPDIR:-/tmp}") |
| +pid="" |
| +cleanup() { |
| + [ -n "$pid" ] && kill "$pid" 2>/dev/null |
| + rm -rf "$work" |
| +} |
| +trap cleanup EXIT |
| + |
| +pass=0 |
| +fail=0 |
| +ok() { pass=$((pass + 1)); } |
| +bad() { fail=$((fail + 1)); echo "FAIL: $*"; } |
| +check() { # check <description> <expected> <actual> |
| + if [ "$2" == "$3" ]; then ok; else bad "$1: expected [$2], got [$3]"; fi |
| +} |
| +contains() { # contains <description> <needle> <haystack> |
| + if [[ "$3" == *"$2"* ]]; then ok; else bad "$1: [$2] not found"; fi |
| +} |
| +lacks() { |
| + if [[ "$3" != *"$2"* ]]; then ok; else bad "$1: [$2] found"; fi |
| +} |
| + |
| +port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])') |
| +base="http://127.0.0.1:$port" |
| +"$bin" --listen "127.0.0.1:$port" --db "$work/app.db" >"$work/app.log" 2>&1 & |
| +pid=$! |
| +for _ in $(seq 50); do curl -sf "$base/healthz" >/dev/null && break; sleep 0.1; done |
| + |
| +status() { curl -s -o /dev/null -w '%{http_code}' "$@"; } |
| +jar="$work/jar" |
| +other="$work/other" |
| + |
| +# --- platform routes |
| +check "healthz" 200 "$(status "$base/healthz")" |
| +check "version" 200 "$(status "$base/api/version")" |
| +check "version body" "$(cat "$here/version.rb" | sed -n 's/^APP_VERSION = "\(.*\)"$/\1/p')" "$(curl -s "$base/api/version")" |
| +check "unknown path" 404 "$(status "$base/nope")" |
| + |
| +# --- listens only where it was told to |
| +listeners=$(ss -ltnH "sport = :$port" | awk '{print $4}') |
| +check "bound to 127.0.0.1 only" "127.0.0.1:$port" "$listeners" |
| + |
| +# --- first visit: cookie, empty list, no main/footer |
| +headers=$(curl -s -D - -o "$work/page" -c "$jar" "$base/") |
| +contains "cookie set" "Set-Cookie: tid=" "$headers" |
| +contains "cookie HttpOnly" "HttpOnly" "$headers" |
| +contains "cookie SameSite" "SameSite=Lax" "$headers" |
| +contains "cookie Path" "Path=/" "$headers" |
| +lacks "no Secure over http" "Secure" "$headers" |
| +token=$(awk '$6 == "tid" {print $7}' "$jar") |
| +check "token is 22 base64url chars" 1 "$([[ "$token" =~ ^[A-Za-z0-9_-]{22}$ ]] && echo 1 || echo 0)" |
| +page=$(cat "$work/page") |
| +lacks "empty list hides main" 'class="main"' "$page" |
| +lacks "empty list hides footer" 'class="footer"' "$page" |
| +contains "language in footer" "the CARLOS bakeoff</a> · Spinel" "$page" |
| + |
| +# --- add: trimmed, blank ignored, 303 to the filter |
| +post() { curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$jar" -c "$jar" "$@"; } |
| +check "add" "303 $base/" "$(post --data-urlencode 'title= buy milk ' -d filter=all "$base/todos")" |
| +check "add from active" "303 $base/active" "$(post --data-urlencode 'title=walk the dog' -d filter=active "$base/todos")" |
| +check "blank add" "303 $base/completed" "$(post --data-urlencode 'title= ' -d filter=completed "$base/todos")" |
| +check "unknown filter" "303 $base/" "$(post --data-urlencode 'title=write' -d filter=bogus "$base/todos")" |
| +page=$(curl -s -b "$jar" "$base/") |
| +contains "trimmed title" "<label>buy milk</label>" "$page" |
| +check "three items" 3 "$(grep -c '<li data-id=' <<<"$page")" |
| +contains "counter" "<strong>3</strong> items left" "$page" |
| +lacks "no clear-completed yet" "clear-completed" "$page" |
| + |
| +id1=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | head -1 | grep -o '[0-9]*') |
| +id2=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | sed -n 2p | grep -o '[0-9]*') |
| + |
| +# --- toggle, filters, toggle-all, clear-completed |
| +check "toggle" "303 $base/active" "$(post -d filter=active "$base/todos/$id1/toggle")" |
| +page=$(curl -s -b "$jar" "$base/") |
| +contains "completed class" "<li data-id=\"$id1\" class=\"completed\">" "$page" |
| +contains "singular counter" "<strong>2</strong> items left" "$page" |
| +contains "clear-completed shown" 'class="clear-completed"' "$page" |
| +active=$(curl -s -b "$jar" "$base/active") |
| +lacks "active filter hides completed" "data-id=\"$id1\"" "$active" |
| +contains "active link selected" '<a class="selected" href="/active">' "$active" |
| +completed=$(curl -s -b "$jar" "$base/completed") |
| +check "completed filter" 1 "$(grep -c '<li data-id=' <<<"$completed")" |
| +check "toggle-all" "303 $base/" "$(post -d filter=all "$base/todos/toggle-all")" |
| +check "all completed" 3 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')" |
| +contains "toggle-all checked" 'class="toggle-all" type="checkbox" form="toggle-all-form" checked' "$(curl -s -b "$jar" "$base/")" |
| +post -d filter=all "$base/todos/toggle-all" >/dev/null |
| +check "toggle-all again clears" 0 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')" |
| +post -d filter=all "$base/todos/$id1/toggle" >/dev/null |
| +check "clear-completed" "303 $base/" "$(post -d filter=all "$base/todos/clear-completed")" |
| +lacks "completed item gone" "data-id=\"$id1\"" "$(curl -s -b "$jar" "$base/")" |
| + |
| +# --- edit view, rename, empty rename deletes |
| +edit=$(curl -s -b "$jar" "$base/active?edit=$id2") |
| +contains "editing class" "<li data-id=\"$id2\" class=\"editing\">" "$edit" |
| +contains "edit form" "action=\"/todos/$id2/edit\"" "$edit" |
| +contains "edit link keeps filter" "href=\"/active?edit=$id2\"" "$edit" |
| +check "rename" "303 $base/active" "$(post --data-urlencode 'title= walk the cat ' -d filter=active "$base/todos/$id2/edit")" |
| +contains "renamed" "<label>walk the cat</label>" "$(curl -s -b "$jar" "$base/")" |
| +check "empty rename" "303 $base/" "$(post --data-urlencode 'title= ' -d filter=all "$base/todos/$id2/edit")" |
| +lacks "empty rename deletes" "data-id=\"$id2\"" "$(curl -s -b "$jar" "$base/")" |
| + |
| +# --- escaping, UTF-8, Content-Length, long titles |
| +post --data-urlencode 'title=<img src=x onerror=alert(1)> & "q" '"'"'s' -d filter=all "$base/todos" >/dev/null |
| +post --data-urlencode 'title=naïve 🚀 日本語' -d filter=all "$base/todos" >/dev/null |
| +headers=$(curl -s -D - -o "$work/page" -b "$jar" "$base/") |
| +page=$(cat "$work/page") |
| +contains "escaped title" "<img src=x onerror=alert(1)> & "q" 's" "$page" |
| +lacks "no raw img" "<img" "$page" |
| +contains "UTF-8 kept" "naïve 🚀 日本語" "$page" |
| +declared=$(tr -d '\r' <<<"$headers" | sed -n 's/^Content-Length: //p') |
| +check "Content-Length is bytes" "$(wc -c <"$work/page")" "$declared" |
| +long=$(printf 'é%.0s' $(seq 10000)) |
| +post --data-urlencode "title=$long" -d filter=all "$base/todos" >/dev/null |
| +contains "10,000-char title" "<label>$long</label>" "$(curl -s -b "$jar" "$base/")" |
| + |
| +# --- isolation, foreign ids, garbled cookies, Origin |
| +check "foreign id from another visitor" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$id1/toggle")" |
| +own=$(curl -s -b "$jar" "$base/" | grep -o '<li data-id="[0-9]*"' | head -1 | grep -o '[0-9]*') |
| +check "someone else's toggle" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$own/toggle")" |
| +check "someone else's edit" 404 "$(status -b "$other" -d title=x -d filter=all "$base/todos/$own/edit")" |
| +check "someone else's destroy" 404 "$(status -b "$other" -d filter=all "$base/todos/$own/destroy")" |
| +lacks "other visitor sees nothing" "<li data-id" "$(curl -s -b "$other" "$base/")" |
| +check "non-numeric id" 404 "$(status -b "$jar" -d filter=all "$base/todos/abc/toggle")" |
| +check "garbled cookie" 200 "$(status -b 'tid=%%%' "$base/")" |
| +lacks "garbled cookie gets empty list" "<li data-id" "$(curl -s -b 'tid=%%%' "$base/")" |
| +contains "garbled cookie replaced" "Set-Cookie: tid=" "$(curl -s -D - -o /dev/null -b 'tid=%%%' "$base/")" |
| +check "cross-site POST" 403 "$(status -b "$jar" -H 'Origin: https://evil.example' --data-urlencode title=evil "$base/todos")" |
| +lacks "cross-site POST changed nothing" "evil" "$(curl -s -b "$jar" "$base/")" |
| +check "same-origin POST" 303 "$(status -b "$jar" -H "Origin: $base" --data-urlencode title=fine "$base/todos")" |
| + |
| +# --- the script's fragment answers |
| +frag=$(curl -s -b "$jar" -H 'X-Fragment: 1' -d filter=active --data-urlencode title=fragment "$base/todos") |
| +check "fragment starts with main" '<section class="main">' "$(head -1 <<<"$frag")" |
| +lacks "fragment is not a page" "<html" "$frag" |
| +contains "fragment keeps the filter" '<a class="selected" href="/active">' "$frag" |
| + |
| +# --- static files, byte for byte |
| +for f in base.css index.css; do |
| + curl -s "$base/static/$f" -o "$work/$f" |
| + if cmp -s "$work/$f" "$here/../../shared/$f"; then ok; else bad "/static/$f differs from shared/$f"; fi |
| +done |
| +contains "css type" "Content-Type: text/css" "$(curl -s -D - -o /dev/null "$base/static/index.css")" |
| + |
| +# --- cookies must be ones this server issued |
| +made_up="AAAAAAAAAAAAAAAAAAAAAA" |
| +hdr=$(curl -s -D - -o /dev/null -H "Cookie: tid=$made_up" -d filter=all --data-urlencode title=shared "$base/todos") |
| +contains "made-up token replaced" "Set-Cookie: tid=" "$hdr" |
| +lacks "made-up token not kept" "tid=$made_up" "$hdr" |
| +lacks "made-up token holds no list" "<label>shared</label>" "$(curl -s -H "Cookie: tid=$made_up" "$base/")" |
| + |
| +# --- long bodies arrive whole; short, chunked and oversized ones change nothing |
| +rockets=$(python3 -c 'print("\U0001F680" * 10000, end="")') |
| +check "10,000-emoji title" 303 "$(status -b "$jar" --data-urlencode "title=$rockets" -d filter=all "$base/todos")" |
| +contains "10,000 emoji kept whole" "<label>$rockets</label>" "$(curl -s -b "$jar" "$base/")" |
| +before=$(curl -s -b "$jar" "$base/" | grep -c '<li data-id=') |
| +raw() { # raw <python body>: talk to the server over a raw socket, print the status line |
| + python3 -I -c " |
| +import socket, sys, time |
| +s = socket.create_connection(('127.0.0.1', $port)) |
| +h = 'POST /todos HTTP/1.1\\r\\nHost: x\\r\\nCookie: tid=$token\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\n' |
| +$1 |
| +s.settimeout(30) |
| +print(s.recv(100).split(b'\\r\\n')[0].decode()) |
| +" |
| +} |
| +contains "short body refused" " 400 " "$(raw "s.sendall((h + 'Content-Length: 5000\\r\\n\\r\\n').encode() + b'title=' + b'y' * 1000); s.shutdown(socket.SHUT_WR)")" |
| +contains "chunked body refused" " 400 " "$(raw "s.sendall((h + 'Transfer-Encoding: chunked\\r\\n\\r\\n5\\r\\ntitle\\r\\n0\\r\\n\\r\\n').encode())")" |
| +contains "oversized body refused" " 413 " "$(raw "s.sendall((h + 'Content-Length: 300000\\r\\n\\r\\n').encode())")" |
| +check "refused bodies changed nothing" "$before" "$(curl -s -b "$jar" "$base/" | grep -c '<li data-id=')" |
| + |
| +# --- a client trickling its body is cut off at the 10-second request deadline |
| +start=$(date +%s) |
| +raw "s.sendall((h + 'Content-Length: 100\\r\\n\\r\\n').encode()) |
| +for _ in range(30): |
| + try: s.sendall(b'x'); time.sleep(1) |
| + except OSError: break" >/dev/null 2>&1 |
| +took=$(( $(date +%s) - start )) |
| +check "trickling client cut off within 12s" 1 "$([ "$took" -le 12 ] && echo 1 || echo 0)" |
| +check "server still answers after a trickler" 200 "$(status "$base/healthz")" |
| + |
| +# --- only loopback listeners, and no silent every-interface fallback |
| +timeout 5 "$bin" --listen "0.0.0.0:$((port + 1))" --db "$work/x.db" >/dev/null 2>&1 |
| +check "--listen 0.0.0.0 refused" 2 "$?" |
| +timeout 5 "$bin" --listen "10.1.2.3:$((port + 1))" --db "$work/x.db" >/dev/null 2>&1 |
| +check "--listen 10.1.2.3 refused" 2 "$?" |
| +timeout 5 "$bin" --db "$work/x.db" -p "$((port + 1))" >/dev/null 2>&1 |
| +check "no listener refused" 2 "$?" |
| + |
| +# --- clean exit on SIGTERM, and the data survives a restart |
| +kill -TERM "$pid" |
| +code=0 |
| +for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done |
| +if kill -0 "$pid" 2>/dev/null; then bad "still running after SIGTERM"; else wait "$pid"; code=$?; check "SIGTERM exit status" 0 "$code"; fi |
| +pid="" |
| + |
| +# --- the unix-socket contract, with CARLOS_HOST |
| +sock="$work/app.sock" |
| +CARLOS_HOST=todo.example.test "$bin" --socket "$sock" --db "$work/app.db" >>"$work/app.log" 2>&1 & |
| +pid=$! |
| +for _ in $(seq 50); do [ -S "$sock" ] && break; sleep 0.1; done |
| +u=(curl -s --unix-socket "$sock") |
| +check "socket healthz" 200 "$("${u[@]}" -o /dev/null -w '%{http_code}' http://x/healthz)" |
| +contains "data survived restart" "<label>fragment</label>" "$("${u[@]}" -H "Cookie: tid=$token" http://x/)" |
| +contains "Secure cookie on Carloku" "Secure" "$("${u[@]}" -D - -o /dev/null http://x/)" |
| +check "Carloku origin accepted" 303 "$("${u[@]}" -o /dev/null -w '%{http_code}' -H "Cookie: tid=$token" -H 'Origin: https://todo.example.test' -d title=x http://x/todos)" |
| +check "local origin refused on Carloku" 403 "$("${u[@]}" -o /dev/null -w '%{http_code}' -H "Cookie: tid=$token" -H "Origin: $base" -d title=x http://x/todos)" |
| +kill -TERM "$pid" |
| +for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done |
| +if kill -0 "$pid" 2>/dev/null; then bad "socket server still running after SIGTERM"; else wait "$pid"; check "socket SIGTERM exit status" 0 "$?"; fi |
| +pid="" |
| + |
| +echo "$pass passed, $fail failed" |
| +[ "$fail" -eq 0 ] |