Campfire, Dockerized
For anyone arriving here cold: Roundhouse transpiles Rails applications to other languages, and Campfire — Basecamp's MIT-licensed chat app — is the one I have been using to find out what that takes when the app pushes as well as responds. Its source is unmodified. Its target here is Spinel, Matz's ahead-of-time Ruby compiler, so the result is a native binary with no interpreter in it.
Ten days ago that binary became a tarball you could build for yourself, provided you had Spinel on your PATH, a C compiler, the SQLite and jemalloc headers, and Node. That is four prerequisites, and one of them is a compiler you would be installing for the occasion. Today the list is one item long:
curl -sL https://rubys.github.io/roundhouse/campfire/docker.tgz | tar xz
cd campfire-docker
docker build -t campfire .
docker run --rm -p 3000:3000 -v campfire-data:/app/storage campfire
Five and a half megabytes down. The build compiles for about 35 seconds
on an M-series Mac and about 80 on a GitHub Actions runner, and produces
a 158 MB image whose only additions to debian:trixie-slim are the
SQLite and jemalloc runtime libraries. Then open
localhost:3000.
What you should see
Campfire's own first-run page: create the account, name yourself, pick a password, and you land in the first room. The settings page has an invite link; open it in a second browser (a private window is enough), join, and talk to yourself. The message you post in one browser arrives in the other over a WebSocket, rendered by the binary. The named volume keeps the database, so stopping the container and running it again puts you back at the sign-in page rather than the first-run page.
What you should not expect
This is a minimum viable Campfire, not Campfire. The measure of the distance is Campfire's own test suite, which the CI runs against the compiled binary on every push and publishes as the compiled conformance page: today, 167 of 288 tests pass and 19 of 54 test files are green, with the failures clustered by cause. (The same emitted source run under CRuby passes 257, so most of the remaining gap belongs to the compiler rather than to the transpilation, and the page keeps those two questions in separate columns.)
The things you will notice first:
- Attachments and uploaded avatars. No file uploads of any kind — no images in messages, no files, no custom avatar. There is no blob storage and no image processor behind the binary. The initials avatars everyone gets on a fresh install render fine; the signup form's avatar picker posts a filename rather than a file until the form learns multipart.
- Web push. Each message enqueues a delivery job that logs one line and gives up, on purpose. The message itself was already broadcast before the job ran.
- Search. The search page answers 500 today: a table name in the
query it builds comes out as a pointer instead of a name. It is on the
page as the
Searchablefailures.
Everything else on that page is smaller than these three, and the README in the archive repeats the list. If you want the product, Basecamp gives it away; this is a compiler's progress report you can chat on.
What is in the archive
Not Ruby. The previous tarball was the transpiled application — Ruby
files a Spinel checkout compiles for you. This one is the program after
that step: one 12 MB C file, the Spinel runtime as C source beside it,
the C of the native packages the app links (JSON, Base64, bcrypt), a
Makefile, and the things the binary reads at run time — prebuilt static
assets, error pages, the seed. Plus a twenty-line Dockerfile whose first
stage runs make and whose second stage keeps the binary.
The mechanism is spin pack, which Matz landed in Spinel the same day
I went looking for it. (It answers an
RFC someone else had
filed; I got lucky on timing.) Its Makefile is derived from what the
compiler reports the program requires — defines, include paths,
libraries — rather than written by hand, so it cannot drift from the
build Spinel itself would do. And the runtime travels as source rather
than as a compiled archive, on the reasoning that the generated C
includes the runtime's headers and most of the runtime is static inline in them; you compile most of it either way, and a prebuilt
archive would only add a way for the two halves to disagree.
That last property fixes something the old README had to confess. Spinel tracks its master branch unpinned, and the archive warned that a later checkout might behave differently from the one it was generated against. This archive has no such caveat. The runtime it was generated against is in the tarball.
Which means the Dockerfile is a recipe, not a requirement. On Linux with
a C compiler, make, and the two -dev packages, make -C pack -j CC=clang builds the same binary with no container at all. The README
covers that, and the macOS wrinkle (Homebrew's headers live where the
Makefile isn't looking).
Compilers
One number surprised me enough to change the Dockerfile. The build is one translation unit and nothing else of consequence, so it is pure single-core compiler speed, and on the same Debian image that unit takes gcc 14 seventy-three seconds and clang 19 thirty-three. The build stage installs clang; it never reaches the runtime image, so its apt weight costs the download nothing.
I then went and measured the same swap on the nightly
conformance lane,
which compiles fifty-four test binaries and had been on gcc. Five
percent. The lane compiles at -O0, where the C compiler is a sliver of
each build and Spinel's own front end is the cost — the 2x was an
optimizer fact, not a compiler fact. That lane stays on gcc, and the
difference between the two measurements is the whole reason to measure
rather than assume.
What shipping the output turned up
spin pack was a day old when I first ran it against a real application,
and a real application is a different animal from the program its test
packs. Three things, all fixed upstream the same day:
- A dependency's carried C didn't travel. Campfire depends on a bcrypt package by git; its objects are built into a shared cache while its sources stay in the package tree, and the pack looked for one beside the other. The tarball warned and then produced a Makefile that failed to link on the recipient's machine — which is to say, on the one machine where the warning wasn't visible.
- A pack made on a Mac wouldn't link on Linux.
String#cryptis libccrypt(3), a separate-lcrypton glibc and part of libSystem on Darwin, and the compiler reported the requirement for its platform rather than leaving it to the recipient's. The Makefile now decides that fromunamewhere it runs. - The compiler's own end-to-end check couldn't reach the pack test on
macOS at all, because BSD
wc -lpads its output with spaces.
And one more, which is my favourite because of how it was found. The published image had been through the same probes as every earlier lane: the root redirects, the first-run page renders, the room page renders after signup, the stylesheet is served. All 200. Then I did the thing the last post said pays for itself embarrassingly fast — I opened it in a browser — and the server log said:
500 GET /account/logo -- Errno::ENOENT: ./app/assets/images/logos/app-icon.png
The sign-in page carries the account logo, the controller answers that
route by reading a PNG from the source tree, and the source tree is
exactly what this archive leaves out. The web copy of the same bytes sits
under static/, uselessly. My probes fetched pages; nothing fetched the
things the pages fetch. A grep for every Rails.root read in the
application found three, the archive now carries all three, and the CI
smoke asserts the logo route beside the others — the previously
published archive would have failed it. If the
last post's ladder had a rung for
"a person opens it," this was it, again.
Why this shape
I want people to be able to try this without adopting anything. A Spinel checkout is a commitment; Docker is something most developers already have. The archive is also the most portable form of the claim: there is no platform question, because the recipient compiles for their own, and there is no version question, because the runtime is inside. And it is honest in a way a prebuilt image wouldn't be — you can read the C, you can read the Makefile, and if something in the room page looks wrong you have the whole program in front of you.
The next step in that direction is a registry image, so the compile happens once instead of on every laptop. But the tarball had to come first, because the tarball is what you can inspect.
If you run it and something answers wrongly — a page, a frame, a log line like the one above — that is the witness I still need most. File it.
Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.