diff --git a/.eslintrc.yaml b/.eslintrc.yaml deleted file mode 100644 index 45998d206..000000000 --- a/.eslintrc.yaml +++ /dev/null @@ -1,18 +0,0 @@ -env: - browser: true - es6: true - mocha: true - node: true -extends: - - eslint:recommended - - plugin:prettier/recommended -parserOptions: - ecmaVersion: 9 -rules: - no-console: off - no-var: error - prefer-const: error - quotes: - - error - - single - - avoidEscape: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..6c12fea14 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,52 @@ +name: Bug report +description: Create a bug report +body: + - type: markdown + attributes: + value: | + Thank you for reporting an issue. + + This issue tracker is for bugs and issues found in ws. + General support questions should be raised on a channel like Stack Overflow. + + Please fill in as much of the template below as you're able. + - type: checkboxes + attributes: + label: Is there an existing issue for this? + description: + Please search to see if an issue already exists for the bug you + encountered. + options: + - label: + I've searched for any related issues and avoided creating a + duplicate issue. + required: true + - type: textarea + attributes: + label: Description + description: + Description of the bug or feature, preferably a simple code snippet that + can be run directly without installing third-party dependencies. + - type: input + attributes: + label: ws version + - type: input + attributes: + label: Node.js Version + description: Output of `node -v`. + - type: textarea + attributes: + label: System + description: Output of `npx envinfo --system`. + - type: textarea + attributes: + label: Expected result + description: What you expected to happen. + - type: textarea + attributes: + label: Actual result + description: What actually happened. + - type: textarea + attributes: + label: Attachments + description: Logs, screenshots, screencast, etc. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/issue_template.md b/.github/issue_template.md deleted file mode 100644 index 5c3ca6a01..000000000 --- a/.github/issue_template.md +++ /dev/null @@ -1,41 +0,0 @@ - - -- [ ] I've searched for any related issues and avoided creating a duplicate - issue. - -#### Description - - - -#### Reproducible in: - -- version: -- Node.js version(s): -- OS version(s): - -#### Steps to reproduce: - -1. - -2. - -3. - -#### Expected result: - - - -#### Actual result: - - - -#### Attachments: - - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 158a50e32..04693fc7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,47 +4,68 @@ on: - push - pull_request +permissions: {} + jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: + arch: + - x64 + - x86 node: - - 8 - 10 - 12 - 14 - 16 + - 18 + - 20 + - 22 os: - macOS-latest - ubuntu-latest - windows-latest + exclude: + - arch: x86 + os: macOS-latest + - arch: x86 + os: ubuntu-latest + - arch: x86 + node: 18 + os: windows-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} + architecture: ${{ matrix.arch }} + cache: 'npm' + cache-dependency-path: ./package.json - run: npm install - run: npm run lint - if: matrix.node == 16 && matrix.os == 'ubuntu-latest' + if: + matrix.os == 'ubuntu-latest' && matrix.node == 20 && matrix.arch == + 'x64' - run: npm test - - run: - echo ::set-output name=job_id::$(node -e - "console.log(crypto.randomBytes(16).toString('hex'))") + - run: | + id=$(node -e "console.log(crypto.randomBytes(16).toString('hex'))") + + echo "job_id=$id" >> $GITHUB_OUTPUT id: get_job_id shell: bash - - uses: coverallsapp/github-action@v1.1.2 + - uses: coverallsapp/github-action@v2 with: flag-name: ${{ steps.get_job_id.outputs.job_id }} (Node.js ${{ matrix.node }} - on ${{ matrix.os }}) + ${{ matrix.arch }} on ${{ matrix.os }}) github-token: ${{ secrets.GITHUB_TOKEN }} parallel: true coverage: needs: test runs-on: ubuntu-latest steps: - - uses: coverallsapp/github-action@v1.1.2 + - uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true diff --git a/FUNDING.json b/FUNDING.json new file mode 100644 index 000000000..043b42fec --- /dev/null +++ b/FUNDING.json @@ -0,0 +1,7 @@ +{ + "drips": { + "ethereum": { + "ownedBy": "0x3D4f997A071d2BA735AC767E68052679423c3dBe" + } + } +} diff --git a/LICENSE b/LICENSE index a145cd1df..1da5b96a1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,20 @@ -The MIT License (MIT) - Copyright (c) 2011 Einar Otto Stangvik +Copyright (c) 2013 Arnout Kazemier and contributors +Copyright (c) 2016 Luigi Pinca and contributors -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: +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. +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/README.md b/README.md index 1cb19d650..21f10df10 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # ws: a Node.js WebSocket library [![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws) -[![Build](https://img.shields.io/github/workflow/status/websockets/ws/CI/master?label=build&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) -[![Windows x86 Build](https://img.shields.io/appveyor/ci/lpinca/ws/master.svg?logo=appveyor)](https://ci.appveyor.com/project/lpinca/ws) +[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=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 @@ -12,8 +11,8 @@ 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 +reference to a backend 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 @@ -24,6 +23,7 @@ can use one of the many wrappers available on npm, like - [Protocol support](#protocol-support) - [Installing](#installing) - [Opt-in for performance](#opt-in-for-performance) + - [Legacy opt-in for performance](#legacy-opt-in-for-performance) - [API docs](#api-docs) - [WebSocket compression](#websocket-compression) - [Usage examples](#usage-examples) @@ -34,7 +34,7 @@ can use one of the many wrappers available on npm, like - [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server) - [Client authentication](#client-authentication) - [Server broadcast](#server-broadcast) - - [echo.websocket.org demo](#echowebsocketorg-demo) + - [Round-trip time](#round-trip-time) - [Use the Node.js streams API](#use-the-nodejs-streams-api) - [Other examples](#other-examples) - [FAQ](#faq) @@ -58,16 +58,37 @@ 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. +[bufferutil][] is an optional module that can be installed alongside the ws +module: -- `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. +``` +npm install --save-optional bufferutil +``` + +This is a binary addon that improves the performance of certain operations such +as masking and unmasking the data payload of the WebSocket frames. Prebuilt +binaries are available for the most popular platforms, so you don't necessarily +need to have a C++ compiler installed on your machine. + +To force ws to not use bufferutil, use the +[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This +can 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. + +#### Legacy opt-in for performance + +If you are running on an old version of Node.js (prior to v18.14.0), ws also +supports the [utf-8-validate][] module: + +``` +npm install --save-optional utf-8-validate +``` + +This contains a binary polyfill for [`buffer.isUtf8()`][]. + +To force ws not to use utf-8-validate, use the +[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable. ## API docs @@ -98,9 +119,9 @@ into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs]. See [the docs][ws-server-options] for more options. ```js -const WebSocket = require('ws'); +import WebSocket, { WebSocketServer } from 'ws'; -const wss = new WebSocket.Server({ +const wss = new WebSocketServer({ port: 8080, perMessageDeflate: { zlibDeflateOptions: { @@ -119,17 +140,17 @@ const wss = new WebSocket.Server({ // 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. + // 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 +server. To always disable the extension on the client, set the `perMessageDeflate` option to `false`. ```js -const WebSocket = require('ws'); +import WebSocket from 'ws'; const ws = new WebSocket('ws://www.host.com/path', { perMessageDeflate: false @@ -141,26 +162,30 @@ const ws = new WebSocket('ws://www.host.com/path', { ### Sending and receiving text data ```js -const WebSocket = require('ws'); +import WebSocket from 'ws'; const ws = new WebSocket('ws://www.host.com/path'); +ws.on('error', console.error); + ws.on('open', function open() { ws.send('something'); }); -ws.on('message', function incoming(data) { - console.log(data); +ws.on('message', function message(data) { + console.log('received: %s', data); }); ``` ### Sending binary data ```js -const WebSocket = require('ws'); +import WebSocket from 'ws'; const ws = new WebSocket('ws://www.host.com/path'); +ws.on('error', console.error); + ws.on('open', function open() { const array = new Float32Array(5); @@ -175,13 +200,15 @@ ws.on('open', function open() { ### Simple server ```js -const WebSocket = require('ws'); +import { WebSocketServer } from 'ws'; -const wss = new WebSocket.Server({ port: 8080 }); +const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { - ws.on('message', function incoming(message) { - console.log('received: %s', message); + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log('received: %s', data); }); ws.send('something'); @@ -191,19 +218,21 @@ wss.on('connection', function connection(ws) { ### External HTTP/S server ```js -const fs = require('fs'); -const https = require('https'); -const WebSocket = require('ws'); +import { createServer } from 'https'; +import { readFileSync } from 'fs'; +import { WebSocketServer } from 'ws'; -const server = https.createServer({ - cert: fs.readFileSync('/path/to/cert.pem'), - key: fs.readFileSync('/path/to/key.pem') +const server = createServer({ + cert: readFileSync('/path/to/cert.pem'), + key: readFileSync('/path/to/key.pem') }); -const wss = new WebSocket.Server({ server }); +const wss = new WebSocketServer({ server }); wss.on('connection', function connection(ws) { - ws.on('message', function incoming(message) { - console.log('received: %s', message); + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log('received: %s', data); }); ws.send('something'); @@ -215,24 +244,27 @@ server.listen(8080); ### Multiple servers sharing a single HTTP/S server ```js -const http = require('http'); -const WebSocket = require('ws'); -const url = require('url'); +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; -const server = http.createServer(); -const wss1 = new WebSocket.Server({ noServer: true }); -const wss2 = new WebSocket.Server({ noServer: true }); +const server = createServer(); +const wss1 = new WebSocketServer({ noServer: true }); +const wss2 = new WebSocketServer({ noServer: true }); wss1.on('connection', function connection(ws) { + ws.on('error', console.error); + // ... }); wss2.on('connection', function connection(ws) { + ws.on('error', console.error); + // ... }); server.on('upgrade', function upgrade(request, socket, head) { - const pathname = url.parse(request.url).pathname; + const { pathname } = new URL(request.url, 'wss://base.url'); if (pathname === '/foo') { wss1.handleUpgrade(request, socket, head, function done(ws) { @@ -253,27 +285,37 @@ server.listen(8080); ### Client authentication ```js -const http = require('http'); -const WebSocket = require('ws'); +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; + +function onSocketError(err) { + console.error(err); +} -const server = http.createServer(); -const wss = new WebSocket.Server({ noServer: true }); +const server = createServer(); +const wss = new WebSocketServer({ noServer: true }); wss.on('connection', function connection(ws, request, client) { - ws.on('message', function message(msg) { - console.log(`Received message ${msg} from user ${client}`); + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log(`Received message ${data} from user ${client}`); }); }); server.on('upgrade', function upgrade(request, socket, head) { + socket.on('error', onSocketError); + // This function is not defined on purpose. Implement it with your own logic. - authenticate(request, (err, client) => { + authenticate(request, function next(err, client) { if (err || !client) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } + socket.removeListener('error', onSocketError); + wss.handleUpgrade(request, socket, head, function done(ws) { wss.emit('connection', ws, request, client); }); @@ -291,15 +333,17 @@ A client WebSocket broadcasting to all connected WebSocket clients, including itself. ```js -const WebSocket = require('ws'); +import WebSocket, { WebSocketServer } from 'ws'; -const wss = new WebSocket.Server({ port: 8080 }); +const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { - ws.on('message', function incoming(data) { + ws.on('error', console.error); + + ws.on('message', function message(data, isBinary) { wss.clients.forEach(function each(client) { if (client.readyState === WebSocket.OPEN) { - client.send(data); + client.send(data, { binary: isBinary }); } }); }); @@ -310,29 +354,31 @@ A client WebSocket broadcasting to every other connected WebSocket clients, excluding itself. ```js -const WebSocket = require('ws'); +import WebSocket, { WebSocketServer } from 'ws'; -const wss = new WebSocket.Server({ port: 8080 }); +const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { - ws.on('message', function incoming(data) { + ws.on('error', console.error); + + ws.on('message', function message(data, isBinary) { wss.clients.forEach(function each(client) { if (client !== ws && client.readyState === WebSocket.OPEN) { - client.send(data); + client.send(data, { binary: isBinary }); } }); }); }); ``` -### echo.websocket.org demo +### Round-trip time ```js -const WebSocket = require('ws'); +import WebSocket from 'ws'; -const ws = new WebSocket('wss://echo.websocket.org/', { - origin: 'https://websocket.org' -}); +const ws = new WebSocket('wss://websocket-echo.com/'); + +ws.on('error', console.error); ws.on('open', function open() { console.log('connected'); @@ -343,8 +389,8 @@ ws.on('close', function close() { console.log('disconnected'); }); -ws.on('message', function incoming(data) { - console.log(`Roundtrip time: ${Date.now() - data} ms`); +ws.on('message', function message(data) { + console.log(`Round-trip time: ${Date.now() - data} ms`); setTimeout(function timeout() { ws.send(Date.now()); @@ -355,13 +401,13 @@ ws.on('message', function incoming(data) { ### Use the Node.js streams API ```js -const WebSocket = require('ws'); +import WebSocket, { createWebSocketStream } from 'ws'; -const ws = new WebSocket('wss://echo.websocket.org/', { - origin: 'https://websocket.org' -}); +const ws = new WebSocket('wss://websocket-echo.com/'); + +const duplex = createWebSocketStream(ws, { encoding: 'utf8' }); -const duplex = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }); +duplex.on('error', console.error); duplex.pipe(process.stdout); process.stdin.pipe(duplex); @@ -381,12 +427,14 @@ Otherwise, see the test cases. The remote IP address can be obtained from the raw socket. ```js -const WebSocket = require('ws'); +import { WebSocketServer } from 'ws'; -const wss = new WebSocket.Server({ port: 8080 }); +const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws, req) { const ip = req.socket.remoteAddress; + + ws.on('error', console.error); }); ``` @@ -396,31 +444,32 @@ the `X-Forwarded-For` header. ```js wss.on('connection', function connection(ws, req) { const ip = req.headers['x-forwarded-for'].split(',')[0].trim(); + + ws.on('error', console.error); }); ``` ### 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 +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 +In these cases, ping messages can be used as a means to verify that the remote endpoint is still responsive. ```js -const WebSocket = require('ws'); - -function noop() {} +import { WebSocketServer } from 'ws'; function heartbeat() { this.isAlive = true; } -const wss = new WebSocket.Server({ port: 8080 }); +const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { ws.isAlive = true; + ws.on('error', console.error); ws.on('pong', heartbeat); }); @@ -429,7 +478,7 @@ const interval = setInterval(function ping() { if (ws.isAlive === false) return ws.terminate(); ws.isAlive = false; - ws.ping(noop); + ws.ping(); }); }, 30000); @@ -441,12 +490,12 @@ wss.on('close', function close() { 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 +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 -const WebSocket = require('ws'); +import WebSocket from 'ws'; function heartbeat() { clearTimeout(this.pingTimeout); @@ -460,8 +509,9 @@ function heartbeat() { }, 30000 + 1000); } -const client = new WebSocket('wss://echo.websocket.org/'); +const client = new WebSocket('wss://websocket-echo.com/'); +client.on('error', console.error); client.on('open', heartbeat); client.on('ping', heartbeat); client.on('close', function clear() { @@ -482,6 +532,8 @@ We're using the GitHub [releases][changelog] for changelog entries. [MIT](LICENSE) +[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input +[bufferutil]: https://github.com/websockets/bufferutil [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 @@ -492,5 +544,5 @@ We're using the GitHub [releases][changelog] for changelog entries. [server-report]: http://websockets.github.io/ws/autobahn/servers/ [session-parse-example]: ./examples/express-session-parse [socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent -[ws-server-options]: - https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback +[utf-8-validate]: https://github.com/websockets/utf-8-validate +[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback diff --git a/SECURITY.md b/SECURITY.md index 0baf19a63..fb492e834 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,21 +12,21 @@ blocked instantly. ## Exceptions -If you do not receive an acknowledgement within the said time frame please give +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 +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: + find the e-mails in the git logs, for example, using the following command: `git --no-pager show -s --format='%an <%ae>' ` where `` 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 +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 @@ -34,6 +34,8 @@ publish a security advisory to - 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) + [DoS in the `Sec-Websocket-Extensions` header parser](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) +- 16 Jun 2024: + [DoS when handling a request with many HTTP headers](https://github.com/websockets/ws/releases/tag/8.17.1) diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index f4c05fbf4..000000000 --- a/appveyor.yml +++ /dev/null @@ -1,19 +0,0 @@ -environment: - matrix: - - nodejs_version: '16' - - nodejs_version: '14' - - nodejs_version: '12' - - nodejs_version: '10' - - nodejs_version: '8' -platform: - - x86 -matrix: - fast_finish: true -install: - - ps: Install-Product node $env:nodejs_version $env:platform - - npm install -test_script: - - node --version - - npm --version - - npm test -build: off diff --git a/bench/parser.benchmark.js b/bench/parser.benchmark.js index dd97701af..a6e359d05 100644 --- a/bench/parser.benchmark.js +++ b/bench/parser.benchmark.js @@ -36,7 +36,12 @@ const binaryFrame3 = createBinaryFrame(200 * 1024); const binaryFrame4 = createBinaryFrame(1024 * 1024); const suite = new benchmark.Suite(); -const receiver = new Receiver('nodebuffer', {}, true); +const receiver = new Receiver({ + binaryType: 'nodebuffer', + extensions: {}, + isServer: true, + skipUTF8Validation: false +}); suite.add('ping frame (5 bytes payload)', { defer: true, diff --git a/bench/speed.js b/bench/speed.js index 32ec0fb81..bef6a3067 100644 --- a/bench/speed.js +++ b/bench/speed.js @@ -19,7 +19,9 @@ if (cluster.isMaster) { }); wss.on('connection', (ws) => { - ws.on('message', (data) => ws.send(data)); + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); }); server.listen(path ? { path } : { port }, () => cluster.fork()); diff --git a/doc/ws.md b/doc/ws.md index a3b1bff81..f30ad4cae 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -2,13 +2,14 @@ ## Table of Contents -- [Class: WebSocket.Server](#class-websocketserver) - - [new WebSocket.Server(options[, callback])](#new-websocketserveroptions-callback) +- [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) @@ -17,13 +18,14 @@ - [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) + - [IPC connections](#ipc-connections) - [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) @@ -31,20 +33,26 @@ - [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) -- [WebSocket.createWebSocketStream(websocket[, options])](#websocketcreatewebsocketstreamwebsocket-options) -- [WS Error Codes](#ws-error-codes) +- [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) @@ -57,43 +65,55 @@ - [WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH](#ws_err_unsupported_data_payload_length) - [WS_ERR_UNSUPPORTED_MESSAGE_LENGTH](#ws_err_unsupported_message_length) -## Class: WebSocket.Server +## Class: WebSocketServer This class represents a WebSocket server. It extends the `EventEmitter`. -### new WebSocket.Server(options[, callback]) +### new WebSocketServer(options[, callback]) - `options` {Object} + - `autoPong` {Boolean} Specifies whether or not to automatically send a pong + in response to a ping. Defaults to `true`. + - `allowSynchronousEvents` {Boolean} Specifies whether any of the `'message'`, + `'ping'`, and `'pong'` events can be emitted multiple times in the same + tick. Defaults to `true`. Setting it to `false` improves compatibility with + the WHATWG standardbut may negatively impact performance. + - `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. - - `backlog` {Number} The maximum length of the queue of pending connections. - `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)) - - `handleProtocols` {Function} A function which can be used to handle the - WebSocket subprotocols. See description below. - - `path` {String} Accept only connections matching this path. - - `noServer` {Boolean} Enable no server mode. - - `clientTracking` {Boolean} Specifies whether or not to track clients. - - `perMessageDeflate` {Boolean|Object} Enable/disable permessage-deflate. - - `maxPayload` {Number} The maximum allowed message size in bytes. + - `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 +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 -completly detached from the HTTP/S server. This makes it possible, for example, +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 +> authentication in the `'upgrade'` event of the HTTP server. See examples for > more details. -If `verifyClient` is not set then the handshake is automatically accepted. If it -is provided with a single argument then that is: +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. @@ -104,36 +124,37 @@ is provided with a single argument then that is: The return value (`Boolean`) of the function determines whether or not to accept the handshake. -if `verifyClient` is provided with two arguments then those are: +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 + - `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 + - `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, + - `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` {Array} The list of WebSocket subprotocols indicated by the client +- `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 +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 +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: +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 @@ -144,19 +165,19 @@ value). If an object is provided then that is extension parameters: 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. - Defaults to 1024 bytes. +- `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 +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. +`callback` will be added as a listener for the `'listening'` event on the HTTP +server when the `port` option is set. ### Event: 'close' @@ -191,6 +212,21 @@ handshake. This allows you to inspect/modify the headers before they are sent. 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 @@ -202,19 +238,24 @@ a pipe or UNIX domain socket, the name is returned as a string. - {Set} -A set that stores all connected clients. Please note that this property is only -added when the `clientTracking` is truthy. +A set that stores all connected clients. This property is only added when the +`clientTracking` is truthy. ### server.close([callback]) -Close the HTTP server if created internally, terminate all clients and call -callback when done. If an external HTTP server is used via the `server` or -`noServer` constructor options, it must be closed manually. +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} The network socket between the server and client. +- `socket` {stream.Duplex} The network socket between the server and client. - `head` {Buffer} The first packet of the upgraded stream. - `callback` {Function}. @@ -232,7 +273,7 @@ If the upgrade is successful, the `callback` is called with two arguments: - `request` {http.IncomingMessage} The client HTTP GET request. -See if a given request should be handled by this server. By default this method +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. @@ -257,42 +298,80 @@ This class represents a WebSocket. It extends the `EventEmitter`. - `address` {String|url.URL} The URL to which to connect. - `protocols` {String|Array} The list of subprotocols. - `options` {Object} + - `autoPong` {Boolean} Specifies whether or not to automatically send a pong + in response to a ping. Defaults to `true`. + - `allowSynchronousEvents` {Boolean} Specifies whether any of the `'message'`, + `'ping'`, and `'pong'` events can be emitted multiple times in the same + tick. Defaults to `true`. Setting it to `false` improves compatibility with + the WHATWG standardbut may negatively impact performance. + - `finishRequest` {Function} A function which can be used to customize the + headers of each HTTP request before it is sent. See description below. - `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. - - `perMessageDeflate` {Boolean|Object} Enable/disable permessage-deflate. - - `protocolVersion` {Number} Value of the `Sec-WebSocket-Version` header. - `origin` {String} Value of the `Origin` or `Sec-WebSocket-Origin` header depending on the `protocolVersion`. - - `maxPayload` {Number} The maximum allowed message size in bytes. - - Any other option allowed in [http.request()][] or [https.request()][]. + - `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. +Create a new WebSocket instance. + `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. +`finishRequest` is called with arguments -#### UNIX Domain Sockets +- `request` {http.ClientRequest} +- `websocket` {WebSocket} -`ws` supports making requests to UNIX domain sockets. To make one, use the -following URL scheme: +for each HTTP GET request (the initial one and any caused by redirects) when it +is ready to be sent, to allow for last minute customization of the headers. If +`finishRequest` is set, then it has the responsibility to call `request.end()` +once it is done setting request headers. This is intended for niche use-cases +where some headers can't be provided in advance e.g. because they depend on the +underlying socket. -``` -ws+unix:///absolute/path/to/uds_socket:/pathname?search_params -``` +#### IPC connections -Note that `:` is the separator between the socket path and the URL path. If the -URL path is omitted +`ws` supports IPC connections. To connect to an IPC endpoint, use the following +URL form: + +- On Unices + + ``` + ws+unix:/absolute/path/to/uds_socket:/pathname?search_params + ``` + +- On Windows + + ``` + ws+unix:\\.\pipe\pipe_name:/pathname?search_params + ``` + +The character `:` is the separator between the IPC path (the Unix domain socket +path or the Windows named pipe) and the URL path. The IPC path must not include +the characters `:` and `?`, otherwise the URL is incorrectly parsed. If the URL +path is omitted ``` -ws+unix:///absolute/path/to/uds_socket +ws+unix:/absolute/path/to/uds_socket ``` it defaults to `/`. @@ -300,24 +379,27 @@ it defaults to `/`. ### Event: 'close' - `code` {Number} -- `reason` {String} +- `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 -human-readable string explaining why the connection has been closed. +`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 [WS Error Codes](#ws-error-codes). +of the string values defined below under [Error codes](#error-codes). ### Event: 'message' -- `data` {String|Buffer|ArrayBuffer|Buffer[]} +- `data` {Buffer|ArrayBuffer|Buffer[]} +- `isBinary` {Boolean} -Emitted when a message is received from the server. +Emitted when a message is received. `data` is the message content. `isBinary` +specifies whether the message is binary or not. ### Event: 'open' @@ -327,13 +409,26 @@ Emitted when the connection is established. - `data` {Buffer} -Emitted when a ping is received from the server. +Emitted when a ping is received. ### Event: 'pong' - `data` {Buffer} -Emitted when a pong is received from the server. +Emitted when a pong is received. + +### 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' @@ -356,23 +451,26 @@ handshake. This allows you to read headers from the server, for example ### websocket.addEventListener(type, listener[, options]) - `type` {String} A string representing the event type to listen for. -- `listener` {Function} The listener to add. +- `listener` {Function|Object} 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. +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. +This should be one of "nodebuffer", "arraybuffer", "blob", 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 @@ -382,18 +480,23 @@ 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. 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} A human-readable string explaining why the connection is - closing. +- `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} @@ -418,8 +521,8 @@ An event listener to be called when an error occurs. The listener receives an - {Function} -An event listener to be called when a message is received from the server. The -listener receives a `MessageEvent` named "message". +An event listener to be called when a message is received. The listener receives +a `MessageEvent` named "message". ### websocket.onopen @@ -428,27 +531,37 @@ listener receives a `MessageEvent` named "message". 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` + {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray|Blob} 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. + frame is written out. If an error occurs, the callback is called with the + error as its first argument. -Send a ping. +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` + {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray|Blob} 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. + frame is written out. If an error occurs, the callback is called with the + error as its first argument. -Send a pong. +Send a pong. This method throws an error if the ready state is `CONNECTING`. ### websocket.protocol @@ -456,6 +569,11 @@ Send a pong. 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} @@ -465,31 +583,38 @@ 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. +- `listener` {Function|Object} The listener to remove. -Removes an event listener emulating the `EventTarget` interface. +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. +- `data` + {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray|Blob} The + data to send. `Object` values are only supported if they conform to the + requirements of [`Buffer.from()`][]. If those constraints are not met, a + `TypeError` is thrown. - `options` {Object} - - `compress` {Boolean} Specifies whether `data` should be compressed or not. - Defaults to `true` when permessage-deflate is enabled. - `binary` {Boolean} Specifies whether `data` should be sent as a binary or not. Default is autodetected. - - `mask` {Boolean} Specifies whether `data` should be masked or not. Defaults - to `true` when `websocket` is not a server client. + - `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. + written out. If an error occurs, the callback is called with the error as its + first argument. -Send `data` through the connection. +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()][]. +Forcibly close the connection. Internally, this calls [`socket.destroy()`][]. ### websocket.url @@ -497,7 +622,7 @@ Forcibly close the connection. Internally this calls [socket.destroy()][]. The URL of the WebSocket server. Server clients don't have this attribute. -## WebSocket.createWebSocketStream(websocket[, options]) +## createWebSocketStream(websocket[, options]) - `websocket` {WebSocket} A `WebSocket` object. - `options` {Object} [Options][duplex-options] to pass to the `Duplex` @@ -506,7 +631,19 @@ The URL of the WebSocket server. Server clients don't have this attribute. Returns a `Duplex` stream that allows to use the Node.js streams API on top of a given `WebSocket`. -## WS Error Codes +## 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: @@ -560,11 +697,16 @@ 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()]: +[`buffer.from()`]: + https://nodejs.org/api/buffer.html#static-method-bufferfromobject-offsetorencoding-length +[`http.request()`]: https://nodejs.org/api/http.html#http_http_request_options_callback -[https.request()]: +[`https.request()`]: https://nodejs.org/api/https.html#https_https_request_options_callback [permessage-deflate]: https://tools.ietf.org/html/draft-ietf-hybi-permessage-compression-19 -[socket.destroy()]: https://nodejs.org/api/net.html#net_socket_destroy_error +[`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/eslint.config.js b/eslint.config.js new file mode 100644 index 000000000..4e685b9ad --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +'use strict'; + +const pluginPrettierRecommended = require('eslint-plugin-prettier/recommended'); +const globals = require('globals'); +const js = require('@eslint/js'); + +module.exports = [ + js.configs.recommended, + { + ignores: ['.nyc_output/', '.vscode/', 'coverage/', 'node_modules/'], + languageOptions: { + ecmaVersion: 'latest', + globals: { + ...globals.browser, + ...globals.mocha, + ...globals.node + }, + sourceType: 'module' + }, + rules: { + 'no-console': 'off', + 'no-unused-vars': ['error', { caughtErrors: 'none' }], + 'no-var': 'error', + 'prefer-const': 'error' + } + }, + pluginPrettierRecommended +]; diff --git a/examples/express-session-parse/index.js b/examples/express-session-parse/index.js index 8fc4ce029..e0f214406 100644 --- a/examples/express-session-parse/index.js +++ b/examples/express-session-parse/index.js @@ -5,7 +5,11 @@ const express = require('express'); const http = require('http'); const uuid = require('uuid'); -const WebSocket = require('../..'); +const { WebSocketServer } = require('../..'); + +function onSocketError(err) { + console.error(err); +} const app = express(); const map = new Map(); @@ -56,9 +60,11 @@ const server = http.createServer(app); // // Create a WebSocket server completely detached from the HTTP server. // -const wss = new WebSocket.Server({ clientTracking: false, noServer: true }); +const wss = new WebSocketServer({ clientTracking: false, noServer: true }); server.on('upgrade', function (request, socket, head) { + socket.on('error', onSocketError); + console.log('Parsing session from request...'); sessionParser(request, {}, () => { @@ -70,6 +76,8 @@ server.on('upgrade', function (request, socket, head) { console.log('Session is parsed!'); + socket.removeListener('error', onSocketError); + wss.handleUpgrade(request, socket, head, function (ws) { wss.emit('connection', ws, request); }); @@ -81,6 +89,8 @@ wss.on('connection', function (ws, request) { map.set(userId, ws); + ws.on('error', console.error); + ws.on('message', function (message) { // // Here we can now use session parameters. diff --git a/examples/express-session-parse/package.json b/examples/express-session-parse/package.json index f8cd22e30..406706ce8 100644 --- a/examples/express-session-parse/package.json +++ b/examples/express-session-parse/package.json @@ -6,6 +6,6 @@ "dependencies": { "express": "^4.16.4", "express-session": "^1.16.1", - "uuid": "^3.3.2" + "uuid": "^8.3.2" } } diff --git a/examples/server-stats/index.js b/examples/server-stats/index.js index da1f95a3b..afab8363f 100644 --- a/examples/server-stats/index.js +++ b/examples/server-stats/index.js @@ -4,13 +4,13 @@ const express = require('express'); const path = require('path'); const { createServer } = require('http'); -const WebSocket = require('../../'); +const { WebSocketServer } = require('../..'); const app = express(); app.use(express.static(path.join(__dirname, '/public'))); const server = createServer(app); -const wss = new WebSocket.Server({ server }); +const wss = new WebSocketServer({ server }); wss.on('connection', function (ws) { const id = setInterval(function () { @@ -22,6 +22,8 @@ wss.on('connection', function (ws) { }, 100); console.log('started client interval'); + ws.on('error', console.error); + ws.on('close', function () { console.log('stopping client interval'); clearInterval(id); diff --git a/examples/ssl.js b/examples/ssl.js index c4d5b0758..83fb5f280 100644 --- a/examples/ssl.js +++ b/examples/ssl.js @@ -3,18 +3,20 @@ const https = require('https'); const fs = require('fs'); -const WebSocket = require('..'); +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 WebSocket.Server({ server }); +const wss = new WebSocketServer({ server }); wss.on('connection', function connection(ws) { + ws.on('error', console.error); + ws.on('message', function message(msg) { - console.log(msg); + console.log(msg.toString()); }); }); @@ -31,6 +33,8 @@ server.listen(function listening() { rejectUnauthorized: false }); + ws.on('error', console.error); + ws.on('open', function open() { ws.send('All glory to WebSockets!'); }); diff --git a/index.js b/index.js index 722c78676..41edb3b81 100644 --- a/index.js +++ b/index.js @@ -7,4 +7,7 @@ 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/lib/buffer-util.js b/lib/buffer-util.js index 6fd84c311..f7536e28e 100644 --- a/lib/buffer-util.js +++ b/lib/buffer-util.js @@ -2,6 +2,8 @@ const { EMPTY_BUFFER } = require('./constants'); +const FastBuffer = Buffer[Symbol.species]; + /** * Merges an array of buffers into a new buffer. * @@ -23,7 +25,9 @@ function concat(list, totalLength) { offset += buf.length; } - if (offset < totalLength) return target.slice(0, offset); + if (offset < totalLength) { + return new FastBuffer(target.buffer, target.byteOffset, offset); + } return target; } @@ -52,9 +56,7 @@ function _mask(source, mask, output, offset, length) { * @public */ function _unmask(buffer, mask) { - // Required until https://github.com/nodejs/node/issues/9006 is resolved. - const length = buffer.length; - for (let i = 0; i < length; i++) { + for (let i = 0; i < buffer.length; i++) { buffer[i] ^= mask[i & 3]; } } @@ -67,11 +69,11 @@ function _unmask(buffer, mask) { * @public */ function toArrayBuffer(buf) { - if (buf.byteLength === buf.buffer.byteLength) { + if (buf.length === buf.buffer.byteLength) { return buf.buffer; } - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length); } /** @@ -90,9 +92,9 @@ function toBuffer(data) { let buf; if (data instanceof ArrayBuffer) { - buf = Buffer.from(data); + buf = new FastBuffer(data); } else if (ArrayBuffer.isView(data)) { - buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength); } else { buf = Buffer.from(data); toBuffer.readOnly = false; @@ -101,29 +103,29 @@ function toBuffer(data) { return buf; } -try { - const bufferUtil = require('bufferutil'); - const bu = bufferUtil.BufferUtil || bufferUtil; +module.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask +}; - module.exports = { - concat, - mask(source, mask, output, offset, length) { +/* 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 bu.mask(source, mask, output, offset, length); - }, - toArrayBuffer, - toBuffer, - unmask(buffer, mask) { + else bufferUtil.mask(source, mask, output, offset, length); + }; + + module.exports.unmask = function (buffer, mask) { if (buffer.length < 32) _unmask(buffer, mask); - else bu.unmask(buffer, mask); - } - }; -} catch (e) /* istanbul ignore next */ { - module.exports = { - concat, - mask: _mask, - toArrayBuffer, - toBuffer, - unmask: _unmask - }; + else bufferUtil.unmask(buffer, mask); + }; + } catch (e) { + // Continue regardless of the error. + } } diff --git a/lib/constants.js b/lib/constants.js index 4082981f8..74214d466 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -1,10 +1,18 @@ 'use strict'; +const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments']; +const hasBlob = typeof Blob !== 'undefined'; + +if (hasBlob) BINARY_TYPES.push('blob'); + module.exports = { - BINARY_TYPES: ['nodebuffer', 'arraybuffer', 'fragments'], + BINARY_TYPES, + EMPTY_BUFFER: Buffer.alloc(0), GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + hasBlob, + kForOnEventAttribute: Symbol('kIsForOnEventAttribute'), + kListener: Symbol('kListener'), kStatusCode: Symbol('status-code'), kWebSocket: Symbol('websocket'), - EMPTY_BUFFER: Buffer.alloc(0), NOOP: () => {} }; diff --git a/lib/event-target.js b/lib/event-target.js index a6fbe72b7..fea4cbc52 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -1,111 +1,172 @@ '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. - * - * @private */ class Event { /** * Create a new `Event`. * * @param {String} type The name of the event - * @param {Object} target A reference to the target to which the event was - * dispatched + * @throws {TypeError} If the `type` argument is not specified */ - constructor(type, target) { - this.target = target; - this.type = type; + constructor(type) { + this[kTarget] = null; + this[kType] = type; } -} -/** - * Class representing a message event. - * - * @extends Event - * @private - */ -class MessageEvent extends Event { /** - * Create a new `MessageEvent`. - * - * @param {(String|Buffer|ArrayBuffer|Buffer[])} data The received data - * @param {WebSocket} target A reference to the target to which the event was - * dispatched + * @type {*} */ - constructor(data, target) { - super('message', target); + get target() { + return this[kTarget]; + } - this.data = data; + /** + * @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 - * @private */ class CloseEvent extends Event { /** * Create a new `CloseEvent`. * - * @param {Number} code The status code explaining why the connection is being - * closed - * @param {String} reason A human-readable string explaining why the - * connection is closing - * @param {WebSocket} target A reference to the target to which the event was - * dispatched + * @param {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(code, reason, target) { - super('close', target); + constructor(type, options = {}) { + super(type); - this.wasClean = target._closeFrameReceived && target._closeFrameSent; - this.reason = reason; - this.code = code; + 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 open event. + * Class representing an error event. * * @extends Event - * @private */ -class OpenEvent extends Event { +class ErrorEvent extends Event { /** - * Create a new `OpenEvent`. + * Create a new `ErrorEvent`. * - * @param {WebSocket} target A reference to the target to which the event was - * dispatched + * @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 {*} */ - constructor(target) { - super('open', target); + 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 an error event. + * Class representing a message event. * * @extends Event - * @private */ -class ErrorEvent extends Event { +class MessageEvent extends Event { /** - * Create a new `ErrorEvent`. + * Create a new `MessageEvent`. * - * @param {Object} error The error that generated this event - * @param {WebSocket} target A reference to the target to which the event was - * dispatched + * @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(error, target) { - super('error', target); + constructor(type, options = {}) { + super(type); + + this[kData] = options.data === undefined ? null : options.data; + } - this.message = error.message; - this.error = error; + /** + * @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. @@ -117,49 +178,75 @@ 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 {(Function|Object)} handler 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 + * @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) { - if (typeof listener !== 'function') return; - - function onMessage(data) { - listener.call(this, new MessageEvent(data, this)); - } - - function onClose(code, message) { - listener.call(this, new CloseEvent(code, message, this)); - } - - function onError(error) { - listener.call(this, new ErrorEvent(error, this)); - } - - function onOpen() { - listener.call(this, new OpenEvent(this)); + addEventListener(type, handler, options = {}) { + for (const listener of this.listeners(type)) { + if ( + !options[kForOnEventAttribute] && + listener[kListener] === handler && + !listener[kForOnEventAttribute] + ) { + return; + } } - const method = options && options.once ? 'once' : 'on'; + let wrapper; if (type === 'message') { - onMessage._listener = listener; - this[method](type, onMessage); + wrapper = function onMessage(data, isBinary) { + const event = new MessageEvent('message', { + data: isBinary ? data : data.toString() + }); + + event[kTarget] = this; + callListener(handler, this, event); + }; } else if (type === 'close') { - onClose._listener = listener; - this[method](type, onClose); + wrapper = function onClose(code, message) { + const event = new CloseEvent('close', { + code, + reason: message.toString(), + wasClean: this._closeFrameReceived && this._closeFrameSent + }); + + event[kTarget] = this; + callListener(handler, this, event); + }; } else if (type === 'error') { - onError._listener = listener; - this[method](type, onError); + wrapper = function onError(error) { + const event = new ErrorEvent('error', { + error, + message: error.message + }); + + event[kTarget] = this; + callListener(handler, this, event); + }; } else if (type === 'open') { - onOpen._listener = listener; - this[method](type, onOpen); + wrapper = function onOpen() { + const event = new Event('open'); + + event[kTarget] = this; + callListener(handler, this, event); + }; } else { - this[method](type, listener); + return; + } + + wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; + wrapper[kListener] = handler; + + if (options.once) { + this.once(type, wrapper); + } else { + this.on(type, wrapper); } }, @@ -167,18 +254,39 @@ const EventTarget = { * Remove an event listener. * * @param {String} type A string representing the event type to remove - * @param {Function} listener The listener to remove + * @param {(Function|Object)} handler The listener to remove * @public */ - removeEventListener(type, listener) { - const listeners = this.listeners(type); - - for (let i = 0; i < listeners.length; i++) { - if (listeners[i] === listener || listeners[i]._listener === listener) { - this.removeListener(type, listeners[i]); + removeEventListener(type, handler) { + for (const listener of this.listeners(type)) { + if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { + this.removeListener(type, listener); + break; } } } }; -module.exports = EventTarget; +module.exports = { + CloseEvent, + ErrorEvent, + Event, + EventTarget, + MessageEvent +}; + +/** + * Call an event listener + * + * @param {(Function|Object)} listener The listener to call + * @param {*} thisArg The value to use as `this`` when calling the listener + * @param {Event} event The event to pass to the listener + * @private + */ +function callListener(listener, thisArg, event) { + if (typeof listener === 'object' && listener.handleEvent) { + listener.handleEvent.call(listener, event); + } else { + listener.call(thisArg, event); + } +} diff --git a/lib/extension.js b/lib/extension.js index 87a421329..3d7895c1b 100644 --- a/lib/extension.js +++ b/lib/extension.js @@ -1,27 +1,6 @@ '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 -]; +const { tokenChars } = require('./validation'); /** * Adds an offer to the map of extension offers or a parameter to the map of @@ -47,9 +26,6 @@ function push(dest, name, elem) { */ function parse(header) { const offers = Object.create(null); - - if (header === undefined || header === '') return offers; - let params = Object.create(null); let mustUnescape = false; let isEscaping = false; @@ -57,16 +33,20 @@ function parse(header) { let extensionName; let paramName; let start = -1; + let code = -1; let end = -1; let i = 0; for (; i < header.length; i++) { - const code = header.charCodeAt(i); + code = header.charCodeAt(i); if (extensionName === undefined) { if (end === -1 && tokenChars[code] === 1) { if (start === -1) start = i; - } else if (code === 0x20 /* ' ' */ || code === 0x09 /* '\t' */) { + } 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) { @@ -167,7 +147,7 @@ function parse(header) { } } - if (start === -1 || inQuotes) { + if (start === -1 || inQuotes || code === 0x20 || code === 0x09) { throw new SyntaxError('Unexpected end of input'); } diff --git a/lib/permessage-deflate.js b/lib/permessage-deflate.js index ce9178429..77d918b55 100644 --- a/lib/permessage-deflate.js +++ b/lib/permessage-deflate.js @@ -4,8 +4,9 @@ const zlib = require('zlib'); const bufferUtil = require('./buffer-util'); const Limiter = require('./limiter'); -const { kStatusCode, NOOP } = require('./constants'); +const { kStatusCode } = require('./constants'); +const FastBuffer = Buffer[Symbol.species]; const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); const kPerMessageDeflate = Symbol('permessage-deflate'); const kTotalLength = Symbol('total-length'); @@ -30,22 +31,22 @@ class PerMessageDeflate { * Creates a PerMessageDeflate instance. * * @param {Object} [options] Configuration options - * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept - * disabling of server context takeover + * @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|Number)} [options.clientMaxWindowBits] Advertise support - * for, or request, a custom client 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 {Number} [options.threshold=1024] Size (in bytes) below which - * messages should not be compressed - * @param {Number} [options.concurrencyLimit=10] The number of concurrent - * calls to zlib * @param {Boolean} [isServer=false] Create the instance in either server or * client mode * @param {Number} [maxPayload=0] The maximum allowed message length @@ -313,7 +314,7 @@ class PerMessageDeflate { /** * Compress data. Concurrency limited. * - * @param {Buffer} data Data to compress + * @param {(Buffer|String)} data Data to compress * @param {Boolean} fin Specifies whether or not this is the last fragment * @param {Function} callback Callback * @public @@ -395,7 +396,7 @@ class PerMessageDeflate { /** * Compress data. * - * @param {Buffer} data Data to compress + * @param {(Buffer|String)} data Data to compress * @param {Boolean} fin Specifies whether or not this is the last fragment * @param {Function} callback Callback * @private @@ -418,13 +419,6 @@ class PerMessageDeflate { this._deflate[kTotalLength] = 0; this._deflate[kBuffers] = []; - // - // An `'error'` event is emitted, only on Node.js < 10.0.0, if the - // `zlib.DeflateRaw` instance is closed while data is being processed. - // This can happen if `PerMessageDeflate#cleanup()` is called at the wrong - // time due to an abnormal WebSocket closure. - // - this._deflate.on('error', NOOP); this._deflate.on('data', deflateOnData); } @@ -444,7 +438,9 @@ class PerMessageDeflate { this._deflate[kTotalLength] ); - if (fin) data = data.slice(0, data.length - 4); + if (fin) { + data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4); + } // // Ensure that the callback will not be called again in diff --git a/lib/receiver.js b/lib/receiver.js index 1d2af76e1..54d9b4fad 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -12,12 +12,15 @@ const { const { concat, toArrayBuffer, unmask } = require('./buffer-util'); const { isValidStatusCode, isValidUTF8 } = require('./validation'); +const FastBuffer = Buffer[Symbol.species]; + 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; +const DEFER_EVENT = 6; /** * HyBi Receiver implementation. @@ -28,20 +31,32 @@ class Receiver extends Writable { /** * Creates a Receiver instance. * - * @param {String} [binaryType=nodebuffer] The type for binary data - * @param {Object} [extensions] An object containing the negotiated extensions - * @param {Boolean} [isServer=false] Specifies whether to operate in client or - * server mode - * @param {Number} [maxPayload=0] The maximum allowed message length + * @param {Object} [options] Options object + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @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(binaryType, extensions, isServer, maxPayload) { + constructor(options = {}) { super(); - this._binaryType = binaryType || BINARY_TYPES[0]; + this._allowSynchronousEvents = + options.allowSynchronousEvents !== undefined + ? options.allowSynchronousEvents + : true; + 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._extensions = extensions || {}; - this._isServer = !!isServer; - this._maxPayload = maxPayload | 0; this._bufferedBytes = 0; this._buffers = []; @@ -58,8 +73,9 @@ class Receiver extends Writable { this._messageLength = 0; this._fragments = []; - this._state = GET_INFO; + this._errored = false; this._loop = false; + this._state = GET_INFO; } /** @@ -92,8 +108,13 @@ class Receiver extends Writable { if (n < this._buffers[0].length) { const buf = this._buffers[0]; - this._buffers[0] = buf.slice(n); - return buf.slice(0, n); + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + + return new FastBuffer(buf.buffer, buf.byteOffset, n); } const dst = Buffer.allocUnsafe(n); @@ -106,7 +127,11 @@ class Receiver extends Writable { dst.set(this._buffers.shift(), offset); } else { dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); - this._buffers[0] = buf.slice(n); + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); } n -= buf.length; @@ -122,43 +147,42 @@ class Receiver extends Writable { * @private */ startLoop(cb) { - let err; this._loop = true; do { switch (this._state) { case GET_INFO: - err = this.getInfo(); + this.getInfo(cb); break; case GET_PAYLOAD_LENGTH_16: - err = this.getPayloadLength16(); + this.getPayloadLength16(cb); break; case GET_PAYLOAD_LENGTH_64: - err = this.getPayloadLength64(); + this.getPayloadLength64(cb); break; case GET_MASK: this.getMask(); break; case GET_DATA: - err = this.getData(cb); + this.getData(cb); break; - default: - // `INFLATING` + case INFLATING: + case DEFER_EVENT: this._loop = false; return; } } while (this._loop); - cb(err); + if (!this._errored) cb(); } /** * Reads the first two bytes of a frame. * - * @return {(RangeError|undefined)} A possible error + * @param {Function} cb Callback * @private */ - getInfo() { + getInfo(cb) { if (this._bufferedBytes < 2) { this._loop = false; return; @@ -167,27 +191,31 @@ class Receiver extends Writable { const buf = this.consume(2); if ((buf[0] & 0x30) !== 0x00) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'RSV2 and RSV3 must be clear', true, 1002, 'WS_ERR_UNEXPECTED_RSV_2_3' ); + + cb(error); + return; } const compressed = (buf[0] & 0x40) === 0x40; if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'RSV1 must be clear', true, 1002, 'WS_ERR_UNEXPECTED_RSV_1' ); + + cb(error); + return; } this._fin = (buf[0] & 0x80) === 0x80; @@ -196,83 +224,100 @@ class Receiver extends Writable { if (this._opcode === 0x00) { if (compressed) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'RSV1 must be clear', true, 1002, 'WS_ERR_UNEXPECTED_RSV_1' ); + + cb(error); + return; } if (!this._fragmented) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'invalid opcode 0', true, 1002, 'WS_ERR_INVALID_OPCODE' ); + + cb(error); + return; } this._opcode = this._fragmented; } else if (this._opcode === 0x01 || this._opcode === 0x02) { if (this._fragmented) { - this._loop = false; - return error( + const error = this.createError( RangeError, `invalid opcode ${this._opcode}`, true, 1002, 'WS_ERR_INVALID_OPCODE' ); + + cb(error); + return; } this._compressed = compressed; } else if (this._opcode > 0x07 && this._opcode < 0x0b) { if (!this._fin) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'FIN must be set', true, 1002, 'WS_ERR_EXPECTED_FIN' ); + + cb(error); + return; } if (compressed) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'RSV1 must be clear', true, 1002, 'WS_ERR_UNEXPECTED_RSV_1' ); + + cb(error); + return; } - if (this._payloadLength > 0x7d) { - this._loop = false; - return error( + if ( + this._payloadLength > 0x7d || + (this._opcode === 0x08 && this._payloadLength === 1) + ) { + const error = this.createError( RangeError, `invalid payload length ${this._payloadLength}`, true, 1002, 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' ); + + cb(error); + return; } } else { - this._loop = false; - return error( + const error = this.createError( RangeError, `invalid opcode ${this._opcode}`, true, 1002, 'WS_ERR_INVALID_OPCODE' ); + + cb(error); + return; } if (!this._fin && !this._fragmented) this._fragmented = this._opcode; @@ -280,54 +325,58 @@ class Receiver extends Writable { if (this._isServer) { if (!this._masked) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'MASK must be set', true, 1002, 'WS_ERR_EXPECTED_MASK' ); + + cb(error); + return; } } else if (this._masked) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'MASK must be clear', true, 1002, 'WS_ERR_UNEXPECTED_MASK' ); + + cb(error); + return; } 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(); + else this.haveLength(cb); } /** * Gets extended payload length (7+16). * - * @return {(RangeError|undefined)} A possible error + * @param {Function} cb Callback * @private */ - getPayloadLength16() { + getPayloadLength16(cb) { if (this._bufferedBytes < 2) { this._loop = false; return; } this._payloadLength = this.consume(2).readUInt16BE(0); - return this.haveLength(); + this.haveLength(cb); } /** * Gets extended payload length (7+64). * - * @return {(RangeError|undefined)} A possible error + * @param {Function} cb Callback * @private */ - getPayloadLength64() { + getPayloadLength64(cb) { if (this._bufferedBytes < 8) { this._loop = false; return; @@ -341,38 +390,42 @@ class Receiver extends Writable { // if payload length is greater than this number. // if (num > Math.pow(2, 53 - 32) - 1) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'Unsupported WebSocket frame: payload length > 2^53 - 1', false, 1009, 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH' ); + + cb(error); + return; } this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); - return this.haveLength(); + this.haveLength(cb); } /** * Payload length has been read. * - * @return {(RangeError|undefined)} A possible error + * @param {Function} cb Callback * @private */ - haveLength() { + haveLength(cb) { if (this._payloadLength && this._opcode < 0x08) { this._totalPayloadLength += this._payloadLength; if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'Max payload size exceeded', false, 1009, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' ); + + cb(error); + return; } } @@ -399,7 +452,6 @@ class Receiver extends Writable { * Reads data bytes. * * @param {Function} cb Callback - * @return {(Error|RangeError|undefined)} A possible error * @private */ getData(cb) { @@ -412,10 +464,19 @@ class Receiver extends Writable { } data = this.consume(this._payloadLength); - if (this._masked) unmask(data, this._mask); + + 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._opcode > 0x07) { + this.controlMessage(data, cb); + return; + } if (this._compressed) { this._state = INFLATING; @@ -425,14 +486,14 @@ class Receiver extends Writable { if (data.length) { // - // This message is not compressed so its lenght is the sum of the payload + // 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(); + this.dataMessage(cb); } /** @@ -451,74 +512,98 @@ class Receiver extends Writable { 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' - ) + const error = this.createError( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' ); + + cb(error); + return; } this._fragments.push(buf); } - const er = this.dataMessage(); - if (er) return cb(er); - - this.startLoop(cb); + this.dataMessage(cb); + if (this._state === GET_INFO) this.startLoop(cb); }); } /** * Handles a data message. * - * @return {(Error|undefined)} A possible error + * @param {Function} cb Callback * @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; - } + dataMessage(cb) { + if (!this._fin) { + this._state = GET_INFO; + return; + } + + 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; - this.emit('message', data); + if (this._binaryType === 'nodebuffer') { + data = concat(fragments, messageLength); + } else if (this._binaryType === 'arraybuffer') { + data = toArrayBuffer(concat(fragments, messageLength)); + } else if (this._binaryType === 'blob') { + data = new Blob(fragments); } else { - const buf = concat(fragments, messageLength); + data = fragments; + } - if (!isValidUTF8(buf)) { - this._loop = false; - return error( - Error, - 'invalid UTF-8 sequence', - true, - 1007, - 'WS_ERR_INVALID_UTF8' - ); - } + if (this._allowSynchronousEvents) { + this.emit('message', data, true); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit('message', data, true); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } else { + const buf = concat(fragments, messageLength); - this.emit('message', buf.toString()); + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + + cb(error); + return; } - } - this._state = GET_INFO; + if (this._state === INFLATING || this._allowSynchronousEvents) { + this.emit('message', buf, false); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit('message', buf, false); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } } /** @@ -528,80 +613,94 @@ class Receiver extends Writable { * @return {(Error|RangeError|undefined)} A possible error * @private */ - controlMessage(data) { + controlMessage(data, cb) { if (this._opcode === 0x08) { - this._loop = false; - if (data.length === 0) { - this.emit('conclude', 1005, ''); + this._loop = false; + 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( + const error = this.createError( RangeError, `invalid status code ${code}`, true, 1002, 'WS_ERR_INVALID_CLOSE_CODE' ); + + cb(error); + return; } - const buf = data.slice(2); + const buf = new FastBuffer( + data.buffer, + data.byteOffset + 2, + data.length - 2 + ); - if (!isValidUTF8(buf)) { - return error( + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( Error, 'invalid UTF-8 sequence', true, 1007, 'WS_ERR_INVALID_UTF8' ); + + cb(error); + return; } - this.emit('conclude', code, buf.toString()); + this._loop = false; + this.emit('conclude', code, buf); this.end(); } - } else if (this._opcode === 0x09) { - this.emit('ping', data); + + this._state = GET_INFO; + return; + } + + if (this._allowSynchronousEvents) { + this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); + this._state = GET_INFO; } else { - this.emit('pong', data); + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); + this._state = GET_INFO; + this.startLoop(cb); + }); } + } - this._state = GET_INFO; + /** + * 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 + */ + createError(ErrorCtor, message, prefix, statusCode, errorCode) { + this._loop = false; + this._errored = true; + + const err = new ErrorCtor( + prefix ? `Invalid WebSocket frame: ${message}` : message + ); + + Error.captureStackTrace(err, this.createError); + err.code = errorCode; + err[kStatusCode] = statusCode; + return err; } } 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/lib/sender.js b/lib/sender.js index 441171c57..ee16cea5a 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -1,17 +1,24 @@ -/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls$" }] */ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */ 'use strict'; -const net = require('net'); -const tls = require('tls'); +const { Duplex } = require('stream'); const { randomFillSync } = require('crypto'); const PerMessageDeflate = require('./permessage-deflate'); -const { EMPTY_BUFFER } = require('./constants'); -const { isValidStatusCode } = require('./validation'); +const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants'); +const { isBlob, isValidStatusCode } = require('./validation'); const { mask: applyMask, toBuffer } = require('./buffer-util'); -const mask = Buffer.alloc(4); +const kByteLength = Symbol('kByteLength'); +const maskBuffer = Buffer.alloc(4); +const RANDOM_POOL_SIZE = 8 * 1024; +let randomPool; +let randomPoolPointer = RANDOM_POOL_SIZE; + +const DEFAULT = 0; +const DEFLATING = 1; +const GET_BLOB_DATA = 2; /** * HyBi Sender implementation. @@ -20,52 +27,116 @@ class Sender { /** * Creates a Sender instance. * - * @param {(net.Socket|tls.Socket)} socket The connection socket + * @param {Duplex} 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) { + 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 = []; + this._state = DEFAULT; + this.onerror = NOOP; + this[kWebSocket] = undefined; } /** * Frames a piece of data according to the HyBi WebSocket protocol. * - * @param {Buffer} data The data to frame + * @param {(Buffer|String)} data The data to frame * @param {Object} options Options object - * @param {Number} options.opcode The opcode - * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be - * modified * @param {Boolean} [options.fin=false] Specifies whether or not to set the * FIN bit + * @param {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[]} The framed data as a list of `Buffer` instances + * @return {(Buffer|String)[]} The framed data * @public */ static frame(data, options) { - const merge = options.mask && options.readOnly; - let offset = options.mask ? 6 : 2; - let payloadLength = data.length; + 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 { + if (randomPoolPointer === RANDOM_POOL_SIZE) { + /* istanbul ignore else */ + if (randomPool === undefined) { + // + // This is lazily initialized because server-sent frames must not + // be masked so it may never be used. + // + randomPool = Buffer.alloc(RANDOM_POOL_SIZE); + } + + randomFillSync(randomPool, 0, RANDOM_POOL_SIZE); + randomPoolPointer = 0; + } - if (data.length >= 65536) { + mask[0] = randomPool[randomPoolPointer++]; + mask[1] = randomPool[randomPoolPointer++]; + mask[2] = randomPool[randomPoolPointer++]; + mask[3] = randomPool[randomPoolPointer++]; + } + + 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 (data.length > 125) { + } else if (dataLength > 125) { offset += 2; payloadLength = 126; } - const target = Buffer.allocUnsafe(merge ? data.length + offset : offset); + const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset); target[0] = options.fin ? options.opcode | 0x80 : options.opcode; if (options.rsv1) target[0] |= 0x40; @@ -73,28 +144,28 @@ class Sender { target[1] = payloadLength; if (payloadLength === 126) { - target.writeUInt16BE(data.length, 2); + target.writeUInt16BE(dataLength, 2); } else if (payloadLength === 127) { - target.writeUInt32BE(0, 2); - target.writeUInt32BE(data.length, 6); + target[2] = target[3] = 0; + target.writeUIntBE(dataLength, 4, 6); } if (!options.mask) return [target, data]; - randomFillSync(mask, 0, 4); - 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, data.length); + applyMask(data, mask, target, offset, dataLength); return [target]; } - applyMask(data, mask, data, 0, data.length); + applyMask(data, mask, data, 0, dataLength); return [target, data]; } @@ -102,7 +173,7 @@ class Sender { * Sends a close message to the other peer. * * @param {Number} [code] The status code component of the body - * @param {String} [data] The message 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 @@ -114,7 +185,7 @@ class Sender { 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 === '') { + } else if (data === undefined || !data.length) { buf = Buffer.allocUnsafe(2); buf.writeUInt16BE(code, 0); } else { @@ -126,37 +197,32 @@ class Sender { buf = Buffer.allocUnsafe(2 + length); buf.writeUInt16BE(code, 0); - buf.write(data, 2); + + if (typeof data === 'string') { + buf.write(data, 2); + } else { + buf.set(data, 2); + } } - if (this._deflating) { - this.enqueue([this.doClose, buf, mask, cb]); + const options = { + [kByteLength]: buf.length, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x08, + readOnly: false, + rsv1: false + }; + + if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, buf, false, options, cb]); } else { - this.doClose(buf, mask, cb); + this.sendFrame(Sender.frame(buf, options), cb); } } - /** - * Frames and sends a close message. - * - * @param {Buffer} data The message to send - * @param {Boolean} [mask=false] Specifies whether or not to mask `data` - * @param {Function} [cb] Callback - * @private - */ - doClose(data, mask, cb) { - this.sendFrame( - Sender.frame(data, { - fin: true, - rsv1: false, - opcode: 0x08, - mask, - readOnly: false - }), - cb - ); - } - /** * Sends a ping message to the other peer. * @@ -166,41 +232,49 @@ class Sender { * @public */ ping(data, mask, cb) { - const buf = toBuffer(data); + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } - if (buf.length > 125) { + if (byteLength > 125) { throw new RangeError('The data size must not be greater than 125 bytes'); } - if (this._deflating) { - this.enqueue([this.doPing, buf, mask, toBuffer.readOnly, cb]); + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x09, + readOnly, + rsv1: false + }; + + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options, cb]); + } else { + this.getBlobData(data, false, options, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options, cb]); } else { - this.doPing(buf, mask, toBuffer.readOnly, cb); + this.sendFrame(Sender.frame(data, options), cb); } } - /** - * Frames and sends a ping message. - * - * @param {Buffer} data The message to send - * @param {Boolean} [mask=false] Specifies whether or not to mask `data` - * @param {Boolean} [readOnly=false] Specifies whether `data` can be modified - * @param {Function} [cb] Callback - * @private - */ - doPing(data, mask, readOnly, cb) { - this.sendFrame( - Sender.frame(data, { - fin: true, - rsv1: false, - opcode: 0x09, - mask, - readOnly - }), - cb - ); - } - /** * Sends a pong message to the other peer. * @@ -210,50 +284,58 @@ class Sender { * @public */ pong(data, mask, cb) { - const buf = toBuffer(data); + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } - if (buf.length > 125) { + if (byteLength > 125) { throw new RangeError('The data size must not be greater than 125 bytes'); } - if (this._deflating) { - this.enqueue([this.doPong, buf, mask, toBuffer.readOnly, cb]); + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x0a, + readOnly, + rsv1: false + }; + + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options, cb]); + } else { + this.getBlobData(data, false, options, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options, cb]); } else { - this.doPong(buf, mask, toBuffer.readOnly, cb); + this.sendFrame(Sender.frame(data, options), cb); } } - /** - * Frames and sends a pong message. - * - * @param {Buffer} data The message to send - * @param {Boolean} [mask=false] Specifies whether or not to mask `data` - * @param {Boolean} [readOnly=false] Specifies whether `data` can be modified - * @param {Function} [cb] Callback - * @private - */ - doPong(data, mask, readOnly, cb) { - this.sendFrame( - Sender.frame(data, { - fin: true, - rsv1: false, - opcode: 0x0a, - mask, - readOnly - }), - cb - ); - } - /** * Sends a data message to the other peer. * * @param {*} data The message to send * @param {Object} options Options object - * @param {Boolean} [options.compress=false] Specifies whether or not to - * compress `data` * @param {Boolean} [options.binary=false] Specifies whether `data` is binary * or text + * @param {Boolean} [options.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 @@ -262,15 +344,37 @@ class Sender { * @public */ send(data, options, cb) { - const buf = toBuffer(data); 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 if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (this._firstFragment) { this._firstFragment = false; - if (rsv1 && perMessageDeflate) { - rsv1 = buf.length >= perMessageDeflate._threshold; + if ( + rsv1 && + perMessageDeflate && + perMessageDeflate.params[ + perMessageDeflate._isServer + ? 'server_no_context_takeover' + : 'client_no_context_takeover' + ] + ) { + rsv1 = byteLength >= perMessageDeflate._threshold; } this._compress = rsv1; } else { @@ -280,48 +384,112 @@ class Sender { if (options.fin) this._firstFragment = true; - if (perMessageDeflate) { - const opts = { - fin: options.fin, - rsv1, - opcode, - mask: options.mask, - readOnly: toBuffer.readOnly - }; - - if (this._deflating) { - this.enqueue([this.dispatch, buf, this._compress, opts, cb]); + const opts = { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1 + }; + + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, this._compress, opts, cb]); } else { - this.dispatch(buf, this._compress, opts, cb); + this.getBlobData(data, this._compress, opts, cb); } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, this._compress, opts, cb]); } else { - this.sendFrame( - Sender.frame(buf, { - fin: options.fin, - rsv1: false, - opcode, - mask: options.mask, - readOnly: toBuffer.readOnly - }), - cb - ); + this.dispatch(data, this._compress, opts, cb); } } /** - * Dispatches a data message. + * Gets the contents of a blob as binary data. * - * @param {Buffer} data The message to send + * @param {Blob} blob The blob * @param {Boolean} [compress=false] Specifies whether or not to compress - * `data` + * the 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 + */ + getBlobData(blob, compress, options, cb) { + this._bufferedBytes += options[kByteLength]; + this._state = GET_BLOB_DATA; + + blob + .arrayBuffer() + .then((arrayBuffer) => { + if (this._socket.destroyed) { + const err = new Error( + 'The socket was closed while the blob was being read' + ); + + // + // `callCallbacks` is called in the next tick to ensure that errors + // that might be thrown in the callbacks behave like errors thrown + // outside the promise chain. + // + process.nextTick(callCallbacks, this, err, cb); + return; + } + + this._bufferedBytes -= options[kByteLength]; + const data = toBuffer(arrayBuffer); + + if (!compress) { + this._state = DEFAULT; + this.sendFrame(Sender.frame(data, options), cb); + this.dequeue(); + } else { + this.dispatch(data, compress, options, cb); + } + }) + .catch((err) => { + // + // `onError` is called in the next tick for the same reason that + // `callCallbacks` above is. + // + process.nextTick(onError, this, err, 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 @@ -335,27 +503,20 @@ class Sender { const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; - this._bufferedBytes += data.length; - this._deflating = true; + this._bufferedBytes += options[kByteLength]; + this._state = DEFLATING; perMessageDeflate.compress(data, options.fin, (_, buf) => { if (this._socket.destroyed) { const err = new Error( 'The socket was closed while data was being compressed' ); - if (typeof cb === 'function') cb(err); - - for (let i = 0; i < this._queue.length; i++) { - const callback = this._queue[i][4]; - - if (typeof callback === 'function') callback(err); - } - + callCallbacks(this, err, cb); return; } - this._bufferedBytes -= data.length; - this._deflating = false; + this._bufferedBytes -= options[kByteLength]; + this._state = DEFAULT; options.readOnly = false; this.sendFrame(Sender.frame(buf, options), cb); this.dequeue(); @@ -368,10 +529,10 @@ class Sender { * @private */ dequeue() { - while (!this._deflating && this._queue.length) { + while (this._state === DEFAULT && this._queue.length) { const params = this._queue.shift(); - this._bufferedBytes -= params[1].length; + this._bufferedBytes -= params[3][kByteLength]; Reflect.apply(params[0], this, params.slice(1)); } } @@ -383,7 +544,7 @@ class Sender { * @private */ enqueue(params) { - this._bufferedBytes += params[1].length; + this._bufferedBytes += params[3][kByteLength]; this._queue.push(params); } @@ -407,3 +568,35 @@ class Sender { } module.exports = Sender; + +/** + * Calls queued callbacks with an error. + * + * @param {Sender} sender The `Sender` instance + * @param {Error} err The error to call the callbacks with + * @param {Function} [cb] The first callback + * @private + */ +function callCallbacks(sender, err, cb) { + if (typeof cb === 'function') cb(err); + + for (let i = 0; i < sender._queue.length; i++) { + const params = sender._queue[i]; + const callback = params[params.length - 1]; + + if (typeof callback === 'function') callback(err); + } +} + +/** + * Handles a `Sender` error. + * + * @param {Sender} sender The `Sender` instance + * @param {Error} err The error + * @param {Function} [cb] The first pending callback + * @private + */ +function onError(sender, err, cb) { + callCallbacks(sender, err, cb); + sender.onerror(err); +} diff --git a/lib/stream.js b/lib/stream.js index b0896ff83..230734b79 100644 --- a/lib/stream.js +++ b/lib/stream.js @@ -47,23 +47,8 @@ function duplexOnError(err) { * @public */ function createWebSocketStream(ws, options) { - let resumeOnReceiverDrain = true; let terminateOnDestroy = true; - function receiverOnDrain() { - if (resumeOnReceiverDrain) ws._socket.resume(); - } - - if (ws.readyState === ws.CONNECTING) { - ws.once('open', function open() { - ws._receiver.removeAllListeners('drain'); - ws._receiver.on('drain', receiverOnDrain); - }); - } else { - ws._receiver.removeAllListeners('drain'); - ws._receiver.on('drain', receiverOnDrain); - } - const duplex = new Duplex({ ...options, autoDestroy: false, @@ -72,11 +57,11 @@ function createWebSocketStream(ws, options) { writableObjectMode: false }); - ws.on('message', function message(msg) { - if (!duplex.push(msg)) { - resumeOnReceiverDrain = false; - ws._socket.pause(); - } + 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) { @@ -152,10 +137,7 @@ function createWebSocketStream(ws, options) { }; duplex._read = function () { - if (ws.readyState === ws.OPEN && !resumeOnReceiverDrain) { - resumeOnReceiverDrain = true; - if (!ws._receiver._writableState.needDrain) ws._socket.resume(); - } + if (ws.isPaused) ws.resume(); }; duplex._write = function (chunk, encoding, callback) { diff --git a/lib/subprotocol.js b/lib/subprotocol.js new file mode 100644 index 000000000..d4381e886 --- /dev/null +++ b/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/lib/validation.js b/lib/validation.js index 169ac6f06..4a2e68d51 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -1,5 +1,32 @@ 'use strict'; +const { isUtf8 } = require('buffer'); + +const { hasBlob } = require('./constants'); + +// +// 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. * @@ -82,23 +109,44 @@ function _isValidUTF8(buf) { return true; } -try { - let isValidUTF8 = require('utf-8-validate'); +/** + * Determines whether a value is a `Blob`. + * + * @param {*} value The value to be tested + * @return {Boolean} `true` if `value` is a `Blob`, else `false` + * @private + */ +function isBlob(value) { + return ( + hasBlob && + typeof value === 'object' && + typeof value.arrayBuffer === 'function' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + (value[Symbol.toStringTag] === 'Blob' || + value[Symbol.toStringTag] === 'File') + ); +} - /* istanbul ignore if */ - if (typeof isValidUTF8 === 'object') { - isValidUTF8 = isValidUTF8.Validation.isValidUTF8; // utf-8-validate@<3.0.0 - } +module.exports = { + isBlob, + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars +}; - module.exports = { - isValidStatusCode, - isValidUTF8(buf) { - return buf.length < 150 ? _isValidUTF8(buf) : isValidUTF8(buf); - } - }; -} catch (e) /* istanbul ignore next */ { - module.exports = { - isValidStatusCode, - isValidUTF8: _isValidUTF8 +if (isUtf8) { + module.exports.isValidUTF8 = function (buf) { + return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf); }; +} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) { + try { + const isValidUTF8 = require('utf-8-validate'); + + module.exports.isValidUTF8 = function (buf) { + return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf); + }; + } catch (e) { + // Continue regardless of the error. + } } diff --git a/lib/websocket-server.js b/lib/websocket-server.js index fe7fdf501..67b52ffdd 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -1,17 +1,16 @@ -/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls|https$" }] */ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */ 'use strict'; const EventEmitter = require('events'); const http = require('http'); -const https = require('https'); -const net = require('net'); -const tls = require('tls'); +const { Duplex } = require('stream'); const { createHash } = require('crypto'); +const extension = require('./extension'); const PerMessageDeflate = require('./permessage-deflate'); +const subprotocol = require('./subprotocol'); const WebSocket = require('./websocket'); -const { format, parse } = require('./extension'); const { GUID, kWebSocket } = require('./constants'); const keyRegex = /^[+/0-9A-Za-z]{22}==$/; @@ -30,6 +29,11 @@ class WebSocketServer extends EventEmitter { * Create a `WebSocketServer` instance. * * @param {Object} options Configuration options + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Boolean} [options.autoPong=true] Specifies whether or not to + * automatically send a pong in response to a ping * @param {Number} [options.backlog=511] The maximum length of the queue of * pending connections * @param {Boolean} [options.clientTracking=true] Specifies whether or not to @@ -45,14 +49,21 @@ class WebSocketServer extends EventEmitter { * @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 = { + allowSynchronousEvents: true, + autoPong: true, maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, perMessageDeflate: false, handleProtocols: null, clientTracking: true, @@ -63,6 +74,7 @@ class WebSocketServer extends EventEmitter { host: null, path: null, port: null, + WebSocket, ...options }; @@ -110,7 +122,11 @@ class WebSocketServer extends EventEmitter { } if (options.perMessageDeflate === true) options.perMessageDeflate = {}; - if (options.clientTracking) this.clients = new Set(); + if (options.clientTracking) { + this.clients = new Set(); + this._shouldEmitClose = false; + } + this.options = options; this._state = RUNNING; } @@ -134,45 +150,58 @@ class WebSocketServer extends EventEmitter { } /** - * Close the server. + * Stop the server from accepting new connections and emit the `'close'` event + * when all existing connections are closed. * - * @param {Function} [cb] Callback + * @param {Function} [cb] A one-time listener for the `'close'` event * @public */ close(cb) { - if (cb) this.once('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; - // - // Terminate all associated clients. - // - if (this.clients) { - for (const client of this.clients) client.terminate(); - } + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } - const server = this._server; + 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; - if (server) { this._removeListeners(); this._removeListeners = this._server = null; // - // Close the http server if it was internally created. + // The HTTP/S server was created internally. Close it, and rely on its + // `'close'` event. // - if (this.options.port != null) { - server.close(emitClose.bind(undefined, this)); - return; - } + server.close(() => { + emitClose(this); + }); } - - process.nextTick(emitClose, this); } /** @@ -197,8 +226,7 @@ class WebSocketServer extends EventEmitter { * 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 {Duplex} socket The network socket between the server and client * @param {Buffer} head The first packet of the upgraded stream * @param {Function} cb Callback * @public @@ -206,25 +234,59 @@ class WebSocketServer extends EventEmitter { handleUpgrade(req, socket, head, cb) { socket.on('error', socketOnError); - const key = - req.headers['sec-websocket-key'] !== undefined - ? req.headers['sec-websocket-key'].trim() - : false; + const key = req.headers['sec-websocket-key']; + const upgrade = req.headers.upgrade; const version = +req.headers['sec-websocket-version']; + + if (req.method !== 'GET') { + const message = 'Invalid HTTP method'; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); + return; + } + + if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') { + const message = 'Invalid Upgrade header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (key === undefined || !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 ( - req.method !== 'GET' || - req.headers.upgrade.toLowerCase() !== 'websocket' || - !key || - !keyRegex.test(key) || - (version !== 8 && version !== 13) || - !this.shouldHandle(req) + this.options.perMessageDeflate && + secWebSocketExtensions !== undefined ) { - return abortHandshake(socket, 400); - } - - if (this.options.perMessageDeflate) { const perMessageDeflate = new PerMessageDeflate( this.options.perMessageDeflate, true, @@ -232,14 +294,17 @@ class WebSocketServer extends EventEmitter { ); try { - const offers = parse(req.headers['sec-websocket-extensions']); + const offers = extension.parse(secWebSocketExtensions); if (offers[PerMessageDeflate.extensionName]) { perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); extensions[PerMessageDeflate.extensionName] = perMessageDeflate; } } catch (err) { - return abortHandshake(socket, 400); + const message = + 'Invalid or unacceptable Sec-WebSocket-Extensions header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; } } @@ -260,7 +325,15 @@ class WebSocketServer extends EventEmitter { return abortHandshake(socket, code || 401, message, headers); } - this.completeUpgrade(key, extensions, req, socket, head, cb); + this.completeUpgrade( + extensions, + key, + protocols, + req, + socket, + head, + cb + ); }); return; } @@ -268,23 +341,23 @@ class WebSocketServer extends EventEmitter { if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); } - this.completeUpgrade(key, extensions, req, socket, head, cb); + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); } /** * Upgrade the connection to WebSocket. * - * @param {String} key The value of the `Sec-WebSocket-Key` header * @param {Object} extensions The accepted extensions + * @param {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 {Duplex} 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(key, extensions, req, socket, head, cb) { + completeUpgrade(extensions, key, protocols, req, socket, head, cb) { // // Destroy the socket if the client has already sent a FIN packet. // @@ -310,20 +383,15 @@ class WebSocketServer extends EventEmitter { `Sec-WebSocket-Accept: ${digest}` ]; - const ws = new WebSocket(null); - let protocol = req.headers['sec-websocket-protocol']; - - if (protocol) { - protocol = protocol.split(',').map(trim); + const ws = new this.options.WebSocket(null, undefined, this.options); + if (protocols.size) { // // Optionally call external protocol selection handler. // - if (this.options.handleProtocols) { - protocol = this.options.handleProtocols(protocol, req); - } else { - protocol = protocol[0]; - } + const protocol = this.options.handleProtocols + ? this.options.handleProtocols(protocols, req) + : protocols.values().next().value; if (protocol) { headers.push(`Sec-WebSocket-Protocol: ${protocol}`); @@ -333,7 +401,7 @@ class WebSocketServer extends EventEmitter { if (extensions[PerMessageDeflate.extensionName]) { const params = extensions[PerMessageDeflate.extensionName].params; - const value = format({ + const value = extension.format({ [PerMessageDeflate.extensionName]: [params] }); headers.push(`Sec-WebSocket-Extensions: ${value}`); @@ -348,11 +416,21 @@ class WebSocketServer extends EventEmitter { socket.write(headers.concat('\r\n').join('\r\n')); socket.removeListener('error', socketOnError); - ws.setSocket(socket, head, this.options.maxPayload); + ws.setSocket(socket, head, { + allowSynchronousEvents: this.options.allowSynchronousEvents, + maxPayload: this.options.maxPayload, + skipUTF8Validation: this.options.skipUTF8Validation + }); if (this.clients) { this.clients.add(ws); - ws.on('close', () => this.clients.delete(ws)); + ws.on('close', () => { + this.clients.delete(ws); + + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(emitClose, this); + } + }); } cb(ws, req); @@ -393,7 +471,7 @@ function emitClose(server) { } /** - * Handle premature socket errors. + * Handle socket errors. * * @private */ @@ -404,44 +482,59 @@ function socketOnError() { /** * Close the connection when preconditions are not fulfilled. * - * @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request + * @param {Duplex} 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) { - if (socket.writable) { - message = message || http.STATUS_CODES[code]; - headers = { - Connection: 'close', - 'Content-Type': 'text/html', - 'Content-Length': Buffer.byteLength(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.write( - `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 - ); - } + socket.once('finish', socket.destroy); - socket.removeListener('error', socketOnError); - 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 + ); } /** - * Remove whitespace characters from both ends of a string. + * Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least + * one listener for it, otherwise call `abortHandshake()`. * - * @param {String} str The string - * @return {String} A new string representing `str` stripped of whitespace - * characters from both its beginning and end + * @param {WebSocketServer} server The WebSocket server + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The socket of the upgrade request + * @param {Number} code The HTTP response status code + * @param {String} message The HTTP response body * @private */ -function trim(str) { - return str.trim(); +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/lib/websocket.js b/lib/websocket.js index 7f1e3bcfa..7fb402970 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -1,3 +1,5 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$", "caughtErrors": "none" }] */ + 'use strict'; const EventEmitter = require('events'); @@ -6,26 +8,35 @@ const http = require('http'); const net = require('net'); const tls = require('tls'); const { randomBytes, createHash } = require('crypto'); +const { Duplex, Readable } = require('stream'); const { URL } = require('url'); const PerMessageDeflate = require('./permessage-deflate'); const Receiver = require('./receiver'); const Sender = require('./sender'); +const { isBlob } = require('./validation'); + const { BINARY_TYPES, EMPTY_BUFFER, GUID, + kForOnEventAttribute, + kListener, kStatusCode, kWebSocket, NOOP } = require('./constants'); -const { addEventListener, removeEventListener } = require('./event-target'); +const { + EventTarget: { addEventListener, removeEventListener } +} = require('./event-target'); const { format, parse } = require('./extension'); const { toBuffer } = require('./buffer-util'); -const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; -const protocolVersions = [8, 13]; 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. @@ -47,9 +58,11 @@ class WebSocket extends EventEmitter { this._closeCode = 1006; this._closeFrameReceived = false; this._closeFrameSent = false; - this._closeMessage = ''; + this._closeMessage = EMPTY_BUFFER; this._closeTimer = null; + this._errorEmitted = false; this._extensions = {}; + this._paused = false; this._protocol = ''; this._readyState = WebSocket.CONNECTING; this._receiver = null; @@ -61,23 +74,27 @@ class WebSocket extends EventEmitter { this._isServer = false; this._redirects = 0; - if (Array.isArray(protocols)) { - protocols = protocols.join(', '); - } else if (typeof protocols === 'object' && protocols !== null) { - options = protocols; - protocols = undefined; + 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._autoPong = options.autoPong; 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). + * For historical reasons, the custom "nodebuffer" type is used by the default + * instead of "blob". * * @type {String} */ @@ -112,50 +129,45 @@ class WebSocket extends EventEmitter { return Object.keys(this._extensions).join(); } + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + /** * @type {Function} */ /* istanbul ignore next */ get onclose() { - return undefined; + return null; } - /* istanbul ignore next */ - set onclose(listener) {} - /** * @type {Function} */ /* istanbul ignore next */ get onerror() { - return undefined; + return null; } - /* istanbul ignore next */ - set onerror(listener) {} - /** * @type {Function} */ /* istanbul ignore next */ get onopen() { - return undefined; + return null; } - /* istanbul ignore next */ - set onopen(listener) {} - /** * @type {Function} */ /* istanbul ignore next */ get onmessage() { - return undefined; + return null; } - /* istanbul ignore next */ - set onmessage(listener) {} - /** * @type {String} */ @@ -180,25 +192,37 @@ class WebSocket extends EventEmitter { /** * Set up the socket and the internal resources. * - * @param {(net.Socket|tls.Socket)} socket The network socket between the - * server and client + * @param {Duplex} socket The network socket between the server and client * @param {Buffer} head The first packet of the upgraded stream - * @param {Number} [maxPayload=0] The maximum allowed message size + * @param {Object} options Options object + * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @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, maxPayload) { - const receiver = new Receiver( - this.binaryType, - this._extensions, - this._isServer, - maxPayload - ); + setSocket(socket, head, options) { + const receiver = new Receiver({ + allowSynchronousEvents: options.allowSynchronousEvents, + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation + }); + + const sender = new Sender(socket, this._extensions, options.generateMask); - this._sender = new Sender(socket, this._extensions); this._receiver = receiver; + this._sender = sender; this._socket = socket; receiver[kWebSocket] = this; + sender[kWebSocket] = this; socket[kWebSocket] = this; receiver.on('conclude', receiverOnConclude); @@ -208,8 +232,13 @@ class WebSocket extends EventEmitter { receiver.on('ping', receiverOnPing); receiver.on('pong', receiverOnPong); - socket.setTimeout(0); - socket.setNoDelay(); + sender.onerror = senderOnError; + + // + // These methods may not be available if `socket` is just a `Duplex`. + // + if (socket.setTimeout) socket.setTimeout(0); + if (socket.setNoDelay) socket.setNoDelay(); if (head.length > 0) socket.unshift(head); @@ -259,14 +288,16 @@ class WebSocket extends EventEmitter { * +---+ * * @param {Number} [code] Status code explaining why the connection is closing - * @param {String} [data] A string 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); + abortHandshake(this, this._req, msg); + return; } if (this.readyState === WebSocket.CLOSING) { @@ -298,13 +329,24 @@ class WebSocket extends EventEmitter { } }); - // - // Specify a timeout for the closing handshake to complete. - // - this._closeTimer = setTimeout( - this._socket.destroy.bind(this._socket), - closeTimeout - ); + setCloseTimer(this); + } + + /** + * Pause the socket. + * + * @public + */ + pause() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = true; + this._socket.pause(); } /** @@ -371,15 +413,32 @@ class WebSocket extends EventEmitter { 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.compress] Specifies whether or not to compress - * `data` * @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` @@ -427,7 +486,8 @@ class WebSocket extends EventEmitter { 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); + abortHandshake(this, this._req, msg); + return; } if (this._socket) { @@ -513,6 +573,7 @@ Object.defineProperty(WebSocket.prototype, 'CLOSED', { 'binaryType', 'bufferedAmount', 'extensions', + 'isPaused', 'protocol', 'readyState', 'url' @@ -528,22 +589,25 @@ Object.defineProperty(WebSocket.prototype, 'CLOSED', { Object.defineProperty(WebSocket.prototype, `on${method}`, { enumerable: true, get() { - const listeners = this.listeners(method); - for (let i = 0; i < listeners.length; i++) { - if (listeners[i]._listener) return listeners[i]._listener; + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; } - return undefined; + return null; }, - set(listener) { - const listeners = this.listeners(method); - for (let i = 0; i < listeners.length; i++) { - // - // Remove only the listeners added via `addEventListener`. - // - if (listeners[i]._listener) this.removeListener(method, listeners[i]); + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } } - this.addEventListener(method, listener); + + if (typeof handler !== 'function') return; + + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); } }); }); @@ -558,43 +622,58 @@ module.exports = WebSocket; * * @param {WebSocket} websocket The client to initialize * @param {(String|URL)} address The URL to which to connect - * @param {String} [protocols] The subprotocols + * @param {Array} protocols The subprotocols * @param {Object} [options] Connection options - * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable - * permessage-deflate + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether any + * of the `'message'`, `'ping'`, and `'pong'` events can be emitted multiple + * times in the same tick + * @param {Boolean} [options.autoPong=true] Specifies whether or not to + * automatically send a pong in response to a ping + * @param {Function} [options.finishRequest] A function which can be used to + * customize the headers of each http request before it is sent + * @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.protocolVersion=13] Value of the - * `Sec-WebSocket-Version` header - * @param {String} [options.origin] Value of the `Origin` or - * `Sec-WebSocket-Origin` header * @param {Number} [options.maxPayload=104857600] The maximum allowed message * size - * @param {Boolean} [options.followRedirects=false] Whether or not to follow - * redirects * @param {Number} [options.maxRedirects=10] The maximum number of redirects * allowed + * @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 = { + allowSynchronousEvents: true, + autoPong: true, 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: undefined, + method: 'GET', host: undefined, path: undefined, port: undefined }; + websocket._autoPong = opts.autoPong; + if (!protocolVersions.includes(opts.protocolVersion)) { throw new RangeError( `Unsupported protocol version: ${opts.protocolVersion} ` + @@ -606,37 +685,66 @@ function initAsClient(websocket, address, protocols, options) { if (address instanceof URL) { parsedUrl = address; - websocket._url = address.href; } else { - parsedUrl = new URL(address); - websocket._url = address; + try { + parsedUrl = new URL(address); + } catch (e) { + throw new SyntaxError(`Invalid URL: ${address}`); + } } - const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; + if (parsedUrl.protocol === 'http:') { + parsedUrl.protocol = 'ws:'; + } else if (parsedUrl.protocol === 'https:') { + parsedUrl.protocol = 'wss:'; + } - if (!parsedUrl.host && (!isUnixSocket || !parsedUrl.pathname)) { - throw new Error(`Invalid URL: ${websocket.url}`); + websocket._url = parsedUrl.href; + + const isSecure = parsedUrl.protocol === 'wss:'; + const isIpcUrl = parsedUrl.protocol === 'ws+unix:'; + let invalidUrlMessage; + + if (parsedUrl.protocol !== 'ws:' && !isSecure && !isIpcUrl) { + invalidUrlMessage = + 'The URL\'s protocol must be one of "ws:", "wss:", ' + + '"http:", "https", or "ws+unix:"'; + } else if (isIpcUrl && !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 isSecure = - parsedUrl.protocol === 'wss:' || parsedUrl.protocol === 'https:'; const defaultPort = isSecure ? 443 : 80; const key = randomBytes(16).toString('base64'); - const get = isSecure ? https.get : http.get; + const request = isSecure ? https.request : http.request; + const protocolSet = new Set(); let perMessageDeflate; - opts.createConnection = isSecure ? tlsConnect : netConnect; + opts.createConnection = + 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.headers + Upgrade: 'websocket' }; opts.path = parsedUrl.pathname + parsedUrl.search; opts.timeout = opts.handshakeTimeout; @@ -651,8 +759,22 @@ function initAsClient(websocket, address, protocols, options) { [PerMessageDeflate.extensionName]: perMessageDeflate.offer() }); } - if (protocols) { - opts.headers['Sec-WebSocket-Protocol'] = protocols; + 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) { @@ -665,14 +787,86 @@ function initAsClient(websocket, address, protocols, options) { opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; } - if (isUnixSocket) { + if (isIpcUrl) { const parts = opts.path.split(':'); opts.socketPath = parts[0]; opts.path = parts[1]; } - let req = (websocket._req = get(opts)); + let req; + + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalIpc = isIpcUrl; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isIpcUrl + ? 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 = isIpcUrl + ? websocket._originalIpc + ? opts.socketPath === websocket._originalHostOrSocketPath + : false + : websocket._originalIpc + ? 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', () => { @@ -681,12 +875,10 @@ function initAsClient(websocket, address, protocols, options) { } req.on('error', (err) => { - if (req === null || req.aborted) return; + if (req === null || req[kAborted]) return; req = websocket._req = null; - websocket._readyState = WebSocket.CLOSING; - websocket.emit('error', err); - websocket.emitClose(); + emitErrorAndClose(websocket, err); }); req.on('response', (res) => { @@ -706,7 +898,15 @@ function initAsClient(websocket, address, protocols, options) { req.abort(); - const addr = new URL(location, address); + 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)) { @@ -722,13 +922,20 @@ function initAsClient(websocket, address, protocols, options) { websocket.emit('upgrade', res); // - // The user may have closed the connection from a listener of the `upgrade` - // event. + // The user may have closed the connection from a listener of the + // `'upgrade'` event. // if (websocket.readyState !== WebSocket.CONNECTING) return; req = websocket._req = null; + const upgrade = res.headers.upgrade; + + if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') { + abortHandshake(websocket, socket, 'Invalid Upgrade header'); + return; + } + const digest = createHash('sha1') .update(key + GUID) .digest('base64'); @@ -739,15 +946,16 @@ function initAsClient(websocket, address, protocols, options) { } const serverProt = res.headers['sec-websocket-protocol']; - const protList = (protocols || '').split(/, */); let protError; - if (!protocols && serverProt) { - protError = 'Server sent a subprotocol but none was requested'; - } else if (protocols && !serverProt) { + 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'; - } else if (serverProt && !protList.includes(serverProt)) { - protError = 'Server sent an invalid subprotocol'; } if (protError) { @@ -780,32 +988,58 @@ function initAsClient(websocket, address, protocols, options) { const extensionNames = Object.keys(extensions); - if (extensionNames.length) { - 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; - } + if ( + extensionNames.length !== 1 || + extensionNames[0] !== PerMessageDeflate.extensionName + ) { + const message = 'Server indicated an extension that was not requested'; + abortHandshake(websocket, socket, message); + return; + } - websocket._extensions[PerMessageDeflate.extensionName] = - perMessageDeflate; + 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, opts.maxPayload); + websocket.setSocket(socket, head, { + allowSynchronousEvents: opts.allowSynchronousEvents, + generateMask: opts.generateMask, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); }); + + if (opts.finishRequest) { + opts.finishRequest(req, websocket); + } else { + 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; + // + // The following assignment is practically useless and is done only for + // consistency. + // + websocket._errorEmitted = true; + websocket.emit('error', err); + websocket.emitClose(); } /** @@ -853,6 +1087,7 @@ function abortHandshake(websocket, stream, message) { Error.captureStackTrace(err, abortHandshake); if (stream.setHeader) { + stream[kAborted] = true; stream.abort(); if (stream.socket && !stream.socket.destroyed) { @@ -864,8 +1099,7 @@ function abortHandshake(websocket, stream, message) { stream.socket.destroy(); } - stream.once('abort', websocket.emitClose.bind(websocket)); - websocket.emit('error', err); + process.nextTick(emitErrorAndClose, websocket, err); } else { stream.destroy(err); stream.once('error', websocket.emit.bind(websocket, 'error')); @@ -884,7 +1118,7 @@ function abortHandshake(websocket, stream, message) { */ function sendAfterClose(websocket, data, cb) { if (data) { - const length = toBuffer(data).length; + const length = isBlob(data) ? data.size : toBuffer(data).length; // // The `_bufferedAmount` property is used only when the peer is a client and @@ -901,7 +1135,7 @@ function sendAfterClose(websocket, data, cb) { `WebSocket is not open: readyState ${websocket.readyState} ` + `(${readyStates[websocket.readyState]})` ); - cb(err); + process.nextTick(cb, err); } } @@ -909,19 +1143,21 @@ function sendAfterClose(websocket, data, cb) { * The listener of the `Receiver` `'conclude'` event. * * @param {Number} code The status code - * @param {String} reason The reason for closing + * @param {Buffer} reason The reason for closing * @private */ function receiverOnConclude(code, reason) { const websocket = this[kWebSocket]; - websocket._socket.removeListener('data', socketOnData); - websocket._socket.resume(); - 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); } @@ -932,7 +1168,9 @@ function receiverOnConclude(code, reason) { * @private */ function receiverOnDrain() { - this[kWebSocket]._socket.resume(); + const websocket = this[kWebSocket]; + + if (!websocket.isPaused) websocket._socket.resume(); } /** @@ -944,11 +1182,22 @@ function receiverOnDrain() { function receiverOnError(err) { const websocket = this[kWebSocket]; - websocket._socket.removeListener('data', socketOnData); - websocket._socket.resume(); + if (websocket._socket[kWebSocket] !== undefined) { + websocket._socket.removeListener('data', socketOnData); - websocket.close(err[kStatusCode]); - websocket.emit('error', err); + // + // 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]); + } + + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } } /** @@ -963,11 +1212,12 @@ function receiverOnFinish() { /** * The listener of the `Receiver` `'message'` event. * - * @param {(String|Buffer|ArrayBuffer|Buffer[])} data The message + * @param {Buffer|ArrayBuffer|Buffer[])} data The message + * @param {Boolean} isBinary Specifies whether the message is binary or not * @private */ -function receiverOnMessage(data) { - this[kWebSocket].emit('message', data); +function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit('message', data, isBinary); } /** @@ -979,7 +1229,7 @@ function receiverOnMessage(data) { function receiverOnPing(data) { const websocket = this[kWebSocket]; - websocket.pong(data, !websocket._isServer, NOOP); + if (websocket._autoPong) websocket.pong(data, !this._isServer, NOOP); websocket.emit('ping', data); } @@ -994,7 +1244,58 @@ function receiverOnPong(data) { } /** - * The listener of the `net.Socket` `'close'` event. + * Resume a readable stream + * + * @param {Readable} stream The readable stream + * @private + */ +function resume(stream) { + stream.resume(); +} + +/** + * The `Sender` error event handler. + * + * @param {Error} The error + * @private + */ +function senderOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket.readyState === WebSocket.CLOSED) return; + if (websocket.readyState === WebSocket.OPEN) { + websocket._readyState = WebSocket.CLOSING; + setCloseTimer(websocket); + } + + // + // `socket.end()` is used instead of `socket.destroy()` to allow the other + // peer to finish sending queued data. There is no need to set a timer here + // because `CLOSING` means that it is already set or not needed. + // + this._socket.end(); + + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } +} + +/** + * Set a timer to destroy the underlying raw socket of a WebSocket. + * + * @param {WebSocket} websocket The WebSocket instance + * @private + */ +function setCloseTimer(websocket) { + websocket._closeTimer = setTimeout( + websocket._socket.destroy.bind(websocket._socket), + closeTimeout + ); +} + +/** + * The listener of the socket `'close'` event. * * @private */ @@ -1002,10 +1303,13 @@ 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 @@ -1013,13 +1317,19 @@ function socketOnClose() { // 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 and emitted synchronously in a single - // `'data'` event. + // data will be read as a single chunk. // - websocket._socket.read(); + if ( + !this._readableState.endEmitted && + !websocket._closeFrameReceived && + !websocket._receiver._writableState.errorEmitted && + (chunk = websocket._socket.read()) !== null + ) { + websocket._receiver.write(chunk); + } + websocket._receiver.end(); - this.removeListener('data', socketOnData); this[kWebSocket] = undefined; clearTimeout(websocket._closeTimer); @@ -1036,7 +1346,7 @@ function socketOnClose() { } /** - * The listener of the `net.Socket` `'data'` event. + * The listener of the socket `'data'` event. * * @param {Buffer} chunk A chunk of data * @private @@ -1048,7 +1358,7 @@ function socketOnData(chunk) { } /** - * The listener of the `net.Socket` `'end'` event. + * The listener of the socket `'end'` event. * * @private */ @@ -1061,7 +1371,7 @@ function socketOnEnd() { } /** - * The listener of the `net.Socket` `'error'` event. + * The listener of the socket `'error'` event. * * @private */ diff --git a/package.json b/package.json index d6dff1396..4f7155deb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "7.5.3", + "version": "8.18.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", @@ -12,27 +12,39 @@ ], "homepage": "https://github.com/websockets/ws", "bugs": "https://github.com/websockets/ws/issues", - "repository": "websockets/ws", + "repository": { + "type": "git", + "url": "git+https://github.com/websockets/ws.git" + }, "author": "Einar Otto Stangvik (http://2x.io)", "license": "MIT", "main": "index.js", + "exports": { + ".": { + "browser": "./browser.js", + "import": "./wrapper.mjs", + "require": "./index.js" + }, + "./package.json": "./package.json" + }, "browser": "browser.js", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "files": [ "browser.js", "index.js", - "lib/*.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}\"" + "lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\"" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -45,12 +57,13 @@ "devDependencies": { "benchmark": "^2.1.4", "bufferutil": "^4.0.1", - "eslint": "^7.2.0", - "eslint-config-prettier": "^8.1.0", - "eslint-plugin-prettier": "^3.0.1", - "mocha": "^7.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "globals": "^15.0.0", + "mocha": "^8.4.0", "nyc": "^15.0.0", - "prettier": "^2.0.5", - "utf-8-validate": "^5.0.2" + "prettier": "^3.0.0", + "utf-8-validate": "^6.0.0" } } diff --git a/test/autobahn-server.js b/test/autobahn-server.js index 6e0be43ff..24ade1149 100644 --- a/test/autobahn-server.js +++ b/test/autobahn-server.js @@ -10,6 +10,8 @@ const wss = new WebSocket.Server({ port }, () => { }); wss.on('connection', (ws) => { - ws.on('message', (data) => ws.send(data)); + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); ws.on('error', (e) => console.error(e)); }); diff --git a/test/autobahn.js b/test/autobahn.js index cdda513a5..51532fc52 100644 --- a/test/autobahn.js +++ b/test/autobahn.js @@ -18,7 +18,9 @@ function nextTest() { ws = new WebSocket( `ws://localhost:9001/runCase?case=${currentTest}&agent=ws` ); - ws.on('message', (data) => ws.send(data)); + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); ws.on('close', () => { currentTest++; process.nextTick(nextTest); diff --git a/test/create-websocket-stream.test.js b/test/create-websocket-stream.test.js index b96ac9b1b..54a13c6c8 100644 --- a/test/create-websocket-stream.test.js +++ b/test/create-websocket-stream.test.js @@ -3,12 +3,17 @@ const assert = require('assert'); const EventEmitter = require('events'); const { createServer } = require('http'); -const { Duplex } = require('stream'); +const { Duplex, getDefaultHighWaterMark } = 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'); + +const highWaterMark = getDefaultHighWaterMark + ? getDefaultHighWaterMark(false) + : 16 * 1024; describe('createWebSocketStream', () => { it('is exposed as a property of the `WebSocket` class', () => { @@ -58,11 +63,12 @@ describe('createWebSocketStream', () => { }); wss.on('connection', (ws) => { - ws.on('message', (message) => { + ws.on('message', (message, isBinary) => { ws.on('close', (code, reason) => { - assert.ok(message.equals(chunk)); + assert.deepStrictEqual(message, chunk); + assert.ok(isBinary); assert.strictEqual(code, 1005); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); wss.close(done); }); }); @@ -229,7 +235,7 @@ describe('createWebSocketStream', () => { ws._socket.write(Buffer.from([0x85, 0x00])); ws.on('close', (code, reason) => { assert.strictEqual(code, 1002); - assert.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (duplexCloseEventEmitted) wss.close(done); @@ -293,11 +299,14 @@ describe('createWebSocketStream', () => { ws._socket.write(Buffer.from([0x85, 0x00])); }); - assert.strictEqual(process.listenerCount('uncaughtException'), 1); + assert.strictEqual( + process.listenerCount('uncaughtException'), + EventEmitter.usingDomains ? 2 : 1 + ); - const [listener] = process.listeners('uncaughtException'); + const listener = process.listeners('uncaughtException').pop(); - process.removeAllListeners('uncaughtException'); + process.removeListener('uncaughtException', listener); process.once('uncaughtException', (err) => { assert.ok(err instanceof Error); assert.strictEqual( @@ -440,12 +449,15 @@ describe('createWebSocketStream', () => { }; const list = [ - ...Sender.frame(randomBytes(16 * 1024), { rsv1: false, ...opts }), + ...Sender.frame(randomBytes(highWaterMark), { + 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. + // `highWaterMark` bytes will be sent as a single TCP packet. ws._socket.push(Buffer.concat(list)); }); @@ -489,7 +501,10 @@ describe('createWebSocketStream', () => { }; const list = [ - ...Sender.frame(randomBytes(16 * 1024), { rsv1: false, ...opts }), + ...Sender.frame(randomBytes(highWaterMark), { + rsv1: false, + ...opts + }), ...Sender.frame(Buffer.alloc(1), { rsv1: true, ...opts }) ]; @@ -538,5 +553,59 @@ describe('createWebSocketStream', () => { }); }); }); + + 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(highWaterMark)); + }); + }); }); }); diff --git a/test/duplex-pair.js b/test/duplex-pair.js new file mode 100644 index 000000000..92d5e778e --- /dev/null +++ b/test/duplex-pair.js @@ -0,0 +1,73 @@ +// +// This code was copied from +// https://github.com/nodejs/node/blob/c506660f3267/test/common/duplexpair.js +// +// Copyright Node.js contributors. All rights reserved. +// +// 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. +// +'use strict'; + +const assert = require('assert'); +const { Duplex } = require('stream'); + +const kCallback = Symbol('Callback'); +const kOtherSide = Symbol('Other'); + +class DuplexSocket extends Duplex { + constructor() { + super(); + this[kCallback] = null; + this[kOtherSide] = null; + } + + _read() { + const callback = this[kCallback]; + if (callback) { + this[kCallback] = null; + callback(); + } + } + + _write(chunk, encoding, callback) { + assert.notStrictEqual(this[kOtherSide], null); + assert.strictEqual(this[kOtherSide][kCallback], null); + if (chunk.length === 0) { + process.nextTick(callback); + } else { + this[kOtherSide].push(chunk); + this[kOtherSide][kCallback] = callback; + } + } + + _final(callback) { + this[kOtherSide].on('end', callback); + this[kOtherSide].push(null); + } +} + +function makeDuplexPair() { + const clientSide = new DuplexSocket(); + const serverSide = new DuplexSocket(); + clientSide[kOtherSide] = serverSide; + serverSide[kOtherSide] = clientSide; + return { clientSide, serverSide }; +} + +module.exports = makeDuplexPair; diff --git a/test/event-target.test.js b/test/event-target.test.js new file mode 100644 index 000000000..5caaa5c27 --- /dev/null +++ b/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/test/extension.test.js b/test/extension.test.js index 6cfbc1b23..a4b3e749d 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -6,11 +6,6 @@ const { format, parse } = require('../lib/extension'); describe('extension', () => { describe('parse', () => { - it('returns an empty object if the argument is `undefined`', () => { - assert.deepStrictEqual(parse(), { __proto__: null }); - assert.deepStrictEqual(parse(''), { __proto__: null }); - }); - it('parses a single extension', () => { assert.deepStrictEqual(parse('foo'), { foo: [{ __proto__: null }], @@ -73,7 +68,7 @@ describe('extension', () => { }); it('ignores the optional white spaces', () => { - const header = 'foo; bar\t; \tbaz=1\t ; bar="1"\t\t, \tqux\t ;norf '; + 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 }], @@ -105,10 +100,12 @@ describe('extension', () => { 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= ', 8], + ['foo;bar=ba z', 11] ].forEach((element) => { assert.throws( () => parse(element[0]), @@ -147,13 +144,18 @@ describe('extension', () => { 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="1\\', + 'foo;bar="baz" ' ].forEach((header) => { assert.throws( () => parse(header), diff --git a/test/permessage-deflate.test.js b/test/permessage-deflate.test.js index a547762ca..a9c9bf165 100644 --- a/test/permessage-deflate.test.js +++ b/test/permessage-deflate.test.js @@ -344,7 +344,7 @@ describe('PerMessageDeflate', () => { describe('#compress and #decompress', () => { it('works with unfragmented messages', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); const buf = Buffer.from([1, 2, 3]); perMessageDeflate.accept([{}]); @@ -361,7 +361,7 @@ describe('PerMessageDeflate', () => { }); it('works with fragmented messages', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); const buf = Buffer.from([1, 2, 3, 4]); perMessageDeflate.accept([{}]); @@ -388,7 +388,6 @@ describe('PerMessageDeflate', () => { it('works with the negotiated parameters', (done) => { const perMessageDeflate = new PerMessageDeflate({ - threshold: 0, memLevel: 5, level: 9 }); @@ -415,11 +414,9 @@ describe('PerMessageDeflate', () => { it('honors the `level` option', (done) => { const lev0 = new PerMessageDeflate({ - threshold: 0, zlibDeflateOptions: { level: 0 } }); const lev9 = new PerMessageDeflate({ - threshold: 0, zlibDeflateOptions: { level: 9 } }); const extensionStr = @@ -459,7 +456,6 @@ describe('PerMessageDeflate', () => { it('honors the `zlib{Deflate,Inflate}Options` option', (done) => { const lev0 = new PerMessageDeflate({ - threshold: 0, zlibDeflateOptions: { level: 0, chunkSize: 256 @@ -469,7 +465,6 @@ describe('PerMessageDeflate', () => { } }); const lev9 = new PerMessageDeflate({ - threshold: 0, zlibDeflateOptions: { level: 9, chunkSize: 128 @@ -523,7 +518,7 @@ describe('PerMessageDeflate', () => { }); it("doesn't use contex takeover if not allowed", (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }, true); + const perMessageDeflate = new PerMessageDeflate({}, true); const extensions = extension.parse( 'permessage-deflate;server_no_context_takeover' ); @@ -554,7 +549,7 @@ describe('PerMessageDeflate', () => { }); it('uses contex takeover if allowed', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }, true); + const perMessageDeflate = new PerMessageDeflate({}, true); const extensions = extension.parse('permessage-deflate'); const buf = Buffer.from('foofoo'); @@ -583,7 +578,7 @@ describe('PerMessageDeflate', () => { }); it('calls the callback when an error occurs (inflate)', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); const data = Buffer.from('something invalid'); perMessageDeflate.accept([{}]); @@ -596,11 +591,7 @@ describe('PerMessageDeflate', () => { }); it("doesn't call the callback twice when `maxPayload` is exceeded", (done) => { - const perMessageDeflate = new PerMessageDeflate( - { threshold: 0 }, - false, - 25 - ); + const perMessageDeflate = new PerMessageDeflate({}, false, 25); const buf = Buffer.from('A'.repeat(50)); perMessageDeflate.accept([{}]); @@ -616,7 +607,7 @@ describe('PerMessageDeflate', () => { }); it('calls the callback if the deflate stream is closed prematurely', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); const buf = Buffer.from('A'.repeat(50)); perMessageDeflate.accept([{}]); diff --git a/test/receiver.test.js b/test/receiver.test.js index cd5770dfb..243a91606 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -2,20 +2,20 @@ const assert = require('assert'); const crypto = require('crypto'); +const EventEmitter = require('events'); const PerMessageDeflate = require('../lib/permessage-deflate'); -const constants = require('../lib/constants'); const Receiver = require('../lib/receiver'); const Sender = require('../lib/sender'); - -const kStatusCode = constants.kStatusCode; +const { EMPTY_BUFFER, hasBlob, kStatusCode } = require('../lib/constants'); describe('Receiver', () => { it('parses an unmasked text message', (done) => { const receiver = new Receiver(); - receiver.on('message', (data) => { - assert.strictEqual(data, 'Hello'); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); done(); }); @@ -27,7 +27,7 @@ describe('Receiver', () => { receiver.on('conclude', (code, data) => { assert.strictEqual(code, 1005); - assert.strictEqual(data, ''); + assert.strictEqual(data, EMPTY_BUFFER); done(); }); @@ -39,7 +39,7 @@ describe('Receiver', () => { receiver.on('conclude', (code, data) => { assert.strictEqual(code, 1000); - assert.strictEqual(data, 'DONE'); + assert.deepStrictEqual(data, Buffer.from('DONE')); done(); }); @@ -48,10 +48,11 @@ describe('Receiver', () => { }); it('parses a masked text message', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); - receiver.on('message', (data) => { - assert.strictEqual(data, '5:::{"name":"echo"}'); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from('5:::{"name":"echo"}')); + assert.ok(!isBinary); done(); }); @@ -61,21 +62,22 @@ describe('Receiver', () => { }); it('parses a masked text message longer than 125 B', (done) => { - const receiver = new Receiver(undefined, {}, true); - const msg = 'A'.repeat(200); + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(200)); - const list = Sender.frame(Buffer.from(msg), { + const list = Sender.frame(msg, { fin: true, rsv1: false, opcode: 0x01, mask: true, - readOnly: false + readOnly: true }); const frame = Buffer.concat(list); - receiver.on('message', (data) => { - assert.strictEqual(data, msg); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); done(); }); @@ -84,21 +86,22 @@ describe('Receiver', () => { }); it('parses a really long masked text message', (done) => { - const receiver = new Receiver(undefined, {}, true); - const msg = 'A'.repeat(64 * 1024); + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(64 * 1024)); - const list = Sender.frame(Buffer.from(msg), { + const list = Sender.frame(msg, { fin: true, rsv1: false, opcode: 0x01, mask: true, - readOnly: false + readOnly: true }); const frame = Buffer.concat(list); - receiver.on('message', (data) => { - assert.strictEqual(data, msg); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); done(); }); @@ -106,31 +109,32 @@ describe('Receiver', () => { }); it('parses a 300 B fragmented masked text message', (done) => { - const receiver = new Receiver(undefined, {}, true); - const msg = 'A'.repeat(300); + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(300)); - const fragment1 = msg.substr(0, 150); - const fragment2 = msg.substr(150); + const fragment1 = msg.slice(0, 150); + const fragment2 = msg.slice(150); - const options = { rsv1: false, mask: true, readOnly: false }; + const options = { rsv1: false, mask: true, readOnly: true }; const frame1 = Buffer.concat( - Sender.frame(Buffer.from(fragment1), { + Sender.frame(fragment1, { fin: false, opcode: 0x01, ...options }) ); const frame2 = Buffer.concat( - Sender.frame(Buffer.from(fragment2), { + Sender.frame(fragment2, { fin: true, opcode: 0x00, ...options }) ); - receiver.on('message', (data) => { - assert.strictEqual(data, msg); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); done(); }); @@ -139,21 +143,21 @@ describe('Receiver', () => { }); it('parses a ping message', (done) => { - const receiver = new Receiver(undefined, {}, true); - const msg = 'Hello'; + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('Hello'); - const list = Sender.frame(Buffer.from(msg), { + const list = Sender.frame(msg, { fin: true, rsv1: false, opcode: 0x09, mask: true, - readOnly: false + readOnly: true }); const frame = Buffer.concat(list); receiver.on('ping', (data) => { - assert.strictEqual(data.toString(), msg); + assert.deepStrictEqual(data, msg); done(); }); @@ -164,7 +168,7 @@ describe('Receiver', () => { const receiver = new Receiver(); receiver.on('ping', (data) => { - assert.ok(data.equals(Buffer.alloc(0))); + assert.strictEqual(data, EMPTY_BUFFER); done(); }); @@ -172,31 +176,31 @@ describe('Receiver', () => { }); it('parses a 300 B fragmented masked text message with a ping in the middle (1/2)', (done) => { - const receiver = new Receiver(undefined, {}, true); - const msg = 'A'.repeat(300); - const pingMessage = 'Hello'; + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(300)); + const pingMessage = Buffer.from('Hello'); - const fragment1 = msg.substr(0, 150); - const fragment2 = msg.substr(150); + const fragment1 = msg.slice(0, 150); + const fragment2 = msg.slice(150); - const options = { rsv1: false, mask: true, readOnly: false }; + const options = { rsv1: false, mask: true, readOnly: true }; const frame1 = Buffer.concat( - Sender.frame(Buffer.from(fragment1), { + Sender.frame(fragment1, { fin: false, opcode: 0x01, ...options }) ); const frame2 = Buffer.concat( - Sender.frame(Buffer.from(pingMessage), { + Sender.frame(pingMessage, { fin: true, opcode: 0x09, ...options }) ); const frame3 = Buffer.concat( - Sender.frame(Buffer.from(fragment2), { + Sender.frame(fragment2, { fin: true, opcode: 0x00, ...options @@ -205,14 +209,15 @@ describe('Receiver', () => { let gotPing = false; - receiver.on('message', (data) => { - assert.strictEqual(data, msg); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); assert.ok(gotPing); done(); }); receiver.on('ping', (data) => { gotPing = true; - assert.strictEqual(data.toString(), pingMessage); + assert.ok(data.equals(pingMessage)); }); receiver.write(frame1); @@ -221,12 +226,12 @@ describe('Receiver', () => { }); it('parses a 300 B fragmented masked text message with a ping in the middle (2/2)', (done) => { - const receiver = new Receiver(undefined, {}, true); - const msg = 'A'.repeat(300); - const pingMessage = 'Hello'; + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(300)); + const pingMessage = Buffer.from('Hello'); - const fragment1 = msg.substr(0, 150); - const fragment2 = msg.substr(150); + const fragment1 = msg.slice(0, 150); + const fragment2 = msg.slice(150); const options = { rsv1: false, mask: true, readOnly: false }; @@ -264,14 +269,15 @@ describe('Receiver', () => { let gotPing = false; - receiver.on('message', (data) => { - assert.strictEqual(data, msg); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); assert.ok(gotPing); done(); }); receiver.on('ping', (data) => { gotPing = true; - assert.strictEqual(data.toString(), pingMessage); + assert.ok(data.equals(pingMessage)); }); for (let i = 0; i < chunks.length; ++i) { @@ -280,7 +286,7 @@ describe('Receiver', () => { }); it('parses a 100 B masked binary message', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); const msg = crypto.randomBytes(100); const list = Sender.frame(msg, { @@ -293,8 +299,9 @@ describe('Receiver', () => { const frame = Buffer.concat(list); - receiver.on('message', (data) => { - assert.ok(data.equals(msg)); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); done(); }); @@ -302,7 +309,7 @@ describe('Receiver', () => { }); it('parses a 256 B masked binary message', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); const msg = crypto.randomBytes(256); const list = Sender.frame(msg, { @@ -315,8 +322,9 @@ describe('Receiver', () => { const frame = Buffer.concat(list); - receiver.on('message', (data) => { - assert.ok(data.equals(msg)); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); done(); }); @@ -324,7 +332,7 @@ describe('Receiver', () => { }); it('parses a 200 KiB masked binary message', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); const msg = crypto.randomBytes(200 * 1024); const list = Sender.frame(msg, { @@ -337,8 +345,9 @@ describe('Receiver', () => { const frame = Buffer.concat(list); - receiver.on('message', (data) => { - assert.ok(data.equals(msg)); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); done(); }); @@ -359,8 +368,9 @@ describe('Receiver', () => { const frame = Buffer.concat(list); - receiver.on('message', (data) => { - assert.ok(data.equals(msg)); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); done(); }); @@ -371,13 +381,16 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); const buf = Buffer.from('Hello'); - receiver.on('message', (data) => { - assert.strictEqual(data, 'Hello'); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, buf); + assert.ok(!isBinary); done(); }); @@ -393,14 +406,17 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); const buf1 = Buffer.from('foo'); const buf2 = Buffer.from('bar'); - receiver.on('message', (data) => { - assert.strictEqual(data, 'foobar'); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.concat([buf1, buf2])); + assert.ok(!isBinary); done(); }); @@ -430,8 +446,9 @@ describe('Receiver', () => { const receiver = new Receiver(); let counter = 0; - receiver.on('message', (data) => { - assert.strictEqual(data, ''); + receiver.on('message', (data, isBinary) => { + assert.strictEqual(data, EMPTY_BUFFER); + assert.ok(!isBinary); if (++counter === 20000) done(); }); @@ -439,11 +456,12 @@ describe('Receiver', () => { }); it('resets `totalPayloadLength` only on final frame (unfragmented)', (done) => { - const receiver = new Receiver(undefined, {}, false, 10); + const receiver = new Receiver({ maxPayload: 10 }); - receiver.on('message', (data) => { + receiver.on('message', (data, isBinary) => { assert.strictEqual(receiver._totalPayloadLength, 0); - assert.strictEqual(data, 'Hello'); + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); done(); }); @@ -452,11 +470,12 @@ describe('Receiver', () => { }); it('resets `totalPayloadLength` only on final frame (fragmented)', (done) => { - const receiver = new Receiver(undefined, {}, false, 10); + const receiver = new Receiver({ maxPayload: 10 }); - receiver.on('message', (data) => { + receiver.on('message', (data, isBinary) => { assert.strictEqual(receiver._totalPayloadLength, 0); - assert.strictEqual(data, 'Hello'); + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); done(); }); @@ -467,17 +486,18 @@ describe('Receiver', () => { }); it('resets `totalPayloadLength` only on final frame (fragmented + ping)', (done) => { - const receiver = new Receiver(undefined, {}, false, 10); + const receiver = new Receiver({ maxPayload: 10 }); let data; receiver.on('ping', (buf) => { assert.strictEqual(receiver._totalPayloadLength, 2); - data = buf.toString(); + data = buf; }); - receiver.on('message', (buf) => { + receiver.on('message', (buf, isBinary) => { assert.strictEqual(receiver._totalPayloadLength, 0); - assert.strictEqual(data, ''); - assert.strictEqual(buf.toString(), 'Hello'); + assert.deepStrictEqual(data, EMPTY_BUFFER); + assert.deepStrictEqual(buf, Buffer.from('Hello')); + assert.ok(isBinary); done(); }); @@ -491,15 +511,22 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + 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, ['', 1005, '']); + assert.deepStrictEqual(results, [ + EMPTY_BUFFER, + false, + 1005, + EMPTY_BUFFER + ]); done(); }); @@ -529,8 +556,10 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); receiver.on('error', (err) => { @@ -655,8 +684,10 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); receiver.on('error', (err) => { @@ -691,7 +722,7 @@ describe('Receiver', () => { }); it('emits an error if a frame has the MASK bit off (server mode)', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); receiver.on('error', (err) => { assert.ok(err instanceof RangeError); @@ -708,7 +739,7 @@ describe('Receiver', () => { }); it('emits an error if a frame has the MASK bit on (client mode)', (done) => { - const receiver = new Receiver(undefined, {}, false); + const receiver = new Receiver(); receiver.on('error', (err) => { assert.ok(err instanceof RangeError); @@ -786,8 +817,10 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); const buf = Buffer.from([0xce, 0xba, 0xe1, 0xbd]); @@ -824,7 +857,7 @@ describe('Receiver', () => { done(); }); - receiver.write(Buffer.from([0x88, 0x01, 0x00])); + receiver.write(Buffer.from([0x88, 0x01])); }); it('emits an error if a close frame contains an invalid close code', (done) => { @@ -864,7 +897,7 @@ describe('Receiver', () => { }); it('emits an error if a frame payload length is bigger than `maxPayload`', (done) => { - const receiver = new Receiver(undefined, {}, true, 20 * 1024); + const receiver = new Receiver({ isServer: true, maxPayload: 20 * 1024 }); const msg = crypto.randomBytes(200 * 1024); const list = Sender.frame(msg, { @@ -892,14 +925,11 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate({}, false, 25); perMessageDeflate.accept([{}]); - const receiver = new Receiver( - undefined, - { - 'permessage-deflate': perMessageDeflate - }, - false, - 25 - ); + const receiver = new Receiver({ + extensions: { 'permessage-deflate': perMessageDeflate }, + isServer: false, + maxPayload: 25 + }); const buf = Buffer.from('A'.repeat(50)); receiver.on('error', (err) => { @@ -922,14 +952,11 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate({}, false, 25); perMessageDeflate.accept([{}]); - const receiver = new Receiver( - undefined, - { - 'permessage-deflate': perMessageDeflate - }, - false, - 25 - ); + const receiver = new Receiver({ + extensions: { 'permessage-deflate': perMessageDeflate }, + isServer: false, + maxPayload: 25 + }); const buf = Buffer.from('A'.repeat(15)); receiver.on('error', (err) => { @@ -964,9 +991,9 @@ describe('Receiver', () => { crypto.randomBytes(3) ]; - receiver.on('message', (data) => { - assert.ok(Buffer.isBuffer(data)); - assert.ok(data.equals(Buffer.concat(frags))); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.concat(frags)); + assert.ok(isBinary); done(); }); @@ -982,17 +1009,17 @@ describe('Receiver', () => { }); it("honors the 'arraybuffer' binary type", (done) => { - const receiver = new Receiver(); + const receiver = new Receiver({ binaryType: 'arraybuffer' }); const frags = [ crypto.randomBytes(19221), crypto.randomBytes(954), crypto.randomBytes(623987) ]; - receiver._binaryType = 'arraybuffer'; - receiver.on('message', (data) => { + receiver.on('message', (data, isBinary) => { assert.ok(data instanceof ArrayBuffer); - assert.ok(Buffer.from(data).equals(Buffer.concat(frags))); + assert.deepStrictEqual(Buffer.from(data), Buffer.concat(frags)); + assert.ok(isBinary); done(); }); @@ -1008,7 +1035,7 @@ describe('Receiver', () => { }); it("honors the 'fragments' binary type", (done) => { - const receiver = new Receiver(); + const receiver = new Receiver({ binaryType: 'fragments' }); const frags = [ crypto.randomBytes(17), crypto.randomBytes(419872), @@ -1017,9 +1044,9 @@ describe('Receiver', () => { crypto.randomBytes(1) ]; - receiver._binaryType = 'fragments'; - receiver.on('message', (data) => { + receiver.on('message', (data, isBinary) => { assert.deepStrictEqual(data, frags); + assert.ok(isBinary); done(); }); @@ -1033,4 +1060,142 @@ describe('Receiver', () => { }).forEach((buf) => receiver.write(buf)); }); }); + + it("honors the 'blob' binary type", function (done) { + if (!hasBlob) return this.skip(); + + const receiver = new Receiver({ binaryType: 'blob' }); + const frags = [ + crypto.randomBytes(75688), + crypto.randomBytes(2688), + crypto.randomBytes(46753) + ]; + + receiver.on('message', (data, isBinary) => { + assert.ok(data instanceof Blob); + assert.ok(isBinary); + + data + .arrayBuffer() + .then((arrayBuffer) => { + assert.deepStrictEqual( + Buffer.from(arrayBuffer), + Buffer.concat(frags) + ); + + done(); + }) + .catch(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])); + }); + + it('honors the `allowSynchronousEvents` option', (done) => { + const actual = []; + const expected = [ + '1', + '- 1', + '-- 1', + '2', + '- 2', + '-- 2', + '3', + '- 3', + '-- 3', + '4', + '- 4', + '-- 4' + ]; + + function listener(data) { + const message = data.toString(); + actual.push(message); + + // `queueMicrotask()` is not available in Node.js < 11. + Promise.resolve().then(() => { + actual.push(`- ${message}`); + + Promise.resolve().then(() => { + actual.push(`-- ${message}`); + + if (actual.length === 12) { + assert.deepStrictEqual(actual, expected); + done(); + } + }); + }); + } + + const receiver = new Receiver({ allowSynchronousEvents: false }); + + receiver.on('message', listener); + receiver.on('ping', listener); + receiver.on('pong', listener); + + receiver.write(Buffer.from('8101318901328a0133820134', 'hex')); + }); + + it('does not swallow errors thrown from event handlers', (done) => { + const receiver = new Receiver(); + let count = 0; + + receiver.on('message', () => { + if (++count === 2) { + throw new Error('Oops'); + } + }); + + assert.strictEqual( + process.listenerCount('uncaughtException'), + EventEmitter.usingDomains ? 2 : 1 + ); + + const listener = process.listeners('uncaughtException').pop(); + + process.removeListener('uncaughtException', listener); + process.once('uncaughtException', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Oops'); + + process.on('uncaughtException', listener); + done(); + }); + + setImmediate(() => { + receiver.write(Buffer.from('82008200', 'hex')); + }); + }); }); diff --git a/test/sender.test.js b/test/sender.test.js index 58eca8fbf..df9057e8a 100644 --- a/test/sender.test.js +++ b/test/sender.test.js @@ -2,8 +2,10 @@ const assert = require('assert'); +const extension = require('../lib/extension'); const PerMessageDeflate = require('../lib/permessage-deflate'); const Sender = require('../lib/sender'); +const { EMPTY_BUFFER, hasBlob } = require('../lib/constants'); class MockSocket { constructor({ write } = {}) { @@ -34,8 +36,8 @@ describe('Sender', () => { assert.ok(buf.equals(Buffer.from([1, 2, 3, 4, 5]))); }); - it('sets RSV1 bit if compressed', () => { - const list = Sender.frame(Buffer.from('hi'), { + it('honors the `rsv1` option', () => { + const list = Sender.frame(EMPTY_BUFFER, { readOnly: false, mask: false, rsv1: true, @@ -45,16 +47,39 @@ describe('Sender', () => { 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 perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); - let count = 0; + const chunks = []; + const perMessageDeflate = new PerMessageDeflate(); const mockSocket = new MockSocket({ - write: (data) => { - assert.strictEqual(data[0] & 0x40, 0x40); - if (++count === 3) done(); + 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, { @@ -71,39 +96,173 @@ describe('Sender', () => { sender.send('hi', options); }); - it('does not compress data for small payloads', (done) => { - const perMessageDeflate = new PerMessageDeflate(); - const mockSocket = new MockSocket({ - write: (data) => { - assert.notStrictEqual(data[0] & 0x40, 0x40); - done(); - } + 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 }); }); - const sender = new Sender(mockSocket, { - 'permessage-deflate': perMessageDeflate + + 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 }); }); - perMessageDeflate.accept([{}]); + 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 }); + }); - sender.send('hi', { 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 }); + }); }); + }); - it('compresses all frames in a fragmented message', (done) => { - const chunks = []; - const perMessageDeflate = new PerMessageDeflate({ threshold: 3 }); + describe('#ping', () => { + it('can send a string as ping payload', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + let count = 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, 9); + write: (data) => { + if (++count < 3) return; - assert.strictEqual(chunks[2].length, 2); - assert.strictEqual(chunks[2][0] & 0x40, 0x00); - assert.strictEqual(chunks[3].length, 4); - done(); + if (count === 3) { + assert.deepStrictEqual(data, Buffer.from([0x89, 0x02])); + } else { + assert.strictEqual(data, 'hi'); + done(); + } } }); const sender = new Sender(mockSocket, { @@ -112,110 +271,85 @@ describe('Sender', () => { perMessageDeflate.accept([{}]); - sender.send('123', { compress: true, fin: false }); - sender.send('12', { compress: true, fin: true }); + sender.send('foo', { compress: true, fin: true }); + sender.ping('hi', false); }); - it('compresses no frames in a fragmented message', (done) => { - const chunks = []; - const perMessageDeflate = new PerMessageDeflate({ threshold: 3 }); + it('can send a `TypedArray` as ping payload', (done) => { + let count = 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, 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(); + write: (data) => { + if (++count === 1) { + assert.deepStrictEqual(data, Buffer.from([0x89, 0x02])); + } else { + assert.deepStrictEqual(data, Buffer.from([0x68, 0x69])); + done(); + } } }); - const sender = new Sender(mockSocket, { - 'permessage-deflate': perMessageDeflate - }); - perMessageDeflate.accept([{}]); + const sender = new Sender(mockSocket); + const array = new Uint8Array([0x68, 0x69]); - sender.send('12', { compress: true, fin: false }); - sender.send('123', { compress: true, fin: true }); + sender.ping(array, false); }); - it('compresses empty buffer as first fragment', (done) => { - const chunks = []; - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + it('can send an `ArrayBuffer` as ping payload', (done) => { + let count = 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(); + write: (data) => { + if (++count === 1) { + assert.deepStrictEqual(data, Buffer.from([0x89, 0x02])); + } else { + assert.deepStrictEqual(data, Buffer.from([0x68, 0x69])); + done(); + } } }); - const sender = new Sender(mockSocket, { - 'permessage-deflate': perMessageDeflate - }); - perMessageDeflate.accept([{}]); + const sender = new Sender(mockSocket); + const array = new Uint8Array([0x68, 0x69]); - sender.send(Buffer.alloc(0), { compress: true, fin: false }); - sender.send('data', { compress: true, fin: true }); + sender.ping(array.buffer, false); }); - 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); + it('can send a `Blob` as ping payload', function (done) { + if (!hasBlob) return this.skip(); - assert.strictEqual(chunks[2].length, 2); - assert.strictEqual(chunks[2][0] & 0x40, 0x00); - assert.strictEqual(chunks[3].length, 1); - done(); + let count = 0; + const mockSocket = new MockSocket({ + write: (data) => { + if (++count % 2) { + assert.deepStrictEqual(data, Buffer.from([0x89, 0x02])); + } else { + assert.deepStrictEqual(data, Buffer.from([0x68, 0x69])); + if (count === 4) done(); + } } }); - const sender = new Sender(mockSocket, { - 'permessage-deflate': perMessageDeflate - }); - perMessageDeflate.accept([{}]); + const sender = new Sender(mockSocket); + const blob = new Blob(['hi']); - sender.send('data', { compress: true, fin: false }); - sender.send(Buffer.alloc(0), { compress: true, fin: true }); + sender.ping(blob, false); + sender.ping(blob, false); }); }); - describe('#ping', () => { - it('works with multiple types of data', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + describe('#pong', () => { + it('can send a string as ping payload', (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]))); + if (count === 3) { + assert.deepStrictEqual(data, Buffer.from([0x8a, 0x02])); } else { - assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + assert.strictEqual(data, 'hi'); + done(); } - - if (count === 8) done(); } }); const sender = new Sender(mockSocket, { @@ -224,50 +358,99 @@ describe('Sender', () => { 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); + sender.pong('hi', false); }); - }); - describe('#pong', () => { - it('works with multiple types of data', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + it('can send a `TypedArray` as ping payload', (done) => { let count = 0; const mockSocket = new MockSocket({ write: (data) => { - if (++count < 3) return; - - if (count % 2) { - assert.ok(data.equals(Buffer.from([0x8a, 0x02]))); + if (++count === 1) { + assert.deepStrictEqual(data, Buffer.from([0x8a, 0x02])); } else { - assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + assert.deepStrictEqual(data, Buffer.from([0x68, 0x69])); + done(); } - - if (count === 8) done(); } }); - const sender = new Sender(mockSocket, { - 'permessage-deflate': perMessageDeflate - }); - perMessageDeflate.accept([{}]); + const sender = new Sender(mockSocket); + const array = new Uint8Array([0x68, 0x69]); + sender.pong(array, false); + }); + + it('can send an `ArrayBuffer` as ping payload', (done) => { + let count = 0; + const mockSocket = new MockSocket({ + write: (data) => { + if (++count === 1) { + assert.deepStrictEqual(data, Buffer.from([0x8a, 0x02])); + } else { + assert.deepStrictEqual(data, Buffer.from([0x68, 0x69])); + done(); + } + } + }); + + const sender = new Sender(mockSocket); 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); + }); + + it('can send a `Blob` as ping payload', function (done) { + if (!hasBlob) return this.skip(); + + let count = 0; + const mockSocket = new MockSocket({ + write: (data) => { + if (++count % 2) { + assert.deepStrictEqual(data, Buffer.from([0x8a, 0x02])); + } else { + assert.deepStrictEqual(data, Buffer.from([0x68, 0x69])); + if (count === 4) done(); + } + } + }); + + const sender = new Sender(mockSocket); + const blob = new Blob(['hi']); + + sender.pong(blob, false); + sender.pong(blob, 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({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); let count = 0; const mockSocket = new MockSocket({ diff --git a/test/subprotocol.test.js b/test/subprotocol.test.js new file mode 100644 index 000000000..91dd5d69d --- /dev/null +++ b/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/test/websocket-server.test.js b/test/websocket-server.test.js index 90ceb5646..34de4dcfa 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -11,6 +11,7 @@ const net = require('net'); const fs = require('fs'); const os = require('os'); +const makeDuplexPair = require('./duplex-pair'); const Sender = require('../lib/sender'); const WebSocket = require('..'); const { NOOP } = require('../lib/constants'); @@ -75,6 +76,8 @@ describe('WebSocketServer', () => { }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); } ); @@ -87,6 +90,56 @@ describe('WebSocketServer', () => { 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('honors the `autoPong` option', (done) => { + const wss = new WebSocket.Server({ autoPong: false, port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.ping(); + }); + + ws.on('pong', () => { + done(new Error("Unexpected 'pong' event")); + }); + }); + + wss.on('connection', (ws) => { + ws.on('ping', () => { + ws.close(); + }); + + ws.on('close', () => { + wss.close(done); + }); + }); + }); }); it('emits an error if http server bind fails', (done) => { @@ -103,6 +156,8 @@ describe('WebSocketServer', () => { 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)); @@ -120,12 +175,14 @@ describe('WebSocketServer', () => { server.listen(0, () => { const wss = new WebSocket.Server({ server }); - const ws = new WebSocket(`ws://localhost:${server.address().port}`); wss.on('connection', () => { - wss.close(); server.close(done); }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', ws.close); }); }); @@ -146,22 +203,16 @@ describe('WebSocketServer', () => { }); }); - 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(); + it('uses a precreated http server listening on IPC', (done) => { + const randomString = crypto.randomBytes(16).toString('hex'); + const ipcPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\ws-pipe-${randomString}` + : path.join(os.tmpdir(), `ws-${randomString}.sock`); const server = http.createServer(); - const sockPath = path.join( - os.tmpdir(), - `ws.${crypto.randomBytes(16).toString('hex')}.sock` - ); - server.listen(sockPath, () => { + server.listen(ipcPath, () => { const wss = new WebSocket.Server({ server }); wss.on('connection', (ws, req) => { @@ -169,13 +220,17 @@ describe('WebSocketServer', () => { assert.strictEqual(req.url, '/foo?bar=bar'); } else { assert.strictEqual(req.url, '/'); - wss.close(); + + 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}`)); + const ws = new WebSocket(`ws+unix:${ipcPath}:/foo?bar=bar`); + ws.on('open', () => new WebSocket(`ws+unix:${ipcPath}`)); }); }); }); @@ -209,30 +264,13 @@ describe('WebSocketServer', () => { }); describe('#close', () => { - it('does not throw when called twice', (done) => { + 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(); - - done(); - }); - }); - - it('closes all clients', (done) => { - let closes = 0; - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('close', () => { - if (++closes === 2) done(); - }); - }); - - wss.on('connection', (ws) => { - ws.on('close', () => { - if (++closes === 2) done(); - }); - wss.close(); }); }); @@ -254,6 +292,8 @@ describe('WebSocketServer', () => { server.listen(0, () => { const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', ws.close); }); }); @@ -265,11 +305,15 @@ describe('WebSocketServer', () => { it('cleans event handlers on precreated server', (done) => { const server = http.createServer(); + const listeningListenerCount = server.listenerCount('listening'); const wss = new WebSocket.Server({ server }); server.listen(0, () => { wss.close(() => { - assert.strictEqual(server.listenerCount('listening'), 0); + assert.strictEqual( + server.listenerCount('listening'), + listeningListenerCount + ); assert.strictEqual(server.listenerCount('upgrade'), 0); assert.strictEqual(server.listenerCount('error'), 0); @@ -309,10 +353,35 @@ describe('WebSocketServer', () => { }); }); + 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(); }); @@ -324,7 +393,10 @@ describe('WebSocketServer', () => { 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', () => { @@ -404,16 +476,17 @@ describe('WebSocketServer', () => { const wss = new WebSocket.Server({ noServer: true }); server.on('upgrade', (req, socket, head) => { - wss.handleUpgrade(req, socket, head, (client) => - client.send('hello') - ); + wss.handleUpgrade(req, socket, head, (ws) => { + ws.send('hello'); + ws.close(); + }); }); const ws = new WebSocket(`ws://localhost:${server.address().port}`); - ws.on('message', (message) => { - assert.strictEqual(message, 'hello'); - wss.close(); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hello')); + assert.ok(!isBinary); server.close(done); }); }); @@ -425,7 +498,9 @@ describe('WebSocketServer', () => { port: wss.address().port, headers: { Connection: 'Upgrade', - Upgrade: 'websocket' + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13 } }); @@ -451,7 +526,54 @@ describe('WebSocketServer', () => { req.on('response', (res) => { assert.strictEqual(res.statusCode, 400); - wss.close(done); + + 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); + }); + }); + }); + }); + + it('completes a WebSocket upgrade over any duplex stream', (done) => { + const server = http.createServer(); + + server.listen(0, () => { + const wss = new WebSocket.Server({ noServer: true }); + + server.on('upgrade', (req, socket, head) => { + // + // Put a stream between the raw socket and our websocket processing. + // + const { clientSide, serverSide } = makeDuplexPair(); + + socket.pipe(clientSide); + clientSide.pipe(socket); + + // + // Pass the other side of the stream as the socket to upgrade. + // + wss.handleUpgrade(req, serverSide, 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); }); }); }); @@ -494,6 +616,121 @@ describe('WebSocketServer', () => { }); 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 cannot be read', (done) => { + const server = http.createServer(); + const wss = new WebSocket.Server({ noServer: true }); + + server.maxHeadersCount = 1; + + server.on('upgrade', (req, socket, head) => { + assert.deepStrictEqual(req.headers, { foo: 'bar' }); + wss.handleUpgrade(req, socket, head, () => { + done(new Error('Unexpected callback invocation')); + }); + }); + + server.listen(() => { + const req = http.get({ + port: server.address().port, + headers: { + foo: 'bar', + bar: 'baz', + 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(), + 'Invalid Upgrade header' + ); + server.close(done); + }); + }); + }); + }); + + 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({ @@ -506,7 +743,20 @@ describe('WebSocketServer', () => { req.on('response', (res) => { assert.strictEqual(res.statusCode, 400); - wss.close(done); + + 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); + }); }); }); @@ -528,7 +778,20 @@ describe('WebSocketServer', () => { req.on('response', (res) => { assert.strictEqual(res.statusCode, 400); - wss.close(done); + + 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); + }); }); }); @@ -550,7 +813,20 @@ describe('WebSocketServer', () => { req.on('response', (res) => { assert.strictEqual(res.statusCode, 400); - wss.close(done); + + 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); + }); }); }); @@ -573,7 +849,57 @@ describe('WebSocketServer', () => { req.on('response', (res) => { assert.strictEqual(res.statusCode, 400); - wss.close(done); + + 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); + }); }); }); @@ -603,7 +929,20 @@ describe('WebSocketServer', () => { req.on('response', (res) => { assert.strictEqual(res.statusCode, 400); - wss.close(done); + + 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); + }); }); } ); @@ -613,6 +952,40 @@ describe('WebSocketServer', () => { }); }); + 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 }); @@ -658,6 +1031,7 @@ describe('WebSocketServer', () => { socket.once('data', (chunk) => { assert.strictEqual(chunk[0], 0x88); + socket.destroy(); wss.close(done); }); }); @@ -717,7 +1091,6 @@ describe('WebSocketServer', () => { }); wss.on('connection', () => { - wss.close(); server.close(done); }); @@ -726,6 +1099,8 @@ describe('WebSocketServer', () => { headers: { Origin: 'https://example.com', foo: 'bar' }, rejectUnauthorized: false }); + + ws.on('open', ws.close); }); }); @@ -737,6 +1112,8 @@ describe('WebSocketServer', () => { }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); } ); @@ -908,8 +1285,9 @@ describe('WebSocketServer', () => { }); wss.on('connection', (ws) => { - ws.on('message', (data) => { - assert.strictEqual(data, 'Hello'); + ws.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); wss.close(done); }); }); @@ -920,7 +1298,7 @@ describe('WebSocketServer', () => { const handleProtocols = (protocols, request) => { assert.ok(request instanceof http.IncomingMessage); assert.strictEqual(request.url, '/'); - return protocols.pop(); + return Array.from(protocols).pop(); }; const wss = new WebSocket.Server({ handleProtocols, port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`, [ @@ -933,24 +1311,32 @@ describe('WebSocketServer', () => { 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}`); + const ws = new WebSocket( + `ws://localhost:${wss.address().port}?foo=bar` + ); - 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, '/'); + ws.on('open', ws.close); + }); - wss.on('connection', () => wss.close(done)); - }); + 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, '/?foo=bar'); + + wss.on('connection', () => wss.close(done)); }); }); }); @@ -959,6 +1345,8 @@ describe('WebSocketServer', () => { 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) => { @@ -990,6 +1378,10 @@ describe('WebSocketServer', () => { }); } ); + + wss.on('connection', (ws) => { + ws.close(); + }); }); }); }); diff --git a/test/websocket.integration.js b/test/websocket.integration.js index 5ff87a640..abd96c61e 100644 --- a/test/websocket.integration.js +++ b/test/websocket.integration.js @@ -6,43 +6,49 @@ const WebSocket = require('..'); describe('WebSocket', () => { it('communicates successfully with echo service (ws)', (done) => { - const ws = new WebSocket('ws://echo.websocket.org/', { - origin: 'http://www.websocket.org', + const ws = new WebSocket('ws://websocket-echo.com/', { protocolVersion: 13 }); - const str = Date.now().toString(); let dataReceived = false; - ws.on('open', () => ws.send(str)); + ws.on('open', () => { + ws.send('hello'); + }); + ws.on('close', () => { assert.ok(dataReceived); done(); }); - ws.on('message', (data) => { + + ws.on('message', (message, isBinary) => { dataReceived = true; - assert.strictEqual(data, str); + assert.ok(!isBinary); + assert.strictEqual(message.toString(), 'hello'); ws.close(); }); }); it('communicates successfully with echo service (wss)', (done) => { - const ws = new WebSocket('wss://echo.websocket.org/', { - origin: 'https://www.websocket.org', + const ws = new WebSocket('wss://websocket-echo.com/', { protocolVersion: 13 }); - const str = Date.now().toString(); let dataReceived = false; - ws.on('open', () => ws.send(str)); + ws.on('open', () => { + ws.send('hello'); + }); + ws.on('close', () => { assert.ok(dataReceived); done(); }); - ws.on('message', (data) => { + + ws.on('message', (message, isBinary) => { dataReceived = true; - assert.strictEqual(data, str); + assert.ok(!isBinary); + assert.strictEqual(message.toString(), 'hello'); ws.close(); }); }); diff --git a/test/websocket.test.js b/test/websocket.test.js index a1a1cda43..811a3e15b 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -6,12 +6,33 @@ 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 { getDefaultHighWaterMark } = require('stream'); const { URL } = require('url'); +const Sender = require('../lib/sender'); const WebSocket = require('..'); -const { GUID, NOOP } = require('../lib/constants'); +const { + CloseEvent, + ErrorEvent, + Event, + MessageEvent +} = require('../lib/event-target'); +const { + EMPTY_BUFFER, + GUID, + hasBlob, + kListener, + NOOP +} = require('../lib/constants'); + +const highWaterMark = getDefaultHighWaterMark + ? getDefaultHighWaterMark(false) + : 16 * 1024; class CustomAgent extends http.Agent { addRequest() {} @@ -20,14 +41,46 @@ class CustomAgent extends http.Agent { 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('bad-scheme://websocket-echo.com'), + (err) => { + assert.strictEqual( + err.message, + 'The URL\'s protocol must be one of "ws:", "wss:", ' + + '"http:", "https", or "ws+unix:"' + ); + + return true; + } + ); + assert.throws( () => new WebSocket('ws+unix:'), - /^Error: Invalid URL: 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(); + const agent = new http.Agent(); agent.addRequest = (req, opts) => { assert.strictEqual(opts.host, '::1'); @@ -38,19 +91,48 @@ describe('WebSocket', () => { const ws = new WebSocket(new URL('ws://[::1]'), { agent }); }); + it('allows the http scheme', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req, opts) => { + assert.strictEqual(opts.host, 'localhost'); + assert.strictEqual(opts.port, 80); + done(); + }; + + const ws = new WebSocket('http://localhost', { agent }); + }); + + it('allows the https scheme', (done) => { + const agent = new https.Agent(); + + agent.addRequest = (req, opts) => { + assert.strictEqual(opts.host, 'localhost'); + assert.strictEqual(opts.port, 443); + done(); + }; + + const ws = new WebSocket('https://localhost', { agent }); + }); + describe('options', () => { it('accepts the `options` object as 3rd argument', () => { - const agent = new CustomAgent(); + const agent = new http.Agent(); let count = 0; let ws; - agent.addRequest = () => count++; + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('sec-websocket-protocol'), + undefined + ); + count++; + }; ws = new WebSocket('ws://localhost', undefined, { agent }); - ws = new WebSocket('ws://localhost', null, { agent }); ws = new WebSocket('ws://localhost', [], { agent }); - assert.strictEqual(count, 3); + assert.strictEqual(count, 2); }); it('accepts the `maxPayload` option', (done) => { @@ -76,16 +158,80 @@ describe('WebSocket', () => { }); } ); + + 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), + () => new WebSocket('ws://localhost', { protocolVersion: 1000 }), /^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(); + }); + }); + }); + + it('honors the `autoPong` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + autoPong: false + }); + + ws.on('ping', () => { + ws.close(); + }); + + ws.on('close', () => { + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('pong', () => { + done(new Error("Unexpected 'pong' event")); + }); + + ws.ping(); + }); + }); }); }); @@ -201,6 +347,10 @@ describe('WebSocket', () => { wss.close(done); }; }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it('takes into account the data in the sender queue', (done) => { @@ -229,6 +379,10 @@ describe('WebSocket', () => { }); } ); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it('takes into account the data in the socket queue', (done) => { @@ -311,6 +465,39 @@ describe('WebSocket', () => { }); }); + 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( @@ -419,16 +606,24 @@ describe('WebSocket', () => { }); it('exposes the server url', () => { - const url = 'ws://localhost'; - const ws = new WebSocket(url, { agent: new CustomAgent() }); + const schemes = new Map([ + ['ws', 'ws'], + ['wss', 'wss'], + ['http', 'ws'], + ['https', 'wss'] + ]); + + for (const [key, value] of schemes) { + const ws = new WebSocket(`${key}://localhost/`, { lookup() {} }); - assert.strictEqual(ws.url, url); + assert.strictEqual(ws.url, `${value}://localhost/`); + } }); }); }); describe('Events', () => { - it("emits an 'error' event if an error occurs", (done) => { + it("emits an 'error' event if an error occurs (1/2)", (done) => { let clientCloseEventEmitted = false; let serverClientCloseEventEmitted = false; @@ -445,7 +640,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); clientCloseEventEmitted = true; if (serverClientCloseEventEmitted) wss.close(done); @@ -456,7 +651,7 @@ describe('WebSocket', () => { wss.on('connection', (ws) => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1002); - assert.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (clientCloseEventEmitted) wss.close(done); @@ -466,25 +661,221 @@ describe('WebSocket', () => { }); }); - it('does not re-emit `net.Socket` errors', (done) => { - const codes = ['EPIPE', 'ECONNABORTED', 'ECANCELED', 'ECONNRESET']; + it("emits an 'error' event if an error occurs (2/2)", function (done) { + if (!fs.openAsBlob) return this.skip(); + + const randomString = crypto.randomBytes(16).toString('hex'); + const file = path.join(os.tmpdir(), `ws-${randomString}.txt`); + + fs.writeFileSync(file, 'x'.repeat(64)); + + fs.openAsBlob(file) + .then((blob) => { + fs.writeFileSync(file, 'x'.repeat(32)); + runTest(blob); + }) + .catch(done); + + function runTest(blob) { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + ws.send(blob); + + ws.on('error', (err) => { + try { + assert.ok(err instanceof DOMException); + assert.strictEqual(err.name, 'NotReadableError'); + assert.strictEqual(err.message, 'The blob could not be read'); + } finally { + fs.unlinkSync(file); + } + + ws.on('close', () => { + wss.close(done); + }); + }); + }); + } + }); + + it("emits the 'error' event only once (1/2)", function (done) { + if (!fs.openAsBlob) return this.skip(); + + const randomString = crypto.randomBytes(16).toString('hex'); + const file = path.join(os.tmpdir(), `ws-${randomString}.txt`); + + fs.writeFileSync(file, 'x'.repeat(64)); + + fs.openAsBlob(file) + .then((blob) => { + fs.writeFileSync(file, 'x'.repeat(32)); + runTest(blob); + }) + .catch(done); + + function runTest(blob) { + 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(blob); + }); + + ws.on('error', (err) => { + try { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + } finally { + fs.unlinkSync(file); + } + + ws.on('close', () => { + wss.close(done); + }); + }); + } + ); + + wss.on('connection', (ws) => { + ws._socket.write(Buffer.from([0x85, 0x00])); + }); + } + }); + + it("emits the 'error' event only once (2/2)", function (done) { + if (!fs.openAsBlob) return this.skip(); + + const randomString = crypto.randomBytes(16).toString('hex'); + const file = path.join(os.tmpdir(), `ws-${randomString}.txt`); + + fs.writeFileSync(file, 'x'.repeat(64)); + + fs.openAsBlob(file) + .then((blob) => { + fs.writeFileSync(file, 'x'.repeat(32)); + runTest(blob); + }) + .catch(done); + + function runTest(blob) { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(blob); + }); + + ws.on('error', (err) => { + try { + assert.ok(err instanceof DOMException); + assert.strictEqual(err.name, 'NotReadableError'); + assert.strictEqual(err.message, 'The blob could not be read'); + } finally { + fs.unlinkSync(file); + } + + ws.on('close', () => { + wss.close(done); + }); + }); + } + ); + + wss.on('connection', (ws) => { + const buf = Buffer.from('c10100'.repeat(5) + '8500', 'hex'); + + ws._socket.write(buf); + }); + } + }); + + it("does not emit 'error' after 'close'", function (done) { + if (!fs.openAsBlob) return this.skip(); + + const randomString = crypto.randomBytes(16).toString('hex'); + const file = path.join(os.tmpdir(), `ws-${randomString}.bin`); + + fs.writeFileSync(file, crypto.randomBytes(1024 * 1024)); + fs.openAsBlob(file).then(runTest).catch(done); + + function runTest(blob) { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(blob, (err) => { + try { + assert.ok(err instanceof DOMException); + assert.strictEqual(err.name, 'NotReadableError'); + assert.strictEqual(err.message, 'The blob could not be read'); + } catch (e) { + ws.removeListener(onClose); + throw e; + } finally { + fs.unlinkSync(file); + } + + wss.close(done); + }); + }); + + ws.on('error', () => { + done(new Error("Unexpected 'error' event")); + }); + ws.on('close', onClose); + + function onClose() { + fs.writeFileSync(file, crypto.randomBytes(32)); + } + }); + + wss.on('connection', (ws) => { + ws._socket.end(); + }); + } + }); + + it('does not re-emit `net.Socket` errors', function (done) { + // + // `socket.resetAndDestroy()` is not available in Node.js < 16.17.0. + // + if (process.versions.modules < 93) return this.skip(); + 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}`); + assert.strictEqual(err.code, 'ECONNRESET'); ws.on('close', (code, message) => { - assert.strictEqual(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'); + wss.clients.values().next().value._socket.resetAndDestroy(); }); }); }); @@ -497,6 +888,10 @@ describe('WebSocket', () => { wss.close(done); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it("emits a 'ping' event", (done) => { @@ -505,7 +900,10 @@ describe('WebSocket', () => { ws.on('ping', () => wss.close(done)); }); - wss.on('connection', (ws) => ws.ping()); + wss.on('connection', (ws) => { + ws.ping(); + ws.close(); + }); }); it("emits a 'pong' event", (done) => { @@ -514,15 +912,96 @@ describe('WebSocket', () => { ws.on('pong', () => wss.close(done)); }); - wss.on('connection', (ws) => ws.pong()); + wss.on('connection', (ws) => { + ws.pong(); + ws.close(); + }); }); - }); - describe('Connection establishing', () => { - const server = http.createServer(); + it("emits a 'redirect' event", (done) => { + const server = http.createServer(); + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); - beforeEach((done) => server.listen(0, done)); - afterEach((done) => server.close(done)); + 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 cannot be read', (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: websocket\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws._req.maxHeadersCount = 1; + + ws.on('upgrade', (res) => { + assert.deepStrictEqual(res.headers, { connection: 'Upgrade' }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid Upgrade header'); + 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) => { @@ -565,7 +1044,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); done(); }); }); @@ -651,7 +1130,7 @@ describe('WebSocket', () => { server.once('upgrade', (req, socket) => socket.on('end', socket.end)); const port = server.address().port; - const ws = new WebSocket(`ws://localhost:${port}`, null, { + const ws = new WebSocket(`ws://localhost:${port}`, { handshakeTimeout: 100 }); @@ -837,7 +1316,7 @@ describe('WebSocket', () => { }); }); - it('fails if server sends an invalid subprotocol', (done) => { + it('fails if server sends an invalid subprotocol (1/2)', (done) => { const wss = new WebSocket.Server({ handleProtocols: () => 'baz', server @@ -856,6 +1335,36 @@ describe('WebSocket', () => { }); }); + 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() {}, @@ -875,6 +1384,35 @@ describe('WebSocket', () => { }); }); + it('honors the `createConnection` option', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + server.once('upgrade', (req, socket, head) => { + assert.strictEqual(req.headers.host, 'google.com:22'); + wss.handleUpgrade(req, socket, head, NOOP); + }); + + const ws = new WebSocket('ws://google.com:22/foo', { + createConnection: (options) => { + assert.strictEqual(options.host, 'google.com'); + assert.strictEqual(options.port, '22'); + + // Ignore the `options` argument, and use the correct hostname and + // port to connect to the server. + return net.createConnection({ + host: 'localhost', + port: server.address().port + }); + } + }); + + ws.on('open', () => { + assert.strictEqual(ws.url, 'ws://google.com:22/foo'); + ws.on('close', () => done()); + ws.close(); + }); + }); + it('does not follow redirects by default', (done) => { server.once('upgrade', (req, socket) => { socket.end( @@ -940,161 +1478,948 @@ describe('WebSocket', () => { ws.on('close', () => done()); }); }); - }); - describe('Connection with query string', () => { - it('connects when pathname is not null', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const port = wss.address().port; - const ws = new WebSocket(`ws://localhost:${port}/?token=qwerty`); + 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'); + }); - ws.on('open', () => wss.close(done)); + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true }); - }); - 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', () => 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('open', () => wss.close(done)); + ws.on('close', () => done()); }); }); - }); - describe('#ping', () => { - it('throws an error if `readyState` is `CONNECTING`', () => { - const ws = new WebSocket('ws://localhost', { - lookup() {} + 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: bad-scheme://localhost\r\n\r\n' + ); }); - 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() {} + 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 Error); + assert.ok(err instanceof SyntaxError); assert.strictEqual( err.message, - 'WebSocket was closed before the connection was established' + 'The URL\'s protocol must be one of "ws:", "wss:", ' + + '"http:", "https", or "ws+unix:"' ); + assert.strictEqual(ws._redirects, 1); - assert.strictEqual(ws.readyState, WebSocket.CLOSING); - assert.strictEqual(ws.bufferedAmount, 0); + ws.on('close', () => done()); + }); + }); - ws.ping('hi'); - assert.strictEqual(ws.bufferedAmount, 2); + it('uses the first url userinfo when following redirects', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + const authorization = 'Basic Zm9vOmJhcg=='; - ws.ping(); - assert.strictEqual(ws.bufferedAmount, 2); + 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(); + }); + }); + }); - ws.on('close', () => { - assert.strictEqual(ws.readyState, WebSocket.CLOSED); + const port = server.address().port; + const ws = new WebSocket(`ws://foo:bar@localhost:${port}`, { + followRedirects: true + }); - ws.ping('hi'); - assert.strictEqual(ws.bufferedAmount, 4); + assert.strictEqual(ws._req.getHeader('Authorization'), authorization); - ws.ping(); - assert.strictEqual(ws.bufferedAmount, 4); + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://baz:qux@localhost:${port}/foo`); + assert.strictEqual(ws._redirects, 1); - done(); - }); + wss.close(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}`); - }); + describe('When moving away from a secure context', () => { + function proxy(httpServer, httpsServer) { + const server = net.createServer({ allowHalfOpen: true }); - wss.on('connection', (ws) => { - ws.close(); + server.on('connection', (socket) => { + socket.on('readable', function read() { + socket.removeListener('readable', read); - assert.strictEqual(ws.bufferedAmount, 0); + const buf = socket.read(1); + const target = buf[0] === 22 ? httpsServer : httpServer; - 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); + socket.unshift(buf); + target.emit('connection', socket); + }); + }); - ws.on('close', () => { - ws.ping((err) => { - assert.ok(err instanceof Error); - assert.strictEqual( - err.message, - 'WebSocket is not open: readyState 3 (CLOSED)' + 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` ); - assert.strictEqual(ws.bufferedAmount, 2); + }); - wss.close(done); + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + ws.close(); }); - }); - }); - }); - }); - 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}`); + const ws = new WebSocket(`wss://localhost:${port}`, { + auth: 'foo:bar', + followRedirects: true, + rejectUnauthorized: false + }); - ws.on('open', () => { - ws.ping(() => ws.ping()); - }); - }); + assert.strictEqual( + ws._req.getHeader('Authorization'), + 'Basic Zm9vOmJhcg==' + ); - 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); + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + server.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}`); + 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); - ws.on('open', () => { - ws.ping('hi', () => ws.ping('hi', true)); - }); - }); + server.listen(() => { + const port = server.address().port; - wss.on('connection', (ws) => { - let pings = 0; - ws.on('ping', (message) => { - assert.strictEqual(message.toString(), 'hi'); - if (++pings === 2) wss.close(done); - }); - }); - }); + 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` + ); + }); - it('can send numbers as ping payload', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; - ws.on('open', () => ws.ping(0)); - }); + const wss = new WebSocket.Server({ server: httpServer }); - wss.on('connection', (ws) => { + 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)', (done) => { + // Test the `ws:` to `ws+unix:` case. + + const randomString = crypto.randomBytes(16).toString('hex'); + const ipcPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\ws-pipe-${randomString}` + : path.join(os.tmpdir(), `ws-${randomString}.sock`); + + server.once('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 302 Found\r\nLocation: ws+unix:${ipcPath}\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(ipcPath, () => { + 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:${ipcPath}`); + assert.strictEqual(ws._redirects, 1); + + redirectedServer.close(done); + }); + }); + }); + + it('drops the Authorization, Cookie and Host headers (3/4)', (done) => { + // Test the `ws+unix:` to `ws+unix:` case. + + const randomString1 = crypto.randomBytes(16).toString('hex'); + const randomString2 = crypto.randomBytes(16).toString('hex'); + let redirectingServerIpcPath; + let redirectedServerIpcPath; + + if (process.platform === 'win32') { + redirectingServerIpcPath = `\\\\.\\pipe\\ws-pipe-${randomString1}`; + redirectedServerIpcPath = `\\\\.\\pipe\\ws-pipe-${randomString2}`; + } else { + redirectingServerIpcPath = path.join( + os.tmpdir(), + `ws-${randomString1}.sock` + ); + redirectedServerIpcPath = path.join( + os.tmpdir(), + `ws-${randomString2}.sock` + ); + } + + const redirectingServer = http.createServer(); + + redirectingServer.on('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws+unix:${redirectedServerIpcPath}\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(redirectingServerIpcPath, listening); + redirectedServer.listen(redirectedServerIpcPath, 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:${redirectingServerIpcPath}`, { + 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:${redirectedServerIpcPath}`); + assert.strictEqual(ws._redirects, 1); + + redirectingServer.close(); + redirectedServer.close(done); + }); + } + }); + + it('drops the Authorization, Cookie and Host headers (4/4)', (done) => { + // 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 randomString = crypto.randomBytes(16).toString('hex'); + const ipcPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\ws-pipe-${randomString}` + : path.join(os.tmpdir(), `ws-${randomString}.sock`); + + redirectingServer.listen(ipcPath, 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:${ipcPath}`, { + 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('#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); + + if (hasBlob) { + ws.ping(new Blob(['hi'])); + assert.strictEqual(ws.bufferedAmount, 6); + } + + 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); @@ -1115,6 +2440,10 @@ describe('WebSocket', () => { wss.close(done); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); }); @@ -1165,6 +2494,11 @@ describe('WebSocket', () => { ws.pong(); assert.strictEqual(ws.bufferedAmount, 4); + if (hasBlob) { + ws.pong(new Blob(['hi'])); + assert.strictEqual(ws.bufferedAmount, 6); + } + done(); }); }); @@ -1211,7 +2545,10 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('open', () => { - ws.pong(() => ws.pong()); + ws.pong(() => { + ws.pong(); + ws.close(); + }); }); }); @@ -1230,7 +2567,10 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('open', () => { - ws.pong('hi', () => ws.pong('hi', true)); + ws.pong('hi', () => { + ws.pong('hi', true); + ws.close(); + }); }); }); @@ -1247,7 +2587,10 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.pong(0)); + ws.on('open', () => { + ws.pong(0); + ws.close(); + }); }); wss.on('connection', (ws) => { @@ -1271,6 +2614,85 @@ describe('WebSocket', () => { wss.close(done); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('is called automatically when a ping is received', (done) => { + const buf = Buffer.from('hi'); + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.ping(buf); + }); + + ws.on('pong', (data) => { + assert.deepStrictEqual(data, buf); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('ping', (data) => { + assert.deepStrictEqual(data, buf); + 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); + }); }); }); @@ -1321,6 +2743,11 @@ describe('WebSocket', () => { ws.send(); assert.strictEqual(ws.bufferedAmount, 4); + if (hasBlob) { + ws.send(new Blob(['hi'])); + assert.strictEqual(ws.bufferedAmount, 6); + } + done(); }); }); @@ -1364,7 +2791,7 @@ describe('WebSocket', () => { it('can send a big binary message', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { - const array = new Float32Array(5 * 1024 * 1024); + const array = new Float32Array(1024 * 1024); for (let i = 0; i < array.length; i++) { array[i] = i / 5; @@ -1372,15 +2799,20 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send(array, { compress: false })); - ws.on('message', (msg) => { - assert.ok(msg.equals(Buffer.from(array.buffer))); + 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) => ws.send(msg, { compress: false })); + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + ws.close(); + }); }); }); @@ -1389,14 +2821,18 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('open', () => ws.send('hi')); - ws.on('message', (message) => { - assert.strictEqual(message, '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) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + ws.close(); + }); }); }); @@ -1407,12 +2843,14 @@ describe('WebSocket', () => { ws.on('open', () => { ws.send('fragment', { fin: false }); ws.send('fragment', { fin: true }); + ws.close(); }); }); wss.on('connection', (ws) => { - ws.on('message', (msg) => { - assert.strictEqual(msg, 'fragmentfragment'); + ws.on('message', (msg, isBinary) => { + assert.deepStrictEqual(msg, Buffer.from('fragmentfragment')); + assert.ok(!isBinary); wss.close(done); }); }); @@ -1422,18 +2860,22 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send(0)); + ws.on('open', () => { + ws.send(0); + ws.close(); + }); }); wss.on('connection', (ws) => { - ws.on('message', (msg) => { - assert.strictEqual(msg, '0'); + ws.on('message', (msg, isBinary) => { + assert.deepStrictEqual(msg, Buffer.from('0')); + assert.ok(!isBinary); wss.close(done); }); }); }); - it('can send binary data as an array', (done) => { + it('can send a `TypedArray`', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const array = new Float32Array(6); @@ -1450,32 +2892,23 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send(partial)); - ws.on('message', (message) => { - assert.ok(message.equals(buf)); - wss.close(done); + ws.on('open', () => { + ws.send(partial); + ws.close(); }); - }); - - wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); - }); - }); - it('can send binary data as a buffer', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const buf = Buffer.from('foobar'); - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - - ws.on('open', () => ws.send(buf)); - ws.on('message', (message) => { - assert.ok(message.equals(buf)); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, buf); + assert.ok(isBinary); wss.close(done); }); }); wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); }); }); @@ -1489,7 +2922,11 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send(array.buffer)); + ws.on('open', () => { + ws.send(array.buffer); + ws.close(); + }); + ws.onmessage = (event) => { assert.ok(event.data.equals(Buffer.from(array.buffer))); wss.close(done); @@ -1497,7 +2934,10 @@ describe('WebSocket', () => { }); wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); }); }); @@ -1506,16 +2946,55 @@ describe('WebSocket', () => { const buf = Buffer.from('foobar'); const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send(buf)); + ws.on('open', () => { + ws.send(buf); + ws.close(); + }); ws.onmessage = (event) => { - assert.ok(event.data.equals(buf)); + assert.deepStrictEqual(event.data, buf); wss.close(done); }; }); wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); + }); + }); + + it('can send a `Blob`', function (done) { + if (!hasBlob) return this.skip(); + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + const messages = []; + + ws.on('open', () => { + ws.send(new Blob(['foo'])); + ws.send(new Blob(['bar'])); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + messages.push(message.toString()); + + if (messages.length === 2) { + assert.deepStrictEqual(messages, ['foo', 'bar']); + wss.close(done); + } + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message); + }); }); }); @@ -1530,18 +3009,68 @@ describe('WebSocket', () => { }); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('calls the callback if the socket is forcibly closed', function (done) { + if (!hasBlob) return this.skip(); + + const called = []; + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(new Blob(['foo']), (err) => { + called.push(1); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The socket was closed while the blob was being read' + ); + }); + ws.send('bar'); + ws.send('baz', (err) => { + called.push(2); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The socket was closed while the blob was being read' + ); + }); + + ws.terminate(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('close', () => { + assert.deepStrictEqual(called, [1, 2]); + wss.close(done); + }); + }); }); it('works when the `data` argument is falsy', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send()); + ws.on('open', () => { + ws.send(); + ws.close(); + }); }); wss.on('connection', (ws) => { - ws.on('message', (message) => { - assert.ok(message.equals(Buffer.alloc(0))); + ws.on('message', (message, isBinary) => { + assert.strictEqual(message, EMPTY_BUFFER); + assert.ok(isBinary); wss.close(done); }); }); @@ -1557,7 +3086,7 @@ describe('WebSocket', () => { ws.on('open', () => ws.send('hi', { mask: false })); ws.on('close', (code, reason) => { assert.strictEqual(code, 1002); - assert.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); clientCloseEventEmitted = true; if (serverClientCloseEventEmitted) wss.close(done); @@ -1583,7 +3112,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (clientCloseEventEmitted) wss.close(done); @@ -1683,76 +3212,79 @@ describe('WebSocket', () => { }); it('can be called from an error listener while connecting', (done) => { - const ws = new WebSocket('ws://localhost:1337'); + const server = net.createServer(); - 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()); + server.on('connection', (socket) => { + socket.on('end', socket.end); + socket.resume(); + socket.write(Buffer.from('foo\r\n')); }); - }).timeout(4000); - 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}`); + 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', () => wss.close(done)); + assert.strictEqual(err.code, 'HPE_INVALID_CONSTANT'); + ws.close(); + ws.on('close', () => { + server.close(done); + }); }); - ws.on('upgrade', () => ws.close()); }); }); - it('throws an error if the first argument is invalid (1/2)', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - - ws.on('open', () => { - assert.throws( - () => ws.close('error'), - /^TypeError: First argument must be a valid error code number$/ - ); + it("can be called from a listener of the 'redirect' event", (done) => { + const server = http.createServer(); - wss.close(done); - }); + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); }); - }); - it('throws an error if the first argument is invalid (2/2)', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); ws.on('open', () => { - assert.throws( - () => ws.close(1004), - /^TypeError: First argument must be a valid error code number$/ + 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' ); - wss.close(done); + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + server.close(done); + }); + }); + + ws.on('redirect', () => { + ws.close(); }); }); }); - it('throws an error if the message is greater than 123 bytes', (done) => { + it("can be called from a listener of the 'upgrade' event", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => { - assert.throws( - () => ws.close(1000, 'a'.repeat(124)), - /^RangeError: The message must not be greater than 123 bytes$/ + 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' ); - - wss.close(done); + ws.on('close', () => wss.close(done)); }); + ws.on('upgrade', () => ws.close()); }); }); @@ -1770,12 +3302,15 @@ describe('WebSocket', () => { wss.on('connection', (ws) => { ws._socket.once('data', (received) => { - assert.ok(received.slice(0, 2).equals(Buffer.from([0x88, 0x80]))); - assert.ok(sent.equals(Buffer.from([0x88, 0x00]))); + 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, ''); + assert.strictEqual(reason, EMPTY_BUFFER); wss.close(done); }); }); @@ -1792,8 +3327,8 @@ describe('WebSocket', () => { wss.on('connection', (ws) => { ws.on('close', (code, message) => { - assert.strictEqual(message, ''); assert.strictEqual(code, 1000); + assert.deepStrictEqual(message, EMPTY_BUFFER); wss.close(done); }); }); @@ -1808,8 +3343,8 @@ describe('WebSocket', () => { wss.on('connection', (ws) => { ws.on('close', (code, message) => { - assert.strictEqual(message, 'some reason'); assert.strictEqual(code, 1000); + assert.deepStrictEqual(message, Buffer.from('some reason')); wss.close(done); }); }); @@ -1825,7 +3360,10 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); const messages = []; - ws.on('message', (message) => messages.push(message)); + 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']); @@ -1892,7 +3430,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1000); - assert.strictEqual(reason, 'some reason'); + assert.deepStrictEqual(reason, Buffer.from('some reason')); wss.close(done); }); @@ -1960,16 +3498,64 @@ describe('WebSocket', () => { }); it('can be called from an error listener while connecting', (done) => { - const ws = new WebSocket('ws://localhost:1337'); + const server = net.createServer(); - 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()); + server.on('connection', (socket) => { + socket.on('end', socket.end); + socket.resume(); + socket.write(Buffer.from('foo\r\n')); }); - }).timeout(4000); + + 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.code, 'HPE_INVALID_CONSTANT'); + ws.terminate(); + ws.on('close', () => { + server.close(done); + }); + }); + }); + }); + + 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 }, () => { @@ -2020,10 +3606,10 @@ describe('WebSocket', () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - assert.strictEqual(ws.onmessage, undefined); - assert.strictEqual(ws.onclose, undefined); - assert.strictEqual(ws.onerror, undefined); - assert.strictEqual(ws.onopen, undefined); + 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; @@ -2034,6 +3620,11 @@ describe('WebSocket', () => { 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) => { @@ -2055,7 +3646,9 @@ describe('WebSocket', () => { }); wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + }); }); }); @@ -2065,7 +3658,7 @@ describe('WebSocket', () => { ws.on('open', NOOP); assert.deepStrictEqual(ws.listeners('open'), [NOOP]); - assert.strictEqual(ws.onopen, undefined); + assert.strictEqual(ws.onopen, null); }); it("doesn't remove listeners added with `on`", () => { @@ -2078,7 +3671,7 @@ describe('WebSocket', () => { assert.strictEqual(listeners.length, 2); assert.strictEqual(listeners[0], NOOP); - assert.strictEqual(listeners[1]._listener, NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); ws.onclose = NOOP; @@ -2086,95 +3679,172 @@ describe('WebSocket', () => { assert.strictEqual(listeners.length, 2); assert.strictEqual(listeners[0], NOOP); - assert.strictEqual(listeners[1]._listener, NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); }); - it('adds listeners for custom events with `addEventListener`', () => { + it('supports the `addEventListener` method', () => { + const events = []; const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - ws.addEventListener('foo', NOOP); - assert.strictEqual(ws.listeners('foo')[0], NOOP); + ws.addEventListener('foo', () => {}); + assert.strictEqual(ws.listenerCount('foo'), 0); - // - // Fails silently when the `listener` is not a function. - // - ws.addEventListener('bar', {}); - assert.strictEqual(ws.listeners('bar').length, 0); + function onOpen() { + events.push('open'); + assert.strictEqual(ws.listenerCount('open'), 1); + } + + ws.addEventListener('open', onOpen); + ws.addEventListener('open', onOpen); + + assert.strictEqual(ws.listenerCount('open'), 1); + + const listener = { + handleEvent() { + events.push('message'); + assert.strictEqual(this, listener); + assert.strictEqual(ws.listenerCount('message'), 0); + } + }; + + ws.addEventListener('message', listener, { once: true }); + ws.addEventListener('message', listener); + + assert.strictEqual(ws.listenerCount('message'), 1); + + 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.onerror = NOOP; + ws.addEventListener('error', NOOP); + + listeners = ws.listeners('error'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0][kListener], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + + ws.emit('open'); + ws.emit('message', EMPTY_BUFFER, false); + + assert.deepStrictEqual(events, ['open', 'message']); }); - it('allows to add one time listeners with `addEventListener`', (done) => { + it("doesn't return listeners added with `addEventListener`", () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - ws.addEventListener( - 'foo', - () => { - assert.strictEqual(ws.listenerCount('foo'), 0); - done(); - }, - { once: true } - ); + 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); - assert.strictEqual(ws.listenerCount('foo'), 1); - ws.emit('foo'); + 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); + const listener = { handleEvent() {} }; + + ws.addEventListener('message', listener); ws.addEventListener('open', NOOP); - ws.addEventListener('foo', NOOP); - assert.strictEqual(ws.listeners('message')[0]._listener, NOOP); - assert.strictEqual(ws.listeners('open')[0]._listener, NOOP); - assert.strictEqual(ws.listeners('foo')[0], NOOP); + assert.strictEqual(ws.listeners('message')[0][kListener], listener); + assert.strictEqual(ws.listeners('open')[0][kListener], NOOP); ws.removeEventListener('message', () => {}); - assert.strictEqual(ws.listeners('message')[0]._listener, NOOP); + assert.strictEqual(ws.listeners('message')[0][kListener], listener); - ws.removeEventListener('message', NOOP); + ws.removeEventListener('message', listener); ws.removeEventListener('open', NOOP); - ws.removeEventListener('foo', NOOP); assert.strictEqual(ws.listenerCount('message'), 0); assert.strictEqual(ws.listenerCount('open'), 0); - assert.strictEqual(ws.listenerCount('foo'), 0); ws.addEventListener('message', NOOP, { once: true }); ws.addEventListener('open', NOOP, { once: true }); - ws.addEventListener('foo', NOOP, { once: true }); - assert.strictEqual(ws.listeners('message')[0]._listener, NOOP); - assert.strictEqual(ws.listeners('open')[0]._listener, NOOP); - assert.strictEqual(ws.listeners('foo')[0], 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]._listener, NOOP); + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); ws.removeEventListener('message', NOOP); ws.removeEventListener('open', NOOP); - ws.removeEventListener('foo', NOOP); assert.strictEqual(ws.listenerCount('message'), 0); assert.strictEqual(ws.listenerCount('open'), 0); - assert.strictEqual(ws.listenerCount('foo'), 0); + + // 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.addEventListener('message', (messageEvent) => { - assert.strictEqual(messageEvent.data, 'hi'); + 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) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + }); }); }); @@ -2182,10 +3852,11 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.addEventListener('close', (closeEvent) => { - assert.ok(closeEvent.wasClean); - assert.strictEqual(closeEvent.reason, ''); - assert.strictEqual(closeEvent.code, 1000); + 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); }); }); @@ -2197,10 +3868,11 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.addEventListener('close', (closeEvent) => { - assert.ok(closeEvent.wasClean); - assert.strictEqual(closeEvent.reason, 'some daft reason'); - assert.strictEqual(closeEvent.code, 4000); + 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); }); }); @@ -2213,27 +3885,31 @@ describe('WebSocket', () => { const err = new Error('forced'); const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.addEventListener('open', (openEvent) => { - assert.strictEqual(openEvent.type, 'open'); - assert.strictEqual(openEvent.target, ws); + ws.addEventListener('open', (event) => { + assert.ok(event instanceof Event); + assert.strictEqual(event.type, 'open'); + assert.strictEqual(event.target, ws); }); - ws.addEventListener('message', (messageEvent) => { - assert.strictEqual(messageEvent.type, 'message'); - assert.strictEqual(messageEvent.target, ws); - wss.close(); + ws.addEventListener('message', (event) => { + assert.ok(event instanceof MessageEvent); + assert.strictEqual(event.type, 'message'); + assert.strictEqual(event.target, ws); + ws.close(); }); - ws.addEventListener('close', (closeEvent) => { - assert.strictEqual(closeEvent.type, 'close'); - assert.strictEqual(closeEvent.target, ws); + 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', (errorEvent) => { - assert.strictEqual(errorEvent.message, 'forced'); - assert.strictEqual(errorEvent.type, 'error'); - assert.strictEqual(errorEvent.target, ws); - assert.strictEqual(errorEvent.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); - done(); + wss.close(done); }); }); @@ -2250,7 +3926,10 @@ describe('WebSocket', () => { }; }); - wss.on('connection', (ws) => ws.send(new Uint8Array(4096))); + wss.on('connection', (ws) => { + ws.send(new Uint8Array(4096)); + ws.close(); + }); }); it('ignores `binaryType` for text messages', (done) => { @@ -2265,7 +3944,10 @@ describe('WebSocket', () => { }; }); - wss.on('connection', (ws) => ws.send('foo')); + wss.on('connection', (ws) => { + ws.send('foo'); + ws.close(); + }); }); it('allows to update `binaryType` on the fly', (done) => { @@ -2279,30 +3961,52 @@ describe('WebSocket', () => { ws.onmessage = (evt) => { if (binaryType === 'nodebuffer') { assert.ok(Buffer.isBuffer(evt.data)); - assert.ok(evt.data.equals(buf)); + assert.deepStrictEqual(evt.data, buf); + next(); } else if (binaryType === 'arraybuffer') { assert.ok(evt.data instanceof ArrayBuffer); - assert.ok(Buffer.from(evt.data).equals(buf)); + assert.deepStrictEqual(Buffer.from(evt.data), buf); + next(); } else if (binaryType === 'fragments') { assert.deepStrictEqual(evt.data, [buf]); + next(); + } else if (binaryType === 'blob') { + assert.ok(evt.data instanceof Blob); + evt.data + .arrayBuffer() + .then((arrayBuffer) => { + assert.deepStrictEqual(Buffer.from(arrayBuffer), buf); + next(); + }) + .catch(done); } - next(); }; ws.send(buf); } + function close() { + ws.close(); + wss.close(done); + } + ws.onopen = () => { testType('nodebuffer', () => { testType('arraybuffer', () => { - testType('fragments', () => wss.close(done)); + testType('fragments', () => { + if (hasBlob) testType('blob', close); + else close(); + }); }); }); }; }); wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); }); }); }); @@ -2316,7 +4020,6 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ server }); wss.on('connection', () => { - wss.close(); server.close(done); }); @@ -2324,6 +4027,8 @@ describe('WebSocket', () => { const ws = new WebSocket(`wss://127.0.0.1:${server.address().port}`, { rejectUnauthorized: false }); + + ws.on('open', ws.close); }); }); @@ -2335,19 +4040,17 @@ describe('WebSocket', () => { requestCert: true }); - let success = false; - const wss = new WebSocket.Server({ - verifyClient: (info) => { - success = !!info.req.client.authorized; - return true; - }, - server - }); + const wss = new WebSocket.Server({ noServer: true }); - wss.on('connection', () => { - assert.ok(success); - server.close(done); - wss.close(); + 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, () => { @@ -2356,6 +4059,8 @@ describe('WebSocket', () => { key: fs.readFileSync('test/fixtures/client-key.pem'), rejectUnauthorized: false }); + + ws.on('open', ws.close); }); }); @@ -2386,10 +4091,10 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ server }); wss.on('connection', (ws) => { - ws.on('message', (message) => { - assert.strictEqual(message, 'foobar'); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('foobar')); + assert.ok(!isBinary); server.close(done); - wss.close(); }); }); @@ -2398,7 +4103,10 @@ describe('WebSocket', () => { rejectUnauthorized: false }); - ws.on('open', () => ws.send('foobar')); + ws.on('open', () => { + ws.send('foobar'); + ws.close(); + }); }); }); @@ -2411,7 +4119,11 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ server }); wss.on('connection', (ws) => { - ws.on('message', (message) => ws.send(message)); + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message); + ws.close(); + }); }); server.listen(0, () => { @@ -2420,11 +4132,11 @@ describe('WebSocket', () => { }); ws.on('open', () => ws.send(buf)); - ws.on('message', (message) => { - assert.ok(buf.equals(message)); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, buf); + assert.ok(isBinary); server.close(done); - wss.close(); }); }); }).timeout(4000); @@ -2477,7 +4189,7 @@ describe('WebSocket', () => { describe('Request headers', () => { it('adds the authorization header if the url has userinfo', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); const userinfo = 'test:testpass'; agent.addRequest = (req) => { @@ -2492,7 +4204,7 @@ describe('WebSocket', () => { }); it('honors the `auth` option', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); const auth = 'user:pass'; agent.addRequest = (req) => { @@ -2507,7 +4219,7 @@ describe('WebSocket', () => { }); it('favors the url userinfo over the `auth` option', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); const auth = 'foo:bar'; const userinfo = 'baz:qux'; @@ -2523,7 +4235,7 @@ describe('WebSocket', () => { }); it('adds custom headers', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); agent.addRequest = (req) => { assert.strictEqual(req.getHeader('cookie'), 'foo=bar'); @@ -2552,7 +4264,7 @@ describe('WebSocket', () => { }); it("doesn't add the origin header by default", (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); agent.addRequest = (req) => { assert.strictEqual(req.getHeader('origin'), undefined); @@ -2563,7 +4275,7 @@ describe('WebSocket', () => { }); it('honors the `origin` option (1/2)', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); agent.addRequest = (req) => { assert.strictEqual(req.getHeader('origin'), 'https://example.com:8000'); @@ -2577,7 +4289,7 @@ describe('WebSocket', () => { }); it('honors the `origin` option (2/2)', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); agent.addRequest = (req) => { assert.strictEqual( @@ -2587,17 +4299,48 @@ describe('WebSocket', () => { done(); }; - const ws = new WebSocket('ws://localhost', { - origin: 'https://example.com:8000', - protocolVersion: 8, - agent + const ws = new WebSocket('ws://localhost', { + origin: 'https://example.com:8000', + protocolVersion: 8, + agent + }); + }); + + it('honors the `finishRequest` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const host = `localhost:${wss.address().port}`; + const ws = new WebSocket(`ws://${host}`, { + finishRequest(req, ws) { + assert.ok(req instanceof http.ClientRequest); + assert.strictEqual(req.getHeader('host'), host); + assert.ok(ws instanceof WebSocket); + assert.strictEqual(req, ws._req); + + req.on('socket', (socket) => { + socket.on('connect', () => { + req.setHeader('Cookie', 'foo=bar'); + req.end(); + }); + }); + } + }); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + wss.close(done); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.cookie, 'foo=bar'); + ws.close(); }); }); }); describe('permessage-deflate', () => { it('is enabled by default', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); agent.addRequest = (req) => { assert.strictEqual( @@ -2611,7 +4354,7 @@ describe('WebSocket', () => { }); it('can be disabled', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); agent.addRequest = (req) => { assert.strictEqual( @@ -2628,7 +4371,7 @@ describe('WebSocket', () => { }); it('can send extension parameters', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); const value = 'permessage-deflate; server_no_context_takeover;' + @@ -2651,115 +4394,117 @@ describe('WebSocket', () => { }); }); - it('can send and receive text data', (done) => { + it('consumes all received data when connection is closed (1/2)', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: { threshold: 0 }, port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - perMessageDeflate: { threshold: 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('open', () => ws.send('hi', { compress: true })); - ws.on('message', (message) => { - assert.strictEqual(message, 'hi'); + 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.on('message', (message) => ws.send(message, { compress: true })); + ws.send('foo'); + ws.send('bar'); + ws.send('baz'); + ws.send('qux', () => ws._socket.end()); }); }); - it('can send and receive a `TypedArray`', (done) => { - const array = new Float32Array(5); - - for (let i = 0; i < array.length; i++) { - array[i] = i / 2; - } - + it('consumes all received data when connection is closed (2/2)', (done) => { const wss = new WebSocket.Server( { - perMessageDeflate: { threshold: 0 }, + perMessageDeflate: true, port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - perMessageDeflate: { threshold: 0 } - }); + const messageLengths = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send(array, { compress: true })); - ws.on('message', (message) => { - assert.ok(message.equals(Buffer.from(array.buffer))); - wss.close(done); - }); - } - ); + ws.on('open', () => { + ws._socket.prependListener('close', () => { + assert.strictEqual(ws._receiver._state, 5); + assert.strictEqual(ws._socket._readableState.length, 3); + }); - wss.on('connection', (ws) => { - ws.on('message', (message) => ws.send(message, { compress: true })); - }); - }); + const push = ws._socket.push; - it('can send and receive an `ArrayBuffer`', (done) => { - const array = new Float32Array(5); + // 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(); + }; - for (let i = 0; i < array.length; i++) { - array[i] = i / 2; - } + const payload1 = Buffer.alloc(highWaterMark - 1024); + const payload2 = Buffer.alloc(1); - const wss = new WebSocket.Server( - { - perMessageDeflate: { threshold: 0 }, - port: 0 - }, - () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - perMessageDeflate: { threshold: 0 } - }); + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; - ws.on('open', () => ws.send(array.buffer, { compress: true })); - ws.on('message', (message) => { - assert.ok(message.equals(Buffer.from(array.buffer))); - wss.close(done); - }); - } - ); + const list = [ + ...Sender.frame(payload1, { rsv1: false, ...opts }), + ...Sender.frame(payload2, { rsv1: true, ...opts }) + ]; - wss.on('connection', (ws) => { - ws.on('message', (message) => ws.send(message, { compress: true })); - }); - }); + for (let i = 0; i < 340; i++) { + list.push(list[list.length - 2], list[list.length - 1]); + } - it('consumes all received data when connection is closed abnormally', (done) => { - const wss = new WebSocket.Server( - { - perMessageDeflate: { threshold: 0 }, - port: 0 - }, - () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - const messages = []; + const data = Buffer.concat(list); + + assert.ok(data.length > highWaterMark); + + // This hack is used because there is no guarantee that more than + // `highWaterMark` bytes will be sent as a single TCP packet. + push.call(ws._socket, data); + + wss.clients + .values() + .next() + .value.send(payload2, { compress: false }); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + messageLengths.push(message.length); + }); - ws.on('message', (message) => messages.push(message)); ws.on('close', (code) => { assert.strictEqual(code, 1006); - assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); + assert.strictEqual(messageLengths.length, 343); + assert.strictEqual(messageLengths[0], highWaterMark - 1024); + assert.strictEqual(messageLengths[messageLengths.length - 1], 1); wss.close(done); }); } ); - - wss.on('connection', (ws) => { - ws.send('foo'); - ws.send('bar'); - ws.send('baz'); - ws.send('qux', () => ws._socket.end()); - }); }); it('handles a close frame received while compressing data', (done) => { @@ -2775,7 +4520,7 @@ describe('WebSocket', () => { ws.on('open', () => { ws._receiver.on('conclude', () => { - assert.ok(ws._sender._deflating); + assert.strictEqual(ws._sender._state, 1); }); ws.send('foo'); @@ -2789,14 +4534,15 @@ describe('WebSocket', () => { wss.on('connection', (ws) => { const messages = []; - ws.on('message', (message) => { - messages.push(message); + 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.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); wss.close(done); }); @@ -2815,22 +4561,23 @@ describe('WebSocket', () => { 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); - ws.on('message', (message) => { - if (messages.push(message) > 1) return; + if (messages.push(message.toString()) > 1) return; - ws.close(1000); + setImmediate(() => { + process.nextTick(() => { + assert.strictEqual(ws._receiver._state, 5); + ws.close(1000); + }); + }); }); ws.on('close', (code, reason) => { assert.deepStrictEqual(messages, ['', '', '', '']); assert.strictEqual(code, 1000); - assert.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); wss.close(done); }); } @@ -2844,29 +4591,184 @@ describe('WebSocket', () => { }); 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('can send a `Blob`', function (done) { + if (!hasBlob) return this.skip(); + + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + const messages = []; + + ws.on('open', () => { + ws.send(new Blob(['foo'])); + ws.send(new Blob(['bar'])); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + messages.push(message.toString()); + + if (messages.length === 2) { + assert.deepStrictEqual(messages, ['foo', 'bar']); + wss.close(done); + } + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message); + }); + }); + }); + 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.on('message', (message) => { - assert.strictEqual(message, 'hi'); + 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) => ws.send(message, { compress: true })); + 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 called = []; const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { perMessageDeflate: { threshold: 0 } }); @@ -2894,16 +4796,17 @@ describe('WebSocket', () => { 'The socket was closed while data was being compressed' ); }); - }); - - ws.on('close', () => { - assert.deepStrictEqual(called, [1, 2]); - wss.close(done); + ws.close(); }); } ); wss.on('connection', (ws) => { + ws.on('close', () => { + assert.deepStrictEqual(called, [1, 2]); + wss.close(done); + }); + ws._socket.end(); }); }); @@ -2950,19 +4853,23 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); const messages = []; - ws.on('message', (message) => { - if (messages.push(message) > 1) return; + 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(); + setImmediate(() => { + 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, ''); + assert.strictEqual(reason, EMPTY_BUFFER); wss.close(done); }); } @@ -2976,7 +4883,7 @@ describe('WebSocket', () => { }); }); - describe('Connection close edge cases', () => { + describe('Connection close', () => { it('closes cleanly after simultaneous errors (1/2)', (done) => { let clientCloseEventEmitted = false; let serverClientCloseEventEmitted = false; @@ -2994,7 +4901,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); clientCloseEventEmitted = true; if (serverClientCloseEventEmitted) wss.close(done); @@ -3022,7 +4929,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (clientCloseEventEmitted) wss.close(done); @@ -3048,7 +4955,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); clientCloseEventEmitted = true; if (serverClientCloseEventEmitted) wss.close(done); @@ -3080,7 +4987,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (clientCloseEventEmitted) wss.close(done); @@ -3088,5 +4995,59 @@ describe('WebSocket', () => { }); }); }); + + 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/wrapper.mjs b/wrapper.mjs new file mode 100644 index 000000000..7245ad15d --- /dev/null +++ b/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;