How does Turbo work with Action Cable?

Ben seems to have unplugged something important

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.

What is a WebSocket connection?

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.

WebSockets on the server

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 simplified

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.

How come 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.

How does Turbo connect to ActionCable?

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:

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.

Conclusion

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.

Want to be notified when I publish a new article?