Super Solid Cable

Solid Cable

Solid Cable is a new adapter for Action Cable - now the default in Rails 8 - that brings the magic of web sockets and real time updates to your Rails app.

I’d never had chance to really use Action Cable before, but a colleague gave me a great suggestion to improve an internal tool I’ve been building, and that coincided with me having just reviewed some upcoming videos for the Rails Foundation - including solid cable - and a 90 minute train journey from Bristol to London!

How far could I get introducing real time updates to my app on a train to London I wondered? What could go wrong? Well, Solid Cable is so ridiculously simple that I’d finished before I even got to Paddington! Now en-route home on the same journey, I thought I would write up a blog post, as always, for future me. So, here we go.

Simple Demo App

Just to keep things really simple, lets create a new Rails 8 app that lets people chat. Here’s the simplest thing I could come up with but will be perfect to illustrate how to use Solid Cable.

A Simple Chat App

First up you’ll need Ruby and Rails 8 on your machine. If you don’t yet, follow the steps in section 3.1 of the fantastic, newly polished and updated Rails Guides here

OK, lets go ahead and create our new Rails app:

rails new super-simple-chat -c tailwind

Next, we’re going to create a model, a controller and some views to play with. Initially we’ll use standard rails and the app will be feature complete. First we’ll cd into the app directory and generate the model, controller and index view by running these commands on the console:

# change into the app directory
cd super-simple-chat

# generate the Chat model and controller
rails g model chat message:string
rails g controller chats index create

# migrate the database
rails db:migrate

We need to add a bit of code here, and I won’t explain this in detail since it’s very basic Rails. I’ll save the more detailed descriptions for when we add the real time Solid Cable magic coming up. So, open up each of these files and replace the existing code in each with the code below:

config/routes.rb

Rails.application.routes.draw do
  resources :chats
  root "chats#index"
end

app/controllers/chats_controller.rb

class ChatsController < ApplicationController
  def index
    @chats = Chat.all
  end

  def create
    @chat = Chat.build(chat_params)
    @chat.save

    redirect_to chats_path
  end

  private

  def chat_params
    params.require(:chat).permit(:message)
  end
end

app/views/chats/index.html.erb

<div class="w-1/2">
  <h1 class="mb-6 text-slate-700 font-bold text-2xl">Chats</h1>
  <div class="space-y-4 mb-8">
    <div id="chats">
      <%= render @chats %>
    </div>
  </div>
  <%= render "form" %>
</div>

And then we need to create two partials, one for the input form, and one for the individual chat messages:

app/views/chats/_form.html.erb

<%= form_with model: Chat.new, local: false do |f| %>
  <div class="flex">
    <%= f.text_field :message,
        autocomplete: :off,
        class: "block w-2/3 rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400",
        placeholder: "enter message..." %>
    <%= f.submit "Send",
        class: "inline-flex w-1/3 items-center justify-center rounded-md bg-slate-400 ml-3 px-3 py-2 text-sm font-semibold text-white shadow-sm" %>
  </div>
<% end %>

app/views/chats/_chat.html.erb

<div>
  <div class="flex justify-between items-start">
    <div class="text-gray-700">
      <%= chat.message %>
    </div>
    <div class="text-sm text-gray-400">
      <%= time_ago_in_words(chat.created_at) %> ago
    </div>
  </div>
</div>

The Basic App

OK! If you followed the instructions above, you should be able to run the app with bin/dev in the console and visit http://localhost:3000. It should look like the screenshot above, and you can type in chats and see them rendered above the input box. Try it out!

This actually works great, and shows the power of Rails out of the box. Whats happening is the text input is posted to the server and the page is reloaded rendering the list of messages.

If someone else sends a message from another server though, we won’t see it unless we reload the page. We want to see these messages in real time, that’s how we roll, so lets bring the magic with Solid Cable!

Introducing Solid Cable

We’ve built the basics, and this is essentially where I was with my app. To introduce Solid Cable we need to have the gem in our Gemfile and some minor configuration set up.

If you’re using Rails 8.0.0, you’re in luck. You don’t need to do anything! Solid Cable is available by default. If you don’t have Solid Cable for some reason, like an older version of Rails, you’ll need to add the gem and install as follows:

bundle add solid_cable
bin/rails solid_cable:install

Configuring Solid Cable

The main configuration file for Action Cable is config/cable.yml and by default in “development” the adapter is set to “async”. Since we want to enable Solid Cable on localhost, we need to update this file, so open it up and change:

development:
  adapter: async

to:

development:
  adapter: solid_cable
  connects_to:
    database:
      writing: cable
  polling_interval: 0.1.seconds
  message_retention: 1.day

Then lastly we need to set up a cable database for Solid Cable to store our messages. Unlike the typical way Action Cable is used where events are published to Redis then immediately broadcast back to Action Cable with a pub/sub strategy, with Solid Cable, messages are written to a database and Solid Cable polls for new ones, broadcasting them back to clients when found. Solid Cable also cleans up after itself, trimming data older than message_retention above.

So we need to set up a new database to accept the events from Solid Cable. SQLite is perfect for this task so similar to the cable config above, we need to set up the “cable” database in our development environment. Open up the config/database.yml file and change the following:

development:
  <<: *default
  database: storage/development.sqlite3

to

development:
  primary:
    <<: *default
    database: storage/development.sqlite3
  cable:
    <<: *default
    database: storage/development_cable.sqlite3
    migrations_paths: db/cable_migrate

Now lets initialise the database with rails db:prepare

Unleash the Web Sockets

OK! Back to our app now. We’re going to add some real time web socket goodness with just a few lines of code!

Firstly we need to web socket enable our view so that newly broadcasted Chats can be rendered. Open up the app/views/chats/index.html.erb file and insert the turbo_stream_from tag on line 4 as shown here

<div class="w-1/2">
  <h1 class="mb-6 text-slate-700 font-bold text-2xl">Chats</h1>
  <div class="space-y-4 mb-8">
    <%= turbo_stream_from "chats" %>
    <div id="chats">
      <%= render @chats %>
    </div>
  </div>
  <%= render "form" %>
</div>

And then we just need to update our Chat model to broadcast when a new Chat is created. Open up app/models/chat.rb and replace the code with the following:

class Chat < ApplicationRecord
  after_create_commit :broadcast_chat

  def broadcast_chat
    broadcast_append_to(
      "chats",
      target: "chats",
      partial: "chats/chat",
      locals: { chat: self }
    )
  end
end

One last tidy up is we don’t need to redirect to the index page in the controller after a Chat is created. We’ll be updating the page via websockets, so we can just return nothing. In the app/controllers/chats_controller.rb file “create” action, simple replace the line

redirect_to chats_path

with

render json: {}, status: :no_content

Now you may not believe this. But thats it. You’re done! Restart your server, then open up two browsers and behold the magic! When you send a chat in one, you should see it in the other in real time, no refresh required!

Under the Hood

If you take a look in your Rails log you should be able to see not only the Chat record being inserted when a chat message is sent, but also the event being inserted in the cable database. It will looks something like this:

SolidCable::Message Insert (0.5ms)  INSERT INTO "solid_cable_messages" ("created_at","channel",
"payload","channel_hash") VALUES ('2024-11-26 06:23:40.882477', x'6368617473', x'225c753030336
3747572626f2d73747265616d20616374696f6e3d5c22617070656e645c22207461726765743d5c2263686174735c2
25c75303033655c753030336374656d706c6174655c75303033655c7530303363212d2d20424547494e206170702f7
6696577732f63686174732f5f636861742e68746d6c2e657262202d2d5c75303033655c75303033636469765c75303
033655c6e20205c753030336364697620636c6173733d5c22666c6578206a7573746966792d6265747765656e20697
f6469765c75303033655c6e202020205c753030336364697620636c6173733d5c22746578742d736d20746578742d6
77261792d3430305c225c75303033655c6e2020202020206c657373207468616e2061206d696e7574652061676f5c6
e202020205c75303033632f6469765c75303033655c6e20205c75303033632f6469765c75303033655c6e5c7530303
3632f6469765c75303033655c6e5c7530303363212d2d20454e44206170702f76696577732f63686174732f5f63686
1742e68746d6c2e657262202d2d5c75303033655c75303033632f74656d706c6174655c75303033655c75303033632
f747572626f2d73747265616d5c753030336522', -2034957492849835134) ON CONFLICT  DO NOTHING RETURNING
"id" /*action='create',application='SuperSolidCable',controller='chats'*/

and then also you can see our partial being broadcast out to clients with the “append” action

Turbo::StreamsChannel transmitting "<turbo-stream action=\"append\" target=\"chats\"><template>
<!-- BEGIN app/views/chats/_chat.html.erb --><div>\n  <div class=\"flex justify-between items-
start\">\n    <div class=\"text-gray-700\">\n      solid cable\n    </div>\n    <div class=\"te
xt-sm text-gray-400\">\n      less than a minute... (via streamed from chats)

Pretty damn cool!

Wrapping Up

This is quite a long blog post to demonstrate how you can add web sockets and real time updates in your Rails apps with a few lines of code, I know, but hopefully its been fun.

As always, oh wait, not as always as I moved to Blue Sky mostly, but yes, don’t hesitate to drop me a note there at https://bsky.app/profile/mileswoodroffe.com or via any channels listed here