Ruby EventMachine Web Socket Redis Pub/Sub Chat Room

Yet another web socket chat room. This one uses Ruby, EventMachine, Redis pub/sub, and web sockets via jQuery.

Back end first. Install Redis if you have not already.

# install via homebrew
brew install redis

# start in background
redis-server /usr/local/etc/redis.conf &

Here’s a few required gems. file: Gemfile

source 'https://rubygems.org'

gem 'em-websocket'
gem 'sinatra'
gem 'thin'
gem 'em-hiredis'

Install them.

bundle install

Up next: EventMachine server. This script handles the backend pubsub and web socket connections. file: server_em.rb

#!/usr/bin/env ruby

require 'em-websocket'
require 'em-hiredis'
require 'json'
require 'logger'

# helper module to do some parsing and cleaning
module ChatServer
  extend self

  DEFAULT_CHAT_ROOM = 'default'
  DEFAULT_USER_NAME = 'anonymous'
  DEFAULT_MESSAGE = ':)'
  VALID_MESSAGE_KEYS = ['user_name', 'chat_room', 'message']

  # clean chat room name
  def chat_room_name(data)
    return DEFAULT_CHAT_ROOM if data.nil? || !data.respond_to?(:gsub)
    chat_room_name = data.gsub(/\W/,'')
    return DEFAULT_CHAT_ROOM if chat_room_name.nil? || chat_room_name.empty?
    chat_room_name
  end

  # clean user name
  def user_name(data)
    return DEFAULT_USER_NAME if data.nil? || !data.respond_to?(:gsub)
    user_name = data.gsub(/[^\w\ ]/, '')
    return DEFAULT_USER_NAME if user_name.nil? || user_name.empty?
    user_name
  end

  # strip tags from a message
  def clean_message(message)
    return DEFAULT_MESSAGE if message.nil? || !message.respond_to?(:gsub)
    return message.gsub(/<(?:.|\n)*?>/, '')
  end

  # parse JSON message and clean data
  def message_string_to_object(message_string)

    begin
      data = JSON.parse message_string
    rescue => e
      data = {}
    end

    # remove invalid keys
    data.delete_if {|key,value| !VALID_MESSAGE_KEYS.include?(key) }

    # clean data
    data['message'] = clean_message data['message']
    data['chat_room'] = chat_room_name data['chat_room']
    data['user_name'] = user_name data['user_name']

    data

  end

end

EM.run do

  @log = Logger.new(STDOUT)

  EM::WebSocket.run(:host => "0.0.0.0", :port => 8080) do |ws|

    # event: web socket open
    ws.onopen do |handshake|

      chat_room_name = ChatServer.chat_room_name handshake.path

      # log
      @log.info "WebSocket connection opened; chat room: #{chat_room_name}"

      # connect to Redis and subscribe
      @redis = EM::Hiredis.connect
      pubsub = @redis.pubsub
      pubsub.subscribe chat_room_name
      pubsub.on(:message) do |channel, message|

        # log
        @log.debug "redis pubsub.on(:message); channel: #{channel}; message: #{message}"

        ws.send ChatServer.message_string_to_object(message).to_json

      end

    end

    # event: web socket close
    ws.onclose do

      # log
      @log.info "WebSocket connection closed"

    end

    # event: web socket received message
    ws.onmessage do |message|

      # log
      @log.debug "ws.onmessage; message: #{message}"

      begin

        # parse/clean json message
        data = ChatServer.message_string_to_object message

        # publish to redis pubsub
        @redis.publish data['chat_room'], data.to_json

      rescue => e
        @log.error e
      end

    end

  end

end

I then created a simple sinatra front end server. file: server.web.rb

#!/usr/bin/env ruby

require 'sinatra'

set :server, :thin

get '/' do
  erb :chat_room
end

Some front end html. file: views/chat_room.erb

<div class='row'>

  <div class="col-md-4">

    <!-- this form is shown first and is used to submit chat room and user name -->
    <!-- it is hidden once submitted -->
    <form id='enter_chat_room' role='form'>

      <div class="form-group">
        <label for="chat_room">Chat room</label>
        <input type="text" class="form-control" id="chat_room" placeholder="Enter chat room">
      </div>

      <div class="form-group">
        <label for="user_name">Name</label>
        <input type="text" class="form-control" id="user_name" placeholder="Enter name">
      </div>

      <button type="submit" class="btn btn-default">Enter</button>

    </form>

    <!-- this form is shown once the user enters chat room and user name -->
    <form id='send_message' role='form'>

      <div class="form-group">
        <label for="chat_room">Chat Room</label>
        <input type="text" class="form-control" id="chat_room_ro" readonly>
      </div>

      <div class="form-group">
        <label for="message">Message</label>
        <input type="text" class="form-control" id="message" placeholder="Enter message">
      </div>

      <button type="submit" class="btn btn-default">Submit</button>

    </form>

  </div>

  <div class="col-md-8">
    <dl class="dl-horizontal" id="chat_messages">
    </dl>
  </div>

</div>

Some jQuery web socket and form processing code. file: public/js/chat.js

var user_name = null;
var chat_room = null;
var ws = null;

var strip_tags = function(data) {
  return data.replace(/<(?:.|\n)*?>/gm, '');
}

var clean_chat_room = function(data) {
  data = strip_tags(data);
  return data.replace(/[^\w]/g, '');
}

var clean_user_name = function(data) {
  data = strip_tags(data);
  return data.replace(/[^\w\ ]/g, '');
}

var init_enter_chat_room_form = function(){
  $('#enter_chat_room').submit(function(){

    $form = $(this);
    $chat_room_field = $('#chat_room', $form);
    $user_name_field = $('#user_name', $form);
    chat_room = $chat_room_field.val();
    user_name = $user_name_field.val();

    // clean data
    chat_room = clean_chat_room(chat_room);
    user_name = clean_user_name(user_name);

    // update field vals
    $chat_room_field.val( chat_room );
    $user_name_field.val( user_name );

    // validate
    if (chat_room == '') {
      alert('Chat room is required.');
      return false;
    }
    if (user_name == '') {
      alert('Name is required.');
      return false;
    }

    // hide chat/user form, show message form
    $form.hide();
    init_chat_session();

    return false;

  });
}

var init_chat_session = function(){

  // open web socket
  ws = new WebSocket("ws://127.0.0.1:8080/" + chat_room);

  ws.onerror = function(error){};

  ws.onclose = function(){};

  ws.onopen = function(){
    init_send_message_form();
  };

  ws.onmessage = function (e) {

    data = JSON.parse(e.data);
    new_message = "<dt>"+data.user_name+"</dt><dd>"+data.message+"</dd>";
    $('#chat_messages').append( new_message );

  };

}

var init_send_message_form = function(){

  // show message form
  $('#send_message').show();

  $('#chat_room_ro', $('#send_message')).val( chat_room );

  // submit handler
  $('#send_message').submit(function(){

    $form = $(this);
    $message_field = $('#message', $form);
    message = $message_field.val();

    // clean data
    message = strip_tags(message);

    // update field vals
    $message_field.val( message );

    // validate
    if (message == '') {
      alert('Message is required.');
      return false;
    }

    data = {
      user_name: user_name,
      chat_room: chat_room,
      message: message
    }

    // send message
    try {
      ws.send( JSON.stringify(data) );
    }
    catch(err) {
      // debug
      //console.debug(err);
    }

    return false;
  });

};

$(document).ready(function(){

  init_enter_chat_room_form();

});

Run the servers.

chmod +x server_em.rb
chmod +x server_web.rb

./server_em.rb &
./server_web.rb &

Screenshot of chat room.

chat room

Source code on GitHub.

Updated: