Mini-chat with Rails and Server-Sent Events

Share this article

cloud Sharing Concept

Recently, I’ve penned a couple of articles on building a chat application with Rails (Mini-Chat with Rails and Realtime Mini-Chat with Rails and Faye – check out the working demo here). In those articles, I explained how to build a simple mini-chat application and make it truly asynchronous using two alternatives: AJAX polling (less preferred) and Web Sockets.

One of the readers noted in the comments that Web Sockets could be replaced with HTML5 Server-Sent Events, so I decided to research the topic. Thus, this article came to life :).

We will be using the same Rails app from the previous articles so, if you wish to follow along, clone it from the corresponding GitHub repo. The final version of the code is located on the SSE branch.

Topics to be covered in this article are:

  • A general overview of Server-Sent Events
  • Using Rails 4 ActionController::Live for streaming
  • Basic setup of the Puma web server
  • Using PostgreSQL LISTEN/NOTIFY functionality to send notifications
  • Rendering new comments with JSON and HTML (with the help of underscore.js templates)

It is time to start!

General Idea

HTML5 introduced an API to work with Server-Sent Events. The main idea behind SSE is simple: the web page subscribes to an event source on the web server that streams updates. The web page does not have to constantly poll the server to check for updates (as we’ve done with AJAX polling) – they come automatically. Please note that the script on the client side can only listen to the updates, it cannot publish anything (compare this to Web Sockets where the client can both subscribe and publish). Therefore, all publishing functionality is performed by the server.

Probably one of the main drawbacks of SSE is no support in Internet Explorer at all (are you surprised?). There are some polyfills available, though.

Using SSE technology is easier than Web Sockets, and there are use cases for it, such as Twitter updates or updating statistics for a football (basketball, volleyball, etc.) match in real time. You may be interested in this discussion on SO about comparing Web Sockets and SSE.

Returning to our example we have to do the following:

  • Create an event source on the server (a route and a controller action)
  • Add streaming functionality (luckily there is ActionController::Live, which we will discuss shortly)
  • Send a notification each time a comment is created so the event source sends an update to the client (we will use PostgreSQL LISTEN/NOTIFY, but there are other possibilities)
  • Render a new comment each time a notification is received

As you can see, there are not many steps to complete the task, but some of them were pretty tricky. But we are not afraid of difficulties, so let’s get started!

Setting Up the App and the Server

If you are following along clone the source code from this GitHub repo and switch to a new branch called sse:

$ git checkout -b sse

Now, do some clean up by removing Faye, which provided the Web Sockets functionality for us. Remove this line from the Gemfile

Gemfile

[...]
gem 'faye-rails'
[...]

and from application.js:

application.js

[...]
//= require faye
[...]

Remove all the code from the comments.coffee file and delete the Faye configuration from application.rb:

config/application.rb

[...]
require File.expand_path('../csrf_protection', __FILE__)
[...]
config.middleware.delete Rack::Lock
config.middleware.use FayeRails::Middleware, extensions: [CsrfProtection.new], mount: '/faye', :timeout => 25
[...]

Also delete config/csrf_protection.rb file entirely. We won’t need it anymore because clients cannot publish updates with SSE.

Add a new method to the Comment model formatting the comment’s creation date:

models/comment.rb

[...]
def timestamp
  created_at.strftime('%-d %B %Y, %H:%M:%S')
end
[...]

and call it from the partial:

comments/_comment.html.erb

[...]
<h4 class="media-heading"><%= link_to comment.user.name, comment.user.profile_url, target: '_blank' %> says
  <small class="text-muted">[at <%= comment.timestamp %>]</small></h4>
[...]

Lastly, simplify the create action in the CommentsController:

comments_controller.rb

[...]
def create
  if current_user
    @comment = current_user.comments.build(comment_params)
    @comment.save
  end
end
[...]

Great, we are ready to proceed. The next step is setting up a web server that supports multithreading, which is required for SSE. The default WEBrick server buffers the output so it won’t work. For this demo, we will use Puma – a web server built for speed & concurrency by Evan Phoenix and others. Replace this line in your Gemfile

Gemfile

[...]
gem 'thin'
[...]

with gem 'puma' and run

$ bundle install

If you are on Windows, there are a couple of additional steps to be done to install Puma:

  • Download and install the DevKit package if for some reason you don’t have it
  • Download and extract the OpenSSL Developer Package (for example, to c:\openssl)
  • Copy OpenSSL’s dlls (from openssl/bin) to the ruby/bin directory
  • Install Puma by issuing gem install puma -- --with-opt-dir=c:\openssl

Read more here, if needed.

Time to confiugure Puma. Heroku provides a nice guide explaining how to set up Puma, so let’s use it. Create a new puma.rb file in the config directory and add the following content:

config/puma.rb

workers Integer(ENV['PUMA_WORKERS'] || 3)
threads Integer(ENV['MIN_THREADS']  || 1), Integer(ENV['MAX_THREADS'] || 16)

preload_app!

rackup      DefaultRackup
port        ENV['PORT']     || 3000
environment ENV['RACK_ENV'] || 'development'

on_worker_boot do
  # worker specific setup
  ActiveSupport.on_load(:active_record) do
    config = ActiveRecord::Base.configurations[Rails.env] ||
        Rails.application.config.database_configuration[Rails.env]
    config['pool'] = ENV['MAX_THREADS'] || 16
    ActiveRecord::Base.establish_connection(config)
  end
end

All those numbers should be adjusted for a real app, especially if you are expecting many users to access your site simultaneously (otherwise available connections will be depleted too quickly).

Also, replace the contents of the Procfile (located in the root of the app) with:

Procfile

web: bundle exec puma -C config/puma.rb

Now run

$ rails s

to check that the server is booting up with no errors. Cool!

We still have to make a few more changes. First, set config.eager_load and config.cache_classes in config/environments/development.rb to true to test streaming and SSE on your developer machine:

config/environments/development.rb

[...]
config.cache_classes = true
config.eager_load = true
[...]

Keep in mind, with those settings set to true you will have to reload your server each time you modify your code.

Lastly, change your development database to PostgreSQL (we are going to use its LISTEN/NOTIFY functionality and SQLite does not support it). Visit downloads section if you do not have this RDBMS installed on your machine. Installing PostgreSQL is not too bad.

config/database.yml

[...]
development:
  adapter: postgresql
  encoding: unicode
  database: database_name
  pool: 16
  username: username
  password: password
  host: localhost
  port: 5432
[...]

Set the pool‘s value equal to Puma’s MAX_THREADS setting so that each user can get a connection to the database.

At this point, we are done with all the required configuration and ready to continue!

Streaming

The next step is to add streaming functionality for our server. For this task, a route and controller method is needed. Let’s use the /comments route as an event source, so modify your routes file like so:

config/routes.rb

[...]
resources :comments, only: [:new, :create, :index]
[...]

The index action needs to be equipped with streaming functionality and, luckily, Rails 4 introduces ActionController::Live designed specifically for this task. All we need to do is include this module into our controller

comments_controller.rb

class CommentsController < ApplicationController
  include ActionController::Live
[...]

and set response’s type to text/event-stream

comments_controller.rb

[...]
def index
  response.headers['Content-Type'] = 'text/event-stream'
[...]

Now, inside the index method we have streaming functionality. However, currently we have no mechanism to notify this method when a new comment is added, so it doesn’t know when to stream updates. For now, let’s just create a skeleton inside this method and return to it in a bit:

comments_controller.rb

def index
  response.headers['Content-Type'] = 'text/event-stream'
  sse = SSE.new(response.stream)
  begin
    Comment.on_change do |data|
      sse.write(data)
    end
  rescue IOError
    # Client Disconnected
  ensure
    sse.close
  end
  render nothing: true
end

We use the sse local variable to stream updates. on_change is a method to listen for notifications, and we’ll write that in a few minutes. The rescue IOError block is meant to handle the situation when a user has disconnected.

The ensure block is vital here – we have to close the stream to free the thread.

The write method accepts a bunch of arguments, but it can be called with a single string:

sse.write('Test')

Here, Test will be streamed to the clients. A JSON object may also be provided:

sse.write({name: 'Test'})

We can also provide an event name by setting event:

sse.write({name: 'Test'}, event: "event_name")

This name is then used on the client’s side to identify which event was sent (for more complex scenarios). The other two options are retry, which allows to set the reconnection time, and id, used to track the order of events. If the connection dies while sending an SSE to the browser, the server will receive a Last-Event-ID header with value equal to id.

Okay, now let’s focus on notifications.

Notifications

Today, I’ve decided to use PostgreSQL’s LISTEN/NOTIFY functionality to implement notifications. However, there are some guides that explain how to employ Redis’ Pub/Sub mechanism for the same purpose.

To send a NOTIFY message, an after_create callback can be used:

models/comment.rb

[...]
after_create :notify_comment_added
[...] 
private

def notify_comment_added
  Comment.connection.execute "NOTIFY comments, 'data'"
end
[...]

Raw SQL is used here to send data over the comments channel. For now, it is unclear which data to send, but we will return to this method in due time.

Next, we need to write the on_change method that was introduced in the previous iteration. This method listens to the comments channel:

models/comment.rb

[...]
class << self
  def on_change
    Comment.connection.execute "LISTEN comments"
    loop do
      Comment.connection.raw_connection.wait_for_notify do |event, pid, comment|
        yield comment
      end
    end
  ensure
    Comment.connection.execute "UNLISTEN comments"
  end
end
[...]

wait_for_notify is used to wait for notification on the channel. As soon as the notification (and its data) arrive we pass it (stored in the comment variable) to the controller’s method:

comments_controller.rb

[...]
Comment.on_change do |data|
  sse.write(data)
end
[...]

so, the data is the comment.

Now, we need to do some modifications on the client’s side to subscribe to our new shiny event source.

Subscribing to the Event Source

Subscribing to an event source is very easy:

comments.coffee

source = new EventSource('/comments')

Event listeners can be attached to source. The basic event listener is called onmessage and it is triggered when a data without a named event arrives. As you recall, on the server side we can provide an event option to the write method like this:

sse.write({name: 'Test'}, event: "event_name")

So, if the event field is not set, our onmessage is very simple:

comments.coffee

source = new EventSource('/comments')

source.onmessage = (event) ->
  console.log event.data

If you use the event field, the following structure should be used:

comments.coffee

source = new EventSource('/comments')
source.addEventListener("event_name", (event) ->
  console.log event.data
)

Okay, we still haven’t decided which data will be sent, but there are a couple of other things to do before we get there. Disable the “Post” button after a user has sent his comment:

comments.coffee

[...]
jQuery ->
  $('#new_comment').submit ->
    $(this).find("input[type='submit']").val('Sending...').prop('disabled', true)

Then, once the comment is saved, enable the button and clear the textarea:

comments/create.js.erb

<% if !@comment || @comment.errors.any? %>
alert('Your comment cannot be saved.');
<% else %>
$('#comment_body').val('');
$('#new_comment').find("input[type='submit']").val('Submit').prop('disabled', false)
<% end %>

Finally, all the pieces are ready and it’s time to glue them together and complete the puzzle. On to the next iteration!

Putting This All Together

It is time to solve the last problem – what data will be sent to the client to easily render the newly added comment? I will show you the two possible solutions: using JSON and using HTML (with the help of the render_to_string method).

Passing Data as JSON

First, let’s try out the approach using JSON. All we need to do is sent notifications with new comment’s data formatted as JSON, then resend it to the client, parse it, and render the comment. Pretty simple.

Create a new method to prepare the JSON data and call it from the notify_comment_added:

models/comment.rb

[...]
def basic_info_json
  JSON.generate({user_name: user.name, user_avatar: user.avatar_url, user_profile: user.profile_url,
                 body: body, timestamp: timestamp})
end

private

def notify_comment_added
  Comment.connection.execute "NOTIFY comments, '#{self.basic_info_json}'"
end
[...]

As you can see, simple generate a JSON object containing all the data required to display the comment. Note that those single quotes are required because we have to send this string as a part of the SQL query. Without the single quotes, you will get an “incorrect statement” error.

Use this data in the controller:

comments_controller.rb

def index
  response.headers['Content-Type'] = 'text/event-stream'
  sse = SSE.new(response.stream)
  begin
    Comment.on_change do |comment|
      sse.write(comment)
    end
  rescue IOError
    # Client Disconnected
  ensure
    sse.close
  end
  render nothing: true
end

In the controller, send the received data to the client.

On the client side, we have to parse the data and use it to render the comment. However, as you recall, our comment’s template is pretty complex. Of course we could just create a JS variable containing that HTML markup, but that would be tedious and inconvenient. So instead, let’s use underscore.js templates (you can choose other alternatives – for example, Handlebars.JS).

First, add underscore.js to your project:

Gemfile

[...]
gem 'underscore-rails'
[...]

Run

$ bundle install

application.js

[...]
//= require underscore
[...]

Then create a new template:

templates/_comment.html

<script id="comment_temp" type="text/template">
  <li class="media comment">
    <a href="<%%- user_profile %>" target="_blank" class="pull-left">
      <img src="<%%- user_avatar %>" class="media-object" alt="<%%- user_name %>" />
    </a>
    <div class="media-body">
      <h4 class="media-heading">
        <a href="<%%- user_profile %>" target="_blank"><%%- user_name %></a>
        says
        <small class="text-muted">[at <%%- timestamp %>]</small></h4>
      <p><%%- body %></p>
    </div>
  </li>
</script>

I’ve given this template an ID of comment_temp to easily reference it later. I’ve simply copied all the contents from the comment/_comment.html.erb file and used < %%- %> to mark the places where variable content should be interpolated.

We have to include this template in the page:

comments/new.html.erb

[...]
<%= render 'templates/comment' %>

Now we are ready to use this template:

comments.coffee

source = new EventSource('/comments')

source.onmessage = (event) ->
  comment_template = _.template($('#comment_temp').html())
  comment = $.parseJSON(event.data)
  if comment
    $('#comments').find('.media-list').prepend(comment_template( {
      body: comment['body']
      user_name: comment['user_name']
      user_avatar: comment['user_avatar']
      user_profile: comment['user_profile']
      timestamp: comment['timestamp']
    } ))

[...]

comment_template contains the template’s content and comment is the data sent by the server. We prepend a new comment to the comments list by passing all the required data to the template. Brilliant!

This solution, however, has a drawback. We need to change the markup in two places if modifications are needed for the comment template. I think this is more suitable in situations when you do not need any complex markup for the data to render.

Passing Data as HTML

Now let’s have a look at another solution:- using the render_to_string method. In this scenario, we will only need to send the new comment’s ID to the controller, fetch the comment, render its template, and send the generated markup to the client. On the client side, this markup just needs to be inserted on the page without modification.

Tweak your model:

models/comments.rb

[...]
private

def notify_comment_added
  Comment.connection.execute "NOTIFY comments, '#{self.id}'"
end
[...]

and the controller:

comments_controller.rb

def index
  response.headers['Content-Type'] = 'text/event-stream'
  sse = SSE.new(response.stream)
  begin
    Comment.on_change do |id|
      comment = Comment.find(id)
      t = render_to_string(partial: 'comment', formats: [:html], locals: {comment: comment})
      sse.write(t)
    end
  rescue IOError
    # Client Disconnected
  ensure
    sse.close
  end
  render nothing: true
end

The render_to_string method is similar to render, but it does not send the result as a response body to the browser – it saves this result as a string. Note, we have to provide formats, otherwise Rails will search for a partial with a format of text/event-stream (because we’ve set that response header earlier).

And lastly, on the client side:

source = new EventSource('/comments')

source.onmessage = (event) ->
  $('#comments').find('.media-list').prepend($.parseHTML(event.data))

[…]

That’s easy. Just prepend a new comment by parsing the received string.

At this point, boot up the server, open your app in two windows, and have a nice chat with yourself. Messages will be displayed almost instantly without any reloads, just like when we used Web Sockets.

Conclusion

In replacing Web Sockets with Server-Send Events, we’ve taken a look at using Puma, ActionController::Live, PostgreSQL LISTEN/NOTIFY functionality, and underscore.js templates. All in all, I think this solution is less preferable than Web Sockets with Faye.

First of all, it involves extra overhead and it only works with PostgreSQL (or Redis, if you utilize its Pub/Sub mechanics). Also, it seems that using Web Sockets is more suitable to our needs, since we need both a subscribing and publishing mechanism on the client side.

I hope this article was interesting for you! Have you ever used Server-Send Events in your projects? Would you prefer using Web Sockets to build a similar chat application? Share your opinion, I’d like to know!

Frequently Asked Questions (FAQs) about Mini Chat Rails and Server-Sent Events

What are Server-Sent Events (SSE) in Rails?

Server-Sent Events (SSE) is a standard that allows a web server to push real-time updates to the client, or the web browser. This is achieved by keeping a single, long-lived connection open between the server and the client. In Rails, SSE is implemented using the ActionController::Live module, which allows you to stream data directly to the client. This is particularly useful in applications that require real-time features, such as a chat application.

How do I implement Server-Sent Events in a Rails application?

Implementing Server-Sent Events in a Rails application involves a few steps. First, you need to include the ActionController::Live module in your controller. Then, in the action that will handle the SSE, you create a new response stream and write data to it. This data should be formatted as a valid SSE. Finally, you need to ensure that the connection is closed properly once the streaming is done.

What is ActionController::Live and how does it work?

ActionController::Live is a module in Rails that provides the ability to stream data to the client. When you include this module in a controller, it adds a stream object to the response. You can write data to this stream, and it will be sent to the client in real-time. This is done using the response.stream.write method. The connection remains open until you call response.stream.close.

How do I format data as a valid Server-Sent Event?

A valid Server-Sent Event is a simple text format, which consists of one or more lines of text, separated by a pair of newline characters. Each line of text should be prefixed with “data: “, and the last line should be followed by two newline characters. For example, to send the message “Hello, world!”, you would write response.stream.write "data: Hello, world!\n\n".

How do I handle incoming Server-Sent Events on the client side?

On the client side, you handle incoming Server-Sent Events using the EventSource API. This API provides an interface for receiving events from a server. You create a new EventSource object, passing the URL of the server-side endpoint that will send the events. Then, you can add event listeners to this object to handle the incoming events.

What are the benefits of using Server-Sent Events over other real-time technologies?

Server-Sent Events have several benefits over other real-time technologies. First, they are a standard, which means they are supported by all modern browsers and can be used without any additional libraries or plugins. Second, they are designed to be easy to use, with a simple text-based format and a straightforward API. Finally, they are efficient, as they use a single, long-lived connection to push updates from the server to the client.

Can I use Server-Sent Events in a Rails API?

Yes, you can use Server-Sent Events in a Rails API. The process is the same as in a regular Rails application: you include the ActionController::Live module in your controller, create a new response stream in the action that will handle the SSE, write data to the stream, and ensure that the connection is closed properly.

How do I handle errors and exceptions when using Server-Sent Events?

When using Server-Sent Events, it’s important to handle errors and exceptions properly to ensure that the connection is closed. This can be done using a begin-rescue-ensure block. In the begin block, you write data to the stream. If an exception occurs, it’s caught in the rescue block, where you can handle it appropriately. Finally, in the ensure block, you close the stream to ensure that the connection is closed no matter what.

Can I send different types of events with Server-Sent Events?

Yes, with Server-Sent Events, you can send different types of events. By default, all events are of type “message”, but you can specify a different type by including an “event” field in the data. For example, to send an event of type “update”, you would write response.stream.write "event: update\ndata: New update!\n\n".

How do I test a Rails application that uses Server-Sent Events?

Testing a Rails application that uses Server-Sent Events can be a bit tricky, as it involves asynchronous operations. However, there are several tools and techniques that can help. For example, you can use the Capybara gem, which provides a high-level API for interacting with web pages, and the Poltergeist gem, which allows you to run tests against a real, JavaScript-enabled web page. You can also use the EventMachine library to simulate a client that connects to the server and receives events.

Ilya Bodrov-KrukowskiIlya Bodrov-Krukowski
View Author

Ilya Bodrov is personal IT teacher, a senior engineer working at Campaigner LLC, author and teaching assistant at Sitepoint and lecturer at Moscow Aviations Institute. His primary programming languages are Ruby (with Rails) and JavaScript. He enjoys coding, teaching people and learning new things. Ilya also has some Cisco and Microsoft certificates and was working as a tutor in an educational center for a couple of years. In his free time he tweets, writes posts for his website, participates in OpenSource projects, goes in for sports and plays music.

GlennG
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week