intertwingly

It’s just data

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:

Eleven links between Stimulus controllers: nine events and two outlets. Eight are declared in Ruby helper methods, three in ERB templates.

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:

Seven Action Cable paths between Ruby producers, channels and browser controllers. Opening a room sends present to PresenceChannel, which broadcasts on the user's reads stream, which clears the unread badge in every open tab.

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:

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:

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

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.