Realtime Mini-Chat with Rails and Faye
In the previous part of this article, we built a messaging app that let users sign in via Facebook or Twitter and post their comments. We’ve also equipped the commenting form with AJAX so that the page is not reloaded every time the comment is posted.
However, there is a problem with our app – users have to reload the page each time they want to check for new comments. That is not very convenient, is it? How can we solve this problem?
There are at least two solutions: AJAX polling and web sockets. The first one is pretty outdated, however it can still be used in some scenarios where instant updates are not required. The latter seems to be more suitable for our case. However, let’s implement them both to see which is the best one!
The source code is available at GitHub.
The source code for the AJAX polling is available at the comments_polling branch.
The working demo can be found at http://sitepoint-minichat.herokuapp.com/.
AJAX Polling
I recommend switching to a new branch (if you are using Git) to start implementing this functionality:
$ git checkout -b polling
The key idea behind the AJAX polling is that we have some kind of timer that fires after a couple of seconds and sends a request checking if there are any new comments. The new comments are then displayed on the page. The cycle repeats.
There are some problems with this scheme, though. First of all, we are sending these poll requests constantly,
even if there are no new comments. Second, the new comments won’t appear on the page instantly – they will be displayed only on the next polling phase. There is also a third problem with polling that we will discuss a bit later.
To start our implemention, create a new index
route to check for new comments:
config/routes.rb
[...]
resources :comments, only: [:new, :create, :index]
[...]
If you are using Turbolinks and haven’t included jquery-turbolinks gem yet, do it now, otherwise the default jQuery.ready()
function will not work:
Gemfile
[...]
gem 'jquery-turbolinks'
[...]
application.js
[...]
//= require jquery.turbolinks
//= require turbolinks
Now create a new file .coffee file with the following code:
polling.coffee:
window.Poll = ->
setTimeout ->
$.get('/comments')
, 5000
jQuery ->
Poll() if $('#comments').size() > 0
Don’t forget to require this file:
application.js
[...]
//= require polling
We are creating a new function, Poll
, on the window
object (otherwise it will not be visible to the
other scripts). This function sets a timeout that fires after 5 seconds and sends a GET request to the /comments
resource. Call this function as soon as DOM is loaded if there is a #comments
block present on the page. We could have used setInterval
instead, but if our server is responding slowly we may end up with a situation where we send requests too fast.
Now create an index
method and the corresponding view:
comments_controller.rb
[...]
def index
@comments = Comment.order('created_at DESC')
end
[...]
comments/index.js.erb
$('#comments').find('.media-list').html('<%= j render @comments %>');
window.Poll();
We are replacing all the comments with the new ones and once again set the timer to fire off. You are probably thinking that this is not an optimal solution, and you’re right. But for now, fire up the server, open your app in two separate windows, and try to post some comments. Your comments should appear in both windows.
Great, now you can chat with yourself when you feel bored!
Let’s optimize our solution and make the index
action return only new comments, not all of them. One of the
ways to do this is to provide an id
of the last comment available on the page. This way the resource will load only the comments with an id
more than the provided one.
To implement this, we need to tweak our comment partial a bit:
comments/_comment.html.erb
<li class="media comment" data-id="<%= comment.id %>">
[...]
Now each comment has a data-id
attribute storing its id
. Also change the index
method:
comments_controller.rb
[...]
def index
@comments = Comment.where('id > ?', params[:after_id].to_i).order('created_at DESC')
end
[...]
The only thing we need to do now is send a GET request and provide an id
of the last comment on the page. However, things start to get a bit complicated here.
Imagine this situation: User A and User B opened up our messaging app. User A posted his comment with an id of 50. User B then also posted a comment and it was assigned an id of 51. Now the polling phase occurs. On User A’s side everything is working fine – a GET request with an id of 50 is sent and therefore a new comment with an id of 51 is displayed.
User B, however, will not see User A’s comment because a GET request with an id of 51 will be sent and 50 is less that 51, therefore no new comments will be displayed for User B! To overcome this problem, we can forcibly initiate a polling phase after the comment is posted.
Let’s refactor our JS code a bit:
polling.coffee
window.Poller = {
poll: (timeout) ->
if timeout is 0
Poller.request()
else
this.pollTimeout = setTimeout ->
Poller.request()
, timeout || 5000
clear: -> clearTimeout(this.pollTimeout)
request: ->
first_id = $('.comment').first().data('id')
$.get('/comments', after_id: first_id)
}
jQuery ->
Poller.poll() if $('#comments').size() > 0
I’ve replaced Poll
function with a Poller
object. The interesting part here is the poll
function. This function accepts a single argument – timeout
. If timeout
equals zero, initiate a poll request right away, without creating any timers. You may think that this condition is unneeded, because setTimeout(function() {}, 0);
should fire instantly. However, this is not the case. In reality, there will still be a delay before the function is called, so we don’t need the setTimeout
at all.
If timeout
does not equal to zero, create a pollTimeout
function and attach it to the Poller
object. Note that we are providing a default value of 5000 for the timeout.
clear
simply clears the pollTimeout
.
The request
is the actual GET request sent to /comments
. It also provides an after_id
parameter that equals the id of the newest (first) comment on the page.
We also need to tweak the index.js.erb
view:
comments/index.js.erb
$('#comments').find('.media-list').prepend('<%= j render @comments %>');
Poller.poll();
We’ve change the html
method to prepend
and Poll()
function to Poller.poll()
.
Lastly, we also need to change the create.js.erb
:
comments/create.js.erb
Poller.clear();
Poller.poll(0);
<% if @comment.new_record? %>
alert('Your comment cannot be saved.');
<% else %>
$('#comment_body').val('');
<% end %>
As soon as the comment is posted, clear the currently set timer and initiate the polling phase. Also, note that we do not prepend the comment here – we are relying on index.js.erb.
to do that.
Now this is a pretty messy, isn’t it?
To check if this is working correctly open up your app in two different windows, post a comment in the first window and then quickly (before the polling phase occurs) post another comment in the second window. You should see both comments displayed in the correct order.
AJAX polling is now working correctly. However, it does not seem to be the best solution. Will web sockets save the day?
Web Sockets and Faye
In this last iteration, we will use Faye – a publish/subscribe messaging system available for Ruby and Node.js. To integrate it with Rails, the faye-rails gem by James Coglan will come in handy.
If in the previous iteration we polled our app on a regular time basis to check for new comments, here we instead subscribe for updates and take any action only if an update (comment) is published. When a comment is created, notify all the subscribers so the update process happens instantly.
Switch back to the master
branch and start off by adding two new gems to the Gemfile
Gemfile
[...]
gem 'faye-rails', '~> 2.0`
gem 'thin'
and installing it
$ bundle install
We have to switch to Thin web server, because Faye will not work with the WEBrick. If you are going to publish your app on Heroku, create a new file in the root of your project:
Procfile
web: bundle exec rails server thin -p $PORT -e $RACK_ENV
Now add these lines to the application.rb:
config/application.rb
[...]
config.middleware.delete Rack::Lock
config.middleware.use FayeRails::Middleware, mount: '/faye', :timeout => 25
[...]
We are using FayeRails as middleware and mounting it on the /faye
path.
Now, require two new files in the application.js:
application.js
[...]
//= require faye
//= require comments
The faye.js file is provided by faye-rails
. It contains all the required JS code for Faye to work correctly.
Create the comments.coffee file:
comments.coffee
window.client = new Faye.Client('/faye')
jQuery ->
client.subscribe '/comments', (payload) ->
$('#comments').find('.media-list').prepend(payload.message) if payload.message
We are creating a new Faye client and attaching it to the window
object. Subscribe this client to the /comments
channel. As soon as the update is received, prepend the new message to the list of comments. This is much easier than the AJAX polling, isn’t it?
The last thing to do is modify the create.js.erb view:
comments/create.js.erb
publisher = client.publish('/comments', {
message: '<%= j render @comment %>'
});
Here the client
is used to publish an update to the /comments
channel providing the message that was posted. Note that we do not need to use the prepend
method here because it is used in our comments.coffee file.
Fire up the server and, again, chat with yourself (I hope you are not getting depressed). Notice that the messages are appearing instantly, in contrast to the AJAX polling. Moreover, the code is easier and shorter.
The last thing we could do is disable the “Post” button while the comment is being sent:
comments.coffee
[...]
jQuery ->
client.subscribe '/comments', (payload) ->
$('#comments').find('.media-list').prepend(payload.message) if payload.message
$('#new_comment').submit -> $(this).find("input[type='submit']").val('Sending...').prop('disabled', true)
The prop
method is used to set the disabled
attribute on the input
to true
.
With the help of Faye callbacks, we can restore the button to its original state and clear the textarea as soon as the comment is posted. If there was an error while notifying the subscribers, display it as well:
comments/create.js.erb
publisher = client.publish('/comments', {
message: '<%= j render @comment %>'
});
publisher.callback(function() {
$('#comment_body').val('');
$('#new_comment').find("input[type='submit']").val('Submit').prop('disabled', false)
});
publisher.errback(function() {
alert('There was an error while posting your comment.');
});
How cool is that? With web sockets, our app feels like a real chat. Messages are being sent with a minimum delay and no page refresh is needed! Of course, this app can still be extended with a backend for admin users allowing them to delete comments or block misbehaving chatters.
You may also want to take a look at the Faye-rails demo app – a very simple chat without any database or authentication.
Security
I have to say a couple of words about security. Currently anyone can send any data to the /comments
channel, which is not very secure. Faye’s website has a Security section that describes how to restrict subscriptions, publications, enforce CSRF protection, and some other concepts. I encourage you to read it if you are going to implement Faye in a real app.
For this demo, we are going to enforce CSRF protection – this will require us to modify both the server-side and client-side code. The easiest way to achieve this task is using so-called Faye extensions:
config/application.rb
require File.expand_path('../boot', __FILE__)
require File.expand_path('../csrf_protection', __FILE__)
[...]
class Application < Rails::Application
config.middleware.use FayeRails::Middleware, extensions: [CsrfProtection.new], mount: '/faye', :timeout => 25
[...]
config/csrf_protection.rb
class CsrfProtection
def incoming(message, request, callback)
session_token = request.session['_csrf_token']
message_token = message['ext'] && message['ext'].delete('csrfToken')
unless session_token == message_token
message['error'] = '401::Access denied'
end
callback.call(message)
end
end
We are checking if _csrf_token
is set in the user’s session and compare it with the token sent alongside with the message. If they are not the same, we raise an “Access denied” error.
On to the client-side code:
comments.coffee
window.client = new Faye.Client('/faye')
client.addExtension {
outgoing: (message, callback) ->
message.ext = message.ext || {}
message.ext.csrfToken = $('meta[name=csrf-token]').attr('content')
callback(message)
}
Using the addExtension
method, store the csrfToken
inside the ext
attribute of the message. This way, all publish messages will include the CSRF token. If you remove this client.addExtension
line, open your app along with the javascript console. The browser tries to establish connection but fails because on we require CSRF token to be present.
Conclusion
We’ve come to the end of this two-part article. All the planned functionality was implemented and we prototype AJAX polling vs web sockets, confirming what we already knew: Web sockets are much better for a real chat experience.
Have you ever implemented a similar solution? What problems did you face? Share your experience in the comments!
Thanks for reading!
============================
Update (08.12.2014):
One of the readers have noticed that when you navigate to the chat, then to another page, and return to the chat all sent messages will be duplicated. This happens because each time you visit a page the client subscribes to the `/comments` channel once again, so when you post a message it is being broadcasted via all subscriptions. To fix this problem you can just try to unsubscribe before subscribing. Add the following piece of code to your comments.coffee file:
try
client.unsubscribe '/comments'
catch
console?.log "Can't unsubscribe." # print a message only if console is defined
end
just before the
client.subscribe '/comments', (payload) ->
line. See this diff on GitHub:
Thank you guys so much for all your feedback!
============================