Ruby
Article

Mini-chat with Rails and Server-Sent Events

By Ilya Bodrov-Krukowski

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!

Free Guide:

7 Habits of Successful CTOs

"What makes a great CTO?" Engineering skills? Business savvy? An innate tendency to channel a mythical creature (ahem, unicorn)? All of the above? Discover the top traits of the most successful CTOs in this free guide.

  • Ilya Bodrov

    Some links appeared to be broken when publishing via CMS. Will be fixed soon.

    • ggsp

      Fixed. Those responsible have been sacked.

      • Ilya Bodrov

        Ouch..

  • Suresh Kumar

    Hi Ilya Bodrov
    Thanks for amazing post.
    Could you please share, how I could see the messages for debugging purpose because i can’t see messages using developer console.

  • Suresh Kumar

    Hi

    The app works when I login and test by adding few comments. But after sometime I keep getting the below mentioned error. When I do
    heroku pg:reset DATABASE and heroku run rake db:migrate, things start to work again.

    Could you please share how I can avoid this error?

    2014-11-26T20:05:01.914923+00:00 app[web.1]: Started GET “/” for 117.202.133.252 at 2014-11-26 20:05:01 +0000
    2014-11-26T20:05:11.916910+00:00 app[web.1]:
    2014-11-26T20:05:11.916917+00:00 app[web.1]: ActiveRecord::ConnectionTimeoutError (could not obtain a database connection within 5.000 seconds (waited 5.000 seconds)):

  • Suresh Kumar

    Hi Ilya Bodrov

    I have become a big fan. Could you please do a post on how to send messages between users using MailBoxer gem?

    Thanks.

  • Giorgio Robino

    Hi Ilya!

    I appreciated you article
    I partially disagree with your conclusion, when you talk about overhead need by SSE (in comparison with websocket).

    Maybe you are right with your initial “application” requirements (if I weel undesrtood you foresse using Rails4 + Postgresql)

    In my modest opinion we have to decouple the persistence (Redis/Postgres/whatever) from push communications protocols.
    So let a part for a moment the database solution.

    The real points (comparing websockets and Server-sent Events) are
    – kind of application
    – latency
    – to HTTP or not to HTTP (in relation to pass-through proxy… that’s a possible problem with websocket mainly in 3G/mobile data nets)

    I’m doimg some test with SSE with mi microproject here:
    github.com/solyaris/pere

    I suggest the Darren Book I mention in README.md. The book point out well the problems of streaming/websockets and mobile networkings…

    my two cents
    Giorgio @solyarisoftware

    • Ilya Bodrov

      Yeah, I also thought about decoupling persistence. Will take a look at your project, thanks! :)

  • Suresh Kumar

    Thanks. More dynos are really very costly

  • https://www.assessup.com Leooo

    very good post, maybe an addition to be even more DRY: you can even send with SSE your js.erb views using render_to_string(..,formats:[:js]) and then use eval() to run them on the browser; found a small problem with escape_javascript if you render html partials in your js.erb template (https://github.com/rails/rails/issues/17957), but otherwise it works great.

  • Ilya Bodrov

    Great that you’ve liked it! I’ve thought about eval, but not huge fun of it :)

  • http://just4.ru Maxim V.

    Hi Ilya. Thank you for this post. And i have question, but my english not good =) Do you speak russian? I have problem with render_to_string method. This method return only first string from partial template.

  • http://www.grupo.ly sebastian velandia

    Hi thanks for this amazing article, but what if we also want to give the users the possibility to send attached files or photos, documents… is this possible using your solution?

    • Ilya Bodrov

      Hi! Of course, that’s totally possible :)

  • edu depetris

    I have the same problem that you. Did you solve it?

  • Mike Gruszka

    Thanks for this. Any thoughts as to why puma would boot but but not actually start listening? Below is what I see when I run the server. No errors. I have confirmed my Procfile and config/puma.rb file match with above.

    => Booting Puma
    => Rails 4.1.5 application starting in development on http://0.0.0.0:3000
    => Run `rails server -h` for more startup options
    => Notice: server is listening on all interfaces (0.0.0.0). Consider using 127.0.0.1 (–binding option)
    => Ctrl-C to shutdown server

  • edenisn

    And how to organize deleting message?

Recommended
Sponsors
Because We Like You
Free Ebooks!

Grab SitePoint's top 10 web dev and design ebooks, completely free!

Get the latest in Ruby, once a week, for free.