diff --git a/.eslintrc.yaml b/.eslintrc.yaml index d370e3d2e..45998d206 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -5,12 +5,14 @@ env: node: true extends: - eslint:recommended - - prettier + - plugin:prettier/recommended parserOptions: ecmaVersion: 9 -plugins: - - prettier rules: no-console: off + no-var: error prefer-const: error - prettier/prettier: error + quotes: + - error + - single + - avoidEscape: true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6313b56c5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..2c4cd0207 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: + - lpinca diff --git a/ISSUE_TEMPLATE.md b/.github/issue_template.md similarity index 100% rename from ISSUE_TEMPLATE.md rename to .github/issue_template.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..158a50e32 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + - push + - pull_request + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + node: + - 8 + - 10 + - 12 + - 14 + - 16 + os: + - macOS-latest + - ubuntu-latest + - windows-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + - run: npm install + - run: npm run lint + if: matrix.node == 16 && matrix.os == 'ubuntu-latest' + - run: npm test + - run: + echo ::set-output name=job_id::$(node -e + "console.log(crypto.randomBytes(16).toString('hex'))") + id: get_job_id + shell: bash + - uses: coverallsapp/github-action@v1.1.2 + with: + flag-name: + ${{ steps.get_job_id.outputs.job_id }} (Node.js ${{ matrix.node }} + on ${{ matrix.os }}) + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel: true + coverage: + needs: test + runs-on: ubuntu-latest + steps: + - uses: coverallsapp/github-action@v1.1.2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..43c97e719 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 754ed23e2..fe2f506e3 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -2,3 +2,4 @@ arrowParens: always endOfLine: lf proseWrap: always singleQuote: true +trailingComma: none diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ebb063d65..000000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: node_js -sudo: false -node_js: - - "11" - - "10" - - "8" - - "6" -after_success: - - "npm install coveralls@3 && nyc report --reporter=text-lcov | coveralls" diff --git a/README.md b/README.md index 7c87b11e1..9c6e5287c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # ws: a Node.js WebSocket library -[![Version npm](https://img.shields.io/npm/v/ws.svg)](https://www.npmjs.com/package/ws) -[![Linux Build](https://img.shields.io/travis/websockets/ws/master.svg)](https://travis-ci.org/websockets/ws) -[![Windows Build](https://ci.appveyor.com/api/projects/status/github/websockets/ws?branch=master&svg=true)](https://ci.appveyor.com/project/lpinca/ws) -[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg)](https://coveralls.io/r/websockets/ws?branch=master) +[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws) +[![Build](https://img.shields.io/github/workflow/status/websockets/ws/CI/master?label=build&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) +[![Windows x86 Build](https://img.shields.io/appveyor/ci/lpinca/ws/master.svg?logo=appveyor)](https://ci.appveyor.com/project/lpinca/ws) +[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg)](https://coveralls.io/github/websockets/ws) ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and server implementation. @@ -32,10 +32,11 @@ can use one of the many wrappers available on npm, like - [Simple server](#simple-server) - [External HTTP/S server](#external-https-server) - [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server) + - [Client authentication](#client-authentication) - [Server broadcast](#server-broadcast) - [echo.websocket.org demo](#echowebsocketorg-demo) + - [Use the Node.js streams API](#use-the-nodejs-streams-api) - [Other examples](#other-examples) -- [Error handling best practices](#error-handling-best-practices) - [FAQ](#faq) - [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client) - [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections) @@ -55,7 +56,7 @@ can use one of the many wrappers available on npm, like npm install ws ``` -### Opt-in for performance and spec compliance +### Opt-in for performance There are 2 optional modules that can be installed along side with the ws module. These modules are binary addons which improve certain operations. @@ -66,11 +67,12 @@ necessarily need to have a C++ compiler installed on your machine. operations such as masking and unmasking the data payload of the WebSocket frames. - `npm install --save-optional utf-8-validate`: Allows to efficiently check if a - message contains valid UTF-8 as required by the spec. + message contains valid UTF-8. ## API docs -See [`/doc/ws.md`](./doc/ws.md) for Node.js-like docs for the ws classes. +See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and +utility functions. ## WebSocket compression @@ -193,7 +195,7 @@ const fs = require('fs'); const https = require('https'); const WebSocket = require('ws'); -const server = new https.createServer({ +const server = https.createServer({ cert: fs.readFileSync('/path/to/cert.pem'), key: fs.readFileSync('/path/to/key.pem') }); @@ -215,6 +217,7 @@ server.listen(8080); ```js const http = require('http'); const WebSocket = require('ws'); +const url = require('url'); const server = http.createServer(); const wss1 = new WebSocket.Server({ noServer: true }); @@ -247,25 +250,72 @@ server.on('upgrade', function upgrade(request, socket, head) { server.listen(8080); ``` +### Client authentication + +```js +const http = require('http'); +const WebSocket = require('ws'); + +const server = http.createServer(); +const wss = new WebSocket.Server({ noServer: true }); + +wss.on('connection', function connection(ws, request, client) { + ws.on('message', function message(msg) { + console.log(`Received message ${msg} from user ${client}`); + }); +}); + +server.on('upgrade', function upgrade(request, socket, head) { + // This function is not defined on purpose. Implement it with your own logic. + authenticate(request, (err, client) => { + if (err || !client) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + + wss.handleUpgrade(request, socket, head, function done(ws) { + wss.emit('connection', ws, request, client); + }); + }); +}); + +server.listen(8080); +``` + +Also see the provided [example][session-parse-example] using `express-session`. + ### Server broadcast +A client WebSocket broadcasting to all connected WebSocket clients, including +itself. + ```js const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); -// Broadcast to all. -wss.broadcast = function broadcast(data) { - wss.clients.forEach(function each(client) { - if (client.readyState === WebSocket.OPEN) { - client.send(data); - } +wss.on('connection', function connection(ws) { + ws.on('message', function incoming(data) { + wss.clients.forEach(function each(client) { + if (client.readyState === WebSocket.OPEN) { + client.send(data); + } + }); }); -}; +}); +``` + +A client WebSocket broadcasting to every other connected WebSocket clients, +excluding itself. + +```js +const WebSocket = require('ws'); + +const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', function connection(ws) { ws.on('message', function incoming(data) { - // Broadcast to everyone else. wss.clients.forEach(function each(client) { if (client !== ws && client.readyState === WebSocket.OPEN) { client.send(data); @@ -302,6 +352,21 @@ ws.on('message', function incoming(data) { }); ``` +### Use the Node.js streams API + +```js +const WebSocket = require('ws'); + +const ws = new WebSocket('wss://echo.websocket.org/', { + origin: 'https://websocket.org' +}); + +const duplex = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }); + +duplex.pipe(process.stdout); +process.stdin.pipe(duplex); +``` + ### Other examples For a full example with a browser client communicating with a ws server, see the @@ -309,30 +374,6 @@ examples folder. Otherwise, see the test cases. -## Error handling best practices - -```js -// If the WebSocket is closed before the following send is attempted -ws.send('something'); - -// Errors (both immediate and async write errors) can be detected in an optional -// callback. The callback is also the only way of being notified that data has -// actually been sent. -ws.send('something', function ack(error) { - // If error is not defined, the send has been completed, otherwise the error - // object will indicate what failed. -}); - -// Immediate errors can also be handled with `try...catch`, but **note** that -// since sends are inherently asynchronous, socket write failures will *not* be -// captured when this technique is used. -try { - ws.send('something'); -} catch (e) { - /* handle error */ -} -``` - ## FAQ ### How to get the IP address of the client? @@ -345,7 +386,7 @@ const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', function connection(ws, req) { - const ip = req.connection.remoteAddress; + const ip = req.socket.remoteAddress; }); ``` @@ -391,6 +432,10 @@ const interval = setInterval(function ping() { ws.ping(noop); }); }, 30000); + +wss.on('close', function close() { + clearInterval(interval); +}); ``` Pong messages are automatically sent in response to ping messages as required by @@ -406,9 +451,10 @@ const WebSocket = require('ws'); function heartbeat() { clearTimeout(this.pingTimeout); - // Use `WebSocket#terminate()` and not `WebSocket#close()`. Delay should be - // equal to the interval at which your server sends out pings plus a - // conservative assumption of the latency. + // Use `WebSocket#terminate()`, which immediately destroys the connection, + // instead of `WebSocket#close()`, which waits for the close timer. + // Delay should be equal to the interval at which your server + // sends out pings plus a conservative assumption of the latency. this.pingTimeout = setTimeout(() => { this.terminate(); }, 30000 + 1000); @@ -436,14 +482,15 @@ We're using the GitHub [releases][changelog] for changelog entries. [MIT](LICENSE) -[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent -[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent -[client-report]: http://websockets.github.io/ws/autobahn/clients/ -[server-report]: http://websockets.github.io/ws/autobahn/servers/ -[permessage-deflate]: https://tools.ietf.org/html/rfc7692 [changelog]: https://github.com/websockets/ws/releases +[client-report]: http://websockets.github.io/ws/autobahn/clients/ +[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent [node-zlib-bug]: https://github.com/nodejs/node/issues/8871 [node-zlib-deflaterawdocs]: https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options +[permessage-deflate]: https://tools.ietf.org/html/rfc7692 +[server-report]: http://websockets.github.io/ws/autobahn/servers/ +[session-parse-example]: ./examples/express-session-parse +[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent [ws-server-options]: https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback diff --git a/SECURITY.md b/SECURITY.md index 258ff59fd..eebb2a552 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,8 +25,9 @@ following methods: Once we have acknowledged receipt of your report and confirmed the bug ourselves we will work with you to fix the vulnerability and publicly acknowledge your -responsible disclosure, if you wish. In addition to that we will report all -vulnerabilities to the [Node Security Project](https://nodesecurity.io/). +responsible disclosure, if you wish. In addition to that we will create and +publish a security advisory to +[GitHub Security Advisories](https://github.com/websockets/ws/security/advisories). ## History diff --git a/appveyor.yml b/appveyor.yml index 917169ad0..f4c05fbf4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,12 +1,12 @@ environment: matrix: - - nodejs_version: "11" - - nodejs_version: "10" - - nodejs_version: "8" - - nodejs_version: "6" + - nodejs_version: '16' + - nodejs_version: '14' + - nodejs_version: '12' + - nodejs_version: '10' + - nodejs_version: '8' platform: - x86 - - x64 matrix: fast_finish: true install: diff --git a/bench/parser.benchmark.js b/bench/parser.benchmark.js index c0a61675a..dd97701af 100644 --- a/bench/parser.benchmark.js +++ b/bench/parser.benchmark.js @@ -16,27 +16,27 @@ const options = { }; function createBinaryFrame(length) { - const list = Sender.frame( - crypto.randomBytes(length), - Object.assign({ opcode: 0x02 }, options) - ); + const list = Sender.frame(crypto.randomBytes(length), { + opcode: 0x02, + ...options + }); return Buffer.concat(list); } const pingFrame1 = Buffer.concat( - Sender.frame(crypto.randomBytes(5), Object.assign({ opcode: 0x09 }, options)) + Sender.frame(crypto.randomBytes(5), { opcode: 0x09, ...options }) ); const textFrame = Buffer.from('819461616161' + '61'.repeat(20), 'hex'); -const pingFrame2 = Buffer.from('8900', 'hex'); +const pingFrame2 = Buffer.from('8980146e915a', 'hex'); const binaryFrame1 = createBinaryFrame(125); const binaryFrame2 = createBinaryFrame(65535); const binaryFrame3 = createBinaryFrame(200 * 1024); const binaryFrame4 = createBinaryFrame(1024 * 1024); const suite = new benchmark.Suite(); -const receiver = new Receiver(); +const receiver = new Receiver('nodebuffer', {}, true); suite.add('ping frame (5 bytes payload)', { defer: true, diff --git a/bench/speed.js b/bench/speed.js index c87dc0b78..32ec0fb81 100644 --- a/bench/speed.js +++ b/bench/speed.js @@ -60,7 +60,7 @@ if (cluster.isMaster) { console.log('Generating %s of test data...', humanSize(largest)); const randomBytes = Buffer.allocUnsafe(largest); - for (var i = 0; i < largest; ++i) { + for (let i = 0; i < largest; ++i) { randomBytes[i] = ~~(Math.random() * 127); } @@ -72,8 +72,8 @@ if (cluster.isMaster) { const ws = new WebSocket(url, { maxPayload: 600 * 1024 * 1024 }); - var roundtrip = 0; - var time; + let roundtrip = 0; + let time; ws.on('error', (err) => { console.error(err.stack); @@ -87,7 +87,7 @@ if (cluster.isMaster) { if (++roundtrip !== roundtrips) return ws.send(data, { binary: useBinary }); - var elapsed = process.hrtime(time); + let elapsed = process.hrtime(time); elapsed = elapsed[0] * 1e9 + elapsed[1]; console.log( @@ -106,7 +106,7 @@ if (cluster.isMaster) { (function run() { if (configs.length === 0) return cluster.worker.disconnect(); - var config = configs.shift(); + const config = configs.shift(); config.push(run); runConfig.apply(null, config); })(); diff --git a/browser.js b/browser.js index 782077969..ca4f628ac 100644 --- a/browser.js +++ b/browser.js @@ -1,6 +1,6 @@ 'use strict'; -module.exports = function() { +module.exports = function () { throw new Error( 'ws does not work in the browser. Browser clients must use the native ' + 'WebSocket object' diff --git a/doc/ws.md b/doc/ws.md index 0b21101eb..de62e6756 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -1,5 +1,50 @@ # ws +## Table of Contents + +- [Class: WebSocket.Server](#class-websocketserver) + - [new WebSocket.Server(options[, callback])](#new-websocketserveroptions-callback) + - [Event: 'close'](#event-close) + - [Event: 'connection'](#event-connection) + - [Event: 'error'](#event-error) + - [Event: 'headers'](#event-headers) + - [Event: 'listening'](#event-listening) + - [server.address()](#serveraddress) + - [server.clients](#serverclients) + - [server.close([callback])](#serverclosecallback) + - [server.handleUpgrade(request, socket, head, callback)](#serverhandleupgraderequest-socket-head-callback) + - [server.shouldHandle(request)](#servershouldhandlerequest) +- [Class: WebSocket](#class-websocket) + - [Ready state constants](#ready-state-constants) + - [new WebSocket(address[, protocols][, options])](#new-websocketaddress-protocols-options) + - [UNIX Domain Sockets](#unix-domain-sockets) + - [Event: 'close'](#event-close-1) + - [Event: 'error'](#event-error-1) + - [Event: 'message'](#event-message) + - [Event: 'open'](#event-open) + - [Event: 'ping'](#event-ping) + - [Event: 'pong'](#event-pong) + - [Event: 'unexpected-response'](#event-unexpected-response) + - [Event: 'upgrade'](#event-upgrade) + - [websocket.addEventListener(type, listener[, options])](#websocketaddeventlistenertype-listener-options) + - [websocket.binaryType](#websocketbinarytype) + - [websocket.bufferedAmount](#websocketbufferedamount) + - [websocket.close([code[, reason]])](#websocketclosecode-reason) + - [websocket.extensions](#websocketextensions) + - [websocket.onclose](#websocketonclose) + - [websocket.onerror](#websocketonerror) + - [websocket.onmessage](#websocketonmessage) + - [websocket.onopen](#websocketonopen) + - [websocket.ping([data[, mask]][, callback])](#websocketpingdata-mask-callback) + - [websocket.pong([data[, mask]][, callback])](#websocketpongdata-mask-callback) + - [websocket.protocol](#websocketprotocol) + - [websocket.readyState](#websocketreadystate) + - [websocket.removeEventListener(type, listener)](#websocketremoveeventlistenertype-listener) + - [websocket.send(data[, options][, callback])](#websocketsenddata-options-callback) + - [websocket.terminate()](#websocketterminate) + - [websocket.url](#websocketurl) +- [WebSocket.createWebSocketStream(websocket[, options])](#websocketcreatewebsocketstreamwebsocket-options) + ## Class: WebSocket.Server This class represents a WebSocket server. It extends the `EventEmitter`. @@ -12,7 +57,8 @@ This class represents a WebSocket server. It extends the `EventEmitter`. - `backlog` {Number} The maximum length of the queue of pending connections. - `server` {http.Server|https.Server} A pre-created Node.js HTTP/S server. - `verifyClient` {Function} A function which can be used to validate incoming - connections. See description below. + connections. See description below. (Usage is discouraged: see + [Issue #337](https://github.com/websockets/ws/issues/377#issuecomment-462152231)) - `handleProtocols` {Function} A function which can be used to handle the WebSocket subprotocols. See description below. - `path` {String} Accept only connections matching this path. @@ -30,16 +76,20 @@ started manually. The "noServer" mode allows the WebSocket server to be completly detached from the HTTP/S server. This makes it possible, for example, to share a single HTTP/S server between multiple WebSocket servers. +> **NOTE:** Use of `verifyClient` is discouraged. Rather handle client +> authentication in the `upgrade` event of the HTTP server. See examples for +> more details. + If `verifyClient` is not set then the handshake is automatically accepted. If it is provided with a single argument then that is: - `info` {Object} - `origin` {String} The value in the Origin header indicated by the client. - `req` {http.IncomingMessage} The client HTTP GET request. - - `secure` {Boolean} `true` if `req.connection.authorized` or - `req.connection.encrypted` is set. + - `secure` {Boolean} `true` if `req.socket.authorized` or + `req.socket.encrypted` is set. -The return value (Boolean) of the function determines whether or not to accept +The return value (`Boolean`) of the function determines whether or not to accept the handshake. if `verifyClient` is provided with two arguments then those are: @@ -104,7 +154,7 @@ emitted independently. ### Event: 'connection' -- `socket` {WebSocket} +- `websocket` {WebSocket} - `request` {http.IncomingMessage} Emitted when the handshake is complete. `request` is the http GET request sent @@ -129,13 +179,6 @@ handshake. This allows you to inspect/modify the headers before they are sent. Emitted when the underlying server has been bound. -### server.clients - -- {Set} - -A set that stores all connected clients. Please note that this property is only -added when the `clientTracking` is truthy. - ### server.address() Returns an object with `port`, `family`, and `address` properties specifying the @@ -143,6 +186,13 @@ bound address, the address family name, and port of the server as reported by the operating system if listening on an IP socket. If the server is listening on a pipe or UNIX domain socket, the name is returned as a string. +### server.clients + +- {Set} + +A set that stores all connected clients. Please note that this property is only +added when the `clientTracking` is truthy. + ### server.close([callback]) Close the HTTP server if created internally, terminate all clients and call @@ -161,8 +211,10 @@ when the HTTP server is passed via the `server` option, this method is called automatically. When operating in "noServer" mode, this method must be called manually. -If the upgrade is successful, the `callback` is called with a `WebSocket` object -as parameter. +If the upgrade is successful, the `callback` is called with two arguments: + +- `websocket` {WebSocket} A `WebSocket` object. +- `request` {http.IncomingMessage} The client HTTP GET request. ### server.shouldHandle(request) @@ -190,17 +242,23 @@ This class represents a WebSocket. It extends the `EventEmitter`. ### new WebSocket(address[, protocols][, options]) -- `address` {String|url.Url|url.URL} The URL to which to connect. +- `address` {String|url.URL} The URL to which to connect. - `protocols` {String|Array} The list of subprotocols. - `options` {Object} + - `followRedirects` {Boolean} Whether or not to follow redirects. Defaults to + `false`. - `handshakeTimeout` {Number} Timeout in milliseconds for the handshake - request. + request. This is reset after every redirection. + - `maxRedirects` {Number} The maximum number of redirects allowed. Defaults + to 10. - `perMessageDeflate` {Boolean|Object} Enable/disable permessage-deflate. - `protocolVersion` {Number} Value of the `Sec-WebSocket-Version` header. - `origin` {String} Value of the `Origin` or `Sec-WebSocket-Origin` header depending on the `protocolVersion`. - `maxPayload` {Number} The maximum allowed message size in bytes. - Any other option allowed in [http.request()][] or [https.request()][]. + Options given do not have any effect if parsed from the URL given with the + `address` parameter. `perMessageDeflate` default value is `true`. When using an object, parameters are the same of the server. The only difference is the direction of requests. @@ -282,10 +340,14 @@ Emitted when response headers are received from the server as part of the handshake. This allows you to read headers from the server, for example 'set-cookie' headers. -### websocket.addEventListener(type, listener) +### websocket.addEventListener(type, listener[, options]) - `type` {String} A string representing the event type to listen for. - `listener` {Function} The listener to add. +- `options` {Object} + - `once` {Boolean} A `Boolean` indicating that the listener should be invoked + at most once after being added. If `true`, the listener would be + automatically removed when invoked. Register an event listener emulating the `EventTarget` interface. @@ -304,7 +366,11 @@ of binary protocols transferring large messages with multiple fragments. - {Number} The number of bytes of data that have been queued using calls to `send()` but -not yet transmitted to the network. +not yet transmitted to the network. This deviates from the HTML standard in the +following ways: + +1. If the data is immediately sent the value is `0`. +1. All framing bytes are included. ### websocket.close([code[, reason]]) @@ -351,7 +417,8 @@ receives an `OpenEvent` named "open". ### websocket.ping([data[, mask]][, callback]) -- `data` {Any} The data to send in the ping frame. +- `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The + data to send in the ping frame. - `mask` {Boolean} Specifies whether `data` should be masked or not. Defaults to `true` when `websocket` is not a server client. - `callback` {Function} An optional callback which is invoked when the ping @@ -361,7 +428,8 @@ Send a ping. ### websocket.pong([data[, mask]][, callback]) -- `data` {Any} The data to send in the pong frame. +- `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The + data to send in the pong frame. - `mask` {Boolean} Specifies whether `data` should be masked or not. Defaults to `true` when `websocket` is not a server client. - `callback` {Function} An optional callback which is invoked when the pong @@ -390,7 +458,8 @@ Removes an event listener emulating the `EventTarget` interface. ### websocket.send(data[, options][, callback]) -- `data` {Any} The data to send. +- `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The + data to send. - `options` {Object} - `compress` {Boolean} Specifies whether `data` should be compressed or not. Defaults to `true` when permessage-deflate is enabled. @@ -407,7 +476,7 @@ Send `data` through the connection. ### websocket.terminate() -Forcibly close the connection. +Forcibly close the connection. Internally this calls [socket.destroy()][]. ### websocket.url @@ -415,11 +484,23 @@ Forcibly close the connection. The URL of the WebSocket server. Server clients don't have this attribute. +## WebSocket.createWebSocketStream(websocket[, options]) + +- `websocket` {WebSocket} A `WebSocket` object. +- `options` {Object} [Options][duplex-options] to pass to the `Duplex` + constructor. + +Returns a `Duplex` stream that allows to use the Node.js streams API on top of a +given `WebSocket`. + [concurrency-limit]: https://github.com/websockets/ws/issues/1202 -[permessage-deflate]: - https://tools.ietf.org/html/draft-ietf-hybi-permessage-compression-19 -[zlib-options]: https://nodejs.org/api/zlib.html#zlib_class_options +[duplex-options]: + https://nodejs.org/api/stream.html#stream_new_stream_duplex_options [http.request()]: https://nodejs.org/api/http.html#http_http_request_options_callback [https.request()]: https://nodejs.org/api/https.html#https_https_request_options_callback +[permessage-deflate]: + https://tools.ietf.org/html/draft-ietf-hybi-permessage-compression-19 +[socket.destroy()]: https://nodejs.org/api/net.html#net_socket_destroy_error +[zlib-options]: https://nodejs.org/api/zlib.html#zlib_class_options diff --git a/examples/express-session-parse/index.js b/examples/express-session-parse/index.js index 5250b2cb6..8fc4ce029 100644 --- a/examples/express-session-parse/index.js +++ b/examples/express-session-parse/index.js @@ -8,6 +8,7 @@ const uuid = require('uuid'); const WebSocket = require('../..'); const app = express(); +const map = new Map(); // // We need the same instance of the session parser in express and @@ -25,7 +26,7 @@ const sessionParser = session({ app.use(express.static('public')); app.use(sessionParser); -app.post('/login', (req, res) => { +app.post('/login', function (req, res) { // // "Log in" user and set userId to session. // @@ -36,43 +37,65 @@ app.post('/login', (req, res) => { res.send({ result: 'OK', message: 'Session updated' }); }); -app.delete('/logout', (request, response) => { +app.delete('/logout', function (request, response) { + const ws = map.get(request.session.userId); + console.log('Destroying session'); - request.session.destroy(); - response.send({ result: 'OK', message: 'Session destroyed' }); + request.session.destroy(function () { + if (ws) ws.close(); + + response.send({ result: 'OK', message: 'Session destroyed' }); + }); }); // -// Create HTTP server by ourselves. +// Create an HTTP server. // const server = http.createServer(app); -const wss = new WebSocket.Server({ - verifyClient: (info, done) => { - console.log('Parsing session from request...'); - sessionParser(info.req, {}, () => { - console.log('Session is parsed!'); - - // - // We can reject the connection by returning false to done(). For example, - // reject here if user is unknown. - // - done(info.req.session.userId); +// +// Create a WebSocket server completely detached from the HTTP server. +// +const wss = new WebSocket.Server({ clientTracking: false, noServer: true }); + +server.on('upgrade', function (request, socket, head) { + console.log('Parsing session from request...'); + + sessionParser(request, {}, () => { + if (!request.session.userId) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + + console.log('Session is parsed!'); + + wss.handleUpgrade(request, socket, head, function (ws) { + wss.emit('connection', ws, request); }); - }, - server + }); }); -wss.on('connection', (ws, req) => { - ws.on('message', (message) => { +wss.on('connection', function (ws, request) { + const userId = request.session.userId; + + map.set(userId, ws); + + ws.on('message', function (message) { // // Here we can now use session parameters. // - console.log(`WS message ${message} from user ${req.session.userId}`); + console.log(`Received message ${message} from user ${userId}`); + }); + + ws.on('close', function () { + map.delete(userId); }); }); // // Start the server. // -server.listen(8080, () => console.log('Listening on http://localhost:8080')); +server.listen(8080, function () { + console.log('Listening on http://localhost:8080'); +}); diff --git a/examples/express-session-parse/package.json b/examples/express-session-parse/package.json index 5390e7e0a..f8cd22e30 100644 --- a/examples/express-session-parse/package.json +++ b/examples/express-session-parse/package.json @@ -4,8 +4,8 @@ "version": "0.0.0", "repository": "websockets/ws", "dependencies": { - "express": "~4.16.3", - "express-session": "~1.15.6", - "uuid": "~3.3.2" + "express": "^4.16.4", + "express-session": "^1.16.1", + "uuid": "^3.3.2" } } diff --git a/examples/express-session-parse/public/app.js b/examples/express-session-parse/public/app.js index 916fafd41..f70dc2183 100644 --- a/examples/express-session-parse/public/app.js +++ b/examples/express-session-parse/public/app.js @@ -1,46 +1,67 @@ -/* global fetch, WebSocket, location */ -(() => { +(function () { const messages = document.querySelector('#messages'); const wsButton = document.querySelector('#wsButton'); + const wsSendButton = document.querySelector('#wsSendButton'); const logout = document.querySelector('#logout'); const login = document.querySelector('#login'); - const showMessage = (message) => { + function showMessage(message) { messages.textContent += `\n${message}`; messages.scrollTop = messages.scrollHeight; - }; + } - const handleResponse = (response) => { + function handleResponse(response) { return response.ok ? response.json().then((data) => JSON.stringify(data, null, 2)) : Promise.reject(new Error('Unexpected response')); - }; + } - login.onclick = () => { + login.onclick = function () { fetch('/login', { method: 'POST', credentials: 'same-origin' }) .then(handleResponse) .then(showMessage) - .catch((err) => showMessage(err.message)); + .catch(function (err) { + showMessage(err.message); + }); }; - logout.onclick = () => { + logout.onclick = function () { fetch('/logout', { method: 'DELETE', credentials: 'same-origin' }) .then(handleResponse) .then(showMessage) - .catch((err) => showMessage(err.message)); + .catch(function (err) { + showMessage(err.message); + }); }; let ws; - wsButton.onclick = () => { + wsButton.onclick = function () { if (ws) { ws.onerror = ws.onopen = ws.onclose = null; ws.close(); } ws = new WebSocket(`ws://${location.host}`); - ws.onerror = () => showMessage('WebSocket error'); - ws.onopen = () => showMessage('WebSocket connection established'); - ws.onclose = () => showMessage('WebSocket connection closed'); + ws.onerror = function () { + showMessage('WebSocket error'); + }; + ws.onopen = function () { + showMessage('WebSocket connection established'); + }; + ws.onclose = function () { + showMessage('WebSocket connection closed'); + ws = null; + }; + }; + + wsSendButton.onclick = function () { + if (!ws) { + showMessage('No WebSocket connection'); + return; + } + + ws.send('Hello World!'); + showMessage('Sent "Hello World!"'); }; })(); diff --git a/examples/express-session-parse/public/index.html b/examples/express-session-parse/public/index.html index c99949c77..c07aa2e87 100644 --- a/examples/express-session-parse/public/index.html +++ b/examples/express-session-parse/public/index.html @@ -15,6 +15,9 @@

Choose an action.

+

     
   
diff --git a/examples/fileapi/.gitignore b/examples/fileapi/.gitignore
deleted file mode 100644
index dcd575688..000000000
--- a/examples/fileapi/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
-uploaded
diff --git a/examples/fileapi/package.json b/examples/fileapi/package.json
deleted file mode 100644
index 770b5ebc1..000000000
--- a/examples/fileapi/package.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "author": "",
-  "name": "fileapi",
-  "version": "0.0.0",
-  "repository": "websockets/ws",
-  "dependencies": {
-    "express": "~4.16.3",
-    "ansi": "https://github.com/einaros/ansi.js/tarball/master"
-  }
-}
diff --git a/examples/fileapi/public/app.js b/examples/fileapi/public/app.js
deleted file mode 100644
index 80300db52..000000000
--- a/examples/fileapi/public/app.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/* global Uploader */
-function onFilesSelected(e) {
-  var button = e.srcElement;
-  button.disabled = true;
-  var progress = document.querySelector('div#progress');
-  progress.innerHTML = '0%';
-  var files = e.target.files;
-  var totalFiles = files.length;
-  var filesSent = 0;
-  if (totalFiles) {
-    var uploader = new Uploader('ws://localhost:8080', function() {
-      Array.prototype.slice.call(files, 0).forEach(function(file) {
-        if (file.name === '.') {
-          --totalFiles;
-          return;
-        }
-        uploader.sendFile(file, function(error) {
-          if (error) {
-            console.log(error);
-            return;
-          }
-          ++filesSent;
-          progress.innerHTML = ~~((filesSent / totalFiles) * 100) + '%';
-          console.log('Sent: ' + file.name);
-        });
-      });
-    });
-  }
-  uploader.ondone = function() {
-    uploader.close();
-    progress.innerHTML = '100% done, ' + totalFiles + ' files sent.';
-  };
-}
-
-window.onload = function() {
-  var importButtons = document.querySelectorAll('[type="file"]');
-  Array.prototype.slice.call(importButtons, 0).forEach(function(importButton) {
-    importButton.addEventListener('change', onFilesSelected, false);
-  });
-};
diff --git a/examples/fileapi/public/index.html b/examples/fileapi/public/index.html
deleted file mode 100644
index 0d463dd5a..000000000
--- a/examples/fileapi/public/index.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-  
-    
-    
-    
-  
-  
-    

This example will upload an entire directory tree to the node.js server via a fast and persistent WebSocket connection.

-

Note that the example is Chrome only for now.

-

- Upload status: -
Please select a directory to upload.
- - diff --git a/examples/fileapi/public/uploader.js b/examples/fileapi/public/uploader.js deleted file mode 100644 index 11332d43c..000000000 --- a/examples/fileapi/public/uploader.js +++ /dev/null @@ -1,58 +0,0 @@ -/* global WebSocket */ -function Uploader(url, cb) { - this.ws = new WebSocket(url); - if (cb) this.ws.onopen = cb; - this.sendQueue = []; - this.sending = null; - this.sendCallback = null; - this.ondone = null; - var self = this; - this.ws.onmessage = function(event) { - var data = JSON.parse(event.data); - var callback; - if (data.event === 'complete') { - if (data.path !== self.sending.path) { - self.sendQueue = []; - self.sending = null; - self.sendCallback = null; - throw new Error('Got message for wrong file!'); - } - self.sending = null; - callback = self.sendCallback; - self.sendCallback = null; - if (callback) callback(); - if (self.sendQueue.length === 0 && self.ondone) self.ondone(null); - if (self.sendQueue.length > 0) { - var args = self.sendQueue.pop(); - setTimeout(function() { - self.sendFile.apply(self, args); - }, 0); - } - } else if (data.event === 'error') { - self.sendQueue = []; - self.sending = null; - callback = self.sendCallback; - self.sendCallback = null; - var error = new Error('Server reported send error for file ' + data.path); - if (callback) callback(error); - if (self.ondone) self.ondone(error); - } - }; -} - -Uploader.prototype.sendFile = function(file, cb) { - if (this.ws.readyState !== WebSocket.OPEN) throw new Error('Not connected'); - if (this.sending) { - this.sendQueue.push(arguments); - return; - } - var fileData = { name: file.name, path: file.webkitRelativePath }; - this.sending = fileData; - this.sendCallback = cb; - this.ws.send(JSON.stringify(fileData)); - this.ws.send(file); -}; - -Uploader.prototype.close = function() { - this.ws.close(); -}; diff --git a/examples/fileapi/server.js b/examples/fileapi/server.js deleted file mode 100644 index 4bb1b61e6..000000000 --- a/examples/fileapi/server.js +++ /dev/null @@ -1,134 +0,0 @@ -var WebSocketServer = require('../../').Server; -var express = require('express'); -var fs = require('fs'); -var util = require('util'); -var path = require('path'); -var app = express(); -var server = require('http').Server(app); -var events = require('events'); -var ansi = require('ansi'); -var cursor = ansi(process.stdout); - -function BandwidthSampler(ws, interval) { - interval = interval || 2000; - var previousByteCount = 0; - var self = this; - var intervalId = setInterval(function() { - var byteCount = ws.bytesReceived; - var bytesPerSec = (byteCount - previousByteCount) / (interval / 1000); - previousByteCount = byteCount; - self.emit('sample', bytesPerSec); - }, interval); - ws.on('close', function() { - clearInterval(intervalId); - }); -} -util.inherits(BandwidthSampler, events.EventEmitter); - -function makePathForFile(filePath, prefix, cb) { - if (typeof cb !== 'function') throw new Error('callback is required'); - filePath = path.dirname(path.normalize(filePath)).replace(/^(\/|\\)+/, ''); - var pieces = filePath.split(/(\\|\/)/); - var incrementalPath = prefix; - function step(error) { - if (error) return cb(error); - if (pieces.length === 0) return cb(null, incrementalPath); - incrementalPath += '/' + pieces.shift(); - fs.access(incrementalPath, function(err) { - if (err) fs.mkdir(incrementalPath, step); - else process.nextTick(step); - }); - } - step(); -} - -cursor.eraseData(2).goto(1, 1); -app.use(express.static(path.join(__dirname, '/public'))); - -var clientId = 0; -var wss = new WebSocketServer({ server: server }); -wss.on('connection', function(ws) { - var thisId = ++clientId; - cursor.goto(1, 4 + thisId).eraseLine(); - console.log('Client #%d connected', thisId); - - var sampler = new BandwidthSampler(ws); - sampler.on('sample', function(bps) { - cursor.goto(1, 4 + thisId).eraseLine(); - console.log( - 'WebSocket #%d incoming bandwidth: %d MB/s', - thisId, - Math.round(bps / (1024 * 1024)) - ); - }); - - var filesReceived = 0; - var currentFile = null; - ws.on('message', function(data) { - if (typeof data === 'string') { - currentFile = JSON.parse(data); - // note: a real-world app would want to sanity check the data - } else { - if (currentFile == null) return; - makePathForFile( - currentFile.path, - path.join(__dirname, '/uploaded'), - function(error, path) { - if (error) { - console.log(error); - ws.send( - JSON.stringify({ - event: 'error', - path: currentFile.path, - message: error.message - }) - ); - return; - } - fs.writeFile(path + '/' + currentFile.name, data, function(error) { - if (error) { - console.log(error); - ws.send( - JSON.stringify({ - event: 'error', - path: currentFile.path, - message: error.message - }) - ); - return; - } - ++filesReceived; - // console.log('received %d bytes long file, %s', data.length, currentFile.path); - ws.send( - JSON.stringify({ event: 'complete', path: currentFile.path }) - ); - currentFile = null; - }); - } - ); - } - }); - - ws.on('close', function() { - cursor.goto(1, 4 + thisId).eraseLine(); - console.log( - 'Client #%d disconnected. %d files received.', - thisId, - filesReceived - ); - }); - - ws.on('error', function(e) { - cursor.goto(1, 4 + thisId).eraseLine(); - console.log('Client #%d error: %s', thisId, e.message); - }); -}); - -fs.mkdir(path.join(__dirname, '/uploaded'), function() { - // ignore errors, most likely means directory exists - console.log('Uploaded files will be saved to %s/uploaded.', __dirname); - console.log('Remember to wipe this directory if you upload lots and lots.'); - server.listen(8080, function() { - console.log('Listening on http://localhost:8080'); - }); -}); diff --git a/examples/server-stats/index.js b/examples/server-stats/index.js new file mode 100644 index 000000000..da1f95a3b --- /dev/null +++ b/examples/server-stats/index.js @@ -0,0 +1,33 @@ +'use strict'; + +const express = require('express'); +const path = require('path'); +const { createServer } = require('http'); + +const WebSocket = require('../../'); + +const app = express(); +app.use(express.static(path.join(__dirname, '/public'))); + +const server = createServer(app); +const wss = new WebSocket.Server({ server }); + +wss.on('connection', function (ws) { + const id = setInterval(function () { + ws.send(JSON.stringify(process.memoryUsage()), function () { + // + // Ignore errors. + // + }); + }, 100); + console.log('started client interval'); + + ws.on('close', function () { + console.log('stopping client interval'); + clearInterval(id); + }); +}); + +server.listen(8080, function () { + console.log('Listening on http://localhost:8080'); +}); diff --git a/examples/serverstats/package.json b/examples/server-stats/package.json similarity index 83% rename from examples/serverstats/package.json rename to examples/server-stats/package.json index dbc86aff0..20e202913 100644 --- a/examples/serverstats/package.json +++ b/examples/server-stats/package.json @@ -4,6 +4,6 @@ "version": "0.0.0", "repository": "websockets/ws", "dependencies": { - "express": "~4.16.3" + "express": "^4.16.4" } } diff --git a/examples/server-stats/public/index.html b/examples/server-stats/public/index.html new file mode 100644 index 000000000..a82815af6 --- /dev/null +++ b/examples/server-stats/public/index.html @@ -0,0 +1,63 @@ + + + + + Server stats + + + +

Server stats

+ + + + + + + + + + + + + + + + + + + + + + + + +
Memory usage
RSS
Heap total
Heap used
External
+ + + diff --git a/examples/serverstats/public/index.html b/examples/serverstats/public/index.html deleted file mode 100644 index 24d84e120..000000000 --- a/examples/serverstats/public/index.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - Server Stats
- RSS:

- Heap total:

- Heap used:

- - diff --git a/examples/serverstats/server.js b/examples/serverstats/server.js deleted file mode 100644 index 7f7f23c0f..000000000 --- a/examples/serverstats/server.js +++ /dev/null @@ -1,26 +0,0 @@ -var WebSocketServer = require('../../').Server; -var express = require('express'); -var path = require('path'); -var app = express(); -var server = require('http').createServer(); - -app.use(express.static(path.join(__dirname, '/public'))); - -var wss = new WebSocketServer({ server: server }); -wss.on('connection', function(ws) { - var id = setInterval(function() { - ws.send(JSON.stringify(process.memoryUsage()), function() { - /* ignore errors */ - }); - }, 100); - console.log('started client interval'); - ws.on('close', function() { - console.log('stopping client interval'); - clearInterval(id); - }); -}); - -server.on('request', app); -server.listen(8080, function() { - console.log('Listening on http://localhost:8080'); -}); diff --git a/index.js b/index.js index b8d6be1c9..722c78676 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ const WebSocket = require('./lib/websocket'); +WebSocket.createWebSocketStream = require('./lib/stream'); WebSocket.Server = require('./lib/websocket-server'); WebSocket.Receiver = require('./lib/receiver'); WebSocket.Sender = require('./lib/sender'); diff --git a/lib/buffer-util.js b/lib/buffer-util.js index 54867ac4f..6fd84c311 100644 --- a/lib/buffer-util.js +++ b/lib/buffer-util.js @@ -1,5 +1,7 @@ 'use strict'; +const { EMPTY_BUFFER } = require('./constants'); + /** * Merges an array of buffers into a new buffer. * @@ -9,15 +11,20 @@ * @public */ function concat(list, totalLength) { + if (list.length === 0) return EMPTY_BUFFER; + if (list.length === 1) return list[0]; + const target = Buffer.allocUnsafe(totalLength); - var offset = 0; + let offset = 0; - for (var i = 0; i < list.length; i++) { + for (let i = 0; i < list.length; i++) { const buf = list[i]; - buf.copy(target, offset); + target.set(buf, offset); offset += buf.length; } + if (offset < totalLength) return target.slice(0, offset); + return target; } @@ -32,7 +39,7 @@ function concat(list, totalLength) { * @public */ function _mask(source, mask, output, offset, length) { - for (var i = 0; i < length; i++) { + for (let i = 0; i < length; i++) { output[offset + i] = source[i] ^ mask[i & 3]; } } @@ -47,26 +54,76 @@ function _mask(source, mask, output, offset, length) { function _unmask(buffer, mask) { // Required until https://github.com/nodejs/node/issues/9006 is resolved. const length = buffer.length; - for (var i = 0; i < length; i++) { + for (let i = 0; i < length; i++) { buffer[i] ^= mask[i & 3]; } } +/** + * Converts a buffer to an `ArrayBuffer`. + * + * @param {Buffer} buf The buffer to convert + * @return {ArrayBuffer} Converted buffer + * @public + */ +function toArrayBuffer(buf) { + if (buf.byteLength === buf.buffer.byteLength) { + return buf.buffer; + } + + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +/** + * Converts `data` to a `Buffer`. + * + * @param {*} data The data to convert + * @return {Buffer} The buffer + * @throws {TypeError} + * @public + */ +function toBuffer(data) { + toBuffer.readOnly = true; + + if (Buffer.isBuffer(data)) return data; + + let buf; + + if (data instanceof ArrayBuffer) { + buf = Buffer.from(data); + } else if (ArrayBuffer.isView(data)) { + buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } else { + buf = Buffer.from(data); + toBuffer.readOnly = false; + } + + return buf; +} + try { const bufferUtil = require('bufferutil'); const bu = bufferUtil.BufferUtil || bufferUtil; module.exports = { + concat, mask(source, mask, output, offset, length) { if (length < 48) _mask(source, mask, output, offset, length); else bu.mask(source, mask, output, offset, length); }, + toArrayBuffer, + toBuffer, unmask(buffer, mask) { if (buffer.length < 32) _unmask(buffer, mask); else bu.unmask(buffer, mask); - }, - concat + } }; } catch (e) /* istanbul ignore next */ { - module.exports = { concat, mask: _mask, unmask: _unmask }; + module.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask + }; } diff --git a/lib/event-target.js b/lib/event-target.js index 44c81d991..a6fbe72b7 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -10,7 +10,8 @@ class Event { * Create a new `Event`. * * @param {String} type The name of the event - * @param {Object} target A reference to the target to which the event was dispatched + * @param {Object} target A reference to the target to which the event was + * dispatched */ constructor(type, target) { this.target = target; @@ -29,7 +30,8 @@ class MessageEvent extends Event { * Create a new `MessageEvent`. * * @param {(String|Buffer|ArrayBuffer|Buffer[])} data The received data - * @param {WebSocket} target A reference to the target to which the event was dispatched + * @param {WebSocket} target A reference to the target to which the event was + * dispatched */ constructor(data, target) { super('message', target); @@ -48,9 +50,12 @@ class CloseEvent extends Event { /** * Create a new `CloseEvent`. * - * @param {Number} code The status code explaining why the connection is being closed - * @param {String} reason A human-readable string explaining why the connection is closing - * @param {WebSocket} target A reference to the target to which the event was dispatched + * @param {Number} code The status code explaining why the connection is being + * closed + * @param {String} reason A human-readable string explaining why the + * connection is closing + * @param {WebSocket} target A reference to the target to which the event was + * dispatched */ constructor(code, reason, target) { super('close', target); @@ -71,7 +76,8 @@ class OpenEvent extends Event { /** * Create a new `OpenEvent`. * - * @param {WebSocket} target A reference to the target to which the event was dispatched + * @param {WebSocket} target A reference to the target to which the event was + * dispatched */ constructor(target) { super('open', target); @@ -89,7 +95,8 @@ class ErrorEvent extends Event { * Create a new `ErrorEvent`. * * @param {Object} error The error that generated this event - * @param {WebSocket} target A reference to the target to which the event was dispatched + * @param {WebSocket} target A reference to the target to which the event was + * dispatched */ constructor(error, target) { super('error', target); @@ -109,11 +116,16 @@ const EventTarget = { /** * Register an event listener. * - * @param {String} method A string representing the event type to listen for + * @param {String} type A string representing the event type to listen for * @param {Function} listener The listener to add + * @param {Object} [options] An options object specifies characteristics about + * the event listener + * @param {Boolean} [options.once=false] A `Boolean`` indicating that the + * listener should be invoked at most once after being added. If `true`, + * the listener would be automatically removed when invoked. * @public */ - addEventListener(method, listener) { + addEventListener(type, listener, options) { if (typeof listener !== 'function') return; function onMessage(data) { @@ -132,36 +144,38 @@ const EventTarget = { listener.call(this, new OpenEvent(this)); } - if (method === 'message') { + const method = options && options.once ? 'once' : 'on'; + + if (type === 'message') { onMessage._listener = listener; - this.on(method, onMessage); - } else if (method === 'close') { + this[method](type, onMessage); + } else if (type === 'close') { onClose._listener = listener; - this.on(method, onClose); - } else if (method === 'error') { + this[method](type, onClose); + } else if (type === 'error') { onError._listener = listener; - this.on(method, onError); - } else if (method === 'open') { + this[method](type, onError); + } else if (type === 'open') { onOpen._listener = listener; - this.on(method, onOpen); + this[method](type, onOpen); } else { - this.on(method, listener); + this[method](type, listener); } }, /** * Remove an event listener. * - * @param {String} method A string representing the event type to remove + * @param {String} type A string representing the event type to remove * @param {Function} listener The listener to remove * @public */ - removeEventListener(method, listener) { - const listeners = this.listeners(method); + removeEventListener(type, listener) { + const listeners = this.listeners(type); - for (var i = 0; i < listeners.length; i++) { + for (let i = 0; i < listeners.length; i++) { if (listeners[i] === listener || listeners[i]._listener === listener) { - this.removeListener(method, listeners[i]); + this.removeListener(type, listeners[i]); } } } diff --git a/lib/extension.js b/lib/extension.js index 47096b973..87a421329 100644 --- a/lib/extension.js +++ b/lib/extension.js @@ -34,8 +34,8 @@ const tokenChars = [ * @private */ function push(dest, name, elem) { - if (Object.prototype.hasOwnProperty.call(dest, name)) dest[name].push(elem); - else dest[name] = [elem]; + if (dest[name] === undefined) dest[name] = [elem]; + else dest[name].push(elem); } /** @@ -46,20 +46,21 @@ function push(dest, name, elem) { * @public */ function parse(header) { - const offers = {}; + const offers = Object.create(null); if (header === undefined || header === '') return offers; - var params = {}; - var mustUnescape = false; - var isEscaping = false; - var inQuotes = false; - var extensionName; - var paramName; - var start = -1; - var end = -1; - - for (var i = 0; i < header.length; i++) { + let params = Object.create(null); + let mustUnescape = false; + let isEscaping = false; + let inQuotes = false; + let extensionName; + let paramName; + let start = -1; + let end = -1; + let i = 0; + + for (; i < header.length; i++) { const code = header.charCodeAt(i); if (extensionName === undefined) { @@ -76,7 +77,7 @@ function parse(header) { const name = header.slice(start, end); if (code === 0x2c) { push(offers, name, params); - params = {}; + params = Object.create(null); } else { extensionName = name; } @@ -99,7 +100,7 @@ function parse(header) { push(params, header.slice(start, end), true); if (code === 0x2c) { push(offers, extensionName, params); - params = {}; + params = Object.create(null); extensionName = undefined; } @@ -146,7 +147,7 @@ function parse(header) { } if (end === -1) end = i; - var value = header.slice(start, end); + let value = header.slice(start, end); if (mustUnescape) { value = value.replace(/\\/g, ''); mustUnescape = false; @@ -154,7 +155,7 @@ function parse(header) { push(params, paramName, value); if (code === 0x2c) { push(offers, extensionName, params); - params = {}; + params = Object.create(null); extensionName = undefined; } @@ -173,7 +174,7 @@ function parse(header) { if (end === -1) end = i; const token = header.slice(start, end); if (extensionName === undefined) { - push(offers, token, {}); + push(offers, token, params); } else { if (paramName === undefined) { push(params, token, true); @@ -198,14 +199,14 @@ function parse(header) { function format(extensions) { return Object.keys(extensions) .map((extension) => { - var configurations = extensions[extension]; + let configurations = extensions[extension]; if (!Array.isArray(configurations)) configurations = [configurations]; return configurations .map((params) => { return [extension] .concat( Object.keys(params).map((k) => { - var values = params[k]; + let values = params[k]; if (!Array.isArray(values)) values = [values]; return values .map((v) => (v === true ? k : `${k}=${v}`)) diff --git a/lib/limiter.js b/lib/limiter.js new file mode 100644 index 000000000..3fd35784e --- /dev/null +++ b/lib/limiter.js @@ -0,0 +1,55 @@ +'use strict'; + +const kDone = Symbol('kDone'); +const kRun = Symbol('kRun'); + +/** + * A very simple job queue with adjustable concurrency. Adapted from + * https://github.com/STRML/async-limiter + */ +class Limiter { + /** + * Creates a new `Limiter`. + * + * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed + * to run concurrently + */ + constructor(concurrency) { + this[kDone] = () => { + this.pending--; + this[kRun](); + }; + this.concurrency = concurrency || Infinity; + this.jobs = []; + this.pending = 0; + } + + /** + * Adds a job to the queue. + * + * @param {Function} job The job to run + * @public + */ + add(job) { + this.jobs.push(job); + this[kRun](); + } + + /** + * Removes a job from the queue and runs it if possible. + * + * @private + */ + [kRun]() { + if (this.pending === this.concurrency) return; + + if (this.jobs.length) { + const job = this.jobs.shift(); + + this.pending++; + job(this[kDone]); + } + } +} + +module.exports = Limiter; diff --git a/lib/permessage-deflate.js b/lib/permessage-deflate.js index 9c887647f..a8974b988 100644 --- a/lib/permessage-deflate.js +++ b/lib/permessage-deflate.js @@ -1,14 +1,12 @@ 'use strict'; -const Limiter = require('async-limiter'); const zlib = require('zlib'); const bufferUtil = require('./buffer-util'); +const Limiter = require('./limiter'); const { kStatusCode, NOOP } = require('./constants'); const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); -const EMPTY_BLOCK = Buffer.from([0x00]); - const kPerMessageDeflate = Symbol('permessage-deflate'); const kTotalLength = Symbol('total-length'); const kCallback = Symbol('callback'); @@ -31,24 +29,26 @@ class PerMessageDeflate { /** * Creates a PerMessageDeflate instance. * - * @param {Object} options Configuration options - * @param {Boolean} options.serverNoContextTakeover Request/accept disabling - * of server context takeover - * @param {Boolean} options.clientNoContextTakeover Advertise/acknowledge - * disabling of client context takeover - * @param {(Boolean|Number)} options.serverMaxWindowBits Request/confirm the + * @param {Object} [options] Configuration options + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ + * acknowledge disabling of client context takeover + * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the * use of a custom server window size - * @param {(Boolean|Number)} options.clientMaxWindowBits Advertise support + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support * for, or request, a custom client window size - * @param {Object} options.zlibDeflateOptions Options to pass to zlib on deflate - * @param {Object} options.zlibInflateOptions Options to pass to zlib on inflate - * @param {Number} options.threshold Size (in bytes) below which messages - * should not be compressed - * @param {Number} options.concurrencyLimit The number of concurrent calls to - * zlib - * @param {Boolean} isServer Create the instance in either server or client - * mode - * @param {Number} maxPayload The maximum allowed message length + * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on + * deflate + * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on + * inflate + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib + * @param {Boolean} [isServer=false] Create the instance in either server or + * client mode + * @param {Number} [maxPayload=0] The maximum allowed message length */ constructor(options, isServer, maxPayload) { this._maxPayload = maxPayload | 0; @@ -66,7 +66,7 @@ class PerMessageDeflate { this._options.concurrencyLimit !== undefined ? this._options.concurrencyLimit : 10; - zlibLimiter = new Limiter({ concurrency }); + zlibLimiter = new Limiter(concurrency); } } @@ -133,8 +133,18 @@ class PerMessageDeflate { } if (this._deflate) { + const callback = this._deflate[kCallback]; + this._deflate.close(); this._deflate = null; + + if (callback) { + callback( + new Error( + 'The deflate stream was closed while data was being processed' + ) + ); + } } } @@ -233,7 +243,7 @@ class PerMessageDeflate { normalizeParams(configurations) { configurations.forEach((params) => { Object.keys(params).forEach((key) => { - var value = params[key]; + let value = params[key]; if (value.length > 1) { throw new Error(`Parameter "${key}" must have only a single value`); @@ -284,7 +294,7 @@ class PerMessageDeflate { } /** - * Decompress data. Concurrency limited by async-limiter. + * Decompress data. Concurrency limited. * * @param {Buffer} data Compressed data * @param {Boolean} fin Specifies whether or not this is the last fragment @@ -292,7 +302,7 @@ class PerMessageDeflate { * @public */ decompress(data, fin, callback) { - zlibLimiter.push((done) => { + zlibLimiter.add((done) => { this._decompress(data, fin, (err, result) => { done(); callback(err, result); @@ -301,7 +311,7 @@ class PerMessageDeflate { } /** - * Compress data. Concurrency limited by async-limiter. + * Compress data. Concurrency limited. * * @param {Buffer} data Data to compress * @param {Boolean} fin Specifies whether or not this is the last fragment @@ -309,7 +319,7 @@ class PerMessageDeflate { * @public */ compress(data, fin, callback) { - zlibLimiter.push((done) => { + zlibLimiter.add((done) => { this._compress(data, fin, (err, result) => { done(); callback(err, result); @@ -335,9 +345,10 @@ class PerMessageDeflate { ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; - this._inflate = zlib.createInflateRaw( - Object.assign({}, this._options.zlibInflateOptions, { windowBits }) - ); + this._inflate = zlib.createInflateRaw({ + ...this._options.zlibInflateOptions, + windowBits + }); this._inflate[kPerMessageDeflate] = this; this._inflate[kTotalLength] = 0; this._inflate[kBuffers] = []; @@ -365,12 +376,16 @@ class PerMessageDeflate { this._inflate[kTotalLength] ); - if (fin && this.params[`${endpoint}_no_context_takeover`]) { + if (this._inflate._readableState.endEmitted) { this._inflate.close(); this._inflate = null; } else { this._inflate[kTotalLength] = 0; this._inflate[kBuffers] = []; + + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._inflate.reset(); + } } callback(null, data); @@ -386,11 +401,6 @@ class PerMessageDeflate { * @private */ _compress(data, fin, callback) { - if (!data || data.length === 0) { - process.nextTick(callback, null, EMPTY_BLOCK); - return; - } - const endpoint = this._isServer ? 'server' : 'client'; if (!this._deflate) { @@ -400,9 +410,10 @@ class PerMessageDeflate { ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; - this._deflate = zlib.createDeflateRaw( - Object.assign({}, this._options.zlibDeflateOptions, { windowBits }) - ); + this._deflate = zlib.createDeflateRaw({ + ...this._options.zlibDeflateOptions, + windowBits + }); this._deflate[kTotalLength] = 0; this._deflate[kBuffers] = []; @@ -417,31 +428,35 @@ class PerMessageDeflate { this._deflate.on('data', deflateOnData); } + this._deflate[kCallback] = callback; + this._deflate.write(data); this._deflate.flush(zlib.Z_SYNC_FLUSH, () => { if (!this._deflate) { // - // This `if` statement is only needed for Node.js < 10.0.0 because as of - // commit https://github.com/nodejs/node/commit/5e3f5164, the flush - // callback is no longer called if the deflate stream is closed while - // data is being processed. + // The deflate stream was closed while data was being processed. // return; } - var data = bufferUtil.concat( + let data = bufferUtil.concat( this._deflate[kBuffers], this._deflate[kTotalLength] ); if (fin) data = data.slice(0, data.length - 4); + // + // Ensure that the callback will not be called again in + // `PerMessageDeflate#cleanup()`. + // + this._deflate[kCallback] = null; + + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { - this._deflate.close(); - this._deflate = null; - } else { - this._deflate[kTotalLength] = 0; - this._deflate[kBuffers] = []; + this._deflate.reset(); } callback(null, data); diff --git a/lib/receiver.js b/lib/receiver.js index a2cef8c44..65a5ab45f 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -1,11 +1,16 @@ 'use strict'; -const stream = require('stream'); +const { Writable } = require('stream'); const PerMessageDeflate = require('./permessage-deflate'); -const bufferUtil = require('./buffer-util'); -const validation = require('./validation'); -const constants = require('./constants'); +const { + BINARY_TYPES, + EMPTY_BUFFER, + kStatusCode, + kWebSocket +} = require('./constants'); +const { concat, toArrayBuffer, unmask } = require('./buffer-util'); +const { isValidStatusCode, isValidUTF8 } = require('./validation'); const GET_INFO = 0; const GET_PAYLOAD_LENGTH_16 = 1; @@ -19,20 +24,23 @@ const INFLATING = 5; * * @extends stream.Writable */ -class Receiver extends stream.Writable { +class Receiver extends Writable { /** * Creates a Receiver instance. * - * @param {String} binaryType The type for binary data - * @param {Object} extensions An object containing the negotiated extensions - * @param {Number} maxPayload The maximum allowed message length + * @param {String} [binaryType=nodebuffer] The type for binary data + * @param {Object} [extensions] An object containing the negotiated extensions + * @param {Boolean} [isServer=false] Specifies whether to operate in client or + * server mode + * @param {Number} [maxPayload=0] The maximum allowed message length */ - constructor(binaryType, extensions, maxPayload) { + constructor(binaryType, extensions, isServer, maxPayload) { super(); - this._binaryType = binaryType || constants.BINARY_TYPES[0]; - this[constants.kWebSocket] = undefined; + this._binaryType = binaryType || BINARY_TYPES[0]; + this[kWebSocket] = undefined; this._extensions = extensions || {}; + this._isServer = !!isServer; this._maxPayload = maxPayload | 0; this._bufferedBytes = 0; @@ -60,6 +68,7 @@ class Receiver extends stream.Writable { * @param {Buffer} chunk The chunk of data to write * @param {String} encoding The character encoding of `chunk` * @param {Function} cb Callback + * @private */ _write(chunk, encoding, cb) { if (this._opcode === 0x08 && this._state == GET_INFO) return cb(); @@ -91,11 +100,12 @@ class Receiver extends stream.Writable { do { const buf = this._buffers[0]; + const offset = dst.length - n; if (n >= buf.length) { - this._buffers.shift().copy(dst, dst.length - n); + dst.set(this._buffers.shift(), offset); } else { - buf.copy(dst, dst.length - n, 0, n); + dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); this._buffers[0] = buf.slice(n); } @@ -112,7 +122,7 @@ class Receiver extends stream.Writable { * @private */ startLoop(cb) { - var err; + let err; this._loop = true; do { @@ -219,6 +229,16 @@ class Receiver extends stream.Writable { if (!this._fin && !this._fragmented) this._fragmented = this._opcode; this._masked = (buf[1] & 0x80) === 0x80; + if (this._isServer) { + if (!this._masked) { + this._loop = false; + return error(RangeError, 'MASK must be set', true, 1002); + } + } else if (this._masked) { + this._loop = false; + return error(RangeError, 'MASK must be clear', true, 1002); + } + if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; else return this.haveLength(); @@ -315,7 +335,7 @@ class Receiver extends stream.Writable { * @private */ getData(cb) { - var data = constants.EMPTY_BUFFER; + let data = EMPTY_BUFFER; if (this._payloadLength) { if (this._bufferedBytes < this._payloadLength) { @@ -324,7 +344,7 @@ class Receiver extends stream.Writable { } data = this.consume(this._payloadLength); - if (this._masked) bufferUtil.unmask(data, this._mask); + if (this._masked) unmask(data, this._mask); } if (this._opcode > 0x07) return this.controlMessage(data); @@ -395,21 +415,21 @@ class Receiver extends stream.Writable { this._fragments = []; if (this._opcode === 2) { - var data; + let data; if (this._binaryType === 'nodebuffer') { - data = toBuffer(fragments, messageLength); + data = concat(fragments, messageLength); } else if (this._binaryType === 'arraybuffer') { - data = toArrayBuffer(toBuffer(fragments, messageLength)); + data = toArrayBuffer(concat(fragments, messageLength)); } else { data = fragments; } this.emit('message', data); } else { - const buf = toBuffer(fragments, messageLength); + const buf = concat(fragments, messageLength); - if (!validation.isValidUTF8(buf)) { + if (!isValidUTF8(buf)) { this._loop = false; return error(Error, 'invalid UTF-8 sequence', true, 1007); } @@ -440,13 +460,13 @@ class Receiver extends stream.Writable { } else { const code = data.readUInt16BE(0); - if (!validation.isValidStatusCode(code)) { + if (!isValidStatusCode(code)) { return error(RangeError, `invalid status code ${code}`, true, 1002); } const buf = data.slice(2); - if (!validation.isValidUTF8(buf)) { + if (!isValidUTF8(buf)) { return error(Error, 'invalid UTF-8 sequence', true, 1007); } @@ -482,34 +502,6 @@ function error(ErrorCtor, message, prefix, statusCode) { ); Error.captureStackTrace(err, error); - err[constants.kStatusCode] = statusCode; + err[kStatusCode] = statusCode; return err; } - -/** - * Makes a buffer from a list of fragments. - * - * @param {Buffer[]} fragments The list of fragments composing the message - * @param {Number} messageLength The length of the message - * @return {Buffer} - * @private - */ -function toBuffer(fragments, messageLength) { - if (fragments.length === 1) return fragments[0]; - if (fragments.length > 1) return bufferUtil.concat(fragments, messageLength); - return constants.EMPTY_BUFFER; -} - -/** - * Converts a buffer to an `ArrayBuffer`. - * - * @param {Buffer} buf The buffer to convert - * @return {ArrayBuffer} Converted buffer - */ -function toArrayBuffer(buf) { - if (buf.byteLength === buf.buffer.byteLength) { - return buf.buffer; - } - - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); -} diff --git a/lib/sender.js b/lib/sender.js index 3ac8bf8c6..ad71e1950 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -1,11 +1,13 @@ 'use strict'; -const crypto = require('crypto'); +const { randomFillSync } = require('crypto'); const PerMessageDeflate = require('./permessage-deflate'); -const bufferUtil = require('./buffer-util'); -const validation = require('./validation'); -const constants = require('./constants'); +const { EMPTY_BUFFER } = require('./constants'); +const { isValidStatusCode } = require('./validation'); +const { mask: applyMask, toBuffer } = require('./buffer-util'); + +const mask = Buffer.alloc(4); /** * HyBi Sender implementation. @@ -15,7 +17,7 @@ class Sender { * Creates a Sender instance. * * @param {net.Socket} socket The connection socket - * @param {Object} extensions An object containing the negotiated extensions + * @param {Object} [extensions] An object containing the negotiated extensions */ constructor(socket, extensions) { this._extensions = extensions || {}; @@ -35,17 +37,21 @@ class Sender { * @param {Buffer} data The data to frame * @param {Object} options Options object * @param {Number} options.opcode The opcode - * @param {Boolean} options.readOnly Specifies whether `data` can be modified - * @param {Boolean} options.fin Specifies whether or not to set the FIN bit - * @param {Boolean} options.mask Specifies whether or not to mask `data` - * @param {Boolean} options.rsv1 Specifies whether or not to set the RSV1 bit + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit * @return {Buffer[]} The framed data as a list of `Buffer` instances * @public */ static frame(data, options) { - const merge = data.length < 1024 || (options.mask && options.readOnly); - var offset = options.mask ? 6 : 2; - var payloadLength = data.length; + const merge = options.mask && options.readOnly; + let offset = options.mask ? 6 : 2; + let payloadLength = data.length; if (data.length >= 65536) { offset += 8; @@ -60,6 +66,8 @@ class Sender { target[0] = options.fin ? options.opcode | 0x80 : options.opcode; if (options.rsv1) target[0] |= 0x40; + target[1] = payloadLength; + if (payloadLength === 126) { target.writeUInt16BE(data.length, 2); } else if (payloadLength === 127) { @@ -67,57 +75,52 @@ class Sender { target.writeUInt32BE(data.length, 6); } - if (!options.mask) { - target[1] = payloadLength; - if (merge) { - data.copy(target, offset); - return [target]; - } - - return [target, data]; - } + if (!options.mask) return [target, data]; - const mask = crypto.randomBytes(4); + randomFillSync(mask, 0, 4); - target[1] = payloadLength | 0x80; + target[1] |= 0x80; target[offset - 4] = mask[0]; target[offset - 3] = mask[1]; target[offset - 2] = mask[2]; target[offset - 1] = mask[3]; if (merge) { - bufferUtil.mask(data, mask, target, offset, data.length); + applyMask(data, mask, target, offset, data.length); return [target]; } - bufferUtil.mask(data, mask, data, 0, data.length); + applyMask(data, mask, data, 0, data.length); return [target, data]; } /** * Sends a close message to the other peer. * - * @param {(Number|undefined)} code The status code component of the body - * @param {String} data The message component of the body - * @param {Boolean} mask Specifies whether or not to mask the message - * @param {Function} cb Callback + * @param {Number} [code] The status code component of the body + * @param {String} [data] The message component of the body + * @param {Boolean} [mask=false] Specifies whether or not to mask the message + * @param {Function} [cb] Callback * @public */ close(code, data, mask, cb) { - var buf; + let buf; if (code === undefined) { - buf = constants.EMPTY_BUFFER; - } else if ( - typeof code !== 'number' || - !validation.isValidStatusCode(code) - ) { + buf = EMPTY_BUFFER; + } else if (typeof code !== 'number' || !isValidStatusCode(code)) { throw new TypeError('First argument must be a valid error code number'); } else if (data === undefined || data === '') { buf = Buffer.allocUnsafe(2); buf.writeUInt16BE(code, 0); } else { - buf = Buffer.allocUnsafe(2 + Buffer.byteLength(data)); + const length = Buffer.byteLength(data); + + if (length > 123) { + throw new RangeError('The message must not be greater than 123 bytes'); + } + + buf = Buffer.allocUnsafe(2 + length); buf.writeUInt16BE(code, 0); buf.write(data, 2); } @@ -133,8 +136,8 @@ class Sender { * Frames and sends a close message. * * @param {Buffer} data The message to send - * @param {Boolean} mask Specifies whether or not to mask `data` - * @param {Function} cb Callback + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback * @private */ doClose(data, mask, cb) { @@ -154,38 +157,31 @@ class Sender { * Sends a ping message to the other peer. * * @param {*} data The message to send - * @param {Boolean} mask Specifies whether or not to mask `data` - * @param {Function} cb Callback + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback * @public */ ping(data, mask, cb) { - var readOnly = true; + const buf = toBuffer(data); - if (!Buffer.isBuffer(data)) { - if (data instanceof ArrayBuffer) { - data = Buffer.from(data); - } else if (ArrayBuffer.isView(data)) { - data = viewToBuffer(data); - } else { - data = Buffer.from(data); - readOnly = false; - } + if (buf.length > 125) { + throw new RangeError('The data size must not be greater than 125 bytes'); } if (this._deflating) { - this.enqueue([this.doPing, data, mask, readOnly, cb]); + this.enqueue([this.doPing, buf, mask, toBuffer.readOnly, cb]); } else { - this.doPing(data, mask, readOnly, cb); + this.doPing(buf, mask, toBuffer.readOnly, cb); } } /** * Frames and sends a ping message. * - * @param {*} data The message to send - * @param {Boolean} mask Specifies whether or not to mask `data` - * @param {Boolean} readOnly Specifies whether `data` can be modified - * @param {Function} cb Callback + * @param {Buffer} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Boolean} [readOnly=false] Specifies whether `data` can be modified + * @param {Function} [cb] Callback * @private */ doPing(data, mask, readOnly, cb) { @@ -205,38 +201,31 @@ class Sender { * Sends a pong message to the other peer. * * @param {*} data The message to send - * @param {Boolean} mask Specifies whether or not to mask `data` - * @param {Function} cb Callback + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback * @public */ pong(data, mask, cb) { - var readOnly = true; + const buf = toBuffer(data); - if (!Buffer.isBuffer(data)) { - if (data instanceof ArrayBuffer) { - data = Buffer.from(data); - } else if (ArrayBuffer.isView(data)) { - data = viewToBuffer(data); - } else { - data = Buffer.from(data); - readOnly = false; - } + if (buf.length > 125) { + throw new RangeError('The data size must not be greater than 125 bytes'); } if (this._deflating) { - this.enqueue([this.doPong, data, mask, readOnly, cb]); + this.enqueue([this.doPong, buf, mask, toBuffer.readOnly, cb]); } else { - this.doPong(data, mask, readOnly, cb); + this.doPong(buf, mask, toBuffer.readOnly, cb); } } /** * Frames and sends a pong message. * - * @param {*} data The message to send - * @param {Boolean} mask Specifies whether or not to mask `data` - * @param {Boolean} readOnly Specifies whether `data` can be modified - * @param {Function} cb Callback + * @param {Buffer} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Boolean} [readOnly=false] Specifies whether `data` can be modified + * @param {Function} [cb] Callback * @private */ doPong(data, mask, readOnly, cb) { @@ -257,35 +246,27 @@ class Sender { * * @param {*} data The message to send * @param {Object} options Options object - * @param {Boolean} options.compress Specifies whether or not to compress `data` - * @param {Boolean} options.binary Specifies whether `data` is binary or text - * @param {Boolean} options.fin Specifies whether the fragment is the last one - * @param {Boolean} options.mask Specifies whether or not to mask `data` - * @param {Function} cb Callback + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` + * @param {Boolean} [options.binary=false] Specifies whether `data` is binary + * or text + * @param {Boolean} [options.fin=false] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Function} [cb] Callback * @public */ send(data, options, cb) { - var opcode = options.binary ? 2 : 1; - var rsv1 = options.compress; - var readOnly = true; - - if (!Buffer.isBuffer(data)) { - if (data instanceof ArrayBuffer) { - data = Buffer.from(data); - } else if (ArrayBuffer.isView(data)) { - data = viewToBuffer(data); - } else { - data = Buffer.from(data); - readOnly = false; - } - } - + const buf = toBuffer(data); const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + let opcode = options.binary ? 2 : 1; + let rsv1 = options.compress; if (this._firstFragment) { this._firstFragment = false; if (rsv1 && perMessageDeflate) { - rsv1 = data.length >= perMessageDeflate._threshold; + rsv1 = buf.length >= perMessageDeflate._threshold; } this._compress = rsv1; } else { @@ -301,22 +282,22 @@ class Sender { rsv1, opcode, mask: options.mask, - readOnly + readOnly: toBuffer.readOnly }; if (this._deflating) { - this.enqueue([this.dispatch, data, this._compress, opts, cb]); + this.enqueue([this.dispatch, buf, this._compress, opts, cb]); } else { - this.dispatch(data, this._compress, opts, cb); + this.dispatch(buf, this._compress, opts, cb); } } else { this.sendFrame( - Sender.frame(data, { + Sender.frame(buf, { fin: options.fin, rsv1: false, opcode, mask: options.mask, - readOnly + readOnly: toBuffer.readOnly }), cb ); @@ -327,14 +308,19 @@ class Sender { * Dispatches a data message. * * @param {Buffer} data The message to send - * @param {Boolean} compress Specifies whether or not to compress `data` + * @param {Boolean} [compress=false] Specifies whether or not to compress + * `data` * @param {Object} options Options object * @param {Number} options.opcode The opcode - * @param {Boolean} options.readOnly Specifies whether `data` can be modified - * @param {Boolean} options.fin Specifies whether or not to set the FIN bit - * @param {Boolean} options.mask Specifies whether or not to mask `data` - * @param {Boolean} options.rsv1 Specifies whether or not to set the RSV1 bit - * @param {Function} cb Callback + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback * @private */ dispatch(data, compress, options, cb) { @@ -345,8 +331,26 @@ class Sender { const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + this._bufferedBytes += data.length; this._deflating = true; perMessageDeflate.compress(data, options.fin, (_, buf) => { + if (this._socket.destroyed) { + const err = new Error( + 'The socket was closed while data was being compressed' + ); + + if (typeof cb === 'function') cb(err); + + for (let i = 0; i < this._queue.length; i++) { + const callback = this._queue[i][4]; + + if (typeof callback === 'function') callback(err); + } + + return; + } + + this._bufferedBytes -= data.length; this._deflating = false; options.readOnly = false; this.sendFrame(Sender.frame(buf, options), cb); @@ -364,7 +368,7 @@ class Sender { const params = this._queue.shift(); this._bufferedBytes -= params[1].length; - params[0].apply(this, params.slice(1)); + Reflect.apply(params[0], this, params.slice(1)); } } @@ -383,13 +387,15 @@ class Sender { * Sends a frame. * * @param {Buffer[]} list The frame to send - * @param {Function} cb Callback + * @param {Function} [cb] Callback * @private */ sendFrame(list, cb) { if (list.length === 2) { + this._socket.cork(); this._socket.write(list[0]); this._socket.write(list[1], cb); + this._socket.uncork(); } else { this._socket.write(list[0], cb); } @@ -397,20 +403,3 @@ class Sender { } module.exports = Sender; - -/** - * Converts an `ArrayBuffer` view into a buffer. - * - * @param {(DataView|TypedArray)} view The view to convert - * @return {Buffer} Converted view - * @private - */ -function viewToBuffer(view) { - const buf = Buffer.from(view.buffer); - - if (view.byteLength !== view.buffer.byteLength) { - return buf.slice(view.byteOffset, view.byteOffset + view.byteLength); - } - - return buf; -} diff --git a/lib/stream.js b/lib/stream.js new file mode 100644 index 000000000..604cf366b --- /dev/null +++ b/lib/stream.js @@ -0,0 +1,165 @@ +'use strict'; + +const { Duplex } = require('stream'); + +/** + * Emits the `'close'` event on a stream. + * + * @param {stream.Duplex} The stream. + * @private + */ +function emitClose(stream) { + stream.emit('close'); +} + +/** + * The listener of the `'end'` event. + * + * @private + */ +function duplexOnEnd() { + if (!this.destroyed && this._writableState.finished) { + this.destroy(); + } +} + +/** + * The listener of the `'error'` event. + * + * @param {Error} err The error + * @private + */ +function duplexOnError(err) { + this.removeListener('error', duplexOnError); + this.destroy(); + if (this.listenerCount('error') === 0) { + // Do not suppress the throwing behavior. + this.emit('error', err); + } +} + +/** + * Wraps a `WebSocket` in a duplex stream. + * + * @param {WebSocket} ws The `WebSocket` to wrap + * @param {Object} [options] The options for the `Duplex` constructor + * @return {stream.Duplex} The duplex stream + * @public + */ +function createWebSocketStream(ws, options) { + let resumeOnReceiverDrain = true; + + function receiverOnDrain() { + if (resumeOnReceiverDrain) ws._socket.resume(); + } + + if (ws.readyState === ws.CONNECTING) { + ws.once('open', function open() { + ws._receiver.removeAllListeners('drain'); + ws._receiver.on('drain', receiverOnDrain); + }); + } else { + ws._receiver.removeAllListeners('drain'); + ws._receiver.on('drain', receiverOnDrain); + } + + const duplex = new Duplex({ + ...options, + autoDestroy: false, + emitClose: false, + objectMode: false, + writableObjectMode: false + }); + + ws.on('message', function message(msg) { + if (!duplex.push(msg)) { + resumeOnReceiverDrain = false; + ws._socket.pause(); + } + }); + + ws.once('error', function error(err) { + if (duplex.destroyed) return; + + duplex.destroy(err); + }); + + ws.once('close', function close() { + if (duplex.destroyed) return; + + duplex.push(null); + }); + + duplex._destroy = function (err, callback) { + if (ws.readyState === ws.CLOSED) { + callback(err); + process.nextTick(emitClose, duplex); + return; + } + + let called = false; + + ws.once('error', function error(err) { + called = true; + callback(err); + }); + + ws.once('close', function close() { + if (!called) callback(err); + process.nextTick(emitClose, duplex); + }); + ws.terminate(); + }; + + duplex._final = function (callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once('open', function open() { + duplex._final(callback); + }); + return; + } + + // If the value of the `_socket` property is `null` it means that `ws` is a + // client websocket and the handshake failed. In fact, when this happens, a + // socket is never assigned to the websocket. Wait for the `'error'` event + // that will be emitted by the websocket. + if (ws._socket === null) return; + + if (ws._socket._writableState.finished) { + callback(); + if (duplex._readableState.endEmitted) duplex.destroy(); + } else { + ws._socket.once('finish', function finish() { + // `duplex` is not destroyed here because the `'end'` event will be + // emitted on `duplex` after this `'finish'` event. The EOF signaling + // `null` chunk is, in fact, pushed when the websocket emits `'close'`. + callback(); + }); + ws.close(); + } + }; + + duplex._read = function () { + if (ws.readyState === ws.OPEN && !resumeOnReceiverDrain) { + resumeOnReceiverDrain = true; + if (!ws._receiver._writableState.needDrain) ws._socket.resume(); + } + }; + + duplex._write = function (chunk, encoding, callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once('open', function open() { + duplex._write(chunk, encoding, callback); + }); + return; + } + + ws.send(chunk, callback); + }; + + duplex.on('end', duplexOnEnd); + duplex.on('error', duplexOnError); + return duplex; +} + +module.exports = createWebSocketStream; diff --git a/lib/validation.js b/lib/validation.js index 479a7db08..169ac6f06 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -1,16 +1,5 @@ 'use strict'; -try { - const isValidUTF8 = require('utf-8-validate'); - - exports.isValidUTF8 = - typeof isValidUTF8 === 'object' - ? isValidUTF8.Validation.isValidUTF8 // utf-8-validate@<3.0.0 - : isValidUTF8; -} catch (e) /* istanbul ignore next */ { - exports.isValidUTF8 = () => true; -} - /** * Checks if a status code is allowed in a close frame. * @@ -18,13 +7,98 @@ try { * @return {Boolean} `true` if the status code is valid, else `false` * @public */ -exports.isValidStatusCode = (code) => { +function isValidStatusCode(code) { return ( (code >= 1000 && - code <= 1013 && + code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) || (code >= 3000 && code <= 4999) ); -}; +} + +/** + * Checks if a given buffer contains only correct UTF-8. + * Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by + * Markus Kuhn. + * + * @param {Buffer} buf The buffer to check + * @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false` + * @public + */ +function _isValidUTF8(buf) { + const len = buf.length; + let i = 0; + + while (i < len) { + if ((buf[i] & 0x80) === 0) { + // 0xxxxxxx + i++; + } else if ((buf[i] & 0xe0) === 0xc0) { + // 110xxxxx 10xxxxxx + if ( + i + 1 === len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i] & 0xfe) === 0xc0 // Overlong + ) { + return false; + } + + i += 2; + } else if ((buf[i] & 0xf0) === 0xe0) { + // 1110xxxx 10xxxxxx 10xxxxxx + if ( + i + 2 >= len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong + (buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF) + ) { + return false; + } + + i += 3; + } else if ((buf[i] & 0xf8) === 0xf0) { + // 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + if ( + i + 3 >= len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i + 3] & 0xc0) !== 0x80 || + (buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong + (buf[i] === 0xf4 && buf[i + 1] > 0x8f) || + buf[i] > 0xf4 // > U+10FFFF + ) { + return false; + } + + i += 4; + } else { + return false; + } + } + + return true; +} + +try { + let isValidUTF8 = require('utf-8-validate'); + + /* istanbul ignore if */ + if (typeof isValidUTF8 === 'object') { + isValidUTF8 = isValidUTF8.Validation.isValidUTF8; // utf-8-validate@<3.0.0 + } + + module.exports = { + isValidStatusCode, + isValidUTF8(buf) { + return buf.length < 150 ? _isValidUTF8(buf) : isValidUTF8(buf); + } + }; +} catch (e) /* istanbul ignore next */ { + module.exports = { + isValidStatusCode, + isValidUTF8: _isValidUTF8 + }; +} diff --git a/lib/websocket-server.js b/lib/websocket-server.js index deca40838..3c3bbe0b0 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -1,13 +1,15 @@ 'use strict'; const EventEmitter = require('events'); -const crypto = require('crypto'); -const http = require('http'); +const { createHash } = require('crypto'); +const { createServer, STATUS_CODES } = require('http'); const PerMessageDeflate = require('./permessage-deflate'); -const extension = require('./extension'); -const constants = require('./constants'); const WebSocket = require('./websocket'); +const { format, parse } = require('./extension'); +const { GUID, kWebSocket } = require('./constants'); + +const keyRegex = /^[+/0-9A-Za-z]{22}==$/; /** * Class representing a WebSocket server. @@ -19,37 +21,40 @@ class WebSocketServer extends EventEmitter { * Create a `WebSocketServer` instance. * * @param {Object} options Configuration options - * @param {String} options.host The hostname where to bind the server - * @param {Number} options.port The port where to bind the server - * @param {http.Server} options.server A pre-created HTTP/S server to use - * @param {Function} options.verifyClient An hook to reject connections - * @param {Function} options.handleProtocols An hook to handle protocols - * @param {String} options.path Accept only connections matching this path - * @param {Boolean} options.noServer Enable no server mode - * @param {Boolean} options.clientTracking Specifies whether or not to track clients - * @param {(Boolean|Object)} options.perMessageDeflate Enable/disable permessage-deflate - * @param {Number} options.maxPayload The maximum allowed message size - * @param {Function} callback A listener for the `listening` event + * @param {Number} [options.backlog=511] The maximum length of the queue of + * pending connections + * @param {Boolean} [options.clientTracking=true] Specifies whether or not to + * track clients + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable + * permessage-deflate + * @param {Number} [options.port] The port where to bind the server + * @param {http.Server} [options.server] A pre-created HTTP/S server to use + * @param {Function} [options.verifyClient] A hook to reject connections + * @param {Function} [callback] A listener for the `listening` event */ constructor(options, callback) { super(); - options = Object.assign( - { - maxPayload: 100 * 1024 * 1024, - perMessageDeflate: false, - handleProtocols: null, - clientTracking: true, - verifyClient: null, - noServer: false, - backlog: null, // use default (511 as implemented in net.js) - server: null, - host: null, - path: null, - port: null - }, - options - ); + options = { + maxPayload: 100 * 1024 * 1024, + perMessageDeflate: false, + handleProtocols: null, + clientTracking: true, + verifyClient: null, + noServer: false, + backlog: null, // use default (511 as implemented in net.js) + server: null, + host: null, + path: null, + port: null, + ...options + }; if (options.port == null && !options.server && !options.noServer) { throw new TypeError( @@ -58,8 +63,8 @@ class WebSocketServer extends EventEmitter { } if (options.port != null) { - this._server = http.createServer((req, res) => { - const body = http.STATUS_CODES[426]; + this._server = createServer((req, res) => { + const body = STATUS_CODES[426]; res.writeHead(426, { 'Content-Length': body.length, @@ -78,13 +83,13 @@ class WebSocketServer extends EventEmitter { } if (this._server) { + const emitConnection = this.emit.bind(this, 'connection'); + this._removeListeners = addListeners(this._server, { listening: this.emit.bind(this, 'listening'), error: this.emit.bind(this, 'error'), upgrade: (req, socket, head) => { - this.handleUpgrade(req, socket, head, (ws) => { - this.emit('connection', ws, req); - }); + this.handleUpgrade(req, socket, head, emitConnection); } }); } @@ -115,7 +120,7 @@ class WebSocketServer extends EventEmitter { /** * Close the server. * - * @param {Function} cb Callback + * @param {Function} [cb] Callback * @public */ close(cb) { @@ -176,13 +181,18 @@ class WebSocketServer extends EventEmitter { handleUpgrade(req, socket, head, cb) { socket.on('error', socketOnError); + const key = + req.headers['sec-websocket-key'] !== undefined + ? req.headers['sec-websocket-key'].trim() + : false; const version = +req.headers['sec-websocket-version']; const extensions = {}; if ( req.method !== 'GET' || req.headers.upgrade.toLowerCase() !== 'websocket' || - !req.headers['sec-websocket-key'] || + !key || + !keyRegex.test(key) || (version !== 8 && version !== 13) || !this.shouldHandle(req) ) { @@ -197,7 +207,7 @@ class WebSocketServer extends EventEmitter { ); try { - const offers = extension.parse(req.headers['sec-websocket-extensions']); + const offers = parse(req.headers['sec-websocket-extensions']); if (offers[PerMessageDeflate.extensionName]) { perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); @@ -215,7 +225,7 @@ class WebSocketServer extends EventEmitter { const info = { origin: req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`], - secure: !!(req.connection.authorized || req.connection.encrypted), + secure: !!(req.socket.authorized || req.socket.encrypted), req }; @@ -225,7 +235,7 @@ class WebSocketServer extends EventEmitter { return abortHandshake(socket, code || 401, message, headers); } - this.completeUpgrade(extensions, req, socket, head, cb); + this.completeUpgrade(key, extensions, req, socket, head, cb); }); return; } @@ -233,42 +243,50 @@ class WebSocketServer extends EventEmitter { if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); } - this.completeUpgrade(extensions, req, socket, head, cb); + this.completeUpgrade(key, extensions, req, socket, head, cb); } /** * Upgrade the connection to WebSocket. * + * @param {String} key The value of the `Sec-WebSocket-Key` header * @param {Object} extensions The accepted extensions * @param {http.IncomingMessage} req The request object * @param {net.Socket} socket The network socket between the server and client * @param {Buffer} head The first packet of the upgraded stream * @param {Function} cb Callback + * @throws {Error} If called more than once with the same socket * @private */ - completeUpgrade(extensions, req, socket, head, cb) { + completeUpgrade(key, extensions, req, socket, head, cb) { // // Destroy the socket if the client has already sent a FIN packet. // if (!socket.readable || !socket.writable) return socket.destroy(); - const key = crypto - .createHash('sha1') - .update(req.headers['sec-websocket-key'] + constants.GUID, 'binary') + if (socket[kWebSocket]) { + throw new Error( + 'server.handleUpgrade() was called more than once with the same ' + + 'socket, possibly due to a misconfiguration' + ); + } + + const digest = createHash('sha1') + .update(key + GUID) .digest('base64'); const headers = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', - `Sec-WebSocket-Accept: ${key}` + `Sec-WebSocket-Accept: ${digest}` ]; const ws = new WebSocket(null); - var protocol = req.headers['sec-websocket-protocol']; + let protocol = req.headers['sec-websocket-protocol']; if (protocol) { - protocol = protocol.trim().split(/ *, */); + protocol = protocol.split(',').map(trim); // // Optionally call external protocol selection handler. @@ -281,13 +299,13 @@ class WebSocketServer extends EventEmitter { if (protocol) { headers.push(`Sec-WebSocket-Protocol: ${protocol}`); - ws.protocol = protocol; + ws._protocol = protocol; } } if (extensions[PerMessageDeflate.extensionName]) { const params = extensions[PerMessageDeflate.extensionName].params; - const value = extension.format({ + const value = format({ [PerMessageDeflate.extensionName]: [params] }); headers.push(`Sec-WebSocket-Extensions: ${value}`); @@ -309,7 +327,7 @@ class WebSocketServer extends EventEmitter { ws.on('close', () => this.clients.delete(ws)); } - cb(ws); + cb(ws, req); } } @@ -321,7 +339,8 @@ module.exports = WebSocketServer; * * @param {EventEmitter} server The event emitter * @param {Object.} map The listeners to add - * @return {Function} A function that will remove the added listeners when called + * @return {Function} A function that will remove the added listeners when + * called * @private */ function addListeners(server, map) { @@ -364,18 +383,16 @@ function socketOnError() { */ function abortHandshake(socket, code, message, headers) { if (socket.writable) { - message = message || http.STATUS_CODES[code]; - headers = Object.assign( - { - Connection: 'close', - 'Content-type': 'text/html', - 'Content-Length': Buffer.byteLength(message) - }, - headers - ); + message = message || STATUS_CODES[code]; + headers = { + Connection: 'close', + 'Content-Type': 'text/html', + 'Content-Length': Buffer.byteLength(message), + ...headers + }; socket.write( - `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` + + `HTTP/1.1 ${code} ${STATUS_CODES[code]}\r\n` + Object.keys(headers) .map((h) => `${h}: ${headers[h]}`) .join('\r\n') + @@ -387,3 +404,15 @@ function abortHandshake(socket, code, message, headers) { socket.removeListener('error', socketOnError); socket.destroy(); } + +/** + * Remove whitespace characters from both ends of a string. + * + * @param {String} str The string + * @return {String} A new string representing `str` stripped of whitespace + * characters from both its beginning and end + * @private + */ +function trim(str) { + return str.trim(); +} diff --git a/lib/websocket.js b/lib/websocket.js index 1d7c4468b..83b471d94 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -1,24 +1,31 @@ 'use strict'; const EventEmitter = require('events'); -const crypto = require('crypto'); const https = require('https'); const http = require('http'); const net = require('net'); const tls = require('tls'); -const url = require('url'); +const { randomBytes, createHash } = require('crypto'); +const { URL } = require('url'); const PerMessageDeflate = require('./permessage-deflate'); -const EventTarget = require('./event-target'); -const extension = require('./extension'); -const constants = require('./constants'); const Receiver = require('./receiver'); const Sender = require('./sender'); +const { + BINARY_TYPES, + EMPTY_BUFFER, + GUID, + kStatusCode, + kWebSocket, + NOOP +} = require('./constants'); +const { addEventListener, removeEventListener } = require('./event-target'); +const { format, parse } = require('./extension'); +const { toBuffer } = require('./buffer-util'); const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; -const kWebSocket = constants.kWebSocket; const protocolVersions = [8, 13]; -const closeTimeout = 30 * 1000; // Allow 30 seconds to terminate the connection cleanly. +const closeTimeout = 30 * 1000; /** * Class representing a WebSocket. @@ -29,29 +36,31 @@ class WebSocket extends EventEmitter { /** * Create a new `WebSocket`. * - * @param {(String|url.Url|url.URL)} address The URL to which to connect - * @param {(String|String[])} protocols The subprotocols - * @param {Object} options Connection options + * @param {(String|url.URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options */ constructor(address, protocols, options) { super(); - this.readyState = WebSocket.CONNECTING; - this.protocol = ''; - - this._binaryType = constants.BINARY_TYPES[0]; + this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; this._closeFrameReceived = false; this._closeFrameSent = false; this._closeMessage = ''; this._closeTimer = null; - this._closeCode = 1006; this._extensions = {}; - this._isServer = true; + this._protocol = ''; + this._readyState = WebSocket.CONNECTING; this._receiver = null; this._sender = null; this._socket = null; if (address !== null) { + this._bufferedAmount = 0; + this._isServer = false; + this._redirects = 0; + if (Array.isArray(protocols)) { protocols = protocols.join(', '); } else if (typeof protocols === 'object' && protocols !== null) { @@ -59,26 +68,16 @@ class WebSocket extends EventEmitter { protocols = undefined; } - initAsClient.call(this, address, protocols, options); + initAsClient(this, address, protocols, options); + } else { + this._isServer = true; } } - get CONNECTING() { - return WebSocket.CONNECTING; - } - get CLOSING() { - return WebSocket.CLOSING; - } - get CLOSED() { - return WebSocket.CLOSED; - } - get OPEN() { - return WebSocket.OPEN; - } - /** - * This deviates from the WHATWG interface since ws doesn't support the required - * default "blob" type (instead we define a custom "nodebuffer" type). + * This deviates from the WHATWG interface since ws doesn't support the + * required default "blob" type (instead we define a custom "nodebuffer" + * type). * * @type {String} */ @@ -87,7 +86,7 @@ class WebSocket extends EventEmitter { } set binaryType(type) { - if (!constants.BINARY_TYPES.includes(type)) return; + if (!BINARY_TYPES.includes(type)) return; this._binaryType = type; @@ -101,12 +100,9 @@ class WebSocket extends EventEmitter { * @type {Number} */ get bufferedAmount() { - if (!this._socket) return 0; + if (!this._socket) return this._bufferedAmount; - // - // `socket.bufferSize` is `undefined` if the socket is closed. - // - return (this._socket.bufferSize || 0) + this._sender._bufferedBytes; + return this._socket._writableState.length + this._sender._bufferedBytes; } /** @@ -116,18 +112,40 @@ class WebSocket extends EventEmitter { return Object.keys(this._extensions).join(); } + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + + /** + * @type {String} + */ + get url() { + return this._url; + } + /** * Set up the socket and the internal resources. * * @param {net.Socket} socket The network socket between the server and client * @param {Buffer} head The first packet of the upgraded stream - * @param {Number} maxPayload The maximum allowed message size + * @param {Number} [maxPayload=0] The maximum allowed message size * @private */ setSocket(socket, head, maxPayload) { const receiver = new Receiver( - this._binaryType, + this.binaryType, this._extensions, + this._isServer, maxPayload ); @@ -155,7 +173,7 @@ class WebSocket extends EventEmitter { socket.on('end', socketOnEnd); socket.on('error', socketOnError); - this.readyState = WebSocket.OPEN; + this._readyState = WebSocket.OPEN; this.emit('open'); } @@ -165,9 +183,8 @@ class WebSocket extends EventEmitter { * @private */ emitClose() { - this.readyState = WebSocket.CLOSED; - if (!this._socket) { + this._readyState = WebSocket.CLOSED; this.emit('close', this._closeCode, this._closeMessage); return; } @@ -177,6 +194,7 @@ class WebSocket extends EventEmitter { } this._receiver.removeAllListeners(); + this._readyState = WebSocket.CLOSED; this.emit('close', this._closeCode, this._closeMessage); } @@ -195,8 +213,8 @@ class WebSocket extends EventEmitter { * - - - - -|fin|<---------------------+ * +---+ * - * @param {Number} code Status code explaining why the connection is closing - * @param {String} data A string explaining why the connection is closing + * @param {Number} [code] Status code explaining why the connection is closing + * @param {String} [data] A string explaining why the connection is closing * @public */ close(code, data) { @@ -211,7 +229,7 @@ class WebSocket extends EventEmitter { return; } - this.readyState = WebSocket.CLOSING; + this._readyState = WebSocket.CLOSING; this._sender.close(code, data, !this._isServer, (err) => { // // This error is handled by the `'error'` listener on the socket. We only @@ -220,31 +238,31 @@ class WebSocket extends EventEmitter { if (err) return; this._closeFrameSent = true; - - if (this._socket.writable) { - if (this._closeFrameReceived) this._socket.end(); - - // - // Ensure that the connection is closed even if the closing handshake - // fails. - // - this._closeTimer = setTimeout( - this._socket.destroy.bind(this._socket), - closeTimeout - ); - } + if (this._closeFrameReceived) this._socket.end(); }); + + // + // Specify a timeout for the closing handshake to complete. + // + this._closeTimer = setTimeout( + this._socket.destroy.bind(this._socket), + closeTimeout + ); } /** * Send a ping. * - * @param {*} data The data to send - * @param {Boolean} mask Indicates whether or not to mask `data` - * @param {Function} cb Callback which is executed when the ping is sent + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent * @public */ ping(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + if (typeof data === 'function') { cb = data; data = mask = undefined; @@ -253,30 +271,30 @@ class WebSocket extends EventEmitter { mask = undefined; } - if (this.readyState !== WebSocket.OPEN) { - const err = new Error( - `WebSocket is not open: readyState ${this.readyState} ` + - `(${readyStates[this.readyState]})` - ); + if (typeof data === 'number') data = data.toString(); - if (cb) return cb(err); - throw err; + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; } - if (typeof data === 'number') data = data.toString(); if (mask === undefined) mask = !this._isServer; - this._sender.ping(data || constants.EMPTY_BUFFER, mask, cb); + this._sender.ping(data || EMPTY_BUFFER, mask, cb); } /** * Send a pong. * - * @param {*} data The data to send - * @param {Boolean} mask Indicates whether or not to mask `data` - * @param {Function} cb Callback which is executed when the pong is sent + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent * @public */ pong(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + if (typeof data === 'function') { cb = data; data = mask = undefined; @@ -285,66 +303,62 @@ class WebSocket extends EventEmitter { mask = undefined; } - if (this.readyState !== WebSocket.OPEN) { - const err = new Error( - `WebSocket is not open: readyState ${this.readyState} ` + - `(${readyStates[this.readyState]})` - ); + if (typeof data === 'number') data = data.toString(); - if (cb) return cb(err); - throw err; + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; } - if (typeof data === 'number') data = data.toString(); if (mask === undefined) mask = !this._isServer; - this._sender.pong(data || constants.EMPTY_BUFFER, mask, cb); + this._sender.pong(data || EMPTY_BUFFER, mask, cb); } /** * Send a data message. * * @param {*} data The message to send - * @param {Object} options Options object - * @param {Boolean} options.compress Specifies whether or not to compress `data` - * @param {Boolean} options.binary Specifies whether `data` is binary or text - * @param {Boolean} options.fin Specifies whether the fragment is the last one - * @param {Boolean} options.mask Specifies whether or not to mask `data` - * @param {Function} cb Callback which is executed when data is written out + * @param {Object} [options] Options object + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out * @public */ send(data, options, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + if (typeof options === 'function') { cb = options; options = {}; } - if (this.readyState !== WebSocket.OPEN) { - const err = new Error( - `WebSocket is not open: readyState ${this.readyState} ` + - `(${readyStates[this.readyState]})` - ); + if (typeof data === 'number') data = data.toString(); - if (cb) return cb(err); - throw err; + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; } - if (typeof data === 'number') data = data.toString(); - - const opts = Object.assign( - { - binary: typeof data !== 'string', - mask: !this._isServer, - compress: true, - fin: true - }, - options - ); + const opts = { + binary: typeof data !== 'string', + mask: !this._isServer, + compress: true, + fin: true, + ...options + }; if (!this._extensions[PerMessageDeflate.extensionName]) { opts.compress = false; } - this._sender.send(data || constants.EMPTY_BUFFER, opts, cb); + this._sender.send(data || EMPTY_BUFFER, opts, cb); } /** @@ -360,14 +374,28 @@ class WebSocket extends EventEmitter { } if (this._socket) { - this.readyState = WebSocket.CLOSING; + this._readyState = WebSocket.CLOSING; this._socket.destroy(); } } } readyStates.forEach((readyState, i) => { - WebSocket[readyState] = i; + const descriptor = { enumerable: true, value: i }; + + Object.defineProperty(WebSocket.prototype, readyState, descriptor); + Object.defineProperty(WebSocket, readyState, descriptor); +}); + +[ + 'binaryType', + 'bufferedAmount', + 'extensions', + 'protocol', + 'readyState', + 'url' +].forEach((property) => { + Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); }); // @@ -376,6 +404,8 @@ readyStates.forEach((readyState, i) => { // ['open', 'error', 'close', 'message'].forEach((method) => { Object.defineProperty(WebSocket.prototype, `on${method}`, { + configurable: true, + enumerable: true, /** * Return the listener of the event. * @@ -384,7 +414,7 @@ readyStates.forEach((readyState, i) => { */ get() { const listeners = this.listeners(method); - for (var i = 0; i < listeners.length; i++) { + for (let i = 0; i < listeners.length; i++) { if (listeners[i]._listener) return listeners[i]._listener; } @@ -398,7 +428,7 @@ readyStates.forEach((readyState, i) => { */ set(listener) { const listeners = this.listeners(method); - for (var i = 0; i < listeners.length; i++) { + for (let i = 0; i < listeners.length; i++) { // // Remove only the listeners added via `addEventListener`. // @@ -409,182 +439,199 @@ readyStates.forEach((readyState, i) => { }); }); -WebSocket.prototype.addEventListener = EventTarget.addEventListener; -WebSocket.prototype.removeEventListener = EventTarget.removeEventListener; +WebSocket.prototype.addEventListener = addEventListener; +WebSocket.prototype.removeEventListener = removeEventListener; module.exports = WebSocket; /** * Initialize a WebSocket client. * - * @param {(String|url.Url|url.URL)} address The URL to which to connect - * @param {String} protocols The subprotocols - * @param {Object} options Connection options - * @param {(Boolean|Object)} options.perMessageDeflate Enable/disable permessage-deflate - * @param {Number} options.handshakeTimeout Timeout in milliseconds for the handshake request - * @param {Number} options.protocolVersion Value of the `Sec-WebSocket-Version` header - * @param {String} options.origin Value of the `Origin` or `Sec-WebSocket-Origin` header - * @param {Number} options.maxPayload The maximum allowed message size + * @param {WebSocket} websocket The client to initialize + * @param {(String|url.URL)} address The URL to which to connect + * @param {String} [protocols] The subprotocols + * @param {Object} [options] Connection options + * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable + * permessage-deflate + * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the + * handshake request + * @param {Number} [options.protocolVersion=13] Value of the + * `Sec-WebSocket-Version` header + * @param {String} [options.origin] Value of the `Origin` or + * `Sec-WebSocket-Origin` header + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.followRedirects=false] Whether or not to follow + * redirects + * @param {Number} [options.maxRedirects=10] The maximum number of redirects + * allowed * @private */ -function initAsClient(address, protocols, options) { - options = Object.assign( - { - protocolVersion: protocolVersions[1], - perMessageDeflate: true, - maxPayload: 100 * 1024 * 1024 - }, - options, - { - createConnection: undefined, - socketPath: undefined, - hostname: undefined, - protocol: undefined, - timeout: undefined, - method: undefined, - auth: undefined, - host: undefined, - path: undefined, - port: undefined - } - ); - - if (!protocolVersions.includes(options.protocolVersion)) { +function initAsClient(websocket, address, protocols, options) { + const opts = { + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options, + createConnection: undefined, + socketPath: undefined, + hostname: undefined, + protocol: undefined, + timeout: undefined, + method: undefined, + host: undefined, + path: undefined, + port: undefined + }; + + if (!protocolVersions.includes(opts.protocolVersion)) { throw new RangeError( - `Unsupported protocol version: ${options.protocolVersion} ` + + `Unsupported protocol version: ${opts.protocolVersion} ` + `(supported versions: ${protocolVersions.join(', ')})` ); } - this._isServer = false; - - var parsedUrl; + let parsedUrl; - if (typeof address === 'object' && address.href !== undefined) { + if (address instanceof URL) { parsedUrl = address; - this.url = address.href; + websocket._url = address.href; } else { - // - // The WHATWG URL constructor is not available on Node.js < 6.13.0 - // - parsedUrl = url.URL ? new url.URL(address) : url.parse(address); - this.url = address; + parsedUrl = new URL(address); + websocket._url = address; } const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; if (!parsedUrl.host && (!isUnixSocket || !parsedUrl.pathname)) { - throw new Error(`Invalid URL: ${this.url}`); + throw new Error(`Invalid URL: ${websocket.url}`); } const isSecure = parsedUrl.protocol === 'wss:' || parsedUrl.protocol === 'https:'; const defaultPort = isSecure ? 443 : 80; - const key = crypto.randomBytes(16).toString('base64'); - const httpObj = isSecure ? https : http; - const path = parsedUrl.search - ? `${parsedUrl.pathname || '/'}${parsedUrl.search}` - : parsedUrl.pathname || '/'; - var perMessageDeflate; - - options.createConnection = isSecure ? tlsConnect : netConnect; - options.defaultPort = options.defaultPort || defaultPort; - options.port = parsedUrl.port || defaultPort; - options.host = parsedUrl.hostname.startsWith('[') + const key = randomBytes(16).toString('base64'); + const get = isSecure ? https.get : http.get; + let perMessageDeflate; + + opts.createConnection = isSecure ? tlsConnect : netConnect; + opts.defaultPort = opts.defaultPort || defaultPort; + opts.port = parsedUrl.port || defaultPort; + opts.host = parsedUrl.hostname.startsWith('[') ? parsedUrl.hostname.slice(1, -1) : parsedUrl.hostname; - options.headers = Object.assign( - { - 'Sec-WebSocket-Version': options.protocolVersion, - 'Sec-WebSocket-Key': key, - Connection: 'Upgrade', - Upgrade: 'websocket' - }, - options.headers - ); - options.path = path; - options.timeout = options.handshakeTimeout; - - if (options.perMessageDeflate) { + opts.headers = { + 'Sec-WebSocket-Version': opts.protocolVersion, + 'Sec-WebSocket-Key': key, + Connection: 'Upgrade', + Upgrade: 'websocket', + ...opts.headers + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + + if (opts.perMessageDeflate) { perMessageDeflate = new PerMessageDeflate( - options.perMessageDeflate !== true ? options.perMessageDeflate : {}, + opts.perMessageDeflate !== true ? opts.perMessageDeflate : {}, false, - options.maxPayload + opts.maxPayload ); - options.headers['Sec-WebSocket-Extensions'] = extension.format({ + opts.headers['Sec-WebSocket-Extensions'] = format({ [PerMessageDeflate.extensionName]: perMessageDeflate.offer() }); } if (protocols) { - options.headers['Sec-WebSocket-Protocol'] = protocols; + opts.headers['Sec-WebSocket-Protocol'] = protocols; } - if (options.origin) { - if (options.protocolVersion < 13) { - options.headers['Sec-WebSocket-Origin'] = options.origin; + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers['Sec-WebSocket-Origin'] = opts.origin; } else { - options.headers.Origin = options.origin; + opts.headers.Origin = opts.origin; } } - if (parsedUrl.auth) { - options.auth = parsedUrl.auth; - } else if (parsedUrl.username || parsedUrl.password) { - options.auth = `${parsedUrl.username}:${parsedUrl.password}`; + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; } if (isUnixSocket) { - const parts = path.split(':'); + const parts = opts.path.split(':'); - options.socketPath = parts[0]; - options.path = parts[1]; + opts.socketPath = parts[0]; + opts.path = parts[1]; } - var req = (this._req = httpObj.get(options)); + let req = (websocket._req = get(opts)); - if (options.handshakeTimeout) { + if (opts.timeout) { req.on('timeout', () => { - abortHandshake(this, req, 'Opening handshake has timed out'); + abortHandshake(websocket, req, 'Opening handshake has timed out'); }); } req.on('error', (err) => { - if (this._req.aborted) return; + if (req === null || req.aborted) return; - req = this._req = null; - this.readyState = WebSocket.CLOSING; - this.emit('error', err); - this.emitClose(); + req = websocket._req = null; + websocket._readyState = WebSocket.CLOSING; + websocket.emit('error', err); + websocket.emitClose(); }); req.on('response', (res) => { - if (this.emit('unexpected-response', req, res)) return; + const location = res.headers.location; + const statusCode = res.statusCode; + + if ( + location && + opts.followRedirects && + statusCode >= 300 && + statusCode < 400 + ) { + if (++websocket._redirects > opts.maxRedirects) { + abortHandshake(websocket, req, 'Maximum redirects exceeded'); + return; + } + + req.abort(); + + const addr = new URL(location, address); - abortHandshake(this, req, `Unexpected server response: ${res.statusCode}`); + initAsClient(websocket, addr, protocols, options); + } else if (!websocket.emit('unexpected-response', req, res)) { + abortHandshake( + websocket, + req, + `Unexpected server response: ${res.statusCode}` + ); + } }); req.on('upgrade', (res, socket, head) => { - this.emit('upgrade', res); + websocket.emit('upgrade', res); // // The user may have closed the connection from a listener of the `upgrade` // event. // - if (this.readyState !== WebSocket.CONNECTING) return; + if (websocket.readyState !== WebSocket.CONNECTING) return; - req = this._req = null; + req = websocket._req = null; - const digest = crypto - .createHash('sha1') - .update(key + constants.GUID, 'binary') + const digest = createHash('sha1') + .update(key + GUID) .digest('base64'); if (res.headers['sec-websocket-accept'] !== digest) { - abortHandshake(this, socket, 'Invalid Sec-WebSocket-Accept header'); + abortHandshake(websocket, socket, 'Invalid Sec-WebSocket-Accept header'); return; } const serverProt = res.headers['sec-websocket-protocol']; const protList = (protocols || '').split(/, */); - var protError; + let protError; if (!protocols && serverProt) { protError = 'Server sent a subprotocol but none was requested'; @@ -595,29 +642,32 @@ function initAsClient(address, protocols, options) { } if (protError) { - abortHandshake(this, socket, protError); + abortHandshake(websocket, socket, protError); return; } - if (serverProt) this.protocol = serverProt; + if (serverProt) websocket._protocol = serverProt; if (perMessageDeflate) { try { - const extensions = extension.parse( - res.headers['sec-websocket-extensions'] - ); + const extensions = parse(res.headers['sec-websocket-extensions']); if (extensions[PerMessageDeflate.extensionName]) { perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); - this._extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + websocket._extensions[PerMessageDeflate.extensionName] = + perMessageDeflate; } } catch (err) { - abortHandshake(this, socket, 'Invalid Sec-WebSocket-Extensions header'); + abortHandshake( + websocket, + socket, + 'Invalid Sec-WebSocket-Extensions header' + ); return; } } - this.setSocket(socket, head, options.maxPayload); + websocket.setSocket(socket, head, opts.maxPayload); }); } @@ -629,13 +679,7 @@ function initAsClient(address, protocols, options) { * @private */ function netConnect(options) { - // - // Override `options.path` only if `options` is a copy of the original options - // object. This is always true on Node.js >= 8 but not on Node.js 6 where - // `options.socketPath` might be `undefined` even if the `socketPath` option - // was originally set. - // - if (options.protocolVersion) options.path = options.socketPath; + options.path = options.socketPath; return net.connect(options); } @@ -648,7 +692,11 @@ function netConnect(options) { */ function tlsConnect(options) { options.path = undefined; - options.servername = options.servername || options.host; + + if (!options.servername && options.servername !== '') { + options.servername = net.isIP(options.host) ? '' : options.host; + } + return tls.connect(options); } @@ -662,13 +710,23 @@ function tlsConnect(options) { * @private */ function abortHandshake(websocket, stream, message) { - websocket.readyState = WebSocket.CLOSING; + websocket._readyState = WebSocket.CLOSING; const err = new Error(message); Error.captureStackTrace(err, abortHandshake); if (stream.setHeader) { stream.abort(); + + if (stream.socket && !stream.socket.destroyed) { + // + // On Node.js >= 14.3.0 `request.abort()` does not destroy the socket if + // called after the request completed. See + // https://github.com/websockets/ws/issues/1869. + // + stream.socket.destroy(); + } + stream.once('abort', websocket.emitClose.bind(websocket)); websocket.emit('error', err); } else { @@ -678,6 +736,38 @@ function abortHandshake(websocket, stream, message) { } } +/** + * Handle cases where the `ping()`, `pong()`, or `send()` methods are called + * when the `readyState` attribute is `CLOSING` or `CLOSED`. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {*} [data] The data to send + * @param {Function} [cb] Callback + * @private + */ +function sendAfterClose(websocket, data, cb) { + if (data) { + const length = toBuffer(data).length; + + // + // The `_bufferedAmount` property is used only when the peer is a client and + // the opening handshake fails. Under these circumstances, in fact, the + // `setSocket()` method is not called, so the `_socket` and `_sender` + // properties are set to `null`. + // + if (websocket._socket) websocket._sender._bufferedBytes += length; + else websocket._bufferedAmount += length; + } + + if (cb) { + const err = new Error( + `WebSocket is not open: readyState ${websocket.readyState} ` + + `(${readyStates[websocket.readyState]})` + ); + cb(err); + } +} + /** * The listener of the `Receiver` `'conclude'` event. * @@ -719,8 +809,8 @@ function receiverOnError(err) { websocket._socket.removeListener('data', socketOnData); - websocket.readyState = WebSocket.CLOSING; - websocket._closeCode = err[constants.kStatusCode]; + websocket._readyState = WebSocket.CLOSING; + websocket._closeCode = err[kStatusCode]; websocket.emit('error', err); websocket._socket.destroy(); } @@ -753,7 +843,7 @@ function receiverOnMessage(data) { function receiverOnPing(data) { const websocket = this[kWebSocket]; - websocket.pong(data, !websocket._isServer, constants.NOOP); + websocket.pong(data, !websocket._isServer, NOOP); websocket.emit('ping', data); } @@ -778,7 +868,7 @@ function socketOnClose() { this.removeListener('close', socketOnClose); this.removeListener('end', socketOnEnd); - websocket.readyState = WebSocket.CLOSING; + websocket._readyState = WebSocket.CLOSING; // // The close frame might not have been received or the `'end'` event emitted, @@ -829,7 +919,7 @@ function socketOnData(chunk) { function socketOnEnd() { const websocket = this[kWebSocket]; - websocket.readyState = WebSocket.CLOSING; + websocket._readyState = WebSocket.CLOSING; websocket._receiver.end(); this.end(); } @@ -843,10 +933,10 @@ function socketOnError() { const websocket = this[kWebSocket]; this.removeListener('error', socketOnError); - this.on('error', constants.NOOP); + this.on('error', NOOP); if (websocket) { - websocket.readyState = WebSocket.CLOSING; + websocket._readyState = WebSocket.CLOSING; this.destroy(); } } diff --git a/package.json b/package.json index af096ea35..2ab6e3769 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "6.1.4", + "version": "7.4.6", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", @@ -17,29 +17,40 @@ "license": "MIT", "main": "index.js", "browser": "browser.js", + "engines": { + "node": ">=8.3.0" + }, "files": [ "browser.js", "index.js", "lib/*.js" ], "scripts": { - "test": "npm run lint && nyc --reporter=html --reporter=text mocha test/*.test.js", - "integration": "npm run lint && mocha test/*.integration.js", - "lint": "eslint . --ignore-path .gitignore && prettylint '**/*.{json,md}' --ignore-path .gitignore" + "test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js", + "integration": "mocha --throw-deprecation test/*.integration.js", + "lint": "eslint --ignore-path .gitignore . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\"" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" }, - "dependencies": { - "async-limiter": "~1.0.0" + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } }, "devDependencies": { - "benchmark": "~2.1.4", - "bufferutil": "~4.0.0", - "eslint": "~5.14.0", - "eslint-config-prettier": "~4.0.0", - "eslint-plugin-prettier": "~3.0.0", - "mocha": "~5.2.0", - "nyc": "~13.3.0", - "prettier": "~1.16.1", - "prettylint": "~1.0.0", - "utf-8-validate": "~5.0.0" + "benchmark": "^2.1.4", + "bufferutil": "^4.0.1", + "eslint": "^7.2.0", + "eslint-config-prettier": "^8.1.0", + "eslint-plugin-prettier": "^3.0.1", + "mocha": "^7.0.0", + "nyc": "^15.0.0", + "prettier": "^2.0.5", + "utf-8-validate": "^5.0.2" } } diff --git a/test/buffer-util.test.js b/test/buffer-util.test.js new file mode 100644 index 000000000..a6b84c94b --- /dev/null +++ b/test/buffer-util.test.js @@ -0,0 +1,15 @@ +'use strict'; + +const assert = require('assert'); + +const { concat } = require('../lib/buffer-util'); + +describe('bufferUtil', () => { + describe('concat', () => { + it('never returns uninitialized data', () => { + const buf = concat([Buffer.from([1, 2]), Buffer.from([3, 4])], 6); + + assert.ok(buf.equals(Buffer.from([1, 2, 3, 4]))); + }); + }); +}); diff --git a/test/create-websocket-stream.test.js b/test/create-websocket-stream.test.js new file mode 100644 index 000000000..5da01bb18 --- /dev/null +++ b/test/create-websocket-stream.test.js @@ -0,0 +1,530 @@ +'use strict'; + +const assert = require('assert'); +const EventEmitter = require('events'); +const { createServer } = require('http'); +const { Duplex } = require('stream'); +const { randomBytes } = require('crypto'); + +const createWebSocketStream = require('../lib/stream'); +const Sender = require('../lib/sender'); +const WebSocket = require('..'); + +describe('createWebSocketStream', () => { + it('is exposed as a property of the `WebSocket` class', () => { + assert.strictEqual(WebSocket.createWebSocketStream, createWebSocketStream); + }); + + it('returns a `Duplex` stream', () => { + const duplex = createWebSocketStream(new EventEmitter()); + + assert.ok(duplex instanceof Duplex); + }); + + it('passes the options object to the `Duplex` constructor', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws, { + allowHalfOpen: false, + encoding: 'utf8' + }); + + duplex.on('data', (chunk) => { + assert.strictEqual(chunk, 'hi'); + + duplex.on('close', () => { + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.send(Buffer.from('hi')); + ws.close(); + }); + }); + + describe('The returned stream', () => { + it('buffers writes if `readyState` is `CONNECTING`', (done) => { + const chunk = randomBytes(1024); + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + + const duplex = createWebSocketStream(ws); + + duplex.write(chunk); + }); + + wss.on('connection', (ws) => { + ws.on('message', (message) => { + ws.on('close', (code, reason) => { + assert.ok(message.equals(chunk)); + assert.strictEqual(code, 1005); + assert.strictEqual(reason, ''); + wss.close(done); + }); + }); + + ws.close(); + }); + }); + + it('errors if a write occurs when `readyState` is `CLOSING`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.ok(duplex.destroyed); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 2 (CLOSING)' + ); + + duplex.on('close', () => { + wss.close(done); + }); + }); + + ws.on('open', () => { + ws._receiver.on('conclude', () => { + duplex.write('hi'); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('errors if a write occurs when `readyState` is `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.ok(duplex.destroyed); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 3 (CLOSED)' + ); + + duplex.on('close', () => { + wss.close(done); + }); + }); + + ws.on('close', () => { + duplex.write('hi'); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('does not error if `_final()` is called while connecting', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + + const duplex = createWebSocketStream(ws); + + duplex.on('close', () => { + wss.close(done); + }); + + duplex.resume(); + duplex.end(); + }); + }); + + it('makes `_final()` a noop if no socket is assigned', (done) => { + const server = createServer(); + + server.on('upgrade', (request, socket) => { + socket.on('end', socket.end); + + const headers = [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Accept: foo' + ]; + + socket.write(headers.concat('\r\n').join('\r\n')); + }); + + server.listen(() => { + const called = []; + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + const duplex = WebSocket.createWebSocketStream(ws); + const final = duplex._final; + + duplex._final = (callback) => { + called.push('final'); + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.strictEqual(ws._socket, null); + + final(callback); + }; + + duplex.on('error', (err) => { + called.push('error'); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Invalid Sec-WebSocket-Accept header' + ); + }); + + duplex.on('finish', () => { + called.push('finish'); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(called, ['final', 'error']); + server.close(done); + }); + + ws.on('upgrade', () => { + process.nextTick(() => { + duplex.end(); + }); + }); + }); + }); + + it('reemits errors', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + duplex.on('close', () => { + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws) => { + ws._socket.write(Buffer.from([0x85, 0x00])); + }); + }); + + it('does not swallow errors that may occur while destroying', (done) => { + const frame = Buffer.concat( + Sender.frame(Buffer.from([0x22, 0xfa, 0xec, 0x78]), { + fin: true, + rsv1: true, + opcode: 0x02, + mask: false, + readOnly: false + }) + ); + + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'Z_DATA_ERROR'); + assert.strictEqual(err.errno, -3); + + duplex.on('close', () => { + wss.close(done); + }); + }); + + let bytesRead = 0; + + ws.on('open', () => { + ws._socket.on('data', (chunk) => { + bytesRead += chunk.length; + if (bytesRead === frame.length) duplex.destroy(); + }); + }); + } + ); + + wss.on('connection', (ws) => { + ws._socket.write(frame); + }); + }); + + it("does not suppress the throwing behavior of 'error' events", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + createWebSocketStream(ws); + }); + + wss.on('connection', (ws) => { + ws._socket.write(Buffer.from([0x85, 0x00])); + }); + + assert.strictEqual(process.listenerCount('uncaughtException'), 1); + + const [listener] = process.listeners('uncaughtException'); + + process.removeAllListeners('uncaughtException'); + process.once('uncaughtException', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + process.on('uncaughtException', listener); + wss.close(done); + }); + }); + + it("is destroyed after 'end' and 'finish' are emitted (1/2)", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const events = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('end', () => { + events.push('end'); + assert.ok(duplex.destroyed); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(events, ['finish', 'end']); + wss.close(done); + }); + + duplex.on('finish', () => { + events.push('finish'); + assert.ok(!duplex.destroyed); + assert.ok(duplex.readable); + + duplex.resume(); + }); + + ws.on('close', () => { + duplex.end(); + }); + }); + + wss.on('connection', (ws) => { + ws.send('foo'); + ws.close(); + }); + }); + + it("is destroyed after 'end' and 'finish' are emitted (2/2)", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const events = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('end', () => { + events.push('end'); + assert.ok(!duplex.destroyed); + assert.ok(duplex.writable); + + duplex.end(); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(events, ['end', 'finish']); + wss.close(done); + }); + + duplex.on('finish', () => { + events.push('finish'); + }); + + duplex.resume(); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('handles backpressure (1/3)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + // eslint-disable-next-line no-unused-vars + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const duplex = createWebSocketStream(ws); + + duplex.resume(); + + duplex.on('drain', () => { + duplex.on('close', () => { + wss.close(done); + }); + + duplex.end(); + }); + + const chunk = randomBytes(1024); + let ret; + + do { + ret = duplex.write(chunk); + } while (ret !== false); + }); + }); + + it('handles backpressure (2/3)', (done) => { + const wss = new WebSocket.Server( + { port: 0, perMessageDeflate: true }, + () => { + const called = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + const read = duplex._read; + + duplex._read = () => { + duplex._read = read; + called.push('read'); + assert.ok(ws._receiver._writableState.needDrain); + read(); + assert.ok(ws._socket.isPaused()); + }; + + ws.on('open', () => { + ws._socket.on('pause', () => { + duplex.resume(); + }); + + ws._receiver.on('drain', () => { + called.push('drain'); + assert.ok(!ws._socket.isPaused()); + duplex.end(); + }); + + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; + + const list = [ + ...Sender.frame(randomBytes(16 * 1024), { rsv1: false, ...opts }), + ...Sender.frame(Buffer.alloc(1), { rsv1: true, ...opts }) + ]; + + // This hack is used because there is no guarantee that more than + // 16 KiB will be sent as a single TCP packet. + ws._socket.push(Buffer.concat(list)); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(called, ['read', 'drain']); + wss.close(done); + }); + } + ); + }); + + it('handles backpressure (3/3)', (done) => { + const wss = new WebSocket.Server( + { port: 0, perMessageDeflate: true }, + () => { + const called = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + const read = duplex._read; + + duplex._read = () => { + called.push('read'); + assert.ok(!ws._receiver._writableState.needDrain); + read(); + assert.ok(!ws._socket.isPaused()); + duplex.end(); + }; + + ws.on('open', () => { + ws._receiver.on('drain', () => { + called.push('drain'); + assert.ok(ws._socket.isPaused()); + duplex.resume(); + }); + + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; + + const list = [ + ...Sender.frame(randomBytes(16 * 1024), { rsv1: false, ...opts }), + ...Sender.frame(Buffer.alloc(1), { rsv1: true, ...opts }) + ]; + + ws._socket.push(Buffer.concat(list)); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(called, ['drain', 'read']); + wss.close(done); + }); + } + ); + }); + + it('can be destroyed (1/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const error = new Error('Oops'); + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.strictEqual(err, error); + + duplex.on('close', () => { + wss.close(done); + }); + }); + + ws.on('open', () => { + duplex.destroy(error); + }); + }); + }); + + it('can be destroyed (2/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('close', () => { + wss.close(done); + }); + + ws.on('open', () => { + duplex.destroy(); + }); + }); + }); + }); +}); diff --git a/test/extension.test.js b/test/extension.test.js index a051ab269..6cfbc1b23 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -2,83 +2,87 @@ const assert = require('assert'); -const extension = require('../lib/extension'); +const { format, parse } = require('../lib/extension'); -describe('extension', function() { - describe('parse', function() { - it('returns an empty object if the argument is `undefined`', function() { - assert.deepStrictEqual(extension.parse(), {}); - assert.deepStrictEqual(extension.parse(''), {}); +describe('extension', () => { + describe('parse', () => { + it('returns an empty object if the argument is `undefined`', () => { + assert.deepStrictEqual(parse(), { __proto__: null }); + assert.deepStrictEqual(parse(''), { __proto__: null }); }); - it('parses a single extension', function() { - const extensions = extension.parse('foo'); - - assert.deepStrictEqual(extensions, { foo: [{}] }); + it('parses a single extension', () => { + assert.deepStrictEqual(parse('foo'), { + foo: [{ __proto__: null }], + __proto__: null + }); }); - it('parses params', function() { - const extensions = extension.parse('foo;bar;baz=1;bar=2'); - - assert.deepStrictEqual(extensions, { - foo: [{ bar: [true, '2'], baz: ['1'] }] + it('parses params', () => { + assert.deepStrictEqual(parse('foo;bar;baz=1;bar=2'), { + foo: [{ bar: [true, '2'], baz: ['1'], __proto__: null }], + __proto__: null }); }); - it('parses multiple extensions', function() { - const extensions = extension.parse('foo,bar;baz,foo;baz'); - - assert.deepStrictEqual(extensions, { - foo: [{}, { baz: [true] }], - bar: [{ baz: [true] }] + it('parses multiple extensions', () => { + assert.deepStrictEqual(parse('foo,bar;baz,foo;baz'), { + foo: [{ __proto__: null }, { baz: [true], __proto__: null }], + bar: [{ baz: [true], __proto__: null }], + __proto__: null }); }); - it('parses quoted params', function() { - assert.deepStrictEqual(extension.parse('foo;bar="hi"'), { - foo: [{ bar: ['hi'] }] + it('parses quoted params', () => { + assert.deepStrictEqual(parse('foo;bar="hi"'), { + foo: [{ bar: ['hi'], __proto__: null }], + __proto__: null }); - assert.deepStrictEqual(extension.parse('foo;bar="\\0"'), { - foo: [{ bar: ['0'] }] + assert.deepStrictEqual(parse('foo;bar="\\0"'), { + foo: [{ bar: ['0'], __proto__: null }], + __proto__: null }); - assert.deepStrictEqual(extension.parse('foo;bar="b\\a\\z"'), { - foo: [{ bar: ['baz'] }] + assert.deepStrictEqual(parse('foo;bar="b\\a\\z"'), { + foo: [{ bar: ['baz'], __proto__: null }], + __proto__: null }); - assert.deepStrictEqual(extension.parse('foo;bar="b\\az";bar'), { - foo: [{ bar: ['baz', true] }] + assert.deepStrictEqual(parse('foo;bar="b\\az";bar'), { + foo: [{ bar: ['baz', true], __proto__: null }], + __proto__: null }); assert.throws( - () => extension.parse('foo;bar="baz"qux'), + () => parse('foo;bar="baz"qux'), /^SyntaxError: Unexpected character at index 13$/ ); assert.throws( - () => extension.parse('foo;bar="baz" qux'), + () => parse('foo;bar="baz" qux'), /^SyntaxError: Unexpected character at index 14$/ ); }); - it('works with names that match `Object.prototype` property names', function() { - const parse = extension.parse; - + it('works with names that match `Object.prototype` property names', () => { assert.deepStrictEqual(parse('hasOwnProperty, toString'), { - hasOwnProperty: [{}], - toString: [{}] + hasOwnProperty: [{ __proto__: null }], + toString: [{ __proto__: null }], + __proto__: null }); assert.deepStrictEqual(parse('foo;constructor'), { - foo: [{ constructor: [true] }] + foo: [{ constructor: [true], __proto__: null }], + __proto__: null }); }); - it('ignores the optional white spaces', function() { + it('ignores the optional white spaces', () => { const header = 'foo; bar\t; \tbaz=1\t ; bar="1"\t\t, \tqux\t ;norf '; - assert.deepStrictEqual(extension.parse(header), { - foo: [{ bar: [true, '1'], baz: ['1'] }], - qux: [{ norf: [true] }] + assert.deepStrictEqual(parse(header), { + foo: [{ bar: [true, '1'], baz: ['1'], __proto__: null }], + qux: [{ norf: [true], __proto__: null }], + __proto__: null }); }); - it('throws an error if a name is empty', function() { + it('throws an error if a name is empty', () => { [ [',', 0], ['foo,,', 4], @@ -91,7 +95,7 @@ describe('extension', function() { ['foo;bar=""', 9] ].forEach((element) => { assert.throws( - () => extension.parse(element[0]), + () => parse(element[0]), new RegExp( `^SyntaxError: Unexpected character at index ${element[1]}$` ) @@ -99,7 +103,7 @@ describe('extension', function() { }); }); - it('throws an error if a white space is misplaced', function() { + it('throws an error if a white space is misplaced', () => { [ ['f oo', 2], ['foo;ba r', 7], @@ -107,7 +111,7 @@ describe('extension', function() { ['foo;bar= ', 8] ].forEach((element) => { assert.throws( - () => extension.parse(element[0]), + () => parse(element[0]), new RegExp( `^SyntaxError: Unexpected character at index ${element[1]}$` ) @@ -115,7 +119,7 @@ describe('extension', function() { }); }); - it('throws an error if a token contains invalid characters', function() { + it('throws an error if a token contains invalid characters', () => { [ ['f@o', 1], ['f\\oo', 1], @@ -133,7 +137,7 @@ describe('extension', function() { ['foo;bar="\\\\"', 10] ].forEach((element) => { assert.throws( - () => extension.parse(element[0]), + () => parse(element[0]), new RegExp( `^SyntaxError: Unexpected character at index ${element[1]}$` ) @@ -141,7 +145,7 @@ describe('extension', function() { }); }); - it('throws an error if the header value ends prematurely', function() { + it('throws an error if the header value ends prematurely', () => { [ 'foo, ', 'foo;', @@ -152,28 +156,28 @@ describe('extension', function() { 'foo;bar="1\\' ].forEach((header) => { assert.throws( - () => extension.parse(header), + () => parse(header), /^SyntaxError: Unexpected end of input$/ ); }); }); }); - describe('format', function() { - it('formats a single extension', function() { - const extensions = extension.format({ foo: {} }); + describe('format', () => { + it('formats a single extension', () => { + const extensions = format({ foo: {} }); assert.strictEqual(extensions, 'foo'); }); - it('formats params', function() { - const extensions = extension.format({ foo: { bar: [true, 2], baz: 1 } }); + it('formats params', () => { + const extensions = format({ foo: { bar: [true, 2], baz: 1 } }); assert.strictEqual(extensions, 'foo; bar; bar=2; baz=1'); }); - it('formats multiple extensions', function() { - const extensions = extension.format({ + it('formats multiple extensions', () => { + const extensions = format({ foo: [{}, { baz: true }], bar: { baz: true } }); diff --git a/test/limiter.test.js b/test/limiter.test.js new file mode 100644 index 000000000..95141f0f5 --- /dev/null +++ b/test/limiter.test.js @@ -0,0 +1,41 @@ +'use strict'; + +const assert = require('assert'); + +const Limiter = require('../lib/limiter'); + +describe('Limiter', () => { + describe('#ctor', () => { + it('takes a `concurrency` argument', () => { + const limiter = new Limiter(0); + + assert.strictEqual(limiter.concurrency, Infinity); + }); + }); + + describe('#kRun', () => { + it('limits the number of jobs allowed to run concurrently', (done) => { + const limiter = new Limiter(1); + + limiter.add((callback) => { + setImmediate(() => { + callback(); + + assert.strictEqual(limiter.jobs.length, 0); + assert.strictEqual(limiter.pending, 1); + }); + }); + + limiter.add((callback) => { + setImmediate(() => { + callback(); + + assert.strictEqual(limiter.pending, 0); + done(); + }); + }); + + assert.strictEqual(limiter.jobs.length, 1); + }); + }); +}); diff --git a/test/permessage-deflate.test.js b/test/permessage-deflate.test.js index c9722bc5e..a547762ca 100644 --- a/test/permessage-deflate.test.js +++ b/test/permessage-deflate.test.js @@ -5,9 +5,9 @@ const assert = require('assert'); const PerMessageDeflate = require('../lib/permessage-deflate'); const extension = require('../lib/extension'); -describe('PerMessageDeflate', function() { - describe('#offer', function() { - it('creates an offer', function() { +describe('PerMessageDeflate', () => { + describe('#offer', () => { + it('creates an offer', () => { const perMessageDeflate = new PerMessageDeflate(); assert.deepStrictEqual(perMessageDeflate.offer(), { @@ -15,7 +15,7 @@ describe('PerMessageDeflate', function() { }); }); - it('uses the configuration options', function() { + it('uses the configuration options', () => { const perMessageDeflate = new PerMessageDeflate({ serverNoContextTakeover: true, clientNoContextTakeover: true, @@ -32,8 +32,8 @@ describe('PerMessageDeflate', function() { }); }); - describe('#accept', function() { - it('throws an error if a parameter has multiple values', function() { + describe('#accept', () => { + it('throws an error if a parameter has multiple values', () => { const perMessageDeflate = new PerMessageDeflate(); const extensions = extension.parse( 'permessage-deflate; server_no_context_takeover; server_no_context_takeover' @@ -45,7 +45,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if a parameter has an invalid name', function() { + it('throws an error if a parameter has an invalid name', () => { const perMessageDeflate = new PerMessageDeflate(); const extensions = extension.parse('permessage-deflate;foo'); @@ -55,7 +55,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if client_no_context_takeover has a value', function() { + it('throws an error if client_no_context_takeover has a value', () => { const perMessageDeflate = new PerMessageDeflate(); const extensions = extension.parse( 'permessage-deflate; client_no_context_takeover=10' @@ -67,7 +67,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if server_no_context_takeover has a value', function() { + it('throws an error if server_no_context_takeover has a value', () => { const perMessageDeflate = new PerMessageDeflate(); const extensions = extension.parse( 'permessage-deflate; server_no_context_takeover=10' @@ -79,7 +79,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if server_max_window_bits has an invalid value', function() { + it('throws an error if server_max_window_bits has an invalid value', () => { const perMessageDeflate = new PerMessageDeflate(); let extensions = extension.parse( @@ -99,14 +99,14 @@ describe('PerMessageDeflate', function() { ); }); - describe('As server', function() { - it('accepts an offer with no parameters', function() { + describe('As server', () => { + it('accepts an offer with no parameters', () => { const perMessageDeflate = new PerMessageDeflate({}, true); assert.deepStrictEqual(perMessageDeflate.accept([{}]), {}); }); - it('accepts an offer with parameters', function() { + it('accepts an offer with parameters', () => { const perMessageDeflate = new PerMessageDeflate({}, true); const extensions = extension.parse( 'permessage-deflate; server_no_context_takeover; ' + @@ -120,12 +120,13 @@ describe('PerMessageDeflate', function() { server_no_context_takeover: true, client_no_context_takeover: true, server_max_window_bits: 10, - client_max_window_bits: 11 + client_max_window_bits: 11, + __proto__: null } ); }); - it('prefers the configuration options', function() { + it('prefers the configuration options', () => { const perMessageDeflate = new PerMessageDeflate( { serverNoContextTakeover: true, @@ -145,12 +146,13 @@ describe('PerMessageDeflate', function() { server_no_context_takeover: true, client_no_context_takeover: true, server_max_window_bits: 12, - client_max_window_bits: 11 + client_max_window_bits: 11, + __proto__: null } ); }); - it('accepts the first supported offer', function() { + it('accepts the first supported offer', () => { const perMessageDeflate = new PerMessageDeflate( { serverMaxWindowBits: 11 }, true @@ -162,12 +164,13 @@ describe('PerMessageDeflate', function() { assert.deepStrictEqual( perMessageDeflate.accept(extensions['permessage-deflate']), { - server_max_window_bits: 11 + server_max_window_bits: 11, + __proto__: null } ); }); - it('throws an error if server_no_context_takeover is unsupported', function() { + it('throws an error if server_no_context_takeover is unsupported', () => { const perMessageDeflate = new PerMessageDeflate( { serverNoContextTakeover: false }, true @@ -182,7 +185,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if server_max_window_bits is unsupported', function() { + it('throws an error if server_max_window_bits is unsupported', () => { const perMessageDeflate = new PerMessageDeflate( { serverMaxWindowBits: false }, true @@ -197,7 +200,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if server_max_window_bits is less than configuration', function() { + it('throws an error if server_max_window_bits is less than configuration', () => { const perMessageDeflate = new PerMessageDeflate( { serverMaxWindowBits: 11 }, true @@ -212,7 +215,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if client_max_window_bits is unsupported on client', function() { + it('throws an error if client_max_window_bits is unsupported on client', () => { const perMessageDeflate = new PerMessageDeflate( { clientMaxWindowBits: 10 }, true @@ -225,7 +228,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if client_max_window_bits has an invalid value', function() { + it('throws an error if client_max_window_bits has an invalid value', () => { const perMessageDeflate = new PerMessageDeflate({}, true); const extensions = extension.parse( @@ -238,14 +241,14 @@ describe('PerMessageDeflate', function() { }); }); - describe('As client', function() { - it('accepts a response with no parameters', function() { + describe('As client', () => { + it('accepts a response with no parameters', () => { const perMessageDeflate = new PerMessageDeflate({}); assert.deepStrictEqual(perMessageDeflate.accept([{}]), {}); }); - it('accepts a response with parameters', function() { + it('accepts a response with parameters', () => { const perMessageDeflate = new PerMessageDeflate({}); const extensions = extension.parse( 'permessage-deflate; server_no_context_takeover; ' + @@ -259,12 +262,13 @@ describe('PerMessageDeflate', function() { server_no_context_takeover: true, client_no_context_takeover: true, server_max_window_bits: 10, - client_max_window_bits: 11 + client_max_window_bits: 11, + __proto__: null } ); }); - it('throws an error if client_no_context_takeover is unsupported', function() { + it('throws an error if client_no_context_takeover is unsupported', () => { const perMessageDeflate = new PerMessageDeflate({ clientNoContextTakeover: false }); @@ -278,7 +282,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if client_max_window_bits is unsupported', function() { + it('throws an error if client_max_window_bits is unsupported', () => { const perMessageDeflate = new PerMessageDeflate({ clientMaxWindowBits: false }); @@ -292,7 +296,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if client_max_window_bits is greater than configuration', function() { + it('throws an error if client_max_window_bits is greater than configuration', () => { const perMessageDeflate = new PerMessageDeflate({ clientMaxWindowBits: 10 }); @@ -306,7 +310,7 @@ describe('PerMessageDeflate', function() { ); }); - it('throws an error if client_max_window_bits has an invalid value', function() { + it('throws an error if client_max_window_bits has an invalid value', () => { const perMessageDeflate = new PerMessageDeflate(); let extensions = extension.parse( @@ -326,7 +330,7 @@ describe('PerMessageDeflate', function() { ); }); - it('uses the config value if client_max_window_bits is not specified', function() { + it('uses the config value if client_max_window_bits is not specified', () => { const perMessageDeflate = new PerMessageDeflate({ clientMaxWindowBits: 10 }); @@ -338,8 +342,8 @@ describe('PerMessageDeflate', function() { }); }); - describe('#compress and #decompress', function() { - it('works with unfragmented messages', function(done) { + describe('#compress and #decompress', () => { + it('works with unfragmented messages', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); const buf = Buffer.from([1, 2, 3]); @@ -356,7 +360,7 @@ describe('PerMessageDeflate', function() { }); }); - it('works with fragmented messages', function(done) { + it('works with fragmented messages', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); const buf = Buffer.from([1, 2, 3, 4]); @@ -382,7 +386,7 @@ describe('PerMessageDeflate', function() { }); }); - it('works with the negotiated parameters', function(done) { + it('works with the negotiated parameters', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0, memLevel: 5, @@ -409,7 +413,7 @@ describe('PerMessageDeflate', function() { }); }); - it('honors the `level` option', function(done) { + it('honors the `level` option', (done) => { const lev0 = new PerMessageDeflate({ threshold: 0, zlibDeflateOptions: { level: 0 } @@ -453,7 +457,7 @@ describe('PerMessageDeflate', function() { }); }); - it('honors the `zlib{Deflate,Inflate}Options` option', function(done) { + it('honors the `zlib{Deflate,Inflate}Options` option', (done) => { const lev0 = new PerMessageDeflate({ threshold: 0, zlibDeflateOptions: { @@ -518,7 +522,7 @@ describe('PerMessageDeflate', function() { }); }); - it("doesn't use contex takeover if not allowed", function(done) { + it("doesn't use contex takeover if not allowed", (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }, true); const extensions = extension.parse( 'permessage-deflate;server_no_context_takeover' @@ -549,7 +553,7 @@ describe('PerMessageDeflate', function() { }); }); - it('uses contex takeover if allowed', function(done) { + it('uses contex takeover if allowed', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }, true); const extensions = extension.parse('permessage-deflate'); const buf = Buffer.from('foofoo'); @@ -578,19 +582,20 @@ describe('PerMessageDeflate', function() { }); }); - it('calls the callback when an error occurs (inflate)', function(done) { + it('calls the callback when an error occurs (inflate)', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); const data = Buffer.from('something invalid'); perMessageDeflate.accept([{}]); perMessageDeflate.decompress(data, true, (err) => { assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'Z_DATA_ERROR'); assert.strictEqual(err.errno, -3); done(); }); }); - it("doesn't call the callback twice when `maxPayload` is exceeded", function(done) { + it("doesn't call the callback twice when `maxPayload` is exceeded", (done) => { const perMessageDeflate = new PerMessageDeflate( { threshold: 0 }, false, @@ -610,17 +615,42 @@ describe('PerMessageDeflate', function() { }); }); - it("doesn't call the callback if the deflate stream is closed prematurely", function(done) { + it('calls the callback if the deflate stream is closed prematurely', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); const buf = Buffer.from('A'.repeat(50)); perMessageDeflate.accept([{}]); - perMessageDeflate.compress(buf, true, () => { - done(new Error('Unexpected callback invocation')); + perMessageDeflate.compress(buf, true, (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The deflate stream was closed while data was being processed' + ); + done(); }); - perMessageDeflate._deflate.on('close', done); process.nextTick(() => perMessageDeflate.cleanup()); }); + + it('recreates the inflate stream if it ends', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover; ' + + 'server_no_context_takeover' + ); + const buf = Buffer.from('33343236313533b7000000', 'hex'); + const expected = Buffer.from('12345678'); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + perMessageDeflate.decompress(buf, true, (err, data) => { + assert.ok(data.equals(expected)); + + perMessageDeflate.decompress(buf, true, (err, data) => { + assert.ok(data.equals(expected)); + done(); + }); + }); + }); }); }); diff --git a/test/receiver.test.js b/test/receiver.test.js index 3bc680719..a70cc8dbe 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -10,8 +10,8 @@ const Sender = require('../lib/sender'); const kStatusCode = constants.kStatusCode; -describe('Receiver', function() { - it('parses an unmasked text message', function(done) { +describe('Receiver', () => { + it('parses an unmasked text message', (done) => { const receiver = new Receiver(); receiver.on('message', (data) => { @@ -22,7 +22,7 @@ describe('Receiver', function() { receiver.write(Buffer.from('810548656c6c6f', 'hex')); }); - it('parses a close message', function(done) { + it('parses a close message', (done) => { const receiver = new Receiver(); receiver.on('conclude', (code, data) => { @@ -34,7 +34,7 @@ describe('Receiver', function() { receiver.write(Buffer.from('8800', 'hex')); }); - it('parses a close message spanning multiple writes', function(done) { + it('parses a close message spanning multiple writes', (done) => { const receiver = new Receiver(); receiver.on('conclude', (code, data) => { @@ -47,8 +47,8 @@ describe('Receiver', function() { receiver.write(Buffer.from('03e8444F4E45', 'hex')); }); - it('parses a masked text message', function(done) { - const receiver = new Receiver(); + it('parses a masked text message', (done) => { + const receiver = new Receiver(undefined, {}, true); receiver.on('message', (data) => { assert.strictEqual(data, '5:::{"name":"echo"}'); @@ -60,8 +60,8 @@ describe('Receiver', function() { ); }); - it('parses a masked text message longer than 125 B', function(done) { - const receiver = new Receiver(); + it('parses a masked text message longer than 125 B', (done) => { + const receiver = new Receiver(undefined, {}, true); const msg = 'A'.repeat(200); const list = Sender.frame(Buffer.from(msg), { @@ -83,8 +83,8 @@ describe('Receiver', function() { setImmediate(() => receiver.write(frame.slice(2))); }); - it('parses a really long masked text message', function(done) { - const receiver = new Receiver(); + it('parses a really long masked text message', (done) => { + const receiver = new Receiver(undefined, {}, true); const msg = 'A'.repeat(64 * 1024); const list = Sender.frame(Buffer.from(msg), { @@ -105,8 +105,8 @@ describe('Receiver', function() { receiver.write(frame); }); - it('parses a 300 B fragmented masked text message', function(done) { - const receiver = new Receiver(); + it('parses a 300 B fragmented masked text message', (done) => { + const receiver = new Receiver(undefined, {}, true); const msg = 'A'.repeat(300); const fragment1 = msg.substr(0, 150); @@ -115,16 +115,18 @@ describe('Receiver', function() { const options = { rsv1: false, mask: true, readOnly: false }; const frame1 = Buffer.concat( - Sender.frame( - Buffer.from(fragment1), - Object.assign({ fin: false, opcode: 0x01 }, options) - ) + Sender.frame(Buffer.from(fragment1), { + fin: false, + opcode: 0x01, + ...options + }) ); const frame2 = Buffer.concat( - Sender.frame( - Buffer.from(fragment2), - Object.assign({ fin: true, opcode: 0x00 }, options) - ) + Sender.frame(Buffer.from(fragment2), { + fin: true, + opcode: 0x00, + ...options + }) ); receiver.on('message', (data) => { @@ -136,8 +138,8 @@ describe('Receiver', function() { receiver.write(frame2); }); - it('parses a ping message', function(done) { - const receiver = new Receiver(); + it('parses a ping message', (done) => { + const receiver = new Receiver(undefined, {}, true); const msg = 'Hello'; const list = Sender.frame(Buffer.from(msg), { @@ -158,7 +160,7 @@ describe('Receiver', function() { receiver.write(frame); }); - it('parses a ping message with no data', function(done) { + it('parses a ping message with no data', (done) => { const receiver = new Receiver(); receiver.on('ping', (data) => { @@ -169,8 +171,8 @@ describe('Receiver', function() { receiver.write(Buffer.from('8900', 'hex')); }); - it('parses a 300 B fragmented masked text message with a ping in the middle (1/2)', function(done) { - const receiver = new Receiver(); + it('parses a 300 B fragmented masked text message with a ping in the middle (1/2)', (done) => { + const receiver = new Receiver(undefined, {}, true); const msg = 'A'.repeat(300); const pingMessage = 'Hello'; @@ -180,22 +182,25 @@ describe('Receiver', function() { const options = { rsv1: false, mask: true, readOnly: false }; const frame1 = Buffer.concat( - Sender.frame( - Buffer.from(fragment1), - Object.assign({ fin: false, opcode: 0x01 }, options) - ) + Sender.frame(Buffer.from(fragment1), { + fin: false, + opcode: 0x01, + ...options + }) ); const frame2 = Buffer.concat( - Sender.frame( - Buffer.from(pingMessage), - Object.assign({ fin: true, opcode: 0x09 }, options) - ) + Sender.frame(Buffer.from(pingMessage), { + fin: true, + opcode: 0x09, + ...options + }) ); const frame3 = Buffer.concat( - Sender.frame( - Buffer.from(fragment2), - Object.assign({ fin: true, opcode: 0x00 }, options) - ) + Sender.frame(Buffer.from(fragment2), { + fin: true, + opcode: 0x00, + ...options + }) ); let gotPing = false; @@ -215,8 +220,8 @@ describe('Receiver', function() { receiver.write(frame3); }); - it('parses a 300 B fragmented masked text message with a ping in the middle (2/2)', function(done) { - const receiver = new Receiver(); + it('parses a 300 B fragmented masked text message with a ping in the middle (2/2)', (done) => { + const receiver = new Receiver(undefined, {}, true); const msg = 'A'.repeat(300); const pingMessage = 'Hello'; @@ -226,22 +231,25 @@ describe('Receiver', function() { const options = { rsv1: false, mask: true, readOnly: false }; const frame1 = Buffer.concat( - Sender.frame( - Buffer.from(fragment1), - Object.assign({ fin: false, opcode: 0x01 }, options) - ) + Sender.frame(Buffer.from(fragment1), { + fin: false, + opcode: 0x01, + ...options + }) ); const frame2 = Buffer.concat( - Sender.frame( - Buffer.from(pingMessage), - Object.assign({ fin: true, opcode: 0x09 }, options) - ) + Sender.frame(Buffer.from(pingMessage), { + fin: true, + opcode: 0x09, + ...options + }) ); const frame3 = Buffer.concat( - Sender.frame( - Buffer.from(fragment2), - Object.assign({ fin: true, opcode: 0x00 }, options) - ) + Sender.frame(Buffer.from(fragment2), { + fin: true, + opcode: 0x00, + ...options + }) ); let chunks = []; @@ -271,8 +279,8 @@ describe('Receiver', function() { } }); - it('parses a 100 B masked binary message', function(done) { - const receiver = new Receiver(); + it('parses a 100 B masked binary message', (done) => { + const receiver = new Receiver(undefined, {}, true); const msg = crypto.randomBytes(100); const list = Sender.frame(msg, { @@ -293,8 +301,8 @@ describe('Receiver', function() { receiver.write(frame); }); - it('parses a 256 B masked binary message', function(done) { - const receiver = new Receiver(); + it('parses a 256 B masked binary message', (done) => { + const receiver = new Receiver(undefined, {}, true); const msg = crypto.randomBytes(256); const list = Sender.frame(msg, { @@ -315,8 +323,8 @@ describe('Receiver', function() { receiver.write(frame); }); - it('parses a 200 KiB masked binary message', function(done) { - const receiver = new Receiver(); + it('parses a 200 KiB masked binary message', (done) => { + const receiver = new Receiver(undefined, {}, true); const msg = crypto.randomBytes(200 * 1024); const list = Sender.frame(msg, { @@ -337,7 +345,7 @@ describe('Receiver', function() { receiver.write(frame); }); - it('parses a 200 KiB unmasked binary message', function(done) { + it('parses a 200 KiB unmasked binary message', (done) => { const receiver = new Receiver(); const msg = crypto.randomBytes(200 * 1024); @@ -359,7 +367,7 @@ describe('Receiver', function() { receiver.write(frame); }); - it('parses a compressed message', function(done) { + it('parses a compressed message', (done) => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); @@ -381,7 +389,7 @@ describe('Receiver', function() { }); }); - it('parses a compressed and fragmented message', function(done) { + it('parses a compressed and fragmented message', (done) => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); @@ -396,13 +404,13 @@ describe('Receiver', function() { done(); }); - perMessageDeflate.compress(buf1, false, function(err, fragment1) { + perMessageDeflate.compress(buf1, false, (err, fragment1) => { if (err) return done(err); receiver.write(Buffer.from([0x41, fragment1.length])); receiver.write(fragment1); - perMessageDeflate.compress(buf2, true, function(err, fragment2) { + perMessageDeflate.compress(buf2, true, (err, fragment2) => { if (err) return done(err); receiver.write(Buffer.from([0x80, fragment2.length])); @@ -411,7 +419,7 @@ describe('Receiver', function() { }); }); - it('parses a buffer with thousands of frames', function(done) { + it('parses a buffer with thousands of frames', (done) => { const buf = Buffer.allocUnsafe(40000); for (let i = 0; i < buf.length; i += 2) { @@ -430,8 +438,8 @@ describe('Receiver', function() { receiver.write(buf); }); - it('resets `totalPayloadLength` only on final frame (unfragmented)', function(done) { - const receiver = new Receiver(undefined, {}, 10); + it('resets `totalPayloadLength` only on final frame (unfragmented)', (done) => { + const receiver = new Receiver(undefined, {}, false, 10); receiver.on('message', (data) => { assert.strictEqual(receiver._totalPayloadLength, 0); @@ -443,8 +451,8 @@ describe('Receiver', function() { receiver.write(Buffer.from('810548656c6c6f', 'hex')); }); - it('resets `totalPayloadLength` only on final frame (fragmented)', function(done) { - const receiver = new Receiver(undefined, {}, 10); + it('resets `totalPayloadLength` only on final frame (fragmented)', (done) => { + const receiver = new Receiver(undefined, {}, false, 10); receiver.on('message', (data) => { assert.strictEqual(receiver._totalPayloadLength, 0); @@ -458,8 +466,8 @@ describe('Receiver', function() { receiver.write(Buffer.from('80036c6c6f', 'hex')); }); - it('resets `totalPayloadLength` only on final frame (fragmented + ping)', function(done) { - const receiver = new Receiver(undefined, {}, 10); + it('resets `totalPayloadLength` only on final frame (fragmented + ping)', (done) => { + const receiver = new Receiver(undefined, {}, false, 10); let data; receiver.on('ping', (buf) => { @@ -479,7 +487,7 @@ describe('Receiver', function() { receiver.write(Buffer.from('80036c6c6f', 'hex')); }); - it('ignores any data after a close frame', function(done) { + it('ignores any data after a close frame', (done) => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); @@ -500,7 +508,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x81, 0x00])); }); - it('emits an error if RSV1 is on and permessage-deflate is disabled', function(done) { + it('emits an error if RSV1 is on and permessage-deflate is disabled', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -516,7 +524,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0xc2, 0x80, 0x00, 0x00, 0x00, 0x00])); }); - it('emits an error if RSV1 is on and opcode is 0', function(done) { + it('emits an error if RSV1 is on and opcode is 0', (done) => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); @@ -537,7 +545,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x40, 0x00])); }); - it('emits an error if RSV2 is on', function(done) { + it('emits an error if RSV2 is on', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -553,7 +561,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0xa2, 0x00])); }); - it('emits an error if RSV3 is on', function(done) { + it('emits an error if RSV3 is on', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -569,7 +577,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x92, 0x00])); }); - it('emits an error if the first frame in a fragmented message has opcode 0', function(done) { + it('emits an error if the first frame in a fragmented message has opcode 0', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -585,7 +593,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x00, 0x00])); }); - it('emits an error if a frame has opcode 1 in the middle of a fragmented message', function(done) { + it('emits an error if a frame has opcode 1 in the middle of a fragmented message', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -602,7 +610,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x01, 0x00])); }); - it('emits an error if a frame has opcode 2 in the middle of a fragmented message', function(done) { + it('emits an error if a frame has opcode 2 in the middle of a fragmented message', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -619,7 +627,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x02, 0x00])); }); - it('emits an error if a control frame has the FIN bit off', function(done) { + it('emits an error if a control frame has the FIN bit off', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -635,7 +643,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x09, 0x00])); }); - it('emits an error if a control frame has the RSV1 bit on', function(done) { + it('emits an error if a control frame has the RSV1 bit on', (done) => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); @@ -656,7 +664,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0xc9, 0x00])); }); - it('emits an error if a control frame has the FIN bit off', function(done) { + it('emits an error if a control frame has the FIN bit off', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -672,7 +680,41 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x09, 0x00])); }); - it('emits an error if a control frame has a payload bigger than 125 B', function(done) { + it('emits an error if a frame has the MASK bit off (server mode)', (done) => { + const receiver = new Receiver(undefined, {}, true); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: MASK must be set' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x81, 0x02, 0x68, 0x69])); + }); + + it('emits an error if a frame has the MASK bit on (client mode)', (done) => { + const receiver = new Receiver(undefined, {}, false); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: MASK must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write( + Buffer.from([0x81, 0x82, 0x56, 0x3a, 0xac, 0x80, 0x3e, 0x53]) + ); + }); + + it('emits an error if a control frame has a payload bigger than 125 B', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -688,7 +730,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x89, 0x7e])); }); - it('emits an error if a data frame has a payload bigger than 2^53 - 1 B', function(done) { + it('emits an error if a data frame has a payload bigger than 2^53 - 1 B', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -709,7 +751,7 @@ describe('Receiver', function() { ); }); - it('emits an error if a text frame contains invalid UTF-8 data', function(done) { + it('emits an error if a text frame contains invalid UTF-8 data (1/2)', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -725,7 +767,34 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x81, 0x04, 0xce, 0xba, 0xe1, 0xbd])); }); - it('emits an error if a close frame has a payload of 1 B', function(done) { + it('emits an error if a text frame contains invalid UTF-8 data (2/2)', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver(undefined, { + 'permessage-deflate': perMessageDeflate + }); + const buf = Buffer.from([0xce, 0xba, 0xe1, 0xbd]); + + receiver.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid UTF-8 sequence' + ); + assert.strictEqual(err[kStatusCode], 1007); + done(); + }); + + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + receiver.write(Buffer.from([0xc1, data.length])); + receiver.write(data); + }); + }); + + it('emits an error if a close frame has a payload of 1 B', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -741,7 +810,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x88, 0x01, 0x00])); }); - it('emits an error if a close frame contains an invalid close code', function(done) { + it('emits an error if a close frame contains an invalid close code', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -757,7 +826,7 @@ describe('Receiver', function() { receiver.write(Buffer.from([0x88, 0x02, 0x00, 0x00])); }); - it('emits an error if a close frame contains invalid UTF-8 data', function(done) { + it('emits an error if a close frame contains invalid UTF-8 data', (done) => { const receiver = new Receiver(); receiver.on('error', (err) => { @@ -775,8 +844,8 @@ describe('Receiver', function() { ); }); - it('emits an error if a frame payload length is bigger than `maxPayload`', function(done) { - const receiver = new Receiver(undefined, {}, 20 * 1024); + it('emits an error if a frame payload length is bigger than `maxPayload`', (done) => { + const receiver = new Receiver(undefined, {}, true, 20 * 1024); const msg = crypto.randomBytes(200 * 1024); const list = Sender.frame(msg, { @@ -799,7 +868,7 @@ describe('Receiver', function() { receiver.write(frame); }); - it('emits an error if the message length exceeds `maxPayload`', function(done) { + it('emits an error if the message length exceeds `maxPayload`', (done) => { const perMessageDeflate = new PerMessageDeflate({}, false, 25); perMessageDeflate.accept([{}]); @@ -808,6 +877,7 @@ describe('Receiver', function() { { 'permessage-deflate': perMessageDeflate }, + false, 25 ); const buf = Buffer.from('A'.repeat(50)); @@ -819,7 +889,7 @@ describe('Receiver', function() { done(); }); - perMessageDeflate.compress(buf, true, function(err, data) { + perMessageDeflate.compress(buf, true, (err, data) => { if (err) return done(err); receiver.write(Buffer.from([0xc1, data.length])); @@ -827,7 +897,7 @@ describe('Receiver', function() { }); }); - it('emits an error if the sum of fragment lengths exceeds `maxPayload`', function(done) { + it('emits an error if the sum of fragment lengths exceeds `maxPayload`', (done) => { const perMessageDeflate = new PerMessageDeflate({}, false, 25); perMessageDeflate.accept([{}]); @@ -836,6 +906,7 @@ describe('Receiver', function() { { 'permessage-deflate': perMessageDeflate }, + false, 25 ); const buf = Buffer.from('A'.repeat(15)); @@ -847,13 +918,13 @@ describe('Receiver', function() { done(); }); - perMessageDeflate.compress(buf, false, function(err, fragment1) { + perMessageDeflate.compress(buf, false, (err, fragment1) => { if (err) return done(err); receiver.write(Buffer.from([0x41, fragment1.length])); receiver.write(fragment1); - perMessageDeflate.compress(buf, true, function(err, fragment2) { + perMessageDeflate.compress(buf, true, (err, fragment2) => { if (err) return done(err); receiver.write(Buffer.from([0x80, fragment2.length])); @@ -862,7 +933,7 @@ describe('Receiver', function() { }); }); - it("honors the 'nodebuffer' binary type", function(done) { + it("honors the 'nodebuffer' binary type", (done) => { const receiver = new Receiver(); const frags = [ crypto.randomBytes(7321), @@ -888,7 +959,7 @@ describe('Receiver', function() { }); }); - it("honors the 'arraybuffer' binary type", function(done) { + it("honors the 'arraybuffer' binary type", (done) => { const receiver = new Receiver(); const frags = [ crypto.randomBytes(19221), @@ -914,7 +985,7 @@ describe('Receiver', function() { }); }); - it("honors the 'fragments' binary type", function(done) { + it("honors the 'fragments' binary type", (done) => { const receiver = new Receiver(); const frags = [ crypto.randomBytes(17), diff --git a/test/sender.test.js b/test/sender.test.js index c6be11a83..58eca8fbf 100644 --- a/test/sender.test.js +++ b/test/sender.test.js @@ -13,12 +13,14 @@ class MockSocket { if (write) this.write = write; } + cork() {} write() {} + uncork() {} } -describe('Sender', function() { - describe('.frame', function() { - it('does not mutate the input buffer if data is `readOnly`', function() { +describe('Sender', () => { + describe('.frame', () => { + it('does not mutate the input buffer if data is `readOnly`', () => { const buf = Buffer.from([1, 2, 3, 4, 5]); Sender.frame(buf, { @@ -32,7 +34,7 @@ describe('Sender', function() { assert.ok(buf.equals(Buffer.from([1, 2, 3, 4, 5]))); }); - it('sets RSV1 bit if compressed', function() { + it('sets RSV1 bit if compressed', () => { const list = Sender.frame(Buffer.from('hi'), { readOnly: false, mask: false, @@ -45,8 +47,8 @@ describe('Sender', function() { }); }); - describe('#send', function() { - it('compresses data if compress option is enabled', function(done) { + describe('#send', () => { + it('compresses data if compress option is enabled', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); let count = 0; const mockSocket = new MockSocket({ @@ -69,7 +71,7 @@ describe('Sender', function() { sender.send('hi', options); }); - it('does not compress data for small payloads', function(done) { + it('does not compress data for small payloads', (done) => { const perMessageDeflate = new PerMessageDeflate(); const mockSocket = new MockSocket({ write: (data) => { @@ -86,18 +88,21 @@ describe('Sender', function() { sender.send('hi', { compress: true, fin: true }); }); - it('compresses all frames in a fragmented message', function(done) { - const fragments = []; + it('compresses all frames in a fragmented message', (done) => { + const chunks = []; const perMessageDeflate = new PerMessageDeflate({ threshold: 3 }); const mockSocket = new MockSocket({ - write: (data) => { - fragments.push(data); - if (fragments.length !== 2) return; + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x40); + assert.strictEqual(chunks[1].length, 9); - assert.strictEqual(fragments[0][0] & 0x40, 0x40); - assert.strictEqual(fragments[0].length, 11); - assert.strictEqual(fragments[1][0] & 0x40, 0x00); - assert.strictEqual(fragments[1].length, 6); + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 4); done(); } }); @@ -111,18 +116,21 @@ describe('Sender', function() { sender.send('12', { compress: true, fin: true }); }); - it('compresses no frames in a fragmented message', function(done) { - const fragments = []; + it('compresses no frames in a fragmented message', (done) => { + const chunks = []; const perMessageDeflate = new PerMessageDeflate({ threshold: 3 }); const mockSocket = new MockSocket({ - write: (data) => { - fragments.push(data); - if (fragments.length !== 2) return; + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; - assert.strictEqual(fragments[0][0] & 0x40, 0x00); - assert.strictEqual(fragments[0].length, 4); - assert.strictEqual(fragments[1][0] & 0x40, 0x00); - assert.strictEqual(fragments[1].length, 5); + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x00); + assert.strictEqual(chunks[1].length, 2); + + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 3); done(); } }); @@ -136,18 +144,21 @@ describe('Sender', function() { sender.send('123', { compress: true, fin: true }); }); - it('compresses empty buffer as first fragment', function(done) { - const fragments = []; + it('compresses empty buffer as first fragment', (done) => { + const chunks = []; const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); const mockSocket = new MockSocket({ - write: (data) => { - fragments.push(data); - if (fragments.length !== 2) return; + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x40); + assert.strictEqual(chunks[1].length, 5); - assert.strictEqual(fragments[0][0] & 0x40, 0x40); - assert.strictEqual(fragments[0].length, 3); - assert.strictEqual(fragments[1][0] & 0x40, 0x00); - assert.strictEqual(fragments[1].length, 8); + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 6); done(); } }); @@ -161,18 +172,21 @@ describe('Sender', function() { sender.send('data', { compress: true, fin: true }); }); - it('compresses empty buffer as last fragment', function(done) { - const fragments = []; + it('compresses empty buffer as last fragment', (done) => { + const chunks = []; const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); const mockSocket = new MockSocket({ - write: (data) => { - fragments.push(data); - if (fragments.length !== 2) return; + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x40); + assert.strictEqual(chunks[1].length, 10); - assert.strictEqual(fragments[0][0] & 0x40, 0x40); - assert.strictEqual(fragments[0].length, 12); - assert.strictEqual(fragments[1][0] & 0x40, 0x00); - assert.strictEqual(fragments[1].length, 3); + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 1); done(); } }); @@ -185,41 +199,23 @@ describe('Sender', function() { sender.send('data', { compress: true, fin: false }); sender.send(Buffer.alloc(0), { compress: true, fin: true }); }); - - it('handles many send calls while processing without crashing on flush', function(done) { - let count = 0; - const perMessageDeflate = new PerMessageDeflate(); - const mockSocket = new MockSocket({ - write: () => { - if (++count > 1e4) done(); - } - }); - const sender = new Sender(mockSocket, { - 'permessage-deflate': perMessageDeflate - }); - - perMessageDeflate.accept([{}]); - - for (let i = 0; i < 1e4; i++) { - sender.processing = true; - sender.send('hi', { compress: false, fin: true }); - } - - sender.processing = false; - sender.send('hi', { compress: false, fin: true }); - }); }); - describe('#ping', function() { - it('works with multiple types of data', function(done) { + describe('#ping', () => { + it('works with multiple types of data', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); let count = 0; const mockSocket = new MockSocket({ write: (data) => { - if (++count === 1) return; + if (++count < 3) return; + + if (count % 2) { + assert.ok(data.equals(Buffer.from([0x89, 0x02]))); + } else { + assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + } - assert.ok(data.equals(Buffer.from([0x89, 0x02, 0x68, 0x69]))); - if (count === 4) done(); + if (count === 8) done(); } }); const sender = new Sender(mockSocket, { @@ -237,16 +233,21 @@ describe('Sender', function() { }); }); - describe('#pong', function() { - it('works with multiple types of data', function(done) { + describe('#pong', () => { + it('works with multiple types of data', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); let count = 0; const mockSocket = new MockSocket({ write: (data) => { - if (++count === 1) return; + if (++count < 3) return; + + if (count % 2) { + assert.ok(data.equals(Buffer.from([0x8a, 0x02]))); + } else { + assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + } - assert.ok(data.equals(Buffer.from([0x8a, 0x02, 0x68, 0x69]))); - if (count === 4) done(); + if (count === 8) done(); } }); const sender = new Sender(mockSocket, { @@ -264,8 +265,8 @@ describe('Sender', function() { }); }); - describe('#close', function() { - it('should consume all data before closing', function(done) { + describe('#close', () => { + it('should consume all data before closing', (done) => { const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); let count = 0; @@ -286,7 +287,7 @@ describe('Sender', function() { sender.send('baz', { compress: true, fin: true }); sender.close(1000, undefined, false, () => { - assert.strictEqual(count, 4); + assert.strictEqual(count, 8); done(); }); }); diff --git a/test/validation.test.js b/test/validation.test.js new file mode 100644 index 000000000..5718b12f0 --- /dev/null +++ b/test/validation.test.js @@ -0,0 +1,52 @@ +'use strict'; + +const assert = require('assert'); + +const { isValidUTF8 } = require('../lib/validation'); + +describe('extension', () => { + describe('isValidUTF8', () => { + it('returns false if it finds invalid bytes', () => { + assert.strictEqual(isValidUTF8(Buffer.from([0xf8])), false); + }); + + it('returns false for overlong encodings', () => { + assert.strictEqual(isValidUTF8(Buffer.from([0xc0, 0xa0])), false); + assert.strictEqual(isValidUTF8(Buffer.from([0xe0, 0x80, 0xa0])), false); + assert.strictEqual( + isValidUTF8(Buffer.from([0xf0, 0x80, 0x80, 0xa0])), + false + ); + }); + + it('returns false for code points in the range U+D800 - U+DFFF', () => { + for (let i = 0xa0; i < 0xc0; i++) { + for (let j = 0x80; j < 0xc0; j++) { + assert.strictEqual(isValidUTF8(Buffer.from([0xed, i, j])), false); + } + } + }); + + it('returns false for code points greater than U+10FFFF', () => { + assert.strictEqual( + isValidUTF8(Buffer.from([0xf4, 0x90, 0x80, 0x80])), + false + ); + assert.strictEqual( + isValidUTF8(Buffer.from([0xf5, 0x80, 0x80, 0x80])), + false + ); + }); + + it('returns true for a well-formed UTF-8 byte sequence', () => { + // prettier-ignore + const buf = Buffer.from([ + 0xe2, 0x82, 0xAC, // € + 0xf0, 0x90, 0x8c, 0x88, // 𐍈 + 0x24 // $ + ]); + + assert.strictEqual(isValidUTF8(buf), true); + }); + }); +}); diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index f6a394f72..71646e5a4 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -6,30 +6,34 @@ const assert = require('assert'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); +const path = require('path'); const net = require('net'); const fs = require('fs'); +const os = require('os'); +const Sender = require('../lib/sender'); const WebSocket = require('..'); +const { NOOP } = require('../lib/constants'); -describe('WebSocketServer', function() { - describe('#ctor', function() { - it('throws an error if no option object is passed', function() { +describe('WebSocketServer', () => { + describe('#ctor', () => { + it('throws an error if no option object is passed', () => { assert.throws(() => new WebSocket.Server()); }); - it('throws an error if no port or server is specified', function() { - assert.throws(() => new WebSocket.Server({})); - }); + describe('options', () => { + it('throws an error if no `port` or `server` option is specified', () => { + assert.throws(() => new WebSocket.Server({})); + }); - describe('options', function() { - it('exposes options passed to constructor', function(done) { + it('exposes options passed to constructor', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { assert.strictEqual(wss.options.port, 0); wss.close(done); }); }); - it('accepts the `maxPayload` option', function(done) { + it('accepts the `maxPayload` option', (done) => { const maxPayload = 20480; const wss = new WebSocket.Server( { @@ -53,7 +57,7 @@ describe('WebSocketServer', function() { }); }); - it('emits an error if http server bind fails', function(done) { + it('emits an error if http server bind fails', (done) => { const wss1 = new WebSocket.Server({ port: 0 }, () => { const wss2 = new WebSocket.Server({ port: wss1.address().port @@ -63,7 +67,7 @@ describe('WebSocketServer', function() { }); }); - it('starts a server on a given port', function(done) { + it('starts a server on a given port', (done) => { const port = 1337; const wss = new WebSocket.Server({ port }, () => { const ws = new WebSocket(`ws://localhost:${port}`); @@ -72,14 +76,14 @@ describe('WebSocketServer', function() { wss.on('connection', () => wss.close(done)); }); - it('binds the server on any IPv6 address when available', function(done) { + it('binds the server on any IPv6 address when available', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { assert.strictEqual(wss._server.address().address, '::'); wss.close(done); }); }); - it('uses a precreated http server', function(done) { + it('uses a precreated http server', (done) => { const server = http.createServer(); server.listen(0, () => { @@ -93,7 +97,7 @@ describe('WebSocketServer', function() { }); }); - it('426s for non-Upgrade requests', function(done) { + it('426s for non-Upgrade requests', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { http.get(`http://localhost:${wss.address().port}`, (res) => { let body = ''; @@ -110,16 +114,20 @@ describe('WebSocketServer', function() { }); }); - it('uses a precreated http server listening on unix socket', function(done) { + it('uses a precreated http server listening on unix socket', function (done) { + // + // Skip this test on Windows. The URL parser: // - // Skip this test on Windows as it throws errors for obvious reasons. + // - Throws an error if the named pipe uses backward slashes. + // - Incorrectly parses the path if the named pipe uses forward slashes. // if (process.platform === 'win32') return this.skip(); const server = http.createServer(); - const sockPath = `/tmp/ws.${crypto - .randomBytes(16) - .toString('hex')}.socket`; + const sockPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); server.listen(sockPath, () => { const wss = new WebSocket.Server({ server }); @@ -140,8 +148,8 @@ describe('WebSocketServer', function() { }); }); - describe('#address', function() { - it('returns the address of the server', function(done) { + describe('#address', () => { + it('returns the address of the server', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const addr = wss.address(); @@ -150,7 +158,7 @@ describe('WebSocketServer', function() { }); }); - it('throws an error when operating in "noServer" mode', function() { + it('throws an error when operating in "noServer" mode', () => { const wss = new WebSocket.Server({ noServer: true }); assert.throws(() => { @@ -158,7 +166,7 @@ describe('WebSocketServer', function() { }, /^Error: The server is operating in "noServer" mode$/); }); - it('returns `null` if called after close', function(done) { + it('returns `null` if called after close', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { wss.close(() => { assert.strictEqual(wss.address(), null); @@ -168,8 +176,8 @@ describe('WebSocketServer', function() { }); }); - describe('#close', function() { - it('does not throw when called twice', function(done) { + describe('#close', () => { + it('does not throw when called twice', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { wss.close(); wss.close(); @@ -179,7 +187,7 @@ describe('WebSocketServer', function() { }); }); - it('closes all clients', function(done) { + it('closes all clients', (done) => { let closes = 0; const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -196,7 +204,7 @@ describe('WebSocketServer', function() { }); }); - it("doesn't close a precreated server", function(done) { + it("doesn't close a precreated server", (done) => { const server = http.createServer(); const realClose = server.close; @@ -217,13 +225,13 @@ describe('WebSocketServer', function() { }); }); - it('invokes the callback in noServer mode', function(done) { + it('invokes the callback in noServer mode', (done) => { const wss = new WebSocket.Server({ noServer: true }); wss.close(done); }); - it('cleans event handlers on precreated server', function(done) { + it('cleans event handlers on precreated server', (done) => { const server = http.createServer(); const wss = new WebSocket.Server({ server }); @@ -238,7 +246,7 @@ describe('WebSocketServer', function() { }); }); - it("emits the 'close' event", function(done) { + it("emits the 'close' event", (done) => { const wss = new WebSocket.Server({ noServer: true }); wss.on('close', done); @@ -246,8 +254,8 @@ describe('WebSocketServer', function() { }); }); - describe('#clients', function() { - it('returns a list of connected clients', function(done) { + describe('#clients', () => { + it('returns a list of connected clients', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { assert.strictEqual(wss.clients.size, 0); const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -259,7 +267,7 @@ describe('WebSocketServer', function() { }); }); - it('can be disabled', function(done) { + it('can be disabled', (done) => { const wss = new WebSocket.Server( { port: 0, clientTracking: false }, () => { @@ -276,7 +284,7 @@ describe('WebSocketServer', function() { }); }); - it('is updated when client terminates the connection', function(done) { + it('is updated when client terminates the connection', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -291,7 +299,7 @@ describe('WebSocketServer', function() { }); }); - it('is updated when client closes the connection', function(done) { + it('is updated when client closes the connection', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -307,22 +315,23 @@ describe('WebSocketServer', function() { }); }); - describe('#shouldHandle', function() { - it('returns true when the path matches', function() { + describe('#shouldHandle', () => { + it('returns true when the path matches', () => { const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); assert.strictEqual(wss.shouldHandle({ url: '/foo' }), true); + assert.strictEqual(wss.shouldHandle({ url: '/foo?bar=baz' }), true); }); - it("returns false when the path doesn't match", function() { + it("returns false when the path doesn't match", () => { const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); assert.strictEqual(wss.shouldHandle({ url: '/bar' }), false); }); }); - describe('#handleUpgrade', function() { - it('can be used for a pre-existing server', function(done) { + describe('#handleUpgrade', () => { + it('can be used for a pre-existing server', (done) => { const server = http.createServer(); server.listen(0, () => { @@ -344,7 +353,7 @@ describe('WebSocketServer', function() { }); }); - it("closes the connection when path doesn't match", function(done) { + it("closes the connection when path doesn't match", (done) => { const wss = new WebSocket.Server({ port: 0, path: '/ws' }, () => { const req = http.get({ port: wss.address().port, @@ -361,7 +370,7 @@ describe('WebSocketServer', function() { }); }); - it('closes the connection when protocol version is Hixie-76', function(done) { + it('closes the connection when protocol version is Hixie-76', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const req = http.get({ port: wss.address().port, @@ -382,8 +391,44 @@ describe('WebSocketServer', function() { }); }); - describe('Connection establishing', function() { - it('fails if the Sec-WebSocket-Key header is invalid', function(done) { + describe('#completeUpgrade', () => { + it('throws an error if called twice with the same socket', (done) => { + const server = http.createServer(); + + server.listen(0, () => { + const wss = new WebSocket.Server({ noServer: true }); + + server.on('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + ws.close(); + }); + assert.throws( + () => wss.handleUpgrade(req, socket, head, NOOP), + (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'server.handleUpgrade() was called more than once with the ' + + 'same socket, possibly due to a misconfiguration' + ); + return true; + } + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => { + ws.on('close', () => { + server.close(done); + }); + }); + }); + }); + }); + + describe('Connection establishing', () => { + it('fails if the Sec-WebSocket-Key header is invalid (1/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const req = http.get({ port: wss.address().port, @@ -404,7 +449,29 @@ describe('WebSocketServer', function() { }); }); - it('fails is the Sec-WebSocket-Version header is invalid (1/2)', function(done) { + it('fails if the Sec-WebSocket-Key header is invalid (2/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'P5l8BJcZwRc=' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + wss.close(done); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails is the Sec-WebSocket-Version header is invalid (1/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const req = http.get({ port: wss.address().port, @@ -426,7 +493,7 @@ describe('WebSocketServer', function() { }); }); - it('fails is the Sec-WebSocket-Version header is invalid (2/2)', function(done) { + it('fails is the Sec-WebSocket-Version header is invalid (2/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const req = http.get({ port: wss.address().port, @@ -449,7 +516,7 @@ describe('WebSocketServer', function() { }); }); - it('fails is the Sec-WebSocket-Extensions header is invalid', function(done) { + it('fails is the Sec-WebSocket-Extensions header is invalid', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: true, @@ -480,8 +547,43 @@ describe('WebSocketServer', function() { }); }); - describe('`verifyClient`', function() { - it('can reject client synchronously', function(done) { + it('handles unsupported extensions', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13, + 'Sec-WebSocket-Extensions': 'foo; bar' + } + }); + + req.on('upgrade', (res, socket, head) => { + if (head.length) socket.unshift(head); + + socket.once('data', (chunk) => { + assert.strictEqual(chunk[0], 0x88); + wss.close(done); + }); + }); + } + ); + + wss.on('connection', (ws) => { + assert.strictEqual(ws.extensions, ''); + ws.close(); + }); + }); + + describe('`verifyClient`', () => { + it('can reject client synchronously', (done) => { const wss = new WebSocket.Server( { verifyClient: () => false, @@ -510,7 +612,7 @@ describe('WebSocketServer', function() { }); }); - it('can accept client synchronously', function(done) { + it('can accept client synchronously', (done) => { const server = https.createServer({ cert: fs.readFileSync('test/fixtures/certificate.pem'), key: fs.readFileSync('test/fixtures/key.pem') @@ -539,7 +641,7 @@ describe('WebSocketServer', function() { }); }); - it('can accept client asynchronously', function(done) { + it('can accept client asynchronously', (done) => { const wss = new WebSocket.Server( { verifyClient: (o, cb) => process.nextTick(cb, true), @@ -553,7 +655,7 @@ describe('WebSocketServer', function() { wss.on('connection', () => wss.close(done)); }); - it('can reject client asynchronously', function(done) { + it('can reject client asynchronously', (done) => { const wss = new WebSocket.Server( { verifyClient: (info, cb) => process.nextTick(cb, false), @@ -582,7 +684,7 @@ describe('WebSocketServer', function() { }); }); - it('can reject client asynchronously w/ status code', function(done) { + it('can reject client asynchronously w/ status code', (done) => { const wss = new WebSocket.Server( { verifyClient: (info, cb) => process.nextTick(cb, false, 404), @@ -611,7 +713,7 @@ describe('WebSocketServer', function() { }); }); - it('can reject client asynchronously w/ custom headers', function(done) { + it('can reject client asynchronously w/ custom headers', (done) => { const wss = new WebSocket.Server( { verifyClient: (info, cb) => { @@ -644,12 +746,21 @@ describe('WebSocketServer', function() { }); }); - it("doesn't emit the 'connection' event if socket is closed prematurely", function(done) { + it("doesn't emit the 'connection' event if socket is closed prematurely", (done) => { const server = http.createServer(); server.listen(0, () => { const wss = new WebSocket.Server({ - verifyClient: (o, cb) => setTimeout(cb, 100, true), + verifyClient: ({ req: { socket } }, cb) => { + assert.strictEqual(socket.readable, true); + assert.strictEqual(socket.writable, true); + + socket.on('end', () => { + assert.strictEqual(socket.readable, false); + assert.strictEqual(socket.writable, true); + cb(true); + }); + }, server }); @@ -663,7 +774,7 @@ describe('WebSocketServer', function() { allowHalfOpen: true }, () => { - socket.write( + socket.end( [ 'GET / HTTP/1.1', 'Host: localhost', @@ -681,12 +792,10 @@ describe('WebSocketServer', function() { wss.close(); server.close(done); }); - - socket.setTimeout(50, () => socket.end()); }); }); - it('handles data passed along with the upgrade request', function(done) { + it('handles data passed along with the upgrade request', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const req = http.request({ port: wss.address().port, @@ -698,7 +807,15 @@ describe('WebSocketServer', function() { } }); - req.write(Buffer.from([0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f])); + const list = Sender.frame(Buffer.from('Hello'), { + fin: true, + rsv1: false, + opcode: 0x01, + mask: true, + readOnly: false + }); + + req.write(Buffer.concat(list)); req.end(); }); @@ -710,8 +827,8 @@ describe('WebSocketServer', function() { }); }); - describe('`handleProtocols`', function() { - it('allows to select a subprotocol', function(done) { + describe('`handleProtocols`', () => { + it('allows to select a subprotocol', (done) => { const handleProtocols = (protocols, request) => { assert.ok(request instanceof http.IncomingMessage); assert.strictEqual(request.url, '/'); @@ -731,7 +848,7 @@ describe('WebSocketServer', function() { }); }); - it("emits the 'headers' event", function(done) { + it("emits the 'headers' event", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -750,8 +867,8 @@ describe('WebSocketServer', function() { }); }); - describe('permessage-deflate', function() { - it('is disabled by default', function(done) { + describe('permessage-deflate', () => { + it('is disabled by default', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); }); @@ -766,7 +883,7 @@ describe('WebSocketServer', function() { }); }); - it('uses configuration options', function(done) { + it('uses configuration options', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: { clientMaxWindowBits: 8 }, diff --git a/test/websocket.integration.js b/test/websocket.integration.js index cc78af220..5ff87a640 100644 --- a/test/websocket.integration.js +++ b/test/websocket.integration.js @@ -4,8 +4,8 @@ const assert = require('assert'); const WebSocket = require('..'); -describe('WebSocket', function() { - it('communicates successfully with echo service (ws)', function(done) { +describe('WebSocket', () => { + it('communicates successfully with echo service (ws)', (done) => { const ws = new WebSocket('ws://echo.websocket.org/', { origin: 'http://www.websocket.org', protocolVersion: 13 @@ -26,7 +26,7 @@ describe('WebSocket', function() { }); }); - it('communicates successfully with echo service (wss)', function(done) { + it('communicates successfully with echo service (wss)', (done) => { const ws = new WebSocket('wss://echo.websocket.org/', { origin: 'https://www.websocket.org', protocolVersion: 13 diff --git a/test/websocket.test.js b/test/websocket.test.js index 71f66044f..8bfc9e151 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -6,39 +6,27 @@ const assert = require('assert'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); -const url = require('url'); +const tls = require('tls'); const fs = require('fs'); +const { URL } = require('url'); -const constants = require('../lib/constants'); const WebSocket = require('..'); +const { GUID, NOOP } = require('../lib/constants'); class CustomAgent extends http.Agent { addRequest() {} } -describe('WebSocket', function() { - describe('#ctor', function() { - it('throws an error when using an invalid url', function() { +describe('WebSocket', () => { + describe('#ctor', () => { + it('throws an error when using an invalid url', () => { assert.throws( () => new WebSocket('ws+unix:'), /^Error: Invalid URL: ws\+unix:$/ ); }); - it('accepts `url.Url` objects as url', function(done) { - const agent = new CustomAgent(); - - agent.addRequest = (req) => { - assert.strictEqual(req.path, '/'); - done(); - }; - - const ws = new WebSocket(url.parse('ws://localhost'), { agent }); - }); - - it('accepts `url.URL` objects as url', function(done) { - if (!url.URL) return this.skip(); - + it('accepts `url.URL` objects as url', (done) => { const agent = new CustomAgent(); agent.addRequest = (req, opts) => { @@ -47,11 +35,11 @@ describe('WebSocket', function() { done(); }; - const ws = new WebSocket(new url.URL('ws://[::1]'), { agent }); + const ws = new WebSocket(new URL('ws://[::1]'), { agent }); }); - describe('options', function() { - it('accepts the `options` object as 3rd argument', function() { + describe('options', () => { + it('accepts the `options` object as 3rd argument', () => { const agent = new CustomAgent(); let count = 0; let ws; @@ -65,7 +53,7 @@ describe('WebSocket', function() { assert.strictEqual(count, 3); }); - it('accepts the `maxPayload` option', function(done) { + it('accepts the `maxPayload` option', (done) => { const maxPayload = 20480; const wss = new WebSocket.Server( { @@ -90,7 +78,7 @@ describe('WebSocket', function() { ); }); - it('throws an error when using an invalid `protocolVersion`', function() { + it('throws an error when using an invalid `protocolVersion`', () => { const options = { agent: new CustomAgent(), protocolVersion: 1000 }; assert.throws( @@ -101,7 +89,7 @@ describe('WebSocket', function() { }); }); - describe('Constants', function() { + describe('Constants', () => { const readyStates = { CONNECTING: 0, OPEN: 1, @@ -110,31 +98,50 @@ describe('WebSocket', function() { }; Object.keys(readyStates).forEach((state) => { - describe(`\`${state}\``, function() { - it('is enumerable property of class', function() { - const propertyDescripter = Object.getOwnPropertyDescriptor( - WebSocket, + describe(`\`${state}\``, () => { + it('is enumerable property of class', () => { + const descriptor = Object.getOwnPropertyDescriptor(WebSocket, state); + + assert.deepStrictEqual(descriptor, { + configurable: false, + enumerable: true, + value: readyStates[state], + writable: false + }); + }); + + it('is enumerable property of prototype', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, state ); - assert.strictEqual(propertyDescripter.value, readyStates[state]); - assert.strictEqual(propertyDescripter.enumerable, true); - }); - - it('is property of instance', function() { - const ws = new WebSocket('ws://localhost', { - agent: new CustomAgent() + assert.deepStrictEqual(descriptor, { + configurable: false, + enumerable: true, + value: readyStates[state], + writable: false }); - - assert.strictEqual(ws[state], readyStates[state]); }); }); }); }); - describe('Attributes', function() { - describe('`binaryType`', function() { - it("defaults to 'nodebuffer'", function() { + describe('Attributes', () => { + describe('`binaryType`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'binaryType' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set !== undefined); + }); + + it("defaults to 'nodebuffer'", () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); @@ -142,7 +149,7 @@ describe('WebSocket', function() { assert.strictEqual(ws.binaryType, 'nodebuffer'); }); - it("can be changed to 'arraybuffer' or 'fragments'", function() { + it("can be changed to 'arraybuffer' or 'fragments'", () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); @@ -164,8 +171,20 @@ describe('WebSocket', function() { }); }); - describe('`bufferedAmount`', function() { - it('defaults to zero', function() { + describe('`bufferedAmount`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'bufferedAmount' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to zero', () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); @@ -173,7 +192,7 @@ describe('WebSocket', function() { assert.strictEqual(ws.bufferedAmount, 0); }); - it('defaults to zero upon "open"', function(done) { + it('defaults to zero upon "open"', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -184,7 +203,7 @@ describe('WebSocket', function() { }); }); - it('takes into account the data in the sender queue', function(done) { + it('takes into account the data in the sender queue', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: true, @@ -197,19 +216,22 @@ describe('WebSocket', function() { ws.on('open', () => { ws.send('foo'); + + assert.strictEqual(ws.bufferedAmount, 3); + ws.send('bar', (err) => { assert.ifError(err); assert.strictEqual(ws.bufferedAmount, 0); wss.close(done); }); - assert.strictEqual(ws.bufferedAmount, 3); + assert.strictEqual(ws.bufferedAmount, 6); }); } ); }); - it('takes into account the data in the socket queue', function(done) { + it('takes into account the data in the socket queue', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); }); @@ -217,23 +239,36 @@ describe('WebSocket', function() { wss.on('connection', (ws) => { const data = Buffer.alloc(1024, 61); - // eslint-disable-next-line no-constant-condition - while (true) { - if (ws._socket.bufferSize > 0) { - assert.strictEqual(ws.bufferedAmount, ws._socket.bufferSize); - break; - } + while (ws.bufferedAmount === 0) { ws.send(data); } + assert.ok(ws.bufferedAmount > 0); + assert.strictEqual( + ws.bufferedAmount, + ws._socket._writableState.length + ); + ws.on('close', () => wss.close(done)); ws.close(); }); }); }); - describe('`extensions`', function() { - it('exposes the negotiated extensions names (1/2)', function(done) { + describe('`extensions`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'bufferedAmount' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('exposes the negotiated extensions names (1/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -251,7 +286,7 @@ describe('WebSocket', function() { }); }); - it('exposes the negotiated extensions names (2/2)', function(done) { + it('exposes the negotiated extensions names (2/2)', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: true, @@ -276,8 +311,20 @@ describe('WebSocket', function() { }); }); - describe('`protocol`', function() { - it('exposes the subprotocol selected by the server', function(done) { + describe('`protocol`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'protocol' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('exposes the subprotocol selected by the server', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const port = wss.address().port; const ws = new WebSocket(`ws://localhost:${port}`, 'foo'); @@ -297,8 +344,20 @@ describe('WebSocket', function() { }); }); - describe('`readyState`', function() { - it('defaults to `CONNECTING`', function() { + describe('`readyState`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'readyState' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to `CONNECTING`', () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); @@ -306,7 +365,7 @@ describe('WebSocket', function() { assert.strictEqual(ws.readyState, WebSocket.CONNECTING); }); - it('is set to `OPEN` once connection is established', function(done) { + it('is set to `OPEN` once connection is established', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -319,7 +378,7 @@ describe('WebSocket', function() { }); }); - it('is set to `CLOSED` once connection is closed', function(done) { + it('is set to `CLOSED` once connection is closed', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -332,7 +391,7 @@ describe('WebSocket', function() { }); }); - it('is set to `CLOSED` once connection is terminated', function(done) { + it('is set to `CLOSED` once connection is terminated', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -346,8 +405,20 @@ describe('WebSocket', function() { }); }); - describe('`url`', function() { - it('exposes the server url', function() { + describe('`url`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'url' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('exposes the server url', () => { const url = 'ws://localhost'; const ws = new WebSocket(url, { agent: new CustomAgent() }); @@ -356,8 +427,8 @@ describe('WebSocket', function() { }); }); - describe('Events', function() { - it("emits an 'error' event if an error occurs", function(done) { + describe('Events', () => { + it("emits an 'error' event if an error occurs", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -381,14 +452,15 @@ describe('WebSocket', function() { }); }); - it('does not re-emit `net.Socket` errors', function(done) { + it('does not re-emit `net.Socket` errors', (done) => { + const codes = ['EPIPE', 'ECONNABORTED', 'ECANCELED', 'ECONNRESET']; const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('open', () => { ws._socket.on('error', (err) => { assert.ok(err instanceof Error); - assert.ok(err.message.startsWith('write E')); + assert.ok(codes.includes(err.code), `Unexpected code: ${err.code}`); ws.on('close', (code, message) => { assert.strictEqual(message, ''); assert.strictEqual(code, 1006); @@ -403,7 +475,7 @@ describe('WebSocket', function() { }); }); - it("emits an 'upgrade' event", function(done) { + it("emits an 'upgrade' event", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('upgrade', (res) => { @@ -413,7 +485,7 @@ describe('WebSocket', function() { }); }); - it("emits a 'ping' event", function(done) { + it("emits a 'ping' event", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('ping', () => wss.close(done)); @@ -422,7 +494,7 @@ describe('WebSocket', function() { wss.on('connection', (ws) => ws.ping()); }); - it("emits a 'pong' event", function(done) { + it("emits a 'pong' event", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('pong', () => wss.close(done)); @@ -432,13 +504,13 @@ describe('WebSocket', function() { }); }); - describe('Connection establishing', function() { + describe('Connection establishing', () => { const server = http.createServer(); beforeEach((done) => server.listen(0, done)); afterEach((done) => server.close(done)); - it('fails if the Sec-WebSocket-Accept header is invalid', function(done) { + it('fails if the Sec-WebSocket-Accept header is invalid', (done) => { server.once('upgrade', (req, socket) => { socket.on('end', socket.end); socket.write( @@ -459,11 +531,11 @@ describe('WebSocket', function() { }); }); - it('close event is raised when server closes connection', function(done) { + it('close event is raised when server closes connection', (done) => { server.once('upgrade', (req, socket) => { const key = crypto .createHash('sha1') - .update(req.headers['sec-websocket-key'] + constants.GUID, 'binary') + .update(req.headers['sec-websocket-key'] + GUID) .digest('base64'); socket.end( @@ -484,7 +556,7 @@ describe('WebSocket', function() { }); }); - it('error is emitted if server aborts connection', function(done) { + it('error is emitted if server aborts connection', (done) => { server.once('upgrade', (req, socket) => { socket.end( `HTTP/1.1 401 ${http.STATUS_CODES[401]}\r\n` + @@ -505,13 +577,13 @@ describe('WebSocket', function() { }); }); - it('unexpected response can be read when sent by server', function(done) { + it('unexpected response can be read when sent by server', (done) => { server.once('upgrade', (req, socket) => { socket.end( `HTTP/1.1 401 ${http.STATUS_CODES[401]}\r\n` + 'Connection: close\r\n' + 'Content-type: text/html\r\n' + - `Content-Length: ${http.STATUS_CODES[401].length}\r\n` + + 'Content-Length: 3\r\n' + '\r\n' + 'foo' ); @@ -537,13 +609,13 @@ describe('WebSocket', function() { }); }); - it('request can be aborted when unexpected response is sent by server', function(done) { + it('request can be aborted when unexpected response is sent by server', (done) => { server.once('upgrade', (req, socket) => { socket.end( `HTTP/1.1 401 ${http.STATUS_CODES[401]}\r\n` + 'Connection: close\r\n' + 'Content-type: text/html\r\n' + - `Content-Length: ${http.STATUS_CODES[401].length}\r\n` + + 'Content-Length: 3\r\n' + '\r\n' + 'foo' ); @@ -561,7 +633,7 @@ describe('WebSocket', function() { }); }); - it('fails if the opening handshake timeout expires', function(done) { + it('fails if the opening handshake timeout expires', (done) => { server.once('upgrade', (req, socket) => socket.on('end', socket.end)); const port = server.address().port; @@ -577,11 +649,11 @@ describe('WebSocket', function() { }); }); - it('fails if the Sec-WebSocket-Extensions response header is invalid', function(done) { + it('fails if the Sec-WebSocket-Extensions response header is invalid', (done) => { server.once('upgrade', (req, socket) => { const key = crypto .createHash('sha1') - .update(req.headers['sec-websocket-key'] + constants.GUID, 'binary') + .update(req.headers['sec-websocket-key'] + GUID) .digest('base64'); socket.end( @@ -607,7 +679,7 @@ describe('WebSocket', function() { }); }); - it('fails if server sends a subprotocol when none was requested', function(done) { + it('fails if server sends a subprotocol when none was requested', (done) => { const wss = new WebSocket.Server({ server }); wss.on('headers', (headers) => { @@ -627,7 +699,7 @@ describe('WebSocket', function() { }); }); - it('fails if server sends an invalid subprotocol', function(done) { + it('fails if server sends an invalid subprotocol', (done) => { const wss = new WebSocket.Server({ handleProtocols: () => 'baz', server @@ -646,9 +718,9 @@ describe('WebSocket', function() { }); }); - it('fails if server sends no subprotocol', function(done) { + it('fails if server sends no subprotocol', (done) => { const wss = new WebSocket.Server({ - handleProtocols: () => {}, + handleProtocols() {}, server }); @@ -664,10 +736,76 @@ describe('WebSocket', function() { ws.on('close', () => wss.close(done)); }); }); + + it('does not follow redirects by default', (done) => { + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 301 Moved Permanently\r\n' + + 'Location: ws://localhost:8080\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Unexpected server response: 301'); + assert.strictEqual(ws._redirects, 0); + ws.on('close', () => done()); + }); + }); + + it('honors the `followRedirects` option', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + server.once('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, NOOP); + }); + }); + + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('open', () => { + assert.strictEqual(ws.url, `ws://localhost:${port}/foo`); + assert.strictEqual(ws._redirects, 1); + ws.on('close', () => done()); + ws.close(); + }); + }); + + it('honors the `maxRedirects` option', (done) => { + const onUpgrade = (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /\r\n\r\n'); + }; + + server.on('upgrade', onUpgrade); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true, + maxRedirects: 1 + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Maximum redirects exceeded'); + assert.strictEqual(ws._redirects, 2); + + server.removeListener('upgrade', onUpgrade); + ws.on('close', () => done()); + }); + }); }); - describe('Connection with query string', function() { - it('connects when pathname is not null', function(done) { + describe('Connection with query string', () => { + it('connects when pathname is not null', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const port = wss.address().port; const ws = new WebSocket(`ws://localhost:${port}/?token=qwerty`); @@ -676,7 +814,7 @@ describe('WebSocket', function() { }); }); - it('connects when pathname is null', function(done) { + it('connects when pathname is null', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const port = wss.address().port; const ws = new WebSocket(`ws://localhost:${port}?token=qwerty`); @@ -686,10 +824,10 @@ describe('WebSocket', function() { }); }); - describe('#ping', function() { - it('throws an error if `readyState` is not `OPEN`', function(done) { + describe('#ping', () => { + it('throws an error if `readyState` is `CONNECTING`', () => { const ws = new WebSocket('ws://localhost', { - agent: new CustomAgent() + lookup() {} }); assert.throws( @@ -697,17 +835,84 @@ describe('WebSocket', function() { /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ ); - ws.ping((err) => { + assert.throws( + () => ws.ping(NOOP), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + }); + + it('increases `bufferedAmount` if `readyState` is 2 or 3', (done) => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + ws.on('error', (err) => { assert.ok(err instanceof Error); assert.strictEqual( err.message, - 'WebSocket is not open: readyState 0 (CONNECTING)' + 'WebSocket was closed before the connection was established' ); - done(); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.strictEqual(ws.bufferedAmount, 0); + + ws.ping('hi'); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.ping(); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.ping('hi'); + assert.strictEqual(ws.bufferedAmount, 4); + + ws.ping(); + assert.strictEqual(ws.bufferedAmount, 4); + + done(); + }); }); + + ws.close(); }); - it('can send a ping with no data', function(done) { + it('calls the callback w/ an error if `readyState` is 2 or 3', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + ws.close(); + + assert.strictEqual(ws.bufferedAmount, 0); + + ws.ping('hi', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 2 (CLOSING)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + ws.ping((err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 3 (CLOSED)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + wss.close(done); + }); + }); + }); + }); + }); + + it('can send a ping with no data', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -726,7 +931,7 @@ describe('WebSocket', function() { }); }); - it('can send a ping with data', function(done) { + it('can send a ping with data', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -744,7 +949,7 @@ describe('WebSocket', function() { }); }); - it('can send numbers as ping payload', function(done) { + it('can send numbers as ping payload', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -758,12 +963,27 @@ describe('WebSocket', function() { }); }); }); + + it('throws an error if the data size is greater than 125 bytes', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + assert.throws( + () => ws.ping(Buffer.alloc(126)), + /^RangeError: The data size must not be greater than 125 bytes$/ + ); + + wss.close(done); + }); + }); + }); }); - describe('#pong', function() { - it('throws an error if `readyState` is not `OPEN`', (done) => { + describe('#pong', () => { + it('throws an error if `readyState` is `CONNECTING`', () => { const ws = new WebSocket('ws://localhost', { - agent: new CustomAgent() + lookup() {} }); assert.throws( @@ -771,17 +991,84 @@ describe('WebSocket', function() { /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ ); - ws.pong((err) => { + assert.throws( + () => ws.pong(NOOP), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + }); + + it('increases `bufferedAmount` if `readyState` is 2 or 3', (done) => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + ws.on('error', (err) => { assert.ok(err instanceof Error); assert.strictEqual( err.message, - 'WebSocket is not open: readyState 0 (CONNECTING)' + 'WebSocket was closed before the connection was established' ); - done(); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.strictEqual(ws.bufferedAmount, 0); + + ws.pong('hi'); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.pong(); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.pong('hi'); + assert.strictEqual(ws.bufferedAmount, 4); + + ws.pong(); + assert.strictEqual(ws.bufferedAmount, 4); + + done(); + }); }); + + ws.close(); }); - it('can send a pong with no data', function(done) { + it('calls the callback w/ an error if `readyState` is 2 or 3', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + ws.close(); + + assert.strictEqual(ws.bufferedAmount, 0); + + ws.pong('hi', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 2 (CLOSING)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + ws.pong((err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 3 (CLOSED)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + wss.close(done); + }); + }); + }); + }); + }); + + it('can send a pong with no data', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -800,7 +1087,7 @@ describe('WebSocket', function() { }); }); - it('can send a pong with data', function(done) { + it('can send a pong with data', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -818,7 +1105,7 @@ describe('WebSocket', function() { }); }); - it('can send numbers as pong payload', function(done) { + it('can send numbers as pong payload', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -832,10 +1119,112 @@ describe('WebSocket', function() { }); }); }); + + it('throws an error if the data size is greater than 125 bytes', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + assert.throws( + () => ws.pong(Buffer.alloc(126)), + /^RangeError: The data size must not be greater than 125 bytes$/ + ); + + wss.close(done); + }); + }); + }); }); - describe('#send', function() { - it('can send a big binary message', function(done) { + describe('#send', () => { + it('throws an error if `readyState` is `CONNECTING`', () => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + assert.throws( + () => ws.send('hi'), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + + assert.throws( + () => ws.send('hi', NOOP), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + }); + + it('increases `bufferedAmount` if `readyState` is 2 or 3', (done) => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.strictEqual(ws.bufferedAmount, 0); + + ws.send('hi'); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.send(); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.send('hi'); + assert.strictEqual(ws.bufferedAmount, 4); + + ws.send(); + assert.strictEqual(ws.bufferedAmount, 4); + + done(); + }); + }); + + ws.close(); + }); + + it('calls the callback w/ an error if `readyState` is 2 or 3', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + ws.close(); + + assert.strictEqual(ws.bufferedAmount, 0); + + ws.send('hi', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 2 (CLOSING)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + ws.send('hi', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 3 (CLOSED)' + ); + assert.strictEqual(ws.bufferedAmount, 4); + + wss.close(done); + }); + }); + }); + }); + }); + + it('can send a big binary message', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const array = new Float32Array(5 * 1024 * 1024); @@ -857,7 +1246,7 @@ describe('WebSocket', function() { }); }); - it('can send text data', function(done) { + it('can send text data', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -873,7 +1262,7 @@ describe('WebSocket', function() { }); }); - it('does not override the `fin` option', function(done) { + it('does not override the `fin` option', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -891,7 +1280,7 @@ describe('WebSocket', function() { }); }); - it('sends numbers as strings', function(done) { + it('sends numbers as strings', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -906,7 +1295,7 @@ describe('WebSocket', function() { }); }); - it('can send binary data as an array', function(done) { + it('can send binary data as an array', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const array = new Float32Array(6); @@ -915,14 +1304,15 @@ describe('WebSocket', function() { } const partial = array.subarray(2, 5); - const buf = Buffer.from(partial.buffer).slice( + const buf = Buffer.from( + partial.buffer, partial.byteOffset, - partial.byteOffset + partial.byteLength + partial.byteLength ); const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send(partial, { binary: true })); + ws.on('open', () => ws.send(partial)); ws.on('message', (message) => { assert.ok(message.equals(buf)); wss.close(done); @@ -934,12 +1324,12 @@ describe('WebSocket', function() { }); }); - it('can send binary data as a buffer', function(done) { + it('can send binary data as a buffer', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const buf = Buffer.from('foobar'); const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send(buf, { binary: true })); + ws.on('open', () => ws.send(buf)); ws.on('message', (message) => { assert.ok(message.equals(buf)); wss.close(done); @@ -951,7 +1341,7 @@ describe('WebSocket', function() { }); }); - it('can send an `ArrayBuffer`', function(done) { + it('can send an `ArrayBuffer`', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const array = new Float32Array(5); @@ -973,7 +1363,7 @@ describe('WebSocket', function() { }); }); - it('can send a `Buffer`', function(done) { + it('can send a `Buffer`', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const buf = Buffer.from('foobar'); const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -991,32 +1381,7 @@ describe('WebSocket', function() { }); }); - it('throws an error if `readyState` is not `OPEN`', function() { - const ws = new WebSocket('ws://localhost', { - agent: new CustomAgent() - }); - - assert.throws( - () => ws.send('hi'), - /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ - ); - }); - - it('passes errors to the callback, if present', function() { - const ws = new WebSocket('ws://localhost', { - agent: new CustomAgent() - }); - - ws.send('hi', (err) => { - assert.ok(err instanceof Error); - assert.strictEqual( - err.message, - 'WebSocket is not open: readyState 0 (CONNECTING)' - ); - }); - }); - - it('calls the optional callback when data is written out', function(done) { + it('calls the callback when data is written out', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1029,7 +1394,7 @@ describe('WebSocket', function() { }); }); - it('works when the `data` argument is falsy', function(done) { + it('works when the `data` argument is falsy', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1044,7 +1409,7 @@ describe('WebSocket', function() { }); }); - it('can send text data with `mask` option set to `false`', function(done) { + it('honors the `mask` option', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1052,37 +1417,34 @@ describe('WebSocket', function() { }); wss.on('connection', (ws) => { - ws.on('message', (message) => { - assert.strictEqual(message, 'hi'); - wss.close(done); - }); - }); - }); - - it('can send binary data with `mask` option set to `false`', function(done) { - const array = new Float32Array(5); - - for (let i = 0; i < array.length; ++i) { - array[i] = i / 2; - } + const chunks = []; - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + ws._socket.prependListener('data', (chunk) => { + chunks.push(chunk); + }); - ws.on('open', () => ws.send(array, { mask: false })); - }); + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: MASK must be set' + ); + assert.ok( + Buffer.concat(chunks).slice(0, 2).equals(Buffer.from('8102', 'hex')) + ); - wss.on('connection', (ws) => { - ws.on('message', (message) => { - assert.ok(message.equals(Buffer.from(array.buffer))); - wss.close(done); + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.strictEqual(reason, ''); + wss.close(done); + }); }); }); }); }); - describe('#close', function() { - it('closes the connection if called while connecting (1/2)', function(done) { + describe('#close', () => { + it('closes the connection if called while connecting (1/3)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1099,7 +1461,7 @@ describe('WebSocket', function() { }); }); - it('closes the connection if called while connecting (2/2)', function(done) { + it('closes the connection if called while connecting (2/3)', (done) => { const wss = new WebSocket.Server( { verifyClient: (info, cb) => setTimeout(cb, 300, true), @@ -1122,7 +1484,55 @@ describe('WebSocket', function() { ); }); - it('can be called from an error listener while connecting', function(done) { + it('closes the connection if called while connecting (3/3)', (done) => { + const server = http.createServer(); + + server.listen(0, function () { + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + ws.on('close', () => { + server.close(done); + }); + }); + + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 502); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'foo'); + ws.close(); + }); + }); + }); + + server.on('upgrade', (req, socket) => { + socket.on('end', socket.end); + + socket.write( + `HTTP/1.1 502 ${http.STATUS_CODES[502]}\r\n` + + 'Connection: keep-alive\r\n' + + 'Content-type: text/html\r\n' + + 'Content-Length: 3\r\n' + + '\r\n' + + 'foo' + ); + }); + }); + + it('can be called from an error listener while connecting', (done) => { const ws = new WebSocket('ws://localhost:1337'); ws.on('open', () => done(new Error("Unexpected 'open' event"))); @@ -1132,9 +1542,9 @@ describe('WebSocket', function() { ws.close(); ws.on('close', () => done()); }); - }); + }).timeout(4000); - it("can be called from a listener of the 'upgrade' event", function(done) { + it("can be called from a listener of the 'upgrade' event", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1151,7 +1561,7 @@ describe('WebSocket', function() { }); }); - it('throws an error if the first argument is invalid (1/2)', function(done) { + it('throws an error if the first argument is invalid (1/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1166,7 +1576,7 @@ describe('WebSocket', function() { }); }); - it('throws an error if the first argument is invalid (2/2)', function(done) { + it('throws an error if the first argument is invalid (2/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1181,7 +1591,22 @@ describe('WebSocket', function() { }); }); - it('sends the close status code only when necessary', function(done) { + it('throws an error if the message is greater than 123 bytes', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + assert.throws( + () => ws.close(1000, 'a'.repeat(124)), + /^RangeError: The message must not be greater than 123 bytes$/ + ); + + wss.close(done); + }); + }); + }); + + it('sends the close status code only when necessary', (done) => { let sent; const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1208,7 +1633,7 @@ describe('WebSocket', function() { }); }); - it('works when close reason is not specified', function(done) { + it('works when close reason is not specified', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1224,7 +1649,7 @@ describe('WebSocket', function() { }); }); - it('works when close reason is specified', function(done) { + it('works when close reason is specified', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1240,28 +1665,7 @@ describe('WebSocket', function() { }); }); - it('ends connection to the server', function(done) { - const wss = new WebSocket.Server( - { - clientTracking: false, - port: 0 - }, - () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - - ws.on('open', () => { - ws.on('close', (code, reason) => { - assert.strictEqual(reason, 'some reason'); - assert.strictEqual(code, 1000); - wss.close(done); - }); - ws.close(1000, 'some reason'); - }); - } - ); - }); - - it('permits all buffered data to be delivered', function(done) { + it('permits all buffered data to be delivered', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: { threshold: 0 }, @@ -1291,7 +1695,7 @@ describe('WebSocket', function() { }); }); - it('allows close code 1013', function(done) { + it('allows close code 1013', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1304,7 +1708,20 @@ describe('WebSocket', function() { wss.on('connection', (ws) => ws.close(1013)); }); - it('does nothing if `readyState` is `CLOSED`', function(done) { + it('allows close code 1014', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', (code) => { + assert.strictEqual(code, 1014); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.close(1014)); + }); + + it('does nothing if `readyState` is `CLOSED`', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1318,10 +1735,41 @@ describe('WebSocket', function() { wss.on('connection', (ws) => ws.close()); }); + + it('sets a timer for the closing handshake to complete', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1000); + assert.strictEqual(reason, 'some reason'); + wss.close(done); + }); + + ws.on('open', () => { + let callbackCalled = false; + + assert.strictEqual(ws._closeTimer, null); + + ws.send('foo', () => { + callbackCalled = true; + }); + + ws.close(1000, 'some reason'); + + // + // Check that the close timer is set even if the `Sender.close()` + // callback is not called. + // + assert.strictEqual(callbackCalled, false); + assert.strictEqual(ws._closeTimer._idleTimeout, 30000); + }); + }); + }); }); - describe('#terminate', function() { - it('closes the connection if called while connecting (1/2)', function(done) { + describe('#terminate', () => { + it('closes the connection if called while connecting (1/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1338,7 +1786,7 @@ describe('WebSocket', function() { }); }); - it('closes the connection if called while connecting (2/2)', function(done) { + it('closes the connection if called while connecting (2/2)', (done) => { const wss = new WebSocket.Server( { verifyClient: (info, cb) => setTimeout(cb, 300, true), @@ -1361,7 +1809,7 @@ describe('WebSocket', function() { ); }); - it('can be called from an error listener while connecting', function(done) { + it('can be called from an error listener while connecting', (done) => { const ws = new WebSocket('ws://localhost:1337'); ws.on('open', () => done(new Error("Unexpected 'open' event"))); @@ -1371,9 +1819,9 @@ describe('WebSocket', function() { ws.terminate(); ws.on('close', () => done()); }); - }); + }).timeout(4000); - it("can be called from a listener of the 'upgrade' event", function(done) { + it("can be called from a listener of the 'upgrade' event", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1390,7 +1838,7 @@ describe('WebSocket', function() { }); }); - it('does nothing if `readyState` is `CLOSED`', function(done) { + it('does nothing if `readyState` is `CLOSED`', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1406,9 +1854,20 @@ describe('WebSocket', function() { }); }); - describe('WHATWG API emulation', function() { - it('supports the `on{close,error,message,open}` attributes', function() { - const listener = () => {}; + describe('WHATWG API emulation', () => { + it('supports the `on{close,error,message,open}` attributes', () => { + for (const property of ['onclose', 'onerror', 'onmessage', 'onopen']) { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + property + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set !== undefined); + } + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); assert.strictEqual(ws.onmessage, undefined); @@ -1416,18 +1875,18 @@ describe('WebSocket', function() { assert.strictEqual(ws.onerror, undefined); assert.strictEqual(ws.onopen, undefined); - ws.onmessage = listener; - ws.onerror = listener; - ws.onclose = listener; - ws.onopen = listener; + ws.onmessage = NOOP; + ws.onerror = NOOP; + ws.onclose = NOOP; + ws.onopen = NOOP; - assert.strictEqual(ws.onmessage, listener); - assert.strictEqual(ws.onclose, listener); - assert.strictEqual(ws.onerror, listener); - assert.strictEqual(ws.onopen, listener); + assert.strictEqual(ws.onmessage, NOOP); + assert.strictEqual(ws.onclose, NOOP); + assert.strictEqual(ws.onerror, NOOP); + assert.strictEqual(ws.onopen, NOOP); }); - it('works like the `EventEmitter` interface', function(done) { + it('works like the `EventEmitter` interface', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1450,44 +1909,41 @@ describe('WebSocket', function() { }); }); - it("doesn't return listeners added with `on`", function() { - const listener = () => {}; + it("doesn't return listeners added with `on`", () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - ws.on('open', listener); + ws.on('open', NOOP); - assert.deepStrictEqual(ws.listeners('open'), [listener]); + assert.deepStrictEqual(ws.listeners('open'), [NOOP]); assert.strictEqual(ws.onopen, undefined); }); - it("doesn't remove listeners added with `on`", function() { - const listener = () => {}; + it("doesn't remove listeners added with `on`", () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - ws.on('close', listener); - ws.onclose = listener; + ws.on('close', NOOP); + ws.onclose = NOOP; let listeners = ws.listeners('close'); assert.strictEqual(listeners.length, 2); - assert.strictEqual(listeners[0], listener); - assert.strictEqual(listeners[1]._listener, listener); + assert.strictEqual(listeners[0], NOOP); + assert.strictEqual(listeners[1]._listener, NOOP); - ws.onclose = listener; + ws.onclose = NOOP; listeners = ws.listeners('close'); assert.strictEqual(listeners.length, 2); - assert.strictEqual(listeners[0], listener); - assert.strictEqual(listeners[1]._listener, listener); + assert.strictEqual(listeners[0], NOOP); + assert.strictEqual(listeners[1]._listener, NOOP); }); - it('adds listeners for custom events with `addEventListener`', function() { - const listener = () => {}; + it('adds listeners for custom events with `addEventListener`', () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - ws.addEventListener('foo', listener); - assert.strictEqual(ws.listeners('foo')[0], listener); + ws.addEventListener('foo', NOOP); + assert.strictEqual(ws.listeners('foo')[0], NOOP); // // Fails silently when the `listener` is not a function. @@ -1496,32 +1952,67 @@ describe('WebSocket', function() { assert.strictEqual(ws.listeners('bar').length, 0); }); - it('supports the `removeEventListener` method', function() { - const listener = () => {}; + it('allows to add one time listeners with `addEventListener`', (done) => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener( + 'foo', + () => { + assert.strictEqual(ws.listenerCount('foo'), 0); + done(); + }, + { once: true } + ); + + assert.strictEqual(ws.listenerCount('foo'), 1); + ws.emit('foo'); + }); + + it('supports the `removeEventListener` method', () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - ws.addEventListener('message', listener); - ws.addEventListener('open', listener); - ws.addEventListener('foo', listener); + ws.addEventListener('message', NOOP); + ws.addEventListener('open', NOOP); + ws.addEventListener('foo', NOOP); + + assert.strictEqual(ws.listeners('message')[0]._listener, NOOP); + assert.strictEqual(ws.listeners('open')[0]._listener, NOOP); + assert.strictEqual(ws.listeners('foo')[0], NOOP); + + ws.removeEventListener('message', () => {}); + + assert.strictEqual(ws.listeners('message')[0]._listener, NOOP); + + ws.removeEventListener('message', NOOP); + ws.removeEventListener('open', NOOP); + ws.removeEventListener('foo', NOOP); + + assert.strictEqual(ws.listenerCount('message'), 0); + assert.strictEqual(ws.listenerCount('open'), 0); + assert.strictEqual(ws.listenerCount('foo'), 0); + + ws.addEventListener('message', NOOP, { once: true }); + ws.addEventListener('open', NOOP, { once: true }); + ws.addEventListener('foo', NOOP, { once: true }); - assert.strictEqual(ws.listeners('message')[0]._listener, listener); - assert.strictEqual(ws.listeners('open')[0]._listener, listener); - assert.strictEqual(ws.listeners('foo')[0], listener); + assert.strictEqual(ws.listeners('message')[0]._listener, NOOP); + assert.strictEqual(ws.listeners('open')[0]._listener, NOOP); + assert.strictEqual(ws.listeners('foo')[0], NOOP); ws.removeEventListener('message', () => {}); - assert.strictEqual(ws.listeners('message')[0]._listener, listener); + assert.strictEqual(ws.listeners('message')[0]._listener, NOOP); - ws.removeEventListener('message', listener); - ws.removeEventListener('open', listener); - ws.removeEventListener('foo', listener); + ws.removeEventListener('message', NOOP); + ws.removeEventListener('open', NOOP); + ws.removeEventListener('foo', NOOP); assert.strictEqual(ws.listenerCount('message'), 0); assert.strictEqual(ws.listenerCount('open'), 0); assert.strictEqual(ws.listenerCount('foo'), 0); }); - it('wraps text data in a `MessageEvent`', function(done) { + it('wraps text data in a `MessageEvent`', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1537,7 +2028,7 @@ describe('WebSocket', function() { }); }); - it('receives a `CloseEvent` when server closes (1000)', function(done) { + it('receives a `CloseEvent` when server closes (1000)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1552,7 +2043,7 @@ describe('WebSocket', function() { wss.on('connection', (ws) => ws.close(1000)); }); - it('receives a `CloseEvent` when server closes (4000)', function(done) { + it('receives a `CloseEvent` when server closes (4000)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1567,7 +2058,7 @@ describe('WebSocket', function() { wss.on('connection', (ws) => ws.close(4000, 'some daft reason')); }); - it('sets `target` and `type` on events', function(done) { + it('sets `target` and `type` on events', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const err = new Error('forced'); const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1599,7 +2090,7 @@ describe('WebSocket', function() { wss.on('connection', (client) => client.send('hi')); }); - it('passes binary data as a Node.js `Buffer` by default', function(done) { + it('passes binary data as a Node.js `Buffer` by default', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1612,7 +2103,7 @@ describe('WebSocket', function() { wss.on('connection', (ws) => ws.send(new Uint8Array(4096))); }); - it('ignores `binaryType` for text messages', function(done) { + it('ignores `binaryType` for text messages', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1627,7 +2118,7 @@ describe('WebSocket', function() { wss.on('connection', (ws) => ws.send('foo')); }); - it('allows to update `binaryType` on the fly', function(done) { + it('allows to update `binaryType` on the fly', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1666,8 +2157,8 @@ describe('WebSocket', function() { }); }); - describe('SSL', function() { - it('connects to secure websocket server', function(done) { + describe('SSL', () => { + it('connects to secure websocket server', (done) => { const server = https.createServer({ cert: fs.readFileSync('test/fixtures/certificate.pem'), key: fs.readFileSync('test/fixtures/key.pem') @@ -1680,13 +2171,13 @@ describe('WebSocket', function() { }); server.listen(0, () => { - const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + const ws = new WebSocket(`wss://127.0.0.1:${server.address().port}`, { rejectUnauthorized: false }); }); }); - it('connects to secure websocket server with client side certificate', function(done) { + it('connects to secure websocket server with client side certificate', (done) => { const server = https.createServer({ cert: fs.readFileSync('test/fixtures/certificate.pem'), ca: [fs.readFileSync('test/fixtures/ca1-cert.pem')], @@ -1718,7 +2209,7 @@ describe('WebSocket', function() { }); }); - it('cannot connect to secure websocket server via ws://', function(done) { + it('cannot connect to secure websocket server via ws://', (done) => { const server = https.createServer({ cert: fs.readFileSync('test/fixtures/certificate.pem'), key: fs.readFileSync('test/fixtures/key.pem') @@ -1737,7 +2228,7 @@ describe('WebSocket', function() { }); }); - it('can send and receive text data', function(done) { + it('can send and receive text data', (done) => { const server = https.createServer({ cert: fs.readFileSync('test/fixtures/certificate.pem'), key: fs.readFileSync('test/fixtures/key.pem') @@ -1761,9 +2252,7 @@ describe('WebSocket', function() { }); }); - it('can send a big binary message', function(done) { - this.timeout(4000); - + it('can send a big binary message', (done) => { const buf = crypto.randomBytes(5 * 1024 * 1024); const server = https.createServer({ cert: fs.readFileSync('test/fixtures/certificate.pem'), @@ -1784,6 +2273,51 @@ describe('WebSocket', function() { ws.on('message', (message) => { assert.ok(buf.equals(message)); + server.close(done); + wss.close(); + }); + }); + }).timeout(4000); + + it('allows to disable sending the SNI extension', (done) => { + const original = tls.connect; + + tls.connect = (options) => { + assert.strictEqual(options.servername, ''); + tls.connect = original; + done(); + }; + + const ws = new WebSocket('wss://127.0.0.1', { servername: '' }); + }); + + it("works around a double 'error' event bug in Node.js", function (done) { + // + // The `minVersion` and `maxVersion` options are not supported in + // Node.js < 10.16.0. + // + if (process.versions.modules < 64) return this.skip(); + + // + // The `'error'` event can be emitted multiple times by the + // `http.ClientRequest` object in Node.js < 13. This test reproduces the + // issue in Node.js 12. + // + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem'), + minVersion: 'TLSv1.2' + }); + const wss = new WebSocket.Server({ server }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + maxVersion: 'TLSv1.1', + rejectUnauthorized: false + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); server.close(done); wss.close(); }); @@ -1791,46 +2325,58 @@ describe('WebSocket', function() { }); }); - describe('Request headers', function() { - it('adds the authorization header if the url has userinfo (1/2)', function(done) { + describe('Request headers', () => { + it('adds the authorization header if the url has userinfo', (done) => { const agent = new CustomAgent(); - const auth = 'test:testpass'; + const userinfo = 'test:testpass'; agent.addRequest = (req) => { assert.strictEqual( - req._headers.authorization, - `Basic ${Buffer.from(auth).toString('base64')}` + req.getHeader('authorization'), + `Basic ${Buffer.from(userinfo).toString('base64')}` ); done(); }; - const ws = new WebSocket(`ws://${auth}@localhost`, { agent }); + const ws = new WebSocket(`ws://${userinfo}@localhost`, { agent }); }); - it('adds the authorization header if the url has userinfo (2/2)', function(done) { - if (!url.URL) return this.skip(); - + it('honors the `auth` option', (done) => { const agent = new CustomAgent(); - const auth = 'test:testpass'; + const auth = 'user:pass'; agent.addRequest = (req) => { assert.strictEqual( - req._headers.authorization, + req.getHeader('authorization'), `Basic ${Buffer.from(auth).toString('base64')}` ); done(); }; - const ws = new WebSocket(new url.URL(`ws://${auth}@localhost`), { - agent - }); + const ws = new WebSocket('ws://localhost', { agent, auth }); + }); + + it('favors the url userinfo over the `auth` option', (done) => { + const agent = new CustomAgent(); + const auth = 'foo:bar'; + const userinfo = 'baz:qux'; + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('authorization'), + `Basic ${Buffer.from(userinfo).toString('base64')}` + ); + done(); + }; + + const ws = new WebSocket(`ws://${userinfo}@localhost`, { agent, auth }); }); - it('adds custom headers', function(done) { + it('adds custom headers', (done) => { const agent = new CustomAgent(); agent.addRequest = (req) => { - assert.strictEqual(req._headers.cookie, 'foo=bar'); + assert.strictEqual(req.getHeader('cookie'), 'foo=bar'); done(); }; @@ -1840,7 +2386,7 @@ describe('WebSocket', function() { }); }); - it('excludes default ports from host header', function() { + it('excludes default ports from host header', () => { const options = { lookup() {} }; const variants = [ ['wss://localhost:8443', 'localhost:8443'], @@ -1851,26 +2397,26 @@ describe('WebSocket', function() { for (const [url, host] of variants) { const ws = new WebSocket(url, options); - assert.strictEqual(ws._req._headers.host, host); + assert.strictEqual(ws._req.getHeader('host'), host); } }); - it("doesn't add the origin header by default", function(done) { + it("doesn't add the origin header by default", (done) => { const agent = new CustomAgent(); agent.addRequest = (req) => { - assert.strictEqual(req._headers.origin, undefined); + assert.strictEqual(req.getHeader('origin'), undefined); done(); }; const ws = new WebSocket('ws://localhost', { agent }); }); - it('honors the `origin` option (1/2)', function(done) { + it('honors the `origin` option (1/2)', (done) => { const agent = new CustomAgent(); agent.addRequest = (req) => { - assert.strictEqual(req._headers.origin, 'https://example.com:8000'); + assert.strictEqual(req.getHeader('origin'), 'https://example.com:8000'); done(); }; @@ -1880,12 +2426,12 @@ describe('WebSocket', function() { }); }); - it('honors the `origin` option (2/2)', function(done) { + it('honors the `origin` option (2/2)', (done) => { const agent = new CustomAgent(); agent.addRequest = (req) => { assert.strictEqual( - req._headers['sec-websocket-origin'], + req.getHeader('sec-websocket-origin'), 'https://example.com:8000' ); done(); @@ -1899,13 +2445,13 @@ describe('WebSocket', function() { }); }); - describe('permessage-deflate', function() { + describe('permessage-deflate', () => { it('is enabled by default', (done) => { const agent = new CustomAgent(); agent.addRequest = (req) => { assert.strictEqual( - req._headers['sec-websocket-extensions'], + req.getHeader('sec-websocket-extensions'), 'permessage-deflate; client_max_window_bits' ); done(); @@ -1914,11 +2460,14 @@ describe('WebSocket', function() { const ws = new WebSocket('ws://localhost', { agent }); }); - it('can be disabled', function(done) { + it('can be disabled', (done) => { const agent = new CustomAgent(); agent.addRequest = (req) => { - assert.strictEqual(req._headers['sec-websocket-extensions'], undefined); + assert.strictEqual( + req.getHeader('sec-websocket-extensions'), + undefined + ); done(); }; @@ -1928,7 +2477,7 @@ describe('WebSocket', function() { }); }); - it('can send extension parameters', function(done) { + it('can send extension parameters', (done) => { const agent = new CustomAgent(); const value = @@ -1937,7 +2486,7 @@ describe('WebSocket', function() { ' client_max_window_bits'; agent.addRequest = (req) => { - assert.strictEqual(req._headers['sec-websocket-extensions'], value); + assert.strictEqual(req.getHeader('sec-websocket-extensions'), value); done(); }; @@ -1952,7 +2501,7 @@ describe('WebSocket', function() { }); }); - it('can send and receive text data', function(done) { + it('can send and receive text data', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: { threshold: 0 }, @@ -1976,7 +2525,7 @@ describe('WebSocket', function() { }); }); - it('can send and receive a `TypedArray`', function(done) { + it('can send and receive a `TypedArray`', (done) => { const array = new Float32Array(5); for (let i = 0; i < array.length; i++) { @@ -2006,7 +2555,7 @@ describe('WebSocket', function() { }); }); - it('can send and receive an `ArrayBuffer`', function(done) { + it('can send and receive an `ArrayBuffer`', (done) => { const array = new Float32Array(5); for (let i = 0; i < array.length; i++) { @@ -2036,7 +2585,7 @@ describe('WebSocket', function() { }); }); - it('consumes all received data when connection is closed abnormally', function(done) { + it('consumes all received data when connection is closed abnormally', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: { threshold: 0 }, @@ -2063,8 +2612,8 @@ describe('WebSocket', function() { }); }); - describe('#send', function() { - it('ignores the `compress` option if the extension is disabled', function(done) { + describe('#send', () => { + it('ignores the `compress` option if the extension is disabled', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { perMessageDeflate: false @@ -2081,10 +2630,56 @@ describe('WebSocket', function() { ws.on('message', (message) => ws.send(message, { compress: true })); }); }); + + it('calls the callback if the socket is closed prematurely', (done) => { + const wss = new WebSocket.Server( + { perMessageDeflate: true, port: 0 }, + () => { + const called = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send('foo'); + ws.send('bar', (err) => { + called.push(1); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The socket was closed while data was being compressed' + ); + }); + ws.send('baz'); + ws.send('qux', (err) => { + called.push(2); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The socket was closed while data was being compressed' + ); + }); + }); + + ws.on('close', () => { + assert.deepStrictEqual(called, [1, 2]); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws._socket.end(); + }); + }); }); - describe('#terminate', function() { - it('can be used while data is being compressed', function(done) { + describe('#terminate', () => { + it('can be used while data is being compressed', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: { threshold: 0 }, @@ -2096,22 +2691,25 @@ describe('WebSocket', function() { }); ws.on('open', () => { - ws.send('hi', () => - done(new Error('Unexpected callback invocation')) - ); + ws.send('hi', (err) => { + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The socket was closed while data was being compressed' + ); + + ws.on('close', () => { + wss.close(done); + }); + }); ws.terminate(); }); } ); - - wss.on('connection', (ws) => { - ws.on('close', () => { - wss.close(done); - }); - }); }); - it('can be used while data is being decompressed', function(done) { + it('can be used while data is being decompressed', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: true,