Using WebSockets on Heroku with Ruby

Last Updated: 27 March 2014

ruby websockets

Table of Contents

This quickstart will get you going with a Sinatra application that uses a WebSocket, deployed to Heroku.

Sample code for the demo application is available on GitHub. Edits and enhancements are welcome. Just fork the repository, make your changes and send us a pull request.

Prerequisites

If you have questions about Ruby on Heroku, consider discussing it in the Ruby on Heroku forums.

Create WebSocket app

The sample application provides a simple example of using WebSockets with Ruby. You can clone the sample and follow along with the code as you read.

Option 1. Clone sample app

$ git clone git@github.com:heroku-examples/ruby-websockets-chat-demo.git
Cloning into 'ruby-websockets-chat-demo'...
remote: Counting objects: 31, done.
remote: Compressing objects: 100% (24/24), done.
remote: Total 31 (delta 0), reused 31 (delta 0)
Receiving objects: 100% (31/31), 38.33 KiB | 0 bytes/s, done.

Option 2. Create a new app

$ mkdir ruby-websockets-chat-demo
$ cd ruby-websockets-chat-demo

Functionality

The demo app is a simple chat application that will open a WebSocket to the backend. Any time a chat message is sent from the browser, it’s sent to the server and then broadcasted to each connecting client and displayed on the page.

There are a few key pieces to this implementation. We’re using Faye’s WebSockets implementation that provides the standard WebSockets API. This allows us to build a Rack middleware that responds to WebSockets.

JavaScript on the browser opens a WebSocket connection to the server and responds to a WebSocket message being received from the server to display the chat message. Let’s take a look at both the backend and frontend in more detail.

Backend

In the sample app, we create a Rack middleware to encapsulate the WebSockets logic called ChatBackend. For organizational purposes we’re going to put all of our Rack middleware in a middlewares directory.

Let’s walk through our ChatBackend middleware. We need to keep track of all the clients that are connecting to the web app, so let’s setup a @clients array along with some basic boilerplate code:

# middlewares/chat_backend.rb
require 'faye/websocket'

module ChatDemo
  class ChatBackend
    KEEPALIVE_TIME = 15 # in seconds

    def initialize(app)
      @app     = app
      @clients = []
    end

    def call(env)
    end
  end
end

Inside the call method, Faye::Websocket allows us to detect whether the incoming request is a WebSockets request by inspecting env. If it is let’s do our WebSockets logic. If it isn’t, then we should just go through the rest of the stack (in our case this is the Sinatra app).

# middlewares/chat_backend.rb
def call(env)
  if Faye::WebSocket.websocket?(env)
    # WebSockets logic goes here

    # Return async Rack response
    ws.rack_response
  else
    @app.call(env)
  end
end

Now let’s add the WebSockets code. We need to first create a new WebSockets object based off the env. We can do this using the Faye::WebSocket initializer. The options hash accepts a ping option which sends a ping to each active connection every X number of seconds. In our case we’re going to use the KEEPALIVE_TIME constant we set above to 15 seconds. We want to do this because if the connection is idle for 55 seconds, Heroku will terminate the connection.

Initialize the WebSocket in the call method of your middleware with the ping option.

# middlewares/chat_backend.rb#call
ws = Faye::WebSocket.new(env, nil, {ping: KEEPALIVE_TIME })

The three WebSocket events that are important for this app are open, message, close. In each event we’re going to print messages to STDOUT for demo purposes to see the lifecycle of WebSockets.

open

open gets invoked when a new connection to the server happens. For open, we’re just going to store that a client has connected in the @clients array we set above.

# middlewares/chat_backend.rb#call
ws.on :open do |event|
  p [:open, ws.object_id]
  @clients << ws
end

message

message gets invoked when a WebSockets message is received by the server. For message, we need to broadcast the message to each connected client. The event object passed in has a data attribute which is the message.

# middlewares/chat_backend.rb#call
ws.on :message do |event|
  p [:message, event.data]
  @clients.each {|client| client.send(event.data) }
end

close

close gets invoked when the client closes the connection. For close, we just need to cleanup by removing the client from our store of clients.

# middlewarces/chat_backend.rb#call
ws.on :close do |event|
  p [:close, ws.object_id, event.code, event.reason]
  @clients.delete(ws)
  ws = nil
end

Frontend

The second part of this is setting up the client side to actually open the WebSockets connection with the server. First, we need to finish setting up the Sinatra app to render the pages to use.

View

In the Sinatra app, we just need to render the index view which we’ll use the built in ERB.

# app.rb
require 'sinatra/base'

module ChatDemo
  class App < Sinatra::Base
    get "/" do
      erb :"index.html"
    end
  end
end

Sinatra stores its views in the views directory. Create an index.html.erb with a basic chat form.

Receive messages

Now back to WebSockets. Let’s write the public/assets/js/application.js that gets loaded by the main page and establishes a WebSocket connection to the backend.

var scheme   = "ws://";
var uri      = scheme + window.document.location.host + "/";
var ws       = new WebSocket(uri);

With an open WebSocket, the browser will receive messages. These messages are structured as a JSON response with two keys: handle (user’s handle) and text (user’s message). When the message is received, it’s inserted as a new entry in the page.

// public/assets/js/application.js
ws.onmessage = function(message) {
  var data = JSON.parse(message.data);
  $("#chat-text").append("<div class='panel panel-default'><div class='panel-heading'>" + data.handle + "</div><div class='panel-body'>" + data.text + "</div></div>");
  $("#chat-text").stop().animate({
    scrollTop: $('#chat-text')[0].scrollHeight
  }, 800);
};

Send messages

The now that the page can receive messages from the server, we need a way to actually send messages. We’ll override the form submit button to grab the the values from the form and send them as a JSON message over the WebSocket to the server.

// public/assets/js/application.js
$("#input-form").on("submit", function(event) {
  event.preventDefault();
  var handle = $("#input-handle")[0].value;
  var text   = $("#input-text")[0].value;
  ws.send(JSON.stringify({ handle: handle, text: text }));
  $("#input-text")[0].value = "";
});

Your final application.js file should now define the complete send and receive functionality.

Local execution

To wire up the application, we’ll use config.ru which sets up Rack::Builder. This tells Rack and the server how to load the application. We want to setup our WebSockets middleware, so requests go through this first.

# config.ru
require './app'
require './middlewares/chat_backend'

use ChatDemo::ChatBackend

run ChatDemo::App

We need to download and fetch the dependencies, so we need to setup a Gemfile.

# Gemfile
source "https://rubygems.org"

ruby "2.0.0"

gem "faye-websocket"
gem "sinatra"
gem "puma"

And install all dependencies.

$ bundle install

We’ll now setup a Procfile, so we have a documented way of running our web service. We’ll be using the puma webserver which is one of the webservers supported by faye-websocket.

web: bundle exec puma -p $PORT

Run the app locally with Foreman.

$ foreman run web
Puma starting in single mode...
* Version 2.6.0, codename: Pantsuit Party
* Min threads: 0, max threads: 16
* Environment: development
* Listening on tcp://0.0.0.0:5000
Use Ctrl-C to stop

Open the app at localhost:5000 and enter a chat message. You should see the following in the server output:

127.0.0.1 - - [03/Oct/2013 17:16:25] "GET / HTTP/1.1" 200 1430 0.0115
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /assets/css/application.css HTTP/1.1" 304 - 0.0164
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /assets/css/bootstrap.min.css HTTP/1.1" 304 - 0.0046
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /assets/js/jquery-2.0.3.min.js HTTP/1.1" 304 - 0.0007
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /assets/js/application.js HTTP/1.1" 200 716 0.0063
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET / HTTP/1.1" HIJACKED -1 0.0059
[:open, 7612540]
127.0.0.1 - - [03/Oct/2013 17:16:25] "GET /favicon.ico HTTP/1.1" 404 490 0.0012
[:message, "{\"handle\":\"hone\",\"text\":\"test\"}"]

Deploy

After the app is running locally, it’s time to deploy it to Heroku. If you haven’t done so already put your application into a git repository:

$ git init
$ git add .
$ git commit -m "Ready to deploy"

Create the app to deploy to on Heroku.

$ heroku create
Creating limitless-ocean-5045... done, stack is cedar
http://limitless-ocean-5045.herokuapp.com/ | git@heroku.com:limitless-ocean-5045.git
Git remote heroku added

While in beta, WebSocket functionality must be enabled via the Heroku Labs:

$ heroku labs:enable websockets
Enabling websockets for morning-taiga-2382... done
WARNING: This feature is experimental and may change or be removed without notice.
For more information see: https://devcenter.heroku.com/articles/heroku-labs-websockets

Deploy your app to Heroku with git push:

$ git push heroku master
Counting objects: 9, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 557 bytes, done.
Total 5 (delta 3), reused 0 (delta 0)

-----> Ruby/Rack app detected
-----> Using Ruby version: ruby-2.0.0
-----> Installing dependencies using Bundler version 1.3.2
       Running: bundle install --without development:test --path vendor/bundle --binstubs vendor/bundle/bin --deployment
       Installing eventmachine (1.0.3)
       Installing websocket-driver (0.3.0)
       Installing faye-websocket (0.7.0)
       Installing rack (1.5.2)
       Installing puma (2.6.0)
       Installing rack-protection (1.5.0)
       Installing redis (3.0.4)
       Installing tilt (1.4.1)
       Installing sinatra (1.4.3)
       Installing bundler (1.3.2)
       Your bundle is complete! It was installed into ./vendor/bundle
       Cleaning up the bundler cache.
-----> Discovering process types
       Procfile declares types     -> web
       Default types for Ruby/Rack -> console, rake

-----> Compiled slug size: 26.8MB
-----> Launching... done, v3
       http://limitless-ocean-5045.herokuapp.com deployed to Heroku

Congratulations! Your web app should now be up and running on Heroku. use heroku open to open it in your browser.

Scaling

Now that you have deployed your app and understand the very basics we’ll want to expand the app to be a bit more robust. This won’t encompass everything you’d want to do before going public, but it will get you along the a path where you’ll be thinking about the right things.

Currently, if you scale your app to more than a single dyno, not everyone will get all messages. Due to the stateless nature of the current app, clients connected to dyno #1 won’t get messages sent by clients connected to dyno #2. We can solve this in the chat example by storing the message state in a global storage system like Redis. This allows messages to be sent to every dyno connected to Redis and is explained in more detail in our WebSockets Application Architecture Section.

Add a Redis add-on service:

$ heroku addons:add rediscloud:20
Adding rediscloud:20 on limitless-ocean-5045... v4 (free)

Make sure you have Redis setup locally. We’ll be using the redis gem to interface with Redis from the ruby app. Like above, we’ll need to edit the Gemfile to configure app dependencies. Add this line to the bottom of your Gemfile.

gem 'redis'

Install the dependencies.

$ bundle install

We need to setup each dyno to use Redis' pubsub system. All the dynos will subscribe to the same channel chat-demo and wait for messages. When each server gets a message, it can publish it to the clients connected.

require 'redis'

module ChatDemo
  class ChatBackend
    ...
    CHANNEL        = "chat-demo"

    def initialize(app)
      ...
      uri      = URI.parse(ENV["REDISCLOUD_URL"])
      @redis   = Redis.new(host: uri.host, port: uri.port, password: uri.password)
      Thread.new do
        redis_sub = Redis.new(host: uri.host, port: uri.port, password: uri.password)
        redis_sub.subscribe(CHANNEL) do |on|
          on.message do |channel, msg|
            @clients.each {|ws| ws.send(msg) }
          end
        end
      end
    end
    ...
end

We need to do the subscribe in a separate thread, because subscribe is blocking function, so it will stop the executon flow to wait for a message. In addition, a second redis connection is needed since once a subscribe command has been made on a connection, the connection can only unsubscribe or receive messages.

Now when the server receives a message, we want to publish it with Redis to the channel instead of sending it to the browser. This way every dyno will get notified of it, so every client can receive the message. Change middlewares/chat_backend#call:

ws.on :message do |event|
  p [:message, event.data]
  @redis.publish(CHANNEL, event.data)
end

All these steps can be viewed in this commit.

Security

Currently, the application is exposed and vulnerable to many attacks. Please refer to WebSockets Security for more general guidelines on securing your WebSockets application. The example app on github sets up WSS and sanitizes input.

Using with Rails

In this sample app we created Rack middleware alongside a Sinatra app. Using the same middleware in a Rails app is trivial.

Copy the existing ChatBackend middleware to app/middleware/chat_backend.rb in your Rails project. Then insert the middleware into your stack, defined in config/application.rb:

require 'chat_backend'
config.middleware.use ChatDemo::ChatBackend

WebSocket requests will now be handled by the custom chat middleware, embedded within your Rails app.