├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── lib ├── parser.js ├── pipeline │ ├── README.md │ ├── cell.js │ ├── functor.js │ ├── index.js │ ├── pledge.js │ └── ring_buffer.js └── websocket_extensions.js ├── package.json └── spec ├── parser_spec.js ├── runner.js └── websocket_extensions_spec.js /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | node: 11 | - '0.8' 12 | - '0.10' 13 | - '0.12' 14 | - '4' 15 | - '6' 16 | - '8' 17 | - '10' 18 | - '12' 19 | - '14' 20 | - '16' 21 | - '18' 22 | - '20' 23 | name: node.js v${{ matrix.node }} 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | 28 | - uses: actions/setup-node@v2 29 | with: 30 | node-version: ${{ matrix.node }} 31 | 32 | - if: matrix.node == '0.8' 33 | run: npm conf set strict-ssl false 34 | 35 | - run: node --version 36 | - run: npm install 37 | 38 | - run: npm install 'nopt@5' 39 | - run: rm -rf node_modules/jstest/node_modules/nopt 40 | 41 | - run: npm test 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.1.4 / 2020-06-02 2 | 3 | - Remove a ReDoS vulnerability in the header parser (CVE-2020-7662, reported by 4 | Robert McLaughlin) 5 | - Change license from MIT to Apache 2.0 6 | 7 | ### 0.1.3 / 2017-11-11 8 | 9 | - Accept extension names and parameters including uppercase letters 10 | - Handle extension names that clash with `Object.prototype` properties 11 | 12 | ### 0.1.2 / 2017-09-10 13 | 14 | - Catch synchronous exceptions thrown when calling an extension 15 | - Fix race condition caused when a message is pushed after a cell has stopped 16 | due to an error 17 | - Fix failure of `close()` to return if a message that's queued after one that 18 | produces an error never finishes being processed 19 | 20 | ### 0.1.1 / 2015-02-19 21 | 22 | - Prevent sessions being closed before they have finished processing messages 23 | - Add a callback to `Extensions.close()` so the caller can tell when it's safe 24 | to close the socket 25 | 26 | ### 0.1.0 / 2014-12-12 27 | 28 | - Initial release 29 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | All projects under the [Faye](https://github.com/faye) umbrella are covered by 4 | the [Code of Conduct](https://github.com/faye/code-of-conduct). 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2014-2020 James Coglan 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 4 | this file except in compliance with the License. You may obtain a copy of the 5 | License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software distributed 10 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 11 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 12 | specific language governing permissions and limitations under the License. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # websocket-extensions 2 | 3 | A minimal framework that supports the implementation of WebSocket extensions in 4 | a way that's decoupled from the main protocol. This library aims to allow a 5 | WebSocket extension to be written and used with any protocol library, by 6 | defining abstract representations of frames and messages that allow modules to 7 | co-operate. 8 | 9 | `websocket-extensions` provides a container for registering extension plugins, 10 | and provides all the functions required to negotiate which extensions to use 11 | during a session via the `Sec-WebSocket-Extensions` header. By implementing the 12 | APIs defined in this document, an extension may be used by any WebSocket library 13 | based on this framework. 14 | 15 | ## Installation 16 | 17 | ``` 18 | $ npm install websocket-extensions 19 | ``` 20 | 21 | ## Usage 22 | 23 | There are two main audiences for this library: authors implementing the 24 | WebSocket protocol, and authors implementing extensions. End users of a 25 | WebSocket library or an extension should be able to use any extension by passing 26 | it as an argument to their chosen protocol library, without needing to know how 27 | either of them work, or how the `websocket-extensions` framework operates. 28 | 29 | The library is designed with the aim that any protocol implementation and any 30 | extension can be used together, so long as they support the same abstract 31 | representation of frames and messages. 32 | 33 | ### Data types 34 | 35 | The APIs provided by the framework rely on two data types; extensions will 36 | expect to be given data and to be able to return data in these formats: 37 | 38 | #### *Frame* 39 | 40 | *Frame* is a structure representing a single WebSocket frame of any type. Frames 41 | are simple objects that must have at least the following properties, which 42 | represent the data encoded in the frame: 43 | 44 | | property | description | 45 | | ------------ | ------------------------------------------------------------------ | 46 | | `final` | `true` if the `FIN` bit is set, `false` otherwise | 47 | | `rsv1` | `true` if the `RSV1` bit is set, `false` otherwise | 48 | | `rsv2` | `true` if the `RSV2` bit is set, `false` otherwise | 49 | | `rsv3` | `true` if the `RSV3` bit is set, `false` otherwise | 50 | | `opcode` | the numeric opcode (`0`, `1`, `2`, `8`, `9`, or `10`) of the frame | 51 | | `masked` | `true` if the `MASK` bit is set, `false` otherwise | 52 | | `maskingKey` | a 4-byte `Buffer` if `masked` is `true`, otherwise `null` | 53 | | `payload` | a `Buffer` containing the (unmasked) application data | 54 | 55 | #### *Message* 56 | 57 | A *Message* represents a complete application message, which can be formed from 58 | text, binary and continuation frames. It has the following properties: 59 | 60 | | property | description | 61 | | -------- | ----------------------------------------------------------------- | 62 | | `rsv1` | `true` if the first frame of the message has the `RSV1` bit set | 63 | | `rsv2` | `true` if the first frame of the message has the `RSV2` bit set | 64 | | `rsv3` | `true` if the first frame of the message has the `RSV3` bit set | 65 | | `opcode` | the numeric opcode (`1` or `2`) of the first frame of the message | 66 | | `data` | the concatenation of all the frame payloads in the message | 67 | 68 | ### For driver authors 69 | 70 | A driver author is someone implementing the WebSocket protocol proper, and who 71 | wishes end users to be able to use WebSocket extensions with their library. 72 | 73 | At the start of a WebSocket session, on both the client and the server side, 74 | they should begin by creating an extension container and adding whichever 75 | extensions they want to use. 76 | 77 | ```js 78 | var Extensions = require('websocket-extensions'), 79 | deflate = require('permessage-deflate'); 80 | 81 | var exts = new Extensions(); 82 | exts.add(deflate); 83 | ``` 84 | 85 | In the following examples, `exts` refers to this `Extensions` instance. 86 | 87 | #### Client sessions 88 | 89 | Clients will use the methods `generateOffer()` and `activate(header)`. 90 | 91 | As part of the handshake process, the client must send a 92 | `Sec-WebSocket-Extensions` header to advertise that it supports the registered 93 | extensions. This header should be generated using: 94 | 95 | ```js 96 | request.headers['sec-websocket-extensions'] = exts.generateOffer(); 97 | ``` 98 | 99 | This returns a string, for example `"permessage-deflate; 100 | client_max_window_bits"`, that represents all the extensions the client is 101 | offering to use, and their parameters. This string may contain multiple offers 102 | for the same extension. 103 | 104 | When the client receives the handshake response from the server, it should pass 105 | the incoming `Sec-WebSocket-Extensions` header in to `exts` to activate the 106 | extensions the server has accepted: 107 | 108 | ```js 109 | exts.activate(response.headers['sec-websocket-extensions']); 110 | ``` 111 | 112 | If the server has sent any extension responses that the client does not 113 | recognize, or are in conflict with one another for use of RSV bits, or that use 114 | invalid parameters for the named extensions, then `exts.activate()` will 115 | `throw`. In this event, the client driver should fail the connection with 116 | closing code `1010`. 117 | 118 | #### Server sessions 119 | 120 | Servers will use the method `generateResponse(header)`. 121 | 122 | A server session needs to generate a `Sec-WebSocket-Extensions` header to send 123 | in its handshake response: 124 | 125 | ```js 126 | var clientOffer = request.headers['sec-websocket-extensions'], 127 | extResponse = exts.generateResponse(clientOffer); 128 | 129 | response.headers['sec-websocket-extensions'] = extResponse; 130 | ``` 131 | 132 | Calling `exts.generateResponse(header)` activates those extensions the client 133 | has asked to use, if they are registered, asks each extension for a set of 134 | response parameters, and returns a string containing the response parameters for 135 | all accepted extensions. 136 | 137 | #### In both directions 138 | 139 | Both clients and servers will use the methods `validFrameRsv(frame)`, 140 | `processIncomingMessage(message)` and `processOutgoingMessage(message)`. 141 | 142 | The WebSocket protocol requires that frames do not have any of the `RSV` bits 143 | set unless there is an extension in use that allows otherwise. When processing 144 | an incoming frame, sessions should pass a *Frame* object to: 145 | 146 | ```js 147 | exts.validFrameRsv(frame) 148 | ``` 149 | 150 | If this method returns `false`, the session should fail the WebSocket connection 151 | with closing code `1002`. 152 | 153 | To pass incoming messages through the extension stack, a session should 154 | construct a *Message* object according to the above datatype definitions, and 155 | call: 156 | 157 | ```js 158 | exts.processIncomingMessage(message, function(error, msg) { 159 | // hand the message off to the application 160 | }); 161 | ``` 162 | 163 | If any extensions fail to process the message, then the callback will yield an 164 | error and the session should fail the WebSocket connection with closing code 165 | `1010`. If `error` is `null`, then `msg` should be passed on to the application. 166 | 167 | To pass outgoing messages through the extension stack, a session should 168 | construct a *Message* as before, and call: 169 | 170 | ```js 171 | exts.processOutgoingMessage(message, function(error, msg) { 172 | // write message to the transport 173 | }); 174 | ``` 175 | 176 | If any extensions fail to process the message, then the callback will yield an 177 | error and the session should fail the WebSocket connection with closing code 178 | `1010`. If `error` is `null`, then `message` should be converted into frames 179 | (with the message's `rsv1`, `rsv2`, `rsv3` and `opcode` set on the first frame) 180 | and written to the transport. 181 | 182 | At the end of the WebSocket session (either when the protocol is explicitly 183 | ended or the transport connection disconnects), the driver should call: 184 | 185 | ```js 186 | exts.close(function() {}) 187 | ``` 188 | 189 | The callback is invoked when all extensions have finished processing any 190 | messages in the pipeline and it's safe to close the socket. 191 | 192 | ### For extension authors 193 | 194 | An extension author is someone implementing an extension that transforms 195 | WebSocket messages passing between the client and server. They would like to 196 | implement their extension once and have it work with any protocol library. 197 | 198 | Extension authors will not install `websocket-extensions` or call it directly. 199 | Instead, they should implement the following API to allow their extension to 200 | plug into the `websocket-extensions` framework. 201 | 202 | An `Extension` is any object that has the following properties: 203 | 204 | | property | description | 205 | | -------- | ---------------------------------------------------------------------------- | 206 | | `name` | a string containing the name of the extension as used in negotiation headers | 207 | | `type` | a string, must be `"permessage"` | 208 | | `rsv1` | either `true` if the extension uses the RSV1 bit, `false` otherwise | 209 | | `rsv2` | either `true` if the extension uses the RSV2 bit, `false` otherwise | 210 | | `rsv3` | either `true` if the extension uses the RSV3 bit, `false` otherwise | 211 | 212 | It must also implement the following methods: 213 | 214 | ```js 215 | ext.createClientSession() 216 | ``` 217 | 218 | This returns a *ClientSession*, whose interface is defined below. 219 | 220 | ```js 221 | ext.createServerSession(offers) 222 | ``` 223 | 224 | This takes an array of offer params and returns a *ServerSession*, whose 225 | interface is defined below. For example, if the client handshake contains the 226 | offer header: 227 | 228 | ``` 229 | Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; server_max_window_bits=8, \ 230 | permessage-deflate; server_max_window_bits=15 231 | ``` 232 | 233 | then the `permessage-deflate` extension will receive the call: 234 | 235 | ```js 236 | ext.createServerSession([ 237 | { server_no_context_takeover: true, server_max_window_bits: 8 }, 238 | { server_max_window_bits: 15 } 239 | ]); 240 | ``` 241 | 242 | The extension must decide which set of parameters it wants to accept, if any, 243 | and return a *ServerSession* if it wants to accept the parameters and `null` 244 | otherwise. 245 | 246 | #### *ClientSession* 247 | 248 | A *ClientSession* is the type returned by `ext.createClientSession()`. It must 249 | implement the following methods, as well as the *Session* API listed below. 250 | 251 | ```js 252 | clientSession.generateOffer() 253 | // e.g. -> [ 254 | // { server_no_context_takeover: true, server_max_window_bits: 8 }, 255 | // { server_max_window_bits: 15 } 256 | // ] 257 | ``` 258 | 259 | This must return a set of parameters to include in the client's 260 | `Sec-WebSocket-Extensions` offer header. If the session wants to offer multiple 261 | configurations, it can return an array of sets of parameters as shown above. 262 | 263 | ```js 264 | clientSession.activate(params) // -> true 265 | ``` 266 | 267 | This must take a single set of parameters from the server's handshake response 268 | and use them to configure the client session. If the client accepts the given 269 | parameters, then this method must return `true`. If it returns any other value, 270 | the framework will interpret this as the client rejecting the response, and will 271 | `throw`. 272 | 273 | #### *ServerSession* 274 | 275 | A *ServerSession* is the type returned by `ext.createServerSession(offers)`. It 276 | must implement the following methods, as well as the *Session* API listed below. 277 | 278 | ```js 279 | serverSession.generateResponse() 280 | // e.g. -> { server_max_window_bits: 8 } 281 | ``` 282 | 283 | This returns the set of parameters the server session wants to send in its 284 | `Sec-WebSocket-Extensions` response header. Only one set of parameters is 285 | returned to the client per extension. Server sessions that would confict on 286 | their use of RSV bits are not activated. 287 | 288 | #### *Session* 289 | 290 | The *Session* API must be implemented by both client and server sessions. It 291 | contains two methods, `processIncomingMessage(message)` and 292 | `processOutgoingMessage(message)`. 293 | 294 | ```js 295 | session.processIncomingMessage(message, function(error, msg) { ... }) 296 | ``` 297 | 298 | The session must implement this method to take an incoming *Message* as defined 299 | above, transform it in any way it needs, then return it via the callback. If 300 | there is an error processing the message, this method should yield an error as 301 | the first argument. 302 | 303 | ```js 304 | session.processOutgoingMessage(message, function(error, msg) { ... }) 305 | ``` 306 | 307 | The session must implement this method to take an outgoing *Message* as defined 308 | above, transform it in any way it needs, then return it via the callback. If 309 | there is an error processing the message, this method should yield an error as 310 | the first argument. 311 | 312 | Note that both `processIncomingMessage()` and `processOutgoingMessage()` can 313 | perform their logic asynchronously, are allowed to process multiple messages 314 | concurrently, and are not required to complete working on messages in the same 315 | order the messages arrive. `websocket-extensions` will reorder messages as your 316 | extension emits them and will make sure every extension is given messages in the 317 | order they arrive from the driver. This allows extensions to maintain state that 318 | depends on the messages' wire order, for example keeping a DEFLATE compression 319 | context between messages. 320 | 321 | ```js 322 | session.close() 323 | ``` 324 | 325 | The framework will call this method when the WebSocket session ends, allowing 326 | the session to release any resources it's using. 327 | 328 | ## Examples 329 | 330 | - Consumer: [websocket-driver](https://github.com/faye/websocket-driver-node) 331 | - Provider: [permessage-deflate](https://github.com/faye/permessage-deflate-node) 332 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var TOKEN = /([!#\$%&'\*\+\-\.\^_`\|~0-9A-Za-z]+)/, 4 | NOTOKEN = /([^!#\$%&'\*\+\-\.\^_`\|~0-9A-Za-z])/g, 5 | QUOTED = /"((?:\\[\x00-\x7f]|[^\x00-\x08\x0a-\x1f\x7f"\\])*)"/, 6 | PARAM = new RegExp(TOKEN.source + '(?:=(?:' + TOKEN.source + '|' + QUOTED.source + '))?'), 7 | EXT = new RegExp(TOKEN.source + '(?: *; *' + PARAM.source + ')*', 'g'), 8 | EXT_LIST = new RegExp('^' + EXT.source + '(?: *, *' + EXT.source + ')*$'), 9 | NUMBER = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?$/; 10 | 11 | var hasOwnProperty = Object.prototype.hasOwnProperty; 12 | 13 | var Parser = { 14 | parseHeader: function(header) { 15 | var offers = new Offers(); 16 | if (header === '' || header === undefined) return offers; 17 | 18 | if (!EXT_LIST.test(header)) 19 | throw new SyntaxError('Invalid Sec-WebSocket-Extensions header: ' + header); 20 | 21 | var values = header.match(EXT); 22 | 23 | values.forEach(function(value) { 24 | var params = value.match(new RegExp(PARAM.source, 'g')), 25 | name = params.shift(), 26 | offer = {}; 27 | 28 | params.forEach(function(param) { 29 | var args = param.match(PARAM), key = args[1], data; 30 | 31 | if (args[2] !== undefined) { 32 | data = args[2]; 33 | } else if (args[3] !== undefined) { 34 | data = args[3].replace(/\\/g, ''); 35 | } else { 36 | data = true; 37 | } 38 | if (NUMBER.test(data)) data = parseFloat(data); 39 | 40 | if (hasOwnProperty.call(offer, key)) { 41 | offer[key] = [].concat(offer[key]); 42 | offer[key].push(data); 43 | } else { 44 | offer[key] = data; 45 | } 46 | }, this); 47 | offers.push(name, offer); 48 | }, this); 49 | 50 | return offers; 51 | }, 52 | 53 | serializeParams: function(name, params) { 54 | var values = []; 55 | 56 | var print = function(key, value) { 57 | if (value instanceof Array) { 58 | value.forEach(function(v) { print(key, v) }); 59 | } else if (value === true) { 60 | values.push(key); 61 | } else if (typeof value === 'number') { 62 | values.push(key + '=' + value); 63 | } else if (NOTOKEN.test(value)) { 64 | values.push(key + '="' + value.replace(/"/g, '\\"') + '"'); 65 | } else { 66 | values.push(key + '=' + value); 67 | } 68 | }; 69 | 70 | for (var key in params) print(key, params[key]); 71 | 72 | return [name].concat(values).join('; '); 73 | } 74 | }; 75 | 76 | var Offers = function() { 77 | this._byName = {}; 78 | this._inOrder = []; 79 | }; 80 | 81 | Offers.prototype.push = function(name, params) { 82 | if (!hasOwnProperty.call(this._byName, name)) 83 | this._byName[name] = []; 84 | 85 | this._byName[name].push(params); 86 | this._inOrder.push({ name: name, params: params }); 87 | }; 88 | 89 | Offers.prototype.eachOffer = function(callback, context) { 90 | var list = this._inOrder; 91 | for (var i = 0, n = list.length; i < n; i++) 92 | callback.call(context, list[i].name, list[i].params); 93 | }; 94 | 95 | Offers.prototype.byName = function(name) { 96 | return this._byName[name] || []; 97 | }; 98 | 99 | Offers.prototype.toArray = function() { 100 | return this._inOrder.slice(); 101 | }; 102 | 103 | module.exports = Parser; 104 | -------------------------------------------------------------------------------- /lib/pipeline/README.md: -------------------------------------------------------------------------------- 1 | # Extension pipelining 2 | 3 | `websocket-extensions` models the extension negotiation and processing pipeline 4 | of the WebSocket protocol. Between the driver parsing messages from the TCP 5 | stream and handing those messages off to the application, there may exist a 6 | stack of extensions that transform the message somehow. 7 | 8 | In the parlance of this framework, a *session* refers to a single instance of an 9 | extension, acting on a particular socket on either the server or the client 10 | side. A session may transform messages both incoming to the application and 11 | outgoing from the application, for example the `permessage-deflate` extension 12 | compresses outgoing messages and decompresses incoming messages. Message streams 13 | in either direction are independent; that is, incoming and outgoing messages 14 | cannot be assumed to 'pair up' as in a request-response protocol. 15 | 16 | Asynchronous processing of messages poses a number of problems that this 17 | pipeline construction is intended to solve. 18 | 19 | 20 | ## Overview 21 | 22 | Logically, we have the following: 23 | 24 | 25 | +-------------+ out +---+ +---+ +---+ +--------+ 26 | | |------>| |---->| |---->| |------>| | 27 | | Application | | A | | B | | C | | Driver | 28 | | |<------| |<----| |<----| |<------| | 29 | +-------------+ in +---+ +---+ +---+ +--------+ 30 | 31 | \ / 32 | +----------o----------+ 33 | | 34 | sessions 35 | 36 | 37 | For outgoing messages, the driver receives the result of 38 | 39 | C.outgoing(B.outgoing(A.outgoing(message))) 40 | 41 | or, [A, B, C].reduce(((m, ext) => ext.outgoing(m)), message) 42 | 43 | For incoming messages, the application receives the result of 44 | 45 | A.incoming(B.incoming(C.incoming(message))) 46 | 47 | or, [C, B, A].reduce(((m, ext) => ext.incoming(m)), message) 48 | 49 | A session is of the following type, to borrow notation from pseudo-Haskell: 50 | 51 | type Session = { 52 | incoming :: Message -> Message 53 | outgoing :: Message -> Message 54 | close :: () -> () 55 | } 56 | 57 | (That `() -> ()` syntax is intended to mean that `close()` is a nullary void 58 | method; I apologise to any Haskell readers for not using the right monad.) 59 | 60 | The `incoming()` and `outgoing()` methods perform message transformation in the 61 | respective directions; `close()` is called when a socket closes so the session 62 | can release any resources it's holding, for example a DEFLATE de/compression 63 | context. 64 | 65 | However because this is JavaScript, the `incoming()` and `outgoing()` methods 66 | may be asynchronous (indeed, `permessage-deflate` is based on `zlib`, whose API 67 | is stream-based). So their interface is strictly: 68 | 69 | type Session = { 70 | incoming :: Message -> Callback -> () 71 | outgoing :: Message -> Callback -> () 72 | close :: () -> () 73 | } 74 | 75 | type Callback = Either Error Message -> () 76 | 77 | This means a message *m2* can be pushed into a session while it's still 78 | processing the preceding message *m1*. The messages can be processed 79 | concurrently but they *must* be given to the next session in line (or to the 80 | application) in the same order they came in. Applications will expect to receive 81 | messages in the order they arrived over the wire, and sessions require this too. 82 | So ordering of messages must be preserved throughout the pipeline. 83 | 84 | Consider the following highly simplified extension that deflates messages on the 85 | wire. `message` is a value conforming the type: 86 | 87 | type Message = { 88 | rsv1 :: Boolean 89 | rsv2 :: Boolean 90 | rsv3 :: Boolean 91 | opcode :: Number 92 | data :: Buffer 93 | } 94 | 95 | Here's the extension: 96 | 97 | ```js 98 | var zlib = require('zlib'); 99 | 100 | var deflate = { 101 | outgoing: function(message, callback) { 102 | zlib.deflateRaw(message.data, function(error, result) { 103 | message.rsv1 = true; 104 | message.data = result; 105 | callback(error, message); 106 | }); 107 | }, 108 | 109 | incoming: function(message, callback) { 110 | // decompress inbound messages (elided) 111 | }, 112 | 113 | close: function() { 114 | // no state to clean up 115 | } 116 | }; 117 | ``` 118 | 119 | We can call it with a large message followed by a small one, and the small one 120 | will be returned first: 121 | 122 | ```js 123 | var crypto = require('crypto'), 124 | large = crypto.randomBytes(1 << 14), 125 | small = new Buffer('hi'); 126 | 127 | deflate.outgoing({ data: large }, function() { 128 | console.log(1, 'large'); 129 | }); 130 | 131 | deflate.outgoing({ data: small }, function() { 132 | console.log(2, 'small'); 133 | }); 134 | 135 | /* prints: 2 'small' 136 | 1 'large' */ 137 | ``` 138 | 139 | So a session that processes messages asynchronously may fail to preserve message 140 | ordering. 141 | 142 | Now, this extension is stateless, so it can process messages in any order and 143 | still produce the same output. But some extensions are stateful and require 144 | message order to be preserved. 145 | 146 | For example, when using `permessage-deflate` without `no_context_takeover` set, 147 | the session retains a DEFLATE de/compression context between messages, which 148 | accumulates state as it consumes data (later messages can refer to sections of 149 | previous ones to improve compression). Reordering parts of the DEFLATE stream 150 | will result in a failed decompression. Messages must be decompressed in the same 151 | order they were compressed by the peer in order for the DEFLATE protocol to 152 | work. 153 | 154 | Finally, there is the problem of closing a socket. When a WebSocket is closed by 155 | the application, or receives a closing request from the other peer, there may be 156 | messages outgoing from the application and incoming from the peer in the 157 | pipeline. If we close the socket and pipeline immediately, two problems arise: 158 | 159 | * We may send our own closing frame to the peer before all prior messages we 160 | sent have been written to the socket, and before we have finished processing 161 | all prior messages from the peer 162 | * The session may be instructed to close its resources (e.g. its de/compression 163 | context) while it's in the middle of processing a message, or before it has 164 | received messages that are upstream of it in the pipeline 165 | 166 | Essentially, we must defer closing the sessions and sending a closing frame 167 | until after all prior messages have exited the pipeline. 168 | 169 | 170 | ## Design goals 171 | 172 | * Message order must be preserved between the protocol driver, the extension 173 | sessions, and the application 174 | * Messages should be handed off to sessions and endpoints as soon as possible, 175 | to maximise throughput of stateless sessions 176 | * The closing procedure should block any further messages from entering the 177 | pipeline, and should allow all existing messages to drain 178 | * Sessions should be closed as soon as possible to prevent them holding memory 179 | and other resources when they have no more messages to handle 180 | * The closing API should allow the caller to detect when the pipeline is empty 181 | and it is safe to continue the WebSocket closing procedure 182 | * Individual extensions should remain as simple as possible to facilitate 183 | modularity and independent authorship 184 | 185 | The final point about modularity is an important one: this framework is designed 186 | to facilitate extensions existing as plugins, by decoupling the protocol driver, 187 | extensions, and application. In an ideal world, plugins should only need to 188 | contain code for their specific functionality, and not solve these problems that 189 | apply to all sessions. Also, solving some of these problems requires 190 | consideration of all active sessions collectively, which an individual session 191 | is incapable of doing. 192 | 193 | For example, it is entirely possible to take the simple `deflate` extension 194 | above and wrap its `incoming()` and `outgoing()` methods in two `Transform` 195 | streams, producing this type: 196 | 197 | type Session = { 198 | incoming :: TransformStream 199 | outtoing :: TransformStream 200 | close :: () -> () 201 | } 202 | 203 | The `Transform` class makes it easy to wrap an async function such that message 204 | order is preserved: 205 | 206 | ```js 207 | var stream = require('stream'), 208 | session = new stream.Transform({ objectMode: true }); 209 | 210 | session._transform = function(message, _, callback) { 211 | var self = this; 212 | deflate.outgoing(message, function(error, result) { 213 | self.push(result); 214 | callback(); 215 | }); 216 | }; 217 | ``` 218 | 219 | However, this has a negative impact on throughput: it works by deferring 220 | `callback()` until the async function has 'returned', which blocks `Transform` 221 | from passing further input into the `_transform()` method until the current 222 | message is dealt with completely. This would prevent sessions from processing 223 | messages concurrently, and would unnecessarily reduce the throughput of 224 | stateless extensions. 225 | 226 | So, input should be handed off to sessions as soon as possible, and all we need 227 | is a mechanism to reorder the output so that message order is preserved for the 228 | next session in line. 229 | 230 | 231 | ## Solution 232 | 233 | We now describe the model implemented here and how it meets the above design 234 | goals. The above diagram where a stack of extensions sit between the driver and 235 | application describes the data flow, but not the object graph. That looks like 236 | this: 237 | 238 | 239 | +--------+ 240 | | Driver | 241 | +---o----+ 242 | | 243 | V 244 | +------------+ +----------+ 245 | | Extensions o----->| Pipeline | 246 | +------------+ +-----o----+ 247 | | 248 | +---------------+---------------+ 249 | | | | 250 | +-----o----+ +-----o----+ +-----o----+ 251 | | Cell [A] | | Cell [B] | | Cell [C] | 252 | +----------+ +----------+ +----------+ 253 | 254 | 255 | A driver using this framework holds an instance of the `Extensions` class, which 256 | it uses to register extension plugins, negotiate headers and transform messages. 257 | The `Extensions` instance itself holds a `Pipeline`, which contains an array of 258 | `Cell` objects, each of which wraps one of the sessions. 259 | 260 | 261 | ### Message processing 262 | 263 | Both the `Pipeline` and `Cell` classes have `incoming()` and `outgoing()` 264 | methods; the `Pipeline` interface pushes messages into the pipe, delegates the 265 | message to each `Cell` in turn, then returns it back to the driver. Outgoing 266 | messages pass through `A` then `B` then `C`, and incoming messages in the 267 | reverse order. 268 | 269 | Internally, a `Cell` contains two `Functor` objects. A `Functor` wraps an async 270 | function and makes sure its output messages maintain the order of its input 271 | messages. This name is due to [@fronx](https://github.com/fronx), on the basis 272 | that, by preserving message order, the abstraction preserves the *mapping* 273 | between input and output messages. To use our simple `deflate` extension from 274 | above: 275 | 276 | ```js 277 | var functor = new Functor(deflate, 'outgoing'); 278 | 279 | functor.call({ data: large }, function() { 280 | console.log(1, 'large'); 281 | }); 282 | 283 | functor.call({ data: small }, function() { 284 | console.log(2, 'small'); 285 | }); 286 | 287 | /* -> 1 'large' 288 | 2 'small' */ 289 | ``` 290 | 291 | A `Cell` contains two of these, one for each direction: 292 | 293 | 294 | +-----------------------+ 295 | +---->| Functor [A, incoming] | 296 | +----------+ | +-----------------------+ 297 | | Cell [A] o------+ 298 | +----------+ | +-----------------------+ 299 | +---->| Functor [A, outgoing] | 300 | +-----------------------+ 301 | 302 | 303 | This satisfies the message transformation requirements: the `Pipeline` simply 304 | loops over the cells in the appropriate direction to transform each message. 305 | Because each `Cell` will preserve message order, we can pass a message to the 306 | next `Cell` in line as soon as the current `Cell` returns it. This gives each 307 | `Cell` all the messages in order while maximising throughput. 308 | 309 | 310 | ### Session closing 311 | 312 | We want to close each session as soon as possible, after all existing messages 313 | have drained. To do this, each `Cell` begins with a pending message counter in 314 | each direction, labelled `in` and `out` below. 315 | 316 | 317 | +----------+ 318 | | Pipeline | 319 | +-----o----+ 320 | | 321 | +---------------+---------------+ 322 | | | | 323 | +-----o----+ +-----o----+ +-----o----+ 324 | | Cell [A] | | Cell [B] | | Cell [C] | 325 | +----------+ +----------+ +----------+ 326 | in: 0 in: 0 in: 0 327 | out: 0 out: 0 out: 0 328 | 329 | 330 | When a message *m1* enters the pipeline, say in the `outgoing` direction, we 331 | increment the `pending.out` counter on all cells immediately. 332 | 333 | 334 | +----------+ 335 | m1 => | Pipeline | 336 | +-----o----+ 337 | | 338 | +---------------+---------------+ 339 | | | | 340 | +-----o----+ +-----o----+ +-----o----+ 341 | | Cell [A] | | Cell [B] | | Cell [C] | 342 | +----------+ +----------+ +----------+ 343 | in: 0 in: 0 in: 0 344 | out: 1 out: 1 out: 1 345 | 346 | 347 | *m1* is handed off to `A`, meanwhile a second message `m2` arrives in the same 348 | direction. All `pending.out` counters are again incremented. 349 | 350 | 351 | +----------+ 352 | m2 => | Pipeline | 353 | +-----o----+ 354 | | 355 | +---------------+---------------+ 356 | m1 | | | 357 | +-----o----+ +-----o----+ +-----o----+ 358 | | Cell [A] | | Cell [B] | | Cell [C] | 359 | +----------+ +----------+ +----------+ 360 | in: 0 in: 0 in: 0 361 | out: 2 out: 2 out: 2 362 | 363 | 364 | When the first cell's `A.outgoing` functor finishes processing *m1*, the first 365 | `pending.out` counter is decremented and *m1* is handed off to cell `B`. 366 | 367 | 368 | +----------+ 369 | | Pipeline | 370 | +-----o----+ 371 | | 372 | +---------------+---------------+ 373 | m2 | m1 | | 374 | +-----o----+ +-----o----+ +-----o----+ 375 | | Cell [A] | | Cell [B] | | Cell [C] | 376 | +----------+ +----------+ +----------+ 377 | in: 0 in: 0 in: 0 378 | out: 1 out: 2 out: 2 379 | 380 | 381 | 382 | As `B` finishes with *m1*, and as `A` finishes with *m2*, the `pending.out` 383 | counters continue to decrement. 384 | 385 | 386 | +----------+ 387 | | Pipeline | 388 | +-----o----+ 389 | | 390 | +---------------+---------------+ 391 | | m2 | m1 | 392 | +-----o----+ +-----o----+ +-----o----+ 393 | | Cell [A] | | Cell [B] | | Cell [C] | 394 | +----------+ +----------+ +----------+ 395 | in: 0 in: 0 in: 0 396 | out: 0 out: 1 out: 2 397 | 398 | 399 | 400 | Say `C` is a little slow, and begins processing *m2* while still processing 401 | *m1*. That's fine, the `Functor` mechanism will keep *m1* ahead of *m2* in the 402 | output. 403 | 404 | 405 | +----------+ 406 | | Pipeline | 407 | +-----o----+ 408 | | 409 | +---------------+---------------+ 410 | | | m2 | m1 411 | +-----o----+ +-----o----+ +-----o----+ 412 | | Cell [A] | | Cell [B] | | Cell [C] | 413 | +----------+ +----------+ +----------+ 414 | in: 0 in: 0 in: 0 415 | out: 0 out: 0 out: 2 416 | 417 | 418 | Once all messages are dealt with, the counters return to `0`. 419 | 420 | 421 | +----------+ 422 | | Pipeline | 423 | +-----o----+ 424 | | 425 | +---------------+---------------+ 426 | | | | 427 | +-----o----+ +-----o----+ +-----o----+ 428 | | Cell [A] | | Cell [B] | | Cell [C] | 429 | +----------+ +----------+ +----------+ 430 | in: 0 in: 0 in: 0 431 | out: 0 out: 0 out: 0 432 | 433 | 434 | The same process applies in the `incoming` direction, the only difference being 435 | that messages are passed to `C` first. 436 | 437 | This makes closing the sessions quite simple. When the driver wants to close the 438 | socket, it calls `Pipeline.close()`. This *immediately* calls `close()` on all 439 | the cells. If a cell has `in == out == 0`, then it immediately calls 440 | `session.close()`. Otherwise, it stores the closing call and defers it until 441 | `in` and `out` have both ticked down to zero. The pipeline will not accept new 442 | messages after `close()` has been called, so we know the pending counts will not 443 | increase after this point. 444 | 445 | This means each session is closed as soon as possible: `A` can close while the 446 | slow `C` session is still working, because it knows there are no more messages 447 | on the way. Similarly, `C` will defer closing if `close()` is called while *m1* 448 | is still in `B`, and *m2* in `A`, because its pending count means it knows it 449 | has work yet to do, even if it's not received those messages yet. This concern 450 | cannot be addressed by extensions acting only on their own local state, unless 451 | we pollute individual extensions by making them all implement this same 452 | mechanism. 453 | 454 | The actual closing API at each level is slightly different: 455 | 456 | type Session = { 457 | close :: () -> () 458 | } 459 | 460 | type Cell = { 461 | close :: () -> Promise () 462 | } 463 | 464 | type Pipeline = { 465 | close :: Callback -> () 466 | } 467 | 468 | This might appear inconsistent so it's worth explaining. Remember that a 469 | `Pipeline` holds a list of `Cell` objects, each wrapping a `Session`. The driver 470 | talks (via the `Extensions` API) to the `Pipeline` interface, and it wants 471 | `Pipeline.close()` to do two things: close all the sessions, and tell me when 472 | it's safe to start the closing procedure (i.e. when all messages have drained 473 | from the pipe and been handed off to the application or socket). A callback API 474 | works well for that. 475 | 476 | At the other end of the stack, `Session.close()` is a nullary void method with 477 | no callback or promise API because we don't care what it does, and whatever it 478 | does do will not block the WebSocket protocol; we're not going to hold off 479 | processing messages while a session closes its de/compression context. We just 480 | tell it to close itself, and don't want to wait while it does that. 481 | 482 | In the middle, `Cell.close()` returns a promise rather than using a callback. 483 | This is for two reasons. First, `Cell.close()` might not do anything 484 | immediately, it might have to defer its effect while messages drain. So, if 485 | given a callback, it would have to store it in a queue for later execution. 486 | Callbacks work fine if your method does something and can then invoke the 487 | callback itself, but if you need to store callbacks somewhere so another method 488 | can execute them, a promise is a better fit. Second, it better serves the 489 | purposes of `Pipeline.close()`: it wants to call `close()` on each of a list of 490 | cells, and wait for all of them to finish. This is simple and idiomatic using 491 | promises: 492 | 493 | ```js 494 | var closed = cells.map((cell) => cell.close()); 495 | Promise.all(closed).then(callback); 496 | ``` 497 | 498 | (We don't actually use a full *Promises/A+* compatible promise here, we use a 499 | much simplified construction that acts as a callback aggregater and resolves 500 | synchronously and does not support chaining, but the principle is the same.) 501 | 502 | 503 | ### Error handling 504 | 505 | We've not mentioned error handling so far but it bears some explanation. The 506 | above counter system still applies, but behaves slightly differently in the 507 | presence of errors. 508 | 509 | Say we push three messages into the pipe in the outgoing direction: 510 | 511 | 512 | +----------+ 513 | m3, m2, m1 => | Pipeline | 514 | +-----o----+ 515 | | 516 | +---------------+---------------+ 517 | | | | 518 | +-----o----+ +-----o----+ +-----o----+ 519 | | Cell [A] | | Cell [B] | | Cell [C] | 520 | +----------+ +----------+ +----------+ 521 | in: 0 in: 0 in: 0 522 | out: 3 out: 3 out: 3 523 | 524 | 525 | They pass through the cells successfully up to this point: 526 | 527 | 528 | +----------+ 529 | | Pipeline | 530 | +-----o----+ 531 | | 532 | +---------------+---------------+ 533 | m3 | m2 | m1 | 534 | +-----o----+ +-----o----+ +-----o----+ 535 | | Cell [A] | | Cell [B] | | Cell [C] | 536 | +----------+ +----------+ +----------+ 537 | in: 0 in: 0 in: 0 538 | out: 1 out: 2 out: 3 539 | 540 | 541 | At this point, session `B` produces an error while processing *m2*, that is *m2* 542 | becomes *e2*. *m1* is still in the pipeline, and *m3* is queued behind *m2*. 543 | What ought to happen is that *m1* is handed off to the socket, then *m2* is 544 | released to the driver, which will detect the error and begin closing the 545 | socket. No further processing should be done on *m3* and it should not be 546 | released to the driver after the error is emitted. 547 | 548 | To handle this, we allow errors to pass down the pipeline just like messages do, 549 | to maintain ordering. But, once a cell sees its session produce an error, or it 550 | receives an error from upstream, it should refuse to accept any further 551 | messages. Session `B` might have begun processing *m3* by the time it produces 552 | the error *e2*, but `C` will have been given *e2* before it receives *m3*, and 553 | can simply drop *m3*. 554 | 555 | Now, say *e2* reaches the slow session `C` while *m1* is still present, 556 | meanwhile *m3* has been dropped. `C` will never receive *m3* since it will have 557 | been dropped upstream. Under the present model, its `out` counter will be `3` 558 | but it is only going to emit two more values: *m1* and *e2*. In order for 559 | closing to work, we need to decrement `out` to reflect this. The situation 560 | should look like this: 561 | 562 | 563 | +----------+ 564 | | Pipeline | 565 | +-----o----+ 566 | | 567 | +---------------+---------------+ 568 | | | e2 | m1 569 | +-----o----+ +-----o----+ +-----o----+ 570 | | Cell [A] | | Cell [B] | | Cell [C] | 571 | +----------+ +----------+ +----------+ 572 | in: 0 in: 0 in: 0 573 | out: 0 out: 0 out: 2 574 | 575 | 576 | When a cell sees its session emit an error, or when it receives an error from 577 | upstream, it sets its pending count in the appropriate direction to equal the 578 | number of messages it is *currently* processing. It will not accept any messages 579 | after it sees the error, so this will allow the counter to reach zero. 580 | 581 | Note that while *e2* is in the pipeline, `Pipeline` should drop any further 582 | messages in the outgoing direction, but should continue to accept incoming 583 | messages. Until *e2* makes it out of the pipe to the driver, behind previous 584 | successful messages, the driver does not know an error has happened, and a 585 | message may arrive over the socket and make it all the way through the incoming 586 | pipe in the meantime. We only halt processing in the affected direction to avoid 587 | doing unnecessary work since messages arriving after an error should not be 588 | processed. 589 | 590 | Some unnecessary work may happen, for example any messages already in the 591 | pipeline following *m2* will be processed by `A`, since it's upstream of the 592 | error. Those messages will be dropped by `B`. 593 | 594 | 595 | ## Alternative ideas 596 | 597 | I am considering implementing `Functor` as an object-mode transform stream 598 | rather than what is essentially an async function. Being object-mode, a stream 599 | would preserve message boundaries and would also possibly help address 600 | back-pressure. I'm not sure whether this would require external API changes so 601 | that such streams could be connected to the downstream driver's streams. 602 | 603 | 604 | ## Acknowledgements 605 | 606 | Credit is due to [@mnowster](https://github.com/mnowster) for helping with the 607 | design and to [@fronx](https://github.com/fronx) for helping name things. 608 | -------------------------------------------------------------------------------- /lib/pipeline/cell.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Functor = require('./functor'), 4 | Pledge = require('./pledge'); 5 | 6 | var Cell = function(tuple) { 7 | this._ext = tuple[0]; 8 | this._session = tuple[1]; 9 | 10 | this._functors = { 11 | incoming: new Functor(this._session, 'processIncomingMessage'), 12 | outgoing: new Functor(this._session, 'processOutgoingMessage') 13 | }; 14 | }; 15 | 16 | Cell.prototype.pending = function(direction) { 17 | var functor = this._functors[direction]; 18 | if (!functor._stopped) functor.pending += 1; 19 | }; 20 | 21 | Cell.prototype.incoming = function(error, message, callback, context) { 22 | this._exec('incoming', error, message, callback, context); 23 | }; 24 | 25 | Cell.prototype.outgoing = function(error, message, callback, context) { 26 | this._exec('outgoing', error, message, callback, context); 27 | }; 28 | 29 | Cell.prototype.close = function() { 30 | this._closed = this._closed || new Pledge(); 31 | this._doClose(); 32 | return this._closed; 33 | }; 34 | 35 | Cell.prototype._exec = function(direction, error, message, callback, context) { 36 | this._functors[direction].call(error, message, function(err, msg) { 37 | if (err) err.message = this._ext.name + ': ' + err.message; 38 | callback.call(context, err, msg); 39 | this._doClose(); 40 | }, this); 41 | }; 42 | 43 | Cell.prototype._doClose = function() { 44 | var fin = this._functors.incoming, 45 | fout = this._functors.outgoing; 46 | 47 | if (!this._closed || fin.pending + fout.pending !== 0) return; 48 | if (this._session) this._session.close(); 49 | this._session = null; 50 | this._closed.done(); 51 | }; 52 | 53 | module.exports = Cell; 54 | -------------------------------------------------------------------------------- /lib/pipeline/functor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var RingBuffer = require('./ring_buffer'); 4 | 5 | var Functor = function(session, method) { 6 | this._session = session; 7 | this._method = method; 8 | this._queue = new RingBuffer(Functor.QUEUE_SIZE); 9 | this._stopped = false; 10 | this.pending = 0; 11 | }; 12 | 13 | Functor.QUEUE_SIZE = 8; 14 | 15 | Functor.prototype.call = function(error, message, callback, context) { 16 | if (this._stopped) return; 17 | 18 | var record = { error: error, message: message, callback: callback, context: context, done: false }, 19 | called = false, 20 | self = this; 21 | 22 | this._queue.push(record); 23 | 24 | if (record.error) { 25 | record.done = true; 26 | this._stop(); 27 | return this._flushQueue(); 28 | } 29 | 30 | var handler = function(err, msg) { 31 | if (!(called ^ (called = true))) return; 32 | 33 | if (err) { 34 | self._stop(); 35 | record.error = err; 36 | record.message = null; 37 | } else { 38 | record.message = msg; 39 | } 40 | 41 | record.done = true; 42 | self._flushQueue(); 43 | }; 44 | 45 | try { 46 | this._session[this._method](message, handler); 47 | } catch (err) { 48 | handler(err); 49 | } 50 | }; 51 | 52 | Functor.prototype._stop = function() { 53 | this.pending = this._queue.length; 54 | this._stopped = true; 55 | }; 56 | 57 | Functor.prototype._flushQueue = function() { 58 | var queue = this._queue, record; 59 | 60 | while (queue.length > 0 && queue.peek().done) { 61 | record = queue.shift(); 62 | if (record.error) { 63 | this.pending = 0; 64 | queue.clear(); 65 | } else { 66 | this.pending -= 1; 67 | } 68 | record.callback.call(record.context, record.error, record.message); 69 | } 70 | }; 71 | 72 | module.exports = Functor; 73 | -------------------------------------------------------------------------------- /lib/pipeline/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Cell = require('./cell'), 4 | Pledge = require('./pledge'); 5 | 6 | var Pipeline = function(sessions) { 7 | this._cells = sessions.map(function(session) { return new Cell(session) }); 8 | this._stopped = { incoming: false, outgoing: false }; 9 | }; 10 | 11 | Pipeline.prototype.processIncomingMessage = function(message, callback, context) { 12 | if (this._stopped.incoming) return; 13 | this._loop('incoming', this._cells.length - 1, -1, -1, message, callback, context); 14 | }; 15 | 16 | Pipeline.prototype.processOutgoingMessage = function(message, callback, context) { 17 | if (this._stopped.outgoing) return; 18 | this._loop('outgoing', 0, this._cells.length, 1, message, callback, context); 19 | }; 20 | 21 | Pipeline.prototype.close = function(callback, context) { 22 | this._stopped = { incoming: true, outgoing: true }; 23 | 24 | var closed = this._cells.map(function(a) { return a.close() }); 25 | if (callback) 26 | Pledge.all(closed).then(function() { callback.call(context) }); 27 | }; 28 | 29 | Pipeline.prototype._loop = function(direction, start, end, step, message, callback, context) { 30 | var cells = this._cells, 31 | n = cells.length, 32 | self = this; 33 | 34 | while (n--) cells[n].pending(direction); 35 | 36 | var pipe = function(index, error, msg) { 37 | if (index === end) return callback.call(context, error, msg); 38 | 39 | cells[index][direction](error, msg, function(err, m) { 40 | if (err) self._stopped[direction] = true; 41 | pipe(index + step, err, m); 42 | }); 43 | }; 44 | pipe(start, null, message); 45 | }; 46 | 47 | module.exports = Pipeline; 48 | -------------------------------------------------------------------------------- /lib/pipeline/pledge.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var RingBuffer = require('./ring_buffer'); 4 | 5 | var Pledge = function() { 6 | this._complete = false; 7 | this._callbacks = new RingBuffer(Pledge.QUEUE_SIZE); 8 | }; 9 | 10 | Pledge.QUEUE_SIZE = 4; 11 | 12 | Pledge.all = function(list) { 13 | var pledge = new Pledge(), 14 | pending = list.length, 15 | n = pending; 16 | 17 | if (pending === 0) pledge.done(); 18 | 19 | while (n--) list[n].then(function() { 20 | pending -= 1; 21 | if (pending === 0) pledge.done(); 22 | }); 23 | return pledge; 24 | }; 25 | 26 | Pledge.prototype.then = function(callback) { 27 | if (this._complete) callback(); 28 | else this._callbacks.push(callback); 29 | }; 30 | 31 | Pledge.prototype.done = function() { 32 | this._complete = true; 33 | var callbacks = this._callbacks, callback; 34 | while (callback = callbacks.shift()) callback(); 35 | }; 36 | 37 | module.exports = Pledge; 38 | -------------------------------------------------------------------------------- /lib/pipeline/ring_buffer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var RingBuffer = function(bufferSize) { 4 | this._bufferSize = bufferSize; 5 | this.clear(); 6 | }; 7 | 8 | RingBuffer.prototype.clear = function() { 9 | this._buffer = new Array(this._bufferSize); 10 | this._ringOffset = 0; 11 | this._ringSize = this._bufferSize; 12 | this._head = 0; 13 | this._tail = 0; 14 | this.length = 0; 15 | }; 16 | 17 | RingBuffer.prototype.push = function(value) { 18 | var expandBuffer = false, 19 | expandRing = false; 20 | 21 | if (this._ringSize < this._bufferSize) { 22 | expandBuffer = (this._tail === 0); 23 | } else if (this._ringOffset === this._ringSize) { 24 | expandBuffer = true; 25 | expandRing = (this._tail === 0); 26 | } 27 | 28 | if (expandBuffer) { 29 | this._tail = this._bufferSize; 30 | this._buffer = this._buffer.concat(new Array(this._bufferSize)); 31 | this._bufferSize = this._buffer.length; 32 | 33 | if (expandRing) 34 | this._ringSize = this._bufferSize; 35 | } 36 | 37 | this._buffer[this._tail] = value; 38 | this.length += 1; 39 | if (this._tail < this._ringSize) this._ringOffset += 1; 40 | this._tail = (this._tail + 1) % this._bufferSize; 41 | }; 42 | 43 | RingBuffer.prototype.peek = function() { 44 | if (this.length === 0) return void 0; 45 | return this._buffer[this._head]; 46 | }; 47 | 48 | RingBuffer.prototype.shift = function() { 49 | if (this.length === 0) return void 0; 50 | 51 | var value = this._buffer[this._head]; 52 | this._buffer[this._head] = void 0; 53 | this.length -= 1; 54 | this._ringOffset -= 1; 55 | 56 | if (this._ringOffset === 0 && this.length > 0) { 57 | this._head = this._ringSize; 58 | this._ringOffset = this.length; 59 | this._ringSize = this._bufferSize; 60 | } else { 61 | this._head = (this._head + 1) % this._ringSize; 62 | } 63 | return value; 64 | }; 65 | 66 | module.exports = RingBuffer; 67 | -------------------------------------------------------------------------------- /lib/websocket_extensions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Parser = require('./parser'), 4 | Pipeline = require('./pipeline'); 5 | 6 | var Extensions = function() { 7 | this._rsv1 = this._rsv2 = this._rsv3 = null; 8 | 9 | this._byName = {}; 10 | this._inOrder = []; 11 | this._sessions = []; 12 | this._index = {}; 13 | }; 14 | 15 | Extensions.MESSAGE_OPCODES = [1, 2]; 16 | 17 | var instance = { 18 | add: function(ext) { 19 | if (typeof ext.name !== 'string') throw new TypeError('extension.name must be a string'); 20 | if (ext.type !== 'permessage') throw new TypeError('extension.type must be "permessage"'); 21 | 22 | if (typeof ext.rsv1 !== 'boolean') throw new TypeError('extension.rsv1 must be true or false'); 23 | if (typeof ext.rsv2 !== 'boolean') throw new TypeError('extension.rsv2 must be true or false'); 24 | if (typeof ext.rsv3 !== 'boolean') throw new TypeError('extension.rsv3 must be true or false'); 25 | 26 | if (this._byName.hasOwnProperty(ext.name)) 27 | throw new TypeError('An extension with name "' + ext.name + '" is already registered'); 28 | 29 | this._byName[ext.name] = ext; 30 | this._inOrder.push(ext); 31 | }, 32 | 33 | generateOffer: function() { 34 | var sessions = [], 35 | offer = [], 36 | index = {}; 37 | 38 | this._inOrder.forEach(function(ext) { 39 | var session = ext.createClientSession(); 40 | if (!session) return; 41 | 42 | var record = [ext, session]; 43 | sessions.push(record); 44 | index[ext.name] = record; 45 | 46 | var offers = session.generateOffer(); 47 | offers = offers ? [].concat(offers) : []; 48 | 49 | offers.forEach(function(off) { 50 | offer.push(Parser.serializeParams(ext.name, off)); 51 | }, this); 52 | }, this); 53 | 54 | this._sessions = sessions; 55 | this._index = index; 56 | 57 | return offer.length > 0 ? offer.join(', ') : null; 58 | }, 59 | 60 | activate: function(header) { 61 | var responses = Parser.parseHeader(header), 62 | sessions = []; 63 | 64 | responses.eachOffer(function(name, params) { 65 | var record = this._index[name]; 66 | 67 | if (!record) 68 | throw new Error('Server sent an extension response for unknown extension "' + name + '"'); 69 | 70 | var ext = record[0], 71 | session = record[1], 72 | reserved = this._reserved(ext); 73 | 74 | if (reserved) 75 | throw new Error('Server sent two extension responses that use the RSV' + 76 | reserved[0] + ' bit: "' + 77 | reserved[1] + '" and "' + ext.name + '"'); 78 | 79 | if (session.activate(params) !== true) 80 | throw new Error('Server sent unacceptable extension parameters: ' + 81 | Parser.serializeParams(name, params)); 82 | 83 | this._reserve(ext); 84 | sessions.push(record); 85 | }, this); 86 | 87 | this._sessions = sessions; 88 | this._pipeline = new Pipeline(sessions); 89 | }, 90 | 91 | generateResponse: function(header) { 92 | var sessions = [], 93 | response = [], 94 | offers = Parser.parseHeader(header); 95 | 96 | this._inOrder.forEach(function(ext) { 97 | var offer = offers.byName(ext.name); 98 | if (offer.length === 0 || this._reserved(ext)) return; 99 | 100 | var session = ext.createServerSession(offer); 101 | if (!session) return; 102 | 103 | this._reserve(ext); 104 | sessions.push([ext, session]); 105 | response.push(Parser.serializeParams(ext.name, session.generateResponse())); 106 | }, this); 107 | 108 | this._sessions = sessions; 109 | this._pipeline = new Pipeline(sessions); 110 | 111 | return response.length > 0 ? response.join(', ') : null; 112 | }, 113 | 114 | validFrameRsv: function(frame) { 115 | var allowed = { rsv1: false, rsv2: false, rsv3: false }, 116 | ext; 117 | 118 | if (Extensions.MESSAGE_OPCODES.indexOf(frame.opcode) >= 0) { 119 | for (var i = 0, n = this._sessions.length; i < n; i++) { 120 | ext = this._sessions[i][0]; 121 | allowed.rsv1 = allowed.rsv1 || ext.rsv1; 122 | allowed.rsv2 = allowed.rsv2 || ext.rsv2; 123 | allowed.rsv3 = allowed.rsv3 || ext.rsv3; 124 | } 125 | } 126 | 127 | return (allowed.rsv1 || !frame.rsv1) && 128 | (allowed.rsv2 || !frame.rsv2) && 129 | (allowed.rsv3 || !frame.rsv3); 130 | }, 131 | 132 | processIncomingMessage: function(message, callback, context) { 133 | this._pipeline.processIncomingMessage(message, callback, context); 134 | }, 135 | 136 | processOutgoingMessage: function(message, callback, context) { 137 | this._pipeline.processOutgoingMessage(message, callback, context); 138 | }, 139 | 140 | close: function(callback, context) { 141 | if (!this._pipeline) return callback.call(context); 142 | this._pipeline.close(callback, context); 143 | }, 144 | 145 | _reserve: function(ext) { 146 | this._rsv1 = this._rsv1 || (ext.rsv1 && ext.name); 147 | this._rsv2 = this._rsv2 || (ext.rsv2 && ext.name); 148 | this._rsv3 = this._rsv3 || (ext.rsv3 && ext.name); 149 | }, 150 | 151 | _reserved: function(ext) { 152 | if (this._rsv1 && ext.rsv1) return [1, this._rsv1]; 153 | if (this._rsv2 && ext.rsv2) return [2, this._rsv2]; 154 | if (this._rsv3 && ext.rsv3) return [3, this._rsv3]; 155 | return false; 156 | } 157 | }; 158 | 159 | for (var key in instance) 160 | Extensions.prototype[key] = instance[key]; 161 | 162 | module.exports = Extensions; 163 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "websocket-extensions", 3 | "description": "Generic extension manager for WebSocket connections", 4 | "homepage": "http://github.com/faye/websocket-extensions-node", 5 | "author": "James Coglan (http://jcoglan.com/)", 6 | "keywords": [ 7 | "websocket" 8 | ], 9 | "license": "Apache-2.0", 10 | "version": "0.1.4", 11 | "engines": { 12 | "node": ">=0.8.0" 13 | }, 14 | "files": [ 15 | "lib" 16 | ], 17 | "main": "./lib/websocket_extensions", 18 | "devDependencies": { 19 | "jstest": "*" 20 | }, 21 | "scripts": { 22 | "test": "jstest spec/runner.js" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git://github.com/faye/websocket-extensions-node.git" 27 | }, 28 | "bugs": "http://github.com/faye/websocket-extensions-node/issues" 29 | } 30 | -------------------------------------------------------------------------------- /spec/parser_spec.js: -------------------------------------------------------------------------------- 1 | var Parser = require('../lib/parser'), 2 | test = require('jstest').Test 3 | 4 | test.describe("Parser", function() { with(this) { 5 | describe("parseHeader", function() { with(this) { 6 | define("parse", function(string) { 7 | return Parser.parseHeader(string).toArray() 8 | }) 9 | 10 | it("parses an empty header", function() { with(this) { 11 | assertEqual( [], parse('') ) 12 | }}) 13 | 14 | it("parses a missing header", function() { with(this) { 15 | assertEqual( [], parse(undefined) ) 16 | }}) 17 | 18 | it("throws on invalid input", function() { with(this) { 19 | assertThrows(SyntaxError, function() { parse('a,') }) 20 | }}) 21 | 22 | it("parses one offer with no params", function() { with(this) { 23 | assertEqual( [{ name: "a", params: {}}], 24 | parse('a') ) 25 | }}) 26 | 27 | it("parses two offers with no params", function() { with(this) { 28 | assertEqual( [{ name: "a", params: {}}, { name: "b", params: {}}], 29 | parse('a, b') ) 30 | }}) 31 | 32 | it("parses a duplicate offer name", function() { with(this) { 33 | assertEqual( [{ name: "a", params: {}}, { name: "a", params: {}}], 34 | parse('a, a') ) 35 | }}) 36 | 37 | it("parses a flag", function() { with(this) { 38 | assertEqual( [{ name: "a", params: { b: true }}], 39 | parse('a; b') ) 40 | }}) 41 | 42 | it("parses an unquoted param", function() { with(this) { 43 | assertEqual( [{ name: "a", params: { b: 1 }}], 44 | parse('a; b=1') ) 45 | }}) 46 | 47 | it("parses a quoted param", function() { with(this) { 48 | assertEqual( [{ name: "a", params: { b: 'hi, "there' }}], 49 | parse('a; b="hi, \\"there"') ) 50 | }}) 51 | 52 | it("parses multiple params", function() { with(this) { 53 | assertEqual( [{ name: "a", params: { b: true, c: 1, d: 'hi' }}], 54 | parse('a; b; c=1; d="hi"') ) 55 | }}) 56 | 57 | it("parses duplicate params", function() { with(this) { 58 | assertEqual( [{ name: "a", params: { b: [true, 'hi'], c: 1 }}], 59 | parse('a; b; c=1; b="hi"') ) 60 | }}) 61 | 62 | it("parses multiple complex offers", function() { with(this) { 63 | assertEqual( [{ name: "a", params: { b: 1 }}, 64 | { name: "c", params: {}}, 65 | { name: "b", params: { d: true }}, 66 | { name: "c", params: { e: ['hi, there', true] }}, 67 | { name: "a", params: { b: true }}], 68 | parse('a; b=1, c, b; d, c; e="hi, there"; e, a; b') ) 69 | }}) 70 | 71 | it("parses an extension name that shadows an Object property", function() { with(this) { 72 | assertEqual( [{ name: "hasOwnProperty", params: {}}], 73 | parse('hasOwnProperty') ) 74 | }}) 75 | 76 | it("parses an extension param that shadows an Object property", function() { with(this) { 77 | var result = parse('foo; hasOwnProperty; x')[0] 78 | assertEqual( result.params.hasOwnProperty, true ) 79 | }}) 80 | 81 | it("rejects a string missing its closing quote", function() { with(this) { 82 | assertThrows(SyntaxError, function() { 83 | 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') 84 | }) 85 | }}) 86 | }}) 87 | 88 | describe("serializeParams", function() { with(this) { 89 | it("serializes empty params", function() { with(this) { 90 | assertEqual( 'a', Parser.serializeParams('a', {}) ) 91 | }}) 92 | 93 | it("serializes a flag", function() { with(this) { 94 | assertEqual( 'a; b', Parser.serializeParams('a', { b: true }) ) 95 | }}) 96 | 97 | it("serializes an unquoted param", function() { with(this) { 98 | assertEqual( 'a; b=42', Parser.serializeParams('a', { b: '42' }) ) 99 | }}) 100 | 101 | it("serializes a quoted param", function() { with(this) { 102 | assertEqual( 'a; b="hi, there"', Parser.serializeParams('a', { b: 'hi, there' }) ) 103 | }}) 104 | 105 | it("serializes multiple params", function() { with(this) { 106 | assertEqual( 'a; b; c=1; d=hi', Parser.serializeParams('a', { b: true, c: 1, d: 'hi' }) ) 107 | }}) 108 | 109 | it("serializes duplicate params", function() { with(this) { 110 | assertEqual( 'a; b; b=hi; c=1', Parser.serializeParams('a', { b: [true, 'hi'], c: 1 }) ) 111 | }}) 112 | }}) 113 | }}) 114 | -------------------------------------------------------------------------------- /spec/runner.js: -------------------------------------------------------------------------------- 1 | require('./parser_spec') 2 | require('./websocket_extensions_spec') 3 | -------------------------------------------------------------------------------- /spec/websocket_extensions_spec.js: -------------------------------------------------------------------------------- 1 | var Extensions = require("../lib/websocket_extensions"), 2 | test = require("jstest").Test 3 | FakeClock = test.FakeClock 4 | 5 | test.describe("Extensions", function() { with(this) { 6 | before(function() { with(this) { 7 | this.extensions = new Extensions() 8 | 9 | this.ext = { name: "deflate", type: "permessage", rsv1: true, rsv2: false, rsv3: false } 10 | this.session = {} 11 | }}) 12 | 13 | describe("add", function() { with(this) { 14 | it("does not throw on valid extensions", function() { with(this) { 15 | assertNothingThrown(function() { extensions.add(ext) }) 16 | }}) 17 | 18 | it("throws if ext.name is not a string", function() { with(this) { 19 | ext.name = 42 20 | assertThrows(TypeError, function() { extensions.add(ext) }) 21 | }}) 22 | 23 | it("throws if ext.rsv1 is not a boolean", function() { with(this) { 24 | ext.rsv1 = 42 25 | assertThrows(TypeError, function() { extensions.add(ext) }) 26 | }}) 27 | 28 | it("throws if ext.rsv2 is not a boolean", function() { with(this) { 29 | ext.rsv2 = 42 30 | assertThrows(TypeError, function() { extensions.add(ext) }) 31 | }}) 32 | 33 | it("throws if ext.rsv3 is not a boolean", function() { with(this) { 34 | ext.rsv3 = 42 35 | assertThrows(TypeError, function() { extensions.add(ext) }) 36 | }}) 37 | }}) 38 | 39 | describe("client sessions", function() { with(this) { 40 | before(function() { with(this) { 41 | this.offer = { mode: "compress" } 42 | stub(ext, "createClientSession").returns(session) 43 | stub(session, "generateOffer").returns(offer) 44 | extensions.add(ext) 45 | 46 | this.conflict = { name: "tar", type: "permessage", rsv1: true, rsv2: false, rsv3: false } 47 | this.conflictSession = {} 48 | stub(conflict, "createClientSession").returns(conflictSession) 49 | stub(conflictSession, "generateOffer").returns({ gzip: true }) 50 | 51 | this.nonconflict = { name: "reverse", type: "permessage", rsv1: false, rsv2: true, rsv3: false } 52 | this.nonconflictSession = {} 53 | stub(nonconflict, "createClientSession").returns(nonconflictSession) 54 | stub(nonconflictSession, "generateOffer").returns({ utf8: true }) 55 | 56 | stub(session, "activate").returns(true) 57 | stub(conflictSession, "activate").returns(true) 58 | stub(nonconflictSession, "activate").returns(true) 59 | }}) 60 | 61 | describe("generateOffer", function() { with(this) { 62 | it("asks the extension to create a client session", function() { with(this) { 63 | expect(ext, "createClientSession").exactly(1).returning(session) 64 | extensions.generateOffer() 65 | }}) 66 | 67 | it("asks the session to generate an offer", function() { with(this) { 68 | expect(session, "generateOffer").exactly(1).returning(offer) 69 | extensions.generateOffer() 70 | }}) 71 | 72 | it("does not ask the session to generate an offer if the extension doesn't build a session", function() { with(this) { 73 | stub(ext, "createClientSession").returns(null) 74 | expect(session, "generateOffer").exactly(0) 75 | extensions.generateOffer() 76 | }}) 77 | 78 | it("returns the serialized offer from the session", function() { with(this) { 79 | assertEqual( "deflate; mode=compress", extensions.generateOffer() ) 80 | }}) 81 | 82 | it("returns a null offer from the session", function() { with(this) { 83 | stub(session, "generateOffer").returns(null) 84 | assertEqual( null, extensions.generateOffer() ) 85 | }}) 86 | 87 | it("returns multiple serialized offers from the session", function() { with(this) { 88 | stub(session, "generateOffer").returns([offer, {}]) 89 | assertEqual( "deflate; mode=compress, deflate", extensions.generateOffer() ) 90 | }}) 91 | 92 | it("returns serialized offers from multiple sessions", function() { with(this) { 93 | extensions.add(nonconflict) 94 | assertEqual( "deflate; mode=compress, reverse; utf8", extensions.generateOffer() ) 95 | }}) 96 | 97 | it("generates offers for potentially conflicting extensions", function() { with(this) { 98 | extensions.add(conflict) 99 | assertEqual( "deflate; mode=compress, tar; gzip", extensions.generateOffer() ) 100 | }}) 101 | }}) 102 | 103 | describe("activate", function() { with(this) { 104 | before(function() { with(this) { 105 | extensions.add(conflict) 106 | extensions.add(nonconflict) 107 | extensions.generateOffer() 108 | }}) 109 | 110 | it("throws if given unregistered extensions", function() { with(this) { 111 | assertThrows(Error, function() { extensions.activate("xml") }) 112 | }}) 113 | 114 | it("does not throw if given registered extensions", function() { with(this) { 115 | assertNothingThrown(function() { extensions.activate("deflate") }) 116 | }}) 117 | 118 | it("does not throw if given only one potentially conflicting extension", function() { with(this) { 119 | assertNothingThrown(function() { extensions.activate("tar") }) 120 | }}) 121 | 122 | it("throws if two extensions conflict on RSV bits", function() { with(this) { 123 | assertThrows(Error, function() { extensions.activate("deflate, tar") }) 124 | }}) 125 | 126 | it("does not throw if given two non-conflicting extensions", function() { with(this) { 127 | assertNothingThrown(function() { extensions.activate("deflate, reverse") }) 128 | }}) 129 | 130 | it("activates one session with no params", function() { with(this) { 131 | expect(session, "activate").given({}).exactly(1).returning(true) 132 | extensions.activate("deflate") 133 | }}) 134 | 135 | it("activates one session with a boolean param", function() { with(this) { 136 | expect(session, "activate").given({ gzip: true }).exactly(1).returning(true) 137 | extensions.activate("deflate; gzip") 138 | }}) 139 | 140 | it("activates one session with a string param", function() { with(this) { 141 | expect(session, "activate").given({ mode: "compress" }).exactly(1).returning(true) 142 | extensions.activate("deflate; mode=compress") 143 | }}) 144 | 145 | it("activates multiple sessions", function() { with(this) { 146 | expect(session, "activate").given({ a: true }).exactly(1).returning(true) 147 | expect(nonconflictSession, "activate").given({ b: true }).exactly(1).returning(true) 148 | extensions.activate("deflate; a, reverse; b") 149 | }}) 150 | 151 | it("does not activate sessions not named in the header", function() { with(this) { 152 | expect(session, "activate").exactly(0) 153 | expect(nonconflictSession, "activate").exactly(1).returning(true) 154 | extensions.activate("reverse") 155 | }}) 156 | 157 | it("throws if session.activate() does not return true", function() { with(this) { 158 | stub(session, "activate").returns("yes") 159 | assertThrows(Error, function() { extensions.activate("deflate") }) 160 | }}) 161 | }}) 162 | 163 | describe("processIncomingMessage", function() { with(this) { 164 | before(function() { with(this) { 165 | extensions.add(conflict) 166 | extensions.add(nonconflict) 167 | extensions.generateOffer() 168 | 169 | stub(session, "processIncomingMessage", function(message, callback) { 170 | message.frames.push("deflate") 171 | callback(null, message) 172 | }) 173 | 174 | stub(nonconflictSession, "processIncomingMessage", function(message, callback) { 175 | message.frames.push("reverse") 176 | callback(null, message) 177 | }) 178 | }}) 179 | 180 | it("processes messages in the reverse order given in the server's response", function() { with(this) { 181 | extensions.activate("deflate, reverse") 182 | 183 | extensions.processIncomingMessage({ frames: [] }, function(error, message) { 184 | assertNull( error ) 185 | assertEqual( ["reverse", "deflate"], message.frames ) 186 | }) 187 | }}) 188 | 189 | it("yields an error if a session yields an error", function() { with(this) { 190 | extensions.activate("deflate") 191 | stub(session, "processIncomingMessage").yields([{ message: "ENOENT" }]) 192 | 193 | extensions.processIncomingMessage({ frames: [] }, function(error, message) { 194 | assertEqual( "deflate: ENOENT", error.message ) 195 | assertNull( message ) 196 | }) 197 | }}) 198 | 199 | it("does not call sessions after one has yielded an error", function() { with(this) { 200 | extensions.activate("deflate, reverse") 201 | stub(nonconflictSession, "processIncomingMessage").yields([{ message: "ENOENT" }]) 202 | 203 | expect(session, "processIncomingMessage").exactly(0) 204 | 205 | extensions.processIncomingMessage({ frames: [] }, function() {}) 206 | }}) 207 | }}) 208 | 209 | describe("processOutgoingMessage", function() { with(this) { 210 | before(function() { with(this) { 211 | extensions.add(conflict) 212 | extensions.add(nonconflict) 213 | extensions.generateOffer() 214 | 215 | stub(session, "processOutgoingMessage", function(message, callback) { 216 | message.frames.push("deflate") 217 | callback(null, message) 218 | }) 219 | 220 | stub(nonconflictSession, "processOutgoingMessage", function(message, callback) { 221 | message.frames.push("reverse") 222 | callback(null, message) 223 | }) 224 | }}) 225 | 226 | describe("error handling", function() { with(this) { 227 | include(FakeClock) 228 | 229 | sharedExamplesFor("handles errors", function() { with(this) { 230 | before(function() { with(this) { 231 | clock.stub() 232 | extensions.activate("deflate, reverse") 233 | 234 | stub(session, "processOutgoingMessage", function(message, callback) { 235 | setTimeout(function() { callback(null, message.concat("a")) }, 100) 236 | }) 237 | 238 | stub(nonconflictSession, "processOutgoingMessage", function(message, callback) { 239 | setTimeout(function() { callback(null, message.concat("b")) }, 100) 240 | }) 241 | 242 | stub(nonconflictSession, "processIncomingMessage", function(message, callback) { 243 | if (message[0] === 5) return emitError(callback) 244 | setTimeout(function() { callback(null, message.concat("c")) }, 50) 245 | }) 246 | 247 | stub(session, "processIncomingMessage", function(message, callback) { 248 | setTimeout(function() { callback(null, message.concat("d")) }, 100) 249 | }) 250 | 251 | stub(session, "close") 252 | stub(nonconflictSession, "close") 253 | 254 | this.messages = [] 255 | 256 | var push = function(error, message) { 257 | if (error) extensions.close(function() { messages.push("close") }) 258 | messages.push(message) 259 | } 260 | 261 | ;[1, 2, 3].forEach(function(n) { 262 | extensions.processOutgoingMessage([n], push) 263 | }) 264 | 265 | ;[4, 5, 6].forEach(function(n, i) { 266 | setTimeout(function() { 267 | extensions.processIncomingMessage([n], push) 268 | }, 20 * i) 269 | }) 270 | 271 | clock.tick(200) 272 | }}) 273 | 274 | it("allows the message before the error through to the end", function() { with(this) { 275 | assertEqual( [4, "c", "d"], messages[0] ) 276 | }}) 277 | 278 | it("yields the error to the end of the pipeline", function() { with(this) { 279 | assertNull( messages[1] ) 280 | }}) 281 | 282 | it("does not yield the message after the error", function() { with(this) { 283 | assertNotEqual( arrayIncluding([6, "c", "d"]), messages ) 284 | }}) 285 | 286 | it("yields all the messages in the direction unaffected by the error", function() { with(this) { 287 | assertEqual( [1, "a", "b"], messages[2] ) 288 | assertEqual( [2, "a", "b"], messages[3] ) 289 | assertEqual( [3, "a", "b"], messages[4] ) 290 | }}) 291 | 292 | it("closes after all messages are processed", function() { with(this) { 293 | assertEqual( "close", messages[5] ) 294 | assertEqual( 6, messages.length ) 295 | }}) 296 | }}) 297 | 298 | describe("with a sync error", function() { with(this) { 299 | define("emitError", function(callback) { 300 | throw new Error("sync error") 301 | }) 302 | 303 | itShouldBehaveLike("handles errors") 304 | }}) 305 | 306 | describe("with an async error", function() { with(this) { 307 | define("emitError", function(callback) { 308 | setTimeout(function() { callback(new Error("async error"), null) }, 10) 309 | }) 310 | 311 | itShouldBehaveLike("handles errors") 312 | }}) 313 | }}) 314 | 315 | describe("async processors", function() { with(this) { 316 | include(FakeClock) 317 | 318 | before(function() { with(this) { 319 | clock.stub() 320 | var tags = ["a", "b", "c", "d"] 321 | 322 | stub(session, "processOutgoingMessage", function(message, callback) { 323 | var time = message.frames.length === 0 ? 100 : 20 324 | message.frames.push(tags.shift()) 325 | setTimeout(function() { callback(null, message) }, time) 326 | }) 327 | 328 | stub(nonconflictSession, "processOutgoingMessage", function(message, callback) { 329 | var time = message.frames.length === 1 ? 100 : 20 330 | message.frames.push(tags.shift()) 331 | setTimeout(function() { callback(null, message) }, time) 332 | }) 333 | }}) 334 | 335 | it("processes messages in order even if upstream emits them out of order", function() { with(this) { 336 | extensions.activate("deflate, reverse") 337 | 338 | var out = [] 339 | extensions.processOutgoingMessage({ frames: [] }, function(error, message) { out.push(message) }) 340 | extensions.processOutgoingMessage({ frames: [1] }, function(error, message) { out.push(message) }) 341 | clock.tick(200) 342 | 343 | assertEqual( [{ frames: ["a", "c"] }, { frames: [1, "b", "d"] }], out ) 344 | }}) 345 | 346 | it("defers closing until the extension has finished processing", function() { with(this) { 347 | extensions.activate("deflate") 348 | 349 | var closed = false, notified = false 350 | stub(session, "close", function() { closed = true }) 351 | 352 | extensions.processOutgoingMessage({ frames: [] }, function() {}) 353 | extensions.close(function() { notified = true }) 354 | 355 | clock.tick(50) 356 | assertNot( closed || notified ) 357 | 358 | clock.tick(50) 359 | assert( closed && notified ) 360 | }}) 361 | 362 | it("closes each session as soon as it finishes processing", function() { with(this) { 363 | extensions.activate("deflate, reverse") 364 | 365 | var closed = [false, false], notified = false 366 | stub(session, "close", function() { closed[0] = true }) 367 | stub(nonconflictSession, "close", function() { closed[1] = true }) 368 | 369 | extensions.processOutgoingMessage({ frames: [] }, function() {}); 370 | extensions.close(function() { notified = true }) 371 | 372 | clock.tick(50) 373 | assertNot( closed[0] || closed[1] || notified ) 374 | 375 | clock.tick(100) 376 | assert( closed[0] ) 377 | assertNot( closed[1] || notified ) 378 | 379 | clock.tick(50) 380 | assert( closed[0] && closed[1] && notified ) 381 | }}) 382 | 383 | it("notifies of closure immeidately if already closed", function() { with(this) { 384 | extensions.activate("deflate") 385 | stub(session, "close", function() { closed = true }) 386 | 387 | extensions.processOutgoingMessage({ frames: [] }, function() {}) 388 | extensions.close() 389 | clock.tick(100) 390 | 391 | var notified = false 392 | extensions.close(function() { notified = true }) 393 | assert( notified ) 394 | }}) 395 | }}) 396 | 397 | it("processes messages in the order given in the server's response", function() { with(this) { 398 | extensions.activate("deflate, reverse") 399 | 400 | extensions.processOutgoingMessage({ frames: [] }, function(error, message) { 401 | assertNull( error ) 402 | assertEqual( ["deflate", "reverse"], message.frames ) 403 | }) 404 | }}) 405 | 406 | it("processes messages in the server's order, not the client's order", function() { with(this) { 407 | extensions.activate("reverse, deflate") 408 | 409 | extensions.processOutgoingMessage({ frames: [] }, function(error, message) { 410 | assertNull( error ) 411 | assertEqual( ["reverse", "deflate"], message.frames ) 412 | }) 413 | }}) 414 | 415 | it("yields an error if a session yields an error", function() { with(this) { 416 | extensions.activate("deflate") 417 | stub(session, "processOutgoingMessage").yields([{ message: "ENOENT" }]) 418 | 419 | extensions.processOutgoingMessage({ frames: [] }, function(error, message) { 420 | assertEqual( "deflate: ENOENT", error.message ) 421 | assertNull( message ) 422 | }) 423 | }}) 424 | 425 | it("does not call sessions after one has yielded an error", function() { with(this) { 426 | extensions.activate("deflate, reverse") 427 | stub(session, "processOutgoingMessage").yields([{ message: "ENOENT" }]) 428 | 429 | expect(nonconflictSession, "processOutgoingMessage").exactly(0) 430 | 431 | extensions.processOutgoingMessage({ frames: [] }, function() {}) 432 | }}) 433 | }}) 434 | }}) 435 | 436 | describe("server sessions", function() { with(this) { 437 | before(function() { with(this) { 438 | this.response = { mode: "compress" } 439 | stub(ext, "createServerSession").returns(session) 440 | stub(session, "generateResponse").returns(response) 441 | 442 | this.conflict = { name: "tar", type: "permessage", rsv1: true, rsv2: false, rsv3: false } 443 | this.conflictSession = {} 444 | stub(conflict, "createServerSession").returns(conflictSession) 445 | stub(conflictSession, "generateResponse").returns({ gzip: true }) 446 | 447 | this.nonconflict = { name: "reverse", type: "permessage", rsv1: false, rsv2: true, rsv3: false } 448 | this.nonconflictSession = {} 449 | stub(nonconflict, "createServerSession").returns(nonconflictSession) 450 | stub(nonconflictSession, "generateResponse").returns({ utf8: true }) 451 | 452 | extensions.add(ext) 453 | extensions.add(conflict) 454 | extensions.add(nonconflict) 455 | }}) 456 | 457 | describe("generateResponse", function() { with(this) { 458 | it("asks the extension for a server session with the offer", function() { with(this) { 459 | expect(ext, "createServerSession").given([{ flag: true }]).exactly(1).returning(session) 460 | extensions.generateResponse("deflate; flag") 461 | }}) 462 | 463 | it("asks the extension for a server session with multiple offers", function() { with(this) { 464 | expect(ext, "createServerSession").given([{ a: true }, { b: true }]).exactly(1).returning(session) 465 | extensions.generateResponse("deflate; a, deflate; b") 466 | }}) 467 | 468 | it("asks the session to generate a response", function() { with(this) { 469 | expect(session, "generateResponse").exactly(1).returning(response) 470 | extensions.generateResponse("deflate") 471 | }}) 472 | 473 | it("asks multiple sessions to generate a response", function() { with(this) { 474 | expect(session, "generateResponse").exactly(1).returning(response) 475 | expect(nonconflictSession, "generateResponse").exactly(1).returning(response) 476 | extensions.generateResponse("deflate, reverse") 477 | }}) 478 | 479 | it("does not ask the session to generate a response if the extension doesn't build a session", function() { with(this) { 480 | stub(ext, "createServerSession").returns(null) 481 | expect(session, "generateResponse").exactly(0) 482 | extensions.generateResponse("deflate") 483 | }}) 484 | 485 | it("does not ask the extension to build a session for unoffered extensions", function() { with(this) { 486 | expect(nonconflict, "createServerSession").exactly(0) 487 | extensions.generateResponse("deflate") 488 | }}) 489 | 490 | it("does not ask the extension to build a session for conflicting extensions", function() { with(this) { 491 | expect(conflict, "createServerSession").exactly(0) 492 | extensions.generateResponse("deflate, tar") 493 | }}) 494 | 495 | it("returns the serialized response from the session", function() { with(this) { 496 | assertEqual( "deflate; mode=compress", extensions.generateResponse("deflate") ) 497 | }}) 498 | 499 | it("returns serialized responses from multiple sessions", function() { with(this) { 500 | assertEqual( "deflate; mode=compress, reverse; utf8", extensions.generateResponse("deflate, reverse") ) 501 | }}) 502 | 503 | it("returns responses in registration order", function() { with(this) { 504 | assertEqual( "deflate; mode=compress, reverse; utf8", extensions.generateResponse("reverse, deflate") ) 505 | }}) 506 | 507 | it("does not return responses for unoffered extensions", function() { with(this) { 508 | assertEqual( "reverse; utf8", extensions.generateResponse("reverse") ) 509 | }}) 510 | 511 | it("does not return responses for conflicting extensions", function() { with(this) { 512 | assertEqual( "deflate; mode=compress", extensions.generateResponse("deflate, tar") ) 513 | }}) 514 | 515 | it("throws an error if the header is invalid", function() { with(this) { 516 | assertThrows(SyntaxError, function() { extensions.generateResponse("x-webkit- -frame") }) 517 | }}) 518 | 519 | it("returns a response for potentially conflicting extensions if their preceding extensions don't build a session", function() { with(this) { 520 | stub(ext, "createServerSession").returns(null) 521 | assertEqual( "tar; gzip", extensions.generateResponse("deflate, tar") ) 522 | }}) 523 | }}) 524 | }}) 525 | }}) 526 | --------------------------------------------------------------------------------