Swiftpack.co - Package - IBM-Swift/Kitura-WebSocket-Compression

Kitura

Build Status - Master macOS Linux Apache 2 Slack Status

Kitura-WebSocket-Compression

A WebSocket compression library based on SwiftNIO

WebSocket Compression

WebSocket Compression, defined by RFC7692 allows WebSocket clients to send and receive compressed data on a WebSocket connection. Compression reduces the total wire-level payload of a WebSocket connection, possibly resulting in an improved throughput.

This document discusses the implementation of WebSocket Compression in Kitura-WebSocket-Compression API using SwiftNIO.

This document assumes the reader is aware of the fundamentals of the WebSocket protocol.

Table of contents

  1. Usage
  2. WebSocket extensions
  3. WebSocket compression - the permessage-deflate algorithm
  4. An implementation of permessage-deflate based on SwiftNIO
  5. Developer notes

1. Usage

Add dependencies

Add the Kitura-WebSocket-Compression package to the dependencies within your application’s Package.swift file. Substitute "x.x.x" with the latest Kitura-WebSocket-Compression release.

.package(url: "https://github.com/IBM-Swift/Kitura-WebSocket-Compression.git", from: "x.x.x")

Add Kitura-WebSocket-Compression to your target's dependencies:

.target(name: "example", dependencies: ["WebSocketCompression"]),

Import package

import WebSocketCompression

2. WebSocket extensions

The WebSocket protocol has a provision for servers to configure protocol extensions, and for clients to request these extensions from the servers. A client notifies about extensions it is interested in through a negotiation offer using the Sec-WebSocket-Extension header. A server may or may not support the extensions requested by the client. Through a negotiation response, the server notifies the client of the extensions that the server agrees upon. Negotiation offers and responses may also include extension-specific parameters. Once an extension is agreed upon, the client and server must invoke the extension from their respective WebSocket implementations.

WebSocket compression is a WebSocket extension.

3. WebSocket Compression: the permessage-deflate algorithm

Permessage-deflate is a WebSocket extension defined by RFC7692 which provides a specification for the compression functionality. It defines the negotiation process and a compression algorithm called DEFLATE. Like any WebSocket extension, the permessage-deflate negotiation comprises of an offer and a response.

The permessage-deflate negotiation offer

A permessage-deflate negotiation happens during the upgrade request from HTTP to WebSocket. A permessage-deflate negotiation offer has a mandatory permessage-deflate string followed by a semi-colon separated list of extension parameters. There are four extension parameters defined for WebSocket compression:

  • client_no_context_takeover, server_no_context_takeover
  • client_max_window_bits, server_max_window_bits

We will revisit these parameters in a later section, where we will discuss their use and effects.

The permessage-deflate negotiation response

A permessage-deflate negotiation response has a mandatory permessage-deflate string followed by a semi-colon separated list of extension parameters, agreed upon by the server. The headers in the negotiation response are the final word on how the data compression/decompression will be done between the client and server. Data compressed by the client must be decompressed by the server and vice versa. The client and server must adopt the same compression/decompression configuration parameters. We will take a detailed look at this in the later sections.

The specification also discusses the DEFLATE algorithm. We utilize the zlib compression library for doing raw compression and decompression. A pair, comprising of a compressor and a decompressor, must be set up at both the ends of the connection. The server's decompressor decompresses messages compressed by the client's compressor and vice versa.

4. An implementation of permessage-deflate based on SwiftNIO

The SwiftNIO framework provides an API which enables HTTP/WebSocket server implementations to view the processing of data, that has been read from or written to sockets, as a sequence of transformations that happen through a pipeline of handlers. An active connection is represented by a Channel. Data which is read from, or written to, a channel moves through a ChannelPipeline of inbound and outbound ChannelHandlers. An EventLoop is associated with every Channel. An EventLoop is a thread-safe abstraction of a thread and provides features for asynchronous code execution using EventLoopFutures and EventLoopPromises.

In Kitura-NIO, we start the HTTP server with the pipeline configured by SwiftNIO, adding Kitura-NIO's HTTPRequestHandler at the end. A view of the inbound and outbound pipelines (with some handlers omitted for simplicity) is this:

  • Inbound channel handler pipeline:

    (Operating System)
    |
    NIOSSLServerHandler
    |
    WebSocketFrameDecoder
    |
    PermessageDeflateDecompressor
    |
    WebSocketConnection
    |
    (Kitura/WebSocket application)

  • Outbound channel handler pipeline:

    (Kitura/WebSocket application)
    |
    PermessageDeflaterCompressor
    |
    WebSocketFrameEncoder
    |
    NIOSSLServerHandler
    |
    (Operating System)

HTTPDecoder and HTTPResponseEncoder convert bytes to HTTP requests, and responses to bytes, respectively. NIOSSLServerHandler is a duplex handler (both inbound and outbound) used to decrypt and encrypt data on a secure connection. The HTTPRequestHandler is used to invoke Kitura's router.

An upgrade to WebSocket causes SwiftNIO to alter the above pipeline in these ways:

  • the HTTPDecoder and HTTPResponseEncoder (and other HTTP related handlers) are removed from the pipeline
  • an inbound handler WebSocketFrameDecoder is added. It convert raw bytes, received on the wire, to WebSocket frames.
  • an outbound handler WebSocketFrameEncoder is added to convert WebSocket frames to raw bytes to be sent on the wire.

Additionally, Kitura-WebSocket-NIO makes the following changes to the pipeline:

  • adds WebSocketConnection, an inbound handler to process received WebSocket messages
  • if the permessage-deflate negotiation goes through, channel handlers WebSocketCompressor and WebSocketDecompressor which uses PermessageDeflateCompressor and PermessageDeflateDeCompressor respectively are added.

The pipelines now look like:

  • Inbound pipeline:

    (Operating System)
    |
    NIOSSLServerHandler
    |
    WebSocketFrameDecoder
    |
    PermessageDeflateDecompressor
    |
    WebSocketConnection
    |
    (Kitura/WebSocket application)

  • Outbound pipeline:

    (Kitura/WebSocket application)
    |
    PermessageDeflaterCompressor
    |
    WebSocketFrameEncoder
    |
    NIOSSLServerHandler
    |
    (Operating System)

WebSocketCompressor is an outbound handler used to compress outbound WebSocket messages. WebSocketDecompressor is an inbound handler used to decompress inbound WebSocket messages. Every WebSocket connection where a compression was negotiated, gets its own (WebSocketCompressor, WebSocketDecompressor) pair. These handlers currently use permessage-deflate for compression.

With this setup, all the inbound data first passes through SwiftNIO's WebSocketDecoder where the WebSocket frames are built. It then moves into the WebSocketDecompressor where multiple frames comprising a message are accumulated and decompressed using zlib's inflater. Subsequently, the decompressed messages are moved to the WebSocketConnection handler.

Outbound WebSocket frames first reach the WebSocketCompressor which compresses the data held within them and relays them to the WebSocketEncoder. Here the frames are marshalled into raw bytes to be written to the wire after encryption.

4.1 Compressor implementation

The compressor is called WebSocketCompressor. It is a ChannelOutboundHandler.

ChannelOutboundHandler's write(context:data:promise) method implemented here gets invoked when the previous outbound handler (WebSocketConnection) writes data to the channel. Here, only data frames and continuation frames are processed. A WebSocket message is either available in a single data frame or a data frame followed by sequence of continuation frames.

The compressor makes sure that we have all the data pertaining to a message accumulated. Subsequently, the deflater is invoked and deflated data is packed into a new WebSocketFrame, which is passed to the WebSocketFrameEncoder.

This library currently implements zlib's deflater PermessageDeflateCompressor which is passed as an argument to WebSocketCompressor for compression

4.2 Decompressor implementation

The decompressor is, functionally, a mirror image of the compressor. It is called WebSocketDeCompressor and is a ChannelInboundHandler.

ChannelInboundHandler's channelRead(context:data) method implemented here is invoked whenever a new WebSocketFrame is produced by the WebSocketDecoder. Similar to the compressor, the decompressor processes data and continuation frames only. All the data pertaining to a message, possibly spread across continuation frames, is accumulated and the inflater is invoked. The inflated data is then packed into a new WebSocketFrame and moved into the next handler in the pipeline.

This library currently implements zlib's inflater PermessageDeflateDeCompressor which is passed as an argument to WebSocketDeCompressor for decompression

4.3 Configuring the Compressor and Decompressor

RFC7692 defines four configuration options. They are actually two pairs of option, one each for the client and the server:

4.3.1 client_no_context_takeover and server_no_context_takeover

These allow the client and server to use a new zlib inflater or deflater on every message. By default we reuse the inflater and deflater instances across messages. This means the inflater and deflater are initialized only once. The memory allocation/deallocation happens only once and the history of the deflate/inflate stream can be reused. Here is an example.

In the Kitura-WebSocket-NIO implementation:

  • a client can inform the server that it isn't using context takeover by sending the client_no_context_takeover extension parameter. The server will respond with the same client_no_context_takeover parameter and configure its decompressor to not use context takeover.
  • a client can request the server to not use context takeover by sending the server_no_context_takeover extension parameter. The server will regard this request and configure its compressor to not use context takeover. It will add the server_no_context_takeover parameter in the response.
  • by default the server will configure its compressor and decompressor to use context takeover i.e. to reuse compression context
4.3.2 client_max_window_bits and server_max_window_bits

These allow the client and server to share the LZ77 sliding window size. The default value is 15 bits which represents a window size of 32768 (2^15). The client's compressor and the server's decompressor must have the same LZ77 window size. The same restriction applies to the server's compressor and the client's decompressor.

In the Kitura-WebSocket-NIO implementation:

  • a client can inform the server of its compressor's LZ77 window size using the client_max_window_bits extension parameter. If the parameter has a valid value, the server will configure its decompressor accordingly and send the same extension parameter in the response, indicating an agreement.
  • a client can also request the server to use a particular LZ77 window size using the server_max_window_bits extension parameter. If the value is valid, the server will configure its compressor accordingly and send the same extension parameter in the response, indicating an agreement.

5. Developer notes

  1. The PermessageDeflateCompressor and PermessageDeflaterDecompressor both consolidate multi-frame messages into a single frame. This loss of framing information may not be serious in typical use-cases. But there may be applications where framing information has to be maintained.

  2. As mentioned in one of the examples here, a client may supply fallback negotiation offers, in case negotiation fails. Kitura-WebSocket-NIO hasn't implemented this. We make sure every offer goes through.

  3. The LZ77 sliding window value must be passed as a negative parameter to deflateInit2()/inflateInit2(). This informs zlib that we use raw deflate streams (as against zlib streams that result with sliding window positive values). See this.

  4. There's an open zlib bug with a sliding window size of 8 bits. See this. There is a workaround for zlib streams. We implement a similar workaround for raw deflate streams here.

  5. Clients may negotiate for compression but send uncompressed frames. To handle this, just before decompressing, we check the RSV1 bit of the first frame to make sure it belongs to a compressed message.

  6. SwiftNIO offers a ChannelDuplexHandler type for channel handlers that are a part of both, the inbound and outbound pipelines. The WebSocketCompressor and WebSocketDecompressor could be merged into a single ChannelDuplexHandler.

Github

link
Stars: 1
Help us keep the lights on

Dependencies

Releases

0.1.0 - Nov 28, 2019

Initial release

  • Compression and Decompression channel handlers, WebSocketCompressor and WebSocketDeCompressor.
  • Protocols for inflater and deflater.
  • Implementation of PermessageDeflate inflater and deflater, PermessageDeflateDecompressor and PermessageDeflateCompressor.