8 February 2019

web sockets

by mo


Today a colleague and myself had a chance to play with web sockets and the rack hijack. Here’s what we learned.

Below is a bare bones rack application.

# config.ru
require 'rack/handler/webrick'

class Application
  def call(env)
    ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
  end
end

Rack::Handler::WEBrick.run Application.new

モ bundle exec rackup config.ru
モ curl -s -i http://localhost:8080
HTTP/1.1 200 OK
Content-Type: text/html
Server: WEBrick/1.4.2 (Ruby/2.6.1/2019-01-30)
Date: Sat, 09 Feb 2019 17:27:03 GMT
Content-Length: 21
Connection: Keep-Alive

A barebones rack app.

To upgrade the connection to a web socket connection we need to send a couple of additional headers in the request. The Connection header indicates that the client would like to make a protocol change, and the Upgrade header tells the server that the protocol that the client would like to upgrade to is a websocket.

"Connection: Upgrade"
"Upgrade: websocket"

To get curl to initiate a web socket connection we can send the following request to the server.

モ curl -i -s \
  --header "Connection: Upgrade" \
  --header "Upgrade: websocket" \
  --header "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
  --header "Sec-WebSocket-Version: 13" \
  http://localhost:8080/

HTTP/1.1 200 OK
Content-Type: text/html
Server: WEBrick/1.4.2 (Ruby/2.6.1/2019-01-30)
Date: Sat, 09 Feb 2019 17:38:03 GMT
Content-Length: 21
Connection: Keep-Alive

A barebones rack app.

At the moment the server is ignoring our web socket upgrade request and returning a response immediately. In order to get our server to handle a web socket upgrade request we will need to look at the rack hijack API.

<tt>rack.hijack?</tt>:: present and true if the server supports
                        connection hijacking. See below, hijacking.
<tt>rack.hijack</tt>:: an object responding to #call that must be
                       called at least once before using
                       rack.hijack_io.
                       It is recommended #call return rack.hijack_io
                       as well as setting it in env if necessary.
<tt>rack.hijack_io</tt>:: if rack.hijack? is true, and rack.hijack
                          has received #call, this will contain
                          an object resembling an IO. See hijacking.
Additional environment specifications have approved to
standardized middleware APIs.  None of these are required to
be implemented by the server.

The spec is pretty self explanatory so let’s update our rack app to support a web socket connection. We will also switch to puma because at the time of this writing WEBrick does not fully implement the hijack API as described here.

# config.ru
require 'rack/handler/puma'

class Application
  def call(env)
    if env['rack.hijack?']
      env['rack.hijack'].call
      io = env['rack.hijack_io']
      Thread.new do
        loop do
          sleep 5
          io.write("[#{Time.now.to_i}] I'm awake...\n")
        end
      end
    else
      ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
    end
  end
end

Rack::Handler::Puma.run Application.new

We’re going to write a response to the client every 5 seconds from a background thread to simulate server to client push messages.

This is pretty neat, we now have a persistent connection with the server that allows the server to dispatch messages to the client whenever it wishes to. We can take this a little bit further by connecting the web socket connection to a redis subscription.

# config.ru
require 'rack/handler/puma'
require 'redis'

class Application
  attr_reader :redis

  def initialize
    @redis = Redis.new
  end

  def call(env)
    if env['rack.hijack?']
      env['rack.hijack'].call
      io = env['rack.hijack_io']
      redis.subscribe("ruby") do |on|
        on.message do |channel, message|
          io.write(message)
        end
      end
    else
      ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
    end
  end
end

Rack::Handler::Puma.run Application.new

When we receive a message published to a channel named ruby from redis we will dispatch that message directly to the connected web socket connection.

To complete the example we need something to publish to the redis channel. Below is a tiny client that will publish messages to redis.

# publisher.rb

require 'redis'

redis = Redis.new
5.times do
  sleep 5
  redis.publish("ruby", "[#{Time.now.to_i}] I love ruby\n")
end

First we need to start the web socket connection using curl. Then we will start publishing messages using the publisher script.

We have built a minimal server side implementation of what Action Cable provides us. The next step is to replace the curl client with a minimal JavaScript client. I leave that as an exercise for the reader.

The full source can be cloned from here.

References

💎