Modern Rails makes it easy to build real-time features into your app. With the turbo-rails
library, you can add a call to turbo_stream_from
in a view file, and that will set up a WebSocket connection between your app server and all browsers that render that view. Your application can then use the broadcast_*
family of methods to send <turbo-stream>
elements to all those browsers. Turbo, on receiving the <turbo-stream>
elements, will add them to the DOM, which will trigger DOM updates. Users thus see updates from the server in real-time, without having had to make separate requests. The amazing thing is that the broadcast_*
methods can be called from any process (e.g. asynchronous jobs, the Rails console etc), and not just the Rails server process.
How does this magic work? What is a WebSocket connection and how is it different from HTTP? How do Turbo and ActionCable create a WebSocket connection? How do the broadcast_*
methods work across processes? Are these connections secure?
While it is nice that Rails abstracts away the details of WebSockets and reduces its complexity to essentially two method calls (!), it is beneficial to peer behind the curtain and develop a mental model of how this works behind the scenes. One, and perhaps most importantly, we get to satisfy our curiosity. Two, adding a new mental model to our tool chest makes us more competent in the long run. Finally, we will be able to sound impressive the next time our neighbor acts like their dog is better than ours.
To develop this mental model, we’re going to imagine and build a very simple version of ActionCable. This ought to give us a rough understanding of how ActionCable works under the hood, and also serve as a starting point for further investigation.
An HTTP connection is primarily client driven. The client (usually a browser) makes a request to the server (e.g. GET
or POST
) and the server responds. That is the end of the story (aka the request). The server can’t communicate to the client of its own accord.
A WebSocket connection on the other hand, is a bidirectional communication channel. Once a connection is established, the server can send messages to the client without the client having to make a separate request. Bidirectionality is what allows us to build real-time features into our apps.
Initiating a WebSocket connection on the browser is not hard. You do something like:
const socket = new WebSocket("ws://localhost:3000/toy_cable");
Once the connection is established, you can listen for messages sent to the socket with something like:
socket.addEventListener("message", (event) => {
/* Do something with event.data, like adding it to the DOM */
document.body.insertAdjacentHTML("beforeend", event.data);
});
The server side of the story is more interesting.
When the browser runs new WebSocket(some_url)
, it sends a GET
request to some_url
along with WebSocket specific headers. These headers tell the server to “upgrade” the HTTP connection being made to a WebSocket connection. At a high level, the server does so by initiating a handshake with the client that concludes with the server sending back a 101 Switching Protocols
response to the client. The server then periodically sends “ping” messages to the client to keep the connection alive. Importantly for us, the server stores the connection object in memory so other parts of the app can send messages through it.
ActionCable
is a Rack
application that is mounted in your Rails app at /cable
. It “hijacks” (in other words, takes over responsibility for the HTTP connection from Rack) GET
requests received at /cable
and then uses the websocket-driver-ruby
gem to complete the WebSocket handshake and upgrade the request to a WebSocket connection. After this is done, ActionCable is left with a connection object representing an open connection that it can call text
on. Messages sent across this connection use the WebSocket protocol, not HTTP. When the application wants to broadcast some_message
to a group of connections, ActionCable just calls text(some_message)
on every connection in the group.
If we were to build a super naïve version of ActionCable called ToyCable
, it would look something like this:
# routes.rb
mount ToyCable => '/toy_cable'
# lib/toy_cable.rb
require "websocket/driver"
class ToyCable
@@connections = []
def self.call(env)
# Hijack the connection and get the connection IO object
socket_io = env["rack.hijack"].call
# Initiate the protocol upgrade and WebSocket handshake process
toy_socket = ToySocket.new(socket_io, env)
connection = WebSocket::Driver.rack(toy_socket)
connection.start
# Store the open connection so we can access it later
@@connections << connection
[-1, {}, []] # Tell Puma to hold off on responding
end
def self.broadcast(message)
@@connections.each do |connection|
connection.text(message)
end
end
end
# lib/toy_socket.rb
class ToySocket
delegate :write, to: :@socket_io
attr_reader :env
def initialize(socket_io, env)
@socket_io = socket_io
@env = env
end
end
Once we have this in place, we can then send a Turbo Stream to all connections by doing:
ToyCable.broadcast('
<turbo-stream action="after" target="messages">
<template>
Check your messages!
</template>
</turbo-stream>
')
Our toy example lacks a couple of important capabilities. One, ToyCable.broadcast
will only work if it is called from the main Rails process. If called from a separate process, a new class instance of ToyCable
will be created and with it a completely new set of @@connections
. Two, there is no way to group connections into “channels” - broadcast
just sends all messages to all connections.
broadcast_*
can be called from any process?The problem is that ActionCable
(or ToyCable
in our case) runs in the main Rails process. ActionCable
has all the WebSocket connections, and it alone can send messages to subscribed clients. If we have a separate process running in a job, how can that process access those connections?
The solution is conceptually simple. Introduce a third-party or intermediary process that both the main Rails process and really any other process running on the machine can talk to, and have that process shuttle messages to ActionCable
. As web developers, we’re very familiar with one such process in our apps - the database! You can talk to it from jobs, the console and even in a command line outside Rails.
If we were to add this capability to ToyCable
, we could have a table called toy_cable_broadcasts
with columns channel
and message
, and write to it whenever we want to send a message to WebSocket clients. ToyCable
could periodically poll this table and broadcast new messages as and when it sees them arrive. That could look something like this:
# app/concerns/broadcastable.rb
module Broadcastable
def broadcast(channel, message)
ToyCableBroadcast.create(channel:, message:) # assume we have a model called ToyCableBroadcast
end
end
# app/jobs/some_job.rb
class SomeJob
include Broadcastable
def perform
do_some_work
broadcast("jobs", '<turbo-stream action="update" target="jobs"> .... </turbo-stream>')
end
end
# lib/toy_cable.rb
require "websocket/driver"
class ToyCable
@@connections = Hash.new { |h,k| h[k] = [] } # Use Hash to group connections by channel
Thread.new do # Start a thread to continually check for new messages when the class is loaded
loop do
sleep(0.1)
ToyCableBroadcast.all.each do |broadcast|
@@connections[broadcast.channel].each do |connection|
connection.text(broadcast.message)
end
broadcast.destroy
end
end
end
def self.call(env)
# Hijack the connection and get the connection IO object
...
# Initiate the protocol upgrade and WebSocket handshake process
...
# Extract channel name from url
# E.g. ws://localhost:3000/toy_cable?channel=room_123
query_string = env['QUERY_STRING']
params = Rack::Utils.parse_query(query_string)
channel = params['channel']
# Store the open connection so we can access it later
@@connections[channel] << connection
[-1, {}, []] # Tell Puma to hold off on responding
end
end
We could connect to ToyCable in views like so:
const wsConnection = new WebSocket(
"ws://localhost:3000/toy_cable?channel=jobs"
);
wsConnection.addEventListener("message", (event) => {
document.body.insertAdjacentHTML("beforeend", event.data);
});
In real life, ActionCable can use one of a few different “adapters” to handle asynchronous messaging. Solid Cable
is a database-backed polling adapter similar to what we built above, but much more suited for production use. ActionCable also supports Redis, which does not require polling. I was also pleasantly surprised to learn that Postgres has a built in “pub/sub” feature that does not require polling. Setting adapter
to postgresql
in config/cable.yml
will tell ActionCable to use it.
ActionCable also has a more complex and robust way to deal with channels that involves the client sending messages across the WebSocket connection, which we won’t get into here.
The turbo_stream_from
helper, when called in a view, renders the <turbo-cable-stream-source>
custom HTML element. There are many ways to invoke turbo_stream_from
but the simplest way is to supply it with a string: turbo_stream_from "jobs"
. That will establish a WebSocket connection (if one doesn’t already exist) with the server and tell it to send any messages received on that channel or stream. You can even have multiple calls to turbo_stream_from
in the same view file and Turbo will use the same WebSocket connection to “subscribe” to the various streams.
Here’s an example of what a call to turbo_stream_from "jobs"
would look like rendered on the page:
<turbo-cable-stream-source
channel="Turbo::StreamsChannel"
signed-stream-name="ImpvYnMi--7eb72b1f2775f74e0a93f873c16d6d021280092d33c890a2025438d8727ad361"
>
</turbo-cable-stream-source>
signed-stream-name
is made up of two parts:
ImpvYnMi
- Base64 encoding of the string "jobs"
. You can check this for yourself by doing Base64.decode64("ImpvYnMi")
in your rails console.7eb...d361
- HMAC signature to prevent tampering. Only processes with knowledge of a secret key can generate this signature for a given string, and it is very hard to predict what the signature for a given string is if you don’t know the secret key.The Turbo::StreamsChannel
class (which is in charge of managing the server-side WebSocket connection) does not implement authentication or authorization and instead relies on signature verification to ensure that channel subscription requests are legitimate. This could make it possible for unauthorized users to receive broadcasts from streams they were previously authorized to subscribe to. If you think your streams need to guarded against this, check out this article on securing ActionCable.
Once you have the requisite turbo_stream_from
calls in your views, you can then use Turbo::StreamsChannel.broadcast_to
to send messages. broadcast_to
works much like our toy example, in that it takes in a string for the channel name, and another string for the Turbo Stream HTML. For example, you could do something like this:
Turbo::StreamsChannel.broadcast_to("jobs",
'<turbo-stream action="append" target="messages">
<template><div>Job XYZ Complete</div></template>
</turbo-stream>'
)
Turbo also comes with the Turbo::Broadcastable
module which gives you a large set of convenience methods that make it easy to wire up changes in the state of your app to broadcasts that reach users.
The great thing about ActionCable (and Rails in general) is that you can use it without knowing how the internals work. However, the longer you use it, the higher the likelihood that you will run into a situation which requires you to level up your knowledge. While ToyCable is an extremely rough approximation of ActionCable, I hope studying it and running it on your machine gives you a decent idea of how ActionCable works under the hood. I’ve certainly learned a thing or two writing this article.
If you got value out of this article and want to hear more from me, subscribe below to get notified when I publish a new article.