pax_global_header00006660000000000000000000000064136654455670014537gustar00rootroot0000000000000052 comment=5ea0b420804ba6c4219e152b52fd3bdd6d144041 websocket-extensions-node-0.1.4/000077500000000000000000000000001366544556700166475ustar00rootroot00000000000000websocket-extensions-node-0.1.4/.gitignore000066400000000000000000000000371366544556700206370ustar00rootroot00000000000000node_modules package-lock.json websocket-extensions-node-0.1.4/.travis.yml000066400000000000000000000003771366544556700207670ustar00rootroot00000000000000sudo: false language: node_js node_js: - "0.8" - "0.10" - "0.12" - "4" - "5" - "6" - "7" - "8" - "9" - "10" - "11" - "12" - "13" - "14" before_install: - '[ "${TRAVIS_NODE_VERSION}" != "0.8" ] || npm install -g npm@~1.4.0' websocket-extensions-node-0.1.4/CHANGELOG.md000066400000000000000000000015731366544556700204660ustar00rootroot00000000000000### 0.1.4 / 2020-06-02 - Remove a ReDoS vulnerability in the header parser (CVE-2020-7662, reported by Robert McLaughlin) - Change license from MIT to Apache 2.0 ### 0.1.3 / 2017-11-11 - Accept extension names and parameters including uppercase letters - Handle extension names that clash with `Object.prototype` properties ### 0.1.2 / 2017-09-10 - Catch synchronous exceptions thrown when calling an extension - Fix race condition caused when a message is pushed after a cell has stopped due to an error - Fix failure of `close()` to return if a message that's queued after one that produces an error never finishes being processed ### 0.1.1 / 2015-02-19 - Prevent sessions being closed before they have finished processing messages - Add a callback to `Extensions.close()` so the caller can tell when it's safe to close the socket ### 0.1.0 / 2014-12-12 - Initial release websocket-extensions-node-0.1.4/CODE_OF_CONDUCT.md000066400000000000000000000002421366544556700214440ustar00rootroot00000000000000# Code of Conduct All projects under the [Faye](https://github.com/faye) umbrella are covered by the [Code of Conduct](https://github.com/faye/code-of-conduct). websocket-extensions-node-0.1.4/LICENSE.md000066400000000000000000000010561366544556700202550ustar00rootroot00000000000000Copyright 2014-2020 James Coglan Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. websocket-extensions-node-0.1.4/README.md000066400000000000000000000315461366544556700201370ustar00rootroot00000000000000# websocket-extensions [![Build status](https://secure.travis-ci.org/faye/websocket-extensions-node.svg)](http://travis-ci.org/faye/websocket-extensions-node) A minimal framework that supports the implementation of WebSocket extensions in a way that's decoupled from the main protocol. This library aims to allow a WebSocket extension to be written and used with any protocol library, by defining abstract representations of frames and messages that allow modules to co-operate. `websocket-extensions` provides a container for registering extension plugins, and provides all the functions required to negotiate which extensions to use during a session via the `Sec-WebSocket-Extensions` header. By implementing the APIs defined in this document, an extension may be used by any WebSocket library based on this framework. ## Installation ``` $ npm install websocket-extensions ``` ## Usage There are two main audiences for this library: authors implementing the WebSocket protocol, and authors implementing extensions. End users of a WebSocket library or an extension should be able to use any extension by passing it as an argument to their chosen protocol library, without needing to know how either of them work, or how the `websocket-extensions` framework operates. The library is designed with the aim that any protocol implementation and any extension can be used together, so long as they support the same abstract representation of frames and messages. ### Data types The APIs provided by the framework rely on two data types; extensions will expect to be given data and to be able to return data in these formats: #### *Frame* *Frame* is a structure representing a single WebSocket frame of any type. Frames are simple objects that must have at least the following properties, which represent the data encoded in the frame: | property | description | | ------------ | ------------------------------------------------------------------ | | `final` | `true` if the `FIN` bit is set, `false` otherwise | | `rsv1` | `true` if the `RSV1` bit is set, `false` otherwise | | `rsv2` | `true` if the `RSV2` bit is set, `false` otherwise | | `rsv3` | `true` if the `RSV3` bit is set, `false` otherwise | | `opcode` | the numeric opcode (`0`, `1`, `2`, `8`, `9`, or `10`) of the frame | | `masked` | `true` if the `MASK` bit is set, `false` otherwise | | `maskingKey` | a 4-byte `Buffer` if `masked` is `true`, otherwise `null` | | `payload` | a `Buffer` containing the (unmasked) application data | #### *Message* A *Message* represents a complete application message, which can be formed from text, binary and continuation frames. It has the following properties: | property | description | | -------- | ----------------------------------------------------------------- | | `rsv1` | `true` if the first frame of the message has the `RSV1` bit set | | `rsv2` | `true` if the first frame of the message has the `RSV2` bit set | | `rsv3` | `true` if the first frame of the message has the `RSV3` bit set | | `opcode` | the numeric opcode (`1` or `2`) of the first frame of the message | | `data` | the concatenation of all the frame payloads in the message | ### For driver authors A driver author is someone implementing the WebSocket protocol proper, and who wishes end users to be able to use WebSocket extensions with their library. At the start of a WebSocket session, on both the client and the server side, they should begin by creating an extension container and adding whichever extensions they want to use. ```js var Extensions = require('websocket-extensions'), deflate = require('permessage-deflate'); var exts = new Extensions(); exts.add(deflate); ``` In the following examples, `exts` refers to this `Extensions` instance. #### Client sessions Clients will use the methods `generateOffer()` and `activate(header)`. As part of the handshake process, the client must send a `Sec-WebSocket-Extensions` header to advertise that it supports the registered extensions. This header should be generated using: ```js request.headers['sec-websocket-extensions'] = exts.generateOffer(); ``` This returns a string, for example `"permessage-deflate; client_max_window_bits"`, that represents all the extensions the client is offering to use, and their parameters. This string may contain multiple offers for the same extension. When the client receives the handshake response from the server, it should pass the incoming `Sec-WebSocket-Extensions` header in to `exts` to activate the extensions the server has accepted: ```js exts.activate(response.headers['sec-websocket-extensions']); ``` If the server has sent any extension responses that the client does not recognize, or are in conflict with one another for use of RSV bits, or that use invalid parameters for the named extensions, then `exts.activate()` will `throw`. In this event, the client driver should fail the connection with closing code `1010`. #### Server sessions Servers will use the method `generateResponse(header)`. A server session needs to generate a `Sec-WebSocket-Extensions` header to send in its handshake response: ```js var clientOffer = request.headers['sec-websocket-extensions'], extResponse = exts.generateResponse(clientOffer); response.headers['sec-websocket-extensions'] = extResponse; ``` Calling `exts.generateResponse(header)` activates those extensions the client has asked to use, if they are registered, asks each extension for a set of response parameters, and returns a string containing the response parameters for all accepted extensions. #### In both directions Both clients and servers will use the methods `validFrameRsv(frame)`, `processIncomingMessage(message)` and `processOutgoingMessage(message)`. The WebSocket protocol requires that frames do not have any of the `RSV` bits set unless there is an extension in use that allows otherwise. When processing an incoming frame, sessions should pass a *Frame* object to: ```js exts.validFrameRsv(frame) ``` If this method returns `false`, the session should fail the WebSocket connection with closing code `1002`. To pass incoming messages through the extension stack, a session should construct a *Message* object according to the above datatype definitions, and call: ```js exts.processIncomingMessage(message, function(error, msg) { // hand the message off to the application }); ``` If any extensions fail to process the message, then the callback will yield an error and the session should fail the WebSocket connection with closing code `1010`. If `error` is `null`, then `msg` should be passed on to the application. To pass outgoing messages through the extension stack, a session should construct a *Message* as before, and call: ```js exts.processOutgoingMessage(message, function(error, msg) { // write message to the transport }); ``` If any extensions fail to process the message, then the callback will yield an error and the session should fail the WebSocket connection with closing code `1010`. If `error` is `null`, then `message` should be converted into frames (with the message's `rsv1`, `rsv2`, `rsv3` and `opcode` set on the first frame) and written to the transport. At the end of the WebSocket session (either when the protocol is explicitly ended or the transport connection disconnects), the driver should call: ```js exts.close(function() {}) ``` The callback is invoked when all extensions have finished processing any messages in the pipeline and it's safe to close the socket. ### For extension authors An extension author is someone implementing an extension that transforms WebSocket messages passing between the client and server. They would like to implement their extension once and have it work with any protocol library. Extension authors will not install `websocket-extensions` or call it directly. Instead, they should implement the following API to allow their extension to plug into the `websocket-extensions` framework. An `Extension` is any object that has the following properties: | property | description | | -------- | ---------------------------------------------------------------------------- | | `name` | a string containing the name of the extension as used in negotiation headers | | `type` | a string, must be `"permessage"` | | `rsv1` | either `true` if the extension uses the RSV1 bit, `false` otherwise | | `rsv2` | either `true` if the extension uses the RSV2 bit, `false` otherwise | | `rsv3` | either `true` if the extension uses the RSV3 bit, `false` otherwise | It must also implement the following methods: ```js ext.createClientSession() ``` This returns a *ClientSession*, whose interface is defined below. ```js ext.createServerSession(offers) ``` This takes an array of offer params and returns a *ServerSession*, whose interface is defined below. For example, if the client handshake contains the offer header: ``` Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; server_max_window_bits=8, \ permessage-deflate; server_max_window_bits=15 ``` then the `permessage-deflate` extension will receive the call: ```js ext.createServerSession([ { server_no_context_takeover: true, server_max_window_bits: 8 }, { server_max_window_bits: 15 } ]); ``` The extension must decide which set of parameters it wants to accept, if any, and return a *ServerSession* if it wants to accept the parameters and `null` otherwise. #### *ClientSession* A *ClientSession* is the type returned by `ext.createClientSession()`. It must implement the following methods, as well as the *Session* API listed below. ```js clientSession.generateOffer() // e.g. -> [ // { server_no_context_takeover: true, server_max_window_bits: 8 }, // { server_max_window_bits: 15 } // ] ``` This must return a set of parameters to include in the client's `Sec-WebSocket-Extensions` offer header. If the session wants to offer multiple configurations, it can return an array of sets of parameters as shown above. ```js clientSession.activate(params) // -> true ``` This must take a single set of parameters from the server's handshake response and use them to configure the client session. If the client accepts the given parameters, then this method must return `true`. If it returns any other value, the framework will interpret this as the client rejecting the response, and will `throw`. #### *ServerSession* A *ServerSession* is the type returned by `ext.createServerSession(offers)`. It must implement the following methods, as well as the *Session* API listed below. ```js serverSession.generateResponse() // e.g. -> { server_max_window_bits: 8 } ``` This returns the set of parameters the server session wants to send in its `Sec-WebSocket-Extensions` response header. Only one set of parameters is returned to the client per extension. Server sessions that would confict on their use of RSV bits are not activated. #### *Session* The *Session* API must be implemented by both client and server sessions. It contains two methods, `processIncomingMessage(message)` and `processOutgoingMessage(message)`. ```js session.processIncomingMessage(message, function(error, msg) { ... }) ``` The session must implement this method to take an incoming *Message* as defined above, transform it in any way it needs, then return it via the callback. If there is an error processing the message, this method should yield an error as the first argument. ```js session.processOutgoingMessage(message, function(error, msg) { ... }) ``` The session must implement this method to take an outgoing *Message* as defined above, transform it in any way it needs, then return it via the callback. If there is an error processing the message, this method should yield an error as the first argument. Note that both `processIncomingMessage()` and `processOutgoingMessage()` can perform their logic asynchronously, are allowed to process multiple messages concurrently, and are not required to complete working on messages in the same order the messages arrive. `websocket-extensions` will reorder messages as your extension emits them and will make sure every extension is given messages in the order they arrive from the driver. This allows extensions to maintain state that depends on the messages' wire order, for example keeping a DEFLATE compression context between messages. ```js session.close() ``` The framework will call this method when the WebSocket session ends, allowing the session to release any resources it's using. ## Examples - Consumer: [websocket-driver](https://github.com/faye/websocket-driver-node) - Provider: [permessage-deflate](https://github.com/faye/permessage-deflate-node) websocket-extensions-node-0.1.4/lib/000077500000000000000000000000001366544556700174155ustar00rootroot00000000000000websocket-extensions-node-0.1.4/lib/parser.js000066400000000000000000000056001366544556700212500ustar00rootroot00000000000000'use strict'; var TOKEN = /([!#\$%&'\*\+\-\.\^_`\|~0-9A-Za-z]+)/, NOTOKEN = /([^!#\$%&'\*\+\-\.\^_`\|~0-9A-Za-z])/g, QUOTED = /"((?:\\[\x00-\x7f]|[^\x00-\x08\x0a-\x1f\x7f"\\])*)"/, PARAM = new RegExp(TOKEN.source + '(?:=(?:' + TOKEN.source + '|' + QUOTED.source + '))?'), EXT = new RegExp(TOKEN.source + '(?: *; *' + PARAM.source + ')*', 'g'), EXT_LIST = new RegExp('^' + EXT.source + '(?: *, *' + EXT.source + ')*$'), NUMBER = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?$/; var hasOwnProperty = Object.prototype.hasOwnProperty; var Parser = { parseHeader: function(header) { var offers = new Offers(); if (header === '' || header === undefined) return offers; if (!EXT_LIST.test(header)) throw new SyntaxError('Invalid Sec-WebSocket-Extensions header: ' + header); var values = header.match(EXT); values.forEach(function(value) { var params = value.match(new RegExp(PARAM.source, 'g')), name = params.shift(), offer = {}; params.forEach(function(param) { var args = param.match(PARAM), key = args[1], data; if (args[2] !== undefined) { data = args[2]; } else if (args[3] !== undefined) { data = args[3].replace(/\\/g, ''); } else { data = true; } if (NUMBER.test(data)) data = parseFloat(data); if (hasOwnProperty.call(offer, key)) { offer[key] = [].concat(offer[key]); offer[key].push(data); } else { offer[key] = data; } }, this); offers.push(name, offer); }, this); return offers; }, serializeParams: function(name, params) { var values = []; var print = function(key, value) { if (value instanceof Array) { value.forEach(function(v) { print(key, v) }); } else if (value === true) { values.push(key); } else if (typeof value === 'number') { values.push(key + '=' + value); } else if (NOTOKEN.test(value)) { values.push(key + '="' + value.replace(/"/g, '\\"') + '"'); } else { values.push(key + '=' + value); } }; for (var key in params) print(key, params[key]); return [name].concat(values).join('; '); } }; var Offers = function() { this._byName = {}; this._inOrder = []; }; Offers.prototype.push = function(name, params) { if (!hasOwnProperty.call(this._byName, name)) this._byName[name] = []; this._byName[name].push(params); this._inOrder.push({ name: name, params: params }); }; Offers.prototype.eachOffer = function(callback, context) { var list = this._inOrder; for (var i = 0, n = list.length; i < n; i++) callback.call(context, list[i].name, list[i].params); }; Offers.prototype.byName = function(name) { return this._byName[name] || []; }; Offers.prototype.toArray = function() { return this._inOrder.slice(); }; module.exports = Parser; websocket-extensions-node-0.1.4/lib/pipeline/000077500000000000000000000000001366544556700212225ustar00rootroot00000000000000websocket-extensions-node-0.1.4/lib/pipeline/README.md000066400000000000000000000602041366544556700225030ustar00rootroot00000000000000# Extension pipelining `websocket-extensions` models the extension negotiation and processing pipeline of the WebSocket protocol. Between the driver parsing messages from the TCP stream and handing those messages off to the application, there may exist a stack of extensions that transform the message somehow. In the parlance of this framework, a *session* refers to a single instance of an extension, acting on a particular socket on either the server or the client side. A session may transform messages both incoming to the application and outgoing from the application, for example the `permessage-deflate` extension compresses outgoing messages and decompresses incoming messages. Message streams in either direction are independent; that is, incoming and outgoing messages cannot be assumed to 'pair up' as in a request-response protocol. Asynchronous processing of messages poses a number of problems that this pipeline construction is intended to solve. ## Overview Logically, we have the following: +-------------+ out +---+ +---+ +---+ +--------+ | |------>| |---->| |---->| |------>| | | Application | | A | | B | | C | | Driver | | |<------| |<----| |<----| |<------| | +-------------+ in +---+ +---+ +---+ +--------+ \ / +----------o----------+ | sessions For outgoing messages, the driver receives the result of C.outgoing(B.outgoing(A.outgoing(message))) or, [A, B, C].reduce(((m, ext) => ext.outgoing(m)), message) For incoming messages, the application receives the result of A.incoming(B.incoming(C.incoming(message))) or, [C, B, A].reduce(((m, ext) => ext.incoming(m)), message) A session is of the following type, to borrow notation from pseudo-Haskell: type Session = { incoming :: Message -> Message outgoing :: Message -> Message close :: () -> () } (That `() -> ()` syntax is intended to mean that `close()` is a nullary void method; I apologise to any Haskell readers for not using the right monad.) The `incoming()` and `outgoing()` methods perform message transformation in the respective directions; `close()` is called when a socket closes so the session can release any resources it's holding, for example a DEFLATE de/compression context. However because this is JavaScript, the `incoming()` and `outgoing()` methods may be asynchronous (indeed, `permessage-deflate` is based on `zlib`, whose API is stream-based). So their interface is strictly: type Session = { incoming :: Message -> Callback -> () outgoing :: Message -> Callback -> () close :: () -> () } type Callback = Either Error Message -> () This means a message *m2* can be pushed into a session while it's still processing the preceding message *m1*. The messages can be processed concurrently but they *must* be given to the next session in line (or to the application) in the same order they came in. Applications will expect to receive messages in the order they arrived over the wire, and sessions require this too. So ordering of messages must be preserved throughout the pipeline. Consider the following highly simplified extension that deflates messages on the wire. `message` is a value conforming the type: type Message = { rsv1 :: Boolean rsv2 :: Boolean rsv3 :: Boolean opcode :: Number data :: Buffer } Here's the extension: ```js var zlib = require('zlib'); var deflate = { outgoing: function(message, callback) { zlib.deflateRaw(message.data, function(error, result) { message.rsv1 = true; message.data = result; callback(error, message); }); }, incoming: function(message, callback) { // decompress inbound messages (elided) }, close: function() { // no state to clean up } }; ``` We can call it with a large message followed by a small one, and the small one will be returned first: ```js var crypto = require('crypto'), large = crypto.randomBytes(1 << 14), small = new Buffer('hi'); deflate.outgoing({ data: large }, function() { console.log(1, 'large'); }); deflate.outgoing({ data: small }, function() { console.log(2, 'small'); }); /* prints: 2 'small' 1 'large' */ ``` So a session that processes messages asynchronously may fail to preserve message ordering. Now, this extension is stateless, so it can process messages in any order and still produce the same output. But some extensions are stateful and require message order to be preserved. For example, when using `permessage-deflate` without `no_context_takeover` set, the session retains a DEFLATE de/compression context between messages, which accumulates state as it consumes data (later messages can refer to sections of previous ones to improve compression). Reordering parts of the DEFLATE stream will result in a failed decompression. Messages must be decompressed in the same order they were compressed by the peer in order for the DEFLATE protocol to work. Finally, there is the problem of closing a socket. When a WebSocket is closed by the application, or receives a closing request from the other peer, there may be messages outgoing from the application and incoming from the peer in the pipeline. If we close the socket and pipeline immediately, two problems arise: * We may send our own closing frame to the peer before all prior messages we sent have been written to the socket, and before we have finished processing all prior messages from the peer * The session may be instructed to close its resources (e.g. its de/compression context) while it's in the middle of processing a message, or before it has received messages that are upstream of it in the pipeline Essentially, we must defer closing the sessions and sending a closing frame until after all prior messages have exited the pipeline. ## Design goals * Message order must be preserved between the protocol driver, the extension sessions, and the application * Messages should be handed off to sessions and endpoints as soon as possible, to maximise throughput of stateless sessions * The closing procedure should block any further messages from entering the pipeline, and should allow all existing messages to drain * Sessions should be closed as soon as possible to prevent them holding memory and other resources when they have no more messages to handle * The closing API should allow the caller to detect when the pipeline is empty and it is safe to continue the WebSocket closing procedure * Individual extensions should remain as simple as possible to facilitate modularity and independent authorship The final point about modularity is an important one: this framework is designed to facilitate extensions existing as plugins, by decoupling the protocol driver, extensions, and application. In an ideal world, plugins should only need to contain code for their specific functionality, and not solve these problems that apply to all sessions. Also, solving some of these problems requires consideration of all active sessions collectively, which an individual session is incapable of doing. For example, it is entirely possible to take the simple `deflate` extension above and wrap its `incoming()` and `outgoing()` methods in two `Transform` streams, producing this type: type Session = { incoming :: TransformStream outtoing :: TransformStream close :: () -> () } The `Transform` class makes it easy to wrap an async function such that message order is preserved: ```js var stream = require('stream'), session = new stream.Transform({ objectMode: true }); session._transform = function(message, _, callback) { var self = this; deflate.outgoing(message, function(error, result) { self.push(result); callback(); }); }; ``` However, this has a negative impact on throughput: it works by deferring `callback()` until the async function has 'returned', which blocks `Transform` from passing further input into the `_transform()` method until the current message is dealt with completely. This would prevent sessions from processing messages concurrently, and would unnecessarily reduce the throughput of stateless extensions. So, input should be handed off to sessions as soon as possible, and all we need is a mechanism to reorder the output so that message order is preserved for the next session in line. ## Solution We now describe the model implemented here and how it meets the above design goals. The above diagram where a stack of extensions sit between the driver and application describes the data flow, but not the object graph. That looks like this: +--------+ | Driver | +---o----+ | V +------------+ +----------+ | Extensions o----->| Pipeline | +------------+ +-----o----+ | +---------------+---------------+ | | | +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ A driver using this framework holds an instance of the `Extensions` class, which it uses to register extension plugins, negotiate headers and transform messages. The `Extensions` instance itself holds a `Pipeline`, which contains an array of `Cell` objects, each of which wraps one of the sessions. ### Message processing Both the `Pipeline` and `Cell` classes have `incoming()` and `outgoing()` methods; the `Pipeline` interface pushes messages into the pipe, delegates the message to each `Cell` in turn, then returns it back to the driver. Outgoing messages pass through `A` then `B` then `C`, and incoming messages in the reverse order. Internally, a `Cell` contains two `Functor` objects. A `Functor` wraps an async function and makes sure its output messages maintain the order of its input messages. This name is due to [@fronx](https://github.com/fronx), on the basis that, by preserving message order, the abstraction preserves the *mapping* between input and output messages. To use our simple `deflate` extension from above: ```js var functor = new Functor(deflate, 'outgoing'); functor.call({ data: large }, function() { console.log(1, 'large'); }); functor.call({ data: small }, function() { console.log(2, 'small'); }); /* -> 1 'large' 2 'small' */ ``` A `Cell` contains two of these, one for each direction: +-----------------------+ +---->| Functor [A, incoming] | +----------+ | +-----------------------+ | Cell [A] o------+ +----------+ | +-----------------------+ +---->| Functor [A, outgoing] | +-----------------------+ This satisfies the message transformation requirements: the `Pipeline` simply loops over the cells in the appropriate direction to transform each message. Because each `Cell` will preserve message order, we can pass a message to the next `Cell` in line as soon as the current `Cell` returns it. This gives each `Cell` all the messages in order while maximising throughput. ### Session closing We want to close each session as soon as possible, after all existing messages have drained. To do this, each `Cell` begins with a pending message counter in each direction, labelled `in` and `out` below. +----------+ | Pipeline | +-----o----+ | +---------------+---------------+ | | | +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 0 out: 0 out: 0 When a message *m1* enters the pipeline, say in the `outgoing` direction, we increment the `pending.out` counter on all cells immediately. +----------+ m1 => | Pipeline | +-----o----+ | +---------------+---------------+ | | | +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 1 out: 1 out: 1 *m1* is handed off to `A`, meanwhile a second message `m2` arrives in the same direction. All `pending.out` counters are again incremented. +----------+ m2 => | Pipeline | +-----o----+ | +---------------+---------------+ m1 | | | +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 2 out: 2 out: 2 When the first cell's `A.outgoing` functor finishes processing *m1*, the first `pending.out` counter is decremented and *m1* is handed off to cell `B`. +----------+ | Pipeline | +-----o----+ | +---------------+---------------+ m2 | m1 | | +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 1 out: 2 out: 2 As `B` finishes with *m1*, and as `A` finishes with *m2*, the `pending.out` counters continue to decrement. +----------+ | Pipeline | +-----o----+ | +---------------+---------------+ | m2 | m1 | +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 0 out: 1 out: 2 Say `C` is a little slow, and begins processing *m2* while still processing *m1*. That's fine, the `Functor` mechanism will keep *m1* ahead of *m2* in the output. +----------+ | Pipeline | +-----o----+ | +---------------+---------------+ | | m2 | m1 +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 0 out: 0 out: 2 Once all messages are dealt with, the counters return to `0`. +----------+ | Pipeline | +-----o----+ | +---------------+---------------+ | | | +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 0 out: 0 out: 0 The same process applies in the `incoming` direction, the only difference being that messages are passed to `C` first. This makes closing the sessions quite simple. When the driver wants to close the socket, it calls `Pipeline.close()`. This *immediately* calls `close()` on all the cells. If a cell has `in == out == 0`, then it immediately calls `session.close()`. Otherwise, it stores the closing call and defers it until `in` and `out` have both ticked down to zero. The pipeline will not accept new messages after `close()` has been called, so we know the pending counts will not increase after this point. This means each session is closed as soon as possible: `A` can close while the slow `C` session is still working, because it knows there are no more messages on the way. Similarly, `C` will defer closing if `close()` is called while *m1* is still in `B`, and *m2* in `A`, because its pending count means it knows it has work yet to do, even if it's not received those messages yet. This concern cannot be addressed by extensions acting only on their own local state, unless we pollute individual extensions by making them all implement this same mechanism. The actual closing API at each level is slightly different: type Session = { close :: () -> () } type Cell = { close :: () -> Promise () } type Pipeline = { close :: Callback -> () } This might appear inconsistent so it's worth explaining. Remember that a `Pipeline` holds a list of `Cell` objects, each wrapping a `Session`. The driver talks (via the `Extensions` API) to the `Pipeline` interface, and it wants `Pipeline.close()` to do two things: close all the sessions, and tell me when it's safe to start the closing procedure (i.e. when all messages have drained from the pipe and been handed off to the application or socket). A callback API works well for that. At the other end of the stack, `Session.close()` is a nullary void method with no callback or promise API because we don't care what it does, and whatever it does do will not block the WebSocket protocol; we're not going to hold off processing messages while a session closes its de/compression context. We just tell it to close itself, and don't want to wait while it does that. In the middle, `Cell.close()` returns a promise rather than using a callback. This is for two reasons. First, `Cell.close()` might not do anything immediately, it might have to defer its effect while messages drain. So, if given a callback, it would have to store it in a queue for later execution. Callbacks work fine if your method does something and can then invoke the callback itself, but if you need to store callbacks somewhere so another method can execute them, a promise is a better fit. Second, it better serves the purposes of `Pipeline.close()`: it wants to call `close()` on each of a list of cells, and wait for all of them to finish. This is simple and idiomatic using promises: ```js var closed = cells.map((cell) => cell.close()); Promise.all(closed).then(callback); ``` (We don't actually use a full *Promises/A+* compatible promise here, we use a much simplified construction that acts as a callback aggregater and resolves synchronously and does not support chaining, but the principle is the same.) ### Error handling We've not mentioned error handling so far but it bears some explanation. The above counter system still applies, but behaves slightly differently in the presence of errors. Say we push three messages into the pipe in the outgoing direction: +----------+ m3, m2, m1 => | Pipeline | +-----o----+ | +---------------+---------------+ | | | +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 3 out: 3 out: 3 They pass through the cells successfully up to this point: +----------+ | Pipeline | +-----o----+ | +---------------+---------------+ m3 | m2 | m1 | +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 1 out: 2 out: 3 At this point, session `B` produces an error while processing *m2*, that is *m2* becomes *e2*. *m1* is still in the pipeline, and *m3* is queued behind *m2*. What ought to happen is that *m1* is handed off to the socket, then *m2* is released to the driver, which will detect the error and begin closing the socket. No further processing should be done on *m3* and it should not be released to the driver after the error is emitted. To handle this, we allow errors to pass down the pipeline just like messages do, to maintain ordering. But, once a cell sees its session produce an error, or it receives an error from upstream, it should refuse to accept any further messages. Session `B` might have begun processing *m3* by the time it produces the error *e2*, but `C` will have been given *e2* before it receives *m3*, and can simply drop *m3*. Now, say *e2* reaches the slow session `C` while *m1* is still present, meanwhile *m3* has been dropped. `C` will never receive *m3* since it will have been dropped upstream. Under the present model, its `out` counter will be `3` but it is only going to emit two more values: *m1* and *e2*. In order for closing to work, we need to decrement `out` to reflect this. The situation should look like this: +----------+ | Pipeline | +-----o----+ | +---------------+---------------+ | | e2 | m1 +-----o----+ +-----o----+ +-----o----+ | Cell [A] | | Cell [B] | | Cell [C] | +----------+ +----------+ +----------+ in: 0 in: 0 in: 0 out: 0 out: 0 out: 2 When a cell sees its session emit an error, or when it receives an error from upstream, it sets its pending count in the appropriate direction to equal the number of messages it is *currently* processing. It will not accept any messages after it sees the error, so this will allow the counter to reach zero. Note that while *e2* is in the pipeline, `Pipeline` should drop any further messages in the outgoing direction, but should continue to accept incoming messages. Until *e2* makes it out of the pipe to the driver, behind previous successful messages, the driver does not know an error has happened, and a message may arrive over the socket and make it all the way through the incoming pipe in the meantime. We only halt processing in the affected direction to avoid doing unnecessary work since messages arriving after an error should not be processed. Some unnecessary work may happen, for example any messages already in the pipeline following *m2* will be processed by `A`, since it's upstream of the error. Those messages will be dropped by `B`. ## Alternative ideas I am considering implementing `Functor` as an object-mode transform stream rather than what is essentially an async function. Being object-mode, a stream would preserve message boundaries and would also possibly help address back-pressure. I'm not sure whether this would require external API changes so that such streams could be connected to the downstream driver's streams. ## Acknowledgements Credit is due to [@mnowster](https://github.com/mnowster) for helping with the design and to [@fronx](https://github.com/fronx) for helping name things. websocket-extensions-node-0.1.4/lib/pipeline/cell.js000066400000000000000000000026761366544556700225120ustar00rootroot00000000000000'use strict'; var Functor = require('./functor'), Pledge = require('./pledge'); var Cell = function(tuple) { this._ext = tuple[0]; this._session = tuple[1]; this._functors = { incoming: new Functor(this._session, 'processIncomingMessage'), outgoing: new Functor(this._session, 'processOutgoingMessage') }; }; Cell.prototype.pending = function(direction) { var functor = this._functors[direction]; if (!functor._stopped) functor.pending += 1; }; Cell.prototype.incoming = function(error, message, callback, context) { this._exec('incoming', error, message, callback, context); }; Cell.prototype.outgoing = function(error, message, callback, context) { this._exec('outgoing', error, message, callback, context); }; Cell.prototype.close = function() { this._closed = this._closed || new Pledge(); this._doClose(); return this._closed; }; Cell.prototype._exec = function(direction, error, message, callback, context) { this._functors[direction].call(error, message, function(err, msg) { if (err) err.message = this._ext.name + ': ' + err.message; callback.call(context, err, msg); this._doClose(); }, this); }; Cell.prototype._doClose = function() { var fin = this._functors.incoming, fout = this._functors.outgoing; if (!this._closed || fin.pending + fout.pending !== 0) return; if (this._session) this._session.close(); this._session = null; this._closed.done(); }; module.exports = Cell; websocket-extensions-node-0.1.4/lib/pipeline/functor.js000066400000000000000000000027621366544556700232470ustar00rootroot00000000000000'use strict'; var RingBuffer = require('./ring_buffer'); var Functor = function(session, method) { this._session = session; this._method = method; this._queue = new RingBuffer(Functor.QUEUE_SIZE); this._stopped = false; this.pending = 0; }; Functor.QUEUE_SIZE = 8; Functor.prototype.call = function(error, message, callback, context) { if (this._stopped) return; var record = { error: error, message: message, callback: callback, context: context, done: false }, called = false, self = this; this._queue.push(record); if (record.error) { record.done = true; this._stop(); return this._flushQueue(); } var handler = function(err, msg) { if (!(called ^ (called = true))) return; if (err) { self._stop(); record.error = err; record.message = null; } else { record.message = msg; } record.done = true; self._flushQueue(); }; try { this._session[this._method](message, handler); } catch (err) { handler(err); } }; Functor.prototype._stop = function() { this.pending = this._queue.length; this._stopped = true; }; Functor.prototype._flushQueue = function() { var queue = this._queue, record; while (queue.length > 0 && queue.peek().done) { record = queue.shift(); if (record.error) { this.pending = 0; queue.clear(); } else { this.pending -= 1; } record.callback.call(record.context, record.error, record.message); } }; module.exports = Functor; websocket-extensions-node-0.1.4/lib/pipeline/index.js000066400000000000000000000027101366544556700226670ustar00rootroot00000000000000'use strict'; var Cell = require('./cell'), Pledge = require('./pledge'); var Pipeline = function(sessions) { this._cells = sessions.map(function(session) { return new Cell(session) }); this._stopped = { incoming: false, outgoing: false }; }; Pipeline.prototype.processIncomingMessage = function(message, callback, context) { if (this._stopped.incoming) return; this._loop('incoming', this._cells.length - 1, -1, -1, message, callback, context); }; Pipeline.prototype.processOutgoingMessage = function(message, callback, context) { if (this._stopped.outgoing) return; this._loop('outgoing', 0, this._cells.length, 1, message, callback, context); }; Pipeline.prototype.close = function(callback, context) { this._stopped = { incoming: true, outgoing: true }; var closed = this._cells.map(function(a) { return a.close() }); if (callback) Pledge.all(closed).then(function() { callback.call(context) }); }; Pipeline.prototype._loop = function(direction, start, end, step, message, callback, context) { var cells = this._cells, n = cells.length, self = this; while (n--) cells[n].pending(direction); var pipe = function(index, error, msg) { if (index === end) return callback.call(context, error, msg); cells[index][direction](error, msg, function(err, m) { if (err) self._stopped[direction] = true; pipe(index + step, err, m); }); }; pipe(start, null, message); }; module.exports = Pipeline; websocket-extensions-node-0.1.4/lib/pipeline/pledge.js000066400000000000000000000014131366544556700230170ustar00rootroot00000000000000'use strict'; var RingBuffer = require('./ring_buffer'); var Pledge = function() { this._complete = false; this._callbacks = new RingBuffer(Pledge.QUEUE_SIZE); }; Pledge.QUEUE_SIZE = 4; Pledge.all = function(list) { var pledge = new Pledge(), pending = list.length, n = pending; if (pending === 0) pledge.done(); while (n--) list[n].then(function() { pending -= 1; if (pending === 0) pledge.done(); }); return pledge; }; Pledge.prototype.then = function(callback) { if (this._complete) callback(); else this._callbacks.push(callback); }; Pledge.prototype.done = function() { this._complete = true; var callbacks = this._callbacks, callback; while (callback = callbacks.shift()) callback(); }; module.exports = Pledge; websocket-extensions-node-0.1.4/lib/pipeline/ring_buffer.js000066400000000000000000000032071366544556700240520ustar00rootroot00000000000000'use strict'; var RingBuffer = function(bufferSize) { this._bufferSize = bufferSize; this.clear(); }; RingBuffer.prototype.clear = function() { this._buffer = new Array(this._bufferSize); this._ringOffset = 0; this._ringSize = this._bufferSize; this._head = 0; this._tail = 0; this.length = 0; }; RingBuffer.prototype.push = function(value) { var expandBuffer = false, expandRing = false; if (this._ringSize < this._bufferSize) { expandBuffer = (this._tail === 0); } else if (this._ringOffset === this._ringSize) { expandBuffer = true; expandRing = (this._tail === 0); } if (expandBuffer) { this._tail = this._bufferSize; this._buffer = this._buffer.concat(new Array(this._bufferSize)); this._bufferSize = this._buffer.length; if (expandRing) this._ringSize = this._bufferSize; } this._buffer[this._tail] = value; this.length += 1; if (this._tail < this._ringSize) this._ringOffset += 1; this._tail = (this._tail + 1) % this._bufferSize; }; RingBuffer.prototype.peek = function() { if (this.length === 0) return void 0; return this._buffer[this._head]; }; RingBuffer.prototype.shift = function() { if (this.length === 0) return void 0; var value = this._buffer[this._head]; this._buffer[this._head] = void 0; this.length -= 1; this._ringOffset -= 1; if (this._ringOffset === 0 && this.length > 0) { this._head = this._ringSize; this._ringOffset = this.length; this._ringSize = this._bufferSize; } else { this._head = (this._head + 1) % this._ringSize; } return value; }; module.exports = RingBuffer; websocket-extensions-node-0.1.4/lib/websocket_extensions.js000066400000000000000000000114011366544556700242150ustar00rootroot00000000000000'use strict'; var Parser = require('./parser'), Pipeline = require('./pipeline'); var Extensions = function() { this._rsv1 = this._rsv2 = this._rsv3 = null; this._byName = {}; this._inOrder = []; this._sessions = []; this._index = {}; }; Extensions.MESSAGE_OPCODES = [1, 2]; var instance = { add: function(ext) { if (typeof ext.name !== 'string') throw new TypeError('extension.name must be a string'); if (ext.type !== 'permessage') throw new TypeError('extension.type must be "permessage"'); if (typeof ext.rsv1 !== 'boolean') throw new TypeError('extension.rsv1 must be true or false'); if (typeof ext.rsv2 !== 'boolean') throw new TypeError('extension.rsv2 must be true or false'); if (typeof ext.rsv3 !== 'boolean') throw new TypeError('extension.rsv3 must be true or false'); if (this._byName.hasOwnProperty(ext.name)) throw new TypeError('An extension with name "' + ext.name + '" is already registered'); this._byName[ext.name] = ext; this._inOrder.push(ext); }, generateOffer: function() { var sessions = [], offer = [], index = {}; this._inOrder.forEach(function(ext) { var session = ext.createClientSession(); if (!session) return; var record = [ext, session]; sessions.push(record); index[ext.name] = record; var offers = session.generateOffer(); offers = offers ? [].concat(offers) : []; offers.forEach(function(off) { offer.push(Parser.serializeParams(ext.name, off)); }, this); }, this); this._sessions = sessions; this._index = index; return offer.length > 0 ? offer.join(', ') : null; }, activate: function(header) { var responses = Parser.parseHeader(header), sessions = []; responses.eachOffer(function(name, params) { var record = this._index[name]; if (!record) throw new Error('Server sent an extension response for unknown extension "' + name + '"'); var ext = record[0], session = record[1], reserved = this._reserved(ext); if (reserved) throw new Error('Server sent two extension responses that use the RSV' + reserved[0] + ' bit: "' + reserved[1] + '" and "' + ext.name + '"'); if (session.activate(params) !== true) throw new Error('Server sent unacceptable extension parameters: ' + Parser.serializeParams(name, params)); this._reserve(ext); sessions.push(record); }, this); this._sessions = sessions; this._pipeline = new Pipeline(sessions); }, generateResponse: function(header) { var sessions = [], response = [], offers = Parser.parseHeader(header); this._inOrder.forEach(function(ext) { var offer = offers.byName(ext.name); if (offer.length === 0 || this._reserved(ext)) return; var session = ext.createServerSession(offer); if (!session) return; this._reserve(ext); sessions.push([ext, session]); response.push(Parser.serializeParams(ext.name, session.generateResponse())); }, this); this._sessions = sessions; this._pipeline = new Pipeline(sessions); return response.length > 0 ? response.join(', ') : null; }, validFrameRsv: function(frame) { var allowed = { rsv1: false, rsv2: false, rsv3: false }, ext; if (Extensions.MESSAGE_OPCODES.indexOf(frame.opcode) >= 0) { for (var i = 0, n = this._sessions.length; i < n; i++) { ext = this._sessions[i][0]; allowed.rsv1 = allowed.rsv1 || ext.rsv1; allowed.rsv2 = allowed.rsv2 || ext.rsv2; allowed.rsv3 = allowed.rsv3 || ext.rsv3; } } return (allowed.rsv1 || !frame.rsv1) && (allowed.rsv2 || !frame.rsv2) && (allowed.rsv3 || !frame.rsv3); }, processIncomingMessage: function(message, callback, context) { this._pipeline.processIncomingMessage(message, callback, context); }, processOutgoingMessage: function(message, callback, context) { this._pipeline.processOutgoingMessage(message, callback, context); }, close: function(callback, context) { if (!this._pipeline) return callback.call(context); this._pipeline.close(callback, context); }, _reserve: function(ext) { this._rsv1 = this._rsv1 || (ext.rsv1 && ext.name); this._rsv2 = this._rsv2 || (ext.rsv2 && ext.name); this._rsv3 = this._rsv3 || (ext.rsv3 && ext.name); }, _reserved: function(ext) { if (this._rsv1 && ext.rsv1) return [1, this._rsv1]; if (this._rsv2 && ext.rsv2) return [2, this._rsv2]; if (this._rsv3 && ext.rsv3) return [3, this._rsv3]; return false; } }; for (var key in instance) Extensions.prototype[key] = instance[key]; module.exports = Extensions; websocket-extensions-node-0.1.4/package.json000066400000000000000000000017371366544556700211450ustar00rootroot00000000000000{ "name": "websocket-extensions", "description": "Generic extension manager for WebSocket connections", "homepage": "http://github.com/faye/websocket-extensions-node", "author": "James Coglan (http://jcoglan.com/)", "keywords": [ "websocket" ], "license": "Apache-2.0", "version": "0.1.4", "engines": { "node": ">=0.8.0" }, "files": [ "lib" ], "main": "./lib/websocket_extensions", "devDependencies": { "jstest": "*" }, "scripts": { "test": "jstest spec/runner.js" }, "repository": { "type": "git", "url": "git://github.com/faye/websocket-extensions-node.git" }, "bugs": "http://github.com/faye/websocket-extensions-node/issues" } websocket-extensions-node-0.1.4/spec/000077500000000000000000000000001366544556700176015ustar00rootroot00000000000000websocket-extensions-node-0.1.4/spec/parser_spec.js000066400000000000000000000077761366544556700224660ustar00rootroot00000000000000var Parser = require('../lib/parser'), test = require('jstest').Test test.describe("Parser", function() { with(this) { describe("parseHeader", function() { with(this) { define("parse", function(string) { return Parser.parseHeader(string).toArray() }) it("parses an empty header", function() { with(this) { assertEqual( [], parse('') ) }}) it("parses a missing header", function() { with(this) { assertEqual( [], parse(undefined) ) }}) it("throws on invalid input", function() { with(this) { assertThrows(SyntaxError, function() { parse('a,') }) }}) it("parses one offer with no params", function() { with(this) { assertEqual( [{ name: "a", params: {}}], parse('a') ) }}) it("parses two offers with no params", function() { with(this) { assertEqual( [{ name: "a", params: {}}, { name: "b", params: {}}], parse('a, b') ) }}) it("parses a duplicate offer name", function() { with(this) { assertEqual( [{ name: "a", params: {}}, { name: "a", params: {}}], parse('a, a') ) }}) it("parses a flag", function() { with(this) { assertEqual( [{ name: "a", params: { b: true }}], parse('a; b') ) }}) it("parses an unquoted param", function() { with(this) { assertEqual( [{ name: "a", params: { b: 1 }}], parse('a; b=1') ) }}) it("parses a quoted param", function() { with(this) { assertEqual( [{ name: "a", params: { b: 'hi, "there' }}], parse('a; b="hi, \\"there"') ) }}) it("parses multiple params", function() { with(this) { assertEqual( [{ name: "a", params: { b: true, c: 1, d: 'hi' }}], parse('a; b; c=1; d="hi"') ) }}) it("parses duplicate params", function() { with(this) { assertEqual( [{ name: "a", params: { b: [true, 'hi'], c: 1 }}], parse('a; b; c=1; b="hi"') ) }}) it("parses multiple complex offers", function() { with(this) { assertEqual( [{ name: "a", params: { b: 1 }}, { name: "c", params: {}}, { name: "b", params: { d: true }}, { name: "c", params: { e: ['hi, there', true] }}, { name: "a", params: { b: true }}], parse('a; b=1, c, b; d, c; e="hi, there"; e, a; b') ) }}) it("parses an extension name that shadows an Object property", function() { with(this) { assertEqual( [{ name: "hasOwnProperty", params: {}}], parse('hasOwnProperty') ) }}) it("parses an extension param that shadows an Object property", function() { with(this) { var result = parse('foo; hasOwnProperty; x')[0] assertEqual( result.params.hasOwnProperty, true ) }}) it("rejects a string missing its closing quote", function() { with(this) { assertThrows(SyntaxError, function() { parse('foo; bar="fooa\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a') }) }}) }}) describe("serializeParams", function() { with(this) { it("serializes empty params", function() { with(this) { assertEqual( 'a', Parser.serializeParams('a', {}) ) }}) it("serializes a flag", function() { with(this) { assertEqual( 'a; b', Parser.serializeParams('a', { b: true }) ) }}) it("serializes an unquoted param", function() { with(this) { assertEqual( 'a; b=42', Parser.serializeParams('a', { b: '42' }) ) }}) it("serializes a quoted param", function() { with(this) { assertEqual( 'a; b="hi, there"', Parser.serializeParams('a', { b: 'hi, there' }) ) }}) it("serializes multiple params", function() { with(this) { assertEqual( 'a; b; c=1; d=hi', Parser.serializeParams('a', { b: true, c: 1, d: 'hi' }) ) }}) it("serializes duplicate params", function() { with(this) { assertEqual( 'a; b; b=hi; c=1', Parser.serializeParams('a', { b: [true, 'hi'], c: 1 }) ) }}) }}) }}) websocket-extensions-node-0.1.4/spec/runner.js000066400000000000000000000001001366544556700214370ustar00rootroot00000000000000require('./parser_spec') require('./websocket_extensions_spec') websocket-extensions-node-0.1.4/spec/websocket_extensions_spec.js000066400000000000000000000505431366544556700254250ustar00rootroot00000000000000var Extensions = require("../lib/websocket_extensions"), test = require("jstest").Test FakeClock = test.FakeClock test.describe("Extensions", function() { with(this) { before(function() { with(this) { this.extensions = new Extensions() this.ext = { name: "deflate", type: "permessage", rsv1: true, rsv2: false, rsv3: false } this.session = {} }}) describe("add", function() { with(this) { it("does not throw on valid extensions", function() { with(this) { assertNothingThrown(function() { extensions.add(ext) }) }}) it("throws if ext.name is not a string", function() { with(this) { ext.name = 42 assertThrows(TypeError, function() { extensions.add(ext) }) }}) it("throws if ext.rsv1 is not a boolean", function() { with(this) { ext.rsv1 = 42 assertThrows(TypeError, function() { extensions.add(ext) }) }}) it("throws if ext.rsv2 is not a boolean", function() { with(this) { ext.rsv2 = 42 assertThrows(TypeError, function() { extensions.add(ext) }) }}) it("throws if ext.rsv3 is not a boolean", function() { with(this) { ext.rsv3 = 42 assertThrows(TypeError, function() { extensions.add(ext) }) }}) }}) describe("client sessions", function() { with(this) { before(function() { with(this) { this.offer = { mode: "compress" } stub(ext, "createClientSession").returns(session) stub(session, "generateOffer").returns(offer) extensions.add(ext) this.conflict = { name: "tar", type: "permessage", rsv1: true, rsv2: false, rsv3: false } this.conflictSession = {} stub(conflict, "createClientSession").returns(conflictSession) stub(conflictSession, "generateOffer").returns({ gzip: true }) this.nonconflict = { name: "reverse", type: "permessage", rsv1: false, rsv2: true, rsv3: false } this.nonconflictSession = {} stub(nonconflict, "createClientSession").returns(nonconflictSession) stub(nonconflictSession, "generateOffer").returns({ utf8: true }) stub(session, "activate").returns(true) stub(conflictSession, "activate").returns(true) stub(nonconflictSession, "activate").returns(true) }}) describe("generateOffer", function() { with(this) { it("asks the extension to create a client session", function() { with(this) { expect(ext, "createClientSession").exactly(1).returning(session) extensions.generateOffer() }}) it("asks the session to generate an offer", function() { with(this) { expect(session, "generateOffer").exactly(1).returning(offer) extensions.generateOffer() }}) it("does not ask the session to generate an offer if the extension doesn't build a session", function() { with(this) { stub(ext, "createClientSession").returns(null) expect(session, "generateOffer").exactly(0) extensions.generateOffer() }}) it("returns the serialized offer from the session", function() { with(this) { assertEqual( "deflate; mode=compress", extensions.generateOffer() ) }}) it("returns a null offer from the session", function() { with(this) { stub(session, "generateOffer").returns(null) assertEqual( null, extensions.generateOffer() ) }}) it("returns multiple serialized offers from the session", function() { with(this) { stub(session, "generateOffer").returns([offer, {}]) assertEqual( "deflate; mode=compress, deflate", extensions.generateOffer() ) }}) it("returns serialized offers from multiple sessions", function() { with(this) { extensions.add(nonconflict) assertEqual( "deflate; mode=compress, reverse; utf8", extensions.generateOffer() ) }}) it("generates offers for potentially conflicting extensions", function() { with(this) { extensions.add(conflict) assertEqual( "deflate; mode=compress, tar; gzip", extensions.generateOffer() ) }}) }}) describe("activate", function() { with(this) { before(function() { with(this) { extensions.add(conflict) extensions.add(nonconflict) extensions.generateOffer() }}) it("throws if given unregistered extensions", function() { with(this) { assertThrows(Error, function() { extensions.activate("xml") }) }}) it("does not throw if given registered extensions", function() { with(this) { assertNothingThrown(function() { extensions.activate("deflate") }) }}) it("does not throw if given only one potentially conflicting extension", function() { with(this) { assertNothingThrown(function() { extensions.activate("tar") }) }}) it("throws if two extensions conflict on RSV bits", function() { with(this) { assertThrows(Error, function() { extensions.activate("deflate, tar") }) }}) it("does not throw if given two non-conflicting extensions", function() { with(this) { assertNothingThrown(function() { extensions.activate("deflate, reverse") }) }}) it("activates one session with no params", function() { with(this) { expect(session, "activate").given({}).exactly(1).returning(true) extensions.activate("deflate") }}) it("activates one session with a boolean param", function() { with(this) { expect(session, "activate").given({ gzip: true }).exactly(1).returning(true) extensions.activate("deflate; gzip") }}) it("activates one session with a string param", function() { with(this) { expect(session, "activate").given({ mode: "compress" }).exactly(1).returning(true) extensions.activate("deflate; mode=compress") }}) it("activates multiple sessions", function() { with(this) { expect(session, "activate").given({ a: true }).exactly(1).returning(true) expect(nonconflictSession, "activate").given({ b: true }).exactly(1).returning(true) extensions.activate("deflate; a, reverse; b") }}) it("does not activate sessions not named in the header", function() { with(this) { expect(session, "activate").exactly(0) expect(nonconflictSession, "activate").exactly(1).returning(true) extensions.activate("reverse") }}) it("throws if session.activate() does not return true", function() { with(this) { stub(session, "activate").returns("yes") assertThrows(Error, function() { extensions.activate("deflate") }) }}) }}) describe("processIncomingMessage", function() { with(this) { before(function() { with(this) { extensions.add(conflict) extensions.add(nonconflict) extensions.generateOffer() stub(session, "processIncomingMessage", function(message, callback) { message.frames.push("deflate") callback(null, message) }) stub(nonconflictSession, "processIncomingMessage", function(message, callback) { message.frames.push("reverse") callback(null, message) }) }}) it("processes messages in the reverse order given in the server's response", function() { with(this) { extensions.activate("deflate, reverse") extensions.processIncomingMessage({ frames: [] }, function(error, message) { assertNull( error ) assertEqual( ["reverse", "deflate"], message.frames ) }) }}) it("yields an error if a session yields an error", function() { with(this) { extensions.activate("deflate") stub(session, "processIncomingMessage").yields([{ message: "ENOENT" }]) extensions.processIncomingMessage({ frames: [] }, function(error, message) { assertEqual( "deflate: ENOENT", error.message ) assertNull( message ) }) }}) it("does not call sessions after one has yielded an error", function() { with(this) { extensions.activate("deflate, reverse") stub(nonconflictSession, "processIncomingMessage").yields([{ message: "ENOENT" }]) expect(session, "processIncomingMessage").exactly(0) extensions.processIncomingMessage({ frames: [] }, function() {}) }}) }}) describe("processOutgoingMessage", function() { with(this) { before(function() { with(this) { extensions.add(conflict) extensions.add(nonconflict) extensions.generateOffer() stub(session, "processOutgoingMessage", function(message, callback) { message.frames.push("deflate") callback(null, message) }) stub(nonconflictSession, "processOutgoingMessage", function(message, callback) { message.frames.push("reverse") callback(null, message) }) }}) describe("error handling", function() { with(this) { include(FakeClock) sharedExamplesFor("handles errors", function() { with(this) { before(function() { with(this) { clock.stub() extensions.activate("deflate, reverse") stub(session, "processOutgoingMessage", function(message, callback) { setTimeout(function() { callback(null, message.concat("a")) }, 100) }) stub(nonconflictSession, "processOutgoingMessage", function(message, callback) { setTimeout(function() { callback(null, message.concat("b")) }, 100) }) stub(nonconflictSession, "processIncomingMessage", function(message, callback) { if (message[0] === 5) return emitError(callback) setTimeout(function() { callback(null, message.concat("c")) }, 50) }) stub(session, "processIncomingMessage", function(message, callback) { setTimeout(function() { callback(null, message.concat("d")) }, 100) }) stub(session, "close") stub(nonconflictSession, "close") this.messages = [] var push = function(error, message) { if (error) extensions.close(function() { messages.push("close") }) messages.push(message) } ;[1, 2, 3].forEach(function(n) { extensions.processOutgoingMessage([n], push) }) ;[4, 5, 6].forEach(function(n, i) { setTimeout(function() { extensions.processIncomingMessage([n], push) }, 20 * i) }) clock.tick(200) }}) it("allows the message before the error through to the end", function() { with(this) { assertEqual( [4, "c", "d"], messages[0] ) }}) it("yields the error to the end of the pipeline", function() { with(this) { assertNull( messages[1] ) }}) it("does not yield the message after the error", function() { with(this) { assertNotEqual( arrayIncluding([6, "c", "d"]), messages ) }}) it("yields all the messages in the direction unaffected by the error", function() { with(this) { assertEqual( [1, "a", "b"], messages[2] ) assertEqual( [2, "a", "b"], messages[3] ) assertEqual( [3, "a", "b"], messages[4] ) }}) it("closes after all messages are processed", function() { with(this) { assertEqual( "close", messages[5] ) assertEqual( 6, messages.length ) }}) }}) describe("with a sync error", function() { with(this) { define("emitError", function(callback) { throw new Error("sync error") }) itShouldBehaveLike("handles errors") }}) describe("with an async error", function() { with(this) { define("emitError", function(callback) { setTimeout(function() { callback(new Error("async error"), null) }, 10) }) itShouldBehaveLike("handles errors") }}) }}) describe("async processors", function() { with(this) { include(FakeClock) before(function() { with(this) { clock.stub() var tags = ["a", "b", "c", "d"] stub(session, "processOutgoingMessage", function(message, callback) { var time = message.frames.length === 0 ? 100 : 20 message.frames.push(tags.shift()) setTimeout(function() { callback(null, message) }, time) }) stub(nonconflictSession, "processOutgoingMessage", function(message, callback) { var time = message.frames.length === 1 ? 100 : 20 message.frames.push(tags.shift()) setTimeout(function() { callback(null, message) }, time) }) }}) it("processes messages in order even if upstream emits them out of order", function() { with(this) { extensions.activate("deflate, reverse") var out = [] extensions.processOutgoingMessage({ frames: [] }, function(error, message) { out.push(message) }) extensions.processOutgoingMessage({ frames: [1] }, function(error, message) { out.push(message) }) clock.tick(200) assertEqual( [{ frames: ["a", "c"] }, { frames: [1, "b", "d"] }], out ) }}) it("defers closing until the extension has finished processing", function() { with(this) { extensions.activate("deflate") var closed = false, notified = false stub(session, "close", function() { closed = true }) extensions.processOutgoingMessage({ frames: [] }, function() {}) extensions.close(function() { notified = true }) clock.tick(50) assertNot( closed || notified ) clock.tick(50) assert( closed && notified ) }}) it("closes each session as soon as it finishes processing", function() { with(this) { extensions.activate("deflate, reverse") var closed = [false, false], notified = false stub(session, "close", function() { closed[0] = true }) stub(nonconflictSession, "close", function() { closed[1] = true }) extensions.processOutgoingMessage({ frames: [] }, function() {}); extensions.close(function() { notified = true }) clock.tick(50) assertNot( closed[0] || closed[1] || notified ) clock.tick(100) assert( closed[0] ) assertNot( closed[1] || notified ) clock.tick(50) assert( closed[0] && closed[1] && notified ) }}) it("notifies of closure immeidately if already closed", function() { with(this) { extensions.activate("deflate") stub(session, "close", function() { closed = true }) extensions.processOutgoingMessage({ frames: [] }, function() {}) extensions.close() clock.tick(100) var notified = false extensions.close(function() { notified = true }) assert( notified ) }}) }}) it("processes messages in the order given in the server's response", function() { with(this) { extensions.activate("deflate, reverse") extensions.processOutgoingMessage({ frames: [] }, function(error, message) { assertNull( error ) assertEqual( ["deflate", "reverse"], message.frames ) }) }}) it("processes messages in the server's order, not the client's order", function() { with(this) { extensions.activate("reverse, deflate") extensions.processOutgoingMessage({ frames: [] }, function(error, message) { assertNull( error ) assertEqual( ["reverse", "deflate"], message.frames ) }) }}) it("yields an error if a session yields an error", function() { with(this) { extensions.activate("deflate") stub(session, "processOutgoingMessage").yields([{ message: "ENOENT" }]) extensions.processOutgoingMessage({ frames: [] }, function(error, message) { assertEqual( "deflate: ENOENT", error.message ) assertNull( message ) }) }}) it("does not call sessions after one has yielded an error", function() { with(this) { extensions.activate("deflate, reverse") stub(session, "processOutgoingMessage").yields([{ message: "ENOENT" }]) expect(nonconflictSession, "processOutgoingMessage").exactly(0) extensions.processOutgoingMessage({ frames: [] }, function() {}) }}) }}) }}) describe("server sessions", function() { with(this) { before(function() { with(this) { this.response = { mode: "compress" } stub(ext, "createServerSession").returns(session) stub(session, "generateResponse").returns(response) this.conflict = { name: "tar", type: "permessage", rsv1: true, rsv2: false, rsv3: false } this.conflictSession = {} stub(conflict, "createServerSession").returns(conflictSession) stub(conflictSession, "generateResponse").returns({ gzip: true }) this.nonconflict = { name: "reverse", type: "permessage", rsv1: false, rsv2: true, rsv3: false } this.nonconflictSession = {} stub(nonconflict, "createServerSession").returns(nonconflictSession) stub(nonconflictSession, "generateResponse").returns({ utf8: true }) extensions.add(ext) extensions.add(conflict) extensions.add(nonconflict) }}) describe("generateResponse", function() { with(this) { it("asks the extension for a server session with the offer", function() { with(this) { expect(ext, "createServerSession").given([{ flag: true }]).exactly(1).returning(session) extensions.generateResponse("deflate; flag") }}) it("asks the extension for a server session with multiple offers", function() { with(this) { expect(ext, "createServerSession").given([{ a: true }, { b: true }]).exactly(1).returning(session) extensions.generateResponse("deflate; a, deflate; b") }}) it("asks the session to generate a response", function() { with(this) { expect(session, "generateResponse").exactly(1).returning(response) extensions.generateResponse("deflate") }}) it("asks multiple sessions to generate a response", function() { with(this) { expect(session, "generateResponse").exactly(1).returning(response) expect(nonconflictSession, "generateResponse").exactly(1).returning(response) extensions.generateResponse("deflate, reverse") }}) it("does not ask the session to generate a response if the extension doesn't build a session", function() { with(this) { stub(ext, "createServerSession").returns(null) expect(session, "generateResponse").exactly(0) extensions.generateResponse("deflate") }}) it("does not ask the extension to build a session for unoffered extensions", function() { with(this) { expect(nonconflict, "createServerSession").exactly(0) extensions.generateResponse("deflate") }}) it("does not ask the extension to build a session for conflicting extensions", function() { with(this) { expect(conflict, "createServerSession").exactly(0) extensions.generateResponse("deflate, tar") }}) it("returns the serialized response from the session", function() { with(this) { assertEqual( "deflate; mode=compress", extensions.generateResponse("deflate") ) }}) it("returns serialized responses from multiple sessions", function() { with(this) { assertEqual( "deflate; mode=compress, reverse; utf8", extensions.generateResponse("deflate, reverse") ) }}) it("returns responses in registration order", function() { with(this) { assertEqual( "deflate; mode=compress, reverse; utf8", extensions.generateResponse("reverse, deflate") ) }}) it("does not return responses for unoffered extensions", function() { with(this) { assertEqual( "reverse; utf8", extensions.generateResponse("reverse") ) }}) it("does not return responses for conflicting extensions", function() { with(this) { assertEqual( "deflate; mode=compress", extensions.generateResponse("deflate, tar") ) }}) it("throws an error if the header is invalid", function() { with(this) { assertThrows(SyntaxError, function() { extensions.generateResponse("x-webkit- -frame") }) }}) it("returns a response for potentially conflicting extensions if their preceding extensions don't build a session", function() { with(this) { stub(ext, "createServerSession").returns(null) assertEqual( "tar; gzip", extensions.generateResponse("deflate, tar") ) }}) }}) }}) }})