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 include
d 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 MessageEvent
s. 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::StreamsChannel
where 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!