The Browser Half
In As If I compared two ways of making Campfire fast: compiling the Rails app with Roundhouse, and porting it by hand to Rust, as 37signals did with once-campfire-rust. Both check their output against the running Rails app. Both are, it turns out, ports of the server only.
Campfire's app/javascript is 3,749 lines. Its models are 1,375 and its controllers 1,325, so the JavaScript is larger than both together. Roundhouse copies it into the output byte for byte. The Rust port serves the Rails app's JavaScript too, and shadows exactly three files.
A bug both ports shipped
One of the Rust port's three overrides is base_autocomplete_handler.js, and its OVERRIDES.md says why: the original calls
fetch(url, { as: "json" })
as: is an option for @rails/request.js, not for plain fetch. The server returns HTML and the suggestions for new pings never appear.
The port's parity harness compares the Rails app against the Rust app across 5,018 cells, and it passed them all with this bug present. That isn't a flaw in the harness. A differential oracle can only find differences, and both sides were running the same JavaScript. Whoever found this one found it by reading.
That's the part of the app where the oracle is structurally blind. So I wanted to see what a static tool could see there.
Strings all the way down
Campfire's 35 Stimulus controllers never import one another. Everything that connects them is a string: a data-controller attribute, a data-action like rooms-list:unread@window->badge-dot#update, an outlet found by CSS selector. Here are all eleven links between controllers, resolved:
The color is where each link is declared. Eight of the eleven live in Ruby helper methods: sidebar_helper.rb, rooms_helper.rb, messages_helper.rb. Three of those are assembled from string variables:
# app/helpers/rooms_helper.rb
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
attachment_actions =
"lexxy:file-accept->composer#preventAttachment " +
"refresh-room:online@window->composer#online" # abridged
...
[ drop_target_actions, drag_and_drop_actions,
attachment_actions, remaining_actions ].join(" ")
No JavaScript tool can see those, because they're Ruby. No ERB tool can see them either, because they aren't in a template. To see them you have to evaluate the helper, which is the kind of thing a whole-program analyzer for Rails already does.
What travels over the wire
The same exercise on Action Cable shows the paths between Ruby and the browser:
The highlighted path is my favorite. Opening a room clears its unread badge in every tab and on every device you have open, and it does that by going through the server. The presence controller sends present, PresenceChannel#present broadcasts on user_<id>_reads, the read-rooms controller turns that into a DOM event, and rooms-list handles it. You can't see that loop from any single file. Two of the seven channels don't carry data to the browser at all: PresenceChannel is used for its actions alone, and HeartbeatChannel is an empty class whose only job is to report the socket going up or down.
Copies
The other thing that shows up when you look at both sides at once is code written twice, once in each language:
messages/_template.html.erbis a hand copy ofmessages/_message.html.erb, with$placeholders$thatclient_message.jsfills in for the optimistic display of a message you just sent. Both are 34 lines. The originals carry a comment: "Be sure to check/update messages/_template.html.erb when changing this file."client_message.jshas its own emoji regex. Ruby hasString#all_emoji?inlib/rails_ext/string.rb.client_message.jshas a list of 56 sound names.Soundhas the same list in Ruby.
This isn't peculiar to Campfire. Lobsters' application.js has a comment that says it outright: // parallel implementation in lib/time_ago_in_words.rb. Two copies in two languages is where drift lives, and no single-language tool can tell you they've drifted.
How I made these
It took an afternoon. Oxc parsed all 70 JavaScript files with no errors and gave me each controller's static targets, values and outlets, every this.dispatch, every channel subscription and every request. Herb parsed the 80 templates with its Action View helper expansion turned on, so tag.div data: { controller: "popup" } and link_to … data: { action: … } came back as real elements with real attributes, nested where they render. The Ruby side (channels, broadcasts, the helper strings) I read with regular expressions and checked by hand. That's the part Roundhouse would do properly.
Herb's expansion found one thing I'd missed. In messages/boosts/_boosts.html.erb:
<%= link_to new_message_boost_path(message),
class: "boost__action txt-small btn", action: "soft-keyboard#open" do %>
renders <a action="soft-keyboard#open">. Stimulus reads data-action, not action, so the inline "add a boost" link never runs soft-keyboard#open. The other boost link in _actions.html.erb gets it right. It's a one-line fix.
Every event a controller dispatches has a listener, and every outlet resolves. Campfire's wiring is clean, which is what you'd expect from people who care about it. The point is that today nothing checks it.
What it could become
Both parsers are written in Rust, and so is Roundhouse. That means one process could hold all three views of an app: Ruby with types, ERB as HTML, and JavaScript. Then:
roundhouse checkcould report the cross-language mistakes: an action naming a method that doesn't exist, a target or value a controller never declares, a value whose Ruby type doesn't match the type the controller declares, an event nobody listens for, a hard-coded URL that isn't a route, afetchoptionfetchdoesn't take, an<a action=…>.- The language server could go from
refresh-room:onlinein a Ruby helper to thedispatchcall in JavaScript, and back. - The MCP server's request trace, which today stops at the rendered page, could continue through the broadcast and the stream to the JavaScript that receives it. An agent asking "what happens when I press send?" would get the whole loop.
- The diagrams above would be generated, not drawn.
There's a timing reason to think about this now. Marco Roth's closing keynote at Deccan Queen on Rails is titled "Herb in Rails 8.2: Your ERB Views, Now HTML-Aware." If Herb's model of a template becomes the one Rails itself uses, a tool built on it is building on Rails, not beside it.
What it would cost
- Two copies of Prism. Herb's Rust crate builds its own Prism, and Roundhouse already links one. Whether they can share is the first thing a prototype has to answer.
- WebAssembly. Roundhouse also runs in the browser. Herb's build has a wasm path and Oxc already runs as WebAssembly, but I haven't tried them together.
- Churn and size. Oxc's crates are 0.x and change often. These would belong behind a feature flag so the compiler's build doesn't carry them.
- Duplicated effort. Herb already lints templates, and Marco's Stimulus tooling already checks attributes against controllers. Anything here should add only what needs all three languages at once, and maybe it belongs in Herb's language server rather than in Roundhouse's.
- The honest one. Some of this might be better fixed in Campfire than checked in a tool. Rendering the optimistic message from the real partial instead of a copy removes the drift instead of detecting it. Strict locals on partials would give every partial a signature. A tool that finds duplication is worth less than a change that removes it, and that change is Campfire's to decide.
Should we build it?
I'm not sure, and that's why I'm writing this instead of code. The questions I'd like answered:
For Marco: can Herb's Rust crate share a Prism with a host that already links one? Would Herb accept helper output evaluated by something else, so its own Stimulus checks can see the eight links that live in Ruby? And where should cross-language checks live?
For Campfire, two suggestions. The first is to adopt Herb: its linter now, and its HTML-aware engine when Rails 8.2 arrives. Herb already finds things in Campfire's views, such as two closing </span> tags with no opening tag in the application layout, and an HTML-aware template is one that every tool, this one included, can read. The second is to backport as much of once-campfire-rust as is practical. Its README lists where it now differs from the Rails app, and the behavior changes on that list, such as its bug fixes and the forgery protection Rails main already uses, would improve the Rails app and keep it a complete definition of what the port does. I made the case for that in As If. Along the way, two things a tool could only detect, the _template.html.erb copy and partials without strict locals, Campfire could simply remove.
For everyone else: does your app's JavaScript have a // parallel implementation in comment somewhere? If a tool could tell you when the two halves disagree, would you run it?
Marco and I will both be in Pune next week. If you'll be there too, find me.
Roundhouse is open source: dual-licensed MIT / Apache-2.0. The diagrams were extracted from once-campfire at 90b3300.