ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

TurboStream experiment on Ruby on Rails

By Domingo Cividanes, backend engineer at UniFa.

With the version release of Ruby on Rails new interesting features comes out. But one of this new features really got my attention: Turbo. Turbo allows to send HTML and not JSON over the wire using minimal JavaScript. I was curious how it worked, so I decided to perform one little experiment with one of our applications, so the experiment is more adjusted to a real-case application, and not a single test case.

Let’s put the experiment in context and define its specifications.

The main purpose is to update one of the screens that manages the direct communication between teachers and parents, making new messages on the system to be streamed to this screen. In order to achieve this, the backend consists of 1 API that will process the new messages and stores the data in an external service. Regarding the current specifications on the frontend, each day is displayed on a different row and each message within the same day is positioned on top of the previous one.

Turbo is added by default in Rails7 projects, but the current project is using a previous version, so need to add it to the project first. You can see more in-depth information and the installation guide on the gem GitHub page https://github.com/hotwired/turbo-rails

Turbo uses Action Cable to deliver updates to subscribers. So the first step is to create an ActionCable channel. This is easy, because Turbo also provides a new helper we can use to do it, turbo_stream_from. All users subscribed to this channel, can receive all messages that are broadcasted into it, so a channel name that includes part of the user unique identifiers is recommended when dealing with sensible information. In this test case, only direct messages related to a specific kid should be shown. Plus, the app itself only should show content related to the logged user/organization.

Let’s add the next code on the view file that is going to be updated:

= turbo_stream_from "organization:#{current_organization.id}:kid:#{@presenter.kid.id}:direct_messages"

Now that the channel is defined, the next step is to define the targets where the new HTML, that will be sent by Turbo, will use as reference to place itself in the correct position. In order to do that, just adding an id in the current HTML it’s enough. In this case, the references are:

  • The div that wrap the messages. A new message will be prepended before the rest, but still inside the messages container.
  • The row that contains messages within the same day. A new row will be added before the target row.
# contacts/direct_messages_by_date.haml

- dm_row_stream_target = "organization:#{current_organization.id}:kid:#{kid_id}:direct_messages:date:#{date}"
- dm_container_stream_target = dm_row_stream_target + ":dm_container"

%tr{ id: dm_row_stream_target }
  %th
    = I18n.l(date.to_date, format: :middle)
  %td
    .container{ id: dm_container_stream_target }
      - direct_messages.each do |direct_message|
        = render "contacts/direct_message", direct_message: direct_message

The next step is to send messages to the channel we just defined. In order to do that, the backend needs to trigger the broadcast when the data is changed. Looking at the documentation the broadcast can be handled on different ways:

  • Model level. Every time a model is created or updated, new changes are sent to the channel. This is not a valid option here, we use an api that connects to an external service that handles the data.
  • Controller level. The controller responds differently depending on the content-type and it will respond to all turbo requests, triggering the broadcast. But, our app receives requests from mobile devices, not related to the web service, so this approach is not valid either.

Neither option is valid in our current infrastructure. Ideally, we want to trigger the direct message broadcast when the Direct Message API is reached and the data is stored successfully. So let’s do that first and dig in how to integrate Turbo with our code later.

# Direct Message API Client

def self.create(params:)
    response, exception = client.create_direct_message(params)

    if response.valid?
      attrs = response.result.attributes
      Broadcast::DirectMessage::BroadcastMessageJob.perform_later(attrs)
    end

  response
end

Now let’s dig into the gem source code in order to check what resources are available to us to manually trigger the direct message broadcast.

Let’s see the code first, and make some explanations after.

class Broadcast::DirectMessage::BroadcastMessageJob < ApplicationJob
  include Turbo::Broadcastable
  extend ActiveModel::Naming
  …
 
  def perform(direct_message_attrs)
    @organization_id = direct_message_attrs[:organization_id]
    @kid_id = direct_message_attrs[:kid_id]
    @direct_message = DirectMessage.new(direct_message_attrs)
    # …

    broadcast_message
  end

  private

  def broadcast_message
    broadcast_action_to(stream_id, **render_options)
  end

  def stream_id
    "organization:#{organization_id}:kid:#{kid_id}:direct_messages"
  end

  def render_options
    first_message_of_day? ? new_row_of_dms_options : prepend_dm_to_existing_row_options
  end

  def prepend_dm_to_existing_row_options
    {
      action: :prepend,
      target: stream_id + ":date:" + @direct_message.sent_at.strftime('%Y%m%d') + ":dm_container",
      partial: "combook/contacts/direct_message",
      locals: {
        direct_message: @direct_message,
      },
    }
  end

  def new_row_of_dms_options
    {
      action: :before,
      target: stream_id + ":date:" + @previous_direct_message.sent_at.strftime('%Y%m%d'),
      partial: "combook/contacts/direct_messages_by_date",
      locals: {
        date: @direct_message.sent_at.strftime('%Y%m%d'),
        kid_id: kid_id,
        direct_messages: Array.wrap(direct_message),
      },
    }
  end

After some code research, we found that on app/models/concerns/turbo/broadcastable.rb is where the broadcast code is defined, so, in order to use it we first need to add include Turbo::Broadcastable in our job class. We also need extend ActiveModel::Naming because internally the broadcast method needs to respond to model_name and it may not be available by default.

The perform method it will just simply call broadcast_action_to with the channel_id we created previously on the view, and the render options that are going to be used to generate the html and place it on the DOM.

  • Action: defines what turbo-stream action will be used. Can be: append, prepend, replace, update, remove, before, and after
  • Target: it refers to the element you want to operate on.
  • Partial/Locals: it refers to what file/variables use to generate the respective html
  • Html: can be used instead of partial, to input html directly.

And that’s it. In some simple steps we updated some static page to a live page.


Unifa is actively recruiting, check our website you're interested

unifa-e.com