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 an 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 = !@accept_no_context_takeover &&
                                !@params["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 = !@request_no_context_takeover &&
                                 !@params["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 the 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 to the previous post for more details.

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