Mini-chat with Rails and Server-Sent Events

Ilya Bodrov-Krukowski
Share

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!

CSS Master, 3rd Edition