Turbo Streams meets Action Cable

posted by Ayush Newatia
16 November, 2022



This post was extracted and adapted from The Rails and Hotwire Codex.

Turbo Streams are a great match-up for Action Cable as it makes it easy to broadcast DOM updates to the browser over WebSockets. The turbo-rails gem integrates with Action Cable to facilitate such updates.

In this blog post, we’ll look at how this integration works. Starting with code examples to establish a connection and broadcast Turbo Streams, we’ll then dig into the individual client and server components of the integration.

An overview

turbo-rails contains a JavaScript custom element which encapsulates the logic to create an Action Cable connection, subscribe to a channel and execute received Turbo Streams. On the server-side, it provides a default channel and a plethora of helper methods to broadcast data based on models.

On the client, an Action Cable connection and subscription is established using:

<%= turbo_stream_from @conversation %>

This will render a <turbo-cable-stream-source> element which subscribes to a stream for the @conversation object. Turbo Streams can then be broadcast to it using:

Turbo::StreamsChannel.broadcast_action_to(
  @conversation,
  action: :append,
  target: dom_id(@conversation, :messages),
  partial: "messages/message"
)

There are helper methods to broadcast the stock Turbo Stream actions. The above snippet can be rewritten as:

Turbo::StreamsChannel.broadcast_append_to(
  @conversation,
  target: dom_id(@conversation, :messages),
  partial: "messages/message"
)

You can also broadcast a Turbo Stream template containing multiple actions:

Turbo::StreamsChannel.broadcast_render_to(
  @conversation,
  template: "messages/create"
)

All the above examples render templates and broadcast them synchronously. This isn’t usually desirable. It should be offloaded to a background job so the request-response cycle isn’t held up.

# enqueues a `Turbo::Streams::ActionBroadcastJob`
Turbo::StreamsChannel.broadcast_append_later_to(
  @conversation,
  target: dom_id(@conversation, :messages),
  partial: "messages/message"
)

# enqueues a `Turbo::Streams::BroadcastJob`
Turbo::StreamsChannel.broadcast_render_later_to(
  @conversation,
  template: "messages/create"
)

Check out the docs for all available helpers: https://rubydoc.info/github/hotwired/turbo-rails/Turbo/Streams/Broadcasts.

In addition to this, there’s also a Broadcastable concern which is included in Active Record. It applies Rails conventions to succinctly broadcast model-specific Turbo Streams. Some example use cases are:

# Broadcasts an `append` action containing the partial
# `conversations/conversation` targeted at the
# ID `conversations`.
Conversation.first.broadcast_append

# The update action targets the specific model's HTML element.
# In this case, it will target `conversation_1`. The content
# will be the partial `conversations/conversation`.
Conversation.first.broadcast_update

# The partial can be explicitly defined if required.
Conversation.first.broadcast_append(
  partial: "messages/message"
)

I recommend taking a peek at the docs to familiarize yourself with all the model-based broadcast methods: https://rubydoc.info/github/hotwired/turbo-rails/Turbo/Broadcastable.

The broadcast helpers in Turbo::StreamsChannel, which we looked at earlier, are all available in Broadcastable as well.

Under the hood, all the broadcast helpers use ActionCable.server.broadcast(...). Hence, they just provide some syntactic sugar on top of Action Cable.

Next, let’s take a closer look at the custom element which receives and processes Turbo Streams.

Turbo Cable Stream Source element

As mentioned earlier, this element is rendered using:

<%= turbo_stream_from @conversation %>

This will render as:

<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="...">
</turbo-cable-stream-source>

This element is underpinned by the class TurboCableStreamSourceElement.

The subscription is established using the two HTML attributes. We can specify a custom channel if the default one isn’t suitable:

<%= turbo_stream_from @conversation,
      channel: "ConversationsChannel" %>

The signed stream name is generated from the @conversation object. You can use multiple arguments for this as well.

<%= turbo_stream_from @conversation, :messages,
      channel: "ConversationsChannel" %>

Let’s dig into how the stream names are generated.

A closer look at stream names

Any type of object can used to generate the stream name as long as it responds to to_gid_param or to_param. Hence, it’s usually an Active Record object, string or symbol.

to_gid_param generates the Global ID for a model and then Base64 encodes it. For strings, to_param returns the object itself. For symbols, it returns the object’s string representation.

The stream name for a Conversation object would look something like:

Z2lkOi8vcGlhenphL0NvbnZlcnNhdGlvbi8x

Base64 decoding the above value will reveal the object’s Global ID:

>> stream = "Z2lkOi8vcGlhenphL0NvbnZlcnNhdGlvbi8x"
>> Base64.urlsafe_decode64(stream)

# => "gid://piazza/Conversation/1"

The stream name is run through a MessageVerifier to obtain the signed stream name which is rendered in the HTML attribute. This ensures the stream name can’t be tampered with on the client.

When using multiple arguments, each one will have to_gid_param or to_param called on it individually. The return values are then joined with a :. The stream name for:

<%= turbo_stream_from @conversation, :messages,
      channel: "ConversationsChannel" %>

will look like:

Z2lkOi8vcGlhenphL0NvbnZlcnNhdGlvbi8x:messages

Using the signed stream name and channel name, the custom element establishes an Action Cable connection and subscribes to the desired stream, ready to receive Turbo Streams over a WebSocket.

Processing Turbo Streams

The easiest way to execute Turbo Streams is by adding them to the DOM. Another way to is by using the connectStreamSource method.

import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"

// ...

connectStreamSource(source)

// ...

disconnectStreamSource(source)

The source can be any object capable of dispatching MessageEvents. Turbo will listen for these events and execute the Turbo Streams they contain.

TurboCableStreamSourceElement connects itself as a stream source when it’s connected to the DOM. When it receives a Turbo Stream over the WebSocket connection, it dispatches a MessageEvent containing it, which Turbo executes.

The default channel

turbo-rails has a default channel called Turbo::StreamsChannel to handle broadcasts and subscriptions. We briefly encountered it while discussing the broadcast helpers. It verifies the signed stream name and subscribes the client to the appropriate stream.

class Turbo::StreamsChannel < ActionCable::Channel::Base
  extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
  include Turbo::Streams::StreamName::ClassMethods

  def subscribed
    if stream_name = verified_stream_name_from_params
      stream_from stream_name
    else
      reject
    end
  end
end

This is generic code and hence contains no authorization logic. I highly recommend writing your own channel based off Turbo::StreamsChannelwhere you run authorization checks before approving the stream.

Most of the channel’s logic in encapsulated in concerns making it easy to reuse in your own channel.

Conclusion

That’s the Turbo Streams integration with Action Cable in a nutshell. It packs in immense power with relatively little code. The docs for turbo-rails are sparse at best, but I recommend keeping the YARD docs handy for reference: https://rubydoc.info/gems/turbo-rails.

If you liked this post, check out my book, The Rails and Hotwire Codex, to level-up your Rails and Hotwire skills!