diff --git a/.eslintrc.yaml b/.eslintrc.yaml deleted file mode 100644 index 70f32c8d4..000000000 --- a/.eslintrc.yaml +++ /dev/null @@ -1,21 +0,0 @@ -env: - browser: true - es6: true - mocha: true - node: true -extends: - - eslint:recommended - - prettier -parserOptions: - ecmaVersion: 9 -plugins: - - prettier -rules: - no-console: off - no-var: error - prefer-const: error - prettier/prettier: error - quotes: - - error - - single - - avoidEscape: true diff --git a/.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 new file mode 100644 index 000000000..538662f24 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + - push + - pull_request + +permissions: {} + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + arch: + - x64 + node: + - 10 + - 12 + - 14 + - 16 + - 18 + - 20 + - 22 + - 24 + - 25 + os: + - macOS-latest + - ubuntu-latest + - windows-latest + include: + - arch: x86 + node: 10 + os: windows-latest + - arch: x86 + node: 12 + os: windows-latest + - arch: x86 + node: 14 + os: windows-latest + - arch: x86 + node: 16 + os: windows-latest + - arch: x86 + node: 20 + os: windows-latest + - arch: x86 + node: 22 + os: windows-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + architecture: ${{ matrix.arch }} + cache: npm + cache-dependency-path: ./package.json + - run: npm install + - run: npm run lint + if: + matrix.os == 'ubuntu-latest' && matrix.node == 22 && matrix.arch == + 'x64' + - run: npm test + - 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@v2 + with: + flag-name: + ${{ steps.get_job_id.outputs.job_id }} (Node.js ${{ matrix.node }} + ${{ matrix.arch }} on ${{ matrix.os }}) + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel: true + coverage: + needs: test + runs-on: ubuntu-latest + steps: + - uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + parallel-finished: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c87ee06ef..000000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -language: node_js -node_js: - - '14' - - '12' - - '10' - - '8' -os: - - linux - - osx - - windows -script: - - if [ "${TRAVIS_NODE_VERSION}" == "14" ] && [ "${TRAVIS_OS_NAME}" == linux ]; - then npm run lint; fi - - npm test -after_success: - - nyc report --reporter=text-lcov | coveralls 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 f36a354bb..21f10df10 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # 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/travis/websockets/ws/master.svg?logo=travis)](https://travis-ci.com/websockets/ws) -[![Windows x86 Build](https://img.shields.io/appveyor/ci/lpinca/ws/master.svg?logo=appveyor)](https://ci.appveyor.com/project/lpinca/ws) -[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg)](https://coveralls.io/github/websockets/ws) +[![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 server implementation. @@ -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 @@ -23,7 +22,8 @@ can use one of the many wrappers available on npm, like - [Protocol support](#protocol-support) - [Installing](#installing) - - [Opt-in for performance and spec compliance](#opt-in-for-performance-and-spec-compliance) + - [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) @@ -56,18 +56,39 @@ can use one of the many wrappers available on npm, like npm install ws ``` -### Opt-in for performance and spec compliance +### Opt-in for performance -There are 2 optional modules that can be installed along side with the ws -module. These modules are binary addons which improve certain operations. -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 as required by the spec. +``` +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); }); ``` @@ -395,32 +443,33 @@ the `X-Forwarded-For` header. ```js wss.on('connection', function connection(ws, req) { - const ip = req.headers['x-forwarded-for'].split(/\s*,\s*/)[0]; + 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 258ff59fd..fb492e834 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,25 +12,30 @@ 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 report all -vulnerabilities to the [Node Security Project](https://nodesecurity.io/). +Once we have acknowledged receipt of your report and confirmed the bug +ourselves, we will work with you to fix the vulnerability and publicly +acknowledge your responsible disclosure, if you wish. In addition to that, we +will create and publish a security advisory to +[GitHub Security Advisories](https://github.com/websockets/ws/security/advisories?state=published). ## History - 04 Jan 2016: [Buffer vulnerability](https://github.com/websockets/ws/releases/tag/1.0.1) - 08 Nov 2017: - [DoS vulnerability](https://github.com/websockets/ws/releases/tag/3.3.1) + [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 2217103dd..000000000 --- a/appveyor.yml +++ /dev/null @@ -1,18 +0,0 @@ -environment: - matrix: - - 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 84e348d9f..7d22a0480 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,97 +33,131 @@ - [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) - -## Class: WebSocket.Server +- [createWebSocketStream(websocket[, options])](#createwebsocketstreamwebsocket-options) +- [Environment variables](#environment-variables) + - [WS_NO_BUFFER_UTIL](#ws_no_buffer_util) + - [WS_NO_UTF_8_VALIDATE](#ws_no_utf_8_validate) +- [Error codes](#error-codes) + - [WS_ERR_EXPECTED_FIN](#ws_err_expected_fin) + - [WS_ERR_EXPECTED_MASK](#ws_err_expected_mask) + - [WS_ERR_INVALID_CLOSE_CODE](#ws_err_invalid_close_code) + - [WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH](#ws_err_invalid_control_payload_length) + - [WS_ERR_INVALID_OPCODE](#ws_err_invalid_opcode) + - [WS_ERR_INVALID_UTF8](#ws_err_invalid_utf8) + - [WS_ERR_UNEXPECTED_MASK](#ws_err_unexpected_mask) + - [WS_ERR_UNEXPECTED_RSV_1](#ws_err_unexpected_rsv_1) + - [WS_ERR_UNEXPECTED_RSV_2_3](#ws_err_unexpected_rsv_2_3) + - [WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH](#ws_err_unsupported_data_payload_length) + - [WS_ERR_UNSUPPORTED_MESSAGE_LENGTH](#ws_err_unsupported_message_length) + +## Class: WebSocketServer This class represents a WebSocket server. It extends the `EventEmitter`. -### new WebSocket.Server(options[, callback]) +### new WebSocketServer(options[, callback]) - `options` {Object} + - `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 standard but may negatively impact performance. + - `autoPong` {Boolean} Specifies whether or not to automatically send a pong + in response to a ping. Defaults to `true`. + - `backlog` {Number} The maximum length of the queue of pending connections. + - `clientTracking` {Boolean} Specifies whether or not to track clients. + - `closeTimeout` {Number} Duration in milliseconds to wait for a graceful + close after [`websocket.close()`][] is called. If the limit is reached, the + connection is forcibly terminated. Defaults to 30000. + - `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 of `port`, `server` or `noServer` must be -provided or an error is thrown. An HTTP server is automatically created, +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. - `req` {http.IncomingMessage} The client HTTP GET request. - - `secure` {Boolean} `true` if `req.connection.authorized` or - `req.connection.encrypted` is set. + - `secure` {Boolean} `true` if `req.socket.authorized` or + `req.socket.encrypted` is set. The return value (`Boolean`) of the function determines whether or not to accept the 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 @@ -132,19 +168,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' @@ -154,7 +190,7 @@ emitted independently. ### Event: 'connection' -- `socket` {WebSocket} +- `websocket` {WebSocket} - `request` {http.IncomingMessage} Emitted when the handshake is complete. `request` is the http GET request sent @@ -179,6 +215,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 @@ -190,35 +241,42 @@ 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}. +- `callback` {Function} Handle a HTTP upgrade request. When the HTTP server is created internally or when the HTTP server is passed via the `server` option, this method is called automatically. When operating in "noServer" mode, this method must be called manually. -If the upgrade is successful, the `callback` is called with a `WebSocket` object -as parameter. +If the upgrade is successful, the `callback` is called with two arguments: + +- `websocket` {WebSocket} A `WebSocket` object. +- `request` {http.IncomingMessage} The client HTTP GET request. ### server.shouldHandle(request) - `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. @@ -243,42 +301,83 @@ 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} + - `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. + - `autoPong` {Boolean} Specifies whether or not to automatically send a pong + in response to a ping. Defaults to `true`. + - `closeTimeout` {Number} Duration in milliseconds to wait for a graceful + close after [`websocket.close()`][] is called. If the limit is reached, the + connection is forcibly terminated. Defaults to 30000. + - `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 + +`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 + ``` -Note that `:` is the separator between the socket path and the URL path. If the -URL path is omitted +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 `/`. @@ -286,23 +385,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. +Emitted when an error occurs. Errors may have a `.code` property, matching one +of the string values defined below under [Error codes](#error-codes). ### Event: 'message' -- `data` {String|Buffer|ArrayBuffer|Buffer[]} +- `data` {ArrayBuffer|Blob|Buffer|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' @@ -312,13 +415,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' @@ -341,23 +457,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 @@ -367,18 +486,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} @@ -403,8 +527,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 @@ -413,25 +537,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` {Any} The data to send in the ping frame. +- `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` {Any} The data to send in the pong frame. +- `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 @@ -439,6 +575,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} @@ -448,30 +589,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` {Any} 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. +Forcibly close the connection. Internally, this calls [`socket.destroy()`][]. ### websocket.url @@ -479,7 +628,7 @@ Forcibly close the connection. 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` @@ -488,13 +637,83 @@ 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`. +## Environment variables + +### WS_NO_BUFFER_UTIL + +When set to a non-empty value, prevents the optional `bufferutil` dependency +from being required. + +### WS_NO_UTF_8_VALIDATE + +When set to a non-empty value, prevents the optional `utf-8-validate` dependency +from being required. + +## Error codes + +Errors emitted by the websocket may have a `.code` property, describing the +specific type of error that has occurred: + +### WS_ERR_EXPECTED_FIN + +A WebSocket frame was received with the FIN bit not set when it was expected. + +### WS_ERR_EXPECTED_MASK + +An unmasked WebSocket frame was received by a WebSocket server. + +### WS_ERR_INVALID_CLOSE_CODE + +A WebSocket close frame was received with an invalid close code. + +### WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH + +A control frame with an invalid payload length was received. + +### WS_ERR_INVALID_OPCODE + +A WebSocket frame was received with an invalid opcode. + +### WS_ERR_INVALID_UTF8 + +A text or close frame was received containing invalid UTF-8 data. + +### WS_ERR_UNEXPECTED_MASK + +A masked WebSocket frame was received by a WebSocket client. + +### WS_ERR_UNEXPECTED_RSV_1 + +A WebSocket frame was received with the RSV1 bit set unexpectedly. + +### WS_ERR_UNEXPECTED_RSV_2_3 + +A WebSocket frame was received with the RSV2 or RSV3 bit set unexpectedly. + +### WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH + +A data frame was received with a length longer than the max supported length +(2^53 - 1, due to JavaScript language limitations). + +### WS_ERR_UNSUPPORTED_MESSAGE_LENGTH + +A message was received with a length longer than the maximum supported length, +as configured by the `maxPayload` option. + [concurrency-limit]: https://github.com/websockets/ws/issues/1202 [duplex-options]: https://nodejs.org/api/stream.html#stream_new_stream_duplex_options -[http.request()]: +[`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 +[`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 +[`websocket.close()`]: #websocketclosecode-reason [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..69b2fe3c4 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -1,10 +1,19 @@ '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, + CLOSE_TIMEOUT: 30000, + 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 d38e1f2d7..fea4cbc52 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -1,104 +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. @@ -110,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 {Object} options An options object specifies characteristics about + * @param {(Function|Object)} handler The listener to add + * @param {Object} [options] An options object specifies characteristics about * the event listener - * @param {Boolean} options.once 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. + * @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); } }, @@ -160,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/limiter.js b/lib/limiter.js index c5e600958..3fd35784e 100644 --- a/lib/limiter.js +++ b/lib/limiter.js @@ -11,8 +11,8 @@ class Limiter { /** * Creates a new `Limiter`. * - * @param {Number} concurrency The maximum number of jobs allowed to run - * concurrently + * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed + * to run concurrently */ constructor(concurrency) { this[kDone] = () => { diff --git a/lib/permessage-deflate.js b/lib/permessage-deflate.js index 7bb7c98d7..41ff70e27 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'); @@ -29,24 +30,26 @@ class PerMessageDeflate { /** * Creates a PerMessageDeflate instance. * - * @param {Object} options Configuration options - * @param {Boolean} options.serverNoContextTakeover Request/accept disabling - * of server context takeover - * @param {Boolean} options.clientNoContextTakeover Advertise/acknowledge - * disabling of client context takeover - * @param {(Boolean|Number)} options.serverMaxWindowBits Request/confirm the - * use of a custom server window size - * @param {(Boolean|Number)} options.clientMaxWindowBits Advertise support + * @param {Object} [options] Configuration options + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support * for, or request, a custom client window size - * @param {Object} options.zlibDeflateOptions Options to pass to zlib on deflate - * @param {Object} options.zlibInflateOptions Options to pass to zlib on inflate - * @param {Number} options.threshold Size (in bytes) below which messages - * should not be compressed - * @param {Number} options.concurrencyLimit The number of concurrent calls to - * zlib - * @param {Boolean} isServer Create the instance in either server or client - * mode - * @param {Number} maxPayload The maximum allowed message length + * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ + * acknowledge disabling of client context takeover + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib + * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the + * use of a custom server window size + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed if context takeover is disabled + * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on + * deflate + * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on + * inflate + * @param {Boolean} [isServer=false] Create the instance in either server or + * client mode + * @param {Number} [maxPayload=0] The maximum allowed message length */ constructor(options, isServer, maxPayload) { this._maxPayload = maxPayload | 0; @@ -311,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 @@ -374,12 +377,16 @@ class PerMessageDeflate { this._inflate[kTotalLength] ); - if (fin && this.params[`${endpoint}_no_context_takeover`]) { + if (this._inflate._readableState.endEmitted) { this._inflate.close(); this._inflate = null; } else { this._inflate[kTotalLength] = 0; this._inflate[kBuffers] = []; + + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._inflate.reset(); + } } callback(null, data); @@ -389,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 @@ -412,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); } @@ -438,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 @@ -446,12 +448,11 @@ class PerMessageDeflate { // this._deflate[kCallback] = null; + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { - this._deflate.close(); - this._deflate = null; - } else { - this._deflate[kTotalLength] = 0; - this._deflate[kBuffers] = []; + this._deflate.reset(); } callback(null, data); @@ -490,8 +491,17 @@ function inflateOnData(chunk) { } this[kError] = new RangeError('Max payload size exceeded'); + this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'; this[kError][kStatusCode] = 1009; this.removeListener('data', inflateOnData); + + // + // The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the + // fact that in Node.js versions prior to 13.10.0, the callback for + // `zlib.flush()` is not called if `zlib.close()` is used. Utilizing + // `zlib.reset()` ensures that either the callback is invoked or an error is + // emitted. + // this.reset(); } @@ -507,6 +517,12 @@ function inflateOnError(err) { // closed when an error is emitted. // this[kPerMessageDeflate]._inflate = null; + + if (this[kError]) { + this[kCallback](this[kError]); + return; + } + err[kStatusCode] = 1007; this[kCallback](err); } diff --git a/lib/receiver.js b/lib/receiver.js index d762393ee..54d9b4fad 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -12,36 +12,51 @@ 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. * - * @extends stream.Writable + * @extends Writable */ class Receiver extends Writable { /** * Creates a Receiver instance. * - * @param {String} binaryType The type for binary data - * @param {Object} extensions An object containing the negotiated extensions - * @param {Boolean} isServer Specifies whether to operate in client or server - * mode - * @param {Number} maxPayload 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,15 +191,31 @@ class Receiver extends Writable { const buf = this.consume(2); if ((buf[0] & 0x30) !== 0x00) { - this._loop = false; - return error(RangeError, 'RSV2 and RSV3 must be clear', true, 1002); + 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(RangeError, 'RSV1 must be clear', true, 1002); + 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; @@ -184,46 +224,100 @@ class Receiver extends Writable { if (this._opcode === 0x00) { if (compressed) { - this._loop = false; - return error(RangeError, 'RSV1 must be clear', true, 1002); + 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(RangeError, 'invalid opcode 0', true, 1002); + 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(RangeError, `invalid opcode ${this._opcode}`, true, 1002); + 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(RangeError, 'FIN must be set', true, 1002); + 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(RangeError, 'RSV1 must be clear', true, 1002); + 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 + 1002, + 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' ); + + cb(error); + return; } } else { - this._loop = false; - return error(RangeError, `invalid opcode ${this._opcode}`, true, 1002); + 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; @@ -231,42 +325,58 @@ class Receiver extends Writable { if (this._isServer) { if (!this._masked) { - this._loop = false; - return error(RangeError, 'MASK must be set', true, 1002); + 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(RangeError, 'MASK must be clear', true, 1002); + 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; @@ -280,31 +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 + 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(RangeError, 'Max payload size exceeded', false, 1009); + const error = this.createError( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' + ); + + cb(error); + return; } } @@ -331,7 +452,6 @@ class Receiver extends Writable { * Reads data bytes. * * @param {Function} cb Callback - * @return {(Error|RangeError|undefined)} A possible error * @private */ getData(cb) { @@ -344,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; @@ -357,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); } /** @@ -383,62 +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) + 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.emit('message', data); + 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 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); - } + 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); + + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); - this.emit('message', buf.toString()); + 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); + }); + } + } } /** @@ -448,60 +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); } else { const code = data.readUInt16BE(0); if (!isValidStatusCode(code)) { - return error(RangeError, `invalid status code ${code}`, true, 1002); + 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(Error, 'invalid UTF-8 sequence', true, 1007); + 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 {(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 - * @return {(Error|RangeError)} The error - * @private - */ -function error(ErrorCtor, message, prefix, statusCode) { - const err = new ErrorCtor( - prefix ? `Invalid WebSocket frame: ${message}` : message - ); - - Error.captureStackTrace(err, error); - err[kStatusCode] = statusCode; - return err; -} diff --git a/lib/sender.js b/lib/sender.js index 3ea2fc147..a8b1da3a9 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -1,13 +1,24 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */ + 'use strict'; +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. @@ -16,48 +27,116 @@ class Sender { /** * Creates a Sender instance. * - * @param {net.Socket} socket The connection socket - * @param {Object} extensions An object containing the negotiated extensions + * @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 {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 Specifies whether `data` can be modified - * @param {Boolean} options.fin Specifies whether or not to set the FIN bit - * @param {Boolean} options.mask Specifies whether or not to mask `data` - * @param {Boolean} options.rsv1 Specifies whether or not to set the RSV1 bit - * @return {Buffer[]} The framed data as a list of `Buffer` instances + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @return {(Buffer|String)[]} The framed data * @public */ static frame(data, options) { - 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; + } + + mask[0] = randomPool[randomPoolPointer++]; + mask[1] = randomPool[randomPoolPointer++]; + mask[2] = randomPool[randomPoolPointer++]; + mask[3] = randomPool[randomPoolPointer++]; + } - if (data.length >= 65536) { + 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; @@ -65,38 +144,38 @@ 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]; } /** * Sends a close message to the other peer. * - * @param {(Number|undefined)} code The status code component of the body - * @param {String} data The message component of the body - * @param {Boolean} mask Specifies whether or not to mask the message - * @param {Function} cb Callback + * @param {Number} [code] The status code component of the body + * @param {(String|Buffer)} [data] The message component of the body + * @param {Boolean} [mask=false] Specifies whether or not to mask the message + * @param {Function} [cb] Callback * @public */ close(code, data, mask, cb) { @@ -106,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 { @@ -118,147 +197,184 @@ 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 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. * * @param {*} data The message to send - * @param {Boolean} mask Specifies whether or not to mask `data` - * @param {Function} cb Callback + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback * @public */ ping(data, mask, cb) { - 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 Specifies whether or not to mask `data` - * @param {Boolean} readOnly 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. * * @param {*} data The message to send - * @param {Boolean} mask Specifies whether or not to mask `data` - * @param {Function} cb Callback + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback * @public */ pong(data, mask, cb) { - 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 Specifies whether or not to mask `data` - * @param {Boolean} readOnly 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 Specifies whether or not to compress `data` - * @param {Boolean} options.binary Specifies whether `data` is binary or text - * @param {Boolean} options.fin Specifies whether the fragment is the last one - * @param {Boolean} options.mask Specifies whether or not to mask `data` - * @param {Function} cb Callback + * @param {Boolean} [options.binary=false] Specifies whether `data` is binary + * or text + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` + * @param {Boolean} [options.fin=false] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Function} [cb] Callback * @public */ send(data, options, cb) { - const 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 { @@ -268,46 +384,115 @@ 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 {Blob} blob The blob + * @param {Boolean} [compress=false] Specifies whether or not to compress + * 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} data The message to send - * @param {Boolean} compress Specifies whether or not to compress `data` + * @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 Specifies whether `data` can be modified - * @param {Boolean} options.fin Specifies whether or not to set the FIN bit - * @param {Boolean} options.mask Specifies whether or not to mask `data` - * @param {Boolean} options.rsv1 Specifies whether or not to set the RSV1 bit - * @param {Function} cb Callback + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback * @private */ dispatch(data, compress, options, cb) { @@ -318,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(); @@ -351,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)); } } @@ -366,15 +544,15 @@ class Sender { * @private */ enqueue(params) { - this._bufferedBytes += params[1].length; + this._bufferedBytes += params[3][kByteLength]; this._queue.push(params); } /** * Sends a frame. * - * @param {Buffer[]} list The frame to send - * @param {Function} cb Callback + * @param {(Buffer | String)[]} list The frame to send + * @param {Function} [cb] Callback * @private */ sendFrame(list, cb) { @@ -390,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 eb03a4993..4c58c911b 100644 --- a/lib/stream.js +++ b/lib/stream.js @@ -1,11 +1,13 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */ 'use strict'; +const WebSocket = require('./websocket'); const { Duplex } = require('stream'); /** * Emits the `'close'` event on a stream. * - * @param {stream.Duplex} The stream. + * @param {Duplex} stream The stream. * @private */ function emitClose(stream) { @@ -42,26 +44,12 @@ function duplexOnError(err) { * Wraps a `WebSocket` in a duplex stream. * * @param {WebSocket} ws The `WebSocket` to wrap - * @param {Object} options The options for the `Duplex` constructor - * @return {stream.Duplex} The duplex stream + * @param {Object} [options] The options for the `Duplex` constructor + * @return {Duplex} The duplex stream * @public */ function createWebSocketStream(ws, options) { - let resumeOnReceiverDrain = true; - - function receiverOnDrain() { - if (resumeOnReceiverDrain) ws._socket.resume(); - } - - if (ws.readyState === ws.CONNECTING) { - ws.once('open', function open() { - ws._receiver.removeAllListeners('drain'); - ws._receiver.on('drain', receiverOnDrain); - }); - } else { - ws._receiver.removeAllListeners('drain'); - ws._receiver.on('drain', receiverOnDrain); - } + let terminateOnDestroy = true; const duplex = new Duplex({ ...options, @@ -71,16 +59,26 @@ 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) { if (duplex.destroyed) return; + // Prevent `ws.terminate()` from being called by `duplex._destroy()`. + // + // - If the `'error'` event is emitted before the `'open'` event, then + // `ws.terminate()` is a noop as no socket is assigned. + // - Otherwise, the error is re-emitted by the listener of the `'error'` + // event of the `Receiver` object. The listener already closes the + // connection by calling `ws.close()`. This allows a close frame to be + // sent to the other peer. If `ws.terminate()` is called right after this, + // then the close frame might not be sent. + terminateOnDestroy = false; duplex.destroy(err); }); @@ -108,7 +106,8 @@ function createWebSocketStream(ws, options) { if (!called) callback(err); process.nextTick(emitClose, duplex); }); - ws.terminate(); + + if (terminateOnDestroy) ws.terminate(); }; duplex._final = function (callback) { @@ -140,10 +139,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 32db5a570..4a2e68d51 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -1,15 +1,31 @@ 'use strict'; -try { - const isValidUTF8 = require('utf-8-validate'); - - exports.isValidUTF8 = - typeof isValidUTF8 === 'object' - ? isValidUTF8.Validation.isValidUTF8 // utf-8-validate@<3.0.0 - : isValidUTF8; -} catch (e) /* istanbul ignore next */ { - exports.isValidUTF8 = () => true; -} +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. @@ -18,7 +34,7 @@ try { * @return {Boolean} `true` if the status code is valid, else `false` * @public */ -exports.isValidStatusCode = (code) => { +function isValidStatusCode(code) { return ( (code >= 1000 && code <= 1014 && @@ -27,4 +43,110 @@ exports.isValidStatusCode = (code) => { code !== 1006) || (code >= 3000 && code <= 4999) ); +} + +/** + * Checks if a given buffer contains only correct UTF-8. + * Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by + * Markus Kuhn. + * + * @param {Buffer} buf The buffer to check + * @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false` + * @public + */ +function _isValidUTF8(buf) { + const len = buf.length; + let i = 0; + + while (i < len) { + if ((buf[i] & 0x80) === 0) { + // 0xxxxxxx + i++; + } else if ((buf[i] & 0xe0) === 0xc0) { + // 110xxxxx 10xxxxxx + if ( + i + 1 === len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i] & 0xfe) === 0xc0 // Overlong + ) { + return false; + } + + i += 2; + } else if ((buf[i] & 0xf0) === 0xe0) { + // 1110xxxx 10xxxxxx 10xxxxxx + if ( + i + 2 >= len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong + (buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF) + ) { + return false; + } + + i += 3; + } else if ((buf[i] & 0xf8) === 0xf0) { + // 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + if ( + i + 3 >= len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i + 3] & 0xc0) !== 0x80 || + (buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong + (buf[i] === 0xf4 && buf[i + 1] > 0x8f) || + buf[i] > 0xf4 // > U+10FFFF + ) { + return false; + } + + i += 4; + } else { + return false; + } + } + + return true; +} + +/** + * 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') + ); +} + +module.exports = { + isBlob, + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars }; + +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 0d3f39592..75e04c1d6 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -1,16 +1,24 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */ + 'use strict'; const EventEmitter = require('events'); +const http = require('http'); +const { Duplex } = require('stream'); const { createHash } = require('crypto'); -const { createServer, STATUS_CODES } = require('http'); +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 { CLOSE_TIMEOUT, GUID, kWebSocket } = require('./constants'); const keyRegex = /^[+/0-9A-Za-z]{22}==$/; +const RUNNING = 0; +const CLOSING = 1; +const CLOSED = 2; + /** * Class representing a WebSocket server. * @@ -21,30 +29,48 @@ class WebSocketServer extends EventEmitter { * Create a `WebSocketServer` instance. * * @param {Object} options Configuration options - * @param {Number} options.backlog The maximum length of the queue of pending - * connections - * @param {Boolean} options.clientTracking Specifies whether or not to track - * clients - * @param {Function} options.handleProtocols A hook to handle protocols - * @param {String} options.host The hostname where to bind the server - * @param {Number} options.maxPayload The maximum allowed message size - * @param {Boolean} options.noServer Enable no server mode - * @param {String} options.path Accept only connections matching this path - * @param {(Boolean|Object)} options.perMessageDeflate Enable/disable + * @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 + * track clients + * @param {Number} [options.closeTimeout=30000] Duration in milliseconds to + * wait for the closing handshake to finish after `websocket.close()` is + * called + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable * permessage-deflate - * @param {Number} options.port The port where to bind the server - * @param {http.Server} options.server A pre-created HTTP/S server to use - * @param {Function} options.verifyClient A hook to reject connections - * @param {Function} callback A listener for the `listening` event + * @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, + closeTimeout: CLOSE_TIMEOUT, verifyClient: null, noServer: false, backlog: null, // use default (511 as implemented in net.js) @@ -52,18 +78,24 @@ class WebSocketServer extends EventEmitter { host: null, path: null, port: null, + WebSocket, ...options }; - if (options.port == null && !options.server && !options.noServer) { + if ( + (options.port == null && !options.server && !options.noServer) || + (options.port != null && (options.server || options.noServer)) || + (options.server && options.noServer) + ) { throw new TypeError( - 'One of the "port", "server", or "noServer" options must be specified' + 'One and only one of the "port", "server", or "noServer" options ' + + 'must be specified' ); } if (options.port != null) { - this._server = createServer((req, res) => { - const body = STATUS_CODES[426]; + this._server = http.createServer((req, res) => { + const body = http.STATUS_CODES[426]; res.writeHead(426, { 'Content-Length': body.length, @@ -82,20 +114,25 @@ class WebSocketServer extends EventEmitter { } if (this._server) { + const emitConnection = this.emit.bind(this, 'connection'); + this._removeListeners = addListeners(this._server, { listening: this.emit.bind(this, 'listening'), error: this.emit.bind(this, 'error'), upgrade: (req, socket, head) => { - this.handleUpgrade(req, socket, head, (ws) => { - this.emit('connection', ws, req); - }); + this.handleUpgrade(req, socket, head, emitConnection); } }); } 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; } /** @@ -117,37 +154,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')); + }); + } - // - // Terminate all associated clients. - // - if (this.clients) { - for (const client of this.clients) client.terminate(); + process.nextTick(emitClose, this); + return; } - const server = this._server; + if (cb) this.once('close', cb); + + if (this._state === CLOSING) return; + this._state = CLOSING; + + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } + + if (this.clients) { + if (!this.clients.size) { + process.nextTick(emitClose, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(emitClose, this); + } + } else { + const server = this._server; - 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(() => this.emit('close')); - return; - } + server.close(() => { + emitClose(this); + }); } - - process.nextTick(emitClose, this); } /** @@ -172,7 +230,7 @@ class WebSocketServer extends EventEmitter { * Handle a HTTP Upgrade request. * * @param {http.IncomingMessage} req The request object - * @param {net.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 @@ -180,25 +238,61 @@ 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 !== 13 && version !== 8) { + const message = 'Missing or invalid Sec-WebSocket-Version header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, { + 'Sec-WebSocket-Version': '13, 8' + }); + 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, @@ -206,14 +300,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; } } @@ -224,7 +321,7 @@ class WebSocketServer extends EventEmitter { const info = { origin: req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`], - secure: !!(req.connection.authorized || req.connection.encrypted), + secure: !!(req.socket.authorized || req.socket.encrypted), req }; @@ -234,7 +331,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; } @@ -242,22 +347,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} 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. // @@ -270,6 +376,8 @@ class WebSocketServer extends EventEmitter { ); } + if (this._state > RUNNING) return abortHandshake(socket, 503); + const digest = createHash('sha1') .update(key + GUID) .digest('base64'); @@ -281,30 +389,25 @@ class WebSocketServer extends EventEmitter { `Sec-WebSocket-Accept: ${digest}` ]; - const ws = new WebSocket(null); - let protocol = req.headers['sec-websocket-protocol']; - - if (protocol) { - protocol = protocol.trim().split(/ *, */); + 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}`); - ws.protocol = protocol; + ws._protocol = protocol; } } 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}`); @@ -319,14 +422,24 @@ 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); + cb(ws, req); } } @@ -338,7 +451,8 @@ module.exports = WebSocketServer; * * @param {EventEmitter} server The event emitter * @param {Object.} map The listeners to add - * @return {Function} A function that will remove the added listeners when called + * @return {Function} A function that will remove the added listeners when + * called * @private */ function addListeners(server, map) { @@ -358,11 +472,12 @@ function addListeners(server, map) { * @private */ function emitClose(server) { + server._state = CLOSED; server.emit('close'); } /** - * Handle premature socket errors. + * Handle socket errors. * * @private */ @@ -373,32 +488,67 @@ function socketOnError() { /** * Close the connection when preconditions are not fulfilled. * - * @param {net.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 || 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} ${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 + ); +} + +/** + * Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least + * one listener for it, otherwise call `abortHandshake()`. + * + * @param {WebSocketServer} server The WebSocket server + * @param {http.IncomingMessage} req The request object + * @param {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] The HTTP response headers + * @private + */ +function abortHandshakeOrEmitwsClientError( + server, + req, + socket, + code, + message, + headers +) { + if (server.listenerCount('wsClientError')) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + + server.emit('wsClientError', err, socket, req); + } else { + abortHandshake(socket, code, message, headers); + } } diff --git a/lib/websocket.js b/lib/websocket.js index ce6f34caf..0da2949cf 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, + CLOSE_TIMEOUT, 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 kAborted = Symbol('kAborted'); const protocolVersions = [8, 13]; -const closeTimeout = 30 * 1000; +const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; +const subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; /** * Class representing a WebSocket. @@ -36,23 +47,24 @@ class WebSocket extends EventEmitter { /** * Create a new `WebSocket`. * - * @param {(String|url.URL)} address The URL to which to connect - * @param {(String|String[])} protocols The subprotocols - * @param {Object} options Connection options + * @param {(String|URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options */ constructor(address, protocols, options) { super(); - this.readyState = WebSocket.CONNECTING; - this.protocol = ''; - this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; this._closeFrameReceived = false; this._closeFrameSent = false; - this._closeMessage = ''; + this._closeMessage = EMPTY_BUFFER; this._closeTimer = null; - this._closeCode = 1006; + this._errorEmitted = false; this._extensions = {}; + this._paused = false; + this._protocol = ''; + this._readyState = WebSocket.CONNECTING; this._receiver = null; this._sender = null; this._socket = null; @@ -62,36 +74,28 @@ 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._closeTimeout = options.closeTimeout; this._isServer = true; } } - get CONNECTING() { - return WebSocket.CONNECTING; - } - get CLOSING() { - return WebSocket.CLOSING; - } - get CLOSED() { - return WebSocket.CLOSED; - } - get OPEN() { - return WebSocket.OPEN; - } - /** - * This deviates from the WHATWG interface since ws doesn't support the - * required default "blob" type (instead we define a custom "nodebuffer" - * type). + * For historical reasons, the custom "nodebuffer" type is used by the default + * instead of "blob". * * @type {String} */ @@ -126,27 +130,100 @@ 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 null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + + /** + * @type {String} + */ + get url() { + return this._url; + } + /** * Set up the socket and the internal resources. * - * @param {net.Socket} 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 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); @@ -156,8 +233,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); @@ -166,7 +248,7 @@ class WebSocket extends EventEmitter { socket.on('end', socketOnEnd); socket.on('error', socketOnError); - this.readyState = WebSocket.OPEN; + this._readyState = WebSocket.OPEN; this.emit('open'); } @@ -177,7 +259,7 @@ class WebSocket extends EventEmitter { */ emitClose() { if (!this._socket) { - this.readyState = WebSocket.CLOSED; + this._readyState = WebSocket.CLOSED; this.emit('close', this._closeCode, this._closeMessage); return; } @@ -187,7 +269,7 @@ class WebSocket extends EventEmitter { } this._receiver.removeAllListeners(); - this.readyState = WebSocket.CLOSED; + this._readyState = WebSocket.CLOSED; this.emit('close', this._closeCode, this._closeMessage); } @@ -206,23 +288,31 @@ class WebSocket extends EventEmitter { * - - - - -|fin|<---------------------+ * +---+ * - * @param {Number} code Status code explaining why the connection is closing - * @param {String} data A string explaining why the connection is closing + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|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) { - if (this._closeFrameSent && this._closeFrameReceived) this._socket.end(); + if ( + this._closeFrameSent && + (this._closeFrameReceived || this._receiver._writableState.errorEmitted) + ) { + this._socket.end(); + } + return; } - this.readyState = WebSocket.CLOSING; + this._readyState = WebSocket.CLOSING; this._sender.close(code, data, !this._isServer, (err) => { // // This error is handled by the `'error'` listener on the socket. We only @@ -231,24 +321,41 @@ class WebSocket extends EventEmitter { if (err) return; this._closeFrameSent = true; - if (this._closeFrameReceived) this._socket.end(); + + if ( + this._closeFrameReceived || + this._receiver._writableState.errorEmitted + ) { + this._socket.end(); + } }); - // - // Specify a timeout for the closing handshake to complete. - // - this._closeTimer = setTimeout( - this._socket.destroy.bind(this._socket), - closeTimeout - ); + setCloseTimer(this); + } + + /** + * Pause the socket. + * + * @public + */ + pause() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = true; + this._socket.pause(); } /** * Send a ping. * - * @param {*} data The data to send - * @param {Boolean} mask Indicates whether or not to mask `data` - * @param {Function} cb Callback which is executed when the ping is sent + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent * @public */ ping(data, mask, cb) { @@ -278,9 +385,9 @@ class WebSocket extends EventEmitter { /** * Send a pong. * - * @param {*} data The data to send - * @param {Boolean} mask Indicates whether or not to mask `data` - * @param {Function} cb Callback which is executed when the pong is sent + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent * @public */ pong(data, mask, cb) { @@ -307,17 +414,36 @@ 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 + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress * `data` - * @param {Boolean} options.binary Specifies whether `data` is binary or text - * @param {Boolean} options.fin Specifies whether the fragment is the last one - * @param {Boolean} options.mask Specifies whether or not to mask `data` - * @param {Function} cb Callback which is executed when data is written out + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out * @public */ send(data, options, cb) { @@ -361,18 +487,99 @@ 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) { - this.readyState = WebSocket.CLOSING; + this._readyState = WebSocket.CLOSING; this._socket.destroy(); } } } -readyStates.forEach((readyState, i) => { - WebSocket[readyState] = i; +/** + * @constant {Number} CONNECTING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +[ + 'binaryType', + 'bufferedAmount', + 'extensions', + 'isPaused', + 'protocol', + 'readyState', + 'url' +].forEach((property) => { + Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); }); // @@ -381,35 +588,27 @@ readyStates.forEach((readyState, i) => { // ['open', 'error', 'close', 'message'].forEach((method) => { Object.defineProperty(WebSocket.prototype, `on${method}`, { - /** - * Return the listener of the event. - * - * @return {(Function|undefined)} The event listener or `undefined` - * @public - */ + 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; }, - /** - * Add a listener for the event. - * - * @param {Function} listener The listener to add - * @public - */ - 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 + }); } }); }); @@ -423,41 +622,63 @@ module.exports = WebSocket; * Initialize a WebSocket client. * * @param {WebSocket} websocket The client to initialize - * @param {(String|url.URL)} address The URL to which to connect - * @param {String} protocols The subprotocols - * @param {Object} options Connection options - * @param {(Boolean|Object)} options.perMessageDeflate Enable/disable - * permessage-deflate - * @param {Number} options.handshakeTimeout Timeout in milliseconds for the + * @param {(String|URL)} address The URL to which to connect + * @param {Array} protocols The subprotocols + * @param {Object} [options] Connection 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.closeTimeout=30000] Duration in milliseconds to wait + * for the closing handshake to finish after `websocket.close()` is called + * @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 Value of the `Sec-WebSocket-Version` - * header - * @param {String} options.origin Value of the `Origin` or + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Number} [options.maxRedirects=10] The maximum number of redirects + * allowed + * @param {String} [options.origin] Value of the `Origin` or * `Sec-WebSocket-Origin` header - * @param {Number} options.maxPayload The maximum allowed message size - * @param {Boolean} options.followRedirects Whether or not to follow redirects - * @param {Number} options.maxRedirects The maximum number of redirects allowed + * @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, + closeTimeout: CLOSE_TIMEOUT, 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; + websocket._closeTimeout = opts.closeTimeout; + if (!protocolVersions.includes(opts.protocolVersion)) { throw new RangeError( `Unsupported protocol version: ${opts.protocolVersion} ` + @@ -469,37 +690,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:'; + } + + websocket._url = parsedUrl.href; - if (!parsedUrl.host && (!isUnixSocket || !parsedUrl.pathname)) { - throw new Error(`Invalid URL: ${websocket.url}`); + 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; @@ -514,8 +764,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) { @@ -528,14 +792,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', () => { @@ -544,12 +880,10 @@ function initAsClient(websocket, address, protocols, options) { } req.on('error', (err) => { - if (websocket._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) => { @@ -569,7 +903,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)) { @@ -585,13 +927,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'); @@ -602,15 +951,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) { @@ -618,30 +968,83 @@ function initAsClient(websocket, address, protocols, options) { return; } - if (serverProt) websocket.protocol = serverProt; + if (serverProt) websocket._protocol = serverProt; + + const secWebSocketExtensions = res.headers['sec-websocket-extensions']; + + if (secWebSocketExtensions !== undefined) { + if (!perMessageDeflate) { + const message = + 'Server sent a Sec-WebSocket-Extensions header but no extension ' + + 'was requested'; + abortHandshake(websocket, socket, message); + return; + } + + let extensions; - if (perMessageDeflate) { try { - const extensions = parse(res.headers['sec-websocket-extensions']); + extensions = parse(secWebSocketExtensions); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } - if (extensions[PerMessageDeflate.extensionName]) { - perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); - websocket._extensions[ - PerMessageDeflate.extensionName - ] = perMessageDeflate; - } + const extensionNames = Object.keys(extensions); + + if ( + extensionNames.length !== 1 || + extensionNames[0] !== PerMessageDeflate.extensionName + ) { + const message = 'Server indicated an extension that was not requested'; + abortHandshake(websocket, socket, message); + return; + } + + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); } catch (err) { - abortHandshake( - websocket, - socket, - 'Invalid Sec-WebSocket-Extensions header' - ); + 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(); } /** @@ -667,7 +1070,7 @@ function tlsConnect(options) { options.path = undefined; if (!options.servername && options.servername !== '') { - options.servername = options.host; + options.servername = net.isIP(options.host) ? '' : options.host; } return tls.connect(options); @@ -677,21 +1080,31 @@ function tlsConnect(options) { * Abort the handshake and emit an error. * * @param {WebSocket} websocket The WebSocket instance - * @param {(http.ClientRequest|net.Socket)} stream The request to abort or the - * socket to destroy + * @param {(http.ClientRequest|net.Socket|tls.Socket)} stream The request to + * abort or the socket to destroy * @param {String} message The error message * @private */ function abortHandshake(websocket, stream, message) { - websocket.readyState = WebSocket.CLOSING; + websocket._readyState = WebSocket.CLOSING; const err = new Error(message); Error.captureStackTrace(err, abortHandshake); if (stream.setHeader) { + stream[kAborted] = true; stream.abort(); - stream.once('abort', websocket.emitClose.bind(websocket)); - websocket.emit('error', err); + + if (stream.socket && !stream.socket.destroyed) { + // + // On Node.js >= 14.3.0 `request.abort()` does not destroy the socket if + // called after the request completed. See + // https://github.com/websockets/ws/issues/1869. + // + stream.socket.destroy(); + } + + process.nextTick(emitErrorAndClose, websocket, err); } else { stream.destroy(err); stream.once('error', websocket.emit.bind(websocket, 'error')); @@ -704,13 +1117,13 @@ function abortHandshake(websocket, stream, message) { * when the `readyState` attribute is `CLOSING` or `CLOSED`. * * @param {WebSocket} websocket The WebSocket instance - * @param {*} data The data to send - * @param {Function} cb Callback + * @param {*} [data] The data to send + * @param {Function} [cb] Callback * @private */ 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 @@ -727,7 +1140,7 @@ function sendAfterClose(websocket, data, cb) { `WebSocket is not open: readyState ${websocket.readyState} ` + `(${readyStates[websocket.readyState]})` ); - cb(err); + process.nextTick(cb, err); } } @@ -735,19 +1148,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); } @@ -758,7 +1173,9 @@ function receiverOnConclude(code, reason) { * @private */ function receiverOnDrain() { - this[kWebSocket]._socket.resume(); + const websocket = this[kWebSocket]; + + if (!websocket.isPaused) websocket._socket.resume(); } /** @@ -770,12 +1187,22 @@ function receiverOnDrain() { function receiverOnError(err) { const websocket = this[kWebSocket]; - websocket._socket.removeListener('data', socketOnData); + if (websocket._socket[kWebSocket] !== undefined) { + websocket._socket.removeListener('data', socketOnData); - websocket.readyState = WebSocket.CLOSING; - websocket._closeCode = err[kStatusCode]; - websocket.emit('error', err); - websocket._socket.destroy(); + // + // 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); + } } /** @@ -790,11 +1217,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); } /** @@ -806,7 +1234,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); } @@ -821,7 +1249,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), + websocket._closeTimeout + ); +} + +/** + * The listener of the socket `'close'` event. * * @private */ @@ -829,24 +1308,33 @@ function socketOnClose() { const websocket = this[kWebSocket]; this.removeListener('close', socketOnClose); + this.removeListener('data', socketOnData); this.removeListener('end', socketOnEnd); - websocket.readyState = WebSocket.CLOSING; + websocket._readyState = WebSocket.CLOSING; // // The close frame might not have been received or the `'end'` event emitted, // for example, if the socket was destroyed due to an error. Ensure that the // `receiver` stream is closed after writing any remaining buffered data to // it. If the readable side of the socket is in flowing mode then there is no - // buffered data as everything has been already written and `readable.read()` - // will return `null`. If instead, the socket is paused, any possible buffered - // data will be read as a single chunk and emitted synchronously in a single - // `'data'` event. + // buffered data as everything has been already written. If instead, the + // socket is paused, any possible buffered data will be read as a single + // chunk. // - websocket._socket.read(); + if ( + !this._readableState.endEmitted && + !websocket._closeFrameReceived && + !websocket._receiver._writableState.errorEmitted && + this._readableState.length !== 0 + ) { + const chunk = this.read(this._readableState.length); + + websocket._receiver.write(chunk); + } + websocket._receiver.end(); - this.removeListener('data', socketOnData); this[kWebSocket] = undefined; clearTimeout(websocket._closeTimer); @@ -863,7 +1351,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 @@ -875,20 +1363,20 @@ function socketOnData(chunk) { } /** - * The listener of the `net.Socket` `'end'` event. + * The listener of the socket `'end'` event. * * @private */ function socketOnEnd() { const websocket = this[kWebSocket]; - websocket.readyState = WebSocket.CLOSING; + websocket._readyState = WebSocket.CLOSING; websocket._receiver.end(); this.end(); } /** - * The listener of the `net.Socket` `'error'` event. + * The listener of the socket `'error'` event. * * @private */ @@ -899,7 +1387,7 @@ function socketOnError() { this.on('error', NOOP); if (websocket) { - websocket.readyState = WebSocket.CLOSING; + websocket._readyState = WebSocket.CLOSING; this.destroy(); } } diff --git a/package.json b/package.json index f45e9d347..383f41bc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "7.3.1", + "version": "8.19.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=html --reporter=text mocha --throw-deprecation test/*.test.js", + "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,13 +57,13 @@ "devDependencies": { "benchmark": "^2.1.4", "bufferutil": "^4.0.1", - "coveralls": "^3.0.3", - "eslint": "^7.2.0", - "eslint-config-prettier": "^6.0.0", - "eslint-plugin-prettier": "^3.0.1", - "mocha": "^7.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.0.0", + "globals": "^17.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 5da01bb18..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); }); }); @@ -203,25 +209,37 @@ describe('createWebSocketStream', () => { }); it('reemits errors', (done) => { + let duplexCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); const duplex = createWebSocketStream(ws); duplex.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid opcode 5' ); duplex.on('close', () => { - wss.close(done); + duplexCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); }); }); }); wss.on('connection', (ws) => { ws._socket.write(Buffer.from([0x85, 0x00])); + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (duplexCloseEventEmitted) wss.close(done); + }); }); }); @@ -281,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( @@ -428,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)); }); @@ -477,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 }) ]; @@ -526,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/fixtures/agent1-cert.pem b/test/fixtures/agent1-cert.pem deleted file mode 100644 index cccb9fb4d..000000000 --- a/test/fixtures/agent1-cert.pem +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICbjCCAdcCCQCVvok5oeLpqzANBgkqhkiG9w0BAQUFADB6MQswCQYDVQQGEwJV -UzELMAkGA1UECBMCQ0ExCzAJBgNVBAcTAlNGMQ8wDQYDVQQKEwZKb3llbnQxEDAO -BgNVBAsTB05vZGUuanMxDDAKBgNVBAMTA2NhMTEgMB4GCSqGSIb3DQEJARYRcnlA -dGlueWNsb3Vkcy5vcmcwHhcNMTMwMzA4MDAzMDIyWhcNNDAwNzIzMDAzMDIyWjB9 -MQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExCzAJBgNVBAcTAlNGMQ8wDQYDVQQK -EwZKb3llbnQxEDAOBgNVBAsTB05vZGUuanMxDzANBgNVBAMTBmFnZW50MTEgMB4G -CSqGSIb3DQEJARYRcnlAdGlueWNsb3Vkcy5vcmcwgZ8wDQYJKoZIhvcNAQEBBQAD -gY0AMIGJAoGBAL6GwKosYb0Yc3Qo0OtQVlCJ4208Idw11ij+t2W5sfYbCil5tyQo -jnhGM1CJhEXynQpXXwjKJuIeTQCkeUibTyFKa0bs8+li2FiGoKYbb4G81ovnqkmE -2iDVb8Gw3rrM4zeZ0ZdFnjMsAZac8h6+C4sB/pS9BiMOo6qTl15RQlcJAgMBAAEw -DQYJKoZIhvcNAQEFBQADgYEAOtmLo8DwTPnI4wfQbQ3hWlTS/9itww6IsxH2ODt9 -ggB7wi7N3uAdIWRZ54ke0NEAO5CW1xNTwsWcxQbiHrDOqX1vfVCjIenI76jVEEap -/Ay53ydHNBKdsKkib61Me14Mu0bA3lUul57VXwmH4NUEFB3w973Q60PschUhOEXj -7DY= ------END CERTIFICATE----- diff --git a/test/fixtures/agent1-key.pem b/test/fixtures/agent1-key.pem deleted file mode 100644 index cbd5f0c26..000000000 --- a/test/fixtures/agent1-key.pem +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIICXAIBAAKBgQC+hsCqLGG9GHN0KNDrUFZQieNtPCHcNdYo/rdlubH2Gwopebck -KI54RjNQiYRF8p0KV18IyibiHk0ApHlIm08hSmtG7PPpYthYhqCmG2+BvNaL56pJ -hNog1W/BsN66zOM3mdGXRZ4zLAGWnPIevguLAf6UvQYjDqOqk5deUUJXCQIDAQAB -AoGANu/CBA+SCyVOvRK70u4yRTzNMAUjukxnuSBhH1rg/pajYnwvG6T6F6IeT72n -P0gKkh3JUE6B0bds+p9yPUZTFUXghxjcF33wlIY44H6gFE4K5WutsFJ9c450wtuu -8rXZTsIg7lAXWjTFVmdtOEPetcGlO2Hpi1O7ZzkzHgB2w9ECQQDksCCYx78or1zY -ZSokm8jmpIjG3VLKdvI9HAoJRN40ldnwFoigrFa1AHwsFtWNe8bKyVRPDoLDUjpB -dkPWgweVAkEA1UfgqguQ2KIkbtp9nDBionu3QaajksrRHwIa8vdfRfLxszfHk2fh -NGY3dkRZF8HUAbzYLrd9poVhCBAEjWekpQJASOM6AHfpnXYHCZF01SYx6hEW5wsz -kARJQODm8f1ZNTlttO/5q/xBxn7ZFNRSTD3fJlL05B2j380ddC/Vf1FT4QJAP1BC -GliqnBSuGhZUWYxni3KMeTm9rzL0F29pjpzutHYlWB2D6ndY/FQnvL0XcZ0Bka58 -womIDGnl3x3aLBwLXQJBAJv6h5CHbXHx7VyDJAcNfppAqZGcEaiVg8yf2F33iWy2 -FLthhJucx7df7SO2aw5h06bRDRAhb9br0R9/3mLr7RE= ------END RSA PRIVATE KEY----- diff --git a/test/fixtures/ca-certificate.pem b/test/fixtures/ca-certificate.pem new file mode 100644 index 000000000..0f1658821 --- /dev/null +++ b/test/fixtures/ca-certificate.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVoCCQCXqK2FegDgiDAKBggqhkjOPQQDAjBhMQswCQYDVQQGEwJJVDEQ +MA4GA1UECAwHUGVydWdpYTEQMA4GA1UEBwwHRm9saWdubzETMBEGA1UECgwKd2Vi +c29ja2V0czELMAkGA1UECwwCd3MxDDAKBgNVBAMMA2NhMTAgFw0yMTA1MjYxOTA1 +MjdaGA8yMTIxMDUwMjE5MDUyN1owYTELMAkGA1UEBhMCSVQxEDAOBgNVBAgMB1Bl +cnVnaWExEDAOBgNVBAcMB0ZvbGlnbm8xEzARBgNVBAoMCndlYnNvY2tldHMxCzAJ +BgNVBAsMAndzMQwwCgYDVQQDDANjYTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC +AASHE75QDQN6XNo/711YSbckaa8r4lt0hGkgtADaBFT9Qn9gcm5omapePZT76Ff9 +rwjMcS+YPXS7J7bk+QHLihJMMAoGCCqGSM49BAMCA0kAMEYCIQCUMdUih+sE0ZTu +ORlcKiM8DKyiKkGU4Ty+dslz6nVJjAIhAMcSy0SBsBDgsai1s9aCmAGJXCijNb6g +vfWaatgq+ma2 +-----END CERTIFICATE----- diff --git a/test/fixtures/ca-key.pem b/test/fixtures/ca-key.pem new file mode 100644 index 000000000..a9352fb6a --- /dev/null +++ b/test/fixtures/ca-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAa/Onpk27cLkqzje69Bac8yG+LTBXIPWT8yGlyjEFbboAoGCCqGSM49 +AwEHoUQDQgAEhxO+UA0DelzaP+9dWEm3JGmvK+JbdIRpILQA2gRU/UJ/YHJuaJmq +Xj2U++hX/a8IzHEvmD10uye25PkBy4oSTA== +-----END EC PRIVATE KEY----- diff --git a/test/fixtures/ca1-cert.pem b/test/fixtures/ca1-cert.pem deleted file mode 100644 index 1d0c0d688..000000000 --- a/test/fixtures/ca1-cert.pem +++ /dev/null @@ -1,15 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICazCCAdQCCQC9/g69HtxXRzANBgkqhkiG9w0BAQUFADB6MQswCQYDVQQGEwJV -UzELMAkGA1UECBMCQ0ExCzAJBgNVBAcTAlNGMQ8wDQYDVQQKEwZKb3llbnQxEDAO -BgNVBAsTB05vZGUuanMxDDAKBgNVBAMTA2NhMTEgMB4GCSqGSIb3DQEJARYRcnlA -dGlueWNsb3Vkcy5vcmcwHhcNMTMwMzA4MDAzMDIyWhcNNDAwNzIzMDAzMDIyWjB6 -MQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExCzAJBgNVBAcTAlNGMQ8wDQYDVQQK -EwZKb3llbnQxEDAOBgNVBAsTB05vZGUuanMxDDAKBgNVBAMTA2NhMTEgMB4GCSqG -SIb3DQEJARYRcnlAdGlueWNsb3Vkcy5vcmcwgZ8wDQYJKoZIhvcNAQEBBQADgY0A -MIGJAoGBAKxr1mARUcv7zaqx5y4AxJPK6c1jdbSg7StcL4vg8klaPAlfNO6o+/Cl -w5CdQD3ukaVUwUOJ4T/+b3Xf7785XcWBC33GdjVQkfbHATJYcka7j7JDw3qev5Jk -1rAbRw48hF6rYlSGcx1mccAjoLoa3I8jgxCNAYHIjUQXgdmU893rAgMBAAEwDQYJ -KoZIhvcNAQEFBQADgYEAis05yxjCtJRuv8uX/DK6TX/j9C9Lzp1rKDNFTaTZ0iRw -KCw1EcNx4OXSj9gNblW4PWxpDvygrt1AmH9h2cb8K859NSHa9JOBFw6MA5C2A4Sj -NQfNATqUl4T6cdORlcDEZwHtT8b6D4A6Er31G/eJF4Sen0TUFpjdjd+l9RBjHlo= ------END CERTIFICATE----- diff --git a/test/fixtures/ca1-key.pem b/test/fixtures/ca1-key.pem deleted file mode 100644 index df1495083..000000000 --- a/test/fixtures/ca1-key.pem +++ /dev/null @@ -1,17 +0,0 @@ ------BEGIN ENCRYPTED PRIVATE KEY----- -MIICxjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIFeWxJE1BrRECAggA -MBQGCCqGSIb3DQMHBAgu9PlMSQ+BOASCAoDEZN2tX0xWo/N+Jg+PrvCrFDk3P+3x -5xG/PEDjtMCAWPBEwbnaYHDzYmhNcAmxzGqEHGMDiWYs46LbO560VS3uMvFbEWPo -KYYVb13vkxl2poXdonCb5cHZA5GUYzTIVVJFptl4LHwBczHoMHtA4FqAhKlYvlWw -EOrdLB8XcwMmGPFabbbGxno0+EWWM27uNjlogfoxj35mQqSW4rOlhZ460XjOB1Zx -LjXMuZeONojkGYQRG5EUMchBoctQpCOM6cAi9r1B9BvtFCBpDV1c1zEZBzTEUd8o -kLn6tjLmY+QpTdylFjEWc7U3ppLY/pkoTBv4r85a2sEMWqkhSJboLaTboWzDJcU3 -Ke61pMpovt/3yCUd3TKgwduVwwQtDVTlBe0p66aN9QVj3CrFy/bKAGO3vxlli24H -aIjZf+OVoBY21ESlW3jLvNlBf7Ezf///2E7j4SCDLyZSFMTpFoAG/jDRyvi+wTKX -Kh485Bptnip6DCSuoH4u2SkOqwz3gJS/6s02YKe4m311QT4Pzne5/FwOFaS/HhQg -Xvyh2/d00OgJ0Y0PYQsHILPRgTUCKUXvj1O58opn3fxSacsPxIXwj6Z4FYAjUTaV -2B85k1lpant/JJEilDqMjqzx4pHZ/Z3Uto1lSM1JZs9SNL/0UR+6F0TXZTULVU9V -w8jYzz4sPr7LEyrrTbzmjQgnQFVbhAN/eKgRZK/SpLjxpmBV5MfpbPKsPUZqT4UC -4nXa8a/NYUQ9e+QKK8enq9E599c2W442W7Z1uFRZTWReMx/lF8wwA6G8zOPG0bdj -d+T5Gegzd5mvRiXMBklCo8RLxOOvgxun1n3PY4a63aH6mqBhdfhiLp5j ------END ENCRYPTED PRIVATE KEY----- diff --git a/test/fixtures/certificate.pem b/test/fixtures/certificate.pem index 0efc2ef5b..538553ee0 100644 --- a/test/fixtures/certificate.pem +++ b/test/fixtures/certificate.pem @@ -1,13 +1,12 @@ -----BEGIN CERTIFICATE----- -MIICATCCAWoCCQDPufXH86n2QzANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQGEwJu -bzETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0 -cyBQdHkgTHRkMB4XDTEyMDEwMTE0NDQwMFoXDTIwMDMxOTE0NDQwMFowRTELMAkG -A1UEBhMCbm8xEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0 -IFdpZGdpdHMgUHR5IEx0ZDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAtrQ7 -+r//2iV/B6F+4boH0XqFn7alcV9lpjvAmwRXNKnxAoa0f97AjYPGNLKrjpkNXXhB -JROIdbRbZnCNeC5fzX1a+JCo7KStzBXuGSZr27TtFmcV4H+9gIRIcNHtZmJLnxbJ -sIhkGR8yVYdmJZe4eT5ldk1zoB1adgPF1hZhCBMCAwEAATANBgkqhkiG9w0BAQUF -AAOBgQCeWBEHYJ4mCB5McwSSUox0T+/mJ4W48L/ZUE4LtRhHasU9hiW92xZkTa7E -QLcoJKQiWfiLX2ysAro0NX4+V8iqLziMqvswnPzz5nezaOLE/9U/QvH3l8qqNkXu -rNbsW1h/IO6FV8avWFYVFoutUwOaZ809k7iMh2F2JMgXQ5EymQ== +MIIBujCCAWACCQDjKdAMt3mZhDAKBggqhkjOPQQDAjBkMQswCQYDVQQGEwJJVDEQ +MA4GA1UECAwHUGVydWdpYTEQMA4GA1UEBwwHRm9saWdubzETMBEGA1UECgwKd2Vi +c29ja2V0czELMAkGA1UECwwCd3MxDzANBgNVBAMMBnNlcnZlcjAgFw0yMTA1MjYx +OTEwMjlaGA8yMTIxMDUwMjE5MTAyOVowZDELMAkGA1UEBhMCSVQxEDAOBgNVBAgM +B1BlcnVnaWExEDAOBgNVBAcMB0ZvbGlnbm8xEzARBgNVBAoMCndlYnNvY2tldHMx +CzAJBgNVBAsMAndzMQ8wDQYDVQQDDAZzZXJ2ZXIwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAAQKhyRhdSVOecbJU4O5XkB/iGodbnCOqmchs4TXmE3Prv5SrNDhODDv +rOWTXwR3/HrrdNfOzPdb54amu8POwpohMAoGCCqGSM49BAMCA0gAMEUCIHMRUSPl +8FGkDLl8KF1A+SbT2ds3zUOLdYvj30Z2SKSVAiEA84U/R1ly9wf5Rzv93sTHI99o +KScsr/PHN8rT2pop5pk= -----END CERTIFICATE----- diff --git a/test/fixtures/client-certificate.pem b/test/fixtures/client-certificate.pem new file mode 100644 index 000000000..0e20560b8 --- /dev/null +++ b/test/fixtures/client-certificate.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAV0CCQDDIX2dKuKP0zAKBggqhkjOPQQDAjBhMQswCQYDVQQGEwJJVDEQ +MA4GA1UECAwHUGVydWdpYTEQMA4GA1UEBwwHRm9saWdubzETMBEGA1UECgwKd2Vi +c29ja2V0czELMAkGA1UECwwCd3MxDDAKBgNVBAMMA2NhMTAgFw0yMTA1MjYxOTE3 +NDJaGA8yMTIxMDUwMjE5MTc0MlowZDELMAkGA1UEBhMCSVQxEDAOBgNVBAgMB1Bl +cnVnaWExEDAOBgNVBAcMB0ZvbGlnbm8xEzARBgNVBAoMCndlYnNvY2tldHMxCzAJ +BgNVBAsMAndzMQ8wDQYDVQQDDAZhZ2VudDEwWTATBgcqhkjOPQIBBggqhkjOPQMB +BwNCAATwHlNS2b13TMhBTSWBXAn6TEPxrsvG93ZZyUlmrEMOXSMX2hI7sv660YNj ++eGyE2CV33XsQxV3TUqi51fUjIu8MAoGCCqGSM49BAMCA0gAMEUCIQCxsqBre+Do +jnfg6XmCaB0fywNzcDlvdoVNuNAWfVNrSAIgDQmbM0mXZaSAkf4sgtKdXnpE3vrb +MElb457Bi3B+rkE= +-----END CERTIFICATE----- diff --git a/test/fixtures/client-key.pem b/test/fixtures/client-key.pem new file mode 100644 index 000000000..e034f57fc --- /dev/null +++ b/test/fixtures/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKVGskK0UR86WwMo5H0+hNAFGRBYsEevK3ye4y1YberVoAoGCCqGSM49 +AwEHoUQDQgAE8B5TUtm9d0zIQU0lgVwJ+kxD8a7Lxvd2WclJZqxDDl0jF9oSO7L+ +utGDY/nhshNgld917EMVd01KoudX1IyLvA== +-----END EC PRIVATE KEY----- diff --git a/test/fixtures/key.pem b/test/fixtures/key.pem index 176fe320b..05bfdb71e 100644 --- a/test/fixtures/key.pem +++ b/test/fixtures/key.pem @@ -1,15 +1,5 @@ ------BEGIN RSA PRIVATE KEY----- -MIICXAIBAAKBgQC2tDv6v//aJX8HoX7hugfReoWftqVxX2WmO8CbBFc0qfEChrR/ -3sCNg8Y0squOmQ1deEElE4h1tFtmcI14Ll/NfVr4kKjspK3MFe4ZJmvbtO0WZxXg -f72AhEhw0e1mYkufFsmwiGQZHzJVh2Yll7h5PmV2TXOgHVp2A8XWFmEIEwIDAQAB -AoGAAlVY8sHi/aE+9xT77twWX3mGHV0SzdjfDnly40fx6S1Gc7bOtVdd9DC7pk6l -3ENeJVR02IlgU8iC5lMHq4JEHPE272jtPrLlrpWLTGmHEqoVFv9AITPqUDLhB9Kk -Hjl7h8NYBKbr2JHKICr3DIPKOT+RnXVb1PD4EORbJ3ooYmkCQQDfknUnVxPgxUGs -ouABw1WJIOVgcCY/IFt4Ihf6VWTsxBgzTJKxn3HtgvE0oqTH7V480XoH0QxHhjLq -DrgobWU9AkEA0TRJ8/ouXGnFEPAXjWr9GdPQRZ1Use2MrFjneH2+Sxc0CmYtwwqL -Kr5kS6mqJrxprJeluSjBd+3/ElxURrEXjwJAUvmlN1OPEhXDmRHd92mKnlkyKEeX -OkiFCiIFKih1S5Y/sRJTQ0781nyJjtJqO7UyC3pnQu1oFEePL+UEniRztQJAMfav -AtnpYKDSM+1jcp7uu9BemYGtzKDTTAYfoiNF42EzSJiGrWJDQn4eLgPjY0T0aAf/ -yGz3Z9ErbhMm/Ysl+QJBAL4kBxRT8gM4ByJw4sdOvSeCCANFq8fhbgm8pGWlCPb5 -JGmX3/GHFM8x2tbWMGpyZP1DLtiNEFz7eCGktWK5rqE= ------END RSA PRIVATE KEY----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIIjLz7YEWIrsGem2+YV8eJhHhetsjYIrjuqJLbdG7B3zoAoGCCqGSM49 +AwEHoUQDQgAECockYXUlTnnGyVODuV5Af4hqHW5wjqpnIbOE15hNz67+UqzQ4Tgw +76zlk18Ed/x663TXzsz3W+eGprvDzsKaIQ== +-----END EC PRIVATE KEY----- diff --git a/test/fixtures/request.pem b/test/fixtures/request.pem deleted file mode 100644 index 51bc7f625..000000000 --- a/test/fixtures/request.pem +++ /dev/null @@ -1,11 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIIBhDCB7gIBADBFMQswCQYDVQQGEwJubzETMBEGA1UECAwKU29tZS1TdGF0ZTEh -MB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEB -AQUAA4GNADCBiQKBgQC2tDv6v//aJX8HoX7hugfReoWftqVxX2WmO8CbBFc0qfEC -hrR/3sCNg8Y0squOmQ1deEElE4h1tFtmcI14Ll/NfVr4kKjspK3MFe4ZJmvbtO0W -ZxXgf72AhEhw0e1mYkufFsmwiGQZHzJVh2Yll7h5PmV2TXOgHVp2A8XWFmEIEwID -AQABoAAwDQYJKoZIhvcNAQEFBQADgYEAjsUXEARgfxZNkMjuUcudgU2w4JXS0gGI -JQ0U1LmU0vMDSKwqndMlvCbKzEgPbJnGJDI8D4MeINCJHa5Ceyb8c+jaJYUcCabl -lQW5Psn3+eWp8ncKlIycDRj1Qk615XuXtV0fhkrgQM2ZCm9LaQ1O1Gd/CzLihLjF -W0MmgMKMMRk= ------END CERTIFICATE REQUEST----- diff --git a/test/permessage-deflate.test.js b/test/permessage-deflate.test.js index 09681d96f..cf795b799 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([{}]); @@ -595,13 +590,27 @@ describe('PerMessageDeflate', () => { }); }); - it("doesn't call the callback twice when `maxPayload` is exceeded", (done) => { - const perMessageDeflate = new PerMessageDeflate( - { threshold: 0 }, - false, - 25 - ); - const buf = Buffer.from('A'.repeat(50)); + it('calls the callback when `maxPayload` is exceeded (1/2)', (done) => { + const perMessageDeflate = new PerMessageDeflate({}, false, 25); + const buf = Buffer.alloc(50, 'A'); + + perMessageDeflate.accept([{}]); + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + perMessageDeflate.decompress(data, true, (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.message, 'Max payload size exceeded'); + done(); + }); + }); + }); + + it('calls the callback when `maxPayload` is exceeded (2/2)', (done) => { + // A copy of the previous test but with a larger input. See + // https://github.com/websockets/ws/pull/2285. + const perMessageDeflate = new PerMessageDeflate({}, false, 25); + const buf = Buffer.alloc(1024 * 1024, 'A'); perMessageDeflate.accept([{}]); perMessageDeflate.compress(buf, true, (err, data) => { @@ -616,7 +625,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([{}]); @@ -631,5 +640,26 @@ describe('PerMessageDeflate', () => { process.nextTick(() => perMessageDeflate.cleanup()); }); + + it('recreates the inflate stream if it ends', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover; ' + + 'server_no_context_takeover' + ); + const buf = Buffer.from('33343236313533b7000000', 'hex'); + const expected = Buffer.from('12345678'); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + perMessageDeflate.decompress(buf, true, (err, data) => { + assert.ok(data.equals(expected)); + + perMessageDeflate.decompress(buf, true, (err, data) => { + assert.ok(data.equals(expected)); + done(); + }); + }); + }); }); }); diff --git a/test/receiver.test.js b/test/receiver.test.js index a70cc8dbe..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(); }); @@ -513,6 +540,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_1'); assert.strictEqual( err.message, 'Invalid WebSocket frame: RSV1 must be clear' @@ -528,12 +556,15 @@ 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) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_1'); assert.strictEqual( err.message, 'Invalid WebSocket frame: RSV1 must be clear' @@ -550,6 +581,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_2_3'); assert.strictEqual( err.message, 'Invalid WebSocket frame: RSV2 and RSV3 must be clear' @@ -566,6 +598,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_2_3'); assert.strictEqual( err.message, 'Invalid WebSocket frame: RSV2 and RSV3 must be clear' @@ -582,6 +615,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid opcode 0' @@ -598,6 +632,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid opcode 1' @@ -615,6 +650,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid opcode 2' @@ -632,6 +668,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_EXPECTED_FIN'); assert.strictEqual( err.message, 'Invalid WebSocket frame: FIN must be set' @@ -647,12 +684,15 @@ 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) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_1'); assert.strictEqual( err.message, 'Invalid WebSocket frame: RSV1 must be clear' @@ -669,6 +709,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_EXPECTED_FIN'); assert.strictEqual( err.message, 'Invalid WebSocket frame: FIN must be set' @@ -681,10 +722,11 @@ 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); + assert.strictEqual(err.code, 'WS_ERR_EXPECTED_MASK'); assert.strictEqual( err.message, 'Invalid WebSocket frame: MASK must be set' @@ -697,10 +739,11 @@ 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); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_MASK'); assert.strictEqual( err.message, 'Invalid WebSocket frame: MASK must be clear' @@ -719,6 +762,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid payload length 126' @@ -735,6 +779,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'); assert.strictEqual( err.message, 'Unsupported WebSocket frame: payload length > 2^53 - 1' @@ -756,6 +801,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'WS_ERR_INVALID_UTF8'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid UTF-8 sequence' @@ -771,13 +817,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([0xce, 0xba, 0xe1, 0xbd]); receiver.on('error', (err) => { assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'WS_ERR_INVALID_UTF8'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid UTF-8 sequence' @@ -799,6 +848,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid payload length 1' @@ -807,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) => { @@ -815,6 +865,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_CLOSE_CODE'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid status code 0' @@ -831,6 +882,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'WS_ERR_INVALID_UTF8'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid UTF-8 sequence' @@ -845,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, { @@ -860,6 +912,7 @@ describe('Receiver', () => { receiver.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); assert.strictEqual(err.message, 'Max payload size exceeded'); assert.strictEqual(err[kStatusCode], 1009); done(); @@ -872,18 +925,16 @@ 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) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); assert.strictEqual(err.message, 'Max payload size exceeded'); assert.strictEqual(err[kStatusCode], 1009); done(); @@ -901,18 +952,16 @@ 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) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); assert.strictEqual(err.message, 'Max payload size exceeded'); assert.strictEqual(err[kStatusCode], 1009); done(); @@ -942,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(); }); @@ -960,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(); }); @@ -986,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), @@ -995,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(); }); @@ -1011,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/validation.test.js b/test/validation.test.js new file mode 100644 index 000000000..5718b12f0 --- /dev/null +++ b/test/validation.test.js @@ -0,0 +1,52 @@ +'use strict'; + +const assert = require('assert'); + +const { isValidUTF8 } = require('../lib/validation'); + +describe('extension', () => { + describe('isValidUTF8', () => { + it('returns false if it finds invalid bytes', () => { + assert.strictEqual(isValidUTF8(Buffer.from([0xf8])), false); + }); + + it('returns false for overlong encodings', () => { + assert.strictEqual(isValidUTF8(Buffer.from([0xc0, 0xa0])), false); + assert.strictEqual(isValidUTF8(Buffer.from([0xe0, 0x80, 0xa0])), false); + assert.strictEqual( + isValidUTF8(Buffer.from([0xf0, 0x80, 0x80, 0xa0])), + false + ); + }); + + it('returns false for code points in the range U+D800 - U+DFFF', () => { + for (let i = 0xa0; i < 0xc0; i++) { + for (let j = 0x80; j < 0xc0; j++) { + assert.strictEqual(isValidUTF8(Buffer.from([0xed, i, j])), false); + } + } + }); + + it('returns false for code points greater than U+10FFFF', () => { + assert.strictEqual( + isValidUTF8(Buffer.from([0xf4, 0x90, 0x80, 0x80])), + false + ); + assert.strictEqual( + isValidUTF8(Buffer.from([0xf5, 0x80, 0x80, 0x80])), + false + ); + }); + + it('returns true for a well-formed UTF-8 byte sequence', () => { + // prettier-ignore + const buf = Buffer.from([ + 0xe2, 0x82, 0xAC, // € + 0xf0, 0x90, 0x8c, 0x88, // 𐍈 + 0x24 // $ + ]); + + assert.strictEqual(isValidUTF8(buf), true); + }); + }); +}); diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index f41993f3f..4d5201735 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'); @@ -18,12 +19,44 @@ const { NOOP } = require('../lib/constants'); describe('WebSocketServer', () => { describe('#ctor', () => { it('throws an error if no option object is passed', () => { - assert.throws(() => new WebSocket.Server()); + assert.throws( + () => new WebSocket.Server(), + new RegExp( + '^TypeError: One and only one of the "port", "server", or ' + + '"noServer" options must be specified$' + ) + ); }); describe('options', () => { - it('throws an error if no `port` or `server` option is specified', () => { - assert.throws(() => new WebSocket.Server({})); + it('throws an error if required options are not specified', () => { + assert.throws( + () => new WebSocket.Server({}), + new RegExp( + '^TypeError: One and only one of the "port", "server", or ' + + '"noServer" options must be specified$' + ) + ); + }); + + it('throws an error if mutually exclusive options are specified', () => { + const server = http.createServer(); + const variants = [ + { port: 0, noServer: true, server }, + { port: 0, noServer: true }, + { port: 0, server }, + { noServer: true, server } + ]; + + for (const options of variants) { + assert.throws( + () => new WebSocket.Server(options), + new RegExp( + '^TypeError: One and only one of the "port", "server", or ' + + '"noServer" options must be specified$' + ) + ); + } }); it('exposes options passed to constructor', (done) => { @@ -43,6 +76,8 @@ describe('WebSocketServer', () => { }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); } ); @@ -55,6 +90,72 @@ 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('honors the `closeTimeout` option', (done) => { + const closeTimeout = 1000; + const wss = new WebSocket.Server({ closeTimeout, port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + ws.on('close', () => { + wss.close(done); + }); + + ws.close(); + assert.strictEqual(ws._closeTimer._idleTimeout, closeTimeout); + }); + }); }); it('emits an error if http server bind fails', (done) => { @@ -71,6 +172,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)); @@ -88,12 +191,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); }); }); @@ -114,22 +219,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(4).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) => { @@ -137,13 +236,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}`)); }); }); }); @@ -177,30 +280,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(); }); }); @@ -222,6 +308,8 @@ describe('WebSocketServer', () => { server.listen(0, () => { const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', ws.close); }); }); @@ -233,11 +321,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); @@ -246,19 +338,81 @@ describe('WebSocketServer', () => { }); }); - it("emits the 'close' event", (done) => { - const wss = new WebSocket.Server({ noServer: true }); + it("emits the 'close' event after the server closes", (done) => { + let serverCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + net.createConnection({ port: wss.address().port }); + }); + + wss._server.on('connection', (socket) => { + wss.close(); + + // + // The server is closing. Ensure this does not emit a `'close'` + // event before the server is actually closed. + // + wss.close(); + + process.nextTick(() => { + socket.end(); + }); + }); + + wss._server.on('close', () => { + serverCloseEventEmitted = true; + }); + + wss.on('close', () => { + assert.ok(serverCloseEventEmitted); + done(); + }); + }); + + it("emits the 'close' event if client tracking is disabled", (done) => { + const wss = new WebSocket.Server({ + noServer: true, + clientTracking: false + }); wss.on('close', done); wss.close(); }); + + it('calls the callback if the server is already closed', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.close(() => { + assert.strictEqual(wss._state, 2); + + wss.close((err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'The server is not running'); + done(); + }); + }); + }); + }); + + it("emits the 'close' event if the server is already closed", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.close(() => { + assert.strictEqual(wss._state, 2); + + wss.on('close', done); + wss.close(); + }); + }); + }); }); describe('#clients', () => { it('returns a list of connected clients', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { assert.strictEqual(wss.clients.size, 0); + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); }); wss.on('connection', () => { @@ -320,6 +474,7 @@ describe('WebSocketServer', () => { const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); assert.strictEqual(wss.shouldHandle({ url: '/foo' }), true); + assert.strictEqual(wss.shouldHandle({ url: '/foo?bar=baz' }), true); }); it("returns false when the path doesn't match", () => { @@ -337,16 +492,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); }); }); @@ -358,7 +514,9 @@ describe('WebSocketServer', () => { port: wss.address().port, headers: { Connection: 'Upgrade', - Upgrade: 'websocket' + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13 } }); @@ -384,7 +542,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); }); }); }); @@ -427,6 +632,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({ @@ -439,7 +759,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); + }); }); }); @@ -461,7 +794,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); + }); }); }); @@ -470,7 +816,7 @@ describe('WebSocketServer', () => { }); }); - it('fails is the Sec-WebSocket-Version header is invalid (1/2)', (done) => { + it('fails if the Sec-WebSocket-Version header is invalid (1/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const req = http.get({ port: wss.address().port, @@ -483,7 +829,21 @@ describe('WebSocketServer', () => { req.on('response', (res) => { assert.strictEqual(res.statusCode, 400); - wss.close(done); + assert.strictEqual(res.headers['sec-websocket-version'], '13, 8'); + + 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); + }); }); }); @@ -492,7 +852,7 @@ describe('WebSocketServer', () => { }); }); - it('fails is the Sec-WebSocket-Version header is invalid (2/2)', (done) => { + it('fails if the Sec-WebSocket-Version header is invalid (2/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const req = http.get({ port: wss.address().port, @@ -506,7 +866,58 @@ describe('WebSocketServer', () => { req.on('response', (res) => { assert.strictEqual(res.statusCode, 400); - wss.close(done); + assert.strictEqual(res.headers['sec-websocket-version'], '13, 8'); + + 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); + }); }); }); @@ -515,7 +926,7 @@ describe('WebSocketServer', () => { }); }); - it('fails is the Sec-WebSocket-Extensions header is invalid', (done) => { + it('fails if the Sec-WebSocket-Extensions header is invalid', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: true, @@ -536,7 +947,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); + }); }); } ); @@ -546,6 +970,98 @@ 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 }); + + server.on('upgrade', (req, socket, head) => { + wss.close(); + wss.handleUpgrade(req, socket, head, () => { + done(new Error('Unexpected callback invocation')); + }); + }); + + server.listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 503); + res.resume(); + server.close(done); + }); + }); + }); + + it('handles unsupported extensions', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13, + 'Sec-WebSocket-Extensions': 'foo; bar' + } + }); + + req.on('upgrade', (res, socket, head) => { + if (head.length) socket.unshift(head); + + socket.once('data', (chunk) => { + assert.strictEqual(chunk[0], 0x88); + socket.destroy(); + wss.close(done); + }); + }); + } + ); + + wss.on('connection', (ws) => { + assert.strictEqual(ws.extensions, ''); + ws.close(); + }); + }); + describe('`verifyClient`', () => { it('can reject client synchronously', (done) => { const wss = new WebSocket.Server( @@ -593,7 +1109,6 @@ describe('WebSocketServer', () => { }); wss.on('connection', () => { - wss.close(); server.close(done); }); @@ -602,6 +1117,8 @@ describe('WebSocketServer', () => { headers: { Origin: 'https://example.com', foo: 'bar' }, rejectUnauthorized: false }); + + ws.on('open', ws.close); }); }); @@ -613,6 +1130,8 @@ describe('WebSocketServer', () => { }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); } ); @@ -784,8 +1303,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); }); }); @@ -796,7 +1316,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}`, [ @@ -809,24 +1329,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)); }); }); }); @@ -835,6 +1363,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) => { @@ -866,6 +1396,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 8defba689..012f7c0a6 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,98 @@ 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(); + }); + }); + + it('honors the `closeTimeout` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const closeTimeout = 1000; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + closeTimeout + }); + + ws.on('open', () => { + ws.close(); + assert.strictEqual(ws._closeTimer._idleTimeout, closeTimeout); + }); + + ws.on('close', () => { + wss.close(done); + }); + }); + }); }); }); @@ -100,21 +264,28 @@ describe('WebSocket', () => { Object.keys(readyStates).forEach((state) => { describe(`\`${state}\``, () => { it('is enumerable property of class', () => { - const propertyDescripter = Object.getOwnPropertyDescriptor( - WebSocket, - state - ); + const descriptor = Object.getOwnPropertyDescriptor(WebSocket, state); - assert.strictEqual(propertyDescripter.value, readyStates[state]); - assert.strictEqual(propertyDescripter.enumerable, true); + assert.deepStrictEqual(descriptor, { + configurable: false, + enumerable: true, + value: readyStates[state], + writable: false + }); }); - it('is property of instance', () => { - const ws = new WebSocket('ws://localhost', { - agent: new CustomAgent() - }); + it('is enumerable property of prototype', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + state + ); - assert.strictEqual(ws[state], readyStates[state]); + assert.deepStrictEqual(descriptor, { + configurable: false, + enumerable: true, + value: readyStates[state], + writable: false + }); }); }); }); @@ -122,6 +293,18 @@ describe('WebSocket', () => { describe('Attributes', () => { describe('`binaryType`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'binaryType' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set !== undefined); + }); + it("defaults to 'nodebuffer'", () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() @@ -153,6 +336,18 @@ describe('WebSocket', () => { }); describe('`bufferedAmount`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'bufferedAmount' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + it('defaults to zero', () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() @@ -170,6 +365,10 @@ describe('WebSocket', () => { wss.close(done); }; }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it('takes into account the data in the sender queue', (done) => { @@ -198,6 +397,10 @@ describe('WebSocket', () => { }); } ); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it('takes into account the data in the socket queue', (done) => { @@ -225,6 +428,18 @@ describe('WebSocket', () => { }); describe('`extensions`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'bufferedAmount' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + it('exposes the negotiated extensions names (1/2)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -268,7 +483,52 @@ 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( + WebSocket.prototype, + 'protocol' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + it('exposes the subprotocol selected by the server', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const port = wss.address().port; @@ -290,6 +550,18 @@ describe('WebSocket', () => { }); describe('`readyState`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'readyState' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + it('defaults to `CONNECTING`', () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() @@ -339,113 +611,431 @@ describe('WebSocket', () => { }); describe('`url`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'url' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + it('exposes the server url', () => { - const url = 'ws://localhost'; - const ws = new WebSocket(url, { agent: new CustomAgent() }); + 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; + const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('error', (err) => { assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); assert.strictEqual( err.message, 'Invalid WebSocket frame: invalid opcode 5' ); ws.on('close', (code, reason) => { - assert.strictEqual(code, 1002); - assert.strictEqual(reason, ''); - wss.close(done); + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); }); }); }); wss.on('connection', (ws) => { + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + ws._socket.write(Buffer.from([0x85, 0x00])); }); }); - it('does not re-emit `net.Socket` errors', (done) => { - const codes = ['EPIPE', 'ECONNABORTED', 'ECANCELED', 'ECONNRESET']; - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + it("emits an 'error' event if an error occurs (2/2)", function (done) { + if (!fs.openAsBlob) return this.skip(); - ws.on('open', () => { - ws._socket.on('error', (err) => { - assert.ok(err instanceof Error); - assert.ok(codes.includes(err.code), `Unexpected code: ${err.code}`); - ws.on('close', (code, message) => { - assert.strictEqual(message, ''); - assert.strictEqual(code, 1006); + const randomString = crypto.randomBytes(4).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); }); }); - - for (const client of wss.clients) client.terminate(); - ws.send('foo'); - ws.send('bar'); }); - }); + } }); - it("emits an 'upgrade' event", (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('upgrade', (res) => { - assert.ok(res instanceof http.IncomingMessage); - wss.close(done); - }); - }); - }); + it("emits the 'error' event only once (1/2)", function (done) { + if (!fs.openAsBlob) return this.skip(); - it("emits a 'ping' event", (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('ping', () => wss.close(done)); - }); + const randomString = crypto.randomBytes(4).toString('hex'); + const file = path.join(os.tmpdir(), `ws-${randomString}.txt`); - wss.on('connection', (ws) => ws.ping()); - }); + fs.writeFileSync(file, 'x'.repeat(64)); - it("emits a 'pong' event", (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('pong', () => wss.close(done)); - }); + fs.openAsBlob(file) + .then((blob) => { + fs.writeFileSync(file, 'x'.repeat(32)); + runTest(blob); + }) + .catch(done); - wss.on('connection', (ws) => ws.pong()); - }); - }); + function runTest(blob) { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); - describe('Connection establishing', () => { - const server = http.createServer(); + ws.on('open', () => { + ws.send('foo'); + ws.send(blob); + }); - beforeEach((done) => server.listen(0, done)); - afterEach((done) => server.close(done)); + 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); + } - it('fails if the Sec-WebSocket-Accept header is invalid', (done) => { - server.once('upgrade', (req, socket) => { - socket.on('end', socket.end); - socket.write( - 'HTTP/1.1 101 Switching Protocols\r\n' + - 'Upgrade: websocket\r\n' + - 'Connection: Upgrade\r\n' + - 'Sec-WebSocket-Accept: CxYS6+NgJSBG74mdgLvGscRvpns=\r\n' + - '\r\n' + ws.on('close', () => { + wss.close(done); + }); + }); + } ); - }); - const ws = new WebSocket(`ws://localhost:${server.address().port}`); + wss.on('connection', (ws) => { + ws._socket.write(Buffer.from([0x85, 0x00])); + }); + } + }); - ws.on('error', (err) => { + it("emits the 'error' event only once (2/2)", function (done) { + if (!fs.openAsBlob) return this.skip(); + + const randomString = crypto.randomBytes(4).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(4).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.strictEqual(err.code, 'ECONNRESET'); + ws.on('close', (code, message) => { + assert.strictEqual(code, 1006); + assert.strictEqual(message, EMPTY_BUFFER); + wss.close(done); + }); + }); + + wss.clients.values().next().value._socket.resetAndDestroy(); + }); + }); + }); + + it("emits an 'upgrade' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + ws.on('upgrade', (res) => { + assert.ok(res instanceof http.IncomingMessage); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it("emits a 'ping' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + ws.on('ping', () => wss.close(done)); + }); + + wss.on('connection', (ws) => { + ws.ping(); + ws.close(); + }); + }); + + it("emits a 'pong' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + ws.on('pong', () => wss.close(done)); + }); + + wss.on('connection', (ws) => { + ws.pong(); + ws.close(); + }); + }); + + it("emits a 'redirect' event", (done) => { + const server = http.createServer(); + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + server.once('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + ws.close(); + }); + }); + }); + + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/foo`); + assert.ok(req instanceof http.ClientRequest); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + server.close(done); + }); + }); + }); + }); + }); + + describe('Connection establishing', () => { + const server = http.createServer(); + + beforeEach((done) => server.listen(0, done)); + afterEach((done) => server.close(done)); + + it('fails if the Upgrade header field value 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) => { + socket.on('end', socket.end); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: CxYS6+NgJSBG74mdgLvGscRvpns=\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('error', (err) => { assert.ok(err instanceof Error); assert.strictEqual(err.message, 'Invalid Sec-WebSocket-Accept header'); done(); @@ -472,7 +1062,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); done(); }); }); @@ -558,7 +1148,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 }); @@ -570,7 +1160,7 @@ describe('WebSocket', () => { }); }); - it('fails if the Sec-WebSocket-Extensions response header is invalid', (done) => { + it('fails if an unexpected Sec-WebSocket-Extensions header is received', (done) => { server.once('upgrade', (req, socket) => { const key = crypto .createHash('sha1') @@ -582,29 +1172,42 @@ describe('WebSocket', () => { 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + `Sec-WebSocket-Accept: ${key}\r\n` + - 'Sec-WebSocket-Extensions: foo;=\r\n' + + 'Sec-WebSocket-Extensions: foo\r\n' + '\r\n' ); }); - const ws = new WebSocket(`ws://localhost:${server.address().port}`); + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + perMessageDeflate: false + }); ws.on('open', () => done(new Error("Unexpected 'open' event"))); ws.on('error', (err) => { assert.ok(err instanceof Error); assert.strictEqual( err.message, - 'Invalid Sec-WebSocket-Extensions header' + 'Server sent a Sec-WebSocket-Extensions header but no extension ' + + 'was requested' ); ws.on('close', () => done()); }); }); - it('fails if server sends a subprotocol when none was requested', (done) => { - const wss = new WebSocket.Server({ server }); + it('fails if the Sec-WebSocket-Extensions header is invalid (1/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); - wss.on('headers', (headers) => { - headers.push('Sec-WebSocket-Protocol: foo'); + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: foo;=\r\n' + + '\r\n' + ); }); const ws = new WebSocket(`ws://localhost:${server.address().port}`); @@ -614,133 +1217,1076 @@ describe('WebSocket', () => { assert.ok(err instanceof Error); assert.strictEqual( err.message, - 'Server sent a subprotocol but none was requested' + 'Invalid Sec-WebSocket-Extensions header' ); - ws.on('close', () => wss.close(done)); + ws.on('close', () => done()); }); }); - it('fails if server sends an invalid subprotocol', (done) => { - const wss = new WebSocket.Server({ - handleProtocols: () => 'baz', - server + it('fails if the Sec-WebSocket-Extensions header is invalid (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: ' + + 'permessage-deflate; client_max_window_bits=7\r\n' + + '\r\n' + ); }); - const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ - 'foo', - 'bar' - ]); + const ws = new WebSocket(`ws://localhost:${server.address().port}`); ws.on('open', () => done(new Error("Unexpected 'open' event"))); ws.on('error', (err) => { assert.ok(err instanceof Error); - assert.strictEqual(err.message, 'Server sent an invalid subprotocol'); - ws.on('close', () => wss.close(done)); + assert.strictEqual( + err.message, + 'Invalid Sec-WebSocket-Extensions header' + ); + ws.on('close', () => done()); }); }); - it('fails if server sends no subprotocol', (done) => { - const wss = new WebSocket.Server({ - handleProtocols() {}, - server - }); + it('fails if an unexpected extension is received (1/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); - const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ - 'foo', + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: foo\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Server indicated an extension that was not requested' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if an unexpected extension is received (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: permessage-deflate,foo\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Server indicated an extension that was not requested' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if server sends a subprotocol when none was requested', (done) => { + const wss = new WebSocket.Server({ server }); + + wss.on('headers', (headers) => { + headers.push('Sec-WebSocket-Protocol: foo'); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Server sent a subprotocol but none was requested' + ); + ws.on('close', () => wss.close(done)); + }); + }); + + it('fails if server sends an invalid subprotocol (1/2)', (done) => { + const wss = new WebSocket.Server({ + handleProtocols: () => 'baz', + server + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent an invalid subprotocol'); + ws.on('close', () => wss.close(done)); + }); + }); + + it('fails if server sends an invalid subprotocol (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Protocol:\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', 'bar' ]); - ws.on('open', () => done(new Error("Unexpected 'open' event"))); - ws.on('error', (err) => { - assert.ok(err instanceof Error); - assert.strictEqual(err.message, 'Server sent no subprotocol'); - ws.on('close', () => wss.close(done)); - }); - }); + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent an invalid subprotocol'); + ws.on('close', () => done()); + }); + }); + + it('fails if server sends no subprotocol', (done) => { + const wss = new WebSocket.Server({ + handleProtocols() {}, + server + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent no subprotocol'); + ws.on('close', () => wss.close(done)); + }); + }); + + it('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( + 'HTTP/1.1 301 Moved Permanently\r\n' + + 'Location: ws://localhost:8080\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Unexpected server response: 301'); + assert.strictEqual(ws._redirects, 0); + ws.on('close', () => done()); + }); + }); + + it('honors the `followRedirects` option', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + server.once('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, NOOP); + }); + }); + + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('open', () => { + assert.strictEqual(ws.url, `ws://localhost:${port}/foo`); + assert.strictEqual(ws._redirects, 1); + ws.on('close', () => done()); + ws.close(); + }); + }); + + it('honors the `maxRedirects` option', (done) => { + const onUpgrade = (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /\r\n\r\n'); + }; + + server.on('upgrade', onUpgrade); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true, + maxRedirects: 1 + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Maximum redirects exceeded'); + assert.strictEqual(ws._redirects, 2); + + server.removeListener('upgrade', onUpgrade); + ws.on('close', () => done()); + }); + }); + + it('emits an error if the redirect URL is invalid (1/2)', (done) => { + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: ws://\r\n\r\n'); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof SyntaxError); + assert.strictEqual(err.message, 'Invalid URL: ws://'); + assert.strictEqual(ws._redirects, 1); + + ws.on('close', () => done()); + }); + }); + + it('emits an error if the redirect URL is invalid (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\nLocation: bad-scheme://localhost\r\n\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof SyntaxError); + assert.strictEqual( + err.message, + 'The URL\'s protocol must be one of "ws:", "wss:", ' + + '"http:", "https:", or "ws+unix:"' + ); + assert.strictEqual(ws._redirects, 1); + + ws.on('close', () => done()); + }); + }); + + it('uses the first url userinfo when following redirects', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + const authorization = 'Basic Zm9vOmJhcg=='; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://baz:qux@localhost:${port}/foo\r\n\r\n` + ); + server.once('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws, req) => { + assert.strictEqual(req.headers.authorization, authorization); + ws.close(); + }); + }); + }); + + const port = server.address().port; + const ws = new WebSocket(`ws://foo:bar@localhost:${port}`, { + followRedirects: true + }); + + assert.strictEqual(ws._req.getHeader('Authorization'), authorization); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://baz:qux@localhost:${port}/foo`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + describe('When moving away from a secure context', () => { + function proxy(httpServer, httpsServer) { + const server = net.createServer({ allowHalfOpen: true }); + + server.on('connection', (socket) => { + socket.on('readable', function read() { + socket.removeListener('readable', read); + + const buf = socket.read(1); + const target = buf[0] === 22 ? httpsServer : httpServer; + + socket.unshift(buf); + target.emit('connection', socket); + }); + }); + + return server; + } + + describe("If there is no 'redirect' event listener", () => { + it('drops the `auth` option', (done) => { + const httpServer = http.createServer(); + const httpsServer = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const server = proxy(httpServer, httpsServer); + + server.listen(() => { + const port = server.address().port; + + httpsServer.on('upgrade', (req, socket) => { + socket.on('error', NOOP); + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + ws.close(); + }); + + const ws = new WebSocket(`wss://localhost:${port}`, { + auth: 'foo:bar', + followRedirects: true, + rejectUnauthorized: false + }); + + assert.strictEqual( + ws._req.getHeader('Authorization'), + 'Basic Zm9vOmJhcg==' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + server.close(done); + }); + }); + }); + + it('drops the Authorization and Cookie headers', (done) => { + const httpServer = http.createServer(); + const httpsServer = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const server = proxy(httpServer, httpsServer); + + server.listen(() => { + const port = server.address().port; + + httpsServer.on('upgrade', (req, socket) => { + socket.on('error', NOOP); + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, headers.host); + + ws.close(); + }); + + const ws = new WebSocket(`wss://localhost:${port}`, { + followRedirects: true, + headers, + rejectUnauthorized: false + }); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + server.close(done); + }); + }); + }); + }); + + describe("If there is at least one 'redirect' event listener", () => { + it('does not drop any headers by default', (done) => { + const httpServer = http.createServer(); + const httpsServer = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const server = proxy(httpServer, httpsServer); + + server.listen(() => { + const port = server.address().port; + + httpsServer.on('upgrade', (req, socket) => { + socket.on('error', NOOP); + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual( + req.headers.authorization, + headers.authorization + ); + assert.strictEqual(req.headers.cookie, headers.cookie); + assert.strictEqual(req.headers.host, headers.host); + + ws.close(); + }); + + const ws = new WebSocket(`wss://localhost:${port}`, { + followRedirects: true, + headers, + rejectUnauthorized: false + }); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/`); + assert.notStrictEqual(firstRequest, req); + assert.strictEqual( + req.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual(req.getHeader('Cookie'), headers.cookie); + assert.strictEqual(req.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + server.close(done); + }); + }); + }); + }); + }); + }); + + describe('When the redirect host is different', () => { + describe("If there is no 'redirect' event listener", () => { + it('drops the `auth` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { + auth: 'foo:bar', + followRedirects: true + } + ); + + assert.strictEqual( + ws._req.getHeader('Authorization'), + 'Basic Zm9vOmJhcg==' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + ws.close(); + }); + }); + + it('drops the Authorization, Cookie and Host headers (1/4)', (done) => { + // Test the `ws:` to `ws:` case. + + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual( + req.headers.host, + `localhost:${wss.address().port}` + ); + + ws.close(); + }); + }); + + it('drops the Authorization, Cookie and Host headers (2/4)', (done) => { + // Test the `ws:` to `ws+unix:` case. + + const randomString = crypto.randomBytes(4).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(4).toString('hex'); + const randomString2 = crypto.randomBytes(4).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 }); - it('does not follow redirects by default', (done) => { - server.once('upgrade', (req, socket) => { - socket.end( - 'HTTP/1.1 301 Moved Permanently\r\n' + - 'Location: ws://localhost:8080\r\n' + - '\r\n' - ); - }); + 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}` + ); - const ws = new WebSocket(`ws://localhost:${server.address().port}`); + ws.close(); + }); - ws.on('open', () => done(new Error("Unexpected 'open' event"))); - ws.on('error', (err) => { - assert.ok(err instanceof Error); - assert.strictEqual(err.message, 'Unexpected server response: 301'); - assert.strictEqual(ws._redirects, 0); - ws.on('close', () => done()); - }); - }); + const randomString = crypto.randomBytes(4).toString('hex'); + const ipcPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\ws-pipe-${randomString}` + : path.join(os.tmpdir(), `ws-${randomString}.sock`); - it('honors the `followRedirects` option', (done) => { - const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + redirectingServer.listen(ipcPath, listening); + redirectedServer.listen(0, listening); - server.once('upgrade', (req, socket) => { - socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); - server.once('upgrade', (req, socket, head) => { - wss.handleUpgrade(req, socket, head, NOOP); + 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); + }); + } }); }); - const port = server.address().port; - const ws = new WebSocket(`ws://localhost:${port}`, { - followRedirects: true - }); + 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' + }; - ws.on('open', () => { - assert.strictEqual(ws.url, `ws://localhost:${port}/foo`); - assert.strictEqual(ws._redirects, 1); - ws.on('close', () => done()); - ws.close(); + 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(); + }); + }); }); }); - it('honors the `maxRedirects` option', (done) => { - const onUpgrade = (req, socket) => { - socket.end('HTTP/1.1 302 Found\r\nLocation: /\r\n\r\n'); - }; + 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'); + }); - server.on('upgrade', onUpgrade); + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); - const ws = new WebSocket(`ws://localhost:${server.address().port}`, { - followRedirects: true, - maxRedirects: 1 + 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(); + }); + }); + }); }); - ws.on('open', () => done(new Error("Unexpected 'open' event"))); - ws.on('error', (err) => { - assert.ok(err instanceof Error); - assert.strictEqual(err.message, 'Maximum redirects exceeded'); - assert.strictEqual(ws._redirects, 2); + it('allows to remove headers', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; - server.removeListener('upgrade', onUpgrade); - ws.on('close', () => done()); + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar' + }; + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true, + headers + }); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/`); + assert.strictEqual( + req.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual(req.getHeader('Cookie'), headers.cookie); + + req.removeHeader('authorization'); + req.removeHeader('cookie'); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + ws.close(); + }); }); }); }); - describe('Connection with query string', () => { - it('connects when pathname is not null', (done) => { + describe('#pause', () => { + it('does nothing if `readyState` is `CONNECTING` or `CLOSED`', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { - const port = wss.address().port; - const ws = new WebSocket(`ws://localhost:${port}/?token=qwerty`); + 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.on('open', () => wss.close(done)); + ws.close(); + }); }); }); - it('connects when pathname is null', (done) => { + it('pauses the socket', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { - const port = wss.address().port; - const ws = new WebSocket(`ws://localhost:${port}?token=qwerty`); + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); - ws.on('open', () => wss.close(done)); + ws.pause(); + assert.ok(ws.isPaused); + assert.ok(ws._socket.isPaused()); + + ws.terminate(); + wss.close(done); }); }); }); @@ -792,6 +2338,11 @@ describe('WebSocket', () => { ws.ping(); assert.strictEqual(ws.bufferedAmount, 4); + if (hasBlob) { + ws.ping(new Blob(['hi'])); + assert.strictEqual(ws.bufferedAmount, 6); + } + done(); }); }); @@ -838,7 +2389,10 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('open', () => { - ws.ping(() => ws.ping()); + ws.ping(() => { + ws.ping(); + ws.close(); + }); }); }); @@ -857,7 +2411,10 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('open', () => { - ws.ping('hi', () => ws.ping('hi', true)); + ws.ping('hi', () => { + ws.ping('hi', true); + ws.close(); + }); }); }); @@ -874,7 +2431,10 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.ping(0)); + ws.on('open', () => { + ws.ping(0); + ws.close(); + }); }); wss.on('connection', (ws) => { @@ -898,6 +2458,10 @@ describe('WebSocket', () => { wss.close(done); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); }); @@ -948,6 +2512,11 @@ describe('WebSocket', () => { ws.pong(); assert.strictEqual(ws.bufferedAmount, 4); + if (hasBlob) { + ws.pong(new Blob(['hi'])); + assert.strictEqual(ws.bufferedAmount, 6); + } + done(); }); }); @@ -994,7 +2563,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(); + }); }); }); @@ -1013,7 +2585,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(); + }); }); }); @@ -1030,7 +2605,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) => { @@ -1054,6 +2632,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); + }); }); }); @@ -1104,6 +2761,11 @@ describe('WebSocket', () => { ws.send(); assert.strictEqual(ws.bufferedAmount, 4); + if (hasBlob) { + ws.send(new Blob(['hi'])); + assert.strictEqual(ws.bufferedAmount, 6); + } + done(); }); }); @@ -1147,7 +2809,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; @@ -1155,15 +2817,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(); + }); }); }); @@ -1172,14 +2839,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(); + }); }); }); @@ -1190,12 +2861,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); }); }); @@ -1205,18 +2878,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); @@ -1233,32 +2910,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); + }); }); }); @@ -1272,7 +2940,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); @@ -1280,7 +2952,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); + }); }); }); @@ -1289,16 +2964,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); + }); }); }); @@ -1313,28 +3027,88 @@ 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); }); }); }); it('honors the `mask` option', (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('open', () => ws.send('hi', { mask: false })); + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); }); wss.on('connection', (ws) => { @@ -1355,9 +3129,11 @@ describe('WebSocket', () => { ); ws.on('close', (code, reason) => { - assert.strictEqual(code, 1002); - assert.strictEqual(reason, ''); - wss.close(done); + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); }); }); }); @@ -1365,7 +3141,7 @@ describe('WebSocket', () => { }); describe('#close', () => { - it('closes the connection if called while connecting (1/2)', (done) => { + it('closes the connection if called while connecting (1/3)', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -1382,7 +3158,7 @@ describe('WebSocket', () => { }); }); - it('closes the connection if called while connecting (2/2)', (done) => { + it('closes the connection if called while connecting (2/3)', (done) => { const wss = new WebSocket.Server( { verifyClient: (info, cb) => setTimeout(cb, 300, true), @@ -1405,21 +3181,11 @@ describe('WebSocket', () => { ); }); - it('can be called from an error listener while connecting', (done) => { - const ws = new WebSocket('ws://localhost:1337'); - - ws.on('open', () => done(new Error("Unexpected 'open' event"))); - ws.on('error', (err) => { - assert.ok(err instanceof Error); - assert.strictEqual(err.code, 'ECONNREFUSED'); - ws.close(); - ws.on('close', () => done()); - }); - }).timeout(4000); + it('closes the connection if called while connecting (3/3)', (done) => { + const server = http.createServer(); - 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) => { @@ -1428,54 +3194,115 @@ describe('WebSocket', () => { err.message, 'WebSocket was closed before the connection was established' ); - ws.on('close', () => wss.close(done)); + ws.on('close', () => { + server.close(done); + }); }); - ws.on('upgrade', () => ws.close()); + + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 502); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'foo'); + ws.close(); + }); + }); + }); + + server.on('upgrade', (req, socket) => { + socket.on('end', socket.end); + + socket.write( + `HTTP/1.1 502 ${http.STATUS_CODES[502]}\r\n` + + 'Connection: keep-alive\r\n' + + 'Content-type: text/html\r\n' + + 'Content-Length: 3\r\n' + + '\r\n' + + 'foo' + ); }); }); - it('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}`); + it('can be called from an error listener while connecting', (done) => { + const server = net.createServer(); - ws.on('open', () => { - assert.throws( - () => ws.close('error'), - /^TypeError: First argument must be a valid error code number$/ - ); + server.on('connection', (socket) => { + socket.on('end', socket.end); + socket.resume(); + socket.write(Buffer.from('foo\r\n')); + }); - wss.close(done); + 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.close(); + ws.on('close', () => { + server.close(done); + }); }); }); }); - it('throws an error if the first argument is invalid (2/2)', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + 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', () => { - 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()); }); }); @@ -1493,12 +3320,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); }); }); @@ -1515,8 +3345,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); }); }); @@ -1531,8 +3361,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); }); }); @@ -1548,7 +3378,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']); @@ -1615,7 +3448,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); }); @@ -1683,16 +3516,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 }, () => { @@ -1729,12 +3610,24 @@ describe('WebSocket', () => { describe('WHATWG API emulation', () => { it('supports the `on{close,error,message,open}` attributes', () => { + for (const property of ['onclose', 'onerror', 'onmessage', 'onopen']) { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + property + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set !== undefined); + } + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - assert.strictEqual(ws.onmessage, undefined); - 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; @@ -1745,6 +3638,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) => { @@ -1766,7 +3664,9 @@ describe('WebSocket', () => { }); wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + }); }); }); @@ -1776,7 +3676,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`", () => { @@ -1789,7 +3689,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; @@ -1797,95 +3697,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('supports the `addEventListener` method', () => { + const events = []; + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('foo', () => {}); + assert.strictEqual(ws.listenerCount('foo'), 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("doesn't return listeners added with `addEventListener`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('open', NOOP); + + const listeners = ws.listeners('open'); + + assert.strictEqual(listeners.length, 1); + assert.strictEqual(listeners[0][kListener], NOOP); + + assert.strictEqual(ws.onopen, null); }); - it('adds listeners for custom events with `addEventListener`', () => { + it("doesn't remove listeners added with `addEventListener`", () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - ws.addEventListener('foo', NOOP); - assert.strictEqual(ws.listeners('foo')[0], NOOP); + ws.addEventListener('close', NOOP); + ws.onclose = NOOP; - // - // Fails silently when the `listener` is not a function. - // - ws.addEventListener('bar', {}); - assert.strictEqual(ws.listeners('bar').length, 0); - }); + let listeners = ws.listeners('close'); - it('allows to add one time listeners with `addEventListener`', (done) => { - const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0][kListener], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); - ws.addEventListener( - 'foo', - () => { - assert.strictEqual(ws.listenerCount('foo'), 0); - done(); - }, - { once: true } - ); + ws.onclose = NOOP; + + listeners = ws.listeners('close'); - assert.strictEqual(ws.listenerCount('foo'), 1); - ws.emit('foo'); + 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 }); + }); }); }); @@ -1893,10 +3870,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); }); }); @@ -1908,10 +3886,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); }); }); @@ -1924,27 +3903,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); }); }); @@ -1961,7 +3944,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) => { @@ -1976,7 +3962,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) => { @@ -1990,30 +3979,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); + }); }); }); }); @@ -2027,46 +4038,47 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ server }); wss.on('connection', () => { - wss.close(); server.close(done); }); server.listen(0, () => { - const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + const ws = new WebSocket(`wss://127.0.0.1:${server.address().port}`, { rejectUnauthorized: false }); + + ws.on('open', ws.close); }); }); it('connects to secure websocket server with client side certificate', (done) => { const server = https.createServer({ cert: fs.readFileSync('test/fixtures/certificate.pem'), - ca: [fs.readFileSync('test/fixtures/ca1-cert.pem')], + ca: [fs.readFileSync('test/fixtures/ca-certificate.pem')], key: fs.readFileSync('test/fixtures/key.pem'), 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, () => { const ws = new WebSocket(`wss://localhost:${server.address().port}`, { - cert: fs.readFileSync('test/fixtures/agent1-cert.pem'), - key: fs.readFileSync('test/fixtures/agent1-key.pem'), + cert: fs.readFileSync('test/fixtures/client-certificate.pem'), + key: fs.readFileSync('test/fixtures/client-key.pem'), rejectUnauthorized: false }); + + ws.on('open', ws.close); }); }); @@ -2097,10 +4109,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(); }); }); @@ -2109,7 +4121,10 @@ describe('WebSocket', () => { rejectUnauthorized: false }); - ws.on('open', () => ws.send('foobar')); + ws.on('open', () => { + ws.send('foobar'); + ws.close(); + }); }); }); @@ -2122,7 +4137,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, () => { @@ -2131,11 +4150,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); @@ -2151,11 +4170,44 @@ describe('WebSocket', () => { const ws = new WebSocket('wss://127.0.0.1', { servername: '' }); }); + + it("works around a double 'error' event bug in Node.js", function (done) { + // + // The `minVersion` and `maxVersion` options are not supported in + // Node.js < 10.16.0. + // + if (process.versions.modules < 64) return this.skip(); + + // + // The `'error'` event can be emitted multiple times by the + // `http.ClientRequest` object in Node.js < 13. This test reproduces the + // issue in Node.js 12. + // + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem'), + minVersion: 'TLSv1.2' + }); + const wss = new WebSocket.Server({ server }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + maxVersion: 'TLSv1.1', + rejectUnauthorized: false + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + server.close(done); + wss.close(); + }); + }); + }); }); describe('Request headers', () => { it('adds the authorization header if the url has userinfo', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); const userinfo = 'test:testpass'; agent.addRequest = (req) => { @@ -2170,7 +4222,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) => { @@ -2185,7 +4237,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'; @@ -2201,7 +4253,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'); @@ -2230,7 +4282,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); @@ -2241,7 +4293,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'); @@ -2255,7 +4307,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( @@ -2271,11 +4323,42 @@ describe('WebSocket', () => { 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( @@ -2289,7 +4372,7 @@ describe('WebSocket', () => { }); it('can be disabled', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); agent.addRequest = (req) => { assert.strictEqual( @@ -2306,7 +4389,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;' + @@ -2329,141 +4412,381 @@ 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 messages = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.on('close', () => { + assert.strictEqual(ws._receiver._state, 5); + }); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + messages.push(message.toString()); + }); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.send('foo'); + ws.send('bar'); + ws.send('baz'); + ws.send('qux', () => ws._socket.end()); + }); + }); + + it('consumes all received data when connection is closed (2/2)', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const messageLengths = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.prependListener('close', () => { + assert.strictEqual(ws._receiver._state, 5); + assert.strictEqual(ws._socket._readableState.length, 3); + }); + + const push = ws._socket.push; + + // Override `ws._socket.push()` to know exactly when data is + // received and call `ws.terminate()` immediately after that without + // relying on a timer. + ws._socket.push = (data) => { + ws._socket.push = push; + ws._socket.push(data); + ws.terminate(); + }; + + const payload1 = Buffer.alloc(highWaterMark - 1024); + const payload2 = Buffer.alloc(1); + + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; + + const list = [ + ...Sender.frame(payload1, { rsv1: false, ...opts }), + ...Sender.frame(payload2, { rsv1: true, ...opts }) + ]; + + for (let i = 0; i < 340; i++) { + list.push(list[list.length - 2], list[list.length - 1]); + } + + 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('close', (code) => { + assert.strictEqual(code, 1006); + assert.strictEqual(messageLengths.length, 343); + assert.strictEqual(messageLengths[0], highWaterMark - 1024); + assert.strictEqual(messageLengths[messageLengths.length - 1], 1); + wss.close(done); + }); + } + ); + }); + + it('handles a close frame received while compressing data', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { perMessageDeflate: { threshold: 0 } }); - ws.on('open', () => ws.send('hi', { compress: true })); - ws.on('message', (message) => { - assert.strictEqual(message, 'hi'); - wss.close(done); + ws.on('open', () => { + ws._receiver.on('conclude', () => { + assert.strictEqual(ws._sender._state, 1); + }); + + ws.send('foo'); + ws.send('bar'); + ws.send('baz'); + ws.send('qux'); + }); + } + ); + + wss.on('connection', (ws) => { + const messages = []; + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + messages.push(message.toString()); + }); + + ws.on('close', (code, reason) => { + assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); + assert.strictEqual(code, 1000); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + + ws.close(1000); + }); + }); + + describe('#close', () => { + it('can be used while data is being decompressed', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const messages = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + + if (messages.push(message.toString()) > 1) return; + + 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.deepStrictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + const buf = Buffer.from('c10100c10100c10100c10100', 'hex'); + ws._socket.write(buf); + }); + }); + }); + + describe('#send', () => { + it('can send text data', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send('hi', { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hi')); + assert.ok(!isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + ws.send(message, { binary: isBinary, compress: true }); }); + }); + }); + + it('can send a `TypedArray`', (done) => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 2; } - ); - wss.on('connection', (ws) => { - ws.on('message', (message) => ws.send(message, { compress: true })); - }); - }); + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); - it('can send and receive a `TypedArray`', (done) => { - const array = new Float32Array(5); + ws.on('open', () => { + ws.send(array, { compress: true }); + ws.close(); + }); - for (let i = 0; i < array.length; i++) { - array[i] = i / 2; - } + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + } + ); - const wss = new WebSocket.Server( - { - perMessageDeflate: { threshold: 0 }, - port: 0 - }, - () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - perMessageDeflate: { threshold: 0 } + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); }); + }); + }); - ws.on('open', () => ws.send(array, { compress: true })); - ws.on('message', (message) => { - assert.ok(message.equals(Buffer.from(array.buffer))); - wss.close(done); - }); + it('can send an `ArrayBuffer`', (done) => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 2; } - ); - wss.on('connection', (ws) => { - ws.on('message', (message) => ws.send(message, { compress: true })); - }); - }); + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); - it('can send and receive an `ArrayBuffer`', (done) => { - const array = new Float32Array(5); + ws.on('open', () => { + ws.send(array.buffer, { compress: true }); + ws.close(); + }); - for (let i = 0; i < array.length; i++) { - array[i] = i / 2; - } + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + } + ); - const wss = new WebSocket.Server( - { - perMessageDeflate: { threshold: 0 }, - port: 0 - }, - () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - perMessageDeflate: { threshold: 0 } + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); }); + }); + }); - ws.on('open', () => ws.send(array.buffer, { compress: true })); - ws.on('message', (message) => { - assert.ok(message.equals(Buffer.from(array.buffer))); - wss.close(done); - }); - } - ); + it('can send a `Blob`', function (done) { + if (!hasBlob) return this.skip(); - wss.on('connection', (ws) => { - ws.on('message', (message) => ws.send(message, { compress: true })); - }); - }); + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); - 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 messages = []; - ws.on('message', (message) => messages.push(message)); - ws.on('close', (code) => { - assert.strictEqual(code, 1006); - assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); - wss.close(done); - }); - } - ); + ws.on('open', () => { + ws.send(new Blob(['foo'])); + ws.send(new Blob(['bar'])); + ws.close(); + }); - wss.on('connection', (ws) => { - ws.send('foo'); - ws.send('bar'); - ws.send('baz'); - ws.send('qux', () => ws._socket.end()); + 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); + }); + }); }); - }); - describe('#send', () => { it('ignores the `compress` option if the extension is disabled', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { perMessageDeflate: false }); - 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 } }); @@ -2491,16 +4814,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(); }); }); @@ -2547,19 +4871,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); - process.nextTick(() => { - assert.strictEqual(ws._receiver._state, 5); - ws.terminate(); + if (messages.push(message.toString()) > 1) return; + + 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); }); } @@ -2572,4 +4900,172 @@ describe('WebSocket', () => { }); }); }); + + describe('Connection close', () => { + it('closes cleanly after simultaneous errors (1/2)', (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + + ws.on('open', () => { + // Write an invalid frame in both directions to trigger simultaneous + // failure. + const chunk = Buffer.from([0x85, 0x00]); + + wss.clients.values().next().value._socket.write(chunk); + ws._socket.write(chunk); + }); + }); + + wss.on('connection', (ws) => { + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + }); + }); + }); + + it('closes cleanly after simultaneous errors (2/2)', (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + + ws.on('open', () => { + // Write an invalid frame in both directions and change the + // `readyState` to `WebSocket.CLOSING`. + const chunk = Buffer.from([0x85, 0x00]); + const serverWs = wss.clients.values().next().value; + + serverWs._socket.write(chunk); + serverWs.close(); + + ws._socket.write(chunk); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + }); + }); + }); + + it('resumes the socket when an error occurs', (done) => { + const maxPayload = 16 * 1024; + const wss = new WebSocket.Server({ maxPayload, port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const list = [ + ...Sender.frame(Buffer.alloc(maxPayload + 1), { + fin: true, + opcode: 0x02, + mask: true, + readOnly: false + }) + ]; + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + }); + + ws._socket.push(Buffer.concat(list)); + }); + }); + + it('resumes the socket when the close frame is received', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const opts = { fin: true, mask: true, readOnly: false }; + const list = [ + ...Sender.frame(Buffer.alloc(16 * 1024), { opcode: 0x02, ...opts }), + ...Sender.frame(EMPTY_BUFFER, { opcode: 0x08, ...opts }) + ]; + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1005); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + + ws._socket.push(Buffer.concat(list)); + }); + }); + }); }); diff --git a/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;