diff options
Diffstat (limited to '')
57 files changed, 15703 insertions, 0 deletions
diff --git a/testing/xpcshell/node-ws/.eslintrc.yaml b/testing/xpcshell/node-ws/.eslintrc.yaml new file mode 100644 index 0000000000..f3d983b9c8 --- /dev/null +++ b/testing/xpcshell/node-ws/.eslintrc.yaml @@ -0,0 +1,19 @@ +env: + browser: true + es6: true + mocha: true + node: true +extends: + - eslint:recommended + - plugin:prettier/recommended +parserOptions: + ecmaVersion: latest + sourceType: module +rules: + no-console: off + no-var: error + prefer-const: error + quotes: + - error + - single + - avoidEscape: true diff --git a/testing/xpcshell/node-ws/.gitignore b/testing/xpcshell/node-ws/.gitignore new file mode 100644 index 0000000000..e37ab1e942 --- /dev/null +++ b/testing/xpcshell/node-ws/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.nyc_output/ +coverage/ +.vscode/ diff --git a/testing/xpcshell/node-ws/.npmrc b/testing/xpcshell/node-ws/.npmrc new file mode 100644 index 0000000000..43c97e719a --- /dev/null +++ b/testing/xpcshell/node-ws/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/testing/xpcshell/node-ws/.prettierrc.yaml b/testing/xpcshell/node-ws/.prettierrc.yaml new file mode 100644 index 0000000000..fe2f506e34 --- /dev/null +++ b/testing/xpcshell/node-ws/.prettierrc.yaml @@ -0,0 +1,5 @@ +arrowParens: always +endOfLine: lf +proseWrap: always +singleQuote: true +trailingComma: none diff --git a/testing/xpcshell/node-ws/LICENSE b/testing/xpcshell/node-ws/LICENSE new file mode 100644 index 0000000000..65ff176bf6 --- /dev/null +++ b/testing/xpcshell/node-ws/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/testing/xpcshell/node-ws/README.md b/testing/xpcshell/node-ws/README.md new file mode 100644 index 0000000000..4ae71f6d06 --- /dev/null +++ b/testing/xpcshell/node-ws/README.md @@ -0,0 +1,495 @@ +# ws: a Node.js WebSocket library + +[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws) +[![CI](https://img.shields.io/github/workflow/status/websockets/ws/CI/master?label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) +[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws) + +ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and +server implementation. + +Passes the quite extensive Autobahn test suite: [server][server-report], +[client][client-report]. + +**Note**: This module does not work in the browser. The client in the docs is a +reference to a back end with the role of a client in the WebSocket +communication. Browser clients must use the native +[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) +object. To make the same code work seamlessly on Node.js and the browser, you +can use one of the many wrappers available on npm, like +[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws). + +## Table of Contents + +- [Protocol support](#protocol-support) +- [Installing](#installing) + - [Opt-in for performance](#opt-in-for-performance) +- [API docs](#api-docs) +- [WebSocket compression](#websocket-compression) +- [Usage examples](#usage-examples) + - [Sending and receiving text data](#sending-and-receiving-text-data) + - [Sending binary data](#sending-binary-data) + - [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) + - [Round-trip time](#round-trip-time) + - [Use the Node.js streams API](#use-the-nodejs-streams-api) + - [Other examples](#other-examples) +- [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) + - [How to connect via a proxy?](#how-to-connect-via-a-proxy) +- [Changelog](#changelog) +- [License](#license) + +## Protocol support + +- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`) +- **HyBi drafts 13-17** (Current default, alternatively option + `protocolVersion: 13`) + +## Installing + +``` +npm install ws +``` + +### 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. +Prebuilt binaries are available for the most popular platforms so you don't +necessarily need to have a C++ compiler installed on your machine. + +- `npm install --save-optional bufferutil`: Allows to efficiently perform + 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. + +To not even try to require and use these modules, use the +[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) and +[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment +variables. These might be useful to enhance security in systems where a user can +put a package in the package search path of an application of another user, due +to how the Node.js resolver algorithm works. + +## API docs + +See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and +utility functions. + +## WebSocket compression + +ws supports the [permessage-deflate extension][permessage-deflate] which enables +the client and server to negotiate a compression algorithm and its parameters, +and then selectively apply it to the data payloads of each WebSocket message. + +The extension is disabled by default on the server and enabled by default on the +client. It adds a significant overhead in terms of performance and memory +consumption so we suggest to enable it only if it is really needed. + +Note that Node.js has a variety of issues with high-performance compression, +where increased concurrency, especially on Linux, can lead to [catastrophic +memory fragmentation][node-zlib-bug] and slow performance. If you intend to use +permessage-deflate in production, it is worthwhile to set up a test +representative of your workload and ensure Node.js/zlib will handle it with +acceptable performance and memory usage. + +Tuning of permessage-deflate can be done via the options defined below. You can +also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly +into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs]. + +See [the docs][ws-server-options] for more options. + +```js +import WebSocket, { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ + port: 8080, + perMessageDeflate: { + zlibDeflateOptions: { + // See zlib defaults. + chunkSize: 1024, + memLevel: 7, + level: 3 + }, + zlibInflateOptions: { + chunkSize: 10 * 1024 + }, + // Other options settable: + clientNoContextTakeover: true, // Defaults to negotiated value. + serverNoContextTakeover: true, // Defaults to negotiated value. + serverMaxWindowBits: 10, // Defaults to negotiated value. + // Below options specified as default values. + concurrencyLimit: 10, // Limits zlib concurrency for perf. + threshold: 1024 // Size (in bytes) below which messages + // should not be compressed if context takeover is disabled. + } +}); +``` + +The client will only use the extension if it is supported and enabled on the +server. To always disable the extension on the client set the +`perMessageDeflate` option to `false`. + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://www.host.com/path', { + perMessageDeflate: false +}); +``` + +## Usage examples + +### Sending and receiving text data + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://www.host.com/path'); + +ws.on('open', function open() { + ws.send('something'); +}); + +ws.on('message', function message(data) { + console.log('received: %s', data); +}); +``` + +### Sending binary data + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://www.host.com/path'); + +ws.on('open', function open() { + const array = new Float32Array(5); + + for (var i = 0; i < array.length; ++i) { + array[i] = i / 2; + } + + ws.send(array); +}); +``` + +### Simple server + +```js +import { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('message', function message(data) { + console.log('received: %s', data); + }); + + ws.send('something'); +}); +``` + +### External HTTP/S server + +```js +import { createServer } from 'https'; +import { readFileSync } from 'fs'; +import { WebSocketServer } from 'ws'; + +const server = createServer({ + cert: readFileSync('/path/to/cert.pem'), + key: readFileSync('/path/to/key.pem') +}); +const wss = new WebSocketServer({ server }); + +wss.on('connection', function connection(ws) { + ws.on('message', function message(data) { + console.log('received: %s', data); + }); + + ws.send('something'); +}); + +server.listen(8080); +``` + +### Multiple servers sharing a single HTTP/S server + +```js +import { createServer } from 'http'; +import { parse } from 'url'; +import { WebSocketServer } from 'ws'; + +const server = createServer(); +const wss1 = new WebSocketServer({ noServer: true }); +const wss2 = new WebSocketServer({ noServer: true }); + +wss1.on('connection', function connection(ws) { + // ... +}); + +wss2.on('connection', function connection(ws) { + // ... +}); + +server.on('upgrade', function upgrade(request, socket, head) { + const { pathname } = parse(request.url); + + if (pathname === '/foo') { + wss1.handleUpgrade(request, socket, head, function done(ws) { + wss1.emit('connection', ws, request); + }); + } else if (pathname === '/bar') { + wss2.handleUpgrade(request, socket, head, function done(ws) { + wss2.emit('connection', ws, request); + }); + } else { + socket.destroy(); + } +}); + +server.listen(8080); +``` + +### Client authentication + +```js +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; + +const server = createServer(); +const wss = new WebSocketServer({ noServer: true }); + +wss.on('connection', function connection(ws, request, client) { + ws.on('message', function message(data) { + console.log(`Received message ${data} 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, function next(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 +import WebSocket, { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('message', function message(data, isBinary) { + wss.clients.forEach(function each(client) { + if (client.readyState === WebSocket.OPEN) { + client.send(data, { binary: isBinary }); + } + }); + }); +}); +``` + +A client WebSocket broadcasting to every other connected WebSocket clients, +excluding itself. + +```js +import WebSocket, { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('message', function message(data, isBinary) { + wss.clients.forEach(function each(client) { + if (client !== ws && client.readyState === WebSocket.OPEN) { + client.send(data, { binary: isBinary }); + } + }); + }); +}); +``` + +### Round-trip time + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('wss://websocket-echo.com/'); + +ws.on('open', function open() { + console.log('connected'); + ws.send(Date.now()); +}); + +ws.on('close', function close() { + console.log('disconnected'); +}); + +ws.on('message', function message(data) { + console.log(`Round-trip time: ${Date.now() - data} ms`); + + setTimeout(function timeout() { + ws.send(Date.now()); + }, 500); +}); +``` + +### Use the Node.js streams API + +```js +import WebSocket, { createWebSocketStream } from 'ws'; + +const ws = new WebSocket('wss://websocket-echo.com/'); + +const duplex = 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 +examples folder. + +Otherwise, see the test cases. + +## FAQ + +### How to get the IP address of the client? + +The remote IP address can be obtained from the raw socket. + +```js +import { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws, req) { + const ip = req.socket.remoteAddress; +}); +``` + +When the server runs behind a proxy like NGINX, the de-facto standard is to use +the `X-Forwarded-For` header. + +```js +wss.on('connection', function connection(ws, req) { + const ip = req.headers['x-forwarded-for'].split(',')[0].trim(); +}); +``` + +### How to detect and close broken connections? + +Sometimes the link between the server and the client can be interrupted in a way +that keeps both the server and the client unaware of the broken state of the +connection (e.g. when pulling the cord). + +In these cases ping messages can be used as a means to verify that the remote +endpoint is still responsive. + +```js +import { WebSocketServer } from 'ws'; + +function heartbeat() { + this.isAlive = true; +} + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.isAlive = true; + ws.on('pong', heartbeat); +}); + +const interval = setInterval(function ping() { + wss.clients.forEach(function each(ws) { + if (ws.isAlive === false) return ws.terminate(); + + ws.isAlive = false; + ws.ping(); + }); +}, 30000); + +wss.on('close', function close() { + clearInterval(interval); +}); +``` + +Pong messages are automatically sent in response to ping messages as required by +the spec. + +Just like the server example above your clients might as well lose connection +without knowing it. You might want to add a ping listener on your clients to +prevent that. A simple implementation would be: + +```js +import WebSocket from 'ws'; + +function heartbeat() { + clearTimeout(this.pingTimeout); + + // 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); +} + +const client = new WebSocket('wss://websocket-echo.com/'); + +client.on('open', heartbeat); +client.on('ping', heartbeat); +client.on('close', function clear() { + clearTimeout(this.pingTimeout); +}); +``` + +### How to connect via a proxy? + +Use a custom `http.Agent` implementation like [https-proxy-agent][] or +[socks-proxy-agent][]. + +## Changelog + +We're using the GitHub [releases][changelog] for changelog entries. + +## License + +[MIT](LICENSE) + +[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]: ./doc/ws.md#new-websocketserveroptions-callback diff --git a/testing/xpcshell/node-ws/SECURITY.md b/testing/xpcshell/node-ws/SECURITY.md new file mode 100644 index 0000000000..0baf19a63b --- /dev/null +++ b/testing/xpcshell/node-ws/SECURITY.md @@ -0,0 +1,39 @@ +# Security Guidelines + +Please contact us directly at **security@3rd-Eden.com** for any bug that might +impact the security of this project. Please prefix the subject of your email +with `[security]` in lowercase and square brackets. Our email filters will +automatically prevent these messages from being moved to our spam box. + +You will receive an acknowledgement of your report within **24 hours**. + +All emails that do not include security vulnerabilities will be removed and +blocked instantly. + +## Exceptions + +If you do not receive an acknowledgement within the said time frame please give +us the benefit of the doubt as it's possible that we haven't seen it yet. In +this case please send us a message **without details** using one of the +following methods: + +- Contact the lead developers of this project on their personal e-mails. You can + find the e-mails in the git logs, for example using the following command: + `git --no-pager show -s --format='%an <%ae>' <gitsha>` where `<gitsha>` is the + SHA1 of their latest commit in the project. +- Create a GitHub issue stating contact details and the severity of the issue. + +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 create and +publish a security advisory to +[GitHub Security Advisories](https://github.com/websockets/ws/security/advisories?state=published). + +## History + +- 04 Jan 2016: + [Buffer vulnerability](https://github.com/websockets/ws/releases/tag/1.0.1) +- 08 Nov 2017: + [DoS vulnerability](https://github.com/websockets/ws/releases/tag/3.3.1) +- 25 May 2021: + [ReDoS in `Sec-Websocket-Protocol` header](https://github.com/websockets/ws/releases/tag/7.4.6) diff --git a/testing/xpcshell/node-ws/bench/parser.benchmark.js b/testing/xpcshell/node-ws/bench/parser.benchmark.js new file mode 100644 index 0000000000..a6e359d05d --- /dev/null +++ b/testing/xpcshell/node-ws/bench/parser.benchmark.js @@ -0,0 +1,95 @@ +'use strict'; + +const benchmark = require('benchmark'); +const crypto = require('crypto'); + +const WebSocket = require('..'); + +const Receiver = WebSocket.Receiver; +const Sender = WebSocket.Sender; + +const options = { + fin: true, + rsv1: false, + mask: true, + readOnly: false +}; + +function createBinaryFrame(length) { + const list = Sender.frame(crypto.randomBytes(length), { + opcode: 0x02, + ...options + }); + + return Buffer.concat(list); +} + +const pingFrame1 = Buffer.concat( + Sender.frame(crypto.randomBytes(5), { opcode: 0x09, ...options }) +); + +const textFrame = Buffer.from('819461616161' + '61'.repeat(20), '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({ + binaryType: 'nodebuffer', + extensions: {}, + isServer: true, + skipUTF8Validation: false +}); + +suite.add('ping frame (5 bytes payload)', { + defer: true, + fn: (deferred) => { + receiver.write(pingFrame1, deferred.resolve.bind(deferred)); + } +}); +suite.add('ping frame (no payload)', { + defer: true, + fn: (deferred) => { + receiver.write(pingFrame2, deferred.resolve.bind(deferred)); + } +}); +suite.add('text frame (20 bytes payload)', { + defer: true, + fn: (deferred) => { + receiver.write(textFrame, deferred.resolve.bind(deferred)); + } +}); +suite.add('binary frame (125 bytes payload)', { + defer: true, + fn: (deferred) => { + receiver.write(binaryFrame1, deferred.resolve.bind(deferred)); + } +}); +suite.add('binary frame (65535 bytes payload)', { + defer: true, + fn: (deferred) => { + receiver.write(binaryFrame2, deferred.resolve.bind(deferred)); + } +}); +suite.add('binary frame (200 KiB payload)', { + defer: true, + fn: (deferred) => { + receiver.write(binaryFrame3, deferred.resolve.bind(deferred)); + } +}); +suite.add('binary frame (1 MiB payload)', { + defer: true, + fn: (deferred) => { + receiver.write(binaryFrame4, deferred.resolve.bind(deferred)); + } +}); + +suite.on('cycle', (e) => console.log(e.target.toString())); + +if (require.main === module) { + suite.run({ async: true }); +} else { + module.exports = suite; +} diff --git a/testing/xpcshell/node-ws/bench/sender.benchmark.js b/testing/xpcshell/node-ws/bench/sender.benchmark.js new file mode 100644 index 0000000000..89d3be24b0 --- /dev/null +++ b/testing/xpcshell/node-ws/bench/sender.benchmark.js @@ -0,0 +1,48 @@ +'use strict'; + +const benchmark = require('benchmark'); +const crypto = require('crypto'); + +const Sender = require('../').Sender; + +const data1 = crypto.randomBytes(64); +const data2 = crypto.randomBytes(16 * 1024); +const data3 = crypto.randomBytes(64 * 1024); +const data4 = crypto.randomBytes(200 * 1024); +const data5 = crypto.randomBytes(1024 * 1024); + +const opts1 = { + readOnly: false, + mask: false, + rsv1: false, + opcode: 2, + fin: true +}; +const opts2 = { + readOnly: true, + rsv1: false, + mask: true, + opcode: 2, + fin: true +}; + +const suite = new benchmark.Suite(); + +suite.add('frame, unmasked (64 B)', () => Sender.frame(data1, opts1)); +suite.add('frame, masked (64 B)', () => Sender.frame(data1, opts2)); +suite.add('frame, unmasked (16 KiB)', () => Sender.frame(data2, opts1)); +suite.add('frame, masked (16 KiB)', () => Sender.frame(data2, opts2)); +suite.add('frame, unmasked (64 KiB)', () => Sender.frame(data3, opts1)); +suite.add('frame, masked (64 KiB)', () => Sender.frame(data3, opts2)); +suite.add('frame, unmasked (200 KiB)', () => Sender.frame(data4, opts1)); +suite.add('frame, masked (200 KiB)', () => Sender.frame(data4, opts2)); +suite.add('frame, unmasked (1 MiB)', () => Sender.frame(data5, opts1)); +suite.add('frame, masked (1 MiB)', () => Sender.frame(data5, opts2)); + +suite.on('cycle', (e) => console.log(e.target.toString())); + +if (require.main === module) { + suite.run({ async: true }); +} else { + module.exports = suite; +} diff --git a/testing/xpcshell/node-ws/bench/speed.js b/testing/xpcshell/node-ws/bench/speed.js new file mode 100644 index 0000000000..bef6a30679 --- /dev/null +++ b/testing/xpcshell/node-ws/bench/speed.js @@ -0,0 +1,115 @@ +'use strict'; + +const cluster = require('cluster'); +const http = require('http'); + +const WebSocket = require('..'); + +const port = 8181; +const path = ''; +// const path = '/tmp/wss.sock'; + +if (cluster.isMaster) { + const server = http.createServer(); + const wss = new WebSocket.Server({ + maxPayload: 600 * 1024 * 1024, + perMessageDeflate: false, + clientTracking: false, + server + }); + + wss.on('connection', (ws) => { + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); + }); + + server.listen(path ? { path } : { port }, () => cluster.fork()); + + cluster.on('exit', () => { + wss.close(); + server.close(); + }); +} else { + const configs = [ + [true, 10000, 64], + [true, 5000, 16 * 1024], + [true, 1000, 128 * 1024], + [true, 100, 1024 * 1024], + [true, 1, 500 * 1024 * 1024], + [false, 10000, 64], + [false, 5000, 16 * 1024], + [false, 1000, 128 * 1024], + [false, 100, 1024 * 1024] + ]; + + const roundPrec = (num, prec) => { + const mul = Math.pow(10, prec); + return Math.round(num * mul) / mul; + }; + + const humanSize = (bytes) => { + if (bytes >= 1073741824) return roundPrec(bytes / 1073741824, 2) + ' GiB'; + if (bytes >= 1048576) return roundPrec(bytes / 1048576, 2) + ' MiB'; + if (bytes >= 1024) return roundPrec(bytes / 1024, 2) + ' KiB'; + return roundPrec(bytes, 2) + ' B'; + }; + + const largest = configs.reduce( + (prev, curr) => (curr[2] > prev ? curr[2] : prev), + 0 + ); + console.log('Generating %s of test data...', humanSize(largest)); + const randomBytes = Buffer.allocUnsafe(largest); + + for (let i = 0; i < largest; ++i) { + randomBytes[i] = ~~(Math.random() * 127); + } + + console.log(`Testing ws on ${path || '[::]:' + port}`); + + const runConfig = (useBinary, roundtrips, size, cb) => { + const data = randomBytes.slice(0, size); + const url = path ? `ws+unix://${path}` : `ws://localhost:${port}`; + const ws = new WebSocket(url, { + maxPayload: 600 * 1024 * 1024 + }); + let roundtrip = 0; + let time; + + ws.on('error', (err) => { + console.error(err.stack); + cluster.worker.disconnect(); + }); + ws.on('open', () => { + time = process.hrtime(); + ws.send(data, { binary: useBinary }); + }); + ws.on('message', () => { + if (++roundtrip !== roundtrips) + return ws.send(data, { binary: useBinary }); + + let elapsed = process.hrtime(time); + elapsed = elapsed[0] * 1e9 + elapsed[1]; + + console.log( + '%d roundtrips of %s %s data:\t%ss\t%s', + roundtrips, + humanSize(size), + useBinary ? 'binary' : 'text', + roundPrec(elapsed / 1e9, 1), + humanSize(((size * 2 * roundtrips) / elapsed) * 1e9) + '/s' + ); + + ws.close(); + cb(); + }); + }; + + (function run() { + if (configs.length === 0) return cluster.worker.disconnect(); + const config = configs.shift(); + config.push(run); + runConfig.apply(null, config); + })(); +} diff --git a/testing/xpcshell/node-ws/browser.js b/testing/xpcshell/node-ws/browser.js new file mode 100644 index 0000000000..ca4f628ac1 --- /dev/null +++ b/testing/xpcshell/node-ws/browser.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = function () { + throw new Error( + 'ws does not work in the browser. Browser clients must use the native ' + + 'WebSocket object' + ); +}; diff --git a/testing/xpcshell/node-ws/doc/ws.md b/testing/xpcshell/node-ws/doc/ws.md new file mode 100644 index 0000000000..4888ee4c6f --- /dev/null +++ b/testing/xpcshell/node-ws/doc/ws.md @@ -0,0 +1,669 @@ +# ws + +## Table of Contents + +- [Class: WebSocketServer](#class-websocketserver) + - [new WebSocketServer(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) + - [Event: 'wsClientError'](#event-wsclienterror) + - [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: 'redirect'](#event-redirect) + - [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.isPaused](#websocketispaused) + - [websocket.onclose](#websocketonclose) + - [websocket.onerror](#websocketonerror) + - [websocket.onmessage](#websocketonmessage) + - [websocket.onopen](#websocketonopen) + - [websocket.pause()](#websocketpause) + - [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.resume()](#websocketresume) + - [websocket.send(data[, options][, callback])](#websocketsenddata-options-callback) + - [websocket.terminate()](#websocketterminate) + - [websocket.url](#websocketurl) +- [createWebSocketStream(websocket[, options])](#createwebsocketstreamwebsocket-options) +- [Environment variables](#environment-variables) + - [WS_NO_BUFFER_UTIL](#ws_no_buffer_util) + - [WS_NO_UTF_8_VALIDATE](#ws_no_utf_8_validate) +- [Error codes](#error-codes) + - [WS_ERR_EXPECTED_FIN](#ws_err_expected_fin) + - [WS_ERR_EXPECTED_MASK](#ws_err_expected_mask) + - [WS_ERR_INVALID_CLOSE_CODE](#ws_err_invalid_close_code) + - [WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH](#ws_err_invalid_control_payload_length) + - [WS_ERR_INVALID_OPCODE](#ws_err_invalid_opcode) + - [WS_ERR_INVALID_UTF8](#ws_err_invalid_utf8) + - [WS_ERR_UNEXPECTED_MASK](#ws_err_unexpected_mask) + - [WS_ERR_UNEXPECTED_RSV_1](#ws_err_unexpected_rsv_1) + - [WS_ERR_UNEXPECTED_RSV_2_3](#ws_err_unexpected_rsv_2_3) + - [WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH](#ws_err_unsupported_data_payload_length) + - [WS_ERR_UNSUPPORTED_MESSAGE_LENGTH](#ws_err_unsupported_message_length) + +## Class: WebSocketServer + +This class represents a WebSocket server. It extends the `EventEmitter`. + +### new WebSocketServer(options[, callback]) + +- `options` {Object} + - `backlog` {Number} The maximum length of the queue of pending connections. + - `clientTracking` {Boolean} Specifies whether or not to track clients. + - `handleProtocols` {Function} A function which can be used to handle the + WebSocket subprotocols. See description below. + - `host` {String} The hostname where to bind the server. + - `maxPayload` {Number} The maximum allowed message size in bytes. Defaults to + 100 MiB (104857600 bytes). + - `noServer` {Boolean} Enable no server mode. + - `path` {String} Accept only connections matching this path. + - `perMessageDeflate` {Boolean|Object} Enable/disable permessage-deflate. + - `port` {Number} The port where to bind the server. + - `server` {http.Server|https.Server} A pre-created Node.js HTTP/S server. + - `skipUTF8Validation` {Boolean} Specifies whether or not to skip UTF-8 + validation for text and close messages. Defaults to `false`. Set to `true` + only if clients are trusted. + - `verifyClient` {Function} A function which can be used to validate incoming + connections. See description below. (Usage is discouraged: see + [Issue #337](https://github.com/websockets/ws/issues/377#issuecomment-462152231)) + - `WebSocket` {Function} Specifies the `WebSocket` class to be used. It must + be extended from the original `WebSocket`. Defaults to `WebSocket`. +- `callback` {Function} + +Create a new server instance. One and only one of `port`, `server` or `noServer` +must be provided or an error is thrown. An HTTP server is automatically created, +started, and used if `port` is set. To use an external HTTP/S server instead, +specify only `server` or `noServer`. In this case the HTTP/S server must be +started manually. The "noServer" mode allows the WebSocket server to be +completely 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 +has a single parameter then `ws` will invoke it with the following argument: + +- `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.socket.authorized` or + `req.socket.encrypted` is set. + +The return value (`Boolean`) of the function determines whether or not to accept +the handshake. + +If `verifyClient` has two parameters then `ws` will invoke it with the following +arguments: + +- `info` {Object} Same as above. +- `cb` {Function} A callback that must be called by the user upon inspection of + the `info` fields. Arguments in this callback are: + - `result` {Boolean} Whether or not to accept the handshake. + - `code` {Number} When `result` is `false` this field determines the HTTP + error status code to be sent to the client. + - `name` {String} When `result` is `false` this field determines the HTTP + reason phrase. + - `headers` {Object} When `result` is `false` this field determines additional + HTTP headers to be sent to the client. For example, + `{ 'Retry-After': 120 }`. + +`handleProtocols` takes two arguments: + +- `protocols` {Set} The list of WebSocket subprotocols indicated by the client + in the `Sec-WebSocket-Protocol` header. +- `request` {http.IncomingMessage} The client HTTP GET request. + +The returned value sets the value of the `Sec-WebSocket-Protocol` header in the +HTTP 101 response. If returned value is `false` the header is not added in the +response. + +If `handleProtocols` is not set then the first of the client's requested +subprotocols is used. + +`perMessageDeflate` can be used to control the behavior of [permessage-deflate +extension][permessage-deflate]. The extension is disabled when `false` (default +value). If an object is provided then that is extension parameters: + +- `serverNoContextTakeover` {Boolean} Whether to use context takeover or not. +- `clientNoContextTakeover` {Boolean} Acknowledge disabling of client context + takeover. +- `serverMaxWindowBits` {Number} The value of `windowBits`. +- `clientMaxWindowBits` {Number} Request a custom client window size. +- `zlibDeflateOptions` {Object} [Additional options][zlib-options] to pass to + zlib on deflate. +- `zlibInflateOptions` {Object} [Additional options][zlib-options] to pass to + zlib on inflate. +- `threshold` {Number} Payloads smaller than this will not be compressed if + context takeover is disabled. Defaults to 1024 bytes. +- `concurrencyLimit` {Number} The number of concurrent calls to zlib. Calls + above this limit will be queued. Default 10. You usually won't need to touch + this option. See [this issue][concurrency-limit] for more details. + +If a property is empty then either an offered configuration or a default value +is used. When sending a fragmented message the length of the first fragment is +compared to the threshold. This determines if compression is used for the entire +message. + +`callback` will be added as a listener for the `listening` event on the HTTP +server when not operating in "noServer" mode. + +### Event: 'close' + +Emitted when the server closes. This event depends on the `'close'` event of +HTTP server only when it is created internally. In all other cases, the event is +emitted independently. + +### Event: 'connection' + +- `websocket` {WebSocket} +- `request` {http.IncomingMessage} + +Emitted when the handshake is complete. `request` is the http GET request sent +by the client. Useful for parsing authority headers, cookie headers, and other +information. + +### Event: 'error' + +- `error` {Error} + +Emitted when an error occurs on the underlying server. + +### Event: 'headers' + +- `headers` {Array} +- `request` {http.IncomingMessage} + +Emitted before the response headers are written to the socket as part of the +handshake. This allows you to inspect/modify the headers before they are sent. + +### Event: 'listening' + +Emitted when the underlying server has been bound. + +### Event: 'wsClientError' + +- `error` {Error} +- `socket` {net.Socket|tls.Socket} +- `request` {http.IncomingMessage} + +Emitted when an error occurs before the WebSocket connection is established. +`socket` and `request` are respectively the socket and the HTTP request from +which the error originated. The listener of this event is responsible for +closing the socket. When the `'wsClientError'` event is emitted there is no +`http.ServerResponse` object, so any HTTP response, including the response +headers and body, must be written directly to the `socket`. If there is no +listener for this event, the socket is closed with a default 4xx response +containing a descriptive error message. + +### server.address() + +Returns an object with `port`, `family`, and `address` properties specifying the +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]) + +Prevent the server from accepting new connections and close the HTTP server if +created internally. If an external HTTP server is used via the `server` or +`noServer` constructor options, it must be closed manually. Existing connections +are not closed automatically. The server emits a `'close'` event when all +connections are closed unless an external HTTP server is used and client +tracking is disabled. In this case the `'close'` event is emitted in the next +tick. The optional callback is called when the `'close'` event occurs and +receives an `Error` if the server is already closed. + +### server.handleUpgrade(request, socket, head, callback) + +- `request` {http.IncomingMessage} The client HTTP GET request. +- `socket` {net.Socket|tls.Socket} The network socket between the server and + client. +- `head` {Buffer} The first packet of the upgraded stream. +- `callback` {Function}. + +Handle a HTTP upgrade request. When the HTTP server is created internally or +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 two arguments: + +- `websocket` {WebSocket} A `WebSocket` object. +- `request` {http.IncomingMessage} The client HTTP GET request. + +### server.shouldHandle(request) + +- `request` {http.IncomingMessage} The client HTTP GET request. + +See if a given request should be handled by this server. By default this method +validates the pathname of the request, matching it against the `path` option if +provided. The return value, `true` or `false`, determines whether or not to +accept the handshake. + +This method can be overridden when a custom handling logic is required. + +## Class: WebSocket + +This class represents a WebSocket. It extends the `EventEmitter`. + +### Ready state constants + +| Constant | Value | Description | +| ---------- | ----- | ------------------------------------------------ | +| CONNECTING | 0 | The connection is not yet open. | +| OPEN | 1 | The connection is open and ready to communicate. | +| CLOSING | 2 | The connection is in the process of closing. | +| CLOSED | 3 | The connection is closed. | + +### new WebSocket(address[, protocols][, options]) + +- `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`. + - `generateMask` {Function} The function used to generate the masking key. It + takes a `Buffer` that must be filled synchronously and is called before a + message is sent, for each message. By default the buffer is filled with + cryptographically strong random bytes. + - `handshakeTimeout` {Number} Timeout in milliseconds for the handshake + request. This is reset after every redirection. + - `maxPayload` {Number} The maximum allowed message size in bytes. Defaults to + 100 MiB (104857600 bytes). + - `maxRedirects` {Number} The maximum number of redirects allowed. Defaults + to 10. + - `origin` {String} Value of the `Origin` or `Sec-WebSocket-Origin` header + depending on the `protocolVersion`. + - `perMessageDeflate` {Boolean|Object} Enable/disable permessage-deflate. + - `protocolVersion` {Number} Value of the `Sec-WebSocket-Version` header. + - `skipUTF8Validation` {Boolean} Specifies whether or not to skip UTF-8 + validation for text and close messages. Defaults to `false`. Set to `true` + only if the server is trusted. + - 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. +For example, `serverNoContextTakeover` can be used to ask the server to disable +context takeover. + +Create a new WebSocket instance. + +#### UNIX Domain Sockets + +`ws` supports making requests to UNIX domain sockets. To make one, use the +following URL scheme: + +``` +ws+unix:///absolute/path/to/uds_socket:/pathname?search_params +``` + +Note that `:` is the separator between the socket path and the URL path. If the +URL path is omitted + +``` +ws+unix:///absolute/path/to/uds_socket +``` + +it defaults to `/`. + +### Event: 'close' + +- `code` {Number} +- `reason` {Buffer} + +Emitted when the connection is closed. `code` is a numeric value indicating the +status code explaining why the connection has been closed. `reason` is a +`Buffer` containing a human-readable string explaining why the connection has +been closed. + +### Event: 'error' + +- `error` {Error} + +Emitted when an error occurs. Errors may have a `.code` property, matching one +of the string values defined below under [Error codes](#error-codes). + +### Event: 'message' + +- `data` {Buffer|ArrayBuffer|Buffer[]} +- `isBinary` {Boolean} + +Emitted when a message is received. `data` is the message content. `isBinary` +specifies whether the message is binary or not. + +### Event: 'open' + +Emitted when the connection is established. + +### Event: 'ping' + +- `data` {Buffer} + +Emitted when a ping is received from the server. + +### Event: 'pong' + +- `data` {Buffer} + +Emitted when a pong is received from the server. + +### Event: 'redirect' + +- `url` {String} +- `request` {http.ClientRequest} + +Emitted before a redirect is followed. `url` is the redirect URL. `request` is +the HTTP GET request with the headers queued. This event gives the ability to +inspect confidential headers and remove them on a per-redirect basis using the +[`request.getHeader()`][] and [`request.removeHeader()`][] API. The `request` +object should be used only for this purpose. When there is at least one listener +for this event, no header is removed by default, even if the redirect is to a +different domain. + +### Event: 'unexpected-response' + +- `request` {http.ClientRequest} +- `response` {http.IncomingMessage} + +Emitted when the server response is not the expected one, for example a 401 +response. This event gives the ability to read the response in order to extract +useful information. If the server sends an invalid response and there isn't a +listener for this event, an error is emitted. + +### Event: 'upgrade' + +- `response` {http.IncomingMessage} + +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[, 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. This method +does nothing if `type` is not one of `'close'`, `'error'`, `'message'`, or +`'open'`. + +### websocket.binaryType + +- {String} + +A string indicating the type of binary data being transmitted by the connection. +This should be one of "nodebuffer", "arraybuffer" or "fragments". Defaults to +"nodebuffer". Type "fragments" will emit the array of fragments as received from +the sender, without copyfull concatenation, which is useful for the performance +of binary protocols transferring large messages with multiple fragments. + +### websocket.bufferedAmount + +- {Number} + +The number of bytes of data that have been queued using calls to `send()` but +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]]) + +- `code` {Number} A numeric value indicating the status code explaining why the + connection is being closed. +- `reason` {String|Buffer} The reason why the connection is closing. + +Initiate a closing handshake. + +### websocket.isPaused + +- {Boolean} + +Indicates whether the websocket is paused. + +### websocket.extensions + +- {Object} + +An object containing the negotiated extensions. + +### websocket.onclose + +- {Function} + +An event listener to be called when connection is closed. The listener receives +a `CloseEvent` named "close". + +### websocket.onerror + +- {Function} + +An event listener to be called when an error occurs. The listener receives an +`ErrorEvent` named "error". + +### websocket.onmessage + +- {Function} + +An event listener to be called when a message is received from the server. The +listener receives a `MessageEvent` named "message". + +### websocket.onopen + +- {Function} + +An event listener to be called when the connection is established. The listener +receives an `OpenEvent` named "open". + +### websocket.pause() + +Pause the websocket causing it to stop emitting events. Some events can still be +emitted after this is called, until all buffered data is consumed. This method +is a noop if the ready state is `CONNECTING` or `CLOSED`. + +### websocket.ping([data[, mask]][, callback]) + +- `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 + frame is written out. If an error occurs, the callback is called with the + error as its first argument. + +Send a ping. This method throws an error if the ready state is `CONNECTING`. + +### websocket.pong([data[, mask]][, callback]) + +- `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 + frame is written out. If an error occurs, the callback is called with the + error as its first argument. + +Send a pong. This method throws an error if the ready state is `CONNECTING`. + +### websocket.protocol + +- {String} + +The subprotocol selected by the server. + +### websocket.resume() + +Make a paused socket resume emitting events. This method is a noop if the ready +state is `CONNECTING` or `CLOSED`. + +### websocket.readyState + +- {Number} + +The current state of the connection. This is one of the ready state constants. + +### websocket.removeEventListener(type, listener) + +- `type` {String} A string representing the event type to remove. +- `listener` {Function} The listener to remove. + +Removes an event listener emulating the `EventTarget` interface. This method +only removes listeners added with +[`websocket.addEventListener()`](#websocketaddeventlistenertype-listener-options). + +### websocket.send(data[, options][, callback]) + +- `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The + data to send. +- `options` {Object} + - `binary` {Boolean} Specifies whether `data` should be sent as a binary or + not. Default is autodetected. + - `compress` {Boolean} Specifies whether `data` should be compressed or not. + Defaults to `true` when permessage-deflate is enabled. + - `fin` {Boolean} Specifies whether `data` is the last fragment of a message + or not. Defaults to `true`. + - `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 `data` is + written out. If an error occurs, the callback is called with the error as its + first argument. + +Send `data` through the connection. This method throws an error if the ready +state is `CONNECTING`. + +### websocket.terminate() + +Forcibly close the connection. Internally this calls [`socket.destroy()`][]. + +### websocket.url + +- {String} + +The URL of the WebSocket server. Server clients don't have this attribute. + +## 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`. + +## Environment variables + +### WS_NO_BUFFER_UTIL + +When set to a non empty value, prevents the optional `bufferutil` dependency +from being required. + +### WS_NO_UTF_8_VALIDATE + +When set to a non empty value, prevents the optional `utf-8-validate` dependency +from being required. + +## Error codes + +Errors emitted by the websocket may have a `.code` property, describing the +specific type of error that has occurred: + +### WS_ERR_EXPECTED_FIN + +A WebSocket frame was received with the FIN bit not set when it was expected. + +### WS_ERR_EXPECTED_MASK + +An unmasked WebSocket frame was received by a WebSocket server. + +### WS_ERR_INVALID_CLOSE_CODE + +A WebSocket close frame was received with an invalid close code. + +### WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH + +A control frame with an invalid payload length was received. + +### WS_ERR_INVALID_OPCODE + +A WebSocket frame was received with an invalid opcode. + +### WS_ERR_INVALID_UTF8 + +A text or close frame was received containing invalid UTF-8 data. + +### WS_ERR_UNEXPECTED_MASK + +A masked WebSocket frame was received by a WebSocket client. + +### WS_ERR_UNEXPECTED_RSV_1 + +A WebSocket frame was received with the RSV1 bit set unexpectedly. + +### WS_ERR_UNEXPECTED_RSV_2_3 + +A WebSocket frame was received with the RSV2 or RSV3 bit set unexpectedly. + +### WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH + +A data frame was received with a length longer than the max supported length +(2^53 - 1, due to JavaScript language limitations). + +### WS_ERR_UNSUPPORTED_MESSAGE_LENGTH + +A message was received with a length longer than the maximum supported length, +as configured by the `maxPayload` option. + +[concurrency-limit]: https://github.com/websockets/ws/issues/1202 +[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 +[`request.getheader()`]: https://nodejs.org/api/http.html#requestgetheadername +[`request.removeheader()`]: + https://nodejs.org/api/http.html#requestremoveheadername +[`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/testing/xpcshell/node-ws/examples/express-session-parse/index.js b/testing/xpcshell/node-ws/examples/express-session-parse/index.js new file mode 100644 index 0000000000..b62a2e4a5f --- /dev/null +++ b/testing/xpcshell/node-ws/examples/express-session-parse/index.js @@ -0,0 +1,101 @@ +'use strict'; + +const session = require('express-session'); +const express = require('express'); +const http = require('http'); +const uuid = require('uuid'); + +const { WebSocketServer } = require('../..'); + +const app = express(); +const map = new Map(); + +// +// We need the same instance of the session parser in express and +// WebSocket server. +// +const sessionParser = session({ + saveUninitialized: false, + secret: '$eCuRiTy', + resave: false +}); + +// +// Serve static files from the 'public' folder. +// +app.use(express.static('public')); +app.use(sessionParser); + +app.post('/login', function (req, res) { + // + // "Log in" user and set userId to session. + // + const id = uuid.v4(); + + console.log(`Updating session for user ${id}`); + req.session.userId = id; + res.send({ result: 'OK', message: 'Session updated' }); +}); + +app.delete('/logout', function (request, response) { + const ws = map.get(request.session.userId); + + console.log('Destroying session'); + request.session.destroy(function () { + if (ws) ws.close(); + + response.send({ result: 'OK', message: 'Session destroyed' }); + }); +}); + +// +// Create an HTTP server. +// +const server = http.createServer(app); + +// +// Create a WebSocket server completely detached from the HTTP server. +// +const wss = new WebSocketServer({ 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); + }); + }); +}); + +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(`Received message ${message} from user ${userId}`); + }); + + ws.on('close', function () { + map.delete(userId); + }); +}); + +// +// Start the server. +// +server.listen(8080, function () { + console.log('Listening on http://localhost:8080'); +}); diff --git a/testing/xpcshell/node-ws/examples/express-session-parse/package.json b/testing/xpcshell/node-ws/examples/express-session-parse/package.json new file mode 100644 index 0000000000..406706ce8a --- /dev/null +++ b/testing/xpcshell/node-ws/examples/express-session-parse/package.json @@ -0,0 +1,11 @@ +{ + "author": "", + "name": "express-session-parse", + "version": "0.0.0", + "repository": "websockets/ws", + "dependencies": { + "express": "^4.16.4", + "express-session": "^1.16.1", + "uuid": "^8.3.2" + } +} diff --git a/testing/xpcshell/node-ws/examples/express-session-parse/public/app.js b/testing/xpcshell/node-ws/examples/express-session-parse/public/app.js new file mode 100644 index 0000000000..f70dc21835 --- /dev/null +++ b/testing/xpcshell/node-ws/examples/express-session-parse/public/app.js @@ -0,0 +1,67 @@ +(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'); + + function showMessage(message) { + messages.textContent += `\n${message}`; + messages.scrollTop = messages.scrollHeight; + } + + function handleResponse(response) { + return response.ok + ? response.json().then((data) => JSON.stringify(data, null, 2)) + : Promise.reject(new Error('Unexpected response')); + } + + login.onclick = function () { + fetch('/login', { method: 'POST', credentials: 'same-origin' }) + .then(handleResponse) + .then(showMessage) + .catch(function (err) { + showMessage(err.message); + }); + }; + + logout.onclick = function () { + fetch('/logout', { method: 'DELETE', credentials: 'same-origin' }) + .then(handleResponse) + .then(showMessage) + .catch(function (err) { + showMessage(err.message); + }); + }; + + let ws; + + wsButton.onclick = function () { + if (ws) { + ws.onerror = ws.onopen = ws.onclose = null; + ws.close(); + } + + ws = new WebSocket(`ws://${location.host}`); + 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/testing/xpcshell/node-ws/examples/express-session-parse/public/index.html b/testing/xpcshell/node-ws/examples/express-session-parse/public/index.html new file mode 100644 index 0000000000..c07aa2e87a --- /dev/null +++ b/testing/xpcshell/node-ws/examples/express-session-parse/public/index.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>Express session demo</title> + </head> + <body> + <h1>Choose an action.</h1> + <button id="login" type="button" title="Simulate login"> + Simulate login + </button> + <button id="logout" type="button" title="Simulate logout"> + Simulate logout + </button> + <button id="wsButton" type="button" title="Open WebSocket connection"> + Open WebSocket connection + </button> + <button id="wsSendButton" type="button" title="Send WebSocket message"> + Send WebSocket message + </button> + <pre id="messages" style="height: 400px; overflow: scroll"></pre> + <script src="app.js"></script> + </body> +</html> diff --git a/testing/xpcshell/node-ws/examples/server-stats/index.js b/testing/xpcshell/node-ws/examples/server-stats/index.js new file mode 100644 index 0000000000..e8754b5b28 --- /dev/null +++ b/testing/xpcshell/node-ws/examples/server-stats/index.js @@ -0,0 +1,33 @@ +'use strict'; + +const express = require('express'); +const path = require('path'); +const { createServer } = require('http'); + +const { WebSocketServer } = require('../..'); + +const app = express(); +app.use(express.static(path.join(__dirname, '/public'))); + +const server = createServer(app); +const wss = new WebSocketServer({ 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/testing/xpcshell/node-ws/examples/server-stats/package.json b/testing/xpcshell/node-ws/examples/server-stats/package.json new file mode 100644 index 0000000000..20e2029133 --- /dev/null +++ b/testing/xpcshell/node-ws/examples/server-stats/package.json @@ -0,0 +1,9 @@ +{ + "author": "", + "name": "serverstats", + "version": "0.0.0", + "repository": "websockets/ws", + "dependencies": { + "express": "^4.16.4" + } +} diff --git a/testing/xpcshell/node-ws/examples/server-stats/public/index.html b/testing/xpcshell/node-ws/examples/server-stats/public/index.html new file mode 100644 index 0000000000..a82815af6f --- /dev/null +++ b/testing/xpcshell/node-ws/examples/server-stats/public/index.html @@ -0,0 +1,63 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>Server stats</title> + <style> + table, td { + border: 1px solid #333; + } + + thead { + background-color: #333; + color: #fff; + } + </style> + </head> + <body> + <h1>Server stats</h1> + <table> + <thead> + <tr> + <th colspan="2">Memory usage</th> + </tr> + </thead> + <tbody> + <tr> + <td>RSS</td> + <td id="rss"></td> + </tr> + <tr> + <td>Heap total</td> + <td id="heapTotal"></td> + </tr> + <tr> + <td>Heap used</td> + <td id="heapUsed"></td> + </tr> + <tr> + <td>External</td> + <td id="external"></td> + </tr> + </tbody> + </table> + <script> + (function() { + const rss = document.getElementById('rss'); + const heapTotal = document.getElementById('heapTotal'); + const heapUsed = document.getElementById('heapUsed'); + const external = document.getElementById('external'); + const ws = new WebSocket(`ws://${location.host}`); + + ws.onmessage = function(event) { + const data = JSON.parse(event.data); + + rss.textContent = data.rss; + heapTotal.textContent = data.heapTotal; + heapUsed.textContent = data.heapUsed; + external.textContent = data.external; + }; + })(); + </script> + </body> +</html> diff --git a/testing/xpcshell/node-ws/examples/ssl.js b/testing/xpcshell/node-ws/examples/ssl.js new file mode 100644 index 0000000000..a5e750b799 --- /dev/null +++ b/testing/xpcshell/node-ws/examples/ssl.js @@ -0,0 +1,37 @@ +'use strict'; + +const https = require('https'); +const fs = require('fs'); + +const { WebSocket, WebSocketServer } = require('..'); + +const server = https.createServer({ + cert: fs.readFileSync('../test/fixtures/certificate.pem'), + key: fs.readFileSync('../test/fixtures/key.pem') +}); + +const wss = new WebSocketServer({ server }); + +wss.on('connection', function connection(ws) { + ws.on('message', function message(msg) { + console.log(msg.toString()); + }); +}); + +server.listen(function listening() { + // + // If the `rejectUnauthorized` option is not `false`, the server certificate + // is verified against a list of well-known CAs. An 'error' event is emitted + // if verification fails. + // + // The certificate used in this example is self-signed so `rejectUnauthorized` + // is set to `false`. + // + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('open', function open() { + ws.send('All glory to WebSockets!'); + }); +}); diff --git a/testing/xpcshell/node-ws/index.js b/testing/xpcshell/node-ws/index.js new file mode 100644 index 0000000000..41edb3b81b --- /dev/null +++ b/testing/xpcshell/node-ws/index.js @@ -0,0 +1,13 @@ +'use strict'; + +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'); + +WebSocket.WebSocket = WebSocket; +WebSocket.WebSocketServer = WebSocket.Server; + +module.exports = WebSocket; diff --git a/testing/xpcshell/node-ws/lib/buffer-util.js b/testing/xpcshell/node-ws/lib/buffer-util.js new file mode 100644 index 0000000000..df75955467 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/buffer-util.js @@ -0,0 +1,127 @@ +'use strict'; + +const { EMPTY_BUFFER } = require('./constants'); + +/** + * Merges an array of buffers into a new buffer. + * + * @param {Buffer[]} list The array of buffers to concat + * @param {Number} totalLength The total length of buffers in the list + * @return {Buffer} The resulting buffer + * @public + */ +function concat(list, totalLength) { + if (list.length === 0) return EMPTY_BUFFER; + if (list.length === 1) return list[0]; + + const target = Buffer.allocUnsafe(totalLength); + let offset = 0; + + for (let i = 0; i < list.length; i++) { + const buf = list[i]; + target.set(buf, offset); + offset += buf.length; + } + + if (offset < totalLength) return target.slice(0, offset); + + return target; +} + +/** + * Masks a buffer using the given mask. + * + * @param {Buffer} source The buffer to mask + * @param {Buffer} mask The mask to use + * @param {Buffer} output The buffer where to store the result + * @param {Number} offset The offset at which to start writing + * @param {Number} length The number of bytes to mask. + * @public + */ +function _mask(source, mask, output, offset, length) { + for (let i = 0; i < length; i++) { + output[offset + i] = source[i] ^ mask[i & 3]; + } +} + +/** + * Unmasks a buffer using the given mask. + * + * @param {Buffer} buffer The buffer to unmask + * @param {Buffer} mask The mask to use + * @public + */ +function _unmask(buffer, mask) { + for (let i = 0; i < buffer.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; +} + +module.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask +}; + +/* istanbul ignore else */ +if (!process.env.WS_NO_BUFFER_UTIL) { + try { + const bufferUtil = require('bufferutil'); + + module.exports.mask = function (source, mask, output, offset, length) { + if (length < 48) _mask(source, mask, output, offset, length); + else bufferUtil.mask(source, mask, output, offset, length); + }; + + module.exports.unmask = function (buffer, mask) { + if (buffer.length < 32) _unmask(buffer, mask); + else bufferUtil.unmask(buffer, mask); + }; + } catch (e) { + // Continue regardless of the error. + } +} diff --git a/testing/xpcshell/node-ws/lib/constants.js b/testing/xpcshell/node-ws/lib/constants.js new file mode 100644 index 0000000000..d691b30a17 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/constants.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = { + BINARY_TYPES: ['nodebuffer', 'arraybuffer', 'fragments'], + EMPTY_BUFFER: Buffer.alloc(0), + GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + kForOnEventAttribute: Symbol('kIsForOnEventAttribute'), + kListener: Symbol('kListener'), + kStatusCode: Symbol('status-code'), + kWebSocket: Symbol('websocket'), + NOOP: () => {} +}; diff --git a/testing/xpcshell/node-ws/lib/event-target.js b/testing/xpcshell/node-ws/lib/event-target.js new file mode 100644 index 0000000000..d5abd83a0f --- /dev/null +++ b/testing/xpcshell/node-ws/lib/event-target.js @@ -0,0 +1,266 @@ +'use strict'; + +const { kForOnEventAttribute, kListener } = require('./constants'); + +const kCode = Symbol('kCode'); +const kData = Symbol('kData'); +const kError = Symbol('kError'); +const kMessage = Symbol('kMessage'); +const kReason = Symbol('kReason'); +const kTarget = Symbol('kTarget'); +const kType = Symbol('kType'); +const kWasClean = Symbol('kWasClean'); + +/** + * Class representing an event. + */ +class Event { + /** + * Create a new `Event`. + * + * @param {String} type The name of the event + * @throws {TypeError} If the `type` argument is not specified + */ + constructor(type) { + this[kTarget] = null; + this[kType] = type; + } + + /** + * @type {*} + */ + get target() { + return this[kTarget]; + } + + /** + * @type {String} + */ + get type() { + return this[kType]; + } +} + +Object.defineProperty(Event.prototype, 'target', { enumerable: true }); +Object.defineProperty(Event.prototype, 'type', { enumerable: true }); + +/** + * Class representing a close event. + * + * @extends Event + */ +class CloseEvent extends Event { + /** + * Create a new `CloseEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {Number} [options.code=0] The status code explaining why the + * connection was closed + * @param {String} [options.reason=''] A human-readable string explaining why + * the connection was closed + * @param {Boolean} [options.wasClean=false] Indicates whether or not the + * connection was cleanly closed + */ + constructor(type, options = {}) { + super(type); + + this[kCode] = options.code === undefined ? 0 : options.code; + this[kReason] = options.reason === undefined ? '' : options.reason; + this[kWasClean] = options.wasClean === undefined ? false : options.wasClean; + } + + /** + * @type {Number} + */ + get code() { + return this[kCode]; + } + + /** + * @type {String} + */ + get reason() { + return this[kReason]; + } + + /** + * @type {Boolean} + */ + get wasClean() { + return this[kWasClean]; + } +} + +Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true }); +Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true }); +Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true }); + +/** + * Class representing an error event. + * + * @extends Event + */ +class ErrorEvent extends Event { + /** + * Create a new `ErrorEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.error=null] The error that generated this event + * @param {String} [options.message=''] The error message + */ + constructor(type, options = {}) { + super(type); + + this[kError] = options.error === undefined ? null : options.error; + this[kMessage] = options.message === undefined ? '' : options.message; + } + + /** + * @type {*} + */ + get error() { + return this[kError]; + } + + /** + * @type {String} + */ + get message() { + return this[kMessage]; + } +} + +Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true }); +Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true }); + +/** + * Class representing a message event. + * + * @extends Event + */ +class MessageEvent extends Event { + /** + * Create a new `MessageEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.data=null] The message content + */ + constructor(type, options = {}) { + super(type); + + this[kData] = options.data === undefined ? null : options.data; + } + + /** + * @type {*} + */ + get data() { + return this[kData]; + } +} + +Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true }); + +/** + * This provides methods for emulating the `EventTarget` interface. It's not + * meant to be used directly. + * + * @mixin + */ +const EventTarget = { + /** + * Register an event listener. + * + * @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(type, listener, options = {}) { + let wrapper; + + if (type === 'message') { + wrapper = function onMessage(data, isBinary) { + const event = new MessageEvent('message', { + data: isBinary ? data : data.toString() + }); + + event[kTarget] = this; + listener.call(this, event); + }; + } else if (type === 'close') { + wrapper = function onClose(code, message) { + const event = new CloseEvent('close', { + code, + reason: message.toString(), + wasClean: this._closeFrameReceived && this._closeFrameSent + }); + + event[kTarget] = this; + listener.call(this, event); + }; + } else if (type === 'error') { + wrapper = function onError(error) { + const event = new ErrorEvent('error', { + error, + message: error.message + }); + + event[kTarget] = this; + listener.call(this, event); + }; + } else if (type === 'open') { + wrapper = function onOpen() { + const event = new Event('open'); + + event[kTarget] = this; + listener.call(this, event); + }; + } else { + return; + } + + wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; + wrapper[kListener] = listener; + + if (options.once) { + this.once(type, wrapper); + } else { + this.on(type, wrapper); + } + }, + + /** + * Remove an event listener. + * + * @param {String} type A string representing the event type to remove + * @param {Function} handler The listener to remove + * @public + */ + removeEventListener(type, handler) { + for (const listener of this.listeners(type)) { + if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { + this.removeListener(type, listener); + break; + } + } + } +}; + +module.exports = { + CloseEvent, + ErrorEvent, + Event, + EventTarget, + MessageEvent +}; diff --git a/testing/xpcshell/node-ws/lib/extension.js b/testing/xpcshell/node-ws/lib/extension.js new file mode 100644 index 0000000000..3d7895c1b0 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/extension.js @@ -0,0 +1,203 @@ +'use strict'; + +const { tokenChars } = require('./validation'); + +/** + * Adds an offer to the map of extension offers or a parameter to the map of + * parameters. + * + * @param {Object} dest The map of extension offers or parameters + * @param {String} name The extension or parameter name + * @param {(Object|Boolean|String)} elem The extension parameters or the + * parameter value + * @private + */ +function push(dest, name, elem) { + if (dest[name] === undefined) dest[name] = [elem]; + else dest[name].push(elem); +} + +/** + * Parses the `Sec-WebSocket-Extensions` header into an object. + * + * @param {String} header The field value of the header + * @return {Object} The parsed object + * @public + */ +function parse(header) { + const offers = Object.create(null); + let params = Object.create(null); + let mustUnescape = false; + let isEscaping = false; + let inQuotes = false; + let extensionName; + let paramName; + let start = -1; + let code = -1; + let end = -1; + let i = 0; + + for (; i < header.length; i++) { + code = header.charCodeAt(i); + + if (extensionName === undefined) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if ( + i !== 0 && + (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ + ) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + const name = header.slice(start, end); + if (code === 0x2c) { + push(offers, name, params); + params = Object.create(null); + } else { + extensionName = name; + } + + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (paramName === undefined) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 0x20 || code === 0x09) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x3b || code === 0x2c) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + push(params, header.slice(start, end), true); + if (code === 0x2c) { + push(offers, extensionName, params); + params = Object.create(null); + extensionName = undefined; + } + + start = end = -1; + } else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) { + paramName = header.slice(start, i); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else { + // + // The value of a quoted-string after unescaping must conform to the + // token ABNF, so only token characters are valid. + // Ref: https://tools.ietf.org/html/rfc6455#section-9.1 + // + if (isEscaping) { + if (tokenChars[code] !== 1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (start === -1) start = i; + else if (!mustUnescape) mustUnescape = true; + isEscaping = false; + } else if (inQuotes) { + if (tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 0x22 /* '"' */ && start !== -1) { + inQuotes = false; + end = i; + } else if (code === 0x5c /* '\' */) { + isEscaping = true; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) { + inQuotes = true; + } else if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (start !== -1 && (code === 0x20 || code === 0x09)) { + if (end === -1) end = i; + } else if (code === 0x3b || code === 0x2c) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + let value = header.slice(start, end); + if (mustUnescape) { + value = value.replace(/\\/g, ''); + mustUnescape = false; + } + push(params, paramName, value); + if (code === 0x2c) { + push(offers, extensionName, params); + params = Object.create(null); + extensionName = undefined; + } + + paramName = undefined; + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + } + + if (start === -1 || inQuotes || code === 0x20 || code === 0x09) { + throw new SyntaxError('Unexpected end of input'); + } + + if (end === -1) end = i; + const token = header.slice(start, end); + if (extensionName === undefined) { + push(offers, token, params); + } else { + if (paramName === undefined) { + push(params, token, true); + } else if (mustUnescape) { + push(params, paramName, token.replace(/\\/g, '')); + } else { + push(params, paramName, token); + } + push(offers, extensionName, params); + } + + return offers; +} + +/** + * Builds the `Sec-WebSocket-Extensions` header field value. + * + * @param {Object} extensions The map of extensions and parameters to format + * @return {String} A string representing the given object + * @public + */ +function format(extensions) { + return Object.keys(extensions) + .map((extension) => { + let configurations = extensions[extension]; + if (!Array.isArray(configurations)) configurations = [configurations]; + return configurations + .map((params) => { + return [extension] + .concat( + Object.keys(params).map((k) => { + let values = params[k]; + if (!Array.isArray(values)) values = [values]; + return values + .map((v) => (v === true ? k : `${k}=${v}`)) + .join('; '); + }) + ) + .join('; '); + }) + .join(', '); + }) + .join(', '); +} + +module.exports = { format, parse }; diff --git a/testing/xpcshell/node-ws/lib/limiter.js b/testing/xpcshell/node-ws/lib/limiter.js new file mode 100644 index 0000000000..3fd35784ea --- /dev/null +++ b/testing/xpcshell/node-ws/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/testing/xpcshell/node-ws/lib/permessage-deflate.js b/testing/xpcshell/node-ws/lib/permessage-deflate.js new file mode 100644 index 0000000000..94603c98da --- /dev/null +++ b/testing/xpcshell/node-ws/lib/permessage-deflate.js @@ -0,0 +1,511 @@ +'use strict'; + +const zlib = require('zlib'); + +const bufferUtil = require('./buffer-util'); +const Limiter = require('./limiter'); +const { kStatusCode } = require('./constants'); + +const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); +const kPerMessageDeflate = Symbol('permessage-deflate'); +const kTotalLength = Symbol('total-length'); +const kCallback = Symbol('callback'); +const kBuffers = Symbol('buffers'); +const kError = Symbol('error'); + +// +// We limit zlib concurrency, which prevents severe memory fragmentation +// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913 +// and https://github.com/websockets/ws/issues/1202 +// +// Intentionally global; it's the global thread pool that's an issue. +// +let zlibLimiter; + +/** + * permessage-deflate implementation. + */ +class PerMessageDeflate { + /** + * Creates a PerMessageDeflate instance. + * + * @param {Object} [options] Configuration options + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support + * for, or request, a custom client window size + * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ + * acknowledge disabling of client context takeover + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib + * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the + * use of a custom server window size + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed if context takeover is disabled + * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on + * deflate + * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on + * inflate + * @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; + this._options = options || {}; + this._threshold = + this._options.threshold !== undefined ? this._options.threshold : 1024; + this._isServer = !!isServer; + this._deflate = null; + this._inflate = null; + + this.params = null; + + if (!zlibLimiter) { + const concurrency = + this._options.concurrencyLimit !== undefined + ? this._options.concurrencyLimit + : 10; + zlibLimiter = new Limiter(concurrency); + } + } + + /** + * @type {String} + */ + static get extensionName() { + return 'permessage-deflate'; + } + + /** + * Create an extension negotiation offer. + * + * @return {Object} Extension parameters + * @public + */ + offer() { + const params = {}; + + if (this._options.serverNoContextTakeover) { + params.server_no_context_takeover = true; + } + if (this._options.clientNoContextTakeover) { + params.client_no_context_takeover = true; + } + if (this._options.serverMaxWindowBits) { + params.server_max_window_bits = this._options.serverMaxWindowBits; + } + if (this._options.clientMaxWindowBits) { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } else if (this._options.clientMaxWindowBits == null) { + params.client_max_window_bits = true; + } + + return params; + } + + /** + * Accept an extension negotiation offer/response. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Object} Accepted configuration + * @public + */ + accept(configurations) { + configurations = this.normalizeParams(configurations); + + this.params = this._isServer + ? this.acceptAsServer(configurations) + : this.acceptAsClient(configurations); + + return this.params; + } + + /** + * Releases all resources used by the extension. + * + * @public + */ + cleanup() { + if (this._inflate) { + this._inflate.close(); + this._inflate = null; + } + + 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' + ) + ); + } + } + } + + /** + * Accept an extension negotiation offer. + * + * @param {Array} offers The extension negotiation offers + * @return {Object} Accepted configuration + * @private + */ + acceptAsServer(offers) { + const opts = this._options; + const accepted = offers.find((params) => { + if ( + (opts.serverNoContextTakeover === false && + params.server_no_context_takeover) || + (params.server_max_window_bits && + (opts.serverMaxWindowBits === false || + (typeof opts.serverMaxWindowBits === 'number' && + opts.serverMaxWindowBits > params.server_max_window_bits))) || + (typeof opts.clientMaxWindowBits === 'number' && + !params.client_max_window_bits) + ) { + return false; + } + + return true; + }); + + if (!accepted) { + throw new Error('None of the extension offers can be accepted'); + } + + if (opts.serverNoContextTakeover) { + accepted.server_no_context_takeover = true; + } + if (opts.clientNoContextTakeover) { + accepted.client_no_context_takeover = true; + } + if (typeof opts.serverMaxWindowBits === 'number') { + accepted.server_max_window_bits = opts.serverMaxWindowBits; + } + if (typeof opts.clientMaxWindowBits === 'number') { + accepted.client_max_window_bits = opts.clientMaxWindowBits; + } else if ( + accepted.client_max_window_bits === true || + opts.clientMaxWindowBits === false + ) { + delete accepted.client_max_window_bits; + } + + return accepted; + } + + /** + * Accept the extension negotiation response. + * + * @param {Array} response The extension negotiation response + * @return {Object} Accepted configuration + * @private + */ + acceptAsClient(response) { + const params = response[0]; + + if ( + this._options.clientNoContextTakeover === false && + params.client_no_context_takeover + ) { + throw new Error('Unexpected parameter "client_no_context_takeover"'); + } + + if (!params.client_max_window_bits) { + if (typeof this._options.clientMaxWindowBits === 'number') { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } + } else if ( + this._options.clientMaxWindowBits === false || + (typeof this._options.clientMaxWindowBits === 'number' && + params.client_max_window_bits > this._options.clientMaxWindowBits) + ) { + throw new Error( + 'Unexpected or invalid parameter "client_max_window_bits"' + ); + } + + return params; + } + + /** + * Normalize parameters. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Array} The offers/response with normalized parameters + * @private + */ + normalizeParams(configurations) { + configurations.forEach((params) => { + Object.keys(params).forEach((key) => { + let value = params[key]; + + if (value.length > 1) { + throw new Error(`Parameter "${key}" must have only a single value`); + } + + value = value[0]; + + if (key === 'client_max_window_bits') { + if (value !== true) { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (!this._isServer) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else if (key === 'server_max_window_bits') { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if ( + key === 'client_no_context_takeover' || + key === 'server_no_context_takeover' + ) { + if (value !== true) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else { + throw new Error(`Unknown parameter "${key}"`); + } + + params[key] = value; + }); + }); + + return configurations; + } + + /** + * Decompress data. Concurrency limited. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + decompress(data, fin, callback) { + zlibLimiter.add((done) => { + this._decompress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + + /** + * Compress data. Concurrency limited. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + compress(data, fin, callback) { + zlibLimiter.add((done) => { + this._compress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + + /** + * Decompress data. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _decompress(data, fin, callback) { + const endpoint = this._isServer ? 'client' : 'server'; + + if (!this._inflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = + typeof this.params[key] !== 'number' + ? zlib.Z_DEFAULT_WINDOWBITS + : this.params[key]; + + this._inflate = zlib.createInflateRaw({ + ...this._options.zlibInflateOptions, + windowBits + }); + this._inflate[kPerMessageDeflate] = this; + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + this._inflate.on('error', inflateOnError); + this._inflate.on('data', inflateOnData); + } + + this._inflate[kCallback] = callback; + + this._inflate.write(data); + if (fin) this._inflate.write(TRAILER); + + this._inflate.flush(() => { + const err = this._inflate[kError]; + + if (err) { + this._inflate.close(); + this._inflate = null; + callback(err); + return; + } + + const data = bufferUtil.concat( + this._inflate[kBuffers], + this._inflate[kTotalLength] + ); + + 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); + }); + } + + /** + * Compress data. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _compress(data, fin, callback) { + const endpoint = this._isServer ? 'server' : 'client'; + + if (!this._deflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = + typeof this.params[key] !== 'number' + ? zlib.Z_DEFAULT_WINDOWBITS + : this.params[key]; + + this._deflate = zlib.createDeflateRaw({ + ...this._options.zlibDeflateOptions, + windowBits + }); + + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + + this._deflate.on('data', deflateOnData); + } + + this._deflate[kCallback] = callback; + + this._deflate.write(data); + this._deflate.flush(zlib.Z_SYNC_FLUSH, () => { + if (!this._deflate) { + // + // The deflate stream was closed while data was being processed. + // + return; + } + + 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.reset(); + } + + callback(null, data); + }); + } +} + +module.exports = PerMessageDeflate; + +/** + * The listener of the `zlib.DeflateRaw` stream `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function deflateOnData(chunk) { + this[kBuffers].push(chunk); + this[kTotalLength] += chunk.length; +} + +/** + * The listener of the `zlib.InflateRaw` stream `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function inflateOnData(chunk) { + this[kTotalLength] += chunk.length; + + if ( + this[kPerMessageDeflate]._maxPayload < 1 || + this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload + ) { + this[kBuffers].push(chunk); + return; + } + + this[kError] = new RangeError('Max payload size exceeded'); + this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'; + this[kError][kStatusCode] = 1009; + this.removeListener('data', inflateOnData); + this.reset(); +} + +/** + * The listener of the `zlib.InflateRaw` stream `'error'` event. + * + * @param {Error} err The emitted error + * @private + */ +function inflateOnError(err) { + // + // There is no need to call `Zlib#close()` as the handle is automatically + // closed when an error is emitted. + // + this[kPerMessageDeflate]._inflate = null; + err[kStatusCode] = 1007; + this[kCallback](err); +} diff --git a/testing/xpcshell/node-ws/lib/receiver.js b/testing/xpcshell/node-ws/lib/receiver.js new file mode 100644 index 0000000000..2d29d62bb0 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/receiver.js @@ -0,0 +1,618 @@ +'use strict'; + +const { Writable } = require('stream'); + +const PerMessageDeflate = require('./permessage-deflate'); +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; +const GET_PAYLOAD_LENGTH_64 = 2; +const GET_MASK = 3; +const GET_DATA = 4; +const INFLATING = 5; + +/** + * HyBi Receiver implementation. + * + * @extends Writable + */ +class Receiver extends Writable { + /** + * Creates a Receiver instance. + * + * @param {Object} [options] Options object + * @param {String} [options.binaryType=nodebuffer] The type for binary data + * @param {Object} [options.extensions] An object containing the negotiated + * extensions + * @param {Boolean} [options.isServer=false] Specifies whether to operate in + * client or server mode + * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + */ + constructor(options = {}) { + super(); + + this._binaryType = options.binaryType || BINARY_TYPES[0]; + this._extensions = options.extensions || {}; + this._isServer = !!options.isServer; + this._maxPayload = options.maxPayload | 0; + this._skipUTF8Validation = !!options.skipUTF8Validation; + this[kWebSocket] = undefined; + + this._bufferedBytes = 0; + this._buffers = []; + + this._compressed = false; + this._payloadLength = 0; + this._mask = undefined; + this._fragmented = 0; + this._masked = false; + this._fin = false; + this._opcode = 0; + + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragments = []; + + this._state = GET_INFO; + this._loop = false; + } + + /** + * Implements `Writable.prototype._write()`. + * + * @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(); + + this._bufferedBytes += chunk.length; + this._buffers.push(chunk); + this.startLoop(cb); + } + + /** + * Consumes `n` bytes from the buffered data. + * + * @param {Number} n The number of bytes to consume + * @return {Buffer} The consumed bytes + * @private + */ + consume(n) { + this._bufferedBytes -= n; + + if (n === this._buffers[0].length) return this._buffers.shift(); + + if (n < this._buffers[0].length) { + const buf = this._buffers[0]; + this._buffers[0] = buf.slice(n); + return buf.slice(0, n); + } + + const dst = Buffer.allocUnsafe(n); + + do { + const buf = this._buffers[0]; + const offset = dst.length - n; + + if (n >= buf.length) { + dst.set(this._buffers.shift(), offset); + } else { + dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); + this._buffers[0] = buf.slice(n); + } + + n -= buf.length; + } while (n > 0); + + return dst; + } + + /** + * Starts the parsing loop. + * + * @param {Function} cb Callback + * @private + */ + startLoop(cb) { + let err; + this._loop = true; + + do { + switch (this._state) { + case GET_INFO: + err = this.getInfo(); + break; + case GET_PAYLOAD_LENGTH_16: + err = this.getPayloadLength16(); + break; + case GET_PAYLOAD_LENGTH_64: + err = this.getPayloadLength64(); + break; + case GET_MASK: + this.getMask(); + break; + case GET_DATA: + err = this.getData(cb); + break; + default: + // `INFLATING` + this._loop = false; + return; + } + } while (this._loop); + + cb(err); + } + + /** + * Reads the first two bytes of a frame. + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + getInfo() { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + + const buf = this.consume(2); + + if ((buf[0] & 0x30) !== 0x00) { + this._loop = false; + return error( + RangeError, + 'RSV2 and RSV3 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_2_3' + ); + } + + const compressed = (buf[0] & 0x40) === 0x40; + + if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { + this._loop = false; + return error( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + } + + this._fin = (buf[0] & 0x80) === 0x80; + this._opcode = buf[0] & 0x0f; + this._payloadLength = buf[1] & 0x7f; + + if (this._opcode === 0x00) { + if (compressed) { + this._loop = false; + return error( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + } + + if (!this._fragmented) { + this._loop = false; + return error( + RangeError, + 'invalid opcode 0', + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + } + + this._opcode = this._fragmented; + } else if (this._opcode === 0x01 || this._opcode === 0x02) { + if (this._fragmented) { + this._loop = false; + return error( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + } + + this._compressed = compressed; + } else if (this._opcode > 0x07 && this._opcode < 0x0b) { + if (!this._fin) { + this._loop = false; + return error( + RangeError, + 'FIN must be set', + true, + 1002, + 'WS_ERR_EXPECTED_FIN' + ); + } + + if (compressed) { + this._loop = false; + return error( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + } + + if (this._payloadLength > 0x7d) { + this._loop = false; + return error( + RangeError, + `invalid payload length ${this._payloadLength}`, + true, + 1002, + 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' + ); + } + } else { + this._loop = false; + return error( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + } + + 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, + 'WS_ERR_EXPECTED_MASK' + ); + } + } else if (this._masked) { + this._loop = false; + return error( + RangeError, + 'MASK must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_MASK' + ); + } + + 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(); + } + + /** + * Gets extended payload length (7+16). + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + getPayloadLength16() { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + + this._payloadLength = this.consume(2).readUInt16BE(0); + return this.haveLength(); + } + + /** + * Gets extended payload length (7+64). + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + getPayloadLength64() { + if (this._bufferedBytes < 8) { + this._loop = false; + return; + } + + const buf = this.consume(8); + const num = buf.readUInt32BE(0); + + // + // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned + // if payload length is greater than this number. + // + if (num > Math.pow(2, 53 - 32) - 1) { + this._loop = false; + return error( + RangeError, + 'Unsupported WebSocket frame: payload length > 2^53 - 1', + false, + 1009, + 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH' + ); + } + + this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); + return this.haveLength(); + } + + /** + * Payload length has been read. + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + haveLength() { + if (this._payloadLength && this._opcode < 0x08) { + this._totalPayloadLength += this._payloadLength; + if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { + this._loop = false; + return error( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' + ); + } + } + + if (this._masked) this._state = GET_MASK; + else this._state = GET_DATA; + } + + /** + * Reads mask bytes. + * + * @private + */ + getMask() { + if (this._bufferedBytes < 4) { + this._loop = false; + return; + } + + this._mask = this.consume(4); + this._state = GET_DATA; + } + + /** + * Reads data bytes. + * + * @param {Function} cb Callback + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + getData(cb) { + let data = EMPTY_BUFFER; + + if (this._payloadLength) { + if (this._bufferedBytes < this._payloadLength) { + this._loop = false; + return; + } + + data = this.consume(this._payloadLength); + + if ( + this._masked && + (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0 + ) { + unmask(data, this._mask); + } + } + + if (this._opcode > 0x07) return this.controlMessage(data); + + if (this._compressed) { + this._state = INFLATING; + this.decompress(data, cb); + return; + } + + if (data.length) { + // + // This message is not compressed so its length is the sum of the payload + // length of all fragments. + // + this._messageLength = this._totalPayloadLength; + this._fragments.push(data); + } + + return this.dataMessage(); + } + + /** + * Decompresses data. + * + * @param {Buffer} data Compressed data + * @param {Function} cb Callback + * @private + */ + decompress(data, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + + perMessageDeflate.decompress(data, this._fin, (err, buf) => { + if (err) return cb(err); + + if (buf.length) { + this._messageLength += buf.length; + if (this._messageLength > this._maxPayload && this._maxPayload > 0) { + return cb( + error( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' + ) + ); + } + + this._fragments.push(buf); + } + + const er = this.dataMessage(); + if (er) return cb(er); + + this.startLoop(cb); + }); + } + + /** + * Handles a data message. + * + * @return {(Error|undefined)} A possible error + * @private + */ + dataMessage() { + if (this._fin) { + const messageLength = this._messageLength; + const fragments = this._fragments; + + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragmented = 0; + this._fragments = []; + + if (this._opcode === 2) { + let data; + + if (this._binaryType === 'nodebuffer') { + data = concat(fragments, messageLength); + } else if (this._binaryType === 'arraybuffer') { + data = toArrayBuffer(concat(fragments, messageLength)); + } else { + data = fragments; + } + + this.emit('message', data, true); + } else { + const buf = concat(fragments, messageLength); + + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + this._loop = false; + return error( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + } + + this.emit('message', buf, false); + } + } + + this._state = GET_INFO; + } + + /** + * Handles a control message. + * + * @param {Buffer} data Data to handle + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + controlMessage(data) { + if (this._opcode === 0x08) { + this._loop = false; + + if (data.length === 0) { + this.emit('conclude', 1005, EMPTY_BUFFER); + this.end(); + } else if (data.length === 1) { + return error( + RangeError, + 'invalid payload length 1', + true, + 1002, + 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' + ); + } else { + const code = data.readUInt16BE(0); + + if (!isValidStatusCode(code)) { + return error( + RangeError, + `invalid status code ${code}`, + true, + 1002, + 'WS_ERR_INVALID_CLOSE_CODE' + ); + } + + const buf = data.slice(2); + + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + return error( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + } + + this.emit('conclude', code, buf); + this.end(); + } + } else if (this._opcode === 0x09) { + this.emit('ping', data); + } else { + this.emit('pong', data); + } + + this._state = GET_INFO; + } +} + +module.exports = Receiver; + +/** + * Builds an error object. + * + * @param {function(new:Error|RangeError)} ErrorCtor The error constructor + * @param {String} message The error message + * @param {Boolean} prefix Specifies whether or not to add a default prefix to + * `message` + * @param {Number} statusCode The status code + * @param {String} errorCode The exposed error code + * @return {(Error|RangeError)} The error + * @private + */ +function error(ErrorCtor, message, prefix, statusCode, errorCode) { + const err = new ErrorCtor( + prefix ? `Invalid WebSocket frame: ${message}` : message + ); + + Error.captureStackTrace(err, error); + err.code = errorCode; + err[kStatusCode] = statusCode; + return err; +} diff --git a/testing/xpcshell/node-ws/lib/sender.js b/testing/xpcshell/node-ws/lib/sender.js new file mode 100644 index 0000000000..c848853629 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/sender.js @@ -0,0 +1,478 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls$" }] */ + +'use strict'; + +const net = require('net'); +const tls = require('tls'); +const { randomFillSync } = require('crypto'); + +const PerMessageDeflate = require('./permessage-deflate'); +const { EMPTY_BUFFER } = require('./constants'); +const { isValidStatusCode } = require('./validation'); +const { mask: applyMask, toBuffer } = require('./buffer-util'); + +const kByteLength = Symbol('kByteLength'); +const maskBuffer = Buffer.alloc(4); + +/** + * HyBi Sender implementation. + */ +class Sender { + /** + * Creates a Sender instance. + * + * @param {(net.Socket|tls.Socket)} socket The connection socket + * @param {Object} [extensions] An object containing the negotiated extensions + * @param {Function} [generateMask] The function used to generate the masking + * key + */ + constructor(socket, extensions, generateMask) { + this._extensions = extensions || {}; + + if (generateMask) { + this._generateMask = generateMask; + this._maskBuffer = Buffer.alloc(4); + } + + this._socket = socket; + + this._firstFragment = true; + this._compress = false; + + this._bufferedBytes = 0; + this._deflating = false; + this._queue = []; + } + + /** + * Frames a piece of data according to the HyBi WebSocket protocol. + * + * @param {(Buffer|String)} data The data to frame + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @return {(Buffer|String)[]} The framed data + * @public + */ + static frame(data, options) { + let mask; + let merge = false; + let offset = 2; + let skipMasking = false; + + if (options.mask) { + mask = options.maskBuffer || maskBuffer; + + if (options.generateMask) { + options.generateMask(mask); + } else { + randomFillSync(mask, 0, 4); + } + + skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; + offset = 6; + } + + let dataLength; + + if (typeof data === 'string') { + if ( + (!options.mask || skipMasking) && + options[kByteLength] !== undefined + ) { + dataLength = options[kByteLength]; + } else { + data = Buffer.from(data); + dataLength = data.length; + } + } else { + dataLength = data.length; + merge = options.mask && options.readOnly && !skipMasking; + } + + let payloadLength = dataLength; + + if (dataLength >= 65536) { + offset += 8; + payloadLength = 127; + } else if (dataLength > 125) { + offset += 2; + payloadLength = 126; + } + + const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset); + + target[0] = options.fin ? options.opcode | 0x80 : options.opcode; + if (options.rsv1) target[0] |= 0x40; + + target[1] = payloadLength; + + if (payloadLength === 126) { + target.writeUInt16BE(dataLength, 2); + } else if (payloadLength === 127) { + target[2] = target[3] = 0; + target.writeUIntBE(dataLength, 4, 6); + } + + if (!options.mask) return [target, data]; + + target[1] |= 0x80; + target[offset - 4] = mask[0]; + target[offset - 3] = mask[1]; + target[offset - 2] = mask[2]; + target[offset - 1] = mask[3]; + + if (skipMasking) return [target, data]; + + if (merge) { + applyMask(data, mask, target, offset, dataLength); + return [target]; + } + + applyMask(data, mask, data, 0, dataLength); + return [target, data]; + } + + /** + * Sends a close message to the other peer. + * + * @param {Number} [code] The status code component of the body + * @param {(String|Buffer)} [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) { + let buf; + + if (code === undefined) { + 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.length) { + buf = Buffer.allocUnsafe(2); + buf.writeUInt16BE(code, 0); + } else { + 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); + + if (typeof data === 'string') { + buf.write(data, 2); + } else { + buf.set(data, 2); + } + } + + const options = { + [kByteLength]: buf.length, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x08, + readOnly: false, + rsv1: false + }; + + if (this._deflating) { + this.enqueue([this.dispatch, buf, false, options, cb]); + } else { + this.sendFrame(Sender.frame(buf, options), cb); + } + } + + /** + * Sends a ping message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + ping(data, mask, cb) { + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (byteLength > 125) { + throw new RangeError('The data size must not be greater than 125 bytes'); + } + + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x09, + readOnly, + rsv1: false + }; + + if (this._deflating) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(Sender.frame(data, options), cb); + } + } + + /** + * Sends a pong message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + pong(data, mask, cb) { + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (byteLength > 125) { + throw new RangeError('The data size must not be greater than 125 bytes'); + } + + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x0a, + readOnly, + rsv1: false + }; + + if (this._deflating) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(Sender.frame(data, options), cb); + } + } + + /** + * Sends a data message to the other peer. + * + * @param {*} data The message to send + * @param {Object} options Options object + * @param {Boolean} [options.binary=false] Specifies whether `data` is binary + * or text + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` + * @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) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + let opcode = options.binary ? 2 : 1; + let rsv1 = options.compress; + + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (this._firstFragment) { + this._firstFragment = false; + if ( + rsv1 && + perMessageDeflate && + perMessageDeflate.params[ + perMessageDeflate._isServer + ? 'server_no_context_takeover' + : 'client_no_context_takeover' + ] + ) { + rsv1 = byteLength >= perMessageDeflate._threshold; + } + this._compress = rsv1; + } else { + rsv1 = false; + opcode = 0; + } + + if (options.fin) this._firstFragment = true; + + if (perMessageDeflate) { + const opts = { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1 + }; + + if (this._deflating) { + this.enqueue([this.dispatch, data, this._compress, opts, cb]); + } else { + this.dispatch(data, this._compress, opts, cb); + } + } else { + this.sendFrame( + Sender.frame(data, { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1: false + }), + cb + ); + } + } + + /** + * Dispatches a message. + * + * @param {(Buffer|String)} data The message to send + * @param {Boolean} [compress=false] Specifies whether or not to compress + * `data` + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + dispatch(data, compress, options, cb) { + if (!compress) { + this.sendFrame(Sender.frame(data, options), cb); + return; + } + + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + + this._bufferedBytes += options[kByteLength]; + 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 params = this._queue[i]; + const callback = params[params.length - 1]; + + if (typeof callback === 'function') callback(err); + } + + return; + } + + this._bufferedBytes -= options[kByteLength]; + this._deflating = false; + options.readOnly = false; + this.sendFrame(Sender.frame(buf, options), cb); + this.dequeue(); + }); + } + + /** + * Executes queued send operations. + * + * @private + */ + dequeue() { + while (!this._deflating && this._queue.length) { + const params = this._queue.shift(); + + this._bufferedBytes -= params[3][kByteLength]; + Reflect.apply(params[0], this, params.slice(1)); + } + } + + /** + * Enqueues a send operation. + * + * @param {Array} params Send operation parameters. + * @private + */ + enqueue(params) { + this._bufferedBytes += params[3][kByteLength]; + this._queue.push(params); + } + + /** + * Sends a frame. + * + * @param {Buffer[]} list The frame to send + * @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); + } + } +} + +module.exports = Sender; diff --git a/testing/xpcshell/node-ws/lib/stream.js b/testing/xpcshell/node-ws/lib/stream.js new file mode 100644 index 0000000000..230734b79a --- /dev/null +++ b/testing/xpcshell/node-ws/lib/stream.js @@ -0,0 +1,159 @@ +'use strict'; + +const { Duplex } = require('stream'); + +/** + * Emits the `'close'` event on a stream. + * + * @param {Duplex} stream 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 {Duplex} The duplex stream + * @public + */ +function createWebSocketStream(ws, options) { + let terminateOnDestroy = true; + + const duplex = new Duplex({ + ...options, + autoDestroy: false, + emitClose: false, + objectMode: false, + writableObjectMode: false + }); + + ws.on('message', function message(msg, isBinary) { + const data = + !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; + + if (!duplex.push(data)) ws.pause(); + }); + + ws.once('error', function error(err) { + if (duplex.destroyed) return; + + // Prevent `ws.terminate()` from being called by `duplex._destroy()`. + // + // - If the `'error'` event is emitted before the `'open'` event, then + // `ws.terminate()` is a noop as no socket is assigned. + // - Otherwise, the error is re-emitted by the listener of the `'error'` + // event of the `Receiver` object. The listener already closes the + // connection by calling `ws.close()`. This allows a close frame to be + // sent to the other peer. If `ws.terminate()` is called right after this, + // then the close frame might not be sent. + terminateOnDestroy = false; + 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); + }); + + if (terminateOnDestroy) 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.isPaused) ws.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/testing/xpcshell/node-ws/lib/subprotocol.js b/testing/xpcshell/node-ws/lib/subprotocol.js new file mode 100644 index 0000000000..d4381e8864 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/subprotocol.js @@ -0,0 +1,62 @@ +'use strict'; + +const { tokenChars } = require('./validation'); + +/** + * Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names. + * + * @param {String} header The field value of the header + * @return {Set} The subprotocol names + * @public + */ +function parse(header) { + const protocols = new Set(); + let start = -1; + let end = -1; + let i = 0; + + for (i; i < header.length; i++) { + const code = header.charCodeAt(i); + + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if ( + i !== 0 && + (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ + ) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x2c /* ',' */) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + + const protocol = header.slice(start, end); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + + if (start === -1 || end !== -1) { + throw new SyntaxError('Unexpected end of input'); + } + + const protocol = header.slice(start, i); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + return protocols; +} + +module.exports = { parse }; diff --git a/testing/xpcshell/node-ws/lib/validation.js b/testing/xpcshell/node-ws/lib/validation.js new file mode 100644 index 0000000000..44fc202906 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/validation.js @@ -0,0 +1,125 @@ +'use strict'; + +// +// Allowed token characters: +// +// '!', '#', '$', '%', '&', ''', '*', '+', '-', +// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~' +// +// tokenChars[32] === 0 // ' ' +// tokenChars[33] === 1 // '!' +// tokenChars[34] === 0 // '"' +// ... +// +// prettier-ignore +const tokenChars = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31 + 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63 + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127 +]; + +/** + * Checks if a status code is allowed in a close frame. + * + * @param {Number} code The status code + * @return {Boolean} `true` if the status code is valid, else `false` + * @public + */ +function isValidStatusCode(code) { + return ( + (code >= 1000 && + 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; +} + +module.exports = { + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars +}; + +/* istanbul ignore else */ +if (!process.env.WS_NO_UTF_8_VALIDATE) { + try { + const isValidUTF8 = require('utf-8-validate'); + + module.exports.isValidUTF8 = function (buf) { + return buf.length < 150 ? _isValidUTF8(buf) : isValidUTF8(buf); + }; + } catch (e) { + // Continue regardless of the error. + } +} diff --git a/testing/xpcshell/node-ws/lib/websocket-server.js b/testing/xpcshell/node-ws/lib/websocket-server.js new file mode 100644 index 0000000000..bac30eb330 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/websocket-server.js @@ -0,0 +1,535 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls|https$" }] */ + +'use strict'; + +const EventEmitter = require('events'); +const http = require('http'); +const https = require('https'); +const net = require('net'); +const tls = require('tls'); +const { createHash } = require('crypto'); + +const extension = require('./extension'); +const PerMessageDeflate = require('./permessage-deflate'); +const subprotocol = require('./subprotocol'); +const WebSocket = require('./websocket'); +const { GUID, kWebSocket } = require('./constants'); + +const keyRegex = /^[+/0-9A-Za-z]{22}==$/; + +const RUNNING = 0; +const CLOSING = 1; +const CLOSED = 2; + +/** + * Class representing a WebSocket server. + * + * @extends EventEmitter + */ +class WebSocketServer extends EventEmitter { + /** + * Create a `WebSocketServer` instance. + * + * @param {Object} options Configuration options + * @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|https.Server)} [options.server] A pre-created HTTP/S + * server to use + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @param {Function} [options.verifyClient] A hook to reject connections + * @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket` + * class to use. It must be the `WebSocket` class or class that extends it + * @param {Function} [callback] A listener for the `listening` event + */ + constructor(options, callback) { + super(); + + options = { + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + 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, + WebSocket, + ...options + }; + + if ( + (options.port == null && !options.server && !options.noServer) || + (options.port != null && (options.server || options.noServer)) || + (options.server && options.noServer) + ) { + throw new TypeError( + 'One and only one of the "port", "server", or "noServer" options ' + + 'must be specified' + ); + } + + if (options.port != null) { + this._server = http.createServer((req, res) => { + const body = http.STATUS_CODES[426]; + + res.writeHead(426, { + 'Content-Length': body.length, + 'Content-Type': 'text/plain' + }); + res.end(body); + }); + this._server.listen( + options.port, + options.host, + options.backlog, + callback + ); + } else if (options.server) { + this._server = options.server; + } + + 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, emitConnection); + } + }); + } + + if (options.perMessageDeflate === true) options.perMessageDeflate = {}; + if (options.clientTracking) { + this.clients = new Set(); + this._shouldEmitClose = false; + } + + this.options = options; + this._state = RUNNING; + } + + /** + * Returns the 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. + * + * @return {(Object|String|null)} The address of the server + * @public + */ + address() { + if (this.options.noServer) { + throw new Error('The server is operating in "noServer" mode'); + } + + if (!this._server) return null; + return this._server.address(); + } + + /** + * Stop the server from accepting new connections and emit the `'close'` event + * when all existing connections are closed. + * + * @param {Function} [cb] A one-time listener for the `'close'` event + * @public + */ + close(cb) { + if (this._state === CLOSED) { + if (cb) { + this.once('close', () => { + cb(new Error('The server is not running')); + }); + } + + process.nextTick(emitClose, this); + return; + } + + if (cb) this.once('close', cb); + + if (this._state === CLOSING) return; + this._state = CLOSING; + + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } + + if (this.clients) { + if (!this.clients.size) { + process.nextTick(emitClose, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(emitClose, this); + } + } else { + const server = this._server; + + this._removeListeners(); + this._removeListeners = this._server = null; + + // + // The HTTP/S server was created internally. Close it, and rely on its + // `'close'` event. + // + server.close(() => { + emitClose(this); + }); + } + } + + /** + * See if a given request should be handled by this server instance. + * + * @param {http.IncomingMessage} req Request object to inspect + * @return {Boolean} `true` if the request is valid, else `false` + * @public + */ + shouldHandle(req) { + if (this.options.path) { + const index = req.url.indexOf('?'); + const pathname = index !== -1 ? req.url.slice(0, index) : req.url; + + if (pathname !== this.options.path) return false; + } + + return true; + } + + /** + * Handle a HTTP Upgrade request. + * + * @param {http.IncomingMessage} req The request object + * @param {(net.Socket|tls.Socket)} socket The network socket between the + * server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @public + */ + handleUpgrade(req, socket, head, cb) { + socket.on('error', socketOnError); + + const key = req.headers['sec-websocket-key']; + const version = +req.headers['sec-websocket-version']; + + if (req.method !== 'GET') { + const message = 'Invalid HTTP method'; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); + return; + } + + if (req.headers.upgrade.toLowerCase() !== 'websocket') { + const message = 'Invalid Upgrade header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (!key || !keyRegex.test(key)) { + const message = 'Missing or invalid Sec-WebSocket-Key header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (version !== 8 && version !== 13) { + const message = 'Missing or invalid Sec-WebSocket-Version header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (!this.shouldHandle(req)) { + abortHandshake(socket, 400); + return; + } + + const secWebSocketProtocol = req.headers['sec-websocket-protocol']; + let protocols = new Set(); + + if (secWebSocketProtocol !== undefined) { + try { + protocols = subprotocol.parse(secWebSocketProtocol); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Protocol header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + + const secWebSocketExtensions = req.headers['sec-websocket-extensions']; + const extensions = {}; + + if ( + this.options.perMessageDeflate && + secWebSocketExtensions !== undefined + ) { + const perMessageDeflate = new PerMessageDeflate( + this.options.perMessageDeflate, + true, + this.options.maxPayload + ); + + try { + const offers = extension.parse(secWebSocketExtensions); + + if (offers[PerMessageDeflate.extensionName]) { + perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); + extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + } catch (err) { + const message = + 'Invalid or unacceptable Sec-WebSocket-Extensions header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + + // + // Optionally call external client verification handler. + // + if (this.options.verifyClient) { + const info = { + origin: + req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`], + secure: !!(req.socket.authorized || req.socket.encrypted), + req + }; + + if (this.options.verifyClient.length === 2) { + this.options.verifyClient(info, (verified, code, message, headers) => { + if (!verified) { + return abortHandshake(socket, code || 401, message, headers); + } + + this.completeUpgrade( + extensions, + key, + protocols, + req, + socket, + head, + cb + ); + }); + return; + } + + if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); + } + + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); + } + + /** + * Upgrade the connection to WebSocket. + * + * @param {Object} extensions The accepted extensions + * @param {String} key The value of the `Sec-WebSocket-Key` header + * @param {Set} protocols The subprotocols + * @param {http.IncomingMessage} req The request object + * @param {(net.Socket|tls.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, key, protocols, req, socket, head, cb) { + // + // Destroy the socket if the client has already sent a FIN packet. + // + if (!socket.readable || !socket.writable) return socket.destroy(); + + if (socket[kWebSocket]) { + throw new Error( + 'server.handleUpgrade() was called more than once with the same ' + + 'socket, possibly due to a misconfiguration' + ); + } + + if (this._state > RUNNING) return abortHandshake(socket, 503); + + const digest = createHash('sha1') + .update(key + GUID) + .digest('base64'); + + const headers = [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${digest}` + ]; + + const ws = new this.options.WebSocket(null); + + if (protocols.size) { + // + // Optionally call external protocol selection handler. + // + const protocol = this.options.handleProtocols + ? this.options.handleProtocols(protocols, req) + : protocols.values().next().value; + + if (protocol) { + headers.push(`Sec-WebSocket-Protocol: ${protocol}`); + ws._protocol = protocol; + } + } + + if (extensions[PerMessageDeflate.extensionName]) { + const params = extensions[PerMessageDeflate.extensionName].params; + const value = extension.format({ + [PerMessageDeflate.extensionName]: [params] + }); + headers.push(`Sec-WebSocket-Extensions: ${value}`); + ws._extensions = extensions; + } + + // + // Allow external modification/inspection of handshake headers. + // + this.emit('headers', headers, req); + + socket.write(headers.concat('\r\n').join('\r\n')); + socket.removeListener('error', socketOnError); + + ws.setSocket(socket, head, { + maxPayload: this.options.maxPayload, + skipUTF8Validation: this.options.skipUTF8Validation + }); + + if (this.clients) { + this.clients.add(ws); + ws.on('close', () => { + this.clients.delete(ws); + + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(emitClose, this); + } + }); + } + + cb(ws, req); + } +} + +module.exports = WebSocketServer; + +/** + * Add event listeners on an `EventEmitter` using a map of <event, listener> + * pairs. + * + * @param {EventEmitter} server The event emitter + * @param {Object.<String, Function>} map The listeners to add + * @return {Function} A function that will remove the added listeners when + * called + * @private + */ +function addListeners(server, map) { + for (const event of Object.keys(map)) server.on(event, map[event]); + + return function removeListeners() { + for (const event of Object.keys(map)) { + server.removeListener(event, map[event]); + } + }; +} + +/** + * Emit a `'close'` event on an `EventEmitter`. + * + * @param {EventEmitter} server The event emitter + * @private + */ +function emitClose(server) { + server._state = CLOSED; + server.emit('close'); +} + +/** + * Handle socket errors. + * + * @private + */ +function socketOnError() { + this.destroy(); +} + +/** + * Close the connection when preconditions are not fulfilled. + * + * @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request + * @param {Number} code The HTTP response status code + * @param {String} [message] The HTTP response body + * @param {Object} [headers] Additional HTTP response headers + * @private + */ +function abortHandshake(socket, code, message, headers) { + // + // The socket is writable unless the user destroyed or ended it before calling + // `server.handleUpgrade()` or in the `verifyClient` function, which is a user + // error. Handling this does not make much sense as the worst that can happen + // is that some of the data written by the user might be discarded due to the + // call to `socket.end()` below, which triggers an `'error'` event that in + // turn causes the socket to be destroyed. + // + message = message || http.STATUS_CODES[code]; + headers = { + Connection: 'close', + 'Content-Type': 'text/html', + 'Content-Length': Buffer.byteLength(message), + ...headers + }; + + socket.once('finish', socket.destroy); + + socket.end( + `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` + + Object.keys(headers) + .map((h) => `${h}: ${headers[h]}`) + .join('\r\n') + + '\r\n\r\n' + + message + ); +} + +/** + * Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least + * one listener for it, otherwise call `abortHandshake()`. + * + * @param {WebSocketServer} server The WebSocket server + * @param {http.IncomingMessage} req The request object + * @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request + * @param {Number} code The HTTP response status code + * @param {String} message The HTTP response body + * @private + */ +function abortHandshakeOrEmitwsClientError(server, req, socket, code, message) { + if (server.listenerCount('wsClientError')) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + + server.emit('wsClientError', err, socket, req); + } else { + abortHandshake(socket, code, message); + } +} diff --git a/testing/xpcshell/node-ws/lib/websocket.js b/testing/xpcshell/node-ws/lib/websocket.js new file mode 100644 index 0000000000..3132cc1500 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/websocket.js @@ -0,0 +1,1305 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Readable$" }] */ + +'use strict'; + +const EventEmitter = require('events'); +const https = require('https'); +const http = require('http'); +const net = require('net'); +const tls = require('tls'); +const { randomBytes, createHash } = require('crypto'); +const { Readable } = require('stream'); +const { URL } = require('url'); + +const PerMessageDeflate = require('./permessage-deflate'); +const Receiver = require('./receiver'); +const Sender = require('./sender'); +const { + BINARY_TYPES, + EMPTY_BUFFER, + GUID, + kForOnEventAttribute, + kListener, + kStatusCode, + kWebSocket, + NOOP +} = require('./constants'); +const { + EventTarget: { addEventListener, removeEventListener } +} = require('./event-target'); +const { format, parse } = require('./extension'); +const { toBuffer } = require('./buffer-util'); + +const closeTimeout = 30 * 1000; +const kAborted = Symbol('kAborted'); +const protocolVersions = [8, 13]; +const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; +const subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; + +/** + * Class representing a WebSocket. + * + * @extends EventEmitter + */ +class WebSocket extends EventEmitter { + /** + * Create a new `WebSocket`. + * + * @param {(String|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._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; + this._closeFrameReceived = false; + this._closeFrameSent = false; + this._closeMessage = EMPTY_BUFFER; + this._closeTimer = null; + this._extensions = {}; + this._paused = false; + 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 (protocols === undefined) { + protocols = []; + } else if (!Array.isArray(protocols)) { + if (typeof protocols === 'object' && protocols !== null) { + options = protocols; + protocols = []; + } else { + protocols = [protocols]; + } + } + + initAsClient(this, address, protocols, options); + } else { + this._isServer = true; + } + } + + /** + * 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} + */ + get binaryType() { + return this._binaryType; + } + + set binaryType(type) { + if (!BINARY_TYPES.includes(type)) return; + + this._binaryType = type; + + // + // Allow to change `binaryType` on the fly. + // + if (this._receiver) this._receiver._binaryType = type; + } + + /** + * @type {Number} + */ + get bufferedAmount() { + if (!this._socket) return this._bufferedAmount; + + return this._socket._writableState.length + this._sender._bufferedBytes; + } + + /** + * @type {String} + */ + get extensions() { + return Object.keys(this._extensions).join(); + } + + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onclose() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + + /** + * @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|tls.Socket)} socket The network socket between the + * server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options) { + const receiver = new Receiver({ + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation + }); + + this._sender = new Sender(socket, this._extensions, options.generateMask); + this._receiver = receiver; + this._socket = socket; + + receiver[kWebSocket] = this; + socket[kWebSocket] = this; + + receiver.on('conclude', receiverOnConclude); + receiver.on('drain', receiverOnDrain); + receiver.on('error', receiverOnError); + receiver.on('message', receiverOnMessage); + receiver.on('ping', receiverOnPing); + receiver.on('pong', receiverOnPong); + + socket.setTimeout(0); + socket.setNoDelay(); + + if (head.length > 0) socket.unshift(head); + + socket.on('close', socketOnClose); + socket.on('data', socketOnData); + socket.on('end', socketOnEnd); + socket.on('error', socketOnError); + + this._readyState = WebSocket.OPEN; + this.emit('open'); + } + + /** + * Emit the `'close'` event. + * + * @private + */ + emitClose() { + if (!this._socket) { + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + return; + } + + if (this._extensions[PerMessageDeflate.extensionName]) { + this._extensions[PerMessageDeflate.extensionName].cleanup(); + } + + this._receiver.removeAllListeners(); + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + } + + /** + * Start a closing handshake. + * + * +----------+ +-----------+ +----------+ + * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - + * | +----------+ +-----------+ +----------+ | + * +----------+ +-----------+ | + * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING + * +----------+ +-----------+ | + * | | | +---+ | + * +------------------------+-->|fin| - - - - + * | +---+ | +---+ + * - - - - -|fin|<---------------------+ + * +---+ + * + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing + * @public + */ + close(code, data) { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + return abortHandshake(this, this._req, msg); + } + + if (this.readyState === WebSocket.CLOSING) { + if ( + this._closeFrameSent && + (this._closeFrameReceived || this._receiver._writableState.errorEmitted) + ) { + this._socket.end(); + } + + return; + } + + 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 + // want to know if the close frame has been sent here. + // + if (err) return; + + this._closeFrameSent = true; + + if ( + this._closeFrameReceived || + this._receiver._writableState.errorEmitted + ) { + this._socket.end(); + } + }); + + // + // Specify a timeout for the closing handshake to complete. + // + this._closeTimer = setTimeout( + this._socket.destroy.bind(this._socket), + closeTimeout + ); + } + + /** + * Pause the socket. + * + * @public + */ + pause() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = true; + this._socket.pause(); + } + + /** + * 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 + * @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; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + 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 + * @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; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + this._sender.pong(data || EMPTY_BUFFER, mask, cb); + } + + /** + * Resume the socket. + * + * @public + */ + resume() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = false; + if (!this._receiver._writableState.needDrain) this._socket.resume(); + } + + /** + * Send a data message. + * + * @param {*} data The message to send + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @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 (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + 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 || EMPTY_BUFFER, opts, cb); + } + + /** + * Forcibly close the connection. + * + * @public + */ + terminate() { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + return abortHandshake(this, this._req, msg); + } + + if (this._socket) { + this._readyState = WebSocket.CLOSING; + this._socket.destroy(); + } + } +} + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +[ + 'binaryType', + 'bufferedAmount', + 'extensions', + 'isPaused', + 'protocol', + 'readyState', + 'url' +].forEach((property) => { + Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); +}); + +// +// Add the `onopen`, `onerror`, `onclose`, and `onmessage` attributes. +// See https://html.spec.whatwg.org/multipage/comms.html#the-websocket-interface +// +['open', 'error', 'close', 'message'].forEach((method) => { + Object.defineProperty(WebSocket.prototype, `on${method}`, { + enumerable: true, + get() { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; + } + + return null; + }, + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } + } + + if (typeof handler !== 'function') return; + + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); + } + }); +}); + +WebSocket.prototype.addEventListener = addEventListener; +WebSocket.prototype.removeEventListener = removeEventListener; + +module.exports = WebSocket; + +/** + * Initialize a WebSocket client. + * + * @param {WebSocket} websocket The client to initialize + * @param {(String|URL)} address The URL to which to connect + * @param {Array} protocols The subprotocols + * @param {Object} [options] Connection options + * @param {Boolean} [options.followRedirects=false] Whether or not to follow + * redirects + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the + * handshake request + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Number} [options.maxRedirects=10] The maximum number of redirects + * allowed + * @param {String} [options.origin] Value of the `Origin` or + * `Sec-WebSocket-Origin` header + * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable + * permessage-deflate + * @param {Number} [options.protocolVersion=13] Value of the + * `Sec-WebSocket-Version` header + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ +function initAsClient(websocket, address, protocols, options) { + const opts = { + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options, + createConnection: undefined, + socketPath: undefined, + hostname: undefined, + protocol: undefined, + timeout: undefined, + method: 'GET', + host: undefined, + path: undefined, + port: undefined + }; + + if (!protocolVersions.includes(opts.protocolVersion)) { + throw new RangeError( + `Unsupported protocol version: ${opts.protocolVersion} ` + + `(supported versions: ${protocolVersions.join(', ')})` + ); + } + + let parsedUrl; + + if (address instanceof URL) { + parsedUrl = address; + websocket._url = address.href; + } else { + try { + parsedUrl = new URL(address); + } catch (e) { + throw new SyntaxError(`Invalid URL: ${address}`); + } + + websocket._url = address; + } + + const isSecure = parsedUrl.protocol === 'wss:'; + const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; + let invalidURLMessage; + + if (parsedUrl.protocol !== 'ws:' && !isSecure && !isUnixSocket) { + invalidURLMessage = + 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"'; + } else if (isUnixSocket && !parsedUrl.pathname) { + invalidURLMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidURLMessage = 'The URL contains a fragment identifier'; + } + + if (invalidURLMessage) { + const err = new SyntaxError(invalidURLMessage); + + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } + } + + const defaultPort = isSecure ? 443 : 80; + const key = randomBytes(16).toString('base64'); + const request = isSecure ? https.request : http.request; + const protocolSet = new Set(); + 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; + opts.headers = { + ...opts.headers, + 'Sec-WebSocket-Version': opts.protocolVersion, + 'Sec-WebSocket-Key': key, + Connection: 'Upgrade', + Upgrade: 'websocket' + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + + if (opts.perMessageDeflate) { + perMessageDeflate = new PerMessageDeflate( + opts.perMessageDeflate !== true ? opts.perMessageDeflate : {}, + false, + opts.maxPayload + ); + opts.headers['Sec-WebSocket-Extensions'] = format({ + [PerMessageDeflate.extensionName]: perMessageDeflate.offer() + }); + } + if (protocols.length) { + for (const protocol of protocols) { + if ( + typeof protocol !== 'string' || + !subprotocolRegex.test(protocol) || + protocolSet.has(protocol) + ) { + throw new SyntaxError( + 'An invalid or duplicated subprotocol was specified' + ); + } + + protocolSet.add(protocol); + } + + opts.headers['Sec-WebSocket-Protocol'] = protocols.join(','); + } + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers['Sec-WebSocket-Origin'] = opts.origin; + } else { + opts.headers.Origin = opts.origin; + } + } + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; + } + + if (isUnixSocket) { + const parts = opts.path.split(':'); + + opts.socketPath = parts[0]; + opts.path = parts[1]; + } + + let req; + + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalUnixSocket = isUnixSocket; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isUnixSocket + ? opts.socketPath + : parsedUrl.host; + + const headers = options && options.headers; + + // + // Shallow copy the user provided options so that headers can be changed + // without mutating the original object. + // + options = { ...options, headers: {} }; + + if (headers) { + for (const [key, value] of Object.entries(headers)) { + options.headers[key.toLowerCase()] = value; + } + } + } else if (websocket.listenerCount('redirect') === 0) { + const isSameHost = isUnixSocket + ? websocket._originalUnixSocket + ? opts.socketPath === websocket._originalHostOrSocketPath + : false + : websocket._originalUnixSocket + ? false + : parsedUrl.host === websocket._originalHostOrSocketPath; + + if (!isSameHost || (websocket._originalSecure && !isSecure)) { + // + // Match curl 7.77.0 behavior and drop the following headers. These + // headers are also dropped when following a redirect to a subdomain. + // + delete opts.headers.authorization; + delete opts.headers.cookie; + + if (!isSameHost) delete opts.headers.host; + + opts.auth = undefined; + } + } + + // + // Match curl 7.77.0 behavior and make the first `Authorization` header win. + // If the `Authorization` header is set, then there is nothing to do as it + // will take precedence. + // + if (opts.auth && !options.headers.authorization) { + options.headers.authorization = + 'Basic ' + Buffer.from(opts.auth).toString('base64'); + } + + req = websocket._req = request(opts); + + if (websocket._redirects) { + // + // Unlike what is done for the `'upgrade'` event, no early exit is + // triggered here if the user calls `websocket.close()` or + // `websocket.terminate()` from a listener of the `'redirect'` event. This + // is because the user can also call `request.destroy()` with an error + // before calling `websocket.close()` or `websocket.terminate()` and this + // would result in an error being emitted on the `request` object with no + // `'error'` event listeners attached. + // + websocket.emit('redirect', websocket.url, req); + } + } else { + req = websocket._req = request(opts); + } + + if (opts.timeout) { + req.on('timeout', () => { + abortHandshake(websocket, req, 'Opening handshake has timed out'); + }); + } + + req.on('error', (err) => { + if (req === null || req[kAborted]) return; + + req = websocket._req = null; + emitErrorAndClose(websocket, err); + }); + + req.on('response', (res) => { + 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(); + + let addr; + + try { + addr = new URL(location, address); + } catch (e) { + const err = new SyntaxError(`Invalid URL: ${location}`); + emitErrorAndClose(websocket, err); + return; + } + + 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) => { + websocket.emit('upgrade', res); + + // + // The user may have closed the connection from a listener of the + // `'upgrade'` event. + // + if (websocket.readyState !== WebSocket.CONNECTING) return; + + req = websocket._req = null; + + if (res.headers.upgrade.toLowerCase() !== 'websocket') { + abortHandshake(websocket, socket, 'Invalid Upgrade header'); + return; + } + + const digest = createHash('sha1') + .update(key + GUID) + .digest('base64'); + + if (res.headers['sec-websocket-accept'] !== digest) { + abortHandshake(websocket, socket, 'Invalid Sec-WebSocket-Accept header'); + return; + } + + const serverProt = res.headers['sec-websocket-protocol']; + let protError; + + if (serverProt !== undefined) { + if (!protocolSet.size) { + protError = 'Server sent a subprotocol but none was requested'; + } else if (!protocolSet.has(serverProt)) { + protError = 'Server sent an invalid subprotocol'; + } + } else if (protocolSet.size) { + protError = 'Server sent no subprotocol'; + } + + if (protError) { + abortHandshake(websocket, socket, protError); + return; + } + + if (serverProt) websocket._protocol = serverProt; + + const secWebSocketExtensions = res.headers['sec-websocket-extensions']; + + if (secWebSocketExtensions !== undefined) { + if (!perMessageDeflate) { + const message = + 'Server sent a Sec-WebSocket-Extensions header but no extension ' + + 'was requested'; + abortHandshake(websocket, socket, message); + return; + } + + let extensions; + + try { + extensions = parse(secWebSocketExtensions); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + const extensionNames = Object.keys(extensions); + + if ( + extensionNames.length !== 1 || + extensionNames[0] !== PerMessageDeflate.extensionName + ) { + const message = 'Server indicated an extension that was not requested'; + abortHandshake(websocket, socket, message); + return; + } + + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + websocket._extensions[PerMessageDeflate.extensionName] = + perMessageDeflate; + } + + websocket.setSocket(socket, head, { + generateMask: opts.generateMask, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); + }); + + req.end(); +} + +/** + * Emit the `'error'` and `'close'` events. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {Error} The error to emit + * @private + */ +function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket.CLOSING; + websocket.emit('error', err); + websocket.emitClose(); +} + +/** + * Create a `net.Socket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {net.Socket} The newly created socket used to start the connection + * @private + */ +function netConnect(options) { + options.path = options.socketPath; + return net.connect(options); +} + +/** + * Create a `tls.TLSSocket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {tls.TLSSocket} The newly created socket used to start the connection + * @private + */ +function tlsConnect(options) { + options.path = undefined; + + if (!options.servername && options.servername !== '') { + options.servername = net.isIP(options.host) ? '' : options.host; + } + + return tls.connect(options); +} + +/** + * Abort the handshake and emit an error. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {(http.ClientRequest|net.Socket|tls.Socket)} stream The request to + * abort or the socket to destroy + * @param {String} message The error message + * @private + */ +function abortHandshake(websocket, stream, message) { + websocket._readyState = WebSocket.CLOSING; + + const err = new Error(message); + Error.captureStackTrace(err, abortHandshake); + + if (stream.setHeader) { + stream[kAborted] = true; + 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(); + } + + process.nextTick(emitErrorAndClose, websocket, err); + } else { + stream.destroy(err); + stream.once('error', websocket.emit.bind(websocket, 'error')); + stream.once('close', websocket.emitClose.bind(websocket)); + } +} + +/** + * 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. + * + * @param {Number} code The status code + * @param {Buffer} reason The reason for closing + * @private + */ +function receiverOnConclude(code, reason) { + const websocket = this[kWebSocket]; + + websocket._closeFrameReceived = true; + websocket._closeMessage = reason; + websocket._closeCode = code; + + if (websocket._socket[kWebSocket] === undefined) return; + + websocket._socket.removeListener('data', socketOnData); + process.nextTick(resume, websocket._socket); + + if (code === 1005) websocket.close(); + else websocket.close(code, reason); +} + +/** + * The listener of the `Receiver` `'drain'` event. + * + * @private + */ +function receiverOnDrain() { + const websocket = this[kWebSocket]; + + if (!websocket.isPaused) websocket._socket.resume(); +} + +/** + * The listener of the `Receiver` `'error'` event. + * + * @param {(RangeError|Error)} err The emitted error + * @private + */ +function receiverOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket._socket[kWebSocket] !== undefined) { + websocket._socket.removeListener('data', socketOnData); + + // + // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See + // https://github.com/websockets/ws/issues/1940. + // + process.nextTick(resume, websocket._socket); + + websocket.close(err[kStatusCode]); + } + + websocket.emit('error', err); +} + +/** + * The listener of the `Receiver` `'finish'` event. + * + * @private + */ +function receiverOnFinish() { + this[kWebSocket].emitClose(); +} + +/** + * The listener of the `Receiver` `'message'` event. + * + * @param {Buffer|ArrayBuffer|Buffer[])} data The message + * @param {Boolean} isBinary Specifies whether the message is binary or not + * @private + */ +function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit('message', data, isBinary); +} + +/** + * The listener of the `Receiver` `'ping'` event. + * + * @param {Buffer} data The data included in the ping frame + * @private + */ +function receiverOnPing(data) { + const websocket = this[kWebSocket]; + + websocket.pong(data, !websocket._isServer, NOOP); + websocket.emit('ping', data); +} + +/** + * The listener of the `Receiver` `'pong'` event. + * + * @param {Buffer} data The data included in the pong frame + * @private + */ +function receiverOnPong(data) { + this[kWebSocket].emit('pong', data); +} + +/** + * Resume a readable stream + * + * @param {Readable} stream The readable stream + * @private + */ +function resume(stream) { + stream.resume(); +} + +/** + * The listener of the `net.Socket` `'close'` event. + * + * @private + */ +function socketOnClose() { + const websocket = this[kWebSocket]; + + this.removeListener('close', socketOnClose); + this.removeListener('data', socketOnData); + this.removeListener('end', socketOnEnd); + + websocket._readyState = WebSocket.CLOSING; + + let chunk; + + // + // The close frame might not have been received or the `'end'` event emitted, + // for example, if the socket was destroyed due to an error. Ensure that the + // `receiver` stream is closed after writing any remaining buffered data to + // it. If the readable side of the socket is in flowing mode then there is no + // buffered data as everything has been already written and `readable.read()` + // will return `null`. If instead, the socket is paused, any possible buffered + // data will be read as a single chunk. + // + if ( + !this._readableState.endEmitted && + !websocket._closeFrameReceived && + !websocket._receiver._writableState.errorEmitted && + (chunk = websocket._socket.read()) !== null + ) { + websocket._receiver.write(chunk); + } + + websocket._receiver.end(); + + this[kWebSocket] = undefined; + + clearTimeout(websocket._closeTimer); + + if ( + websocket._receiver._writableState.finished || + websocket._receiver._writableState.errorEmitted + ) { + websocket.emitClose(); + } else { + websocket._receiver.on('error', receiverOnFinish); + websocket._receiver.on('finish', receiverOnFinish); + } +} + +/** + * The listener of the `net.Socket` `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function socketOnData(chunk) { + if (!this[kWebSocket]._receiver.write(chunk)) { + this.pause(); + } +} + +/** + * The listener of the `net.Socket` `'end'` event. + * + * @private + */ +function socketOnEnd() { + const websocket = this[kWebSocket]; + + websocket._readyState = WebSocket.CLOSING; + websocket._receiver.end(); + this.end(); +} + +/** + * The listener of the `net.Socket` `'error'` event. + * + * @private + */ +function socketOnError() { + const websocket = this[kWebSocket]; + + this.removeListener('error', socketOnError); + this.on('error', NOOP); + + if (websocket) { + websocket._readyState = WebSocket.CLOSING; + this.destroy(); + } +} diff --git a/testing/xpcshell/node-ws/package.json b/testing/xpcshell/node-ws/package.json new file mode 100644 index 0000000000..27b9244a46 --- /dev/null +++ b/testing/xpcshell/node-ws/package.json @@ -0,0 +1,61 @@ +{ + "name": "ws", + "version": "8.8.1", + "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", + "keywords": [ + "HyBi", + "Push", + "RFC-6455", + "WebSocket", + "WebSockets", + "real-time" + ], + "homepage": "https://github.com/websockets/ws", + "bugs": "https://github.com/websockets/ws/issues", + "repository": "websockets/ws", + "author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)", + "license": "MIT", + "main": "index.js", + "exports": { + "import": "./wrapper.mjs", + "require": "./index.js" + }, + "browser": "browser.js", + "engines": { + "node": ">=10.0.0" + }, + "files": [ + "browser.js", + "index.js", + "lib/*.js", + "wrapper.mjs" + ], + "scripts": { + "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" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + }, + "devDependencies": { + "benchmark": "^2.1.4", + "bufferutil": "^4.0.1", + "eslint": "^8.0.0", + "eslint-config-prettier": "^8.1.0", + "eslint-plugin-prettier": "^4.0.0", + "mocha": "^8.4.0", + "nyc": "^15.0.0", + "prettier": "^2.0.5", + "utf-8-validate": "^5.0.2" + } +} diff --git a/testing/xpcshell/node-ws/test/autobahn-server.js b/testing/xpcshell/node-ws/test/autobahn-server.js new file mode 100644 index 0000000000..24ade11497 --- /dev/null +++ b/testing/xpcshell/node-ws/test/autobahn-server.js @@ -0,0 +1,17 @@ +'use strict'; + +const WebSocket = require('../'); + +const port = process.argv.length > 2 ? parseInt(process.argv[2]) : 9001; +const wss = new WebSocket.Server({ port }, () => { + console.log( + `Listening to port ${port}. Use extra argument to define the port` + ); +}); + +wss.on('connection', (ws) => { + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); + ws.on('error', (e) => console.error(e)); +}); diff --git a/testing/xpcshell/node-ws/test/autobahn.js b/testing/xpcshell/node-ws/test/autobahn.js new file mode 100644 index 0000000000..51532fc52e --- /dev/null +++ b/testing/xpcshell/node-ws/test/autobahn.js @@ -0,0 +1,39 @@ +'use strict'; + +const WebSocket = require('../'); + +let currentTest = 1; +let testCount; + +function nextTest() { + let ws; + + if (currentTest > testCount) { + ws = new WebSocket('ws://localhost:9001/updateReports?agent=ws'); + return; + } + + console.log(`Running test case ${currentTest}/${testCount}`); + + ws = new WebSocket( + `ws://localhost:9001/runCase?case=${currentTest}&agent=ws` + ); + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); + ws.on('close', () => { + currentTest++; + process.nextTick(nextTest); + }); + ws.on('error', (e) => console.error(e)); +} + +const ws = new WebSocket('ws://localhost:9001/getCaseCount'); +ws.on('message', (data) => { + testCount = parseInt(data); +}); +ws.on('close', () => { + if (testCount > 0) { + nextTest(); + } +}); diff --git a/testing/xpcshell/node-ws/test/buffer-util.test.js b/testing/xpcshell/node-ws/test/buffer-util.test.js new file mode 100644 index 0000000000..a6b84c94b1 --- /dev/null +++ b/testing/xpcshell/node-ws/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/testing/xpcshell/node-ws/test/create-websocket-stream.test.js b/testing/xpcshell/node-ws/test/create-websocket-stream.test.js new file mode 100644 index 0000000000..4d51958cd9 --- /dev/null +++ b/testing/xpcshell/node-ws/test/create-websocket-stream.test.js @@ -0,0 +1,598 @@ +'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('..'); +const { EMPTY_BUFFER } = require('../lib/constants'); + +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, isBinary) => { + ws.on('close', (code, reason) => { + assert.deepStrictEqual(message, chunk); + assert.ok(isBinary); + assert.strictEqual(code, 1005); + assert.strictEqual(reason, EMPTY_BUFFER); + 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) => { + let duplexCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + 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.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + duplex.on('close', () => { + duplexCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + }); + + wss.on('connection', (ws) => { + ws._socket.write(Buffer.from([0x85, 0x00])); + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (duplexCloseEventEmitted) wss.close(done); + }); + }); + }); + + 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(); + }); + }); + }); + + it('converts text messages to strings in readable object mode', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const events = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws, { readableObjectMode: true }); + + duplex.on('data', (data) => { + events.push('data'); + assert.strictEqual(data, 'foo'); + }); + + duplex.on('end', () => { + events.push('end'); + duplex.end(); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(events, ['data', 'end']); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.send('foo'); + ws.close(); + }); + }); + + it('resumes the socket if `readyState` is `CLOSING`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + ws.on('message', () => { + assert.ok(ws._socket.isPaused()); + + duplex.on('close', () => { + wss.close(done); + }); + + duplex.end(); + + process.nextTick(() => { + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + duplex.resume(); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.send(randomBytes(16 * 1024)); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/event-target.test.js b/testing/xpcshell/node-ws/test/event-target.test.js new file mode 100644 index 0000000000..5caaa5c273 --- /dev/null +++ b/testing/xpcshell/node-ws/test/event-target.test.js @@ -0,0 +1,253 @@ +'use strict'; + +const assert = require('assert'); + +const { + CloseEvent, + ErrorEvent, + Event, + MessageEvent +} = require('../lib/event-target'); + +describe('Event', () => { + describe('#ctor', () => { + it('takes a `type` argument', () => { + const event = new Event('foo'); + + assert.strictEqual(event.type, 'foo'); + }); + }); + + describe('Properties', () => { + describe('`target`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + Event.prototype, + 'target' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to `null`', () => { + const event = new Event('foo'); + + assert.strictEqual(event.target, null); + }); + }); + + describe('`type`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + Event.prototype, + 'type' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + }); + }); +}); + +describe('CloseEvent', () => { + it('inherits from `Event`', () => { + assert.ok(CloseEvent.prototype instanceof Event); + }); + + describe('#ctor', () => { + it('takes a `type` argument', () => { + const event = new CloseEvent('foo'); + + assert.strictEqual(event.type, 'foo'); + }); + + it('takes an optional `options` argument', () => { + const event = new CloseEvent('close', { + code: 1000, + reason: 'foo', + wasClean: true + }); + + assert.strictEqual(event.type, 'close'); + assert.strictEqual(event.code, 1000); + assert.strictEqual(event.reason, 'foo'); + assert.strictEqual(event.wasClean, true); + }); + }); + + describe('Properties', () => { + describe('`code`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + CloseEvent.prototype, + 'code' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to 0', () => { + const event = new CloseEvent('close'); + + assert.strictEqual(event.code, 0); + }); + }); + + describe('`reason`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + CloseEvent.prototype, + 'reason' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to an empty string', () => { + const event = new CloseEvent('close'); + + assert.strictEqual(event.reason, ''); + }); + }); + + describe('`wasClean`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + CloseEvent.prototype, + 'wasClean' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to false', () => { + const event = new CloseEvent('close'); + + assert.strictEqual(event.wasClean, false); + }); + }); + }); +}); + +describe('ErrorEvent', () => { + it('inherits from `Event`', () => { + assert.ok(ErrorEvent.prototype instanceof Event); + }); + + describe('#ctor', () => { + it('takes a `type` argument', () => { + const event = new ErrorEvent('foo'); + + assert.strictEqual(event.type, 'foo'); + }); + + it('takes an optional `options` argument', () => { + const error = new Error('Oops'); + const event = new ErrorEvent('error', { error, message: error.message }); + + assert.strictEqual(event.type, 'error'); + assert.strictEqual(event.error, error); + assert.strictEqual(event.message, error.message); + }); + }); + + describe('Properties', () => { + describe('`error`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + ErrorEvent.prototype, + 'error' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to `null`', () => { + const event = new ErrorEvent('error'); + + assert.strictEqual(event.error, null); + }); + }); + + describe('`message`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + ErrorEvent.prototype, + 'message' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to an empty string', () => { + const event = new ErrorEvent('error'); + + assert.strictEqual(event.message, ''); + }); + }); + }); +}); + +describe('MessageEvent', () => { + it('inherits from `Event`', () => { + assert.ok(MessageEvent.prototype instanceof Event); + }); + + describe('#ctor', () => { + it('takes a `type` argument', () => { + const event = new MessageEvent('foo'); + + assert.strictEqual(event.type, 'foo'); + }); + + it('takes an optional `options` argument', () => { + const event = new MessageEvent('message', { data: 'bar' }); + + assert.strictEqual(event.type, 'message'); + assert.strictEqual(event.data, 'bar'); + }); + }); + + describe('Properties', () => { + describe('`data`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + MessageEvent.prototype, + 'data' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to `null`', () => { + const event = new MessageEvent('message'); + + assert.strictEqual(event.data, null); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/extension.test.js b/testing/xpcshell/node-ws/test/extension.test.js new file mode 100644 index 0000000000..a4b3e749d0 --- /dev/null +++ b/testing/xpcshell/node-ws/test/extension.test.js @@ -0,0 +1,190 @@ +'use strict'; + +const assert = require('assert'); + +const { format, parse } = require('../lib/extension'); + +describe('extension', () => { + describe('parse', () => { + it('parses a single extension', () => { + assert.deepStrictEqual(parse('foo'), { + foo: [{ __proto__: null }], + __proto__: null + }); + }); + + 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', () => { + 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', () => { + assert.deepStrictEqual(parse('foo;bar="hi"'), { + foo: [{ bar: ['hi'], __proto__: null }], + __proto__: null + }); + assert.deepStrictEqual(parse('foo;bar="\\0"'), { + foo: [{ bar: ['0'], __proto__: null }], + __proto__: null + }); + assert.deepStrictEqual(parse('foo;bar="b\\a\\z"'), { + foo: [{ bar: ['baz'], __proto__: null }], + __proto__: null + }); + assert.deepStrictEqual(parse('foo;bar="b\\az";bar'), { + foo: [{ bar: ['baz', true], __proto__: null }], + __proto__: null + }); + assert.throws( + () => parse('foo;bar="baz"qux'), + /^SyntaxError: Unexpected character at index 13$/ + ); + assert.throws( + () => parse('foo;bar="baz" qux'), + /^SyntaxError: Unexpected character at index 14$/ + ); + }); + + it('works with names that match `Object.prototype` property names', () => { + assert.deepStrictEqual(parse('hasOwnProperty, toString'), { + hasOwnProperty: [{ __proto__: null }], + toString: [{ __proto__: null }], + __proto__: null + }); + assert.deepStrictEqual(parse('foo;constructor'), { + foo: [{ constructor: [true], __proto__: null }], + __proto__: null + }); + }); + + it('ignores the optional white spaces', () => { + const header = 'foo; bar\t; \tbaz=1\t ; bar="1"\t\t, \tqux\t ;norf'; + + 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', () => { + [ + [',', 0], + ['foo,,', 4], + ['foo, ,', 6], + ['foo;=', 4], + ['foo; =', 5], + ['foo;;', 4], + ['foo; ;', 5], + ['foo;bar=,', 8], + ['foo;bar=""', 9] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a white space is misplaced', () => { + [ + [' foo', 0], + ['f oo', 2], + ['foo;ba r', 7], + ['foo;bar =', 8], + ['foo;bar= ', 8], + ['foo;bar=ba z', 11] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a token contains invalid characters', () => { + [ + ['f@o', 1], + ['f\\oo', 1], + ['"foo"', 0], + ['f"oo"', 1], + ['foo;b@r', 5], + ['foo;b\\ar', 5], + ['foo;"bar"', 4], + ['foo;b"ar"', 5], + ['foo;bar=b@z', 9], + ['foo;bar=b\\az ', 9], + ['foo;bar="b@z"', 10], + ['foo;bar="baz;"', 12], + ['foo;bar=b"az"', 9], + ['foo;bar="\\\\"', 10] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if the header value ends prematurely', () => { + [ + '', + 'foo ', + 'foo\t', + 'foo, ', + 'foo;', + 'foo;bar ', + 'foo;bar,', + 'foo;bar; ', + 'foo;bar=', + 'foo;bar="baz', + 'foo;bar="1\\', + 'foo;bar="baz" ' + ].forEach((header) => { + assert.throws( + () => parse(header), + /^SyntaxError: Unexpected end of input$/ + ); + }); + }); + }); + + describe('format', () => { + it('formats a single extension', () => { + const extensions = format({ foo: {} }); + + assert.strictEqual(extensions, 'foo'); + }); + + 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', () => { + const extensions = format({ + foo: [{}, { baz: true }], + bar: { baz: true } + }); + + assert.strictEqual(extensions, 'foo, foo; baz, bar; baz'); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/fixtures/ca-certificate.pem b/testing/xpcshell/node-ws/test/fixtures/ca-certificate.pem new file mode 100644 index 0000000000..0f1658821d --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/ca-certificate.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVoCCQCXqK2FegDgiDAKBggqhkjOPQQDAjBhMQswCQYDVQQGEwJJVDEQ +MA4GA1UECAwHUGVydWdpYTEQMA4GA1UEBwwHRm9saWdubzETMBEGA1UECgwKd2Vi +c29ja2V0czELMAkGA1UECwwCd3MxDDAKBgNVBAMMA2NhMTAgFw0yMTA1MjYxOTA1 +MjdaGA8yMTIxMDUwMjE5MDUyN1owYTELMAkGA1UEBhMCSVQxEDAOBgNVBAgMB1Bl +cnVnaWExEDAOBgNVBAcMB0ZvbGlnbm8xEzARBgNVBAoMCndlYnNvY2tldHMxCzAJ +BgNVBAsMAndzMQwwCgYDVQQDDANjYTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC +AASHE75QDQN6XNo/711YSbckaa8r4lt0hGkgtADaBFT9Qn9gcm5omapePZT76Ff9 +rwjMcS+YPXS7J7bk+QHLihJMMAoGCCqGSM49BAMCA0kAMEYCIQCUMdUih+sE0ZTu +ORlcKiM8DKyiKkGU4Ty+dslz6nVJjAIhAMcSy0SBsBDgsai1s9aCmAGJXCijNb6g +vfWaatgq+ma2 +-----END CERTIFICATE----- diff --git a/testing/xpcshell/node-ws/test/fixtures/ca-key.pem b/testing/xpcshell/node-ws/test/fixtures/ca-key.pem new file mode 100644 index 0000000000..a9352fb6a2 --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/ca-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAa/Onpk27cLkqzje69Bac8yG+LTBXIPWT8yGlyjEFbboAoGCCqGSM49 +AwEHoUQDQgAEhxO+UA0DelzaP+9dWEm3JGmvK+JbdIRpILQA2gRU/UJ/YHJuaJmq +Xj2U++hX/a8IzHEvmD10uye25PkBy4oSTA== +-----END EC PRIVATE KEY----- diff --git a/testing/xpcshell/node-ws/test/fixtures/certificate.pem b/testing/xpcshell/node-ws/test/fixtures/certificate.pem new file mode 100644 index 0000000000..538553ee08 --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/certificate.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBujCCAWACCQDjKdAMt3mZhDAKBggqhkjOPQQDAjBkMQswCQYDVQQGEwJJVDEQ +MA4GA1UECAwHUGVydWdpYTEQMA4GA1UEBwwHRm9saWdubzETMBEGA1UECgwKd2Vi +c29ja2V0czELMAkGA1UECwwCd3MxDzANBgNVBAMMBnNlcnZlcjAgFw0yMTA1MjYx +OTEwMjlaGA8yMTIxMDUwMjE5MTAyOVowZDELMAkGA1UEBhMCSVQxEDAOBgNVBAgM +B1BlcnVnaWExEDAOBgNVBAcMB0ZvbGlnbm8xEzARBgNVBAoMCndlYnNvY2tldHMx +CzAJBgNVBAsMAndzMQ8wDQYDVQQDDAZzZXJ2ZXIwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAAQKhyRhdSVOecbJU4O5XkB/iGodbnCOqmchs4TXmE3Prv5SrNDhODDv +rOWTXwR3/HrrdNfOzPdb54amu8POwpohMAoGCCqGSM49BAMCA0gAMEUCIHMRUSPl +8FGkDLl8KF1A+SbT2ds3zUOLdYvj30Z2SKSVAiEA84U/R1ly9wf5Rzv93sTHI99o +KScsr/PHN8rT2pop5pk= +-----END CERTIFICATE----- diff --git a/testing/xpcshell/node-ws/test/fixtures/client-certificate.pem b/testing/xpcshell/node-ws/test/fixtures/client-certificate.pem new file mode 100644 index 0000000000..0e20560b8c --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/client-certificate.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAV0CCQDDIX2dKuKP0zAKBggqhkjOPQQDAjBhMQswCQYDVQQGEwJJVDEQ +MA4GA1UECAwHUGVydWdpYTEQMA4GA1UEBwwHRm9saWdubzETMBEGA1UECgwKd2Vi +c29ja2V0czELMAkGA1UECwwCd3MxDDAKBgNVBAMMA2NhMTAgFw0yMTA1MjYxOTE3 +NDJaGA8yMTIxMDUwMjE5MTc0MlowZDELMAkGA1UEBhMCSVQxEDAOBgNVBAgMB1Bl +cnVnaWExEDAOBgNVBAcMB0ZvbGlnbm8xEzARBgNVBAoMCndlYnNvY2tldHMxCzAJ +BgNVBAsMAndzMQ8wDQYDVQQDDAZhZ2VudDEwWTATBgcqhkjOPQIBBggqhkjOPQMB +BwNCAATwHlNS2b13TMhBTSWBXAn6TEPxrsvG93ZZyUlmrEMOXSMX2hI7sv660YNj ++eGyE2CV33XsQxV3TUqi51fUjIu8MAoGCCqGSM49BAMCA0gAMEUCIQCxsqBre+Do +jnfg6XmCaB0fywNzcDlvdoVNuNAWfVNrSAIgDQmbM0mXZaSAkf4sgtKdXnpE3vrb +MElb457Bi3B+rkE= +-----END CERTIFICATE----- diff --git a/testing/xpcshell/node-ws/test/fixtures/client-key.pem b/testing/xpcshell/node-ws/test/fixtures/client-key.pem new file mode 100644 index 0000000000..e034f57fc2 --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKVGskK0UR86WwMo5H0+hNAFGRBYsEevK3ye4y1YberVoAoGCCqGSM49 +AwEHoUQDQgAE8B5TUtm9d0zIQU0lgVwJ+kxD8a7Lxvd2WclJZqxDDl0jF9oSO7L+ +utGDY/nhshNgld917EMVd01KoudX1IyLvA== +-----END EC PRIVATE KEY----- diff --git a/testing/xpcshell/node-ws/test/fixtures/key.pem b/testing/xpcshell/node-ws/test/fixtures/key.pem new file mode 100644 index 0000000000..05bfdb71ed --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIIjLz7YEWIrsGem2+YV8eJhHhetsjYIrjuqJLbdG7B3zoAoGCCqGSM49 +AwEHoUQDQgAECockYXUlTnnGyVODuV5Af4hqHW5wjqpnIbOE15hNz67+UqzQ4Tgw +76zlk18Ed/x663TXzsz3W+eGprvDzsKaIQ== +-----END EC PRIVATE KEY----- diff --git a/testing/xpcshell/node-ws/test/limiter.test.js b/testing/xpcshell/node-ws/test/limiter.test.js new file mode 100644 index 0000000000..95141f0f5c --- /dev/null +++ b/testing/xpcshell/node-ws/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/testing/xpcshell/node-ws/test/permessage-deflate.test.js b/testing/xpcshell/node-ws/test/permessage-deflate.test.js new file mode 100644 index 0000000000..a9c9bf165c --- /dev/null +++ b/testing/xpcshell/node-ws/test/permessage-deflate.test.js @@ -0,0 +1,647 @@ +'use strict'; + +const assert = require('assert'); + +const PerMessageDeflate = require('../lib/permessage-deflate'); +const extension = require('../lib/extension'); + +describe('PerMessageDeflate', () => { + describe('#offer', () => { + it('creates an offer', () => { + const perMessageDeflate = new PerMessageDeflate(); + + assert.deepStrictEqual(perMessageDeflate.offer(), { + client_max_window_bits: true + }); + }); + + it('uses the configuration options', () => { + const perMessageDeflate = new PerMessageDeflate({ + serverNoContextTakeover: true, + clientNoContextTakeover: true, + serverMaxWindowBits: 10, + clientMaxWindowBits: 11 + }); + + assert.deepStrictEqual(perMessageDeflate.offer(), { + server_no_context_takeover: true, + client_no_context_takeover: true, + server_max_window_bits: 10, + client_max_window_bits: 11 + }); + }); + }); + + 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' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Parameter "server_no_context_takeover" must have only a single value$/ + ); + }); + + it('throws an error if a parameter has an invalid name', () => { + const perMessageDeflate = new PerMessageDeflate(); + const extensions = extension.parse('permessage-deflate;foo'); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Unknown parameter "foo"$/ + ); + }); + + 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' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "client_no_context_takeover": 10$/ + ); + }); + + 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' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "server_no_context_takeover": 10$/ + ); + }); + + it('throws an error if server_max_window_bits has an invalid value', () => { + const perMessageDeflate = new PerMessageDeflate(); + + let extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=7' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "server_max_window_bits": 7$/ + ); + + extensions = extension.parse( + 'permessage-deflate; server_max_window_bits' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "server_max_window_bits": true$/ + ); + }); + + 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', () => { + const perMessageDeflate = new PerMessageDeflate({}, true); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover; ' + + 'client_no_context_takeover; server_max_window_bits=10; ' + + 'client_max_window_bits=11' + ); + + assert.deepStrictEqual( + perMessageDeflate.accept(extensions['permessage-deflate']), + { + server_no_context_takeover: true, + client_no_context_takeover: true, + server_max_window_bits: 10, + client_max_window_bits: 11, + __proto__: null + } + ); + }); + + it('prefers the configuration options', () => { + const perMessageDeflate = new PerMessageDeflate( + { + serverNoContextTakeover: true, + clientNoContextTakeover: true, + serverMaxWindowBits: 12, + clientMaxWindowBits: 11 + }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=14; client_max_window_bits=13' + ); + + assert.deepStrictEqual( + perMessageDeflate.accept(extensions['permessage-deflate']), + { + server_no_context_takeover: true, + client_no_context_takeover: true, + server_max_window_bits: 12, + client_max_window_bits: 11, + __proto__: null + } + ); + }); + + it('accepts the first supported offer', () => { + const perMessageDeflate = new PerMessageDeflate( + { serverMaxWindowBits: 11 }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=10, permessage-deflate' + ); + + assert.deepStrictEqual( + perMessageDeflate.accept(extensions['permessage-deflate']), + { + server_max_window_bits: 11, + __proto__: null + } + ); + }); + + it('throws an error if server_no_context_takeover is unsupported', () => { + const perMessageDeflate = new PerMessageDeflate( + { serverNoContextTakeover: false }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: None of the extension offers can be accepted$/ + ); + }); + + it('throws an error if server_max_window_bits is unsupported', () => { + const perMessageDeflate = new PerMessageDeflate( + { serverMaxWindowBits: false }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=10' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: None of the extension offers can be accepted$/ + ); + }); + + it('throws an error if server_max_window_bits is less than configuration', () => { + const perMessageDeflate = new PerMessageDeflate( + { serverMaxWindowBits: 11 }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=10' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: None of the extension offers can be accepted$/ + ); + }); + + it('throws an error if client_max_window_bits is unsupported on client', () => { + const perMessageDeflate = new PerMessageDeflate( + { clientMaxWindowBits: 10 }, + true + ); + const extensions = extension.parse('permessage-deflate'); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: None of the extension offers can be accepted$/ + ); + }); + + it('throws an error if client_max_window_bits has an invalid value', () => { + const perMessageDeflate = new PerMessageDeflate({}, true); + + const extensions = extension.parse( + 'permessage-deflate; client_max_window_bits=16' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "client_max_window_bits": 16$/ + ); + }); + }); + + describe('As client', () => { + it('accepts a response with no parameters', () => { + const perMessageDeflate = new PerMessageDeflate({}); + + assert.deepStrictEqual(perMessageDeflate.accept([{}]), {}); + }); + + it('accepts a response with parameters', () => { + const perMessageDeflate = new PerMessageDeflate({}); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover; ' + + 'client_no_context_takeover; server_max_window_bits=10; ' + + 'client_max_window_bits=11' + ); + + assert.deepStrictEqual( + perMessageDeflate.accept(extensions['permessage-deflate']), + { + server_no_context_takeover: true, + client_no_context_takeover: true, + server_max_window_bits: 10, + client_max_window_bits: 11, + __proto__: null + } + ); + }); + + it('throws an error if client_no_context_takeover is unsupported', () => { + const perMessageDeflate = new PerMessageDeflate({ + clientNoContextTakeover: false + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Unexpected parameter "client_no_context_takeover"$/ + ); + }); + + it('throws an error if client_max_window_bits is unsupported', () => { + const perMessageDeflate = new PerMessageDeflate({ + clientMaxWindowBits: false + }); + const extensions = extension.parse( + 'permessage-deflate; client_max_window_bits=10' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Unexpected or invalid parameter "client_max_window_bits"$/ + ); + }); + + it('throws an error if client_max_window_bits is greater than configuration', () => { + const perMessageDeflate = new PerMessageDeflate({ + clientMaxWindowBits: 10 + }); + const extensions = extension.parse( + 'permessage-deflate; client_max_window_bits=11' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Unexpected or invalid parameter "client_max_window_bits"$/ + ); + }); + + it('throws an error if client_max_window_bits has an invalid value', () => { + const perMessageDeflate = new PerMessageDeflate(); + + let extensions = extension.parse( + 'permessage-deflate; client_max_window_bits=16' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "client_max_window_bits": 16$/ + ); + + extensions = extension.parse( + 'permessage-deflate; client_max_window_bits' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "client_max_window_bits": true$/ + ); + }); + + it('uses the config value if client_max_window_bits is not specified', () => { + const perMessageDeflate = new PerMessageDeflate({ + clientMaxWindowBits: 10 + }); + + assert.deepStrictEqual(perMessageDeflate.accept([{}]), { + client_max_window_bits: 10 + }); + }); + }); + }); + + describe('#compress and #decompress', () => { + it('works with unfragmented messages', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const buf = Buffer.from([1, 2, 3]); + + perMessageDeflate.accept([{}]); + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + perMessageDeflate.decompress(data, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + done(); + }); + }); + }); + + it('works with fragmented messages', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const buf = Buffer.from([1, 2, 3, 4]); + + perMessageDeflate.accept([{}]); + + perMessageDeflate.compress(buf.slice(0, 2), false, (err, compressed1) => { + if (err) return done(err); + + perMessageDeflate.compress(buf.slice(2), true, (err, compressed2) => { + if (err) return done(err); + + perMessageDeflate.decompress(compressed1, false, (err, data1) => { + if (err) return done(err); + + perMessageDeflate.decompress(compressed2, true, (err, data2) => { + if (err) return done(err); + + assert.ok(Buffer.concat([data1, data2]).equals(buf)); + done(); + }); + }); + }); + }); + }); + + it('works with the negotiated parameters', (done) => { + const perMessageDeflate = new PerMessageDeflate({ + memLevel: 5, + level: 9 + }); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover; ' + + 'client_no_context_takeover; server_max_window_bits=10; ' + + 'client_max_window_bits=11' + ); + const buf = Buffer.from("Some compressible data, it's compressible."); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + perMessageDeflate.decompress(data, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + done(); + }); + }); + }); + + it('honors the `level` option', (done) => { + const lev0 = new PerMessageDeflate({ + zlibDeflateOptions: { level: 0 } + }); + const lev9 = new PerMessageDeflate({ + zlibDeflateOptions: { level: 9 } + }); + const extensionStr = + 'permessage-deflate; server_no_context_takeover; ' + + 'client_no_context_takeover; server_max_window_bits=10; ' + + 'client_max_window_bits=11'; + const buf = Buffer.from("Some compressible data, it's compressible."); + + lev0.accept(extension.parse(extensionStr)['permessage-deflate']); + lev9.accept(extension.parse(extensionStr)['permessage-deflate']); + + lev0.compress(buf, true, (err, compressed1) => { + if (err) return done(err); + + lev0.decompress(compressed1, true, (err, decompressed1) => { + if (err) return done(err); + + lev9.compress(buf, true, (err, compressed2) => { + if (err) return done(err); + + lev9.decompress(compressed2, true, (err, decompressed2) => { + if (err) return done(err); + + // Level 0 compression actually adds a few bytes due to headers. + assert.ok(compressed1.length > buf.length); + // Level 9 should not, of course. + assert.ok(compressed2.length < buf.length); + // Ensure they both decompress back properly. + assert.ok(decompressed1.equals(buf)); + assert.ok(decompressed2.equals(buf)); + done(); + }); + }); + }); + }); + }); + + it('honors the `zlib{Deflate,Inflate}Options` option', (done) => { + const lev0 = new PerMessageDeflate({ + zlibDeflateOptions: { + level: 0, + chunkSize: 256 + }, + zlibInflateOptions: { + chunkSize: 2048 + } + }); + const lev9 = new PerMessageDeflate({ + zlibDeflateOptions: { + level: 9, + chunkSize: 128 + }, + zlibInflateOptions: { + chunkSize: 1024 + } + }); + + // Note no context takeover so we can get a hold of the raw streams after + // we do the dance. + const extensionStr = + 'permessage-deflate; server_max_window_bits=10; ' + + 'client_max_window_bits=11'; + const buf = Buffer.from("Some compressible data, it's compressible."); + + lev0.accept(extension.parse(extensionStr)['permessage-deflate']); + lev9.accept(extension.parse(extensionStr)['permessage-deflate']); + + lev0.compress(buf, true, (err, compressed1) => { + if (err) return done(err); + + lev0.decompress(compressed1, true, (err, decompressed1) => { + if (err) return done(err); + + lev9.compress(buf, true, (err, compressed2) => { + if (err) return done(err); + + lev9.decompress(compressed2, true, (err, decompressed2) => { + if (err) return done(err); + // Level 0 compression actually adds a few bytes due to headers. + assert.ok(compressed1.length > buf.length); + // Level 9 should not, of course. + assert.ok(compressed2.length < buf.length); + // Ensure they both decompress back properly. + assert.ok(decompressed1.equals(buf)); + assert.ok(decompressed2.equals(buf)); + + // Assert options were set. + assert.ok(lev0._deflate._level === 0); + assert.ok(lev9._deflate._level === 9); + assert.ok(lev0._deflate._chunkSize === 256); + assert.ok(lev9._deflate._chunkSize === 128); + assert.ok(lev0._inflate._chunkSize === 2048); + assert.ok(lev9._inflate._chunkSize === 1024); + done(); + }); + }); + }); + }); + }); + + it("doesn't use contex takeover if not allowed", (done) => { + const perMessageDeflate = new PerMessageDeflate({}, true); + const extensions = extension.parse( + 'permessage-deflate;server_no_context_takeover' + ); + const buf = Buffer.from('foofoo'); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + perMessageDeflate.compress(buf, true, (err, compressed1) => { + if (err) return done(err); + + perMessageDeflate.decompress(compressed1, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + perMessageDeflate.compress(data, true, (err, compressed2) => { + if (err) return done(err); + + assert.strictEqual(compressed2.length, compressed1.length); + perMessageDeflate.decompress(compressed2, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + done(); + }); + }); + }); + }); + }); + + it('uses contex takeover if allowed', (done) => { + const perMessageDeflate = new PerMessageDeflate({}, true); + const extensions = extension.parse('permessage-deflate'); + const buf = Buffer.from('foofoo'); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + perMessageDeflate.compress(buf, true, (err, compressed1) => { + if (err) return done(err); + + perMessageDeflate.decompress(compressed1, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + perMessageDeflate.compress(data, true, (err, compressed2) => { + if (err) return done(err); + + assert.ok(compressed2.length < compressed1.length); + perMessageDeflate.decompress(compressed2, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + done(); + }); + }); + }); + }); + }); + + it('calls the callback when an error occurs (inflate)', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + 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", (done) => { + const perMessageDeflate = new PerMessageDeflate({}, false, 25); + const buf = Buffer.from('A'.repeat(50)); + + perMessageDeflate.accept([{}]); + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + perMessageDeflate.decompress(data, true, (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.message, 'Max payload size exceeded'); + done(); + }); + }); + }); + + it('calls the callback if the deflate stream is closed prematurely', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const buf = Buffer.from('A'.repeat(50)); + + perMessageDeflate.accept([{}]); + 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(); + }); + + 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/testing/xpcshell/node-ws/test/receiver.test.js b/testing/xpcshell/node-ws/test/receiver.test.js new file mode 100644 index 0000000000..7ee35f7402 --- /dev/null +++ b/testing/xpcshell/node-ws/test/receiver.test.js @@ -0,0 +1,1086 @@ +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); + +const PerMessageDeflate = require('../lib/permessage-deflate'); +const Receiver = require('../lib/receiver'); +const Sender = require('../lib/sender'); +const { EMPTY_BUFFER, kStatusCode } = require('../lib/constants'); + +describe('Receiver', () => { + it('parses an unmasked text message', (done) => { + const receiver = new Receiver(); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); + done(); + }); + + receiver.write(Buffer.from('810548656c6c6f', 'hex')); + }); + + it('parses a close message', (done) => { + const receiver = new Receiver(); + + receiver.on('conclude', (code, data) => { + assert.strictEqual(code, 1005); + assert.strictEqual(data, EMPTY_BUFFER); + done(); + }); + + receiver.write(Buffer.from('8800', 'hex')); + }); + + it('parses a close message spanning multiple writes', (done) => { + const receiver = new Receiver(); + + receiver.on('conclude', (code, data) => { + assert.strictEqual(code, 1000); + assert.deepStrictEqual(data, Buffer.from('DONE')); + done(); + }); + + receiver.write(Buffer.from('8806', 'hex')); + receiver.write(Buffer.from('03e8444F4E45', 'hex')); + }); + + it('parses a masked text message', (done) => { + const receiver = new Receiver({ isServer: true }); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from('5:::{"name":"echo"}')); + assert.ok(!isBinary); + done(); + }); + + receiver.write( + Buffer.from('81933483a86801b992524fa1c60959e68a5216e6cb005ba1d5', 'hex') + ); + }); + + it('parses a masked text message longer than 125 B', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(200)); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x01, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + done(); + }); + + receiver.write(frame.slice(0, 2)); + setImmediate(() => receiver.write(frame.slice(2))); + }); + + it('parses a really long masked text message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(64 * 1024)); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x01, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a 300 B fragmented masked text message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(300)); + + const fragment1 = msg.slice(0, 150); + const fragment2 = msg.slice(150); + + const options = { rsv1: false, mask: true, readOnly: true }; + + const frame1 = Buffer.concat( + Sender.frame(fragment1, { + fin: false, + opcode: 0x01, + ...options + }) + ); + const frame2 = Buffer.concat( + Sender.frame(fragment2, { + fin: true, + opcode: 0x00, + ...options + }) + ); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + done(); + }); + + receiver.write(frame1); + receiver.write(frame2); + }); + + it('parses a ping message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('Hello'); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x09, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('ping', (data) => { + assert.deepStrictEqual(data, msg); + done(); + }); + + receiver.write(frame); + }); + + it('parses a ping message with no data', (done) => { + const receiver = new Receiver(); + + receiver.on('ping', (data) => { + assert.strictEqual(data, EMPTY_BUFFER); + done(); + }); + + receiver.write(Buffer.from('8900', 'hex')); + }); + + it('parses a 300 B fragmented masked text message with a ping in the middle (1/2)', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(300)); + const pingMessage = Buffer.from('Hello'); + + const fragment1 = msg.slice(0, 150); + const fragment2 = msg.slice(150); + + const options = { rsv1: false, mask: true, readOnly: true }; + + const frame1 = Buffer.concat( + Sender.frame(fragment1, { + fin: false, + opcode: 0x01, + ...options + }) + ); + const frame2 = Buffer.concat( + Sender.frame(pingMessage, { + fin: true, + opcode: 0x09, + ...options + }) + ); + const frame3 = Buffer.concat( + Sender.frame(fragment2, { + fin: true, + opcode: 0x00, + ...options + }) + ); + + let gotPing = false; + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + assert.ok(gotPing); + done(); + }); + receiver.on('ping', (data) => { + gotPing = true; + assert.ok(data.equals(pingMessage)); + }); + + receiver.write(frame1); + receiver.write(frame2); + receiver.write(frame3); + }); + + it('parses a 300 B fragmented masked text message with a ping in the middle (2/2)', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(300)); + const pingMessage = Buffer.from('Hello'); + + const fragment1 = msg.slice(0, 150); + const fragment2 = msg.slice(150); + + const options = { rsv1: false, mask: true, readOnly: false }; + + const frame1 = Buffer.concat( + Sender.frame(Buffer.from(fragment1), { + fin: false, + opcode: 0x01, + ...options + }) + ); + const frame2 = Buffer.concat( + Sender.frame(Buffer.from(pingMessage), { + fin: true, + opcode: 0x09, + ...options + }) + ); + const frame3 = Buffer.concat( + Sender.frame(Buffer.from(fragment2), { + fin: true, + opcode: 0x00, + ...options + }) + ); + + let chunks = []; + const splitBuffer = (buf) => { + const i = Math.floor(buf.length / 2); + return [buf.slice(0, i), buf.slice(i)]; + }; + + chunks = chunks.concat(splitBuffer(frame1)); + chunks = chunks.concat(splitBuffer(frame2)); + chunks = chunks.concat(splitBuffer(frame3)); + + let gotPing = false; + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + assert.ok(gotPing); + done(); + }); + receiver.on('ping', (data) => { + gotPing = true; + assert.ok(data.equals(pingMessage)); + }); + + for (let i = 0; i < chunks.length; ++i) { + receiver.write(chunks[i]); + } + }); + + it('parses a 100 B masked binary message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = crypto.randomBytes(100); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a 256 B masked binary message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = crypto.randomBytes(256); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a 200 KiB masked binary message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = crypto.randomBytes(200 * 1024); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a 200 KiB unmasked binary message', (done) => { + const receiver = new Receiver(); + const msg = crypto.randomBytes(200 * 1024); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: false, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a compressed message', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + const buf = Buffer.from('Hello'); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, buf); + assert.ok(!isBinary); + done(); + }); + + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + receiver.write(Buffer.from([0xc1, data.length])); + receiver.write(data); + }); + }); + + it('parses a compressed and fragmented message', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + const buf1 = Buffer.from('foo'); + const buf2 = Buffer.from('bar'); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.concat([buf1, buf2])); + assert.ok(!isBinary); + done(); + }); + + 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, (err, fragment2) => { + if (err) return done(err); + + receiver.write(Buffer.from([0x80, fragment2.length])); + receiver.write(fragment2); + }); + }); + }); + + it('parses a buffer with thousands of frames', (done) => { + const buf = Buffer.allocUnsafe(40000); + + for (let i = 0; i < buf.length; i += 2) { + buf[i] = 0x81; + buf[i + 1] = 0x00; + } + + const receiver = new Receiver(); + let counter = 0; + + receiver.on('message', (data, isBinary) => { + assert.strictEqual(data, EMPTY_BUFFER); + assert.ok(!isBinary); + if (++counter === 20000) done(); + }); + + receiver.write(buf); + }); + + it('resets `totalPayloadLength` only on final frame (unfragmented)', (done) => { + const receiver = new Receiver({ maxPayload: 10 }); + + receiver.on('message', (data, isBinary) => { + assert.strictEqual(receiver._totalPayloadLength, 0); + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); + done(); + }); + + assert.strictEqual(receiver._totalPayloadLength, 0); + receiver.write(Buffer.from('810548656c6c6f', 'hex')); + }); + + it('resets `totalPayloadLength` only on final frame (fragmented)', (done) => { + const receiver = new Receiver({ maxPayload: 10 }); + + receiver.on('message', (data, isBinary) => { + assert.strictEqual(receiver._totalPayloadLength, 0); + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); + done(); + }); + + assert.strictEqual(receiver._totalPayloadLength, 0); + receiver.write(Buffer.from('01024865', 'hex')); + assert.strictEqual(receiver._totalPayloadLength, 2); + receiver.write(Buffer.from('80036c6c6f', 'hex')); + }); + + it('resets `totalPayloadLength` only on final frame (fragmented + ping)', (done) => { + const receiver = new Receiver({ maxPayload: 10 }); + let data; + + receiver.on('ping', (buf) => { + assert.strictEqual(receiver._totalPayloadLength, 2); + data = buf; + }); + receiver.on('message', (buf, isBinary) => { + assert.strictEqual(receiver._totalPayloadLength, 0); + assert.deepStrictEqual(data, EMPTY_BUFFER); + assert.deepStrictEqual(buf, Buffer.from('Hello')); + assert.ok(isBinary); + done(); + }); + + assert.strictEqual(receiver._totalPayloadLength, 0); + receiver.write(Buffer.from('02024865', 'hex')); + receiver.write(Buffer.from('8900', 'hex')); + receiver.write(Buffer.from('80036c6c6f', 'hex')); + }); + + it('ignores any data after a close frame', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + const results = []; + const push = results.push.bind(results); + + receiver.on('conclude', push).on('message', push); + receiver.on('finish', () => { + assert.deepStrictEqual(results, [ + EMPTY_BUFFER, + false, + 1005, + EMPTY_BUFFER + ]); + done(); + }); + + receiver.write(Buffer.from([0xc1, 0x01, 0x00])); + receiver.write(Buffer.from([0x88, 0x00])); + receiver.write(Buffer.from([0x81, 0x00])); + }); + + it('emits an error if RSV1 is on and permessage-deflate is disabled', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_1'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV1 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0xc2, 0x80, 0x00, 0x00, 0x00, 0x00])); + }); + + it('emits an error if RSV1 is on and opcode is 0', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_1'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV1 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x40, 0x00])); + }); + + it('emits an error if RSV2 is on', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_2_3'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV2 and RSV3 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0xa2, 0x00])); + }); + + it('emits an error if RSV3 is on', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_2_3'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV2 and RSV3 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x92, 0x00])); + }); + + it('emits an error if the first frame in a fragmented message has opcode 0', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 0' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x00, 0x00])); + }); + + 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) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 1' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x01, 0x00])); + receiver.write(Buffer.from([0x01, 0x00])); + }); + + 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) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 2' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x01, 0x00])); + receiver.write(Buffer.from([0x02, 0x00])); + }); + + it('emits an error if a control frame has the FIN bit off', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_EXPECTED_FIN'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: FIN must be set' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x09, 0x00])); + }); + + it('emits an error if a control frame has the RSV1 bit on', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_1'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV1 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0xc9, 0x00])); + }); + + it('emits an error if a control frame has the FIN bit off', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_EXPECTED_FIN'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: FIN must be set' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x09, 0x00])); + }); + + it('emits an error if a frame has the MASK bit off (server mode)', (done) => { + const receiver = new Receiver({ isServer: true }); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_EXPECTED_MASK'); + 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(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_MASK'); + 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) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid payload length 126' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x89, 0x7e])); + }); + + 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) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'); + assert.strictEqual( + err.message, + 'Unsupported WebSocket frame: payload length > 2^53 - 1' + ); + assert.strictEqual(err[kStatusCode], 1009); + done(); + }); + + receiver.write(Buffer.from([0x82, 0x7f])); + setImmediate(() => + receiver.write( + Buffer.from([0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + ) + ); + }); + + it('emits an error if a text frame contains invalid UTF-8 data (1/2)', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'WS_ERR_INVALID_UTF8'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid UTF-8 sequence' + ); + assert.strictEqual(err[kStatusCode], 1007); + done(); + }); + + receiver.write(Buffer.from([0x81, 0x04, 0xce, 0xba, 0xe1, 0xbd])); + }); + + 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({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + const buf = Buffer.from([0xce, 0xba, 0xe1, 0xbd]); + + receiver.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'WS_ERR_INVALID_UTF8'); + 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) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid payload length 1' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x88, 0x01, 0x00])); + }); + + it('emits an error if a close frame contains an invalid close code', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_CLOSE_CODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid status code 0' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x88, 0x02, 0x00, 0x00])); + }); + + it('emits an error if a close frame contains invalid UTF-8 data', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'WS_ERR_INVALID_UTF8'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid UTF-8 sequence' + ); + assert.strictEqual(err[kStatusCode], 1007); + done(); + }); + + receiver.write( + Buffer.from([0x88, 0x06, 0x03, 0xef, 0xce, 0xba, 0xe1, 0xbd]) + ); + }); + + it('emits an error if a frame payload length is bigger than `maxPayload`', (done) => { + const receiver = new Receiver({ isServer: true, maxPayload: 20 * 1024 }); + const msg = crypto.randomBytes(200 * 1024); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + assert.strictEqual(err[kStatusCode], 1009); + done(); + }); + + receiver.write(frame); + }); + + it('emits an error if the message length exceeds `maxPayload`', (done) => { + const perMessageDeflate = new PerMessageDeflate({}, false, 25); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { 'permessage-deflate': perMessageDeflate }, + isServer: false, + maxPayload: 25 + }); + const buf = Buffer.from('A'.repeat(50)); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + assert.strictEqual(err[kStatusCode], 1009); + 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 the sum of fragment lengths exceeds `maxPayload`', (done) => { + const perMessageDeflate = new PerMessageDeflate({}, false, 25); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { 'permessage-deflate': perMessageDeflate }, + isServer: false, + maxPayload: 25 + }); + const buf = Buffer.from('A'.repeat(15)); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + assert.strictEqual(err[kStatusCode], 1009); + done(); + }); + + 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, (err, fragment2) => { + if (err) return done(err); + + receiver.write(Buffer.from([0x80, fragment2.length])); + receiver.write(fragment2); + }); + }); + }); + + it("honors the 'nodebuffer' binary type", (done) => { + const receiver = new Receiver(); + const frags = [ + crypto.randomBytes(7321), + crypto.randomBytes(137), + crypto.randomBytes(285787), + crypto.randomBytes(3) + ]; + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.concat(frags)); + assert.ok(isBinary); + done(); + }); + + frags.forEach((frag, i) => { + Sender.frame(frag, { + fin: i === frags.length - 1, + opcode: i === 0 ? 2 : 0, + readOnly: true, + mask: false, + rsv1: false + }).forEach((buf) => receiver.write(buf)); + }); + }); + + it("honors the 'arraybuffer' binary type", (done) => { + const receiver = new Receiver({ binaryType: 'arraybuffer' }); + const frags = [ + crypto.randomBytes(19221), + crypto.randomBytes(954), + crypto.randomBytes(623987) + ]; + + receiver.on('message', (data, isBinary) => { + assert.ok(data instanceof ArrayBuffer); + assert.deepStrictEqual(Buffer.from(data), Buffer.concat(frags)); + assert.ok(isBinary); + done(); + }); + + frags.forEach((frag, i) => { + Sender.frame(frag, { + fin: i === frags.length - 1, + opcode: i === 0 ? 2 : 0, + readOnly: true, + mask: false, + rsv1: false + }).forEach((buf) => receiver.write(buf)); + }); + }); + + it("honors the 'fragments' binary type", (done) => { + const receiver = new Receiver({ binaryType: 'fragments' }); + const frags = [ + crypto.randomBytes(17), + crypto.randomBytes(419872), + crypto.randomBytes(83), + crypto.randomBytes(9928), + crypto.randomBytes(1) + ]; + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, frags); + assert.ok(isBinary); + done(); + }); + + frags.forEach((frag, i) => { + Sender.frame(frag, { + fin: i === frags.length - 1, + opcode: i === 0 ? 2 : 0, + readOnly: true, + mask: false, + rsv1: false + }).forEach((buf) => receiver.write(buf)); + }); + }); + + it('honors the `skipUTF8Validation` option (1/2)', (done) => { + const receiver = new Receiver({ skipUTF8Validation: true }); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from([0xf8])); + assert.ok(!isBinary); + done(); + }); + + receiver.write(Buffer.from([0x81, 0x01, 0xf8])); + }); + + it('honors the `skipUTF8Validation` option (2/2)', (done) => { + const receiver = new Receiver({ skipUTF8Validation: true }); + + receiver.on('conclude', (code, data) => { + assert.strictEqual(code, 1000); + assert.deepStrictEqual(data, Buffer.from([0xf8])); + done(); + }); + + receiver.write(Buffer.from([0x88, 0x03, 0x03, 0xe8, 0xf8])); + }); +}); diff --git a/testing/xpcshell/node-ws/test/sender.test.js b/testing/xpcshell/node-ws/test/sender.test.js new file mode 100644 index 0000000000..532239fa1a --- /dev/null +++ b/testing/xpcshell/node-ws/test/sender.test.js @@ -0,0 +1,370 @@ +'use strict'; + +const assert = require('assert'); + +const extension = require('../lib/extension'); +const PerMessageDeflate = require('../lib/permessage-deflate'); +const Sender = require('../lib/sender'); +const { EMPTY_BUFFER } = require('../lib/constants'); + +class MockSocket { + constructor({ write } = {}) { + this.readable = true; + this.writable = true; + + if (write) this.write = write; + } + + cork() {} + write() {} + uncork() {} +} + +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, { + readOnly: true, + rsv1: false, + mask: true, + opcode: 2, + fin: true + }); + + assert.ok(buf.equals(Buffer.from([1, 2, 3, 4, 5]))); + }); + + it('honors the `rsv1` option', () => { + const list = Sender.frame(EMPTY_BUFFER, { + readOnly: false, + mask: false, + rsv1: true, + opcode: 1, + fin: true + }); + + assert.strictEqual(list[0][0] & 0x40, 0x40); + }); + + it('accepts a string as first argument', () => { + const list = Sender.frame('€', { + readOnly: false, + rsv1: false, + mask: false, + opcode: 1, + fin: true + }); + + assert.deepStrictEqual(list[0], Buffer.from('8103', 'hex')); + assert.deepStrictEqual(list[1], Buffer.from('e282ac', 'hex')); + }); + }); + + describe('#send', () => { + it('compresses data if compress option is enabled', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate(); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 6) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x40); + + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x40); + + assert.strictEqual(chunks[4].length, 2); + assert.strictEqual(chunks[4][0] & 0x40, 0x40); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + + perMessageDeflate.accept([{}]); + + const options = { compress: true, fin: true }; + const array = new Uint8Array([0x68, 0x69]); + + sender.send(array.buffer, options); + sender.send(array, options); + sender.send('hi', options); + }); + + describe('when context takeover is disabled', () => { + it('honors the compression threshold', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate(); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 2) return; + + assert.strictEqual(chunks[0].length, 2); + assert.notStrictEqual(chunk[0][0] & 0x40, 0x40); + assert.strictEqual(chunks[1], 'hi'); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send('hi', { compress: true, fin: true }); + }); + + it('compresses all fragments of a fragmented message', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate({ threshold: 3 }); + const mockSocket = new MockSocket({ + 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(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 4); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send('123', { compress: true, fin: false }); + sender.send('12', { compress: true, fin: true }); + }); + + it('does not compress any fragments of a fragmented message', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate({ threshold: 3 }); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; + + 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(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send('12', { compress: true, fin: false }); + sender.send('123', { compress: true, fin: true }); + }); + + it('compresses empty buffer as first fragment', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const mockSocket = new MockSocket({ + 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(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 6); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send(Buffer.alloc(0), { compress: true, fin: false }); + sender.send('data', { compress: true, fin: true }); + }); + + it('compresses empty buffer as last fragment', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const mockSocket = new MockSocket({ + 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(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 1); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send('data', { compress: true, fin: false }); + sender.send(Buffer.alloc(0), { compress: true, fin: true }); + }); + }); + }); + + describe('#ping', () => { + it('works with multiple types of data', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + let count = 0; + const mockSocket = new MockSocket({ + write: (data) => { + if (++count < 3) return; + + if (count % 2) { + assert.ok(data.equals(Buffer.from([0x89, 0x02]))); + } else if (count < 8) { + assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + } else { + assert.strictEqual(data, 'hi'); + done(); + } + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + + perMessageDeflate.accept([{}]); + + const array = new Uint8Array([0x68, 0x69]); + + sender.send('foo', { compress: true, fin: true }); + sender.ping(array.buffer, false); + sender.ping(array, false); + sender.ping('hi', false); + }); + }); + + describe('#pong', () => { + it('works with multiple types of data', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + let count = 0; + const mockSocket = new MockSocket({ + write: (data) => { + if (++count < 3) return; + + if (count % 2) { + assert.ok(data.equals(Buffer.from([0x8a, 0x02]))); + } else if (count < 8) { + assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + } else { + assert.strictEqual(data, 'hi'); + done(); + } + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + + perMessageDeflate.accept([{}]); + + const array = new Uint8Array([0x68, 0x69]); + + sender.send('foo', { compress: true, fin: true }); + sender.pong(array.buffer, false); + sender.pong(array, false); + sender.pong('hi', false); + }); + }); + + describe('#close', () => { + it('throws an error if the first argument is invalid', () => { + const mockSocket = new MockSocket(); + const sender = new Sender(mockSocket); + + assert.throws( + () => sender.close('error'), + /^TypeError: First argument must be a valid error code number$/ + ); + + assert.throws( + () => sender.close(1004), + /^TypeError: First argument must be a valid error code number$/ + ); + }); + + it('throws an error if the message is greater than 123 bytes', () => { + const mockSocket = new MockSocket(); + const sender = new Sender(mockSocket); + + assert.throws( + () => sender.close(1000, 'a'.repeat(124)), + /^RangeError: The message must not be greater than 123 bytes$/ + ); + }); + + it('should consume all data before closing', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + + let count = 0; + const mockSocket = new MockSocket({ + write: (data, cb) => { + count++; + if (cb) cb(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + + perMessageDeflate.accept([{}]); + + sender.send('foo', { compress: true, fin: true }); + sender.send('bar', { compress: true, fin: true }); + sender.send('baz', { compress: true, fin: true }); + + sender.close(1000, undefined, false, () => { + assert.strictEqual(count, 8); + done(); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/subprotocol.test.js b/testing/xpcshell/node-ws/test/subprotocol.test.js new file mode 100644 index 0000000000..91dd5d69d8 --- /dev/null +++ b/testing/xpcshell/node-ws/test/subprotocol.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const assert = require('assert'); + +const { parse } = require('../lib/subprotocol'); + +describe('subprotocol', () => { + describe('parse', () => { + it('parses a single subprotocol', () => { + assert.deepStrictEqual(parse('foo'), new Set(['foo'])); + }); + + it('parses multiple subprotocols', () => { + assert.deepStrictEqual( + parse('foo,bar,baz'), + new Set(['foo', 'bar', 'baz']) + ); + }); + + it('ignores the optional white spaces', () => { + const header = 'foo , bar\t, \tbaz\t , qux\t\t,norf'; + + assert.deepStrictEqual( + parse(header), + new Set(['foo', 'bar', 'baz', 'qux', 'norf']) + ); + }); + + it('throws an error if a subprotocol is empty', () => { + [ + [',', 0], + ['foo,,', 4], + ['foo, ,', 6] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a subprotocol is duplicated', () => { + ['foo,foo,bar', 'foo,bar,foo'].forEach((header) => { + assert.throws( + () => parse(header), + /^SyntaxError: The "foo" subprotocol is duplicated$/ + ); + }); + }); + + it('throws an error if a white space is misplaced', () => { + [ + ['f oo', 2], + [' foo', 0] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a subprotocol contains invalid characters', () => { + [ + ['f@o', 1], + ['f\\oo', 1], + ['foo,b@r', 5] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if the header value ends prematurely', () => { + ['foo ', 'foo, ', 'foo,bar ', 'foo,bar,'].forEach((header) => { + assert.throws( + () => parse(header), + /^SyntaxError: Unexpected end of input$/ + ); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/validation.test.js b/testing/xpcshell/node-ws/test/validation.test.js new file mode 100644 index 0000000000..5718b12f02 --- /dev/null +++ b/testing/xpcshell/node-ws/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/testing/xpcshell/node-ws/test/websocket-server.test.js b/testing/xpcshell/node-ws/test/websocket-server.test.js new file mode 100644 index 0000000000..12928ff495 --- /dev/null +++ b/testing/xpcshell/node-ws/test/websocket-server.test.js @@ -0,0 +1,1284 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^ws$" }] */ + +'use strict'; + +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', () => { + describe('#ctor', () => { + it('throws an error if no option object is passed', () => { + assert.throws( + () => new WebSocket.Server(), + new RegExp( + '^TypeError: One and only one of the "port", "server", or ' + + '"noServer" options must be specified$' + ) + ); + }); + + describe('options', () => { + it('throws an error if required options are not specified', () => { + assert.throws( + () => new WebSocket.Server({}), + new RegExp( + '^TypeError: One and only one of the "port", "server", or ' + + '"noServer" options must be specified$' + ) + ); + }); + + it('throws an error if mutually exclusive options are specified', () => { + const server = http.createServer(); + const variants = [ + { port: 0, noServer: true, server }, + { port: 0, noServer: true }, + { port: 0, server }, + { noServer: true, server } + ]; + + for (const options of variants) { + assert.throws( + () => new WebSocket.Server(options), + new RegExp( + '^TypeError: One and only one of the "port", "server", or ' + + '"noServer" options must be specified$' + ) + ); + } + }); + + 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', (done) => { + const maxPayload = 20480; + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + maxPayload, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + } + ); + + wss.on('connection', (ws) => { + assert.strictEqual(ws._receiver._maxPayload, maxPayload); + assert.strictEqual( + ws._receiver._extensions['permessage-deflate']._maxPayload, + maxPayload + ); + wss.close(done); + }); + }); + + it('honors the `WebSocket` option', (done) => { + class CustomWebSocket extends WebSocket.WebSocket { + get foo() { + return 'foo'; + } + } + + const wss = new WebSocket.Server( + { + port: 0, + WebSocket: CustomWebSocket + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + } + ); + + wss.on('connection', (ws) => { + assert.ok(ws instanceof CustomWebSocket); + assert.strictEqual(ws.foo, 'foo'); + wss.close(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 + }); + + wss2.on('error', () => wss1.close(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}`); + + ws.on('open', ws.close); + }); + + wss.on('connection', () => wss.close(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', (done) => { + const server = http.createServer(); + + server.listen(0, () => { + const wss = new WebSocket.Server({ server }); + + wss.on('connection', () => { + server.close(done); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', ws.close); + }); + }); + + it('426s for non-Upgrade requests', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + http.get(`http://localhost:${wss.address().port}`, (res) => { + let body = ''; + + assert.strictEqual(res.statusCode, 426); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + assert.strictEqual(body, http.STATUS_CODES[426]); + wss.close(done); + }); + }); + }); + }); + + it('uses a precreated http server listening on unix socket', function (done) { + // + // Skip this test on Windows. The URL parser: + // + // - 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 = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + server.listen(sockPath, () => { + const wss = new WebSocket.Server({ server }); + + wss.on('connection', (ws, req) => { + if (wss.clients.size === 1) { + assert.strictEqual(req.url, '/foo?bar=bar'); + } else { + assert.strictEqual(req.url, '/'); + + for (const client of wss.clients) { + client.close(); + } + + server.close(done); + } + }); + + const ws = new WebSocket(`ws+unix://${sockPath}:/foo?bar=bar`); + ws.on('open', () => new WebSocket(`ws+unix://${sockPath}`)); + }); + }); + }); + + describe('#address', () => { + it('returns the address of the server', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const addr = wss.address(); + + assert.deepStrictEqual(addr, wss._server.address()); + wss.close(done); + }); + }); + + it('throws an error when operating in "noServer" mode', () => { + const wss = new WebSocket.Server({ noServer: true }); + + assert.throws(() => { + wss.address(); + }, /^Error: The server is operating in "noServer" mode$/); + }); + + it('returns `null` if called after close', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.close(() => { + assert.strictEqual(wss.address(), null); + done(); + }); + }); + }); + }); + + describe('#close', () => { + it('does not throw if called multiple times', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.on('close', done); + + wss.close(); + wss.close(); + wss.close(); + }); + }); + + it("doesn't close a precreated server", (done) => { + const server = http.createServer(); + const realClose = server.close; + + server.close = () => { + done(new Error('Must not close pre-created server')); + }; + + const wss = new WebSocket.Server({ server }); + + wss.on('connection', () => { + wss.close(); + server.close = realClose; + server.close(done); + }); + + server.listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', ws.close); + }); + }); + + 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', (done) => { + const server = http.createServer(); + const wss = new WebSocket.Server({ server }); + + server.listen(0, () => { + wss.close(() => { + assert.strictEqual(server.listenerCount('listening'), 0); + assert.strictEqual(server.listenerCount('upgrade'), 0); + assert.strictEqual(server.listenerCount('error'), 0); + + server.close(done); + }); + }); + }); + + it("emits the 'close' event after the server closes", (done) => { + let serverCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + net.createConnection({ port: wss.address().port }); + }); + + wss._server.on('connection', (socket) => { + wss.close(); + + // + // The server is closing. Ensure this does not emit a `'close'` + // event before the server is actually closed. + // + wss.close(); + + process.nextTick(() => { + socket.end(); + }); + }); + + wss._server.on('close', () => { + serverCloseEventEmitted = true; + }); + + wss.on('close', () => { + assert.ok(serverCloseEventEmitted); + done(); + }); + }); + + it("emits the 'close' event if client tracking is disabled", (done) => { + const wss = new WebSocket.Server({ + noServer: true, + clientTracking: false + }); + + wss.on('close', done); + wss.close(); + }); + + it('calls the callback if the server is already closed', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.close(() => { + assert.strictEqual(wss._state, 2); + + wss.close((err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'The server is not running'); + done(); + }); + }); + }); + }); + + it("emits the 'close' event if the server is already closed", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.close(() => { + assert.strictEqual(wss._state, 2); + + wss.on('close', done); + wss.close(); + }); + }); + }); + }); + + 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}`); + + ws.on('open', ws.close); + }); + + wss.on('connection', () => { + assert.strictEqual(wss.clients.size, 1); + wss.close(done); + }); + }); + + it('can be disabled', (done) => { + const wss = new WebSocket.Server( + { port: 0, clientTracking: false }, + () => { + assert.strictEqual(wss.clients, undefined); + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.close()); + } + ); + + wss.on('connection', (ws) => { + assert.strictEqual(wss.clients, undefined); + ws.on('close', () => wss.close(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}`); + + ws.on('open', () => ws.terminate()); + }); + + wss.on('connection', (ws) => { + ws.on('close', () => { + assert.strictEqual(wss.clients.size, 0); + wss.close(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}`); + + ws.on('open', () => ws.close()); + }); + + wss.on('connection', (ws) => { + ws.on('close', () => { + assert.strictEqual(wss.clients.size, 0); + wss.close(done); + }); + }); + }); + }); + + 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", () => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + assert.strictEqual(wss.shouldHandle({ url: '/bar' }), false); + }); + }); + + describe('#handleUpgrade', () => { + it('can be used for a pre-existing server', (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.send('hello'); + ws.close(); + }); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hello')); + assert.ok(!isBinary); + server.close(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, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + wss.close(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, + headers: { + Connection: 'Upgrade', + Upgrade: 'WebSocket', + 'Sec-WebSocket-Key1': '4 @1 46546xW%0l 1 5', + 'Sec-WebSocket-Key2': '12998 5 Y3 1 .P00', + 'Sec-WebSocket-Protocol': 'sample' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Key header' + ); + wss.close(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 HTTP method is not GET', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.request({ + method: 'POST', + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 405); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Invalid HTTP method' + ); + wss.close(done); + }); + }); + + req.end(); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the Upgrade header field value is not "websocket"', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'foo' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Invalid Upgrade header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + 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, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Key header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + 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); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Key header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if 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, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Version header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if 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, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 12 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Version header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails is the Sec-WebSocket-Protocol header is invalid', (done) => { + const wss = new WebSocket.Server({ 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-Protocol': 'foo;bar' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Invalid Sec-WebSocket-Protocol header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the Sec-WebSocket-Extensions header is invalid', (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': + 'permessage-deflate; server_max_window_bits=foo' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Invalid or unacceptable Sec-WebSocket-Extensions header' + ); + wss.close(done); + }); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it("emits the 'wsClientError' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.request({ + method: 'POST', + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + wss.close(done); + }); + + req.end(); + }); + + wss.on('wsClientError', (err, socket, request) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid HTTP method'); + + assert.ok(request instanceof http.IncomingMessage); + assert.strictEqual(request.method, 'POST'); + + socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the WebSocket server is closing or closed', (done) => { + const server = http.createServer(); + const wss = new WebSocket.Server({ noServer: true }); + + server.on('upgrade', (req, socket, head) => { + wss.close(); + wss.handleUpgrade(req, socket, head, () => { + done(new Error('Unexpected callback invocation')); + }); + }); + + server.listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 503); + res.resume(); + server.close(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); + socket.destroy(); + 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, + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 8 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 401); + wss.close(done); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('can accept client synchronously', (done) => { + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + + const wss = new WebSocket.Server({ + verifyClient: (info) => { + assert.strictEqual(info.origin, 'https://example.com'); + assert.strictEqual(info.req.headers.foo, 'bar'); + assert.ok(info.secure, true); + return true; + }, + server + }); + + wss.on('connection', () => { + server.close(done); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + headers: { Origin: 'https://example.com', foo: 'bar' }, + rejectUnauthorized: false + }); + + ws.on('open', ws.close); + }); + }); + + it('can accept client asynchronously', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (o, cb) => process.nextTick(cb, true), + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + } + ); + + wss.on('connection', () => wss.close(done)); + }); + + it('can reject client asynchronously', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => process.nextTick(cb, false), + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 8 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 401); + wss.close(done); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('can reject client asynchronously w/ status code', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => process.nextTick(cb, false, 404), + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 8 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 404); + wss.close(done); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('can reject client asynchronously w/ custom headers', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => { + process.nextTick(cb, false, 503, '', { 'Retry-After': 120 }); + }, + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 8 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 503); + assert.strictEqual(res.headers['retry-after'], '120'); + wss.close(done); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + }); + + 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: ({ 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 + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + + const socket = net.connect( + { + port: server.address().port, + allowHalfOpen: true + }, + () => { + socket.end( + [ + 'GET / HTTP/1.1', + 'Host: localhost', + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version: 13', + '\r\n' + ].join('\r\n') + ); + } + ); + + socket.on('end', () => { + wss.close(); + server.close(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, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13 + } + }); + + const list = Sender.frame(Buffer.from('Hello'), { + fin: true, + rsv1: false, + opcode: 0x01, + mask: true, + readOnly: false + }); + + req.write(Buffer.concat(list)); + req.end(); + }); + + wss.on('connection', (ws) => { + ws.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + }); + + describe('`handleProtocols`', () => { + it('allows to select a subprotocol', (done) => { + const handleProtocols = (protocols, request) => { + assert.ok(request instanceof http.IncomingMessage); + assert.strictEqual(request.url, '/'); + return Array.from(protocols).pop(); + }; + const wss = new WebSocket.Server({ handleProtocols, port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => { + assert.strictEqual(ws.protocol, 'bar'); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); + + it("emits the 'headers' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + }); + + wss.on('headers', (headers, request) => { + assert.deepStrictEqual(headers.slice(0, 3), [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade' + ]); + assert.ok(request instanceof http.IncomingMessage); + assert.strictEqual(request.url, '/'); + + wss.on('connection', () => wss.close(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}`); + + ws.on('open', ws.close); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual( + req.headers['sec-websocket-extensions'], + 'permessage-deflate; client_max_window_bits' + ); + assert.strictEqual(ws.extensions, ''); + wss.close(done); + }); + }); + + it('uses configuration options', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { clientMaxWindowBits: 8 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('upgrade', (res) => { + assert.strictEqual( + res.headers['sec-websocket-extensions'], + 'permessage-deflate; client_max_window_bits=8' + ); + + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/websocket.integration.js b/testing/xpcshell/node-ws/test/websocket.integration.js new file mode 100644 index 0000000000..abd96c61e4 --- /dev/null +++ b/testing/xpcshell/node-ws/test/websocket.integration.js @@ -0,0 +1,55 @@ +'use strict'; + +const assert = require('assert'); + +const WebSocket = require('..'); + +describe('WebSocket', () => { + it('communicates successfully with echo service (ws)', (done) => { + const ws = new WebSocket('ws://websocket-echo.com/', { + protocolVersion: 13 + }); + + let dataReceived = false; + + ws.on('open', () => { + ws.send('hello'); + }); + + ws.on('close', () => { + assert.ok(dataReceived); + done(); + }); + + ws.on('message', (message, isBinary) => { + dataReceived = true; + assert.ok(!isBinary); + assert.strictEqual(message.toString(), 'hello'); + ws.close(); + }); + }); + + it('communicates successfully with echo service (wss)', (done) => { + const ws = new WebSocket('wss://websocket-echo.com/', { + protocolVersion: 13 + }); + + let dataReceived = false; + + ws.on('open', () => { + ws.send('hello'); + }); + + ws.on('close', () => { + assert.ok(dataReceived); + done(); + }); + + ws.on('message', (message, isBinary) => { + dataReceived = true; + assert.ok(!isBinary); + assert.strictEqual(message.toString(), 'hello'); + ws.close(); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/websocket.test.js b/testing/xpcshell/node-ws/test/websocket.test.js new file mode 100644 index 0000000000..f5fbf16505 --- /dev/null +++ b/testing/xpcshell/node-ws/test/websocket.test.js @@ -0,0 +1,4514 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^ws$" }] */ + +'use strict'; + +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 tls = require('tls'); +const os = require('os'); +const fs = require('fs'); +const { URL } = require('url'); + +const Sender = require('../lib/sender'); +const WebSocket = require('..'); +const { + CloseEvent, + ErrorEvent, + Event, + MessageEvent +} = require('../lib/event-target'); +const { EMPTY_BUFFER, GUID, kListener, NOOP } = require('../lib/constants'); + +class CustomAgent extends http.Agent { + addRequest() {} +} + +describe('WebSocket', () => { + describe('#ctor', () => { + it('throws an error when using an invalid url', () => { + assert.throws( + () => new WebSocket('foo'), + /^SyntaxError: Invalid URL: foo$/ + ); + + assert.throws( + () => new WebSocket('https://websocket-echo.com'), + /^SyntaxError: The URL's protocol must be one of "ws:", "wss:", or "ws\+unix:"$/ + ); + + assert.throws( + () => new WebSocket('ws+unix:'), + /^SyntaxError: The URL's pathname is empty$/ + ); + + assert.throws( + () => new WebSocket('wss://websocket-echo.com#foo'), + /^SyntaxError: The URL contains a fragment identifier$/ + ); + }); + + it('throws an error if a subprotocol is invalid or duplicated', () => { + for (const subprotocol of [null, '', 'a,b', ['a', 'a']]) { + assert.throws( + () => new WebSocket('ws://localhost', subprotocol), + /^SyntaxError: An invalid or duplicated subprotocol was specified$/ + ); + } + }); + + it('accepts `url.URL` objects as url', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req, opts) => { + assert.strictEqual(opts.host, '::1'); + assert.strictEqual(req.path, '/'); + done(); + }; + + const ws = new WebSocket(new URL('ws://[::1]'), { agent }); + }); + + describe('options', () => { + it('accepts the `options` object as 3rd argument', () => { + const agent = new CustomAgent(); + let count = 0; + let ws; + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('sec-websocket-protocol'), + undefined + ); + count++; + }; + + ws = new WebSocket('ws://localhost', undefined, { agent }); + ws = new WebSocket('ws://localhost', [], { agent }); + + assert.strictEqual(count, 2); + }); + + it('accepts the `maxPayload` option', (done) => { + const maxPayload = 20480; + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: true, + maxPayload + }); + + ws.on('open', () => { + assert.strictEqual(ws._receiver._maxPayload, maxPayload); + assert.strictEqual( + ws._receiver._extensions['permessage-deflate']._maxPayload, + maxPayload + ); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('throws an error when using an invalid `protocolVersion`', () => { + const options = { agent: new CustomAgent(), protocolVersion: 1000 }; + + assert.throws( + () => new WebSocket('ws://localhost', options), + /^RangeError: Unsupported protocol version: 1000 \(supported versions: 8, 13\)$/ + ); + }); + + it('honors the `generateMask` option', (done) => { + const data = Buffer.from('foo'); + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + generateMask() {} + }); + + ws.on('open', () => { + ws.send(data); + }); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1005); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + const chunks = []; + + ws._socket.prependListener('data', (chunk) => { + chunks.push(chunk); + }); + + ws.on('message', (message) => { + assert.deepStrictEqual(message, data); + assert.deepStrictEqual( + Buffer.concat(chunks).slice(2, 6), + Buffer.alloc(4) + ); + + ws.close(); + }); + }); + }); + }); + }); + + describe('Constants', () => { + const readyStates = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3 + }; + + Object.keys(readyStates).forEach((state) => { + 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.deepStrictEqual(descriptor, { + configurable: false, + enumerable: true, + value: readyStates[state], + writable: false + }); + }); + }); + }); + }); + + 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() + }); + + assert.strictEqual(ws.binaryType, 'nodebuffer'); + }); + + it("can be changed to 'arraybuffer' or 'fragments'", () => { + const ws = new WebSocket('ws://localhost', { + agent: new CustomAgent() + }); + + ws.binaryType = 'arraybuffer'; + assert.strictEqual(ws.binaryType, 'arraybuffer'); + + ws.binaryType = 'foo'; + assert.strictEqual(ws.binaryType, 'arraybuffer'); + + ws.binaryType = 'fragments'; + assert.strictEqual(ws.binaryType, 'fragments'); + + ws.binaryType = ''; + assert.strictEqual(ws.binaryType, 'fragments'); + + ws.binaryType = 'nodebuffer'; + assert.strictEqual(ws.binaryType, 'nodebuffer'); + }); + }); + + 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() + }); + + assert.strictEqual(ws.bufferedAmount, 0); + }); + + it('defaults to zero upon "open"', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.onopen = () => { + assert.strictEqual(ws.bufferedAmount, 0); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('takes into account the data in the sender queue', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + 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, 6); + }); + } + ); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + 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}`); + }); + + wss.on('connection', (ws) => { + const data = Buffer.alloc(1024, 61); + + 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`', () => { + 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}`); + + assert.strictEqual(ws.extensions, ''); + + ws.on('open', () => { + assert.strictEqual(ws.extensions, ''); + ws.on('close', () => wss.close(done)); + }); + }); + + wss.on('connection', (ws) => { + assert.strictEqual(ws.extensions, ''); + ws.close(); + }); + }); + + it('exposes the negotiated extensions names (2/2)', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.extensions, ''); + + ws.on('open', () => { + assert.strictEqual(ws.extensions, 'permessage-deflate'); + ws.on('close', () => wss.close(done)); + }); + } + ); + + wss.on('connection', (ws) => { + assert.strictEqual(ws.extensions, 'permessage-deflate'); + ws.close(); + }); + }); + }); + + describe('`isPaused`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'isPaused' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('indicates whether the websocket is paused', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.pause(); + assert.ok(ws.isPaused); + + ws.resume(); + assert.ok(!ws.isPaused); + + ws.close(); + wss.close(done); + }); + + assert.ok(!ws.isPaused); + }); + }); + }); + + 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'); + + assert.strictEqual(ws.extensions, ''); + + ws.on('open', () => { + assert.strictEqual(ws.protocol, 'foo'); + ws.on('close', () => wss.close(done)); + }); + }); + + wss.on('connection', (ws) => { + assert.strictEqual(ws.protocol, 'foo'); + ws.close(); + }); + }); + }); + + 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() + }); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + }); + + 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}`); + + ws.on('open', () => { + assert.strictEqual(ws.readyState, WebSocket.OPEN); + ws.close(); + }); + + ws.on('close', () => wss.close(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}`); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + wss.close(done); + }); + + ws.on('open', () => ws.close(1001)); + }); + }); + + 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}`); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + wss.close(done); + }); + + ws.on('open', () => ws.terminate()); + }); + }); + }); + + 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() }); + + assert.strictEqual(ws.url, url); + }); + }); + }); + + describe('Events', () => { + it("emits an 'error' event if an error occurs", (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + + ws._socket.write(Buffer.from([0x85, 0x00])); + }); + }); + + 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(codes.includes(err.code), `Unexpected code: ${err.code}`); + ws.on('close', (code, message) => { + assert.strictEqual(code, 1006); + assert.strictEqual(message, EMPTY_BUFFER); + wss.close(done); + }); + }); + + for (const client of wss.clients) client.terminate(); + ws.send('foo'); + ws.send('bar'); + }); + }); + }); + + 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) => { + assert.ok(res instanceof http.IncomingMessage); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + 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)); + }); + + wss.on('connection', (ws) => { + ws.ping(); + ws.close(); + }); + }); + + 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)); + }); + + wss.on('connection', (ws) => { + ws.pong(); + ws.close(); + }); + }); + + it("emits a 'redirect' event", (done) => { + const server = http.createServer(); + 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, (ws) => { + ws.close(); + }); + }); + }); + + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/foo`); + assert.ok(req instanceof http.ClientRequest); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + server.close(done); + }); + }); + }); + }); + }); + + describe('Connection establishing', () => { + const server = http.createServer(); + + beforeEach((done) => server.listen(0, done)); + afterEach((done) => server.close(done)); + + it('fails if the Upgrade header field value is not "websocket"', (done) => { + server.once('upgrade', (req, socket) => { + socket.on('end', socket.end); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Connection: Upgrade\r\n' + + 'Upgrade: foo\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid Upgrade header'); + done(); + }); + }); + + it('fails if the Sec-WebSocket-Accept header is invalid', (done) => { + server.once('upgrade', (req, socket) => { + socket.on('end', socket.end); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: CxYS6+NgJSBG74mdgLvGscRvpns=\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid Sec-WebSocket-Accept header'); + 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'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + 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` + + 'Connection: close\r\n' + + 'Content-type: text/html\r\n' + + `Content-Length: ${http.STATUS_CODES[401].length}\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: 401'); + 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: 3\r\n' + + '\r\n' + + 'foo' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', () => done(new Error("Unexpected 'error' event"))); + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 401); + + let data = ''; + + res.on('data', (v) => { + data += v; + }); + + res.on('end', () => { + assert.strictEqual(data, 'foo'); + 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: 3\r\n' + + '\r\n' + + 'foo' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', () => done(new Error("Unexpected 'error' event"))); + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 401); + + res.on('end', done); + req.abort(); + }); + }); + + it('fails if the opening handshake timeout expires', (done) => { + server.once('upgrade', (req, socket) => socket.on('end', socket.end)); + + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + handshakeTimeout: 100 + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Opening handshake has timed out'); + done(); + }); + }); + + it('fails if an unexpected Sec-WebSocket-Extensions header is received', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: foo\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + perMessageDeflate: false + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Server sent a Sec-WebSocket-Extensions header but no extension ' + + 'was requested' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if the Sec-WebSocket-Extensions header is invalid (1/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: foo;=\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, + 'Invalid Sec-WebSocket-Extensions header' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if the Sec-WebSocket-Extensions header is invalid (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: ' + + 'permessage-deflate; client_max_window_bits=7\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, + 'Invalid Sec-WebSocket-Extensions header' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if an unexpected extension is received (1/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: foo\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, + 'Server indicated an extension that was not requested' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if an unexpected extension is received (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: permessage-deflate,foo\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, + 'Server indicated an extension that was not requested' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if server sends a subprotocol when none was requested', (done) => { + const wss = new WebSocket.Server({ server }); + + wss.on('headers', (headers) => { + headers.push('Sec-WebSocket-Protocol: foo'); + }); + + 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, + 'Server sent a subprotocol but none was requested' + ); + ws.on('close', () => wss.close(done)); + }); + }); + + it('fails if server sends an invalid subprotocol (1/2)', (done) => { + const wss = new WebSocket.Server({ + handleProtocols: () => 'baz', + server + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent an invalid subprotocol'); + ws.on('close', () => wss.close(done)); + }); + }); + + it('fails if server sends an invalid subprotocol (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Protocol:\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent an invalid subprotocol'); + ws.on('close', () => done()); + }); + }); + + it('fails if server sends no subprotocol', (done) => { + const wss = new WebSocket.Server({ + handleProtocols() {}, + server + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent no subprotocol'); + 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()); + }); + }); + + it('emits an error if the redirect URL is invalid (1/2)', (done) => { + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: ws://\r\n\r\n'); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof SyntaxError); + assert.strictEqual(err.message, 'Invalid URL: ws://'); + assert.strictEqual(ws._redirects, 1); + + ws.on('close', () => done()); + }); + }); + + it('emits an error if the redirect URL is invalid (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: http://localhost\r\n\r\n'); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof SyntaxError); + assert.strictEqual( + err.message, + 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"' + ); + assert.strictEqual(ws._redirects, 1); + + ws.on('close', () => done()); + }); + }); + + it('uses the first url userinfo when following redirects', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + const authorization = 'Basic Zm9vOmJhcg=='; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://baz:qux@localhost:${port}/foo\r\n\r\n` + ); + server.once('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws, req) => { + assert.strictEqual(req.headers.authorization, authorization); + ws.close(); + }); + }); + }); + + const port = server.address().port; + const ws = new WebSocket(`ws://foo:bar@localhost:${port}`, { + followRedirects: true + }); + + assert.strictEqual(ws._req.getHeader('Authorization'), authorization); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://baz:qux@localhost:${port}/foo`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + describe('When moving away from a secure context', () => { + function proxy(httpServer, httpsServer) { + const server = net.createServer({ allowHalfOpen: true }); + + server.on('connection', (socket) => { + socket.on('readable', function read() { + socket.removeListener('readable', read); + + const buf = socket.read(1); + const target = buf[0] === 22 ? httpsServer : httpServer; + + socket.unshift(buf); + target.emit('connection', socket); + }); + }); + + return server; + } + + describe("If there is no 'redirect' event listener", () => { + it('drops the `auth` option', (done) => { + const httpServer = http.createServer(); + const httpsServer = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const server = proxy(httpServer, httpsServer); + + server.listen(() => { + const port = server.address().port; + + httpsServer.on('upgrade', (req, socket) => { + socket.on('error', NOOP); + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + ws.close(); + }); + + const ws = new WebSocket(`wss://localhost:${port}`, { + auth: 'foo:bar', + followRedirects: true, + rejectUnauthorized: false + }); + + assert.strictEqual( + ws._req.getHeader('Authorization'), + 'Basic Zm9vOmJhcg==' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + server.close(done); + }); + }); + }); + + it('drops the Authorization and Cookie headers', (done) => { + const httpServer = http.createServer(); + const httpsServer = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const server = proxy(httpServer, httpsServer); + + server.listen(() => { + const port = server.address().port; + + httpsServer.on('upgrade', (req, socket) => { + socket.on('error', NOOP); + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, headers.host); + + ws.close(); + }); + + const ws = new WebSocket(`wss://localhost:${port}`, { + followRedirects: true, + headers, + rejectUnauthorized: false + }); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + server.close(done); + }); + }); + }); + }); + + describe("If there is at least one 'redirect' event listener", () => { + it('does not drop any headers by default', (done) => { + const httpServer = http.createServer(); + const httpsServer = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const server = proxy(httpServer, httpsServer); + + server.listen(() => { + const port = server.address().port; + + httpsServer.on('upgrade', (req, socket) => { + socket.on('error', NOOP); + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual( + req.headers.authorization, + headers.authorization + ); + assert.strictEqual(req.headers.cookie, headers.cookie); + assert.strictEqual(req.headers.host, headers.host); + + ws.close(); + }); + + const ws = new WebSocket(`wss://localhost:${port}`, { + followRedirects: true, + headers, + rejectUnauthorized: false + }); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/`); + assert.notStrictEqual(firstRequest, req); + assert.strictEqual( + req.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual(req.getHeader('Cookie'), headers.cookie); + assert.strictEqual(req.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + server.close(done); + }); + }); + }); + }); + }); + }); + + describe('When the redirect host is different', () => { + describe("If there is no 'redirect' event listener", () => { + it('drops the `auth` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { + auth: 'foo:bar', + followRedirects: true + } + ); + + assert.strictEqual( + ws._req.getHeader('Authorization'), + 'Basic Zm9vOmJhcg==' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + ws.close(); + }); + }); + + it('drops the Authorization, Cookie and Host headers (1/4)', (done) => { + // Test the `ws:` to `ws:` case. + + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual( + req.headers.host, + `localhost:${wss.address().port}` + ); + + ws.close(); + }); + }); + + it('drops the Authorization, Cookie and Host headers (2/4)', function (done) { + if (process.platform === 'win32') return this.skip(); + + // Test the `ws:` to `ws+unix:` case. + + const socketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + server.once('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 302 Found\r\nLocation: ws+unix://${socketPath}\r\n\r\n` + ); + }); + + const redirectedServer = http.createServer(); + const wss = new WebSocket.Server({ server: redirectedServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, 'localhost'); + + ws.close(); + }); + + redirectedServer.listen(socketPath, () => { + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws+unix://${socketPath}`); + assert.strictEqual(ws._redirects, 1); + + redirectedServer.close(done); + }); + }); + }); + + it('drops the Authorization, Cookie and Host headers (3/4)', function (done) { + if (process.platform === 'win32') return this.skip(); + + // Test the `ws+unix:` to `ws+unix:` case. + + const redirectingServerSocketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + const redirectedServerSocketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + const redirectingServer = http.createServer(); + + redirectingServer.on('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws+unix://${redirectedServerSocketPath}\r\n\r\n` + ); + }); + + const redirectedServer = http.createServer(); + const wss = new WebSocket.Server({ server: redirectedServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, 'localhost'); + + ws.close(); + }); + + redirectingServer.listen(redirectingServerSocketPath, listening); + redirectedServer.listen(redirectedServerSocketPath, listening); + + let callCount = 0; + + function listening() { + if (++callCount !== 2) return; + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket( + `ws+unix://${redirectingServerSocketPath}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual( + ws.url, + `ws+unix://${redirectedServerSocketPath}` + ); + assert.strictEqual(ws._redirects, 1); + + redirectingServer.close(); + redirectedServer.close(done); + }); + } + }); + + it('drops the Authorization, Cookie and Host headers (4/4)', function (done) { + if (process.platform === 'win32') return this.skip(); + + // Test the `ws+unix:` to `ws:` case. + + const redirectingServer = http.createServer(); + const redirectedServer = http.createServer(); + const wss = new WebSocket.Server({ server: redirectedServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual( + req.headers.host, + `localhost:${redirectedServer.address().port}` + ); + + ws.close(); + }); + + const socketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + redirectingServer.listen(socketPath, listening); + redirectedServer.listen(0, listening); + + let callCount = 0; + + function listening() { + if (++callCount !== 2) return; + + const port = redirectedServer.address().port; + + redirectingServer.on('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 302 Found\r\nLocation: ws://localhost:${port}\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket(`ws+unix://${socketPath}`, { + followRedirects: true, + headers + }); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + redirectingServer.close(); + redirectedServer.close(done); + }); + } + }); + }); + + describe("If there is at least one 'redirect' event listener", () => { + it('does not drop any headers by default', (done) => { + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/`); + assert.notStrictEqual(firstRequest, req); + assert.strictEqual( + req.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual(req.getHeader('Cookie'), headers.cookie); + assert.strictEqual(req.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual( + req.headers.authorization, + headers.authorization + ); + assert.strictEqual(req.headers.cookie, headers.cookie); + assert.strictEqual(req.headers.host, headers.host); + ws.close(); + }); + }); + }); + }); + + describe("In a listener of the 'redirect' event", () => { + it('allows to abort the request without swallowing errors', (done) => { + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + }); + + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/foo`); + + req.on('socket', () => { + req.abort(); + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'socket hang up'); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + done(); + }); + }); + }); + }); + + it('allows to remove headers', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar' + }; + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true, + headers + }); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/`); + assert.strictEqual( + req.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual(req.getHeader('Cookie'), headers.cookie); + + req.removeHeader('authorization'); + req.removeHeader('cookie'); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + ws.close(); + }); + }); + }); + }); + + 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`); + + ws.on('open', () => { + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + 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`); + + ws.on('open', () => { + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); + + describe('#pause', () => { + it('does nothing if `readyState` is `CONNECTING` or `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + assert.ok(!ws.isPaused); + + ws.pause(); + assert.ok(!ws.isPaused); + + ws.on('open', () => { + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.pause(); + assert.ok(!ws.isPaused); + + wss.close(done); + }); + + ws.close(); + }); + }); + }); + + it('pauses the socket', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); + + ws.pause(); + assert.ok(ws.isPaused); + assert.ok(ws._socket.isPaused()); + + ws.terminate(); + wss.close(done); + }); + }); + }); + + describe('#ping', () => { + it('throws an error if `readyState` is `CONNECTING`', () => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + assert.throws( + () => ws.ping(), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + + 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 was closed before the connection was established' + ); + + 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('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}`); + + ws.on('open', () => { + ws.ping(() => { + ws.ping(); + ws.close(); + }); + }); + }); + + wss.on('connection', (ws) => { + let pings = 0; + ws.on('ping', (data) => { + assert.ok(Buffer.isBuffer(data)); + assert.strictEqual(data.length, 0); + if (++pings === 2) wss.close(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}`); + + ws.on('open', () => { + ws.ping('hi', () => { + ws.ping('hi', true); + ws.close(); + }); + }); + }); + + wss.on('connection', (ws) => { + let pings = 0; + ws.on('ping', (message) => { + assert.strictEqual(message.toString(), 'hi'); + if (++pings === 2) wss.close(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}`); + + ws.on('open', () => { + ws.ping(0); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('ping', (message) => { + assert.strictEqual(message.toString(), '0'); + wss.close(done); + }); + }); + }); + + 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); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); + + describe('#pong', () => { + it('throws an error if `readyState` is `CONNECTING`', () => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + assert.throws( + () => ws.pong(), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + + 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 was closed before the connection was established' + ); + + 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('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}`); + + ws.on('open', () => { + ws.pong(() => { + ws.pong(); + ws.close(); + }); + }); + }); + + wss.on('connection', (ws) => { + let pongs = 0; + ws.on('pong', (data) => { + assert.ok(Buffer.isBuffer(data)); + assert.strictEqual(data.length, 0); + if (++pongs === 2) wss.close(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}`); + + ws.on('open', () => { + ws.pong('hi', () => { + ws.pong('hi', true); + ws.close(); + }); + }); + }); + + wss.on('connection', (ws) => { + let pongs = 0; + ws.on('pong', (message) => { + assert.strictEqual(message.toString(), 'hi'); + if (++pongs === 2) wss.close(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}`); + + ws.on('open', () => { + ws.pong(0); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('pong', (message) => { + assert.strictEqual(message.toString(), '0'); + wss.close(done); + }); + }); + }); + + 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); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); + + describe('#resume', () => { + it('does nothing if `readyState` is `CONNECTING` or `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + assert.ok(!ws.isPaused); + + // Verify that no exception is thrown. + ws.resume(); + + ws.on('open', () => { + ws.pause(); + assert.ok(ws.isPaused); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.resume(); + assert.ok(ws.isPaused); + + wss.close(done); + }); + + ws.terminate(); + }); + }); + }); + + it('resumes the socket', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); + + ws.pause(); + assert.ok(ws.isPaused); + assert.ok(ws._socket.isPaused()); + + ws.resume(); + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); + + ws.close(); + wss.close(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); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 5; + } + + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.send(array)); + ws.on('message', (msg, isBinary) => { + assert.deepStrictEqual(msg, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + ws.close(); + }); + }); + }); + + it('can send text data', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.send('hi')); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hi')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + ws.close(); + }); + }); + }); + + it('does not override the `fin` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send('fragment', { fin: false }); + ws.send('fragment', { fin: true }); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.deepStrictEqual(msg, Buffer.from('fragmentfragment')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + }); + + it('sends numbers as strings', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(0); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.deepStrictEqual(msg, Buffer.from('0')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + }); + + it('can send a `TypedArray`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const array = new Float32Array(6); + + for (let i = 0; i < array.length; ++i) { + array[i] = i / 2; + } + + const partial = array.subarray(2, 5); + const buf = Buffer.from( + partial.buffer, + partial.byteOffset, + partial.byteLength + ); + + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(partial); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, buf); + assert.ok(isBinary); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); + }); + }); + + it('can send an `ArrayBuffer`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; ++i) { + array[i] = i / 2; + } + + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(array.buffer); + ws.close(); + }); + + ws.onmessage = (event) => { + assert.ok(event.data.equals(Buffer.from(array.buffer))); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); + }); + }); + + 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}`); + + ws.on('open', () => { + ws.send(buf); + ws.close(); + }); + + ws.onmessage = (event) => { + assert.deepStrictEqual(event.data, buf); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); + }); + }); + + 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}`); + + ws.on('open', () => { + ws.send('hi', (err) => { + assert.ifError(err); + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + 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}`); + + ws.on('open', () => { + ws.send(); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.strictEqual(message, EMPTY_BUFFER); + assert.ok(isBinary); + wss.close(done); + }); + }); + }); + + it('honors the `mask` option', (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.send('hi', { mask: false })); + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + const chunks = []; + + ws._socket.prependListener('data', (chunk) => { + chunks.push(chunk); + }); + + 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')) + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(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}`); + + 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', () => wss.close(done)); + }); + ws.close(1001); + }); + }); + + it('closes the connection if called while connecting (2/3)', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => setTimeout(cb, 300, true), + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.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', () => wss.close(done)); + }); + setTimeout(() => ws.close(1001), 150); + } + ); + }); + + it('closes the connection if called while connecting (3/3)', (done) => { + const server = http.createServer(); + + server.listen(0, () => { + 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"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ECONNREFUSED'); + ws.close(); + ws.on('close', () => done()); + }); + }).timeout(4000); + + it("can be called from a listener of the 'redirect' event", (done) => { + const server = http.createServer(); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + }); + + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + 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', (code) => { + assert.strictEqual(code, 1006); + server.close(done); + }); + }); + + ws.on('redirect', () => { + ws.close(); + }); + }); + }); + + 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}`); + + 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', () => wss.close(done)); + }); + ws.on('upgrade', () => ws.close()); + }); + }); + + 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}`); + + ws.on('open', () => { + ws._socket.once('data', (data) => { + sent = data; + }); + }); + }); + + wss.on('connection', (ws) => { + ws._socket.once('data', (received) => { + assert.deepStrictEqual( + received.slice(0, 2), + Buffer.from([0x88, 0x80]) + ); + assert.deepStrictEqual(sent, Buffer.from([0x88, 0x00])); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1005); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + }); + ws.close(); + }); + }); + + 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}`); + + ws.on('open', () => ws.close(1000)); + }); + + wss.on('connection', (ws) => { + ws.on('close', (code, message) => { + assert.strictEqual(code, 1000); + assert.deepStrictEqual(message, EMPTY_BUFFER); + wss.close(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}`); + + ws.on('open', () => ws.close(1000, 'some reason')); + }); + + wss.on('connection', (ws) => { + ws.on('close', (code, message) => { + assert.strictEqual(code, 1000); + assert.deepStrictEqual(message, Buffer.from('some reason')); + wss.close(done); + }); + }); + }); + + it('permits all buffered data to be delivered', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const messages = []; + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + messages.push(message.toString()); + }); + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.deepStrictEqual(messages, ['foo', 'bar', 'baz']); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + const callback = (err) => assert.ifError(err); + + ws.send('foo', callback); + ws.send('bar', callback); + ws.send('baz', callback); + ws.close(); + ws.close(); + }); + }); + + it('allows close code 1013', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', (code) => { + assert.strictEqual(code, 1013); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.close(1013)); + }); + + 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}`); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + ws.close(); + wss.close(done); + }); + }); + + 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.deepStrictEqual(reason, Buffer.from('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', () => { + 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}`); + + 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', () => wss.close(done)); + }); + ws.terminate(); + }); + }); + + it('closes the connection if called while connecting (2/2)', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => setTimeout(cb, 300, true), + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.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', () => wss.close(done)); + }); + setTimeout(() => ws.terminate(), 150); + } + ); + }); + + 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"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ECONNREFUSED'); + ws.terminate(); + ws.on('close', () => done()); + }); + }).timeout(4000); + + it("can be called from a listener of the 'redirect' event", (done) => { + const server = http.createServer(); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + }); + + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + 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', (code) => { + assert.strictEqual(code, 1006); + server.close(done); + }); + }); + + ws.on('redirect', () => { + ws.terminate(); + }); + }); + }); + + 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}`); + + 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', () => wss.close(done)); + }); + ws.on('upgrade', () => ws.terminate()); + }); + }); + + it('does nothing if `readyState` is `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + ws.terminate(); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.terminate()); + }); + }); + + 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, null); + assert.strictEqual(ws.onclose, null); + assert.strictEqual(ws.onerror, null); + assert.strictEqual(ws.onopen, null); + + ws.onmessage = NOOP; + ws.onerror = NOOP; + ws.onclose = NOOP; + ws.onopen = NOOP; + + assert.strictEqual(ws.onmessage, NOOP); + assert.strictEqual(ws.onclose, NOOP); + assert.strictEqual(ws.onerror, NOOP); + assert.strictEqual(ws.onopen, NOOP); + + ws.onmessage = 'foo'; + + assert.strictEqual(ws.onmessage, null); + assert.strictEqual(ws.listenerCount('message'), 0); + }); + + it('works like the `EventEmitter` interface', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.onmessage = (messageEvent) => { + assert.strictEqual(messageEvent.data, 'foo'); + ws.onclose = (closeEvent) => { + assert.strictEqual(closeEvent.wasClean, true); + assert.strictEqual(closeEvent.code, 1005); + assert.strictEqual(closeEvent.reason, ''); + wss.close(done); + }; + ws.close(); + }; + + ws.onopen = () => ws.send('foo'); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + }); + }); + }); + + it("doesn't return listeners added with `on`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.on('open', NOOP); + + assert.deepStrictEqual(ws.listeners('open'), [NOOP]); + assert.strictEqual(ws.onopen, null); + }); + + it("doesn't remove listeners added with `on`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.on('close', NOOP); + ws.onclose = NOOP; + + let listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + + ws.onclose = NOOP; + + listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + }); + + it('supports the `addEventListener` method', () => { + const events = []; + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('foo', () => {}); + assert.strictEqual(ws.listenerCount('foo'), 0); + + ws.addEventListener('open', () => { + events.push('open'); + assert.strictEqual(ws.listenerCount('open'), 1); + }); + + assert.strictEqual(ws.listenerCount('open'), 1); + + ws.addEventListener( + 'message', + () => { + events.push('message'); + assert.strictEqual(ws.listenerCount('message'), 0); + }, + { once: true } + ); + + assert.strictEqual(ws.listenerCount('message'), 1); + + ws.emit('open'); + ws.emit('message', EMPTY_BUFFER, false); + + assert.deepStrictEqual(events, ['open', 'message']); + }); + + it("doesn't return listeners added with `addEventListener`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('open', NOOP); + + const listeners = ws.listeners('open'); + + assert.strictEqual(listeners.length, 1); + assert.strictEqual(listeners[0][kListener], NOOP); + + assert.strictEqual(ws.onopen, null); + }); + + it("doesn't remove listeners added with `addEventListener`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('close', NOOP); + ws.onclose = NOOP; + + let listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0][kListener], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + + ws.onclose = NOOP; + + listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0][kListener], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + }); + + it('supports the `removeEventListener` method', () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('message', NOOP); + ws.addEventListener('open', NOOP); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + assert.strictEqual(ws.listeners('open')[0][kListener], NOOP); + + ws.removeEventListener('message', () => {}); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + ws.removeEventListener('open', NOOP); + + assert.strictEqual(ws.listenerCount('message'), 0); + assert.strictEqual(ws.listenerCount('open'), 0); + + ws.addEventListener('message', NOOP, { once: true }); + ws.addEventListener('open', NOOP, { once: true }); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + assert.strictEqual(ws.listeners('open')[0][kListener], NOOP); + + ws.removeEventListener('message', () => {}); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + ws.removeEventListener('open', NOOP); + + assert.strictEqual(ws.listenerCount('message'), 0); + assert.strictEqual(ws.listenerCount('open'), 0); + + // Multiple listeners. + ws.addEventListener('message', NOOP); + ws.addEventListener('message', NOOP); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + assert.strictEqual(ws.listeners('message')[1][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + + assert.strictEqual(ws.listenerCount('message'), 0); + + // Listeners not added with `websocket.addEventListener()`. + ws.on('message', NOOP); + + assert.deepStrictEqual(ws.listeners('message'), [NOOP]); + + ws.removeEventListener('message', NOOP); + + assert.deepStrictEqual(ws.listeners('message'), [NOOP]); + + ws.onclose = NOOP; + + assert.strictEqual(ws.listeners('close')[0][kListener], NOOP); + + ws.removeEventListener('close', NOOP); + + assert.strictEqual(ws.listeners('close')[0][kListener], NOOP); + }); + + it('wraps text data in a `MessageEvent`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.addEventListener('open', () => { + ws.send('hi'); + ws.close(); + }); + + ws.addEventListener('message', (event) => { + assert.ok(event instanceof MessageEvent); + assert.strictEqual(event.data, 'hi'); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + }); + }); + }); + + 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}`); + + ws.addEventListener('close', (event) => { + assert.ok(event instanceof CloseEvent); + assert.ok(event.wasClean); + assert.strictEqual(event.reason, ''); + assert.strictEqual(event.code, 1000); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.close(1000)); + }); + + 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}`); + + ws.addEventListener('close', (event) => { + assert.ok(event instanceof CloseEvent); + assert.ok(event.wasClean); + assert.strictEqual(event.reason, 'some daft reason'); + assert.strictEqual(event.code, 4000); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.close(4000, 'some daft reason')); + }); + + 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}`); + + ws.addEventListener('open', (event) => { + assert.ok(event instanceof Event); + assert.strictEqual(event.type, 'open'); + assert.strictEqual(event.target, ws); + }); + ws.addEventListener('message', (event) => { + assert.ok(event instanceof MessageEvent); + assert.strictEqual(event.type, 'message'); + assert.strictEqual(event.target, ws); + ws.close(); + }); + ws.addEventListener('close', (event) => { + assert.ok(event instanceof CloseEvent); + assert.strictEqual(event.type, 'close'); + assert.strictEqual(event.target, ws); + ws.emit('error', err); + }); + ws.addEventListener('error', (event) => { + assert.ok(event instanceof ErrorEvent); + assert.strictEqual(event.message, 'forced'); + assert.strictEqual(event.type, 'error'); + assert.strictEqual(event.target, ws); + assert.strictEqual(event.error, err); + + wss.close(done); + }); + }); + + wss.on('connection', (client) => client.send('hi')); + }); + + 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}`); + + ws.onmessage = (evt) => { + assert.ok(Buffer.isBuffer(evt.data)); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.send(new Uint8Array(4096)); + ws.close(); + }); + }); + + it('ignores `binaryType` for text messages', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.binaryType = 'arraybuffer'; + + ws.onmessage = (evt) => { + assert.strictEqual(evt.data, 'foo'); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.send('foo'); + ws.close(); + }); + }); + + 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}`); + + function testType(binaryType, next) { + const buf = Buffer.from(binaryType); + ws.binaryType = binaryType; + + ws.onmessage = (evt) => { + if (binaryType === 'nodebuffer') { + assert.ok(Buffer.isBuffer(evt.data)); + assert.ok(evt.data.equals(buf)); + } else if (binaryType === 'arraybuffer') { + assert.ok(evt.data instanceof ArrayBuffer); + assert.ok(Buffer.from(evt.data).equals(buf)); + } else if (binaryType === 'fragments') { + assert.deepStrictEqual(evt.data, [buf]); + } + next(); + }; + + ws.send(buf); + } + + ws.onopen = () => { + testType('nodebuffer', () => { + testType('arraybuffer', () => { + testType('fragments', () => { + ws.close(); + wss.close(done); + }); + }); + }); + }; + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); + }); + }); + }); + + 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') + }); + const wss = new WebSocket.Server({ server }); + + wss.on('connection', () => { + server.close(done); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://127.0.0.1:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('open', ws.close); + }); + }); + + 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/ca-certificate.pem')], + key: fs.readFileSync('test/fixtures/key.pem'), + requestCert: true + }); + + const wss = new WebSocket.Server({ noServer: true }); + + server.on('upgrade', (request, socket, head) => { + assert.ok(socket.authorized); + + wss.handleUpgrade(request, socket, head, (ws) => { + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + server.close(done); + }); + }); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + cert: fs.readFileSync('test/fixtures/client-certificate.pem'), + key: fs.readFileSync('test/fixtures/client-key.pem'), + rejectUnauthorized: false + }); + + ws.on('open', ws.close); + }); + }); + + 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') + }); + const wss = new WebSocket.Server({ server }); + + server.listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('error', () => { + server.close(done); + wss.close(); + }); + }); + }); + + 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') + }); + const wss = new WebSocket.Server({ server }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('foobar')); + assert.ok(!isBinary); + server.close(done); + }); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('open', () => { + ws.send('foobar'); + ws.close(); + }); + }); + }); + + 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'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const wss = new WebSocket.Server({ server }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message); + ws.close(); + }); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('open', () => ws.send(buf)); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, buf); + assert.ok(isBinary); + + server.close(done); + }); + }); + }).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(); + }); + }); + }); + }); + + describe('Request headers', () => { + it('adds the authorization header if the url has userinfo', (done) => { + const agent = new CustomAgent(); + const userinfo = 'test:testpass'; + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('authorization'), + `Basic ${Buffer.from(userinfo).toString('base64')}` + ); + done(); + }; + + const ws = new WebSocket(`ws://${userinfo}@localhost`, { agent }); + }); + + it('honors the `auth` option', (done) => { + const agent = new CustomAgent(); + const auth = 'user:pass'; + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('authorization'), + `Basic ${Buffer.from(auth).toString('base64')}` + ); + done(); + }; + + 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', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual(req.getHeader('cookie'), 'foo=bar'); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + headers: { Cookie: 'foo=bar' }, + agent + }); + }); + + it('excludes default ports from host header', () => { + const options = { lookup() {} }; + const variants = [ + ['wss://localhost:8443', 'localhost:8443'], + ['wss://localhost:443', 'localhost'], + ['ws://localhost:88', 'localhost:88'], + ['ws://localhost:80', 'localhost'] + ]; + + for (const [url, host] of variants) { + const ws = new WebSocket(url, options); + assert.strictEqual(ws._req.getHeader('host'), host); + } + }); + + it("doesn't add the origin header by default", (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual(req.getHeader('origin'), undefined); + done(); + }; + + const ws = new WebSocket('ws://localhost', { agent }); + }); + + it('honors the `origin` option (1/2)', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual(req.getHeader('origin'), 'https://example.com:8000'); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + origin: 'https://example.com:8000', + agent + }); + }); + + it('honors the `origin` option (2/2)', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('sec-websocket-origin'), + 'https://example.com:8000' + ); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + origin: 'https://example.com:8000', + protocolVersion: 8, + agent + }); + }); + }); + + describe('permessage-deflate', () => { + it('is enabled by default', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('sec-websocket-extensions'), + 'permessage-deflate; client_max_window_bits' + ); + done(); + }; + + const ws = new WebSocket('ws://localhost', { agent }); + }); + + it('can be disabled', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('sec-websocket-extensions'), + undefined + ); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + perMessageDeflate: false, + agent + }); + }); + + it('can send extension parameters', (done) => { + const agent = new CustomAgent(); + + const value = + 'permessage-deflate; server_no_context_takeover;' + + ' client_no_context_takeover; server_max_window_bits=10;' + + ' client_max_window_bits'; + + agent.addRequest = (req) => { + assert.strictEqual(req.getHeader('sec-websocket-extensions'), value); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + perMessageDeflate: { + clientNoContextTakeover: true, + serverNoContextTakeover: true, + clientMaxWindowBits: true, + serverMaxWindowBits: 10 + }, + agent + }); + }); + + it('consumes all received data when connection is closed (1/2)', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const messages = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.on('close', () => { + assert.strictEqual(ws._receiver._state, 5); + }); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + messages.push(message.toString()); + }); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.send('foo'); + ws.send('bar'); + ws.send('baz'); + ws.send('qux', () => ws._socket.end()); + }); + }); + + it('consumes all received data when connection is closed (2/2)', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const messageLengths = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.prependListener('close', () => { + assert.strictEqual(ws._receiver._state, 5); + assert.strictEqual(ws._socket._readableState.length, 3); + }); + + const push = ws._socket.push; + + // Override `ws._socket.push()` to know exactly when data is + // received and call `ws.terminate()` immediately after that without + // relying on a timer. + ws._socket.push = (data) => { + ws._socket.push = push; + ws._socket.push(data); + ws.terminate(); + }; + + const payload1 = Buffer.alloc(15 * 1024); + const payload2 = Buffer.alloc(1); + + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; + + const list = [ + ...Sender.frame(payload1, { rsv1: false, ...opts }), + ...Sender.frame(payload2, { rsv1: true, ...opts }) + ]; + + for (let i = 0; i < 399; i++) { + list.push(list[list.length - 2], list[list.length - 1]); + } + + // This hack is used because there is no guarantee that more than + // 16 KiB will be sent as a single TCP packet. + push.call(ws._socket, Buffer.concat(list)); + + wss.clients + .values() + .next() + .value.send(payload2, { compress: false }); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + messageLengths.push(message.length); + }); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + assert.strictEqual(messageLengths.length, 402); + assert.strictEqual(messageLengths[0], 15360); + assert.strictEqual(messageLengths[messageLengths.length - 1], 1); + wss.close(done); + }); + } + ); + }); + + it('handles a close frame received while compressing data', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws._receiver.on('conclude', () => { + assert.ok(ws._sender._deflating); + }); + + ws.send('foo'); + ws.send('bar'); + ws.send('baz'); + ws.send('qux'); + }); + } + ); + + wss.on('connection', (ws) => { + const messages = []; + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + messages.push(message.toString()); + }); + + ws.on('close', (code, reason) => { + assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); + assert.strictEqual(code, 1000); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + + ws.close(1000); + }); + }); + + describe('#close', () => { + it('can be used while data is being decompressed', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const messages = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.on('end', () => { + assert.strictEqual(ws._receiver._state, 5); + }); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + + if (messages.push(message.toString()) > 1) return; + + ws.close(1000); + }); + + ws.on('close', (code, reason) => { + assert.deepStrictEqual(messages, ['', '', '', '']); + assert.strictEqual(code, 1000); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + const buf = Buffer.from('c10100c10100c10100c10100', 'hex'); + ws._socket.write(buf); + }); + }); + }); + + describe('#send', () => { + it('can send text data', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send('hi', { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hi')); + assert.ok(!isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + ws.send(message, { binary: isBinary, compress: true }); + }); + }); + }); + + it('can send a `TypedArray`', (done) => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 2; + } + + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send(array, { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); + }); + }); + }); + + it('can send an `ArrayBuffer`', (done) => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 2; + } + + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send(array.buffer, { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); + }); + }); + }); + + 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 + }); + + ws.on('open', () => { + ws.send('hi', { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hi')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + ws.send(message, { binary: isBinary, compress: true }); + }); + }); + }); + + it('calls the callback if the socket is closed prematurely', (done) => { + const called = []; + const wss = new WebSocket.Server( + { perMessageDeflate: true, port: 0 }, + () => { + 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' + ); + }); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('close', () => { + assert.deepStrictEqual(called, [1, 2]); + wss.close(done); + }); + + ws._socket.end(); + }); + }); + }); + + describe('#terminate', () => { + it('can be used while data is being compressed', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + 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(); + }); + } + ); + }); + + it('can be used while data is being decompressed', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const messages = []; + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + + if (messages.push(message.toString()) > 1) return; + + process.nextTick(() => { + assert.strictEqual(ws._receiver._state, 5); + ws.terminate(); + }); + }); + + ws.on('close', (code, reason) => { + assert.deepStrictEqual(messages, ['', '', '', '']); + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + const buf = Buffer.from('c10100c10100c10100c10100', 'hex'); + ws._socket.write(buf); + }); + }); + }); + }); + + describe('Connection close', () => { + it('closes cleanly after simultaneous errors (1/2)', (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + + ws.on('open', () => { + // Write an invalid frame in both directions to trigger simultaneous + // failure. + const chunk = Buffer.from([0x85, 0x00]); + + wss.clients.values().next().value._socket.write(chunk); + ws._socket.write(chunk); + }); + }); + + wss.on('connection', (ws) => { + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + }); + }); + }); + + it('closes cleanly after simultaneous errors (2/2)', (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + + ws.on('open', () => { + // Write an invalid frame in both directions and change the + // `readyState` to `WebSocket.CLOSING`. + const chunk = Buffer.from([0x85, 0x00]); + const serverWs = wss.clients.values().next().value; + + serverWs._socket.write(chunk); + serverWs.close(); + + ws._socket.write(chunk); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + }); + }); + }); + + it('resumes the socket when an error occurs', (done) => { + const maxPayload = 16 * 1024; + const wss = new WebSocket.Server({ maxPayload, port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const list = [ + ...Sender.frame(Buffer.alloc(maxPayload + 1), { + fin: true, + opcode: 0x02, + mask: true, + readOnly: false + }) + ]; + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + }); + + ws._socket.push(Buffer.concat(list)); + }); + }); + + it('resumes the socket when the close frame is received', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const opts = { fin: true, mask: true, readOnly: false }; + const list = [ + ...Sender.frame(Buffer.alloc(16 * 1024), { opcode: 0x02, ...opts }), + ...Sender.frame(EMPTY_BUFFER, { opcode: 0x08, ...opts }) + ]; + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1005); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + + ws._socket.push(Buffer.concat(list)); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/wrapper.mjs b/testing/xpcshell/node-ws/wrapper.mjs new file mode 100644 index 0000000000..7245ad15d0 --- /dev/null +++ b/testing/xpcshell/node-ws/wrapper.mjs @@ -0,0 +1,8 @@ +import createWebSocketStream from './lib/stream.js'; +import Receiver from './lib/receiver.js'; +import Sender from './lib/sender.js'; +import WebSocket from './lib/websocket.js'; +import WebSocketServer from './lib/websocket-server.js'; + +export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer }; +export default WebSocket; |