broadcast_refresh_to is a synchronous write
Three files in this app call the same line. Admin::Catalog::SaveCourseService, Admin::Catalog::ReorderCourseService and Admin::Catalog::ReorderLessonService each end with Turbo::StreamsChannel.broadcast_refresh_to("catalog_roadmap"), and app/views/catalog/courses/index.html.erb opens with <%= turbo_stream_from "catalog_roadmap" %>. Every visitor on the public roadmap page gets a live refresh the moment an admin edits a course. There's no page reload, no polling from the browser, and no Redis anywhere in this app's Gemfile.
The mid-level course "Solid Queue: job architecture", right before this one, traced a job through eleven tables of database-backed queueing. This course traces that broadcast call through the third "Solid" library: database-backed pub/sub.
The call, one layer at a time
broadcast_refresh_to is turbo-rails' own method, defined in Turbo::Streams::Broadcasts:
def broadcast_refresh_to(*streamables, **attributes)
broadcast_stream_to(*streamables, content: turbo_stream_refresh_tag(**attributes))
end
def broadcast_stream_to(*streamables, content:)
streamables.flatten!
streamables.compact_blank!
if streamables.present?
ActionCable.server.broadcast stream_name_from(streamables), content
end
end
stream_name_from maps each streamable and joins them with ":". A plain String has no to_gid_param, so it falls through to .to_param, which for a String returns itself. The channel name that reaches ActionCable.server.broadcast is the literal string "catalog_roadmap", with no signing, hashing or wrapping.
Signing happens at the other end, when turbo_stream_from generates the value the browser subscribes with (Turbo.signed_stream_verifier.generate). The server-side broadcast and storage layer deal with the raw channel name. The mid-level course "Turbo Streams: security", later in this series, covers that signing scheme in full. This course only needs to know that the channel is a plain string by the time it reaches storage.
content is the output of turbo_stream_refresh_tag, and it's tiny. Here's a row read straight out of this app's development cable database:
SolidCable::Message.where(channel: "catalog_roadmap").first.payload
# => "\"\\u003cturbo-stream action=\\\"refresh\\\"\\u003e\\u003c/turbo-stream\\u003e\""
That stored payload is 70 bytes of JSON. Decoded, it's <turbo-stream action="refresh"></turbo-stream>, 45 characters. No course data, no HTML fragment and no rendered partial travels over the wire. action="refresh" tells the browser's Turbo runtime to re-fetch the current page, and this app's layouts set turbo-refresh-method to morph, so Turbo morphs the DOM in place. The broadcast only says "something changed, go look". The looking is an ordinary HTTP GET the browser makes on its own.
No queue, no channel class
ActionCable.server.broadcast is a plain method call, not perform_later. It runs on whatever thread called broadcast_refresh_to, inside the admin request, before the response is sent.
There's a _later_to family (broadcast_refresh_later_to, broadcast_render_later_to) that does go through Active Job, via Turbo::Streams::BroadcastStreamJob. But a grep of this app's app/ directory for _later_to comes back empty. Every broadcast here is the synchronous kind, so Active Job and Solid Queue don't take part in this path. Keep that boundary sharp. It's easy to assume that background work means queued work, and here it doesn't.
There's also no custom ActionCable::Channel subclass in this app: grep -rn "< ActionCable::Channel" app/ returns nothing, and there's no app/channels directory. turbo_stream_from and broadcast_refresh_to both route through turbo-rails' built-in Turbo::StreamsChannel, which this app never subclasses or configures. Writing a channel of your own, with a subscribed method, custom stream authorization, and actions a client can call on the server, is useful Action Cable ground this app hasn't needed yet. The mid-level course "ActionCable: custom channels", later in this series, builds one.
Where the wire ends
One more piece explains why this works with no routing code at all. config/routes.rb has no mount ActionCable.server, yet a WebSocket connection to /cable is live in this app. Action Cable's own engine mounts itself:
# actioncable-8.1.3.1/lib/action_cable/engine.rb
config.action_cable.mount_path = ActionCable::INTERNAL[:default_mount_path]
# ...
unless config.action_cable.mount_path.nil?
app.routes.prepend do
mount ActionCable.server => config.action_cable.mount_path, internal: true, anchor: true
end
end
default_mount_path is "/cable". The engine prepends the mount at boot, the same mechanism that lets any Rails engine add behaviour without the host app writing a line; the junior course "Rails engines overview" covers it.
What hasn't been named yet is which adapter receives that ActionCable.server.broadcast call, and what it does with a plain string channel and a 70-byte payload. That's config/cable.yml, and it's where the next lesson starts.
Key takeaways
- Three services (
SaveCourseService,ReorderCourseService,ReorderLessonService) callTurbo::StreamsChannel.broadcast_refresh_to("catalog_roadmap"), and the public roadmap view subscribes withturbo_stream_from "catalog_roadmap". - The channel name reaching
ActionCable.server.broadcastis the literal string"catalog_roadmap". Signing belongs to the subscription side, and the broadcast and storage layer never touch it. broadcast_refresh_tois synchronous: it callsActionCable.server.broadcastdirectly, notperform_later. This app never uses the_later_tofamily, so Active Job and Solid Queue play no part in this path.- This app has no custom
ActionCable::Channelsubclasses. Every broadcast and subscription goes through turbo-rails' built-inTurbo::StreamsChannel. - Action Cable mounts itself at
/cablefrom inside its own engine at boot; nothing inconfig/routes.rbhas to declare it.
Keep going with lesson 2
One table, a hashed channel, and a raw insert
Solid Cable stores messages in one table, hashes channels into signed 64-bit integers, and writes with a bare insert.
A free account also gives you the quizzes, your progress and notes, and the discussion.