Long-polling with Puma, Rails 4 and Server Sent Events

In this post, I am going to show you how to create a long polling server with Puma, ActionController::Live in Rails 4 and Server Side Events in modern browsers. Why there is another post when there are already many of them on the Internet? It is just because I could not accomplish my goal by looking at just one post. I had to browse through several posts and had to collect piece and piece of information from each site. And my goal is to have a one place which you can really read and understand how to implement.

Important notes

  1. You have to turn on config.cache_classes=true and config.eager_load=true inside environments/development.rb or it will not work. And after this setting, please be noted that you will have to restart the server everytime you make changes to the code. Yes, EVERYTIME you make changes to the code.
  2. You have to use Puma server. Don't try this with other servers such as Thin or Webrick because I don't think it will work, they do not support concurrent requests like Puma

Ok, let's get started

First of all, I think it is necessary to understand the difference between Long Polling and Short Polling.

  • Short Polling: You send a request to server every x seconds to retrieve updates. This is how Pivotral Tracker is using to update their dashboard in real-time

  • Long Polling: You send a request to server and wait for the response until server returns something or timeout. In order server such as Nodejs, this is easy to accomplish. But in Rails, you have to use loop statement to achieve this.

Why Puma?
Good question. As I mention above, you have to use loop statement in server side code. And the current process will be hang inside the loop and will not serve another client request. Puma is a server which support running multiple processes or multiple threads, and one stuck thread won't stop it from serving other users.

What is Server Sent Events?
This is a HTML5 technology which allow browsers to receive updates from server automatically. And when it comes to HTML5 playground, one guy stands out of my way: IE. Please, I have warned you from the beginning of this post that IE will not be supported.

We are going to build a very simple application which allow user to get a new messages submitted by other client in real-time. It is just like a chat app but much simpler

Controller

We will first need to configure our Puma server a little bit. Add following line to your Gemfile and run bundle install

gem 'puma'

Create a config file for Puma server inside config directory and name it puma.rb. The content of this file looks like this:

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

Again, as I have said earlier, you have to set following two config in development.rb to make it work

config.cache_classes = true
config.eager_load = true

Now, you can start your server using the following command

puma -C config/puma.rb

Create a model called Message in your app. This is a very simple model for storing message submitted by users and it has just one main column content

rails g model Message content:text

Create a simple HomeController with the following content

require 'simple_app/sse.rb' 
class HomeController < ApplicationController
  include ActionController::Live

  def check
    # SSE expects the `text/event-stream` content type
    response.headers['Content-Type'] = 'text/event-stream'

    sse = SimpleApp::SSE.new(response.stream)
    begin
      20.times do
        messages = Message.where("created_at > ?", 3.seconds.ago)
        unless messages.empty?
          sse.write({messages: messages.as_json}, {event: 'refresh'})
        end
        sleep 3
      end
    rescue IOError
      # When the client disconnects, we'll get an IOError on write
    ensure
      sse.close
    end
  end
end

In Rails 4, we have the ability to send data directly to client through ActionController::Live module. You can read more at this link: http://edgeguides.rubyonrails.org/action_controller_overview.html#live-streaming-of-arbitrary-data

You will notice that I also create a wrapper at lib/simple_app/sse.rb for sending Server Sent Events to client. I actually borrow the code from this excellent blog: http://tenderlovemaking.com/2012/07/30/is-it-live.html

require 'json'

module SimpleApp
  class SSE
    def initialize io
      @io = io
    end

    def write object, options = {}
      options.each do |k,v|
        @io.write "#{k}: #{v}\n"
      end
      @io.write "data: #{JSON.dump(object)}\n\n"
    end

    def close
      @io.close
    end
  end
end

Hey, please pay a very close attention to the \n\n character at the end of this line

@io.write "data: #{JSON.dump(object)}\n\n"

These \n\n are not there just for fun. They play an important roles. They are the signal to tell client that there are data returned from the server and the client can process it. If you remove these characters by accident (just like I did before), your app will not work as you expected. So please pay attention to that.

Of course, remember to config your routes.rb so that Rails can understand this route

get '/check', to: 'home#check'

Javascript

Here is the JS code which used to check data from server

setTimeout((function() {
  var source;
  source = new EventSource('/check');
  source.addEventListener("refresh", function(e) {
    addMessages($.parseJSON(e.data).messages);
  });
}), 1);

Do you wonder why there is setTimeout function? It is because we want to start this listening event in a new thread so that it won't block the current UI thread on our web. The code is quite simple and easy to understand: we set up and EventSource pointing to our location on server and we listen to the refresh event on this EventSource object. If this event happens, we will parse the data returned from server by running the function addMessages. Be noted that the data returned from server would be in plain text format and we need to convert it to correct JSON object.

I have a very simple addMessages function like the following, you can customize it according to your need:

function addMessages(messages){
  for(var i=0; i<messages.length; i++){
    document.writeln(messages[i].content);
  }
}

That's it. Now try running Rails console to create a new Message record in the database and you notice that the message displayed immediately on client side.

You can also clone the code from this Github repos to run on your local https://github.com/xuanchien/puma_sse_example. Let me know if you have any issues on your side