basecamp/once-campfire-rust

ONCE Campfire in Rust: one binary, the Rails app's data, 19–44× faster

Rust

70

180 commits

updated Sep 28, 2026

See the code

See what people are saying

README

Campfire in Rust

A port of ONCE Campfire from Rails to Rust. It was built to be impossible to tell apart from the Rails app: the same screens pixel for pixel, the same protocols, and the Rails app's existing SQLite database and storage directory. With that parity reached, it now diverges from Rails where that makes it faster or better; each divergence is listed under Known differences. Everything moved to Rust except the frontend: the CSS, Stimulus controllers, Turbo, Lexxy and the other vendored JavaScript ship as they are, apart from the few files in crates/assets/overrides/.

The port ships as a single campfire executable (plus libvips and ffmpeg). It replaces Ruby, Puma, Redis, Resque and Thruster. Against the Rails app it replaces, it serves pages, posts and real-time delivery 20–95× faster, and holds 10,000 connected clients in a fifth of the memory.

What was done

The Rails app lives in reference/ as a git submodule, pinned to the commit being matched. It is the oracle for everything: no expected output was written by hand. Golden vectors, screenshots and protocol recordings all come from running the real Rails app.

The port (crates/)

CrateWhat it replaces
rails_compatRails' signed and encrypted cookies, signed IDs, signed global IDs, Turbo stream names and bcrypt, byte-compatible with Rails so sessions carry over
kitRack, Action Dispatch and Thruster, on Axum: Rails-style nested params, sessions, flash, format negotiation, forgery protection by Sec-Fetch-Site, ETags and gzip built from a page's cached parts, plus an in-process front server with TLS and ACME, HTTP/2 and Thruster's response cache
dbActive Record over the existing schema (rusqlite), with the same callbacks, timestamps and STI values, and a Rails-compatible fixture loader
richtextThe Action Text pipeline: sanitizing, mentions, opengraph embeds and autolinking, byte-identical to Rails on a 647-case corpus apart from the deliberate differences below
storageActive Storage: the same blob keys, disk layout, variants (libvips) and video previews (ffmpeg), with byte-identical thumbnails
cableThe Action Cable protocol server and pub/sub, frame-for-frame with Rails, on a WebSocket implementation of its own that shares and compresses broadcasts
assetsPropshaft and importmap-rails, with identical fingerprinted filenames and tags
viewsThe ERB templates as Askama templates at the same paths, DOM-identical apart from the deliberate differences below
routesPath helpers from config/routes.rb
campfireEvery controller, the channels, the jobs, Web Push, opengraph unfurling, bot webhooks and search

The plan behind it, including why it uses Axum and why pixel parity is tested the way it is, is in plans/rust-conversion.md.

How parity is proven (parity/)

  • A Playwright harness runs the Rails app and the Rust app side by side in pinned containers, on identical seed data generated by the Rails app, with frozen clocks. It compares 225 screen states across Chromium, Firefox and WebKit, four viewports, light and dark mode, and the CSS breakpoints.
  • For every state it compares:
    • the server HTML
    • the live DOM
    • the accessibility tree
    • every subresource the page loads
    • the Action Cable frames
    • the screenshot, pixel for pixel with zero tolerance
  • Before any Rust code existed, the harness had to show the Rails app matching itself, so that nondeterminism couldn't hide real differences.

Results when the port was finished, before it started to diverge:

CheckResult
Rust vs Rails, full matrix (all engines, viewports, schemes and breakpoints, 5 seeds)All 5,018 cells compared; every cell passes on the fixed build
Rust vs Rails, lean gate (the default per-change check)970/970, 0 flaky, nothing allowlisted
Response header shape, about 70 request types0 differences
RollbackRails boots on, reads, searches and edits a database the Rust app wrote

The only thing masked then was the random join code on the first-run screen. Since the port began to diverge, the harness also leaves out the CSRF tags Rails renders, masks the digests of the files in crates/assets/overrides/, and ignores the session cookie writes and the manifest body that now differ on purpose; everything else still has to match. The latest lean gate, for the release that made the repository public, passed 873 of 874 cells in Chromium, Firefox and WebKit; the one left is the web app manifest, allowlisted as a deliberate difference (parity/allowlist.yml).

Performance

These numbers come from benchmarking the v0.1.1 image against the Rails app: production images of both, the same seed data, the same 4 pinned hardware threads, host networking, and 3 interleaved runs per app. The medians are below; the full tables with spreads are in bench/results/v0.1.1-20260928/report.md. The host ran other light work on other cores during the run; the spread between runs stays within a few percent for the Rust app.

Throughput (16 concurrent clients)

RouteRailsRustRust advantage
Room page215 req/s20,479 req/s95×
Messages page (?before=)406 req/s23,365 req/s58×
Sidebar528 req/s12,642 req/s24×
Search385 req/s23,399 req/s61×
Post a message274 req/s5,452 req/s20×
/up4,069 req/s136,228 req/s33×

Latency

MeasurementRailsRustRust advantage
Room page p50, one client10.3 ms0.19 ms54×
Room page p99, 64 clients515 ms5.1 ms101×
Post a message p99, one client13.4 ms1.67 ms8×
Post a message p99, 64 clients349 ms16.7 ms21×
Upload a 505 KB JPEG until its thumbnail is served132 ms29.4 ms4.5×

Real time (Action Cable, up to 10,000 clients in one room)

MeasurementRailsRustRust advantage
Deliveries per second, 100 clients7,892312,27240×
Deliveries per second, 1,000 clients10,771503,30247×
Deliveries per second, 5,000 clients11,930595,88650×
Deliveries per second, 10,000 clients9,585638,68867×
Post to all 1,000 clients received, p50107 ms6.5 ms16×
Post to all 10,000 clients received, p501,171 ms40 ms29×
Post to all 10,000 clients received, p991,519 ms61 ms25×
Connect and subscribe 10,000 clients29.1 s2.4 s12×

Every client subscribed in every run, for both apps.

Startup and memory

MeasurementRailsRustRust advantage
Cold start (docker run until /up answers)2,567 ms149 ms17×
Idle memory (container)309 MB15 MB21×
App process, 1,000 idle cable clients (Pss)645 MB169 MB3.8×
App process, 10,000 idle cable clients (Pss)1,507 MB313 MB4.8×
App process, 10,000 cable clients under load (Pss)2,191 MB310 MB7.1×
Whole container, 10,000 cable clients under load (Pss)3,519 MB310 MB11×
Image size, unpacked933 MB169 MB5.5×
Image size, compressed download359 MB67 MB5.4×

Rails' whole container adds Redis and Thruster to its app processes; the Rust app is one process.

Since the previous benchmark

The run before this one benchmarked main at 898653e the same way (bench/results/scale-20260927). Since then came cached page parts, the new WebSocket layer, and opting out of transparent huge pages (bench/results/thp-20260928):

Rust app898653ev0.1.1Change
Room page, 16 clients6,002 req/s20,479 req/s3.4×
Search, 16 clients8,970 req/s23,399 req/s2.6×
Deliveries per second, 10,000 clients379,608638,6881.7×
Post to all 10,000 clients received, p5042 ms40 ms1.05×
Idle memory (container)47 MB15 MB3.1× less
App process, 10,000 idle cable clients (Pss)582 MB313 MB1.9× less
App process, 10,000 cable clients under load (Pss)876 MB310 MB2.8× less

100,000 clients, and a Raspberry Pi 5

One Campfire holds 100,000 connected clients in 1.5 GB: every one connects in about 13 s, and memory stays flat while messages fan out to all of them. On a Raspberry Pi 5's CPU budget (emulated: four pinned cores capped at 1.2 cores' worth), the app delivered over a million messages a second to those clients, the load generator's limit, using 0.71 of its 1.2 cores. What limits a Pi is its gigabit Ethernet: about 51,000 compressed deliveries a second. That's 100,000 chatters in rooms of 100, each posting every five minutes, with a third to spare; a single room of 100,000 can't be busy on one gigabit link. Details and caveats in bench/results/pi-100k-20260928/report.md.

At 100,000 clientsBeforeAfter
Memory, idle3.2 GB1.5 GB
Memory, while fanning out5.9 GB1.6 GB
A message on the wire~10 KB~2.3 KB (compressed)
Posting while a message fans out to everyone, p50637 ms43 ms

What changed: Action Cable sockets use their own small WebSocket implementation, which writes each broadcast's shared bytes to every socket without copying them per connection, and compresses a broadcast once for all of its subscribers (permessage-deflate, which browsers offer). Connections run on threads of their own, so page loads and posts don't queue behind a fan-out. The app also raises its own open-file limit, which in Docker would otherwise stop it at 65,536 clients.

Where the speed came from

A straight translation was already 3–10× faster than Rails. Profiling (in plans/perf-attribution.md) then showed where the time went, and each change since has been measured before and after, keeping the test suite and the parity gate green. In the order they landed:

ChangeEffect
Look up a message's cached fragment before building its view, as Rails' cache doesRoom page 989 → 1,664 req/s; messages page 1,116 → 2,234 req/s
gzip on the zlib-rs backend instead of miniz_oxideRoom page 662 → 955 req/s
Run SQLite WAL checkpoints on their own thread, off the writerPOST p99 at one client: 12.5 → 1.7 ms
Cache prepared statements for every queryPOSTs about 10% faster
Share cached fragments instead of copying them; build stylesheet tags once per process6–20% less CPU per page
Cable: 4 KiB read buffers instead of zero-filling 128 KiB per read; encode each broadcast once and share it across subscribers; batch socket writes4.7× less CPU per delivery; half the latency and memory under fan-out
Fat LTO, one codegen unit, jemallocA further 5–14% per route
Splice precompressed messages into gzipped pages (below)Room page 2,527 → 5,461 req/s; messages page 3,709 → 16,523 req/s; search 2,123 → 5,526 req/s
Forgery protection by Sec-Fetch-Site instead of CSRF tokens (Known differences)Room page +9%, messages page +6%, search +10%; pages render the same until their content changes, so revalidation gets a 304
Index messages by (room_id, created_at); check "more than a page" without counting the roomIn a room with 236k messages: room page 95 → 6,051 req/s (64×), messages page 87 → 17,972 req/s (208×). Before, a room page sorted the room's whole history, so rooms slowed as they grew; now a long room serves as fast as a new one
Cache every part of a page, not just its messages, and take the ETag from the parts (below); send cookies only when they changeRoom page 2.9×, search 2.8×, messages page 1.3×
Cable: own WebSocket framing with shared, once-compressed frames; connections on their own runtime (above)100,000 clients in 1.6 GB instead of 5.9 GB while fanning out; a post during a 100,000-client fan-out 637 → 43 ms; frames 10 KB → 2.3 KB on the wire
No transparent huge pages for the process or jemalloc (bench/results/thp-20260928)Idle memory 37 → 11 MB on two cores and 160 → 15 MB on 32, where the kernel's THP setting is always; throughput unchanged

Against Rails, the room page went from 4.4× in the preliminary benchmark to 95× in the latest one.

gzip and ETags from cached page parts

Every response is gzipped at level 6, as Rails' Rack::Deflater does, and after the passes above that was 60–76% of the CPU on large pages. Most of a room page is cached messages, whose bytes are the same on every request, so the app stopped compressing them per request, in two steps:

  1. Spliced gzip. Each cached message is compressed once and kept, and pages splice the stored pieces into the gzip stream. Compressing each message on its own would make a room page 4.4× larger, because consecutive messages share most of their markup, so each piece is compressed against the message before it as a preset dictionary, and reused only when that same message (with the same text between them) comes before it again: the steady state for a room page. The layout around the messages was still compressed live, because every page carried a fresh CSRF token.
  2. Cached page parts. Without CSRF tokens (see Known differences), a page renders byte for byte the same until what it shows changes, so the layout can be stored too. A page is now split into parts that cover it end to end: its cached messages and the text between them. Each part is compressed once, against the part before it, and kept under the part's identity (the cached fragment, or the SHA-256 of the text) and its predecessor's; a message keeps pieces for the few predecessors it's seen with (its room, a page of older messages, search results). The ETag comes from the parts' digests instead of a SHA-256 over the whole body.

For a 466 KB room page, gzip and the ETag took ~1,200 µs per request at first, ~460 µs after splicing, and 42 µs now; the first request after a page changes pays ~2 ms, once, to compress its new parts. The decoded body is unchanged, and the compressed page is within 1% of compressing it whole.

Route (16 clients)BeforeSpliced gzipCached page parts
Room page2,527 req/s5,461 req/s16,881 req/s
Messages page (?before=)3,709 req/s16,523 req/s22,580 req/s
Search2,123 req/s5,526 req/s16,097 req/s

Each step was measured natively against the commit before it, in its own session, so the columns come from different runs (the page-parts run on a busy host, which understates it). Details in bench/results/splice-20260927, bench/results/header-csrf-20260927 and bench/results/page-parts-20260927.

Running it

It's a drop-in replacement for the Rails image: the same environment variables, ports and storage layout. Point it at an existing Campfire's storage and everyone stays signed in.

With ONCE, on any server with Docker:

once deploy ghcr.io/basecamp/once-campfire-rust --host chat.example.com

ONCE provides the secrets, TLS, backups and upgrades. The image is published for amd64 and arm64: :latest and a version tag for each release, and :main for every change to main (see .github/workflows).

Or with Docker alone:

docker run -d -p 80:80 -p 443:443 \
  -e SECRET_KEY_BASE=... -e VAPID_PUBLIC_KEY=... -e VAPID_PRIVATE_KEY=... \
  -e TLS_DOMAIN=chat.example.com \
  -v campfire:/rails/storage \
  ghcr.io/basecamp/once-campfire-rust
  • TLS: with TLS_DOMAIN set, the app gets and renews its own Let's Encrypt certificate. It keeps certificates where Thruster did, so an existing install keeps its certificate.
  • Plain HTTP: set DISABLE_SSL instead, for running behind another proxy.
  • Web Push: VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY are a P-256 key pair in URL-safe Base64 (as the Rails image takes them). They're checked at boot; without a valid pair, push notifications are off and the log says why. VAPID_SUBJECT is the contact push services see (a mailto: or https: URL); it defaults to https:// and your TLS_DOMAIN.
  • The app port: as with Puma behind Thruster, the app also answers on TARGET_PORT (3000) without the front server's cache and compression, but only on loopback. Set TARGET_BIND (e.g. 0.0.0.0) to open it further; it trusts X-Forwarded-* from whoever reaches it.
  • Storage: everything lives under /rails/storage: the SQLite database, uploaded files and backups.
  • Media: the image builds libvips 8.16.1 (thumbnails and other variants) and ffmpeg 7.1.5 (video posters, and ffprobe for video and audio metadata) from the Debian trixie source packages the Rails image installs, with the same flags and libraries, so thumbnails and posters are byte for byte the ones Rails makes. Only what Campfire can reach goes in: libvips loads PNG, GIF, JPEG, TIFF, WebP, AVIF and HEIC/HEIF (with EXIF orientation and ICC profiles) and saves PNG, JPEG, GIF and WebP; ffmpeg keeps every built-in demuxer and decoder plus dav1d for AV1, the filters that pick and orient a poster frame, and only the MJPEG encoder and image2 muxer that write it; other encoders and muxers, hardware, network and external codec libraries are left out. That took the image from 640 MB to 169 MB unpacked, and from 246 MB to 67 MB to download (see the Dockerfile).
  • Many clients: every connected browser is a socket, and the app raises its open-file limit to the hard limit at startup (Docker's default soft limit would stop it at 65,536). Past that, it's memory (~15 KB per client) and bandwidth; see 100,000 clients.
  • ONCE hooks: /hooks/pre-backup runs campfire backup, which uses SQLite's online backup API.
  • Other options: see crates/campfire/src/config.rs.

To build the image yourself: docker build -t campfire-rust . (the reference/ submodule must be checked out). For development:

cargo test --workspace --exclude html5ever   # all crates
cargo run -p campfire -- server               # needs SECRET_KEY_BASE or SECRET_KEY_BASE_DUMMY=1

Verifying changes

parity/bin/reference build && parity/bin/candidate build   # Rails and Rust images
parity/bin/seed build                                      # seed data, generated by the Rails app
parity/bin/candidate compare                               # lean parity gate, Rust vs Rails
parity/bin/compare --matrix full ...                       # full matrix, for release checks
reference-tools/http_shape/sweep.py <rails-url> <rust-url> # response header shape
bench/run                                                  # benchmark both apps
bench/results/pi-100k-20260928/run100k.sh BIN LABEL        # 100,000 cable clients (PI=1: a Pi 5's budget)

parity/SCREENS.md documents the screen inventory, masks and the flake policy. AGENTS.md describes the repository layout and working rules, and CONTRIBUTING.md how to propose changes. Report security issues as SECURITY.md describes.

Known differences

Deliberate:

  • Compressed WebSocket frames. The app accepts the permessage-deflate compression browsers offer (without context takeover, so each broadcast is compressed once for all of its subscribers); Rails' Action Cable doesn't negotiate it. The frames decode to the same messages.
  • No CSRF tokens. Forgery protection checks the Sec-Fetch-Site header browsers send, as Rails main's protect_from_forgery using: :header_only does, instead of per-request tokens. Writes are accepted from same-origin and same-site requests; cross-site ones, and HTTPS requests without the header, get a 422. The Origin check still applies. On plain HTTP, where browsers don't send the header, a missing one is accepted, and the SameSite=Lax session cookie and the Origin check protect writes. Pages have no csrf-token meta tag or authenticity_token fields, so they render byte for byte the same until what they show changes: ETags now match on revalidation, and a page's markup can be cached. Browsers from before 2023 that don't send the header (e.g. Safari before 16.4) can't submit forms over HTTPS. Tabs opened before an upgrade keep working: their tokens are ignored, and the header does the job.
  • Redis and Resque are gone. Jobs run in-process and are best-effort: a crash loses queued webhooks and pushes, as a Redis restart would under Rails. Each kind of job (pushes, webhooks, purges, ...) has its own queue and JOB_CONCURRENCY workers, so a slow bot's webhooks can't hold up push notifications.
  • Push subscriptions are kept through our own failures. Rails destroys a push subscription on any OpenSSL error, which includes a bad VAPID key and any TLS failure (an empty CA store, a skewed clock), so a configuration mistake deleted everyone's subscriptions on the next message. The VAPID keys are now checked once at boot (Web Push is off, with a log line, when they're missing or don't form a key pair), and a subscription is destroyed only when the push service answers 410 or 404 (RFC 8030; Rails keeps it on a 404) or its own key isn't a valid P-256 point.
  • Long messages still get push notifications. Rails puts the whole message in the notification, and one over about 4 KB fails to encrypt (a Web Push message holds 4096 bytes), so nobody is notified. The notification's body is now cut short with an ellipsis at 3 KB, and its title at 256 bytes.
  • The VAPID subject is configurable. Rails identifies every install to push services as mailto:support@37signals.com; this uses VAPID_SUBJECT, or https:// and the first TLS_DOMAIN, or the project's URL.
  • Cookies are only sent when they change. Rails rewrites the session cookie, re-signs the session_token cookie and re-sets last_room on nearly every response. The session cookie is now written only when the session changed, and deleted once it's empty (it only holds the flash and a return-to URL); session_token is re-signed when the session's hourly activity refresh runs, which keeps its 20-year expiry rolling; last_room is set when it changes. An authenticated request whose session doesn't need that refresh also no longer passes through the database writer.
  • ETags aren't a digest of the body on pages made of cached messages (room, messages and search pages): they're a SHA-256 over the page's parts. Identical pages still get identical ETags, and any change gets a new one.
  • One more index. On boot the app adds index_messages_on_room_id_and_created_at to the Rails schema if it's missing (a one-time 49 ms for 236k messages). Rails' schema pages a room's messages through index_messages_on_room_id alone, which sorts the room's whole history for every page. The index is additive, so the database still works with the Rails image.
  • Leaner libvips and ffmpeg. The image builds both from the same Debian sources as the Rails image, leaving out what Campfire can't reach (see Running it). Thumbnails, video posters and metadata come out byte for byte the same for every image and video format either image handles. libvips loses only loaders that Vips.block_untrusted already blocks (ImageMagick, SVG, PDF, JPEG XL, JPEG 2000, OpenEXR, FITS, Matlab, OpenSlide). ffmpeg loses the decoders and demuxers that come from external libraries with no built-in equivalent: tracker modules (libopenmpt), game-console music (libgme), JPEG XL and SVG frames, codec2 speech, teletext subtitles, and DASH/IMF manifests. Tracker modules and game-console music attached to a message are now stored without duration or bit rate, which Campfire never shows.
  • Limits where Rails had none, or raised. Request bodies other than file uploads are capped at 16 MiB (a 413), and so are Active Storage direct uploads, which Campfire's editor doesn't use: asking for a larger one is a 413. A QR code for more than a QR code can hold is a 422, not a 500. Page numbers are capped at a billion. A WebSocket connection holds up to 64 subscriptions with identifiers of up to 4 KiB, and a client that doesn't read what it's sent for 30 seconds is disconnected. Deactivating or banning a user closes their open connections once the change commits.
  • Link unfurling is bounded in time. Rails gives each connect and read of an unfurl 60 seconds, across up to 10 redirects and the image check. Now an unfurl gets 10 seconds in all and 5 per connect or read, and a page that takes longer unfurls nothing. At most 16 unfurls run at once, and only a meta tag's first 256 attributes are read.
  • Bot webhooks are bounded. A delivery gets 60 seconds in all, on top of Rails' 7 per connect or read; one that runs out answers "Failed to respond within 60 seconds", as a 7-second timeout answers with its own. A reply larger than 100 MB (after decompression) fails the delivery and posts nothing; Rails read replies of any size into memory.
  • Push deliveries are bounded in time. A push service gets 10 seconds per connect or read and 30 in all, where the web-push gem leaves Net::HTTP's 60 seconds per step; a slow service would otherwise hold one of the few push workers for minutes.
  • The front server is stricter than Thruster. The app's own listener on TARGET_PORT binds loopback only (Puma bound every interface) and has the front's timeouts and MAX_REQUEST_BODY (see Running it). The response cache counts its keys toward CACHE_SIZE, skips URIs longer than 2 KB, keys on the raw path (Thruster decoded it, so /a%2Fb and /a/b shared an entry), and lets range requests through to the app instead of answering them with a whole cached body.
  • Media is processed off the database writer. Rails saves a blob's row and then uploads its file after commit; here the upload is copied into storage first, straight from the request's tempfile, and deleted again if the save fails. Variants, video posters and analysis run on background threads (at most four at a time), and only their rows are written in a transaction, so a large image or video doesn't hold up other writes. A variant or poster is saved already analyzed, where Rails analyzes it in a job after commit; the rows end up the same. Two requests for the same missing variant may both transform it: the first to save wins and the other's file is deleted. ffmpeg is stopped after 60 seconds of drawing a poster and ffprobe after 30 seconds of reading a file, which Rails doesn't limit.
  • Passwords are hashed and checked outside the database. bcrypt (about 250 ms) runs before the write that saves a password, and a sign-in looks the user up and then verifies the password after releasing the database connection. An unknown email address still costs one bcrypt, as in Rails.
  • Searches are for words. Rails passes a search's words to SQLite's full-text MATCH as they are, so NOT, AND, OR or NEAR in the wrong place is a 500. Each word is now matched as itself.
  • /rooms/directs/:id redirects to the room instead of answering 500.
  • Edge's install instructions render. With an EdgeHTML user agent (Edge/), Rails answers profile and room pages with a 500 because the partial names an image that isn't there (install-edge.svg); the Rust app ships it.
  • New-ping suggestions appear. The user picker for a new ping asks for JSON; in Rails it asks for anything, gets HTML, and never shows a suggestion.
  • Autolinking can't break out of an attribute. rails_autolink finds URLs and email addresses with regular expressions over the sanitized HTML, which Nokogiri serializes with < and > left raw in attribute values. A URL after a > in, say, a title was taken for text and linked, and the inserted <a href="..."> closed the attribute, turning the rest of its value into live markup (a stored XSS; it affects the Rails app). The port escapes < and > in attribute values before autolinking, so URLs inside attributes stay as they were. The same DOM otherwise.
  • Cached markup doesn't carry the request's host. A message's "Copy link" button held an absolute URL built from the Host header, inside a fragment cached for everyone, so one request with a forged Host changed the link everyone copied. The button now carries the message's path (data-copy-to-clipboard-url-value), and the copy-to-clipboard controller (an override) makes it absolute against the page. The bot API's cached JSON, whose URLs must be absolute, is cached per base URL instead.
  • Rich text drops name attributes. Rails' default sanitizer allowlist keeps them, which lets a message clobber the page's DOM globals (<img name="body"> shadows document.body). Nothing Campfire's composer writes has one.
  • Rich text keeps only highlight colors in style. Where Rails runs style through Loofah's CSS scrubber, the sanitizer keeps only color and background-color with a plain color value (a keyword, hex, rgb()/hsl(), or a custom property like Lexxy's var(--highlight-1)), which is all Lexxy writes. It shows in the HTML body the bot API and webhooks send; message pages drop style altogether, as they did.
  • The web app manifest is valid JSON. Rails HTML-escapes the account name and URLs into webmanifest.json, so a name with \ or " broke the manifest and the small logo's URL read ?size=small&amp;v=.... They're JSON strings now.
  • Content attachments nest at most 8 deep. An <action-text-attachment> carrying HTML in its content renders that content, attachments included; each level parses and sanitizes everything below it again, so a 336 KB body of nested ones took 10 seconds to render. Deeper levels now render empty. Campfire's composer doesn't nest them at all.
  • A mention of a deleted user shows ☒. Rails can't find a "missing" partial for users, so the mention raised and blanked the whole message, and editing the message raised too. The rest of the message now shows with ☒ in the mention's place, and the editor leaves the mention out.
  • Not ported: the duplicate session_token cookie Rails' Active Storage streaming sends; and legacy AES-CBC encrypted cookies, since Campfire started on GCM.

Not fully covered:

  • HTTP-01 ACME validation is only unit-tested. TLS-ALPN-01 was tested end to end against a local ACME server.
  • Rich text is checked against Rails on a 647-case corpus, 400 of them fuzzed, which matches exactly apart from the deliberate differences above. Active Storage attachments embedded in a message body, which Campfire's composer can't create, render as ☒.

How it was built

The port was built in about a day by coordinated Claude Code agents, each owning one crate or harness component. They followed the plan in plans/rust-conversion.md, which Codex also reviewed. plans/overnight-report.md logs the unattended overnight run: the parity gate going green, the Thruster replacement, the benchmarks, and each optimization with its before and after numbers. The optimizations and divergences since then were made the same way, one pull request each, with their measurements in bench/results/.

License

MIT, like Campfire. See MIT-LICENSE.

campfire
chat
once
rust
self-hosted

basecamp/once-campfire-rust

ONCE Campfire in Rust: one binary, the Rails app's data, 19–44× faster

Rust

70

180 commits

updated Sep 28, 2026

See the code

See what people are saying

README

Campfire in Rust

A port of ONCE Campfire from Rails to Rust. It was built to be impossible to tell apart from the Rails app: the same screens pixel for pixel, the same protocols, and the Rails app's existing SQLite database and storage directory. With that parity reached, it now diverges from Rails where that makes it faster or better; each divergence is listed under Known differences. Everything moved to Rust except the frontend: the CSS, Stimulus controllers, Turbo, Lexxy and the other vendored JavaScript ship as they are, apart from the few files in crates/assets/overrides/.

The port ships as a single campfire executable (plus libvips and ffmpeg). It replaces Ruby, Puma, Redis, Resque and Thruster. Against the Rails app it replaces, it serves pages, posts and real-time delivery 20–95× faster, and holds 10,000 connected clients in a fifth of the memory.

What was done

The Rails app lives in reference/ as a git submodule, pinned to the commit being matched. It is the oracle for everything: no expected output was written by hand. Golden vectors, screenshots and protocol recordings all come from running the real Rails app.

The port (crates/)

CrateWhat it replaces
rails_compatRails' signed and encrypted cookies, signed IDs, signed global IDs, Turbo stream names and bcrypt, byte-compatible with Rails so sessions carry over
kitRack, Action Dispatch and Thruster, on Axum: Rails-style nested params, sessions, flash, format negotiation, forgery protection by Sec-Fetch-Site, ETags and gzip built from a page's cached parts, plus an in-process front server with TLS and ACME, HTTP/2 and Thruster's response cache
dbActive Record over the existing schema (rusqlite), with the same callbacks, timestamps and STI values, and a Rails-compatible fixture loader
richtextThe Action Text pipeline: sanitizing, mentions, opengraph embeds and autolinking, byte-identical to Rails on a 647-case corpus apart from the deliberate differences below
storageActive Storage: the same blob keys, disk layout, variants (libvips) and video previews (ffmpeg), with byte-identical thumbnails
cableThe Action Cable protocol server and pub/sub, frame-for-frame with Rails, on a WebSocket implementation of its own that shares and compresses broadcasts
assetsPropshaft and importmap-rails, with identical fingerprinted filenames and tags
viewsThe ERB templates as Askama templates at the same paths, DOM-identical apart from the deliberate differences below
routesPath helpers from config/routes.rb
campfireEvery controller, the channels, the jobs, Web Push, opengraph unfurling, bot webhooks and search

The plan behind it, including why it uses Axum and why pixel parity is tested the way it is, is in plans/rust-conversion.md.

How parity is proven (parity/)

  • A Playwright harness runs the Rails app and the Rust app side by side in pinned containers, on identical seed data generated by the Rails app, with frozen clocks. It compares 225 screen states across Chromium, Firefox and WebKit, four viewports, light and dark mode, and the CSS breakpoints.
  • For every state it compares:
    • the server HTML
    • the live DOM
    • the accessibility tree
    • every subresource the page loads
    • the Action Cable frames
    • the screenshot, pixel for pixel with zero tolerance
  • Before any Rust code existed, the harness had to show the Rails app matching itself, so that nondeterminism couldn't hide real differences.

Results when the port was finished, before it started to diverge:

CheckResult
Rust vs Rails, full matrix (all engines, viewports, schemes and breakpoints, 5 seeds)All 5,018 cells compared; every cell passes on the fixed build
Rust vs Rails, lean gate (the default per-change check)970/970, 0 flaky, nothing allowlisted
Response header shape, about 70 request types0 differences
RollbackRails boots on, reads, searches and edits a database the Rust app wrote

The only thing masked then was the random join code on the first-run screen. Since the port began to diverge, the harness also leaves out the CSRF tags Rails renders, masks the digests of the files in crates/assets/overrides/, and ignores the session cookie writes and the manifest body that now differ on purpose; everything else still has to match. The latest lean gate, for the release that made the repository public, passed 873 of 874 cells in Chromium, Firefox and WebKit; the one left is the web app manifest, allowlisted as a deliberate difference (parity/allowlist.yml).

Performance

These numbers come from benchmarking the v0.1.1 image against the Rails app: production images of both, the same seed data, the same 4 pinned hardware threads, host networking, and 3 interleaved runs per app. The medians are below; the full tables with spreads are in bench/results/v0.1.1-20260928/report.md. The host ran other light work on other cores during the run; the spread between runs stays within a few percent for the Rust app.

Throughput (16 concurrent clients)

RouteRailsRustRust advantage
Room page215 req/s20,479 req/s95×
Messages page (?before=)406 req/s23,365 req/s58×
Sidebar528 req/s12,642 req/s24×
Search385 req/s23,399 req/s61×
Post a message274 req/s5,452 req/s20×
/up4,069 req/s136,228 req/s33×

Latency

MeasurementRailsRustRust advantage
Room page p50, one client10.3 ms0.19 ms54×
Room page p99, 64 clients515 ms5.1 ms101×
Post a message p99, one client13.4 ms1.67 ms8×
Post a message p99, 64 clients349 ms16.7 ms21×
Upload a 505 KB JPEG until its thumbnail is served132 ms29.4 ms4.5×

Real time (Action Cable, up to 10,000 clients in one room)

MeasurementRailsRustRust advantage
Deliveries per second, 100 clients7,892312,27240×
Deliveries per second, 1,000 clients10,771503,30247×
Deliveries per second, 5,000 clients11,930595,88650×
Deliveries per second, 10,000 clients9,585638,68867×
Post to all 1,000 clients received, p50107 ms6.5 ms16×
Post to all 10,000 clients received, p501,171 ms40 ms29×
Post to all 10,000 clients received, p991,519 ms61 ms25×
Connect and subscribe 10,000 clients29.1 s2.4 s12×

Every client subscribed in every run, for both apps.

Startup and memory

MeasurementRailsRustRust advantage
Cold start (docker run until /up answers)2,567 ms149 ms17×
Idle memory (container)309 MB15 MB21×
App process, 1,000 idle cable clients (Pss)645 MB169 MB3.8×
App process, 10,000 idle cable clients (Pss)1,507 MB313 MB4.8×
App process, 10,000 cable clients under load (Pss)2,191 MB310 MB7.1×
Whole container, 10,000 cable clients under load (Pss)3,519 MB310 MB11×
Image size, unpacked933 MB169 MB5.5×
Image size, compressed download359 MB67 MB5.4×

Rails' whole container adds Redis and Thruster to its app processes; the Rust app is one process.

Since the previous benchmark

The run before this one benchmarked main at 898653e the same way (bench/results/scale-20260927). Since then came cached page parts, the new WebSocket layer, and opting out of transparent huge pages (bench/results/thp-20260928):

Rust app898653ev0.1.1Change
Room page, 16 clients6,002 req/s20,479 req/s3.4×
Search, 16 clients8,970 req/s23,399 req/s2.6×
Deliveries per second, 10,000 clients379,608638,6881.7×
Post to all 10,000 clients received, p5042 ms40 ms1.05×
Idle memory (container)47 MB15 MB3.1× less
App process, 10,000 idle cable clients (Pss)582 MB313 MB1.9× less
App process, 10,000 cable clients under load (Pss)876 MB310 MB2.8× less

100,000 clients, and a Raspberry Pi 5

One Campfire holds 100,000 connected clients in 1.5 GB: every one connects in about 13 s, and memory stays flat while messages fan out to all of them. On a Raspberry Pi 5's CPU budget (emulated: four pinned cores capped at 1.2 cores' worth), the app delivered over a million messages a second to those clients, the load generator's limit, using 0.71 of its 1.2 cores. What limits a Pi is its gigabit Ethernet: about 51,000 compressed deliveries a second. That's 100,000 chatters in rooms of 100, each posting every five minutes, with a third to spare; a single room of 100,000 can't be busy on one gigabit link. Details and caveats in bench/results/pi-100k-20260928/report.md.

At 100,000 clientsBeforeAfter
Memory, idle3.2 GB1.5 GB
Memory, while fanning out5.9 GB1.6 GB
A message on the wire~10 KB~2.3 KB (compressed)
Posting while a message fans out to everyone, p50637 ms43 ms

What changed: Action Cable sockets use their own small WebSocket implementation, which writes each broadcast's shared bytes to every socket without copying them per connection, and compresses a broadcast once for all of its subscribers (permessage-deflate, which browsers offer). Connections run on threads of their own, so page loads and posts don't queue behind a fan-out. The app also raises its own open-file limit, which in Docker would otherwise stop it at 65,536 clients.

Where the speed came from

A straight translation was already 3–10× faster than Rails. Profiling (in plans/perf-attribution.md) then showed where the time went, and each change since has been measured before and after, keeping the test suite and the parity gate green. In the order they landed:

ChangeEffect
Look up a message's cached fragment before building its view, as Rails' cache doesRoom page 989 → 1,664 req/s; messages page 1,116 → 2,234 req/s
gzip on the zlib-rs backend instead of miniz_oxideRoom page 662 → 955 req/s
Run SQLite WAL checkpoints on their own thread, off the writerPOST p99 at one client: 12.5 → 1.7 ms
Cache prepared statements for every queryPOSTs about 10% faster
Share cached fragments instead of copying them; build stylesheet tags once per process6–20% less CPU per page
Cable: 4 KiB read buffers instead of zero-filling 128 KiB per read; encode each broadcast once and share it across subscribers; batch socket writes4.7× less CPU per delivery; half the latency and memory under fan-out
Fat LTO, one codegen unit, jemallocA further 5–14% per route
Splice precompressed messages into gzipped pages (below)Room page 2,527 → 5,461 req/s; messages page 3,709 → 16,523 req/s; search 2,123 → 5,526 req/s
Forgery protection by Sec-Fetch-Site instead of CSRF tokens (Known differences)Room page +9%, messages page +6%, search +10%; pages render the same until their content changes, so revalidation gets a 304
Index messages by (room_id, created_at); check "more than a page" without counting the roomIn a room with 236k messages: room page 95 → 6,051 req/s (64×), messages page 87 → 17,972 req/s (208×). Before, a room page sorted the room's whole history, so rooms slowed as they grew; now a long room serves as fast as a new one
Cache every part of a page, not just its messages, and take the ETag from the parts (below); send cookies only when they changeRoom page 2.9×, search 2.8×, messages page 1.3×
Cable: own WebSocket framing with shared, once-compressed frames; connections on their own runtime (above)100,000 clients in 1.6 GB instead of 5.9 GB while fanning out; a post during a 100,000-client fan-out 637 → 43 ms; frames 10 KB → 2.3 KB on the wire
No transparent huge pages for the process or jemalloc (bench/results/thp-20260928)Idle memory 37 → 11 MB on two cores and 160 → 15 MB on 32, where the kernel's THP setting is always; throughput unchanged

Against Rails, the room page went from 4.4× in the preliminary benchmark to 95× in the latest one.

gzip and ETags from cached page parts

Every response is gzipped at level 6, as Rails' Rack::Deflater does, and after the passes above that was 60–76% of the CPU on large pages. Most of a room page is cached messages, whose bytes are the same on every request, so the app stopped compressing them per request, in two steps:

  1. Spliced gzip. Each cached message is compressed once and kept, and pages splice the stored pieces into the gzip stream. Compressing each message on its own would make a room page 4.4× larger, because consecutive messages share most of their markup, so each piece is compressed against the message before it as a preset dictionary, and reused only when that same message (with the same text between them) comes before it again: the steady state for a room page. The layout around the messages was still compressed live, because every page carried a fresh CSRF token.
  2. Cached page parts. Without CSRF tokens (see Known differences), a page renders byte for byte the same until what it shows changes, so the layout can be stored too. A page is now split into parts that cover it end to end: its cached messages and the text between them. Each part is compressed once, against the part before it, and kept under the part's identity (the cached fragment, or the SHA-256 of the text) and its predecessor's; a message keeps pieces for the few predecessors it's seen with (its room, a page of older messages, search results). The ETag comes from the parts' digests instead of a SHA-256 over the whole body.

For a 466 KB room page, gzip and the ETag took ~1,200 µs per request at first, ~460 µs after splicing, and 42 µs now; the first request after a page changes pays ~2 ms, once, to compress its new parts. The decoded body is unchanged, and the compressed page is within 1% of compressing it whole.

Route (16 clients)BeforeSpliced gzipCached page parts
Room page2,527 req/s5,461 req/s16,881 req/s
Messages page (?before=)3,709 req/s16,523 req/s22,580 req/s
Search2,123 req/s5,526 req/s16,097 req/s

Each step was measured natively against the commit before it, in its own session, so the columns come from different runs (the page-parts run on a busy host, which understates it). Details in bench/results/splice-20260927, bench/results/header-csrf-20260927 and bench/results/page-parts-20260927.

Running it

It's a drop-in replacement for the Rails image: the same environment variables, ports and storage layout. Point it at an existing Campfire's storage and everyone stays signed in.

With ONCE, on any server with Docker:

once deploy ghcr.io/basecamp/once-campfire-rust --host chat.example.com

ONCE provides the secrets, TLS, backups and upgrades. The image is published for amd64 and arm64: :latest and a version tag for each release, and :main for every change to main (see .github/workflows).

Or with Docker alone:

docker run -d -p 80:80 -p 443:443 \
  -e SECRET_KEY_BASE=... -e VAPID_PUBLIC_KEY=... -e VAPID_PRIVATE_KEY=... \
  -e TLS_DOMAIN=chat.example.com \
  -v campfire:/rails/storage \
  ghcr.io/basecamp/once-campfire-rust
  • TLS: with TLS_DOMAIN set, the app gets and renews its own Let's Encrypt certificate. It keeps certificates where Thruster did, so an existing install keeps its certificate.
  • Plain HTTP: set DISABLE_SSL instead, for running behind another proxy.
  • Web Push: VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY are a P-256 key pair in URL-safe Base64 (as the Rails image takes them). They're checked at boot; without a valid pair, push notifications are off and the log says why. VAPID_SUBJECT is the contact push services see (a mailto: or https: URL); it defaults to https:// and your TLS_DOMAIN.
  • The app port: as with Puma behind Thruster, the app also answers on TARGET_PORT (3000) without the front server's cache and compression, but only on loopback. Set TARGET_BIND (e.g. 0.0.0.0) to open it further; it trusts X-Forwarded-* from whoever reaches it.
  • Storage: everything lives under /rails/storage: the SQLite database, uploaded files and backups.
  • Media: the image builds libvips 8.16.1 (thumbnails and other variants) and ffmpeg 7.1.5 (video posters, and ffprobe for video and audio metadata) from the Debian trixie source packages the Rails image installs, with the same flags and libraries, so thumbnails and posters are byte for byte the ones Rails makes. Only what Campfire can reach goes in: libvips loads PNG, GIF, JPEG, TIFF, WebP, AVIF and HEIC/HEIF (with EXIF orientation and ICC profiles) and saves PNG, JPEG, GIF and WebP; ffmpeg keeps every built-in demuxer and decoder plus dav1d for AV1, the filters that pick and orient a poster frame, and only the MJPEG encoder and image2 muxer that write it; other encoders and muxers, hardware, network and external codec libraries are left out. That took the image from 640 MB to 169 MB unpacked, and from 246 MB to 67 MB to download (see the Dockerfile).
  • Many clients: every connected browser is a socket, and the app raises its open-file limit to the hard limit at startup (Docker's default soft limit would stop it at 65,536). Past that, it's memory (~15 KB per client) and bandwidth; see 100,000 clients.
  • ONCE hooks: /hooks/pre-backup runs campfire backup, which uses SQLite's online backup API.
  • Other options: see crates/campfire/src/config.rs.

To build the image yourself: docker build -t campfire-rust . (the reference/ submodule must be checked out). For development:

cargo test --workspace --exclude html5ever   # all crates
cargo run -p campfire -- server               # needs SECRET_KEY_BASE or SECRET_KEY_BASE_DUMMY=1

Verifying changes

parity/bin/reference build && parity/bin/candidate build   # Rails and Rust images
parity/bin/seed build                                      # seed data, generated by the Rails app
parity/bin/candidate compare                               # lean parity gate, Rust vs Rails
parity/bin/compare --matrix full ...                       # full matrix, for release checks
reference-tools/http_shape/sweep.py <rails-url> <rust-url> # response header shape
bench/run                                                  # benchmark both apps
bench/results/pi-100k-20260928/run100k.sh BIN LABEL        # 100,000 cable clients (PI=1: a Pi 5's budget)

parity/SCREENS.md documents the screen inventory, masks and the flake policy. AGENTS.md describes the repository layout and working rules, and CONTRIBUTING.md how to propose changes. Report security issues as SECURITY.md describes.

Known differences

Deliberate:

  • Compressed WebSocket frames. The app accepts the permessage-deflate compression browsers offer (without context takeover, so each broadcast is compressed once for all of its subscribers); Rails' Action Cable doesn't negotiate it. The frames decode to the same messages.
  • No CSRF tokens. Forgery protection checks the Sec-Fetch-Site header browsers send, as Rails main's protect_from_forgery using: :header_only does, instead of per-request tokens. Writes are accepted from same-origin and same-site requests; cross-site ones, and HTTPS requests without the header, get a 422. The Origin check still applies. On plain HTTP, where browsers don't send the header, a missing one is accepted, and the SameSite=Lax session cookie and the Origin check protect writes. Pages have no csrf-token meta tag or authenticity_token fields, so they render byte for byte the same until what they show changes: ETags now match on revalidation, and a page's markup can be cached. Browsers from before 2023 that don't send the header (e.g. Safari before 16.4) can't submit forms over HTTPS. Tabs opened before an upgrade keep working: their tokens are ignored, and the header does the job.
  • Redis and Resque are gone. Jobs run in-process and are best-effort: a crash loses queued webhooks and pushes, as a Redis restart would under Rails. Each kind of job (pushes, webhooks, purges, ...) has its own queue and JOB_CONCURRENCY workers, so a slow bot's webhooks can't hold up push notifications.
  • Push subscriptions are kept through our own failures. Rails destroys a push subscription on any OpenSSL error, which includes a bad VAPID key and any TLS failure (an empty CA store, a skewed clock), so a configuration mistake deleted everyone's subscriptions on the next message. The VAPID keys are now checked once at boot (Web Push is off, with a log line, when they're missing or don't form a key pair), and a subscription is destroyed only when the push service answers 410 or 404 (RFC 8030; Rails keeps it on a 404) or its own key isn't a valid P-256 point.
  • Long messages still get push notifications. Rails puts the whole message in the notification, and one over about 4 KB fails to encrypt (a Web Push message holds 4096 bytes), so nobody is notified. The notification's body is now cut short with an ellipsis at 3 KB, and its title at 256 bytes.
  • The VAPID subject is configurable. Rails identifies every install to push services as mailto:support@37signals.com; this uses VAPID_SUBJECT, or https:// and the first TLS_DOMAIN, or the project's URL.
  • Cookies are only sent when they change. Rails rewrites the session cookie, re-signs the session_token cookie and re-sets last_room on nearly every response. The session cookie is now written only when the session changed, and deleted once it's empty (it only holds the flash and a return-to URL); session_token is re-signed when the session's hourly activity refresh runs, which keeps its 20-year expiry rolling; last_room is set when it changes. An authenticated request whose session doesn't need that refresh also no longer passes through the database writer.
  • ETags aren't a digest of the body on pages made of cached messages (room, messages and search pages): they're a SHA-256 over the page's parts. Identical pages still get identical ETags, and any change gets a new one.
  • One more index. On boot the app adds index_messages_on_room_id_and_created_at to the Rails schema if it's missing (a one-time 49 ms for 236k messages). Rails' schema pages a room's messages through index_messages_on_room_id alone, which sorts the room's whole history for every page. The index is additive, so the database still works with the Rails image.
  • Leaner libvips and ffmpeg. The image builds both from the same Debian sources as the Rails image, leaving out what Campfire can't reach (see Running it). Thumbnails, video posters and metadata come out byte for byte the same for every image and video format either image handles. libvips loses only loaders that Vips.block_untrusted already blocks (ImageMagick, SVG, PDF, JPEG XL, JPEG 2000, OpenEXR, FITS, Matlab, OpenSlide). ffmpeg loses the decoders and demuxers that come from external libraries with no built-in equivalent: tracker modules (libopenmpt), game-console music (libgme), JPEG XL and SVG frames, codec2 speech, teletext subtitles, and DASH/IMF manifests. Tracker modules and game-console music attached to a message are now stored without duration or bit rate, which Campfire never shows.
  • Limits where Rails had none, or raised. Request bodies other than file uploads are capped at 16 MiB (a 413), and so are Active Storage direct uploads, which Campfire's editor doesn't use: asking for a larger one is a 413. A QR code for more than a QR code can hold is a 422, not a 500. Page numbers are capped at a billion. A WebSocket connection holds up to 64 subscriptions with identifiers of up to 4 KiB, and a client that doesn't read what it's sent for 30 seconds is disconnected. Deactivating or banning a user closes their open connections once the change commits.
  • Link unfurling is bounded in time. Rails gives each connect and read of an unfurl 60 seconds, across up to 10 redirects and the image check. Now an unfurl gets 10 seconds in all and 5 per connect or read, and a page that takes longer unfurls nothing. At most 16 unfurls run at once, and only a meta tag's first 256 attributes are read.
  • Bot webhooks are bounded. A delivery gets 60 seconds in all, on top of Rails' 7 per connect or read; one that runs out answers "Failed to respond within 60 seconds", as a 7-second timeout answers with its own. A reply larger than 100 MB (after decompression) fails the delivery and posts nothing; Rails read replies of any size into memory.
  • Push deliveries are bounded in time. A push service gets 10 seconds per connect or read and 30 in all, where the web-push gem leaves Net::HTTP's 60 seconds per step; a slow service would otherwise hold one of the few push workers for minutes.
  • The front server is stricter than Thruster. The app's own listener on TARGET_PORT binds loopback only (Puma bound every interface) and has the front's timeouts and MAX_REQUEST_BODY (see Running it). The response cache counts its keys toward CACHE_SIZE, skips URIs longer than 2 KB, keys on the raw path (Thruster decoded it, so /a%2Fb and /a/b shared an entry), and lets range requests through to the app instead of answering them with a whole cached body.
  • Media is processed off the database writer. Rails saves a blob's row and then uploads its file after commit; here the upload is copied into storage first, straight from the request's tempfile, and deleted again if the save fails. Variants, video posters and analysis run on background threads (at most four at a time), and only their rows are written in a transaction, so a large image or video doesn't hold up other writes. A variant or poster is saved already analyzed, where Rails analyzes it in a job after commit; the rows end up the same. Two requests for the same missing variant may both transform it: the first to save wins and the other's file is deleted. ffmpeg is stopped after 60 seconds of drawing a poster and ffprobe after 30 seconds of reading a file, which Rails doesn't limit.
  • Passwords are hashed and checked outside the database. bcrypt (about 250 ms) runs before the write that saves a password, and a sign-in looks the user up and then verifies the password after releasing the database connection. An unknown email address still costs one bcrypt, as in Rails.
  • Searches are for words. Rails passes a search's words to SQLite's full-text MATCH as they are, so NOT, AND, OR or NEAR in the wrong place is a 500. Each word is now matched as itself.
  • /rooms/directs/:id redirects to the room instead of answering 500.
  • Edge's install instructions render. With an EdgeHTML user agent (Edge/), Rails answers profile and room pages with a 500 because the partial names an image that isn't there (install-edge.svg); the Rust app ships it.
  • New-ping suggestions appear. The user picker for a new ping asks for JSON; in Rails it asks for anything, gets HTML, and never shows a suggestion.
  • Autolinking can't break out of an attribute. rails_autolink finds URLs and email addresses with regular expressions over the sanitized HTML, which Nokogiri serializes with < and > left raw in attribute values. A URL after a > in, say, a title was taken for text and linked, and the inserted <a href="..."> closed the attribute, turning the rest of its value into live markup (a stored XSS; it affects the Rails app). The port escapes < and > in attribute values before autolinking, so URLs inside attributes stay as they were. The same DOM otherwise.
  • Cached markup doesn't carry the request's host. A message's "Copy link" button held an absolute URL built from the Host header, inside a fragment cached for everyone, so one request with a forged Host changed the link everyone copied. The button now carries the message's path (data-copy-to-clipboard-url-value), and the copy-to-clipboard controller (an override) makes it absolute against the page. The bot API's cached JSON, whose URLs must be absolute, is cached per base URL instead.
  • Rich text drops name attributes. Rails' default sanitizer allowlist keeps them, which lets a message clobber the page's DOM globals (<img name="body"> shadows document.body). Nothing Campfire's composer writes has one.
  • Rich text keeps only highlight colors in style. Where Rails runs style through Loofah's CSS scrubber, the sanitizer keeps only color and background-color with a plain color value (a keyword, hex, rgb()/hsl(), or a custom property like Lexxy's var(--highlight-1)), which is all Lexxy writes. It shows in the HTML body the bot API and webhooks send; message pages drop style altogether, as they did.
  • The web app manifest is valid JSON. Rails HTML-escapes the account name and URLs into webmanifest.json, so a name with \ or " broke the manifest and the small logo's URL read ?size=small&amp;v=.... They're JSON strings now.
  • Content attachments nest at most 8 deep. An <action-text-attachment> carrying HTML in its content renders that content, attachments included; each level parses and sanitizes everything below it again, so a 336 KB body of nested ones took 10 seconds to render. Deeper levels now render empty. Campfire's composer doesn't nest them at all.
  • A mention of a deleted user shows ☒. Rails can't find a "missing" partial for users, so the mention raised and blanked the whole message, and editing the message raised too. The rest of the message now shows with ☒ in the mention's place, and the editor leaves the mention out.
  • Not ported: the duplicate session_token cookie Rails' Active Storage streaming sends; and legacy AES-CBC encrypted cookies, since Campfire started on GCM.

Not fully covered:

  • HTTP-01 ACME validation is only unit-tested. TLS-ALPN-01 was tested end to end against a local ACME server.
  • Rich text is checked against Rails on a 647-case corpus, 400 of them fuzzed, which matches exactly apart from the deliberate differences above. Active Storage attachments embedded in a message body, which Campfire's composer can't create, render as ☒.

How it was built

The port was built in about a day by coordinated Claude Code agents, each owning one crate or harness component. They followed the plan in plans/rust-conversion.md, which Codex also reviewed. plans/overnight-report.md logs the unattended overnight run: the parity gate going green, the Thruster replacement, the benchmarks, and each optimization with its before and after numbers. The optimizations and divergences since then were made the same way, one pull request each, with their measurements in bench/results/.

License

MIT, like Campfire. See MIT-LICENSE.

campfire
chat
once
rust
self-hosted

Languages

Rust

45.7%

HTML

43.6%

Ruby

3.6%

TypeScript

2.9%

Python

1.9%

Shell

1.6%