Sapan Diwakar

Software developer

Follow me on Twitter Check out my code on GitHub View some of my designs on Dribbble Take a look at my Linked In profile

Custom WebSocket extension for Rails ActionCable

In the previous post, we saw how to support compression with permessage-deflate extension for Rails ActionCable connections. Now that we are there, let's go one step further and see how we can create a custom extension for WS frame compression.

As an example, I'll demonstrate a way to compress JSON messages with messagepack. A note before starting, don't expect huge savings if you are already using gzip compression using permessage-deflate as messagepack savings can vary hugely based on the kind of data you are transmitting over the socket.

With that out of the way, let's create our own permessage-messagepack extension. First step, check out the documentation. We will need a ClientSession that generates the offer for this custom protocol, a ServerSession that generates a response accepting this custom offer, and a Session that will process incoming and outgoing messages.

I'll be using most of the boilerplate from permessage-messagepack extension. The basic session class would look something like this:

module MessagePack  
  module PermessageMessagePack
    class Session
      VALID_PARAMS = [
        "server_no_context_takeover",
        "client_no_context_takeover"
      ]

      def self.valid_params?(params)
        return false unless params.keys.all? { |k| VALID_PARAMS.include?(k) }
        return false if params.values.grep(Array).any?

        if params.key?("server_no_context_takeover")
          return false unless params["server_no_context_takeover"] == true
        end

        if params.key?("client_no_context_takeover")
          return false unless params["client_no_context_takeover"] == true
        end

        true
      end

      def initialize(options)
        @accept_no_context_takeover = options.fetch(:no_context_takeover, false)
      end
    end
  end
end  

Let's get the easy bits out. Processing incoming and outgoing messages is going to be very straightforward since messagepack has a well-developed ruby gem handling compression and uncompression. So, we can go forward and add our processing code to the Session.

def process_incoming_message(message)  
  return message unless message.rsv2
  suppress(Exception) do
    message.data = Oj.dump(MessagePack.unpack(message.data))
  end
  message
end

def process_outgoing_message(message)  
  suppress(Exception) do
    json = Oj.load(message.data)
    message.data = MessagePack.pack(json)
    message.rsv2 = true
  end
  message
end  

The tricky bit here is understanding the rsv2 flag which needs to be set true when processing outgoing message. This flag indicates to the receiver that the message has passed through this protocol. Similarly, when processing incoming message, we need to make sure this flag is true which would tell us that the message was compressed with this protocol. The WebSocket protocol has three flags, rsv1, rsv2 and rsv3 for use for extensions. The rsv1 is already used by permessage-deflate so we need to use the second one here.

Next up, generating offers from a ClientSession (permessage_message_pack/client_session.rb). Again most of it is boilerplate code from the other extension.

module MessagePack  
  module PermessageMessagePack
    class ClientSession < Session

      def generate_offer
        offer = {}

        if @accept_no_context_takeover
          offer["client_no_context_takeover"] = true
        end

        offer
      end

      def activate(params)
        return false unless ClientSession.valid_params?(params)

        if @request_no_context_takeover && !params["server_no_context_takeover"]
          return false
        end

        @own_context_takeover = !(@accept_no_context_takeover || params["client_no_context_takeover"])
        @peer_context_takeover = !params["server_no_context_takeover"]

        true
      end
    end
  end
end

The final step for the extension, creating a server session.

module MessagePack  
  module PermessageMessagePack
    class ServerSession < Session
      def initialize(options, params)
        super(options)
        @params = params
      end

      def generate_response
        response = {}

        # https://tools.ietf.org/html/rfc7692#section-7.1.1.1
        @own_context_takeover = [email protected]_no_context_takeover &&
                                [email protected]["server_no_context_takeover"]
        response["server_no_context_takeover"] = true unless @own_context_takeover

        # https://tools.ietf.org/html/rfc7692#section-7.1.1.2
        @peer_context_takeover = [email protected]_no_context_takeover &&
                                 [email protected]["client_no_context_takeover"]
        response["client_no_context_takeover"] = true unless @peer_context_takeover

        response
      end
    end
  end
end  

With this out of the way, we can now go forward and create the extension that handles creation of client and server sessions. All boilerplate code except that we now mark that this extension uses rsv2 field.

module MessagePack  
  module PermessageMessagePack
    class Extension
      def initialize(options = {})
        @options = options
      end

      def create_client_session
        ClientSession.new(@options || {})
      end

      def create_server_session(offers)
        offers.each do |offer|
          return ServerSession.new(@options || {}, offer) if ServerSession.valid_params?(offer)
        end
        nil
      end

      def name
        "permessage-message-pack"
      end

      def type
        "permessage"
      end

      def rsv1
        false
      end

      def rsv2
        true
      end

      def rsv3
        false
      end
    end
  end
end  

Using the extension is as simple as adding it to the websocket driver. Please refer the previous post for more details.

@driver.add_extension(::MessagePack::PermessageMessagePack::Extension.new(:no_context_takeover => true))