Mini-chat with Rails and Server-Sent Events
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!