From 0aecf0c95b41eaeeec9e265d5f1cb6c96e2f4d08 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 18 Jun 2021 21:21:01 +0200 Subject: [PATCH 001/207] [major] Validate subprotocol names Make the `WebSocket` constructor throw a `SyntaxError` if any of the subprotocol names are invalid or duplicated. --- lib/websocket.js | 49 ++++++++++++++++++++++++++++++------------ test/websocket.test.js | 22 +++++++++++++++---- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 7f1e3bcfa..37949de15 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -24,6 +24,7 @@ const { format, parse } = require('./extension'); const { toBuffer } = require('./buffer-util'); const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; +const subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; const protocolVersions = [8, 13]; const closeTimeout = 30 * 1000; @@ -61,11 +62,15 @@ 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); @@ -558,7 +563,7 @@ module.exports = WebSocket; * * @param {WebSocket} websocket The client to initialize * @param {(String|URL)} address The URL to which to connect - * @param {String} [protocols] The subprotocols + * @param {Array} protocols The subprotocols * @param {Object} [options] Connection options * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable * permessage-deflate @@ -623,6 +628,7 @@ function initAsClient(websocket, address, protocols, options) { const defaultPort = isSecure ? 443 : 80; const key = randomBytes(16).toString('base64'); const get = isSecure ? https.get : http.get; + const protocolSet = new Set(); let perMessageDeflate; opts.createConnection = isSecure ? tlsConnect : netConnect; @@ -651,8 +657,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) { @@ -739,15 +759,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) { + 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) { diff --git a/test/websocket.test.js b/test/websocket.test.js index a1a1cda43..3301902f3 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -26,6 +26,15 @@ describe('WebSocket', () => { ); }); + 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(); @@ -44,13 +53,18 @@ describe('WebSocket', () => { 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) => { @@ -651,7 +665,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 }); From 1877ddeb9f79a6204a2fc9c85c85aec470657037 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 20 Jun 2021 11:50:02 +0200 Subject: [PATCH 002/207] [major] Validate the Sec-WebSocket-Protocol header Abort the handshake if the `Sec-WebSocket-Protocol` header is invalid. --- doc/ws.md | 2 +- lib/extension.js | 23 +-------- lib/subprotocol.js | 62 ++++++++++++++++++++++++ lib/validation.js | 29 ++++++++++- lib/websocket-server.js | 62 +++++++++++++----------- test/subprotocol.test.js | 91 +++++++++++++++++++++++++++++++++++ test/websocket-server.test.js | 26 +++++++++- 7 files changed, 240 insertions(+), 55 deletions(-) create mode 100644 lib/subprotocol.js create mode 100644 test/subprotocol.test.js diff --git a/doc/ws.md b/doc/ws.md index a3b1bff81..668d0d7c4 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -120,7 +120,7 @@ if `verifyClient` is provided with two arguments then those are: `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. diff --git a/lib/extension.js b/lib/extension.js index 87a421329..0d833fee6 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 diff --git a/lib/subprotocol.js b/lib/subprotocol.js new file mode 100644 index 000000000..d4381e886 --- /dev/null +++ b/lib/subprotocol.js @@ -0,0 +1,62 @@ +'use strict'; + +const { tokenChars } = require('./validation'); + +/** + * Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names. + * + * @param {String} header The field value of the header + * @return {Set} The subprotocol names + * @public + */ +function parse(header) { + const protocols = new Set(); + let start = -1; + let end = -1; + let i = 0; + + for (i; i < header.length; i++) { + const code = header.charCodeAt(i); + + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if ( + i !== 0 && + (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ + ) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x2c /* ',' */) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + + const protocol = header.slice(start, end); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + + if (start === -1 || end !== -1) { + throw new SyntaxError('Unexpected end of input'); + } + + const protocol = header.slice(start, i); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + return protocols; +} + +module.exports = { parse }; diff --git a/lib/validation.js b/lib/validation.js index 169ac6f06..d09b1d39b 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -1,5 +1,28 @@ 'use strict'; +// +// Allowed token characters: +// +// '!', '#', '$', '%', '&', ''', '*', '+', '-', +// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~' +// +// tokenChars[32] === 0 // ' ' +// tokenChars[33] === 1 // '!' +// tokenChars[34] === 0 // '"' +// ... +// +// prettier-ignore +const tokenChars = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31 + 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63 + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127 +]; + /** * Checks if a status code is allowed in a close frame. * @@ -94,11 +117,13 @@ try { isValidStatusCode, isValidUTF8(buf) { return buf.length < 150 ? _isValidUTF8(buf) : isValidUTF8(buf); - } + }, + tokenChars }; } catch (e) /* istanbul ignore next */ { module.exports = { isValidStatusCode, - isValidUTF8: _isValidUTF8 + isValidUTF8: _isValidUTF8, + tokenChars }; } diff --git a/lib/websocket-server.js b/lib/websocket-server.js index fe7fdf501..cb93aeeb3 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -9,9 +9,10 @@ const net = require('net'); const tls = require('tls'); const { createHash } = require('crypto'); +const extension = require('./extension'); const PerMessageDeflate = require('./permessage-deflate'); +const subprotocol = require('./subprotocol'); const WebSocket = require('./websocket'); -const { format, parse } = require('./extension'); const { GUID, kWebSocket } = require('./constants'); const keyRegex = /^[+/0-9A-Za-z]{22}==$/; @@ -208,7 +209,7 @@ class WebSocketServer extends EventEmitter { const key = req.headers['sec-websocket-key'] !== undefined - ? req.headers['sec-websocket-key'].trim() + ? req.headers['sec-websocket-key'] : false; const version = +req.headers['sec-websocket-version']; const extensions = {}; @@ -224,6 +225,17 @@ class WebSocketServer extends EventEmitter { return abortHandshake(socket, 400); } + const secWebSocketProtocol = req.headers['sec-websocket-protocol']; + let protocols = new Set(); + + if (secWebSocketProtocol !== undefined) { + try { + protocols = subprotocol.parse(secWebSocketProtocol); + } catch (err) { + return abortHandshake(socket, 400); + } + } + if (this.options.perMessageDeflate) { const perMessageDeflate = new PerMessageDeflate( this.options.perMessageDeflate, @@ -232,7 +244,7 @@ class WebSocketServer extends EventEmitter { ); try { - const offers = parse(req.headers['sec-websocket-extensions']); + const offers = extension.parse(req.headers['sec-websocket-extensions']); if (offers[PerMessageDeflate.extensionName]) { perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); @@ -260,7 +272,15 @@ class WebSocketServer extends EventEmitter { return abortHandshake(socket, code || 401, message, headers); } - this.completeUpgrade(key, extensions, req, socket, head, cb); + this.completeUpgrade( + extensions, + key, + protocols, + req, + socket, + head, + cb + ); }); return; } @@ -268,14 +288,15 @@ class WebSocketServer extends EventEmitter { if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); } - this.completeUpgrade(key, extensions, req, socket, head, cb); + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); } /** * Upgrade the connection to WebSocket. * - * @param {String} key The value of the `Sec-WebSocket-Key` header * @param {Object} extensions The accepted extensions + * @param {String} key The value of the `Sec-WebSocket-Key` header + * @param {Set} protocols The subprotocols * @param {http.IncomingMessage} req The request object * @param {(net.Socket|tls.Socket)} socket The network socket between the * server and client @@ -284,7 +305,7 @@ class WebSocketServer extends EventEmitter { * @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. // @@ -311,19 +332,14 @@ class WebSocketServer extends EventEmitter { ]; const ws = new WebSocket(null); - let protocol = req.headers['sec-websocket-protocol']; - - if (protocol) { - protocol = protocol.split(',').map(trim); + if (protocols.size) { // // Optionally call external protocol selection handler. // - if (this.options.handleProtocols) { - protocol = this.options.handleProtocols(protocol, req); - } else { - protocol = protocol[0]; - } + const protocol = this.options.handleProtocols + ? this.options.handleProtocols(protocols, req) + : protocols.values().next().value; if (protocol) { headers.push(`Sec-WebSocket-Protocol: ${protocol}`); @@ -333,7 +349,7 @@ class WebSocketServer extends EventEmitter { if (extensions[PerMessageDeflate.extensionName]) { const params = extensions[PerMessageDeflate.extensionName].params; - const value = format({ + const value = extension.format({ [PerMessageDeflate.extensionName]: [params] }); headers.push(`Sec-WebSocket-Extensions: ${value}`); @@ -433,15 +449,3 @@ function abortHandshake(socket, code, message, headers) { socket.removeListener('error', socketOnError); socket.destroy(); } - -/** - * Remove whitespace characters from both ends of a string. - * - * @param {String} str The string - * @return {String} A new string representing `str` stripped of whitespace - * characters from both its beginning and end - * @private - */ -function trim(str) { - return str.trim(); -} diff --git a/test/subprotocol.test.js b/test/subprotocol.test.js new file mode 100644 index 000000000..91dd5d69d --- /dev/null +++ b/test/subprotocol.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const assert = require('assert'); + +const { parse } = require('../lib/subprotocol'); + +describe('subprotocol', () => { + describe('parse', () => { + it('parses a single subprotocol', () => { + assert.deepStrictEqual(parse('foo'), new Set(['foo'])); + }); + + it('parses multiple subprotocols', () => { + assert.deepStrictEqual( + parse('foo,bar,baz'), + new Set(['foo', 'bar', 'baz']) + ); + }); + + it('ignores the optional white spaces', () => { + const header = 'foo , bar\t, \tbaz\t , qux\t\t,norf'; + + assert.deepStrictEqual( + parse(header), + new Set(['foo', 'bar', 'baz', 'qux', 'norf']) + ); + }); + + it('throws an error if a subprotocol is empty', () => { + [ + [',', 0], + ['foo,,', 4], + ['foo, ,', 6] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a subprotocol is duplicated', () => { + ['foo,foo,bar', 'foo,bar,foo'].forEach((header) => { + assert.throws( + () => parse(header), + /^SyntaxError: The "foo" subprotocol is duplicated$/ + ); + }); + }); + + it('throws an error if a white space is misplaced', () => { + [ + ['f oo', 2], + [' foo', 0] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a subprotocol contains invalid characters', () => { + [ + ['f@o', 1], + ['f\\oo', 1], + ['foo,b@r', 5] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if the header value ends prematurely', () => { + ['foo ', 'foo, ', 'foo,bar ', 'foo,bar,'].forEach((header) => { + assert.throws( + () => parse(header), + /^SyntaxError: Unexpected end of input$/ + ); + }); + }); + }); +}); diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 90ceb5646..e0ab2114e 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -582,6 +582,30 @@ describe('WebSocketServer', () => { }); }); + 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); + wss.close(done); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + it('fails if the Sec-WebSocket-Extensions header is invalid', (done) => { const wss = new WebSocket.Server( { @@ -920,7 +944,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}`, [ From e814110ee6cd269a8982a529e38b55632c37ad28 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 22 Jun 2021 12:29:55 +0200 Subject: [PATCH 003/207] [major] Make the Sec-WebSocket-Extensions header parser stricter Make the parser throw an error if the header field value is empty or if it begins or ends with a white space. --- lib/extension.js | 13 +++++++------ lib/websocket-server.js | 11 ++++++++--- lib/websocket.js | 37 +++++++++++++++++-------------------- test/extension.test.js | 18 ++++++++++-------- 4 files changed, 42 insertions(+), 37 deletions(-) diff --git a/lib/extension.js b/lib/extension.js index 0d833fee6..3d7895c1b 100644 --- a/lib/extension.js +++ b/lib/extension.js @@ -26,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; @@ -36,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) { @@ -146,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/websocket-server.js b/lib/websocket-server.js index cb93aeeb3..e2f1af616 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -212,7 +212,6 @@ class WebSocketServer extends EventEmitter { ? req.headers['sec-websocket-key'] : false; const version = +req.headers['sec-websocket-version']; - const extensions = {}; if ( req.method !== 'GET' || @@ -236,7 +235,13 @@ class WebSocketServer extends EventEmitter { } } - if (this.options.perMessageDeflate) { + const secWebSocketExtensions = req.headers['sec-websocket-extensions']; + const extensions = {}; + + if ( + this.options.perMessageDeflate && + secWebSocketExtensions !== undefined + ) { const perMessageDeflate = new PerMessageDeflate( this.options.perMessageDeflate, true, @@ -244,7 +249,7 @@ class WebSocketServer extends EventEmitter { ); try { - const offers = extension.parse(req.headers['sec-websocket-extensions']); + const offers = extension.parse(secWebSocketExtensions); if (offers[PerMessageDeflate.extensionName]) { perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); diff --git a/lib/websocket.js b/lib/websocket.js index 37949de15..400a610d1 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -801,28 +801,25 @@ function initAsClient(websocket, address, protocols, options) { const extensionNames = Object.keys(extensions); - if (extensionNames.length) { - if ( - extensionNames.length !== 1 || - extensionNames[0] !== PerMessageDeflate.extensionName - ) { - const message = - 'Server indicated an extension that was not requested'; - abortHandshake(websocket, socket, message); - return; - } - - try { - perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); - } catch (err) { - const message = 'Invalid Sec-WebSocket-Extensions header'; - abortHandshake(websocket, socket, message); - return; - } + if ( + extensionNames.length !== 1 || + extensionNames[0] !== PerMessageDeflate.extensionName + ) { + const message = 'Server indicated an extension that was not requested'; + abortHandshake(websocket, socket, message); + return; + } - websocket._extensions[PerMessageDeflate.extensionName] = - perMessageDeflate; + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; } + + websocket._extensions[PerMessageDeflate.extensionName] = + perMessageDeflate; } websocket.setSocket(socket, head, opts.maxPayload); 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), From 552b50679590eb97b92101574f9fafe73c09cf9a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 22 Jun 2021 15:40:24 +0200 Subject: [PATCH 004/207] [major] Drop support for Node.js < 10 --- .github/workflows/ci.yml | 1 - appveyor.yml | 1 - lib/buffer-util.js | 9 +++------ lib/permessage-deflate.js | 9 +-------- lib/validation.js | 7 +------ package.json | 2 +- 6 files changed, 6 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 158a50e32..29e75259f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,6 @@ jobs: strategy: matrix: node: - - 8 - 10 - 12 - 14 diff --git a/appveyor.yml b/appveyor.yml index f4c05fbf4..ff8520944 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -4,7 +4,6 @@ environment: - nodejs_version: '14' - nodejs_version: '12' - nodejs_version: '10' - - nodejs_version: '8' platform: - x86 matrix: diff --git a/lib/buffer-util.js b/lib/buffer-util.js index 6fd84c311..1ba1d1beb 100644 --- a/lib/buffer-util.js +++ b/lib/buffer-util.js @@ -52,9 +52,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]; } } @@ -103,19 +101,18 @@ function toBuffer(data) { try { const bufferUtil = require('bufferutil'); - const bu = bufferUtil.BufferUtil || bufferUtil; module.exports = { concat, mask(source, mask, output, offset, length) { if (length < 48) _mask(source, mask, output, offset, length); - else bu.mask(source, mask, output, offset, length); + else bufferUtil.mask(source, mask, output, offset, length); }, toArrayBuffer, toBuffer, unmask(buffer, mask) { if (buffer.length < 32) _unmask(buffer, mask); - else bu.unmask(buffer, mask); + else bufferUtil.unmask(buffer, mask); } }; } catch (e) /* istanbul ignore next */ { diff --git a/lib/permessage-deflate.js b/lib/permessage-deflate.js index ce9178429..7fe9459ff 100644 --- a/lib/permessage-deflate.js +++ b/lib/permessage-deflate.js @@ -4,7 +4,7 @@ const zlib = require('zlib'); const bufferUtil = require('./buffer-util'); const Limiter = require('./limiter'); -const { kStatusCode, NOOP } = require('./constants'); +const { kStatusCode } = require('./constants'); const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); const kPerMessageDeflate = Symbol('permessage-deflate'); @@ -418,13 +418,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); } diff --git a/lib/validation.js b/lib/validation.js index d09b1d39b..ed98c7591 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -106,12 +106,7 @@ function _isValidUTF8(buf) { } try { - let isValidUTF8 = require('utf-8-validate'); - - /* istanbul ignore if */ - if (typeof isValidUTF8 === 'object') { - isValidUTF8 = isValidUTF8.Validation.isValidUTF8; // utf-8-validate@<3.0.0 - } + const isValidUTF8 = require('utf-8-validate'); module.exports = { isValidStatusCode, diff --git a/package.json b/package.json index d6dff1396..5318ff9e4 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "main": "index.js", "browser": "browser.js", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "files": [ "browser.js", From ebea038f82a8aa2ece7dc69d5e811e0d9deccfab Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 23 Jun 2021 11:40:58 +0200 Subject: [PATCH 005/207] [major] Throw an error if the connection URL is invalid Make the `WebSocket` constructor throw a `SyntaxError` if the URL contains a fragment identifier or if the URL's protocol is not one of `'ws:'`, `'wss:'`, or `'ws+unix:'`. --- lib/websocket.js | 24 +++++++++++++++++++----- test/websocket.test.js | 17 ++++++++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 400a610d1..0915fa4c3 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -613,18 +613,32 @@ function initAsClient(websocket, address, protocols, options) { parsedUrl = address; websocket._url = address.href; } else { - parsedUrl = new URL(address); + try { + parsedUrl = new URL(address); + } catch (e) { + throw new SyntaxError(`Invalid URL: ${address}`); + } + websocket._url = address; } + const isSecure = parsedUrl.protocol === 'wss:'; const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; - if (!parsedUrl.host && (!isUnixSocket || !parsedUrl.pathname)) { - throw new Error(`Invalid URL: ${websocket.url}`); + if (parsedUrl.protocol !== 'ws:' && !isSecure && !isUnixSocket) { + throw new SyntaxError( + 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"' + ); + } + + if (isUnixSocket && !parsedUrl.pathname) { + throw new SyntaxError("The URL's pathname is empty"); + } + + if (parsedUrl.hash) { + throw new SyntaxError('The URL contains a fragment identifier'); } - 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; diff --git a/test/websocket.test.js b/test/websocket.test.js index 3301902f3..5c4058e8d 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -20,9 +20,24 @@ 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('https://echo.websocket.org'), + /^SyntaxError: The URL's protocol must be one of "ws:", "wss:", or "ws\+unix:"$/ + ); + assert.throws( () => new WebSocket('ws+unix:'), - /^Error: Invalid URL: ws\+unix:$/ + /^SyntaxError: The URL's pathname is empty$/ + ); + + assert.throws( + () => new WebSocket('wss://echo.websocket.org#foo'), + /^SyntaxError: The URL contains a fragment identifier$/ ); }); From e173423c180dc1e4e6ee8938d9e4376a7a8b9757 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 2 Jul 2021 17:43:32 +0200 Subject: [PATCH 006/207] [major] Do not decode `Buffer`s to strings Avoid decoding text messages and close reasons to strings. Pass them as `Buffer`s to the listeners of their respective events. Also, make listeners of the `'message'` event take a boolean argument to speficy whether or not the message is binary. Refs: https://github.com/websockets/ws/issues/1878 Refs: https://github.com/websockets/ws/issues/1804 --- README.md | 12 +- bench/speed.js | 4 +- doc/ws.md | 14 +- examples/ssl.js | 2 +- lib/event-target.js | 9 +- lib/receiver.js | 8 +- lib/sender.js | 11 +- lib/stream.js | 7 +- lib/websocket.js | 14 +- test/autobahn-server.js | 4 +- test/autobahn.js | 4 +- test/create-websocket-stream.test.js | 38 ++++- test/receiver.test.js | 186 ++++++++++++----------- test/websocket-server.test.js | 10 +- test/websocket.test.js | 212 ++++++++++++++++----------- 15 files changed, 326 insertions(+), 209 deletions(-) diff --git a/README.md b/README.md index 1cb19d650..fd75b6533 100644 --- a/README.md +++ b/README.md @@ -149,8 +149,8 @@ ws.on('open', function open() { ws.send('something'); }); -ws.on('message', function incoming(data) { - console.log(data); +ws.on('message', function incoming(message) { + console.log('received: %s', message); }); ``` @@ -296,10 +296,10 @@ const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', function connection(ws) { - ws.on('message', function incoming(data) { + ws.on('message', function incoming(data, isBinary) { wss.clients.forEach(function each(client) { if (client.readyState === WebSocket.OPEN) { - client.send(data); + client.send(data, { binary: isBinary }); } }); }); @@ -315,10 +315,10 @@ const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', function connection(ws) { - ws.on('message', function incoming(data) { + ws.on('message', function incoming(data, isBinary) { wss.clients.forEach(function each(client) { if (client !== ws && client.readyState === WebSocket.OPEN) { - client.send(data); + client.send(data, { binary: isBinary }); } }); }); 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 668d0d7c4..275a9ea28 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -300,11 +300,12 @@ 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' @@ -315,9 +316,11 @@ of the string values defined below under [WS Error Codes](#ws-error-codes). ### Event: 'message' -- `data` {String|Buffer|ArrayBuffer|Buffer[]} +- `data` {Buffer|ArrayBuffer|Buffer[]} +- `isBinary` {Boolean} -Emitted when a message is received from the server. +Emitted when a message is received. `data` is the message content. `isBinary` +specifies whether the message is binary or not. ### Event: 'open' @@ -389,8 +392,7 @@ following ways: - `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. diff --git a/examples/ssl.js b/examples/ssl.js index c4d5b0758..ad08632b1 100644 --- a/examples/ssl.js +++ b/examples/ssl.js @@ -14,7 +14,7 @@ const wss = new WebSocket.Server({ server }); wss.on('connection', function connection(ws) { ws.on('message', function message(msg) { - console.log(msg); + console.log(msg.toString()); }); }); diff --git a/lib/event-target.js b/lib/event-target.js index a6fbe72b7..cc4f3ba97 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -128,12 +128,15 @@ const EventTarget = { addEventListener(type, listener, options) { if (typeof listener !== 'function') return; - function onMessage(data) { - listener.call(this, new MessageEvent(data, this)); + function onMessage(data, isBinary) { + listener.call( + this, + new MessageEvent(isBinary ? data : data.toString(), this) + ); } function onClose(code, message) { - listener.call(this, new CloseEvent(code, message, this)); + listener.call(this, new CloseEvent(code, message.toString(), this)); } function onError(error) { diff --git a/lib/receiver.js b/lib/receiver.js index 1d2af76e1..d678d6afa 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -499,7 +499,7 @@ class Receiver extends Writable { data = fragments; } - this.emit('message', data); + this.emit('message', data, true); } else { const buf = concat(fragments, messageLength); @@ -514,7 +514,7 @@ class Receiver extends Writable { ); } - this.emit('message', buf.toString()); + this.emit('message', buf, false); } } @@ -533,7 +533,7 @@ class Receiver extends Writable { this._loop = false; if (data.length === 0) { - this.emit('conclude', 1005, ''); + this.emit('conclude', 1005, EMPTY_BUFFER); this.end(); } else if (data.length === 1) { return error( @@ -568,7 +568,7 @@ class Receiver extends Writable { ); } - this.emit('conclude', code, buf.toString()); + this.emit('conclude', code, buf); this.end(); } } else if (this._opcode === 0x09) { diff --git a/lib/sender.js b/lib/sender.js index 441171c57..4f46f4d88 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -102,7 +102,7 @@ class Sender { * Sends a close message to the other peer. * * @param {Number} [code] The status code component of the body - * @param {String} [data] The message component of the body + * @param {(String|Buffer)} [data] The message component of the body * @param {Boolean} [mask=false] Specifies whether or not to mask the message * @param {Function} [cb] Callback * @public @@ -114,7 +114,7 @@ class Sender { buf = EMPTY_BUFFER; } else if (typeof code !== 'number' || !isValidStatusCode(code)) { throw new TypeError('First argument must be a valid error code number'); - } else if (data === undefined || data === '') { + } else if (data === undefined || !data.length) { buf = Buffer.allocUnsafe(2); buf.writeUInt16BE(code, 0); } else { @@ -126,7 +126,12 @@ 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) { diff --git a/lib/stream.js b/lib/stream.js index b0896ff83..0f85ba55d 100644 --- a/lib/stream.js +++ b/lib/stream.js @@ -72,8 +72,11 @@ function createWebSocketStream(ws, options) { writableObjectMode: false }); - ws.on('message', function message(msg) { - if (!duplex.push(msg)) { + ws.on('message', function message(msg, isBinary) { + const data = + !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; + + if (!duplex.push(data)) { resumeOnReceiverDrain = false; ws._socket.pause(); } diff --git a/lib/websocket.js b/lib/websocket.js index 0915fa4c3..c21a4ad68 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -48,7 +48,7 @@ class WebSocket extends EventEmitter { this._closeCode = 1006; this._closeFrameReceived = false; this._closeFrameSent = false; - this._closeMessage = ''; + this._closeMessage = EMPTY_BUFFER; this._closeTimer = null; this._extensions = {}; this._protocol = ''; @@ -264,7 +264,8 @@ class WebSocket extends EventEmitter { * +---+ * * @param {Number} [code] Status code explaining why the connection is closing - * @param {String} [data] A string explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing * @public */ close(code, data) { @@ -941,7 +942,7 @@ 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) { @@ -995,11 +996,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); } /** diff --git a/test/autobahn-server.js b/test/autobahn-server.js index 6e0be43ff..24ade1149 100644 --- a/test/autobahn-server.js +++ b/test/autobahn-server.js @@ -10,6 +10,8 @@ const wss = new WebSocket.Server({ port }, () => { }); wss.on('connection', (ws) => { - ws.on('message', (data) => ws.send(data)); + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); ws.on('error', (e) => console.error(e)); }); diff --git a/test/autobahn.js b/test/autobahn.js index cdda513a5..51532fc52 100644 --- a/test/autobahn.js +++ b/test/autobahn.js @@ -18,7 +18,9 @@ function nextTest() { ws = new WebSocket( `ws://localhost:9001/runCase?case=${currentTest}&agent=ws` ); - ws.on('message', (data) => ws.send(data)); + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); ws.on('close', () => { currentTest++; process.nextTick(nextTest); diff --git a/test/create-websocket-stream.test.js b/test/create-websocket-stream.test.js index b96ac9b1b..8aee1a18a 100644 --- a/test/create-websocket-stream.test.js +++ b/test/create-websocket-stream.test.js @@ -9,6 +9,7 @@ const { randomBytes } = require('crypto'); const createWebSocketStream = require('../lib/stream'); const Sender = require('../lib/sender'); const WebSocket = require('..'); +const { EMPTY_BUFFER } = require('../lib/constants'); describe('createWebSocketStream', () => { it('is exposed as a property of the `WebSocket` class', () => { @@ -58,11 +59,12 @@ describe('createWebSocketStream', () => { }); wss.on('connection', (ws) => { - ws.on('message', (message) => { + ws.on('message', (message, isBinary) => { ws.on('close', (code, reason) => { - assert.ok(message.equals(chunk)); + assert.deepStrictEqual(message, chunk); + assert.ok(isBinary); assert.strictEqual(code, 1005); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); wss.close(done); }); }); @@ -229,7 +231,7 @@ describe('createWebSocketStream', () => { ws._socket.write(Buffer.from([0x85, 0x00])); ws.on('close', (code, reason) => { assert.strictEqual(code, 1002); - assert.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (duplexCloseEventEmitted) wss.close(done); @@ -538,5 +540,33 @@ 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(); + }); + }); }); }); diff --git a/test/receiver.test.js b/test/receiver.test.js index cd5770dfb..0736c6100 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -4,18 +4,17 @@ const assert = require('assert'); const crypto = require('crypto'); 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, 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 +26,7 @@ describe('Receiver', () => { receiver.on('conclude', (code, data) => { assert.strictEqual(code, 1005); - assert.strictEqual(data, ''); + assert.strictEqual(data, EMPTY_BUFFER); done(); }); @@ -39,7 +38,7 @@ describe('Receiver', () => { receiver.on('conclude', (code, data) => { assert.strictEqual(code, 1000); - assert.strictEqual(data, 'DONE'); + assert.deepStrictEqual(data, Buffer.from('DONE')); done(); }); @@ -50,8 +49,9 @@ describe('Receiver', () => { it('parses a masked text message', (done) => { const receiver = new Receiver(undefined, {}, 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(); }); @@ -62,20 +62,21 @@ 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 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(); }); @@ -85,20 +86,21 @@ describe('Receiver', () => { it('parses a really long masked text message', (done) => { const receiver = new Receiver(undefined, {}, true); - const msg = 'A'.repeat(64 * 1024); + 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(); }); @@ -107,30 +109,31 @@ describe('Receiver', () => { it('parses a 300 B fragmented masked text message', (done) => { const receiver = new Receiver(undefined, {}, true); - const msg = 'A'.repeat(300); + 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(); }); @@ -140,20 +143,20 @@ describe('Receiver', () => { it('parses a ping message', (done) => { const receiver = new Receiver(undefined, {}, true); - const msg = 'Hello'; + 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 +167,7 @@ describe('Receiver', () => { const receiver = new Receiver(); receiver.on('ping', (data) => { - assert.ok(data.equals(Buffer.alloc(0))); + assert.strictEqual(data, EMPTY_BUFFER); done(); }); @@ -173,30 +176,30 @@ 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 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 +208,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); @@ -222,11 +226,11 @@ 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 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 +268,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) { @@ -293,8 +298,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(); }); @@ -315,8 +321,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(); }); @@ -337,8 +344,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 +367,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(); }); @@ -376,8 +385,9 @@ describe('Receiver', () => { }); 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(); }); @@ -399,8 +409,9 @@ describe('Receiver', () => { 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 +441,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(); }); @@ -441,9 +453,10 @@ describe('Receiver', () => { it('resets `totalPayloadLength` only on final frame (unfragmented)', (done) => { const receiver = new Receiver(undefined, {}, false, 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(); }); @@ -454,9 +467,10 @@ describe('Receiver', () => { it('resets `totalPayloadLength` only on final frame (fragmented)', (done) => { const receiver = new Receiver(undefined, {}, false, 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(); }); @@ -472,12 +486,13 @@ describe('Receiver', () => { 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(); }); @@ -499,7 +514,12 @@ describe('Receiver', () => { receiver.on('conclude', push).on('message', push); receiver.on('finish', () => { - assert.deepStrictEqual(results, ['', 1005, '']); + assert.deepStrictEqual(results, [ + EMPTY_BUFFER, + false, + 1005, + EMPTY_BUFFER + ]); done(); }); @@ -964,9 +984,9 @@ describe('Receiver', () => { crypto.randomBytes(3) ]; - receiver.on('message', (data) => { - assert.ok(Buffer.isBuffer(data)); - assert.ok(data.equals(Buffer.concat(frags))); + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.concat(frags)); + assert.ok(isBinary); done(); }); @@ -982,17 +1002,17 @@ describe('Receiver', () => { }); it("honors the 'arraybuffer' binary type", (done) => { - const receiver = new Receiver(); + const receiver = new Receiver('arraybuffer'); const frags = [ crypto.randomBytes(19221), crypto.randomBytes(954), crypto.randomBytes(623987) ]; - receiver._binaryType = 'arraybuffer'; - receiver.on('message', (data) => { + receiver.on('message', (data, isBinary) => { assert.ok(data instanceof ArrayBuffer); - assert.ok(Buffer.from(data).equals(Buffer.concat(frags))); + assert.deepStrictEqual(Buffer.from(data), Buffer.concat(frags)); + assert.ok(isBinary); done(); }); @@ -1008,7 +1028,7 @@ describe('Receiver', () => { }); it("honors the 'fragments' binary type", (done) => { - const receiver = new Receiver(); + const receiver = new Receiver('fragments'); const frags = [ crypto.randomBytes(17), crypto.randomBytes(419872), @@ -1017,9 +1037,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(); }); diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index e0ab2114e..5fc26780b 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -411,8 +411,9 @@ describe('WebSocketServer', () => { const ws = new WebSocket(`ws://localhost:${server.address().port}`); - ws.on('message', (message) => { - assert.strictEqual(message, 'hello'); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hello')); + assert.ok(!isBinary); wss.close(); server.close(done); }); @@ -932,8 +933,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); }); }); diff --git a/test/websocket.test.js b/test/websocket.test.js index 5c4058e8d..37768772e 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -11,7 +11,7 @@ const fs = require('fs'); const { URL } = require('url'); const WebSocket = require('..'); -const { GUID, NOOP } = require('../lib/constants'); +const { EMPTY_BUFFER, GUID, NOOP } = require('../lib/constants'); class CustomAgent extends http.Agent { addRequest() {} @@ -474,7 +474,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); clientCloseEventEmitted = true; if (serverClientCloseEventEmitted) wss.close(done); @@ -485,7 +485,7 @@ describe('WebSocket', () => { wss.on('connection', (ws) => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1002); - assert.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (clientCloseEventEmitted) wss.close(done); @@ -505,8 +505,8 @@ describe('WebSocket', () => { 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); + assert.strictEqual(message, EMPTY_BUFFER); wss.close(done); }); }); @@ -594,7 +594,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); done(); }); }); @@ -1401,15 +1401,19 @@ 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); + }); }); }); @@ -1418,14 +1422,17 @@ 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 }); + }); }); }); @@ -1440,8 +1447,9 @@ describe('WebSocket', () => { }); 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); }); }); @@ -1455,14 +1463,15 @@ describe('WebSocket', () => { }); 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); @@ -1480,32 +1489,19 @@ 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)); + 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)); - }); - }); - - 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)); - wss.close(done); + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); }); }); - - wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); - }); }); it('can send an `ArrayBuffer`', (done) => { @@ -1526,7 +1522,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); + }); }); }); @@ -1538,13 +1537,16 @@ describe('WebSocket', () => { ws.on('open', () => ws.send(buf)); 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); + }); }); }); @@ -1569,8 +1571,9 @@ describe('WebSocket', () => { }); 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); }); }); @@ -1586,7 +1589,7 @@ describe('WebSocket', () => { ws.on('open', () => ws.send('hi', { mask: false })); ws.on('close', (code, reason) => { assert.strictEqual(code, 1002); - assert.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); clientCloseEventEmitted = true; if (serverClientCloseEventEmitted) wss.close(done); @@ -1612,7 +1615,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (clientCloseEventEmitted) wss.close(done); @@ -1799,12 +1802,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); }); }); @@ -1821,8 +1827,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); }); }); @@ -1837,8 +1843,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); }); }); @@ -1854,7 +1860,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']); @@ -1921,7 +1930,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); }); @@ -2084,7 +2093,9 @@ describe('WebSocket', () => { }); wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + }); }); }); @@ -2203,7 +2214,9 @@ describe('WebSocket', () => { }); wss.on('connection', (ws) => { - ws.on('message', (msg) => ws.send(msg)); + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + }); }); }); @@ -2331,7 +2344,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); + }); }); }); }); @@ -2415,8 +2431,9 @@ 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(); }); @@ -2440,7 +2457,10 @@ 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); + }); }); server.listen(0, () => { @@ -2449,8 +2469,9 @@ 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(); @@ -2692,15 +2713,18 @@ describe('WebSocket', () => { }); ws.on('open', () => ws.send('hi', { compress: true })); - 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', (message) => ws.send(message, { compress: true })); + ws.on('message', (message, isBinary) => { + ws.send(message, { binary: isBinary, compress: true }); + }); }); }); @@ -2722,15 +2746,19 @@ describe('WebSocket', () => { }); ws.on('open', () => ws.send(array, { compress: true })); - ws.on('message', (message) => { - assert.ok(message.equals(Buffer.from(array.buffer))); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); wss.close(done); }); } ); wss.on('connection', (ws) => { - ws.on('message', (message) => ws.send(message, { compress: true })); + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); + }); }); }); @@ -2752,15 +2780,19 @@ describe('WebSocket', () => { }); ws.on('open', () => ws.send(array.buffer, { compress: true })); - ws.on('message', (message) => { - assert.ok(message.equals(Buffer.from(array.buffer))); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); wss.close(done); }); } ); wss.on('connection', (ws) => { - ws.on('message', (message) => ws.send(message, { compress: true })); + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); + }); }); }); @@ -2774,7 +2806,11 @@ 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, 1006); assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); @@ -2818,14 +2854,15 @@ describe('WebSocket', () => { wss.on('connection', (ws) => { const messages = []; - ws.on('message', (message) => { - messages.push(message); + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + messages.push(message.toString()); }); ws.on('close', (code, reason) => { assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); assert.strictEqual(code, 1000); - assert.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); wss.close(done); }); @@ -2850,8 +2887,10 @@ describe('WebSocket', () => { }); }); - ws.on('message', (message) => { - if (messages.push(message) > 1) return; + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + + if (messages.push(message.toString()) > 1) return; ws.close(1000); }); @@ -2859,7 +2898,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.deepStrictEqual(messages, ['', '', '', '']); assert.strictEqual(code, 1000); - assert.strictEqual(reason, ''); + assert.deepStrictEqual(reason, EMPTY_BUFFER); wss.close(done); }); } @@ -2880,14 +2919,17 @@ describe('WebSocket', () => { }); ws.on('open', () => ws.send('hi', { compress: true })); - 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', (message) => ws.send(message, { compress: true })); + ws.on('message', (message, isBinary) => { + ws.send(message, { binary: isBinary, compress: true }); + }); }); }); @@ -2979,8 +3021,10 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); const messages = []; - ws.on('message', (message) => { - if (messages.push(message) > 1) return; + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + + if (messages.push(message.toString()) > 1) return; process.nextTick(() => { assert.strictEqual(ws._receiver._state, 5); @@ -2991,7 +3035,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.deepStrictEqual(messages, ['', '', '', '']); assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); wss.close(done); }); } @@ -3023,7 +3067,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); clientCloseEventEmitted = true; if (serverClientCloseEventEmitted) wss.close(done); @@ -3051,7 +3095,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (clientCloseEventEmitted) wss.close(done); @@ -3077,7 +3121,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); clientCloseEventEmitted = true; if (serverClientCloseEventEmitted) wss.close(done); @@ -3109,7 +3153,7 @@ describe('WebSocket', () => { ws.on('close', (code, reason) => { assert.strictEqual(code, 1006); - assert.strictEqual(reason, ''); + assert.strictEqual(reason, EMPTY_BUFFER); serverClientCloseEventEmitted = true; if (clientCloseEventEmitted) wss.close(done); From 78adf5f7737b10166c6f4903e6a5cdbbbb59934c Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 6 Jul 2021 09:01:15 +0200 Subject: [PATCH 007/207] [major] Add an ES module wrapper Fixes #1886 --- .eslintrc.yaml | 3 +- README.md | 74 +++++++++++++++++++++++++------------------------- doc/ws.md | 8 +++--- package.json | 7 ++++- wrapper.mjs | 8 ++++++ 5 files changed, 57 insertions(+), 43 deletions(-) create mode 100644 wrapper.mjs diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 45998d206..f3d983b9c 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -7,7 +7,8 @@ extends: - eslint:recommended - plugin:prettier/recommended parserOptions: - ecmaVersion: 9 + ecmaVersion: latest + sourceType: module rules: no-console: off no-var: error diff --git a/README.md b/README.md index fd75b6533..a2ee9254e 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,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: { @@ -129,7 +129,7 @@ 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,7 +141,7 @@ 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'); @@ -157,7 +157,7 @@ ws.on('message', function incoming(message) { ### Sending binary data ```js -const WebSocket = require('ws'); +import WebSocket from 'ws'; const ws = new WebSocket('ws://www.host.com/path'); @@ -175,9 +175,9 @@ 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) { @@ -191,15 +191,15 @@ 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) { @@ -215,13 +215,13 @@ 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 { parse } from 'url'; +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) { // ... @@ -232,7 +232,7 @@ wss2.on('connection', function connection(ws) { }); server.on('upgrade', function upgrade(request, socket, head) { - const pathname = url.parse(request.url).pathname; + const { pathname } = parse(request.url); if (pathname === '/foo') { wss1.handleUpgrade(request, socket, head, function done(ws) { @@ -253,11 +253,11 @@ server.listen(8080); ### Client authentication ```js -const http = require('http'); -const WebSocket = require('ws'); +import WebSocket from 'ws'; +import { createServer } from 'http'; -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) { @@ -291,9 +291,9 @@ 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, isBinary) { @@ -310,9 +310,9 @@ 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, isBinary) { @@ -328,7 +328,7 @@ wss.on('connection', function connection(ws) { ### echo.websocket.org demo ```js -const WebSocket = require('ws'); +import WebSocket from 'ws'; const ws = new WebSocket('wss://echo.websocket.org/', { origin: 'https://websocket.org' @@ -355,13 +355,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 duplex = WebSocket.createWebSocketStream(ws, { encoding: 'utf8' }); +const duplex = createWebSocketStream(ws, { encoding: 'utf8' }); duplex.pipe(process.stdout); process.stdin.pipe(duplex); @@ -381,9 +381,9 @@ 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; @@ -409,7 +409,7 @@ 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'); +import { WebSocketServer } from 'ws'; function noop() {} @@ -417,7 +417,7 @@ 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; @@ -446,7 +446,7 @@ 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); diff --git a/doc/ws.md b/doc/ws.md index 275a9ea28..6e0c6ef9f 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -2,8 +2,8 @@ ## 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) @@ -57,11 +57,11 @@ - [WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH](#ws_err_unsupported_data_payload_length) - [WS_ERR_UNSUPPORTED_MESSAGE_LENGTH](#ws_err_unsupported_message_length) -## Class: WebSocket.Server +## Class: WebSocketServer This class represents a WebSocket server. It extends the `EventEmitter`. -### new WebSocket.Server(options[, callback]) +### new WebSocketServer(options[, callback]) - `options` {Object} - `host` {String} The hostname where to bind the server. diff --git a/package.json b/package.json index 5318ff9e4..32e9b7b9e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,10 @@ "author": "Einar Otto Stangvik (http://2x.io)", "license": "MIT", "main": "index.js", + "exports": { + "import": "./wrapper.mjs", + "require": "./index.js" + }, "browser": "browser.js", "engines": { "node": ">=10.0.0" @@ -23,7 +27,8 @@ "files": [ "browser.js", "index.js", - "lib/*.js" + "lib/*.js", + "wrapper.mjs" ], "scripts": { "test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js", 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; From df7de574a07115e2321fdb5fc9b2d0fea55d27e8 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 12 Jul 2021 15:52:59 +0200 Subject: [PATCH 008/207] [major] Do not close existing connections When `WebSocketServer.prototype.close()` is called, stop accepting new connections but do not close the existing ones. If the HTTP/S server was created internally, then close it and emit the `'close'` event when it closes. Otherwise, if client tracking is enabled, then emit the `'close'` event when the number of connections goes down to zero. Otherwise, emit it in the next tick. Refs: https://github.com/websockets/ws/pull/1902 --- doc/ws.md | 11 +- lib/websocket-server.js | 55 +++++--- test/sender.test.js | 25 ++++ test/websocket-server.test.js | 100 +++++++++------ test/websocket.test.js | 234 ++++++++++++++++++++++------------ 5 files changed, 284 insertions(+), 141 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 6e0c6ef9f..390596270 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -207,9 +207,14 @@ 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) diff --git a/lib/websocket-server.js b/lib/websocket-server.js index e2f1af616..b0cba8315 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -111,7 +111,11 @@ class WebSocketServer extends EventEmitter { } if (options.perMessageDeflate === true) options.perMessageDeflate = {}; - if (options.clientTracking) this.clients = new Set(); + if (options.clientTracking) { + this.clients = new Set(); + this._shouldEmitClose = false; + } + this.options = options; this._state = RUNNING; } @@ -135,9 +139,10 @@ 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) { @@ -151,29 +156,35 @@ class WebSocketServer extends EventEmitter { if (this._state === CLOSING) return; this._state = CLOSING; - // - // Terminate all associated clients. - // - if (this.clients) { - for (const client of this.clients) client.terminate(); - } + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } - const server = this._server; + if (this.clients) { + if (!this.clients.size) { + process.nextTick(emitClose, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(emitClose, this); + } + } else { + const server = this._server; - if (server) { this._removeListeners(); this._removeListeners = this._server = null; // - // Close the http server if it was internally created. + // The HTTP/S server was created internally. Close it, and rely on its + // `'close'` event. // - if (this.options.port != null) { - server.close(emitClose.bind(undefined, this)); - return; - } + server.close(() => { + emitClose(this); + }); } - - process.nextTick(emitClose, this); } /** @@ -373,7 +384,13 @@ class WebSocketServer extends EventEmitter { if (this.clients) { this.clients.add(ws); - ws.on('close', () => this.clients.delete(ws)); + ws.on('close', () => { + this.clients.delete(ws); + + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(emitClose, this); + } + }); } cb(ws, req); diff --git a/test/sender.test.js b/test/sender.test.js index 58eca8fbf..1c43dc5a5 100644 --- a/test/sender.test.js +++ b/test/sender.test.js @@ -266,6 +266,31 @@ describe('Sender', () => { }); 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 }); diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 5fc26780b..ece58cbea 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -75,6 +75,8 @@ describe('WebSocketServer', () => { }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); } ); @@ -103,6 +105,8 @@ describe('WebSocketServer', () => { const port = 1337; const wss = new WebSocket.Server({ port }, () => { const ws = new WebSocket(`ws://localhost:${port}`); + + ws.on('open', ws.close); }); wss.on('connection', () => wss.close(done)); @@ -120,12 +124,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); }); }); @@ -169,7 +175,11 @@ 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); } }); @@ -209,30 +219,13 @@ describe('WebSocketServer', () => { }); describe('#close', () => { - it('does not throw when called twice', (done) => { + it('does not throw if called multiple times', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { + wss.on('close', done); + wss.close(); wss.close(); wss.close(); - - done(); - }); - }); - - it('closes all clients', (done) => { - let closes = 0; - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('close', () => { - if (++closes === 2) done(); - }); - }); - - wss.on('connection', (ws) => { - ws.on('close', () => { - if (++closes === 2) done(); - }); - wss.close(); }); }); @@ -254,6 +247,8 @@ describe('WebSocketServer', () => { server.listen(0, () => { const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', ws.close); }); }); @@ -309,6 +304,16 @@ describe('WebSocketServer', () => { }); }); + it("emits the 'close' event if client tracking is disabled", (done) => { + const wss = new WebSocket.Server({ + noServer: true, + clientTracking: false + }); + + wss.on('close', done); + wss.close(); + }); + it("emits the 'close' event if the server is already closed", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { wss.close(() => { @@ -324,7 +329,10 @@ describe('WebSocketServer', () => { it('returns a list of connected clients', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { assert.strictEqual(wss.clients.size, 0); + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); }); wss.on('connection', () => { @@ -404,9 +412,10 @@ 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}`); @@ -414,7 +423,6 @@ describe('WebSocketServer', () => { ws.on('message', (message, isBinary) => { assert.deepStrictEqual(message, Buffer.from('hello')); assert.ok(!isBinary); - wss.close(); server.close(done); }); }); @@ -683,6 +691,7 @@ describe('WebSocketServer', () => { socket.once('data', (chunk) => { assert.strictEqual(chunk[0], 0x88); + socket.destroy(); wss.close(done); }); }); @@ -742,7 +751,6 @@ describe('WebSocketServer', () => { }); wss.on('connection', () => { - wss.close(); server.close(done); }); @@ -751,6 +759,8 @@ describe('WebSocketServer', () => { headers: { Origin: 'https://example.com', foo: 'bar' }, rejectUnauthorized: false }); + + ws.on('open', ws.close); }); }); @@ -762,6 +772,8 @@ describe('WebSocketServer', () => { }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); } ); @@ -959,6 +971,10 @@ describe('WebSocketServer', () => { wss.close(done); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); }); @@ -966,17 +982,19 @@ describe('WebSocketServer', () => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - 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, '/'); + + wss.on('connection', () => wss.close(done)); }); }); }); @@ -985,6 +1003,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) => { @@ -1016,6 +1036,10 @@ describe('WebSocketServer', () => { }); } ); + + wss.on('connection', (ws) => { + ws.close(); + }); }); }); }); diff --git a/test/websocket.test.js b/test/websocket.test.js index 37768772e..feb1d8c50 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -105,6 +105,10 @@ describe('WebSocket', () => { }); } ); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it('throws an error when using an invalid `protocolVersion`', () => { @@ -230,6 +234,10 @@ describe('WebSocket', () => { wss.close(done); }; }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it('takes into account the data in the sender queue', (done) => { @@ -258,6 +266,10 @@ describe('WebSocket', () => { }); } ); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it('takes into account the data in the socket queue', (done) => { @@ -526,6 +538,10 @@ describe('WebSocket', () => { wss.close(done); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it("emits a 'ping' event", (done) => { @@ -534,7 +550,10 @@ describe('WebSocket', () => { ws.on('ping', () => wss.close(done)); }); - wss.on('connection', (ws) => ws.ping()); + wss.on('connection', (ws) => { + ws.ping(); + ws.close(); + }); }); it("emits a 'pong' event", (done) => { @@ -543,7 +562,10 @@ describe('WebSocket', () => { ws.on('pong', () => wss.close(done)); }); - wss.on('connection', (ws) => ws.pong()); + wss.on('connection', (ws) => { + ws.pong(); + ws.close(); + }); }); }); @@ -977,7 +999,13 @@ describe('WebSocket', () => { const port = wss.address().port; const ws = new WebSocket(`ws://localhost:${port}/?token=qwerty`); - ws.on('open', () => wss.close(done)); + ws.on('open', () => { + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); }); }); @@ -986,7 +1014,13 @@ describe('WebSocket', () => { const port = wss.address().port; const ws = new WebSocket(`ws://localhost:${port}?token=qwerty`); - ws.on('open', () => wss.close(done)); + ws.on('open', () => { + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); }); }); }); @@ -1084,7 +1118,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(); + }); }); }); @@ -1103,7 +1140,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(); + }); }); }); @@ -1120,7 +1160,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) => { @@ -1144,6 +1187,10 @@ describe('WebSocket', () => { wss.close(done); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); }); @@ -1240,7 +1287,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(); + }); }); }); @@ -1259,7 +1309,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(); + }); }); }); @@ -1276,7 +1329,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) => { @@ -1300,6 +1356,10 @@ describe('WebSocket', () => { wss.close(done); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); }); @@ -1413,6 +1473,7 @@ describe('WebSocket', () => { ws.on('message', (msg, isBinary) => { assert.ok(isBinary); ws.send(msg); + ws.close(); }); }); }); @@ -1432,6 +1493,7 @@ describe('WebSocket', () => { wss.on('connection', (ws) => { ws.on('message', (msg, isBinary) => { ws.send(msg, { binary: isBinary }); + ws.close(); }); }); }); @@ -1443,6 +1505,7 @@ describe('WebSocket', () => { ws.on('open', () => { ws.send('fragment', { fin: false }); ws.send('fragment', { fin: true }); + ws.close(); }); }); @@ -1459,7 +1522,10 @@ 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) => { @@ -1488,7 +1554,11 @@ describe('WebSocket', () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send(partial)); + ws.on('open', () => { + ws.send(partial); + ws.close(); + }); + ws.on('message', (message, isBinary) => { assert.deepStrictEqual(message, buf); assert.ok(isBinary); @@ -1514,7 +1584,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); @@ -1534,7 +1608,10 @@ 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.deepStrictEqual(event.data, buf); @@ -1561,13 +1638,20 @@ describe('WebSocket', () => { }); }); }); + + wss.on('connection', (ws) => { + ws.close(); + }); }); it('works when the `data` argument is falsy', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => ws.send()); + ws.on('open', () => { + ws.send(); + ws.close(); + }); }); wss.on('connection', (ws) => { @@ -1743,51 +1827,6 @@ describe('WebSocket', () => { }); }); - it('throws an error if the first argument is invalid (1/2)', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - - ws.on('open', () => { - assert.throws( - () => ws.close('error'), - /^TypeError: First argument must be a valid error code number$/ - ); - - wss.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}`); - - ws.on('open', () => { - assert.throws( - () => ws.close(1004), - /^TypeError: First argument must be a valid error code number$/ - ); - - wss.close(done); - }); - }); - }); - - it('throws an error if the message is greater than 123 bytes', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - - ws.on('open', () => { - assert.throws( - () => ws.close(1000, 'a'.repeat(124)), - /^RangeError: The message must not be greater than 123 bytes$/ - ); - - wss.close(done); - }); - }); - }); - it('sends the close status code only when necessary', (done) => { let sent; const wss = new WebSocket.Server({ port: 0 }, () => { @@ -2206,7 +2245,11 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.addEventListener('open', () => ws.send('hi')); + ws.addEventListener('open', () => { + ws.send('hi'); + ws.close(); + }); + ws.addEventListener('message', (messageEvent) => { assert.strictEqual(messageEvent.data, 'hi'); wss.close(done); @@ -2262,7 +2305,7 @@ describe('WebSocket', () => { ws.addEventListener('message', (messageEvent) => { assert.strictEqual(messageEvent.type, 'message'); assert.strictEqual(messageEvent.target, ws); - wss.close(); + ws.close(); }); ws.addEventListener('close', (closeEvent) => { assert.strictEqual(closeEvent.type, 'close'); @@ -2275,7 +2318,7 @@ describe('WebSocket', () => { assert.strictEqual(errorEvent.target, ws); assert.strictEqual(errorEvent.error, err); - done(); + wss.close(done); }); }); @@ -2292,7 +2335,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) => { @@ -2307,7 +2353,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) => { @@ -2337,7 +2386,10 @@ describe('WebSocket', () => { ws.onopen = () => { testType('nodebuffer', () => { testType('arraybuffer', () => { - testType('fragments', () => wss.close(done)); + testType('fragments', () => { + ws.close(); + wss.close(done); + }); }); }); }; @@ -2361,7 +2413,6 @@ describe('WebSocket', () => { const wss = new WebSocket.Server({ server }); wss.on('connection', () => { - wss.close(); server.close(done); }); @@ -2369,6 +2420,8 @@ describe('WebSocket', () => { const ws = new WebSocket(`wss://127.0.0.1:${server.address().port}`, { rejectUnauthorized: false }); + + ws.on('open', ws.close); }); }); @@ -2392,7 +2445,6 @@ describe('WebSocket', () => { wss.on('connection', () => { assert.ok(success); server.close(done); - wss.close(); }); server.listen(0, () => { @@ -2401,6 +2453,8 @@ describe('WebSocket', () => { key: fs.readFileSync('test/fixtures/client-key.pem'), rejectUnauthorized: false }); + + ws.on('open', ws.close); }); }); @@ -2435,7 +2489,6 @@ describe('WebSocket', () => { assert.deepStrictEqual(message, Buffer.from('foobar')); assert.ok(!isBinary); server.close(done); - wss.close(); }); }); @@ -2444,7 +2497,10 @@ describe('WebSocket', () => { rejectUnauthorized: false }); - ws.on('open', () => ws.send('foobar')); + ws.on('open', () => { + ws.send('foobar'); + ws.close(); + }); }); }); @@ -2460,6 +2516,7 @@ describe('WebSocket', () => { ws.on('message', (message, isBinary) => { assert.ok(isBinary); ws.send(message); + ws.close(); }); }); @@ -2474,7 +2531,6 @@ describe('WebSocket', () => { assert.ok(isBinary); server.close(done); - wss.close(); }); }); }).timeout(4000); @@ -2712,7 +2768,11 @@ describe('WebSocket', () => { perMessageDeflate: { threshold: 0 } }); - ws.on('open', () => ws.send('hi', { compress: true })); + ws.on('open', () => { + ws.send('hi', { compress: true }); + ws.close(); + }); + ws.on('message', (message, isBinary) => { assert.deepStrictEqual(message, Buffer.from('hi')); assert.ok(!isBinary); @@ -2745,7 +2805,11 @@ describe('WebSocket', () => { perMessageDeflate: { threshold: 0 } }); - ws.on('open', () => ws.send(array, { compress: true })); + ws.on('open', () => { + ws.send(array, { compress: true }); + ws.close(); + }); + ws.on('message', (message, isBinary) => { assert.deepStrictEqual(message, Buffer.from(array.buffer)); assert.ok(isBinary); @@ -2779,7 +2843,11 @@ describe('WebSocket', () => { perMessageDeflate: { threshold: 0 } }); - ws.on('open', () => ws.send(array.buffer, { compress: true })); + ws.on('open', () => { + ws.send(array.buffer, { compress: true }); + ws.close(); + }); + ws.on('message', (message, isBinary) => { assert.deepStrictEqual(message, Buffer.from(array.buffer)); assert.ok(isBinary); @@ -2918,7 +2986,11 @@ describe('WebSocket', () => { perMessageDeflate: false }); - ws.on('open', () => ws.send('hi', { compress: true })); + ws.on('open', () => { + ws.send('hi', { compress: true }); + ws.close(); + }); + ws.on('message', (message, isBinary) => { assert.deepStrictEqual(message, Buffer.from('hi')); assert.ok(!isBinary); @@ -2934,10 +3006,10 @@ describe('WebSocket', () => { }); 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 } }); @@ -2966,15 +3038,15 @@ describe('WebSocket', () => { ); }); }); - - ws.on('close', () => { - assert.deepStrictEqual(called, [1, 2]); - wss.close(done); - }); } ); wss.on('connection', (ws) => { + ws.on('close', () => { + assert.deepStrictEqual(called, [1, 2]); + wss.close(done); + }); + ws._socket.end(); }); }); From abde9cfc21ce0f1cb7e2556aea70b423359364c7 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 12 Jul 2021 16:46:06 +0200 Subject: [PATCH 009/207] [major] Call the callback with an error if the server is closed Match the behavior of Node.js core `net.Server` and call the callback of `WebSocketServer.prototype.close()` with an error if the server is already closed. --- lib/websocket-server.js | 10 ++++++++-- test/websocket-server.test.js | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/websocket-server.js b/lib/websocket-server.js index b0cba8315..b147fe5d5 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -146,13 +146,19 @@ class WebSocketServer extends EventEmitter { * @public */ close(cb) { - if (cb) this.once('close', cb); - if (this._state === CLOSED) { + if (cb) { + this.once('close', () => { + cb(new Error('The server is not running')); + }); + } + process.nextTick(emitClose, this); return; } + if (cb) this.once('close', cb); + if (this._state === CLOSING) return; this._state = CLOSING; diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index ece58cbea..e3daf7e0b 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -315,11 +315,21 @@ describe('WebSocketServer', () => { }); it("emits the 'close' event if the server is already closed", (done) => { + let callbackCalled = false; const wss = new WebSocket.Server({ port: 0 }, () => { wss.close(() => { assert.strictEqual(wss._state, 2); - wss.on('close', done); - wss.close(); + + wss.on('close', () => { + callbackCalled = true; + }); + + wss.close((err) => { + assert.ok(callbackCalled); + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'The server is not running'); + done(); + }); }); }); }); From 64b3c71ee621737fcabb431d5113d78e1355af1c Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 15 Jul 2021 11:17:02 +0200 Subject: [PATCH 010/207] [pkg] Update mocha to version 8.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 32e9b7b9e..74558562c 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "eslint": "^7.2.0", "eslint-config-prettier": "^8.1.0", "eslint-plugin-prettier": "^3.0.1", - "mocha": "^7.0.0", + "mocha": "^8.4.0", "nyc": "^15.0.0", "prettier": "^2.0.5", "utf-8-validate": "^5.0.2" From 77a675c344e5c003cf0d0a4687a005cb75e4cc0f Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 15 Jul 2021 11:25:55 +0200 Subject: [PATCH 011/207] [minor] Remove unneeded setters Refs: https://github.com/websockets/ws/commit/ecb9d9ea --- lib/websocket.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index c21a4ad68..cb8077de5 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -125,9 +125,6 @@ class WebSocket extends EventEmitter { return undefined; } - /* istanbul ignore next */ - set onclose(listener) {} - /** * @type {Function} */ @@ -136,9 +133,6 @@ class WebSocket extends EventEmitter { return undefined; } - /* istanbul ignore next */ - set onerror(listener) {} - /** * @type {Function} */ @@ -147,9 +141,6 @@ class WebSocket extends EventEmitter { return undefined; } - /* istanbul ignore next */ - set onopen(listener) {} - /** * @type {Function} */ @@ -158,9 +149,6 @@ class WebSocket extends EventEmitter { return undefined; } - /* istanbul ignore next */ - set onmessage(listener) {} - /** * @type {String} */ From 9558ed1c73fa1c77237efa90b058f5d1d5e44d71 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 16 Jul 2021 17:05:50 +0200 Subject: [PATCH 012/207] [major] Make `WebSocket#addEventListener()` ignore non standard events Make `WebSocket.prototype.addEventListener()` a noop if the `type` argument is not one of `'close'`, `'error'`, `'message'`, or `'open'`. --- doc/ws.md | 4 ++- lib/constants.js | 3 ++- lib/event-target.js | 26 ++++++++---------- lib/websocket.js | 18 +++++++------ test/websocket.test.js | 60 +++++++++++++++++++----------------------- 5 files changed, 53 insertions(+), 58 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 390596270..110980816 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -370,7 +370,9 @@ handshake. This allows you to read headers from the server, for example 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 diff --git a/lib/constants.js b/lib/constants.js index 4082981f8..dce5dd21a 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -2,9 +2,10 @@ module.exports = { BINARY_TYPES: ['nodebuffer', 'arraybuffer', 'fragments'], + EMPTY_BUFFER: Buffer.alloc(0), GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + 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 cc4f3ba97..6987d5b5c 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -1,5 +1,7 @@ 'use strict'; +const { kListener } = require('./constants'); + /** * Class representing an event. * @@ -126,8 +128,6 @@ const EventTarget = { * @public */ addEventListener(type, listener, options) { - if (typeof listener !== 'function') return; - function onMessage(data, isBinary) { listener.call( this, @@ -150,19 +150,17 @@ const EventTarget = { const method = options && options.once ? 'once' : 'on'; if (type === 'message') { - onMessage._listener = listener; + onMessage[kListener] = listener; this[method](type, onMessage); } else if (type === 'close') { - onClose._listener = listener; + onClose[kListener] = listener; this[method](type, onClose); } else if (type === 'error') { - onError._listener = listener; + onError[kListener] = listener; this[method](type, onError); } else if (type === 'open') { - onOpen._listener = listener; + onOpen[kListener] = listener; this[method](type, onOpen); - } else { - this[method](type, listener); } }, @@ -170,15 +168,13 @@ 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} 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 === handler || listener[kListener] === handler) { + this.removeListener(type, listener); } } } diff --git a/lib/websocket.js b/lib/websocket.js index cb8077de5..bf33b6ce8 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -15,6 +15,7 @@ const { BINARY_TYPES, EMPTY_BUFFER, GUID, + kListener, kStatusCode, kWebSocket, NOOP @@ -522,22 +523,23 @@ Object.defineProperty(WebSocket.prototype, 'CLOSED', { Object.defineProperty(WebSocket.prototype, `on${method}`, { enumerable: true, get() { - const listeners = this.listeners(method); - for (let i = 0; i < listeners.length; i++) { - if (listeners[i]._listener) return listeners[i]._listener; + for (const listener of this.listeners(method)) { + if (listener[kListener]) return listener[kListener]; } return undefined; }, - set(listener) { - const listeners = this.listeners(method); - for (let i = 0; i < listeners.length; i++) { + set(handler) { + for (const listener of this.listeners(method)) { // // Remove only the listeners added via `addEventListener`. // - if (listeners[i]._listener) this.removeListener(method, listeners[i]); + if (listener[kListener]) this.removeListener(method, listener); } - this.addEventListener(method, listener); + + if (typeof handler !== 'function') return; + + this.addEventListener(method, handler); } }); }); diff --git a/test/websocket.test.js b/test/websocket.test.js index feb1d8c50..e409e8fca 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -11,7 +11,7 @@ const fs = require('fs'); const { URL } = require('url'); const WebSocket = require('..'); -const { EMPTY_BUFFER, GUID, NOOP } = require('../lib/constants'); +const { EMPTY_BUFFER, GUID, kListener, NOOP } = require('../lib/constants'); class CustomAgent extends http.Agent { addRequest() {} @@ -2157,7 +2157,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; @@ -2165,36 +2165,38 @@ describe('WebSocket', () => { assert.strictEqual(listeners.length, 2); assert.strictEqual(listeners[0], NOOP); - assert.strictEqual(listeners[1]._listener, NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); }); - it('adds listeners for custom events with `addEventListener`', () => { + it('supports the `addEventListener` method', () => { + const events = []; const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - ws.addEventListener('foo', NOOP); - assert.strictEqual(ws.listeners('foo')[0], NOOP); + ws.addEventListener('foo', () => {}); + assert.strictEqual(ws.listenerCount('foo'), 0); - // - // Fails silently when the `listener` is not a function. - // - ws.addEventListener('bar', {}); - assert.strictEqual(ws.listeners('bar').length, 0); - }); + ws.addEventListener('open', () => { + events.push('open'); + assert.strictEqual(ws.listenerCount('open'), 1); + }); - it('allows to add one time listeners with `addEventListener`', (done) => { - const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + assert.strictEqual(ws.listenerCount('open'), 1); ws.addEventListener( - 'foo', + 'message', () => { - assert.strictEqual(ws.listenerCount('foo'), 0); - done(); + events.push('message'); + assert.strictEqual(ws.listenerCount('message'), 0); }, { once: true } ); - assert.strictEqual(ws.listenerCount('foo'), 1); - ws.emit('foo'); + assert.strictEqual(ws.listenerCount('message'), 1); + + ws.emit('open'); + ws.emit('message', EMPTY_BUFFER, false); + + assert.deepStrictEqual(events, ['open', 'message']); }); it('supports the `removeEventListener` method', () => { @@ -2202,43 +2204,35 @@ describe('WebSocket', () => { ws.addEventListener('message', NOOP); ws.addEventListener('open', NOOP); - ws.addEventListener('foo', NOOP); - assert.strictEqual(ws.listeners('message')[0]._listener, NOOP); - assert.strictEqual(ws.listeners('open')[0]._listener, NOOP); - assert.strictEqual(ws.listeners('foo')[0], NOOP); + 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); 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); }); it('wraps text data in a `MessageEvent`', (done) => { From ea95d9c49cbd90776134cf796998b5c74a338134 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 16 Jul 2021 17:20:52 +0200 Subject: [PATCH 013/207] [major] Ignore listeners not added with `WebSocket#addEventListener()` Make `WebSocket.prototype.removeEventListener()` only remove listeners added with `WebSocket.prototype.addEventListener()` and only one at time. --- doc/ws.md | 4 +++- lib/event-target.js | 3 ++- test/websocket.test.js | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 110980816..d657ceeee 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -476,7 +476,9 @@ The current state of the connection. This is one of the ready state constants. - `type` {String} A string representing the event type to remove. - `listener` {Function} 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]) diff --git a/lib/event-target.js b/lib/event-target.js index 6987d5b5c..7230a2081 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -173,8 +173,9 @@ const EventTarget = { */ removeEventListener(type, handler) { for (const listener of this.listeners(type)) { - if (listener === handler || listener[kListener] === handler) { + if (listener[kListener] === handler) { this.removeListener(type, listener); + break; } } } diff --git a/test/websocket.test.js b/test/websocket.test.js index e409e8fca..1af00252b 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -2233,6 +2233,30 @@ describe('WebSocket', () => { assert.strictEqual(ws.listenerCount('message'), 0); assert.strictEqual(ws.listenerCount('open'), 0); + + // Multiple listeners. + ws.addEventListener('message', NOOP); + ws.addEventListener('message', NOOP); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + assert.strictEqual(ws.listeners('message')[1][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + + assert.strictEqual(ws.listenerCount('message'), 0); + + // Listeners not added with `websocket.addEventListener()`. + ws.on('message', NOOP); + + assert.deepStrictEqual(ws.listeners('message'), [NOOP]); + + ws.removeEventListener('message', NOOP); + + assert.deepStrictEqual(ws.listeners('message'), [NOOP]); }); it('wraps text data in a `MessageEvent`', (done) => { From 8c61563b5a5c73478e968ae92cea9fef92ef864c Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 16 Jul 2021 18:44:34 +0200 Subject: [PATCH 014/207] [fix] Make listeners added via event handler properties independent Prevent the `onclose`, `onerror`, `onmessage`, and `onopen` getters and setters from returning or removing event listeners added with `WebSocket.prototype.addEventListener()`. Also prevent `WebSocket.prototype.removeEventListener()` from removing event listeners added with the `onclose`, `onerror`, `onmessage`, and `onopen` setters. Refs: https://github.com/websockets/ws/issues/1818 --- lib/constants.js | 1 + lib/event-target.js | 63 +++++++++++++++++++++--------------------- lib/websocket.js | 15 ++++++---- test/websocket.test.js | 47 +++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 37 deletions(-) diff --git a/lib/constants.js b/lib/constants.js index dce5dd21a..d691b30a1 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -4,6 +4,7 @@ module.exports = { BINARY_TYPES: ['nodebuffer', 'arraybuffer', 'fragments'], EMPTY_BUFFER: Buffer.alloc(0), GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + kForOnEventAttribute: Symbol('kIsForOnEventAttribute'), kListener: Symbol('kListener'), kStatusCode: Symbol('status-code'), kWebSocket: Symbol('websocket'), diff --git a/lib/event-target.js b/lib/event-target.js index 7230a2081..b135463d4 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -1,6 +1,6 @@ 'use strict'; -const { kListener } = require('./constants'); +const { kForOnEventAttribute, kListener } = require('./constants'); /** * Class representing an event. @@ -127,40 +127,41 @@ const EventTarget = { * the listener would be automatically removed when invoked. * @public */ - addEventListener(type, listener, options) { - function onMessage(data, isBinary) { - listener.call( - this, - new MessageEvent(isBinary ? data : data.toString(), this) - ); - } - - function onClose(code, message) { - listener.call(this, new CloseEvent(code, message.toString(), this)); - } - - function onError(error) { - listener.call(this, new ErrorEvent(error, this)); - } - - function onOpen() { - listener.call(this, new OpenEvent(this)); - } - - const method = options && options.once ? 'once' : 'on'; + addEventListener( + type, + listener, + options = { once: false, [kForOnEventAttribute]: false } + ) { + let wrapper; if (type === 'message') { - onMessage[kListener] = listener; - this[method](type, onMessage); + wrapper = function onMessage(data, isBinary) { + const event = new MessageEvent(isBinary ? data : data.toString(), this); + listener.call(this, event); + }; } else if (type === 'close') { - onClose[kListener] = listener; - this[method](type, onClose); + wrapper = function onClose(code, message) { + listener.call(this, new CloseEvent(code, message.toString(), this)); + }; } else if (type === 'error') { - onError[kListener] = listener; - this[method](type, onError); + wrapper = function onError(error) { + listener.call(this, new ErrorEvent(error, this)); + }; } else if (type === 'open') { - onOpen[kListener] = listener; - this[method](type, onOpen); + wrapper = function onOpen() { + listener.call(this, new OpenEvent(this)); + }; + } else { + return; + } + + wrapper[kForOnEventAttribute] = options[kForOnEventAttribute]; + wrapper[kListener] = listener; + + if (options.once) { + this.once(type, wrapper); + } else { + this.on(type, wrapper); } }, @@ -173,7 +174,7 @@ const EventTarget = { */ removeEventListener(type, handler) { for (const listener of this.listeners(type)) { - if (listener[kListener] === handler) { + if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { this.removeListener(type, listener); break; } diff --git a/lib/websocket.js b/lib/websocket.js index bf33b6ce8..5b5ecd3cf 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -15,6 +15,7 @@ const { BINARY_TYPES, EMPTY_BUFFER, GUID, + kForOnEventAttribute, kListener, kStatusCode, kWebSocket, @@ -524,22 +525,24 @@ Object.defineProperty(WebSocket.prototype, 'CLOSED', { enumerable: true, get() { for (const listener of this.listeners(method)) { - if (listener[kListener]) return listener[kListener]; + if (listener[kForOnEventAttribute]) return listener[kListener]; } return undefined; }, set(handler) { for (const listener of this.listeners(method)) { - // - // Remove only the listeners added via `addEventListener`. - // - if (listener[kListener]) this.removeListener(method, listener); + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } } if (typeof handler !== 'function') return; - this.addEventListener(method, handler); + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); } }); }); diff --git a/test/websocket.test.js b/test/websocket.test.js index 1af00252b..3883d855a 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -2111,6 +2111,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, undefined); + assert.strictEqual(ws.listenerCount('onmessage'), 0); }); it('works like the `EventEmitter` interface', (done) => { @@ -2199,6 +2204,40 @@ describe('WebSocket', () => { 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, undefined); + }); + + it("doesn't remove listeners added with `addEventListener`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('close', NOOP); + ws.onclose = NOOP; + + let listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0][kListener], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + + ws.onclose = NOOP; + + listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0][kListener], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + }); + it('supports the `removeEventListener` method', () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); @@ -2257,6 +2296,14 @@ describe('WebSocket', () => { 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) => { From 6756cf58081c34388d5a523447988aecdee5e2fa Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 16 Jul 2021 19:18:07 +0200 Subject: [PATCH 015/207] [fix] Return `null` if the event handler is not set Make the `onclose`, `onerror`, `onmessage`, and `onopen` getters return `null` instead of `undefined` if the event handler is not set. --- lib/websocket.js | 10 +++++----- test/websocket.test.js | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 5b5ecd3cf..45a643f22 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -124,7 +124,7 @@ class WebSocket extends EventEmitter { */ /* istanbul ignore next */ get onclose() { - return undefined; + return null; } /** @@ -132,7 +132,7 @@ class WebSocket extends EventEmitter { */ /* istanbul ignore next */ get onerror() { - return undefined; + return null; } /** @@ -140,7 +140,7 @@ class WebSocket extends EventEmitter { */ /* istanbul ignore next */ get onopen() { - return undefined; + return null; } /** @@ -148,7 +148,7 @@ class WebSocket extends EventEmitter { */ /* istanbul ignore next */ get onmessage() { - return undefined; + return null; } /** @@ -528,7 +528,7 @@ Object.defineProperty(WebSocket.prototype, 'CLOSED', { if (listener[kForOnEventAttribute]) return listener[kListener]; } - return undefined; + return null; }, set(handler) { for (const listener of this.listeners(method)) { diff --git a/test/websocket.test.js b/test/websocket.test.js index 3883d855a..0841a97ae 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -2097,10 +2097,10 @@ describe('WebSocket', () => { const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); - assert.strictEqual(ws.onmessage, undefined); - assert.strictEqual(ws.onclose, undefined); - assert.strictEqual(ws.onerror, undefined); - assert.strictEqual(ws.onopen, undefined); + assert.strictEqual(ws.onmessage, null); + assert.strictEqual(ws.onclose, null); + assert.strictEqual(ws.onerror, null); + assert.strictEqual(ws.onopen, null); ws.onmessage = NOOP; ws.onerror = NOOP; @@ -2114,7 +2114,7 @@ describe('WebSocket', () => { ws.onmessage = 'foo'; - assert.strictEqual(ws.onmessage, undefined); + assert.strictEqual(ws.onmessage, null); assert.strictEqual(ws.listenerCount('onmessage'), 0); }); @@ -2149,7 +2149,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`", () => { @@ -2214,7 +2214,7 @@ describe('WebSocket', () => { assert.strictEqual(listeners.length, 1); assert.strictEqual(listeners[0][kListener], NOOP); - assert.strictEqual(ws.onopen, undefined); + assert.strictEqual(ws.onopen, null); }); it("doesn't remove listeners added with `addEventListener`", () => { From bd7febb685caaa240667cab8091d7af6128a62a9 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 19 Jul 2021 09:48:40 +0200 Subject: [PATCH 016/207] [minor] Fix nits --- lib/event-target.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/event-target.js b/lib/event-target.js index b135463d4..4cf076477 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -122,16 +122,12 @@ const EventTarget = { * @param {Function} listener The listener to add * @param {Object} [options] An options object specifies characteristics about * the event listener - * @param {Boolean} [options.once=false] A `Boolean`` indicating that the + * @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 = { once: false, [kForOnEventAttribute]: false } - ) { + addEventListener(type, listener, options = {}) { let wrapper; if (type === 'message') { @@ -155,7 +151,7 @@ const EventTarget = { return; } - wrapper[kForOnEventAttribute] = options[kForOnEventAttribute]; + wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; wrapper[kListener] = listener; if (options.once) { From 21e65004e0a0fc00f45bb428a8ec548f0a561cc0 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 20 Jul 2021 12:39:46 +0200 Subject: [PATCH 017/207] [major] Overhaul event classes - Remove non-standard `OpenEvent` class. - Make properties read-only. - Update constructor signatures to match the ones defined by the HTML standard. --- lib/event-target.js | 203 +++++++++++++++++++++--------- lib/websocket.js | 4 +- test/event-target.test.js | 253 ++++++++++++++++++++++++++++++++++++++ test/websocket.test.js | 61 +++++---- 4 files changed, 437 insertions(+), 84 deletions(-) create mode 100644 test/event-target.test.js diff --git a/lib/event-target.js b/lib/event-target.js index 4cf076477..d5abd83a0 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -2,112 +2,171 @@ 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 {*} + */ + get error() { + return this[kError]; + } + + /** + * @type {String} */ - constructor(target) { - super('open', target); + 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. @@ -132,20 +191,40 @@ const EventTarget = { if (type === 'message') { wrapper = function onMessage(data, isBinary) { - const event = new MessageEvent(isBinary ? data : data.toString(), this); + const event = new MessageEvent('message', { + data: isBinary ? data : data.toString() + }); + + event[kTarget] = this; listener.call(this, event); }; } else if (type === 'close') { wrapper = function onClose(code, message) { - listener.call(this, new CloseEvent(code, message.toString(), this)); + const event = new CloseEvent('close', { + code, + reason: message.toString(), + wasClean: this._closeFrameReceived && this._closeFrameSent + }); + + event[kTarget] = this; + listener.call(this, event); }; } else if (type === 'error') { wrapper = function onError(error) { - listener.call(this, new ErrorEvent(error, this)); + const event = new ErrorEvent('error', { + error, + message: error.message + }); + + event[kTarget] = this; + listener.call(this, event); }; } else if (type === 'open') { wrapper = function onOpen() { - listener.call(this, new OpenEvent(this)); + const event = new Event('open'); + + event[kTarget] = this; + listener.call(this, event); }; } else { return; @@ -178,4 +257,10 @@ const EventTarget = { } }; -module.exports = EventTarget; +module.exports = { + CloseEvent, + ErrorEvent, + Event, + EventTarget, + MessageEvent +}; diff --git a/lib/websocket.js b/lib/websocket.js index 45a643f22..3b2bacd28 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -21,7 +21,9 @@ const { 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'); 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/websocket.test.js b/test/websocket.test.js index 0841a97ae..82a25bfce 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -11,6 +11,12 @@ const fs = require('fs'); const { URL } = require('url'); const WebSocket = require('..'); +const { + CloseEvent, + ErrorEvent, + Event, + MessageEvent +} = require('../lib/event-target'); const { EMPTY_BUFFER, GUID, kListener, NOOP } = require('../lib/constants'); class CustomAgent extends http.Agent { @@ -2315,8 +2321,9 @@ describe('WebSocket', () => { ws.close(); }); - ws.addEventListener('message', (messageEvent) => { - assert.strictEqual(messageEvent.data, 'hi'); + ws.addEventListener('message', (event) => { + assert.ok(event instanceof MessageEvent); + assert.strictEqual(event.data, 'hi'); wss.close(done); }); }); @@ -2332,10 +2339,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); }); }); @@ -2347,10 +2355,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); }); }); @@ -2363,25 +2372,29 @@ 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); + 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); wss.close(done); }); From fc4024898c0da9b587c50a4a2d02d3ff88cddf06 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 28 Jul 2021 20:10:09 +0200 Subject: [PATCH 018/207] [dist] 8.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74558562c..2334d02a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "7.5.3", + "version": "8.0.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 04e74a1f8c27f639c4c761e92208133f26429144 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 3 Aug 2021 08:40:48 +0200 Subject: [PATCH 019/207] [license] Fix license text --- LICENSE | 2 -- 1 file changed, 2 deletions(-) diff --git a/LICENSE b/LICENSE index a145cd1df..65ff176bf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,3 @@ -The MIT License (MIT) - Copyright (c) 2011 Einar Otto Stangvik Permission is hereby granted, free of charge, to any person obtaining a copy From 6a72da3e636ea658a22b3fe80acb07336683c3c3 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 6 Aug 2021 18:49:48 +0200 Subject: [PATCH 020/207] [fix] Do not rely on undocumented behavior Use the chunk returned by `socket.read()` to handle the buffered data instead of relying on a `'data'` event emitted after the `'close'` event. Refs: https://github.com/nodejs/node/pull/39639 --- lib/websocket.js | 17 ++++++--- test/websocket.test.js | 82 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 3b2bacd28..670921eec 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -1031,10 +1031,13 @@ function socketOnClose() { const websocket = this[kWebSocket]; this.removeListener('close', socketOnClose); + this.removeListener('data', socketOnData); this.removeListener('end', socketOnEnd); websocket._readyState = WebSocket.CLOSING; + let chunk; + // // The close frame might not have been received or the `'end'` event emitted, // for example, if the socket was destroyed due to an error. Ensure that the @@ -1042,13 +1045,19 @@ function socketOnClose() { // it. If the readable side of the socket is in flowing mode then there is no // buffered data as everything has been already written and `readable.read()` // will return `null`. If instead, the socket is paused, any possible buffered - // data will be read as a single chunk and emitted synchronously in a single - // `'data'` event. + // data will be read as a single chunk. // - websocket._socket.read(); + if ( + !this._readableState.endEmitted && + !websocket._closeFrameReceived && + !websocket._receiver._writableState.errorEmitted && + (chunk = websocket._socket.read()) !== null + ) { + websocket._receiver.write(chunk); + } + websocket._receiver.end(); - this.removeListener('data', socketOnData); this[kWebSocket] = undefined; clearTimeout(websocket._closeTimer); diff --git a/test/websocket.test.js b/test/websocket.test.js index 82a25bfce..e8e02443c 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -10,6 +10,7 @@ const tls = require('tls'); const fs = require('fs'); const { URL } = require('url'); +const Sender = require('../lib/sender'); const WebSocket = require('..'); const { CloseEvent, @@ -2942,15 +2943,21 @@ describe('WebSocket', () => { }); }); - it('consumes all received data when connection is closed abnormally', (done) => { + it('consumes all received data when connection is closed (1/2)', (done) => { const wss = new WebSocket.Server( { perMessageDeflate: { threshold: 0 }, port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`); 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); @@ -2973,6 +2980,77 @@ describe('WebSocket', () => { }); }); + it('consumes all received data when connection is closed (2/2)', (done) => { + const payload1 = Buffer.alloc(15 * 1024); + const payload2 = Buffer.alloc(1); + + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; + + const list = [ + ...Sender.frame(payload1, { rsv1: false, ...opts }), + ...Sender.frame(payload2, { rsv1: true, ...opts }) + ]; + + for (let i = 0; i < 399; i++) { + list.push(list[list.length - 2], list[list.length - 1]); + } + + const data = Buffer.concat(list); + + 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; + + ws._socket.push = (data) => { + ws._socket.push = push; + ws._socket.push(data); + ws.terminate(); + }; + + // This hack is used because there is no guarantee that more than + // 16 KiB will be sent as a single TCP packet. + push.call(ws._socket, 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, 402); + assert.strictEqual(messageLengths[0], 15360); + assert.strictEqual(messageLengths[messageLengths.length - 1], 1); + wss.close(done); + }); + } + ); + }); + it('handles a close frame received while compressing data', (done) => { const wss = new WebSocket.Server( { From c95e695d35b1a469704035bc94fa2efee43d86cc Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 6 Aug 2021 21:15:41 +0200 Subject: [PATCH 021/207] [fix] Fix misleading error message Use the correct error message if the server sends an empty subprotocol name. --- lib/websocket.js | 2 +- test/websocket.test.js | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 670921eec..63cc5b389 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -771,7 +771,7 @@ function initAsClient(websocket, address, protocols, options) { const serverProt = res.headers['sec-websocket-protocol']; let protError; - if (serverProt) { + if (serverProt !== undefined) { if (!protocolSet.size) { protError = 'Server sent a subprotocol but none was requested'; } else if (!protocolSet.has(serverProt)) { diff --git a/test/websocket.test.js b/test/websocket.test.js index e8e02443c..be25329f5 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -895,7 +895,7 @@ describe('WebSocket', () => { }); }); - it('fails if server sends an invalid subprotocol', (done) => { + it('fails if server sends an invalid subprotocol (1/2)', (done) => { const wss = new WebSocket.Server({ handleProtocols: () => 'baz', server @@ -914,6 +914,36 @@ describe('WebSocket', () => { }); }); + it('fails if server sends an invalid subprotocol (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Protocol:\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent an invalid subprotocol'); + ws.on('close', () => done()); + }); + }); + it('fails if server sends no subprotocol', (done) => { const wss = new WebSocket.Server({ handleProtocols() {}, From fd47c961fb99e7647a76a41d53784972959f4229 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 7 Aug 2021 08:13:03 +0200 Subject: [PATCH 022/207] [test] Move code block closer to where it is used --- test/websocket.test.js | 45 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index be25329f5..51da54f76 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -3011,27 +3011,6 @@ describe('WebSocket', () => { }); it('consumes all received data when connection is closed (2/2)', (done) => { - const payload1 = Buffer.alloc(15 * 1024); - const payload2 = Buffer.alloc(1); - - const opts = { - fin: true, - opcode: 0x02, - mask: false, - readOnly: false - }; - - const list = [ - ...Sender.frame(payload1, { rsv1: false, ...opts }), - ...Sender.frame(payload2, { rsv1: true, ...opts }) - ]; - - for (let i = 0; i < 399; i++) { - list.push(list[list.length - 2], list[list.length - 1]); - } - - const data = Buffer.concat(list); - const wss = new WebSocket.Server( { perMessageDeflate: true, @@ -3049,15 +3028,37 @@ describe('WebSocket', () => { const push = ws._socket.push; + // Override `ws._socket.push()` to know exactly when data is + // received and call `ws.terminate()` immediately after that without + // relying on a timer. ws._socket.push = (data) => { ws._socket.push = push; ws._socket.push(data); ws.terminate(); }; + const payload1 = Buffer.alloc(15 * 1024); + const payload2 = Buffer.alloc(1); + + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; + + const list = [ + ...Sender.frame(payload1, { rsv1: false, ...opts }), + ...Sender.frame(payload2, { rsv1: true, ...opts }) + ]; + + for (let i = 0; i < 399; i++) { + list.push(list[list.length - 2], list[list.length - 1]); + } + // This hack is used because there is no guarantee that more than // 16 KiB will be sent as a single TCP packet. - push.call(ws._socket, data); + push.call(ws._socket, Buffer.concat(list)); wss.clients .values() From 7f0b5c42bbec7a25ee5c559c04e82c27b0953b70 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 9 Aug 2021 08:56:18 +0200 Subject: [PATCH 023/207] [example] Update uuid to version 8.3.2 --- examples/express-session-parse/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } } From 1e938f1a739b33d5936c2a02bcddcf14d18e2123 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 11 Aug 2021 08:23:37 +0200 Subject: [PATCH 024/207] [major] Use an options object instead of positional arguments Make the `Receiver` constructor take a single options object argument instead of multiple positional arguments. --- bench/parser.benchmark.js | 6 ++- lib/receiver.js | 22 +++++---- lib/websocket.js | 10 ++-- test/receiver.test.js | 98 +++++++++++++++++++++------------------ 4 files changed, 74 insertions(+), 62 deletions(-) diff --git a/bench/parser.benchmark.js b/bench/parser.benchmark.js index dd97701af..ee4954be4 100644 --- a/bench/parser.benchmark.js +++ b/bench/parser.benchmark.js @@ -36,7 +36,11 @@ 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 +}); suite.add('ping frame (5 bytes payload)', { defer: true, diff --git a/lib/receiver.js b/lib/receiver.js index d678d6afa..2cbb48a7d 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -28,20 +28,22 @@ class Receiver extends Writable { /** * Creates a Receiver instance. * - * @param {String} [binaryType=nodebuffer] The type for binary data - * @param {Object} [extensions] An object containing the negotiated extensions - * @param {Boolean} [isServer=false] Specifies whether to operate in client or - * server mode - * @param {Number} [maxPayload=0] The maximum allowed message length + * @param {Object} [options] Options object + * @param {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 */ - constructor(binaryType, extensions, isServer, maxPayload) { + constructor(options = {}) { super(); - this._binaryType = binaryType || BINARY_TYPES[0]; + this._binaryType = options.binaryType || BINARY_TYPES[0]; + this._extensions = options.extensions || {}; + this._isServer = !!options.isServer; + this._maxPayload = options.maxPayload | 0; this[kWebSocket] = undefined; - this._extensions = extensions || {}; - this._isServer = !!isServer; - this._maxPayload = maxPayload | 0; this._bufferedBytes = 0; this._buffers = []; diff --git a/lib/websocket.js b/lib/websocket.js index 63cc5b389..2d7b5c43b 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -184,12 +184,12 @@ class WebSocket extends EventEmitter { * @private */ setSocket(socket, head, maxPayload) { - const receiver = new Receiver( - this.binaryType, - this._extensions, - this._isServer, + const receiver = new Receiver({ + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, maxPayload - ); + }); this._sender = new Sender(socket, this._extensions); this._receiver = receiver; diff --git a/test/receiver.test.js b/test/receiver.test.js index 0736c6100..4f03cea02 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -47,7 +47,7 @@ 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, isBinary) => { assert.deepStrictEqual(data, Buffer.from('5:::{"name":"echo"}')); @@ -61,7 +61,7 @@ describe('Receiver', () => { }); it('parses a masked text message longer than 125 B', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); const msg = Buffer.from('A'.repeat(200)); const list = Sender.frame(msg, { @@ -85,7 +85,7 @@ describe('Receiver', () => { }); it('parses a really long masked text message', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); const msg = Buffer.from('A'.repeat(64 * 1024)); const list = Sender.frame(msg, { @@ -108,7 +108,7 @@ describe('Receiver', () => { }); it('parses a 300 B fragmented masked text message', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); const msg = Buffer.from('A'.repeat(300)); const fragment1 = msg.slice(0, 150); @@ -142,7 +142,7 @@ describe('Receiver', () => { }); it('parses a ping message', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); const msg = Buffer.from('Hello'); const list = Sender.frame(msg, { @@ -175,7 +175,7 @@ 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 receiver = new Receiver({ isServer: true }); const msg = Buffer.from('A'.repeat(300)); const pingMessage = Buffer.from('Hello'); @@ -225,7 +225,7 @@ 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 receiver = new Receiver({ isServer: true }); const msg = Buffer.from('A'.repeat(300)); const pingMessage = Buffer.from('Hello'); @@ -285,7 +285,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, { @@ -308,7 +308,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, { @@ -331,7 +331,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, { @@ -380,8 +380,10 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); const buf = Buffer.from('Hello'); @@ -403,8 +405,10 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); const buf1 = Buffer.from('foo'); const buf2 = Buffer.from('bar'); @@ -451,7 +455,7 @@ 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, isBinary) => { assert.strictEqual(receiver._totalPayloadLength, 0); @@ -465,7 +469,7 @@ 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, isBinary) => { assert.strictEqual(receiver._totalPayloadLength, 0); @@ -481,7 +485,7 @@ 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) => { @@ -506,8 +510,10 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); const results = []; const push = results.push.bind(results); @@ -549,8 +555,10 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); receiver.on('error', (err) => { @@ -675,8 +683,10 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); receiver.on('error', (err) => { @@ -711,7 +721,7 @@ describe('Receiver', () => { }); it('emits an error if a frame has the MASK bit off (server mode)', (done) => { - const receiver = new Receiver(undefined, {}, true); + const receiver = new Receiver({ isServer: true }); receiver.on('error', (err) => { assert.ok(err instanceof RangeError); @@ -728,7 +738,7 @@ describe('Receiver', () => { }); it('emits an error if a frame has the MASK bit on (client mode)', (done) => { - const receiver = new Receiver(undefined, {}, false); + const receiver = new Receiver(); receiver.on('error', (err) => { assert.ok(err instanceof RangeError); @@ -806,8 +816,10 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate(); perMessageDeflate.accept([{}]); - const receiver = new Receiver(undefined, { - 'permessage-deflate': perMessageDeflate + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } }); const buf = Buffer.from([0xce, 0xba, 0xe1, 0xbd]); @@ -884,7 +896,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, { @@ -912,14 +924,11 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate({}, false, 25); perMessageDeflate.accept([{}]); - const receiver = new Receiver( - undefined, - { - 'permessage-deflate': perMessageDeflate - }, - false, - 25 - ); + const receiver = new Receiver({ + extensions: { 'permessage-deflate': perMessageDeflate }, + isServer: false, + maxPayload: 25 + }); const buf = Buffer.from('A'.repeat(50)); receiver.on('error', (err) => { @@ -942,14 +951,11 @@ describe('Receiver', () => { const perMessageDeflate = new PerMessageDeflate({}, false, 25); perMessageDeflate.accept([{}]); - const receiver = new Receiver( - undefined, - { - 'permessage-deflate': perMessageDeflate - }, - false, - 25 - ); + const receiver = new Receiver({ + extensions: { 'permessage-deflate': perMessageDeflate }, + isServer: false, + maxPayload: 25 + }); const buf = Buffer.from('A'.repeat(15)); receiver.on('error', (err) => { @@ -1002,7 +1008,7 @@ describe('Receiver', () => { }); it("honors the 'arraybuffer' binary type", (done) => { - const receiver = new Receiver('arraybuffer'); + const receiver = new Receiver({ binaryType: 'arraybuffer' }); const frags = [ crypto.randomBytes(19221), crypto.randomBytes(954), @@ -1028,7 +1034,7 @@ describe('Receiver', () => { }); it("honors the 'fragments' binary type", (done) => { - const receiver = new Receiver('fragments'); + const receiver = new Receiver({ binaryType: 'fragments' }); const frags = [ crypto.randomBytes(17), crypto.randomBytes(419872), From 9bd3bd1251a4342511142a2cd32ce4a92b39a44c Mon Sep 17 00:00:00 2001 From: Ikko Ashimine Date: Wed, 11 Aug 2021 22:35:27 +0900 Subject: [PATCH 025/207] [minor] Fix typo (#1929) --- lib/receiver.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/receiver.js b/lib/receiver.js index 2cbb48a7d..fe0703b2d 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -427,7 +427,7 @@ 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; From d21c81034f53be7c5c292c5d2486eb3db41bc31a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 11 Aug 2021 15:48:52 +0200 Subject: [PATCH 026/207] [feature] Add ability to skip UTF-8 validation (#1928) Add the `skipUTF8Validation` option to skip UTF-8 validation for text and close messages. Refs: https://github.com/websockets/ws/issues/1878 Closes #1924 --- bench/parser.benchmark.js | 3 ++- doc/ws.md | 6 ++++++ lib/receiver.js | 7 +++++-- lib/websocket-server.js | 8 +++++++- lib/websocket.js | 18 ++++++++++++++---- test/receiver.test.js | 24 ++++++++++++++++++++++++ 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/bench/parser.benchmark.js b/bench/parser.benchmark.js index ee4954be4..a6e359d05 100644 --- a/bench/parser.benchmark.js +++ b/bench/parser.benchmark.js @@ -39,7 +39,8 @@ const suite = new benchmark.Suite(); const receiver = new Receiver({ binaryType: 'nodebuffer', extensions: {}, - isServer: true + isServer: true, + skipUTF8Validation: false }); suite.add('ping frame (5 bytes payload)', { diff --git a/doc/ws.md b/doc/ws.md index d657ceeee..22c712034 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -78,6 +78,9 @@ This class represents a WebSocket server. It extends the `EventEmitter`. - `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. + - `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. - `callback` {Function} Create a new server instance. One and only one of `port`, `server` or `noServer` @@ -273,6 +276,9 @@ This class represents a WebSocket. It extends the `EventEmitter`. - `origin` {String} Value of the `Origin` or `Sec-WebSocket-Origin` header depending on the `protocolVersion`. - `maxPayload` {Number} The maximum allowed message size in bytes. + - `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. diff --git a/lib/receiver.js b/lib/receiver.js index fe0703b2d..e11e26618 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -35,6 +35,8 @@ class Receiver extends Writable { * @param {Boolean} [options.isServer=false] Specifies whether to operate in * client or server mode * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages */ constructor(options = {}) { super(); @@ -43,6 +45,7 @@ class Receiver extends Writable { this._extensions = options.extensions || {}; this._isServer = !!options.isServer; this._maxPayload = options.maxPayload | 0; + this._skipUTF8Validation = !!options.skipUTF8Validation; this[kWebSocket] = undefined; this._bufferedBytes = 0; @@ -505,7 +508,7 @@ class Receiver extends Writable { } else { const buf = concat(fragments, messageLength); - if (!isValidUTF8(buf)) { + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { this._loop = false; return error( Error, @@ -560,7 +563,7 @@ class Receiver extends Writable { const buf = data.slice(2); - if (!isValidUTF8(buf)) { + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { return error( Error, 'invalid UTF-8 sequence', diff --git a/lib/websocket-server.js b/lib/websocket-server.js index b147fe5d5..3c7939f28 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -46,6 +46,8 @@ class WebSocketServer extends EventEmitter { * @param {Number} [options.port] The port where to bind the server * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S * server to use + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages * @param {Function} [options.verifyClient] A hook to reject connections * @param {Function} [callback] A listener for the `listening` event */ @@ -54,6 +56,7 @@ class WebSocketServer extends EventEmitter { options = { maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, perMessageDeflate: false, handleProtocols: null, clientTracking: true, @@ -386,7 +389,10 @@ 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, { + maxPayload: this.options.maxPayload, + skipUTF8Validation: this.options.skipUTF8Validation + }); if (this.clients) { this.clients.add(ws); diff --git a/lib/websocket.js b/lib/websocket.js index 2d7b5c43b..8d8f913e4 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -180,15 +180,19 @@ class WebSocket extends EventEmitter { * @param {(net.Socket|tls.Socket)} socket The network socket between the * server and client * @param {Buffer} head The first packet of the upgraded stream - * @param {Number} [maxPayload=0] The maximum allowed message size + * @param {Object} options Options object + * @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) { + setSocket(socket, head, options) { const receiver = new Receiver({ binaryType: this.binaryType, extensions: this._extensions, isServer: this._isServer, - maxPayload + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation }); this._sender = new Sender(socket, this._extensions); @@ -575,12 +579,15 @@ module.exports = WebSocket; * redirects * @param {Number} [options.maxRedirects=10] The maximum number of redirects * allowed + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages * @private */ function initAsClient(websocket, address, protocols, options) { const opts = { protocolVersion: protocolVersions[1], maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, perMessageDeflate: true, followRedirects: false, maxRedirects: 10, @@ -832,7 +839,10 @@ function initAsClient(websocket, address, protocols, options) { perMessageDeflate; } - websocket.setSocket(socket, head, opts.maxPayload); + websocket.setSocket(socket, head, { + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); }); } diff --git a/test/receiver.test.js b/test/receiver.test.js index 4f03cea02..7ee35f740 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -1059,4 +1059,28 @@ describe('Receiver', () => { }).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])); + }); }); From 142f0911b550f85741297f68f33af0dc72a7f043 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 11 Aug 2021 19:28:53 +0200 Subject: [PATCH 027/207] [dist] 8.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2334d02a4..d163e70e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.0.0", + "version": "8.1.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From f38247e5e00b8d18e736a9240388dbcdb1a474c9 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 12 Aug 2021 11:14:30 +0200 Subject: [PATCH 028/207] [doc] Sort options alphabetically --- doc/ws.md | 32 ++++++++++++++++---------------- lib/permessage-deflate.js | 16 ++++++++-------- lib/sender.js | 14 +++++++------- lib/websocket.js | 20 ++++++++++---------- 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 22c712034..08d70cc7a 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -64,23 +64,23 @@ This class represents a WebSocket server. It extends the `EventEmitter`. ### new WebSocketServer(options[, callback]) - `options` {Object} - - `host` {String} The hostname where to bind the server. - - `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. - - `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)) + - `clientTracking` {Boolean} Specifies whether or not to track clients. - `handleProtocols` {Function} A function which can be used to handle the WebSocket subprotocols. See description below. - - `path` {String} Accept only connections matching this path. + - `host` {String} The hostname where to bind the server. + - `maxPayload` {Number} The maximum allowed message size in bytes. - `noServer` {Boolean} Enable no server mode. - - `clientTracking` {Boolean} Specifies whether or not to track clients. + - `path` {String} Accept only connections matching this path. - `perMessageDeflate` {Boolean|Object} Enable/disable permessage-deflate. - - `maxPayload` {Number} The maximum allowed message size in bytes. + - `port` {Number} The port where to bind the server. + - `server` {http.Server|https.Server} A pre-created Node.js HTTP/S server. - `skipUTF8Validation` {Boolean} Specifies whether or not to skip UTF-8 validation for text and close messages. Defaults to `false`. Set to `true` only if clients are trusted. + - `verifyClient` {Function} A function which can be used to validate incoming + connections. See description below. (Usage is discouraged: see + [Issue #337](https://github.com/websockets/ws/issues/377#issuecomment-462152231)) - `callback` {Function} Create a new server instance. One and only one of `port`, `server` or `noServer` @@ -269,13 +269,13 @@ This class represents a WebSocket. It extends the `EventEmitter`. `false`. - `handshakeTimeout` {Number} Timeout in milliseconds for the handshake request. This is reset after every redirection. + - `maxPayload` {Number} The maximum allowed message size in 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. + - `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. @@ -491,14 +491,14 @@ only removes listeners added with - `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The data to send. - `options` {Object} - - `compress` {Boolean} Specifies whether `data` should be compressed or not. - Defaults to `true` when permessage-deflate is enabled. - `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. diff --git a/lib/permessage-deflate.js b/lib/permessage-deflate.js index 7fe9459ff..9e6003d07 100644 --- a/lib/permessage-deflate.js +++ b/lib/permessage-deflate.js @@ -30,22 +30,22 @@ class PerMessageDeflate { * Creates a PerMessageDeflate instance. * * @param {Object} [options] Configuration options - * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept - * disabling of server context takeover + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support + * for, or request, a custom client window size * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ * acknowledge disabling of client context takeover + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the * use of a custom server window size - * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support - * for, or request, a custom client window size + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on * deflate * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on * inflate - * @param {Number} [options.threshold=1024] Size (in bytes) below which - * messages should not be compressed - * @param {Number} [options.concurrencyLimit=10] The number of concurrent - * calls to zlib * @param {Boolean} [isServer=false] Create the instance in either server or * client mode * @param {Number} [maxPayload=0] The maximum allowed message length diff --git a/lib/sender.js b/lib/sender.js index 4f46f4d88..15df9d065 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -40,13 +40,13 @@ class Sender { * * @param {Buffer} data The data to frame * @param {Object} options Options object - * @param {Number} options.opcode The opcode - * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be - * modified * @param {Boolean} [options.fin=false] Specifies whether or not to set the * FIN bit * @param {Boolean} [options.mask=false] Specifies whether or not to mask * `data` + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the * RSV1 bit * @return {Buffer[]} The framed data as a list of `Buffer` instances @@ -255,10 +255,10 @@ class Sender { * * @param {*} data The message to send * @param {Object} options Options object - * @param {Boolean} [options.compress=false] Specifies whether or not to - * compress `data` * @param {Boolean} [options.binary=false] Specifies whether `data` is binary * or text + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` * @param {Boolean} [options.fin=false] Specifies whether the fragment is the * last one * @param {Boolean} [options.mask=false] Specifies whether or not to mask @@ -321,12 +321,12 @@ class Sender { * `data` * @param {Object} options Options object * @param {Number} options.opcode The opcode - * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be - * modified * @param {Boolean} [options.fin=false] Specifies whether or not to set the * FIN bit * @param {Boolean} [options.mask=false] Specifies whether or not to mask * `data` + * @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 diff --git a/lib/websocket.js b/lib/websocket.js index 8d8f913e4..f0b6eb531 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -378,10 +378,10 @@ class WebSocket extends EventEmitter { * * @param {*} data The message to send * @param {Object} [options] Options object - * @param {Boolean} [options.compress] Specifies whether or not to compress - * `data` * @param {Boolean} [options.binary] Specifies whether `data` is binary or * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` * @param {Boolean} [options.fin=true] Specifies whether the fragment is the * last one * @param {Boolean} [options.mask] Specifies whether or not to mask `data` @@ -565,20 +565,20 @@ module.exports = WebSocket; * @param {(String|URL)} address The URL to which to connect * @param {Array} protocols The subprotocols * @param {Object} [options] Connection options - * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable - * permessage-deflate + * @param {Boolean} [options.followRedirects=false] Whether or not to follow + * redirects * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the * handshake request - * @param {Number} [options.protocolVersion=13] Value of the - * `Sec-WebSocket-Version` header - * @param {String} [options.origin] Value of the `Origin` or - * `Sec-WebSocket-Origin` header * @param {Number} [options.maxPayload=104857600] The maximum allowed message * size - * @param {Boolean} [options.followRedirects=false] Whether or not to follow - * redirects * @param {Number} [options.maxRedirects=10] The maximum number of redirects * allowed + * @param {String} [options.origin] Value of the `Origin` or + * `Sec-WebSocket-Origin` header + * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable + * permessage-deflate + * @param {Number} [options.protocolVersion=13] Value of the + * `Sec-WebSocket-Version` header * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or * not to skip UTF-8 validation for text and close messages * @private From c677aab97852f11bf146fc4e46fe5bdfd4c727d8 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 15 Aug 2021 20:17:18 +0200 Subject: [PATCH 029/207] [doc] Fix `createWebSocketStream()` documentation The ES module wrapper exports `createWebSocketStream()` as a named export and not as a static method of the `WebSocket` class. --- doc/ws.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 08d70cc7a..a4680702f 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -43,7 +43,7 @@ - [websocket.send(data[, options][, callback])](#websocketsenddata-options-callback) - [websocket.terminate()](#websocketterminate) - [websocket.url](#websocketurl) -- [WebSocket.createWebSocketStream(websocket[, options])](#websocketcreatewebsocketstreamwebsocket-options) +- [createWebSocketStream(websocket[, options])](#createwebsocketstreamwebsocket-options) - [WS Error Codes](#ws-error-codes) - [WS_ERR_EXPECTED_FIN](#ws_err_expected_fin) - [WS_ERR_EXPECTED_MASK](#ws_err_expected_mask) @@ -514,7 +514,7 @@ Forcibly close the connection. Internally this calls [socket.destroy()][]. The URL of the WebSocket server. Server clients don't have this attribute. -## WebSocket.createWebSocketStream(websocket[, options]) +## createWebSocketStream(websocket[, options]) - `websocket` {WebSocket} A `WebSocket` object. - `options` {Object} [Options][duplex-options] to pass to the `Duplex` From d5e3549a0350dd94103af88f6fb329d9a27ce193 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 17 Aug 2021 11:55:51 +0200 Subject: [PATCH 030/207] [minor] Add `WebSocket.WebSocket{,Server}` aliases (#1935) Add `WebSocket.WebSocket` as an alias for `WebSocket` and `WebSocket.WebSocketServer` as an alias for `WebSocket.Server` to fix name consistency and improve interoperability with the ES module wrapper. Refs: https://github.com/websockets/ws/issues/1877 Refs: https://github.com/websockets/ws/issues/1932 --- index.js | 3 +++ 1 file changed, 3 insertions(+) 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; From 7647a8920b6a7ada107c28be68e4e82393dac893 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 18 Aug 2021 07:20:48 +0200 Subject: [PATCH 031/207] [dist] 8.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d163e70e1..e04602be6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.1.0", + "version": "8.2.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From ea6c054e975a715b83a8ca20e5af1bbcf80f90e5 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 21 Aug 2021 15:50:20 +0200 Subject: [PATCH 032/207] [test] Reorganize some tests --- test/websocket.test.js | 214 ++++++++++++++++++++--------------------- 1 file changed, 107 insertions(+), 107 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index 51da54f76..888a57715 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -2866,113 +2866,6 @@ describe('WebSocket', () => { }); }); - it('can send and receive 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 and receive a `TypedArray`', (done) => { - const array = new Float32Array(5); - - for (let i = 0; i < array.length; i++) { - array[i] = i / 2; - } - - const wss = new WebSocket.Server( - { - perMessageDeflate: { threshold: 0 }, - port: 0 - }, - () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - perMessageDeflate: { threshold: 0 } - }); - - ws.on('open', () => { - ws.send(array, { compress: true }); - ws.close(); - }); - - ws.on('message', (message, isBinary) => { - assert.deepStrictEqual(message, Buffer.from(array.buffer)); - assert.ok(isBinary); - wss.close(done); - }); - } - ); - - wss.on('connection', (ws) => { - ws.on('message', (message, isBinary) => { - assert.ok(isBinary); - ws.send(message, { compress: true }); - }); - }); - }); - - it('can send and receive an `ArrayBuffer`', (done) => { - const array = new Float32Array(5); - - for (let i = 0; i < array.length; i++) { - array[i] = i / 2; - } - - const wss = new WebSocket.Server( - { - perMessageDeflate: { threshold: 0 }, - port: 0 - }, - () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - perMessageDeflate: { threshold: 0 } - }); - - ws.on('open', () => { - ws.send(array.buffer, { compress: true }); - ws.close(); - }); - - ws.on('message', (message, isBinary) => { - assert.deepStrictEqual(message, Buffer.from(array.buffer)); - assert.ok(isBinary); - wss.close(done); - }); - } - ); - - wss.on('connection', (ws) => { - ws.on('message', (message, isBinary) => { - assert.ok(isBinary); - ws.send(message, { compress: true }); - }); - }); - }); - it('consumes all received data when connection is closed (1/2)', (done) => { const wss = new WebSocket.Server( { @@ -3167,6 +3060,113 @@ describe('WebSocket', () => { }); describe('#send', () => { + it('can send text data', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send('hi', { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hi')); + assert.ok(!isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + ws.send(message, { binary: isBinary, compress: true }); + }); + }); + }); + + it('can send a `TypedArray`', (done) => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 2; + } + + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send(array, { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); + }); + }); + }); + + it('can send an `ArrayBuffer`', (done) => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 2; + } + + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send(array.buffer, { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); + }); + }); + }); + it('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}`, { From 869c9892cd5f1f574fae3181231e462e1d4ab740 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 28 Aug 2021 16:42:49 +0200 Subject: [PATCH 033/207] [fix] Resume the socket in the next tick Ensure that `socket.resume()` is called after `socket.pause()`. Fixes #1940 --- lib/websocket.js | 22 +++++++++++++++-- test/websocket.test.js | 56 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index f0b6eb531..aac87296f 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -1,3 +1,5 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Readable$" }] */ + 'use strict'; const EventEmitter = require('events'); @@ -6,6 +8,7 @@ const http = require('http'); const net = require('net'); const tls = require('tls'); const { randomBytes, createHash } = require('crypto'); +const { Readable } = require('stream'); const { URL } = require('url'); const PerMessageDeflate = require('./permessage-deflate'); @@ -954,7 +957,7 @@ function receiverOnConclude(code, reason) { const websocket = this[kWebSocket]; websocket._socket.removeListener('data', socketOnData); - websocket._socket.resume(); + process.nextTick(resume, websocket._socket); websocket._closeFrameReceived = true; websocket._closeMessage = reason; @@ -983,7 +986,12 @@ function receiverOnError(err) { const websocket = this[kWebSocket]; websocket._socket.removeListener('data', socketOnData); - websocket._socket.resume(); + + // + // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See + // https://github.com/websockets/ws/issues/1940. + // + process.nextTick(resume, websocket._socket); websocket.close(err[kStatusCode]); websocket.emit('error', err); @@ -1032,6 +1040,16 @@ function receiverOnPong(data) { this[kWebSocket].emit('pong', data); } +/** + * Resume a readable stream + * + * @param {Readable} stream The readable stream + * @private + */ +function resume(stream) { + stream.resume(); +} + /** * The listener of the `net.Socket` `'close'` event. * diff --git a/test/websocket.test.js b/test/websocket.test.js index 888a57715..4b85c7948 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -3308,7 +3308,7 @@ describe('WebSocket', () => { }); }); - describe('Connection close edge cases', () => { + describe('Connection close', () => { it('closes cleanly after simultaneous errors (1/2)', (done) => { let clientCloseEventEmitted = false; let serverClientCloseEventEmitted = false; @@ -3420,5 +3420,59 @@ describe('WebSocket', () => { }); }); }); + + it('resumes the socket when an error occurs', (done) => { + const maxPayload = 16 * 1024; + const wss = new WebSocket.Server({ maxPayload, port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const list = [ + ...Sender.frame(Buffer.alloc(maxPayload + 1), { + fin: true, + opcode: 0x02, + mask: true, + readOnly: false + }) + ]; + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + }); + + ws._socket.push(Buffer.concat(list)); + }); + }); + + it('resumes the socket when the close frame is received', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const opts = { fin: true, mask: true, readOnly: false }; + const list = [ + ...Sender.frame(Buffer.alloc(16 * 1024), { opcode: 0x02, ...opts }), + ...Sender.frame(EMPTY_BUFFER, { opcode: 0x08, ...opts }) + ]; + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1005); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + + ws._socket.push(Buffer.concat(list)); + }); + }); }); }); From cc7a7798b749cf263636abcba4ba19532161c3ea Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 28 Aug 2021 17:07:15 +0200 Subject: [PATCH 034/207] [dist] 8.2.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e04602be6..97424848c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.2.0", + "version": "8.2.1", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From ec9377ca745e51cf182c11550d4c39872e3d307f Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 30 Aug 2021 20:20:45 +0200 Subject: [PATCH 035/207] [minor] Skip unnecessary operations if the socket is already closed There is no need to remove the already removed `socketOnData` listener, resume the socket, and call `websocket.close()` if the socket is already closed. --- lib/websocket.js | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index aac87296f..818f26914 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -956,13 +956,15 @@ function sendAfterClose(websocket, data, cb) { function receiverOnConclude(code, reason) { const websocket = this[kWebSocket]; - websocket._socket.removeListener('data', socketOnData); - process.nextTick(resume, websocket._socket); - 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); } @@ -985,15 +987,18 @@ function receiverOnDrain() { function receiverOnError(err) { const websocket = this[kWebSocket]; - websocket._socket.removeListener('data', socketOnData); + if (websocket._socket[kWebSocket] !== undefined) { + websocket._socket.removeListener('data', socketOnData); - // - // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See - // https://github.com/websockets/ws/issues/1940. - // - process.nextTick(resume, websocket._socket); + // + // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See + // https://github.com/websockets/ws/issues/1940. + // + process.nextTick(resume, websocket._socket); + + websocket.close(err[kStatusCode]); + } - websocket.close(err[kStatusCode]); websocket.emit('error', err); } From 48c2cb36df1555745063633ced0a4a07d7f8c245 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 31 Aug 2021 08:49:52 +0200 Subject: [PATCH 036/207] [pkg] Update eslint-plugin-prettier to version 4.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 97424848c..27aff6cac 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "bufferutil": "^4.0.1", "eslint": "^7.2.0", "eslint-config-prettier": "^8.1.0", - "eslint-plugin-prettier": "^3.0.1", + "eslint-plugin-prettier": "^4.0.0", "mocha": "^8.4.0", "nyc": "^15.0.0", "prettier": "^2.0.5", From 04c032c51b3219f76a3a48897027e38f4a9f5f50 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 31 Aug 2021 09:55:03 +0200 Subject: [PATCH 037/207] [ci] Use Github Actions for Windows x86 testing --- .github/workflows/ci.yml | 17 ++++++++++++++--- README.md | 1 - appveyor.yml | 18 ------------------ 3 files changed, 14 insertions(+), 22 deletions(-) delete mode 100644 appveyor.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29e75259f..52f5f9aa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,9 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: + arch: + - x64 + - x86 node: - 10 - 12 @@ -18,14 +21,22 @@ jobs: - macOS-latest - ubuntu-latest - windows-latest + exclude: + - arch: x86 + os: macOS-latest + - arch: x86 + os: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: ${{ matrix.node }} + architecture: ${{ matrix.arch }} - run: npm install - run: npm run lint - if: matrix.node == 16 && matrix.os == 'ubuntu-latest' + if: + matrix.os == 'ubuntu-latest' && matrix.node == 16 && matrix.arch == + 'x64' - run: npm test - run: echo ::set-output name=job_id::$(node -e @@ -35,8 +46,8 @@ jobs: - uses: coverallsapp/github-action@v1.1.2 with: flag-name: - ${{ steps.get_job_id.outputs.job_id }} (Node.js ${{ matrix.node }} - on ${{ matrix.os }}) + ${{ steps.get_job_id.outputs.job_id }} (Node.js ${{ matrix.node }} - + $ {{ matrix.arch }} on ${{ matrix.os }}) github-token: ${{ secrets.GITHUB_TOKEN }} parallel: true coverage: diff --git a/README.md b/README.md index a2ee9254e..b00ec1e92 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws) [![Build](https://img.shields.io/github/workflow/status/websockets/ws/CI/master?label=build&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) -[![Windows x86 Build](https://img.shields.io/appveyor/ci/lpinca/ws/master.svg?logo=appveyor)](https://ci.appveyor.com/project/lpinca/ws) [![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws) ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index ff8520944..000000000 --- a/appveyor.yml +++ /dev/null @@ -1,18 +0,0 @@ -environment: - matrix: - - nodejs_version: '16' - - nodejs_version: '14' - - nodejs_version: '12' - - nodejs_version: '10' -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 From 3039b6b9d2b290eca74f67e444dc7aa44e574f00 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 4 Sep 2021 20:44:38 +0200 Subject: [PATCH 038/207] [doc] Change label text to CI --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b00ec1e92..a0317031b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ws: a Node.js WebSocket library [![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws) -[![Build](https://img.shields.io/github/workflow/status/websockets/ws/CI/master?label=build&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) +[![CI](https://img.shields.io/github/workflow/status/websockets/ws/CI/master?label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) [![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws) ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and From 72296e54cad6b105f901b82174f86837b4bcd414 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 8 Sep 2021 21:42:28 +0200 Subject: [PATCH 039/207] [dist] 8.2.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 27aff6cac..1e26e491f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.2.1", + "version": "8.2.2", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From f871195c55cff39ac51020ea344ec6a2c4ea9f4e Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 9 Sep 2021 21:44:52 +0200 Subject: [PATCH 040/207] [doc] Update issue template --- .github/ISSUE_TEMPLATE/bug_report.yml | 48 +++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/issue_template.md | 41 ----------------------- 3 files changed, 49 insertions(+), 41 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/issue_template.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..9f437067f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,48 @@ +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: + 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: - - From 5b85322f5490fdaed950ad6b8d7ad381077164df Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 11 Sep 2021 21:42:52 +0200 Subject: [PATCH 041/207] [ci] Update coverallsapp/github-action action to version 1.1.3 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52f5f9aa1..66ddf9cc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: "console.log(crypto.randomBytes(16).toString('hex'))") id: get_job_id shell: bash - - uses: coverallsapp/github-action@v1.1.2 + - uses: coverallsapp/github-action@1.1.3 with: flag-name: ${{ steps.get_job_id.outputs.job_id }} (Node.js ${{ matrix.node }} - @@ -54,7 +54,7 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: coverallsapp/github-action@v1.1.2 + - uses: coverallsapp/github-action@1.1.3 with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true From 055949fd23b0d7f6a23ba9b9532b7834909df192 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 13 Sep 2021 20:29:42 +0200 Subject: [PATCH 042/207] [doc] Remove no longer needed noop function from code snippet --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index a0317031b..c6b71110e 100644 --- a/README.md +++ b/README.md @@ -410,8 +410,6 @@ endpoint is still responsive. ```js import { WebSocketServer } from 'ws'; -function noop() {} - function heartbeat() { this.isAlive = true; } @@ -428,7 +426,7 @@ const interval = setInterval(function ping() { if (ws.isAlive === false) return ws.terminate(); ws.isAlive = false; - ws.ping(noop); + ws.ping(); }); }, 30000); From 474aa3616fc9ebbb23369c7c3d3760f68d72b56e Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 28 Sep 2021 16:30:19 +0200 Subject: [PATCH 043/207] [doc] Improve `WebSocket#{p{i,o}ng,send}()` documentation Specify that an error is thrown if the ready state is `CONNECTING` and that if an error occurs, the callback is called with the error as its first argument. Fixes #1953 --- doc/ws.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index a4680702f..b9569497d 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -450,9 +450,10 @@ receives an `OpenEvent` named "open". - `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]) @@ -461,9 +462,10 @@ Send a ping. - `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 @@ -500,9 +502,11 @@ only removes listeners added with - `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() From 41ae56313be61a8c2344e714603eb6da56d5ea99 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 29 Sep 2021 16:41:32 +0200 Subject: [PATCH 044/207] [fix] Ignore the `threshold` option if context takeover is enabled When context takeover is enabled, compress messages even if their size is below the value of `threshold` option. Refs: https://github.com/websockets/ws/issues/1950 --- README.md | 2 +- doc/ws.md | 4 +- lib/permessage-deflate.js | 2 +- lib/sender.js | 10 +- test/permessage-deflate.test.js | 23 +-- test/sender.test.js | 260 +++++++++++++++++--------------- 6 files changed, 159 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index c6b71110e..b78b1f665 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ const wss = new WebSocketServer({ // 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. } }); ``` diff --git a/doc/ws.md b/doc/ws.md index b9569497d..fc14e7e97 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -147,8 +147,8 @@ 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. diff --git a/lib/permessage-deflate.js b/lib/permessage-deflate.js index 9e6003d07..504069719 100644 --- a/lib/permessage-deflate.js +++ b/lib/permessage-deflate.js @@ -41,7 +41,7 @@ class PerMessageDeflate { * @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 + * 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 diff --git a/lib/sender.js b/lib/sender.js index 15df9d065..c1e357c78 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -274,7 +274,15 @@ class Sender { if (this._firstFragment) { this._firstFragment = false; - if (rsv1 && perMessageDeflate) { + if ( + rsv1 && + perMessageDeflate && + perMessageDeflate.params[ + perMessageDeflate._isServer + ? 'server_no_context_takeover' + : 'client_no_context_takeover' + ] + ) { rsv1 = buf.length >= perMessageDeflate._threshold; } this._compress = rsv1; diff --git a/test/permessage-deflate.test.js b/test/permessage-deflate.test.js index a547762ca..a9c9bf165 100644 --- a/test/permessage-deflate.test.js +++ b/test/permessage-deflate.test.js @@ -344,7 +344,7 @@ describe('PerMessageDeflate', () => { describe('#compress and #decompress', () => { it('works with unfragmented messages', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); const buf = Buffer.from([1, 2, 3]); perMessageDeflate.accept([{}]); @@ -361,7 +361,7 @@ describe('PerMessageDeflate', () => { }); it('works with fragmented messages', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); const buf = Buffer.from([1, 2, 3, 4]); perMessageDeflate.accept([{}]); @@ -388,7 +388,6 @@ describe('PerMessageDeflate', () => { it('works with the negotiated parameters', (done) => { const perMessageDeflate = new PerMessageDeflate({ - threshold: 0, memLevel: 5, level: 9 }); @@ -415,11 +414,9 @@ describe('PerMessageDeflate', () => { it('honors the `level` option', (done) => { const lev0 = new PerMessageDeflate({ - threshold: 0, zlibDeflateOptions: { level: 0 } }); const lev9 = new PerMessageDeflate({ - threshold: 0, zlibDeflateOptions: { level: 9 } }); const extensionStr = @@ -459,7 +456,6 @@ describe('PerMessageDeflate', () => { it('honors the `zlib{Deflate,Inflate}Options` option', (done) => { const lev0 = new PerMessageDeflate({ - threshold: 0, zlibDeflateOptions: { level: 0, chunkSize: 256 @@ -469,7 +465,6 @@ describe('PerMessageDeflate', () => { } }); const lev9 = new PerMessageDeflate({ - threshold: 0, zlibDeflateOptions: { level: 9, chunkSize: 128 @@ -523,7 +518,7 @@ describe('PerMessageDeflate', () => { }); it("doesn't use contex takeover if not allowed", (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }, true); + const perMessageDeflate = new PerMessageDeflate({}, true); const extensions = extension.parse( 'permessage-deflate;server_no_context_takeover' ); @@ -554,7 +549,7 @@ describe('PerMessageDeflate', () => { }); it('uses contex takeover if allowed', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }, true); + const perMessageDeflate = new PerMessageDeflate({}, true); const extensions = extension.parse('permessage-deflate'); const buf = Buffer.from('foofoo'); @@ -583,7 +578,7 @@ describe('PerMessageDeflate', () => { }); it('calls the callback when an error occurs (inflate)', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); const data = Buffer.from('something invalid'); perMessageDeflate.accept([{}]); @@ -596,11 +591,7 @@ describe('PerMessageDeflate', () => { }); it("doesn't call the callback twice when `maxPayload` is exceeded", (done) => { - const perMessageDeflate = new PerMessageDeflate( - { threshold: 0 }, - false, - 25 - ); + const perMessageDeflate = new PerMessageDeflate({}, false, 25); const buf = Buffer.from('A'.repeat(50)); perMessageDeflate.accept([{}]); @@ -616,7 +607,7 @@ describe('PerMessageDeflate', () => { }); it('calls the callback if the deflate stream is closed prematurely', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); const buf = Buffer.from('A'.repeat(50)); perMessageDeflate.accept([{}]); diff --git a/test/sender.test.js b/test/sender.test.js index 1c43dc5a5..662d2941d 100644 --- a/test/sender.test.js +++ b/test/sender.test.js @@ -3,6 +3,7 @@ const assert = require('assert'); const PerMessageDeflate = require('../lib/permessage-deflate'); +const extension = require('../lib/extension'); const Sender = require('../lib/sender'); class MockSocket { @@ -49,7 +50,7 @@ describe('Sender', () => { describe('#send', () => { it('compresses data if compress option is enabled', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); let count = 0; const mockSocket = new MockSocket({ write: (data) => { @@ -71,139 +72,156 @@ 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(); - } - }); - const sender = new Sender(mockSocket, { - 'permessage-deflate': perMessageDeflate - }); - - perMessageDeflate.accept([{}]); + describe('when context takeover is disabled', () => { + it('honors the compression threshold', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const mockSocket = new MockSocket({ + write: (data) => { + assert.notStrictEqual(data[0] & 0x40, 0x40); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); - sender.send('hi', { compress: true, fin: true }); - }); + perMessageDeflate.accept(extensions['permessage-deflate']); - it('compresses all frames in 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 + sender.send('hi', { compress: true, fin: true }); }); - perMessageDeflate.accept([{}]); - - sender.send('123', { compress: true, fin: false }); - sender.send('12', { compress: true, fin: true }); - }); - - it('compresses no frames in 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 + 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([{}]); - - sender.send('12', { compress: true, fin: false }); - sender.send('123', { compress: true, fin: true }); - }); - - it('compresses empty buffer as first fragment', (done) => { - const chunks = []; - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); - const mockSocket = new MockSocket({ - write: (chunk) => { - chunks.push(chunk); - if (chunks.length !== 4) return; - - assert.strictEqual(chunks[0].length, 2); - assert.strictEqual(chunks[0][0] & 0x40, 0x40); - assert.strictEqual(chunks[1].length, 5); - - assert.strictEqual(chunks[2].length, 2); - assert.strictEqual(chunks[2][0] & 0x40, 0x00); - assert.strictEqual(chunks[3].length, 6); - done(); - } - }); - const sender = new Sender(mockSocket, { - 'permessage-deflate': perMessageDeflate + 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 }); }); - perMessageDeflate.accept([{}]); - - 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 + 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 }); }); - perMessageDeflate.accept([{}]); - - sender.send('data', { compress: true, fin: false }); - sender.send(Buffer.alloc(0), { compress: true, fin: true }); + it('compresses empty buffer as last fragment', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x40); + assert.strictEqual(chunks[1].length, 10); + + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 1); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send('data', { compress: true, fin: false }); + sender.send(Buffer.alloc(0), { compress: true, fin: true }); + }); }); }); describe('#ping', () => { it('works with multiple types of data', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); let count = 0; const mockSocket = new MockSocket({ write: (data) => { @@ -235,7 +253,7 @@ describe('Sender', () => { describe('#pong', () => { it('works with multiple types of data', (done) => { - const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const perMessageDeflate = new PerMessageDeflate(); let count = 0; const mockSocket = new MockSocket({ write: (data) => { @@ -292,7 +310,7 @@ describe('Sender', () => { }); 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({ From fef7942a18889eb45903ff3a678f7e005a0a5f06 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 30 Sep 2021 12:54:28 +0200 Subject: [PATCH 045/207] [ci] Fix typo --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66ddf9cc4..7ded18571 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,8 +46,8 @@ jobs: - uses: coverallsapp/github-action@1.1.3 with: flag-name: - ${{ steps.get_job_id.outputs.job_id }} (Node.js ${{ matrix.node }} - - $ {{ matrix.arch }} on ${{ matrix.os }}) + ${{ steps.get_job_id.outputs.job_id }} (Node.js ${{ matrix.node }} + ${{ matrix.arch }} on ${{ matrix.os }}) github-token: ${{ secrets.GITHUB_TOKEN }} parallel: true coverage: From cfd99b6309d59da5c35c4087520b480ec060cbd9 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 2 Oct 2021 20:31:21 +0200 Subject: [PATCH 046/207] [dist] 8.2.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1e26e491f..8afdd2e48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.2.2", + "version": "8.2.3", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From a3a22e4ed39c1a3be8e727e9c630dd440edc61dd Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 11 Oct 2021 08:32:09 +0200 Subject: [PATCH 047/207] [pkg] Update eslint to version 8.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8afdd2e48..5b9f7e41d 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "devDependencies": { "benchmark": "^2.1.4", "bufferutil": "^4.0.1", - "eslint": "^7.2.0", + "eslint": "^8.0.0", "eslint-config-prettier": "^8.1.0", "eslint-plugin-prettier": "^4.0.0", "mocha": "^8.4.0", From 65717f64b4ad12f209d8c0e4e9b5f0c1d60e4d2e Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 20 Oct 2021 19:23:28 +0200 Subject: [PATCH 048/207] [ci] Test on node 17 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ded18571..9a3c50a32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: - 12 - 14 - 16 + - 17 os: - macOS-latest - ubuntu-latest From 5991c3548404e441129a16887e6a15250722a960 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 1 Nov 2021 21:06:17 +0100 Subject: [PATCH 049/207] [doc] Fix nits --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b78b1f665..82ca8db49 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,8 @@ ws.on('open', function open() { ws.send('something'); }); -ws.on('message', function incoming(message) { - console.log('received: %s', message); +ws.on('message', function message(data) { + console.log('received: %s', data); }); ``` @@ -179,8 +179,8 @@ import { WebSocketServer } from 'ws'; 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('message', function message(data) { + console.log('received: %s', data); }); ws.send('something'); @@ -201,8 +201,8 @@ const server = createServer({ const wss = new WebSocketServer({ server }); wss.on('connection', function connection(ws) { - ws.on('message', function incoming(message) { - console.log('received: %s', message); + ws.on('message', function message(data) { + console.log('received: %s', data); }); ws.send('something'); @@ -259,14 +259,14 @@ 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('message', function message(data) { + console.log(`Received message ${data} from user ${client}`); }); }); server.on('upgrade', function upgrade(request, socket, head) { // This function is not defined on purpose. Implement it with your own logic. - authenticate(request, (err, client) => { + authenticate(request, function next(err, client) { if (err || !client) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); @@ -295,7 +295,7 @@ import WebSocket, { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { - ws.on('message', function incoming(data, isBinary) { + ws.on('message', function message(data, isBinary) { wss.clients.forEach(function each(client) { if (client.readyState === WebSocket.OPEN) { client.send(data, { binary: isBinary }); @@ -314,7 +314,7 @@ import WebSocket, { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { - ws.on('message', function incoming(data, isBinary) { + ws.on('message', function message(data, isBinary) { wss.clients.forEach(function each(client) { if (client !== ws && client.readyState === WebSocket.OPEN) { client.send(data, { binary: isBinary }); @@ -342,7 +342,7 @@ ws.on('close', function close() { console.log('disconnected'); }); -ws.on('message', function incoming(data) { +ws.on('message', function message(data) { console.log(`Roundtrip time: ${Date.now() - data} ms`); setTimeout(function timeout() { From 4916d03ad84fe0e609ce3d4286e7637f193b5004 Mon Sep 17 00:00:00 2001 From: Austin Cheney Date: Tue, 16 Nov 2021 13:00:16 -0600 Subject: [PATCH 050/207] [minor] Allow to write frames with up to 2^48 - 1 bytes of data (#1973) --- lib/sender.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/sender.js b/lib/sender.js index c1e357c78..58d03ce0a 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -75,8 +75,9 @@ class Sender { if (payloadLength === 126) { target.writeUInt16BE(data.length, 2); } else if (payloadLength === 127) { - target.writeUInt32BE(0, 2); - target.writeUInt32BE(data.length, 6); + target[2] = 0; + target[3] = 0; + target.writeUIntBE(data.length, 4, 6); } if (!options.mask) return [target, data]; From 89d81e86703f4a494373154bd91a614668d994af Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 16 Nov 2021 20:08:00 +0100 Subject: [PATCH 051/207] [minor] Fix nit --- lib/sender.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/sender.js b/lib/sender.js index 58d03ce0a..4490a623b 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -75,8 +75,7 @@ class Sender { if (payloadLength === 126) { target.writeUInt16BE(data.length, 2); } else if (payloadLength === 127) { - target[2] = 0; - target[3] = 0; + target[2] = target[3] = 0; target.writeUIntBE(data.length, 4, 6); } From 5a905e49be91203c04a2546bfc292717b8e9bee9 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 20 Nov 2021 08:07:59 +0100 Subject: [PATCH 052/207] [minor] Add missing label to the issue form Fixes #1974 --- .github/ISSUE_TEMPLATE/bug_report.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9f437067f..6c12fea14 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,4 +1,4 @@ -name: 'Bug report' +name: Bug report description: Create a bug report body: - type: markdown @@ -12,6 +12,10 @@ body: 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 From b8186dd11577979d5870a933fc0ce4ac29f893eb Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 22 Nov 2021 15:27:29 +0100 Subject: [PATCH 053/207] [fix] Do not throw if the redirect URL is invalid (#1980) If the redirect URL is invalid, then emit the error instead of throwing it, otherwise there is no way to handle it. Closes #1975 --- lib/websocket.js | 50 ++++++++++++++++++++++++++++++++---------- test/websocket.test.js | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 818f26914..53aac9a57 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -630,19 +630,26 @@ function initAsClient(websocket, address, protocols, options) { const isSecure = parsedUrl.protocol === 'wss:'; const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; + let invalidURLMessage; if (parsedUrl.protocol !== 'ws:' && !isSecure && !isUnixSocket) { - throw new SyntaxError( - 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"' - ); + invalidURLMessage = + 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"'; + } else if (isUnixSocket && !parsedUrl.pathname) { + invalidURLMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidURLMessage = 'The URL contains a fragment identifier'; } - if (isUnixSocket && !parsedUrl.pathname) { - throw new SyntaxError("The URL's pathname is empty"); - } + if (invalidURLMessage) { + const err = new SyntaxError(invalidURLMessage); - if (parsedUrl.hash) { - throw new SyntaxError('The URL contains a fragment identifier'); + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } } const defaultPort = isSecure ? 443 : 80; @@ -724,9 +731,7 @@ function initAsClient(websocket, address, protocols, options) { if (req === null || req.aborted) return; req = websocket._req = null; - websocket._readyState = WebSocket.CLOSING; - websocket.emit('error', err); - websocket.emitClose(); + emitErrorAndClose(websocket, err); }); req.on('response', (res) => { @@ -746,7 +751,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)) { @@ -849,6 +862,19 @@ function initAsClient(websocket, address, protocols, options) { }); } +/** + * Emit the `'error'` and `'close'` event. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {Error} The error to emit + * @private + */ +function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket.CLOSING; + websocket.emit('error', err); + websocket.emitClose(); +} + /** * Create a `net.Socket` and initiate a connection. * diff --git a/test/websocket.test.js b/test/websocket.test.js index 4b85c7948..4703debef 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1028,6 +1028,53 @@ describe('WebSocket', () => { ws.on('close', () => done()); }); }); + + it('emits an error if the redirect URL is invalid (1/2)', (done) => { + const onUpgrade = (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: ws://\r\n\r\n'); + }; + + server.on('upgrade', onUpgrade); + + 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); + + server.removeListener('upgrade', onUpgrade); + ws.on('close', () => done()); + }); + }); + + it('emits an error if the redirect URL is invalid (2/2)', (done) => { + const onUpgrade = (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: http://localhost\r\n\r\n'); + }; + + server.on('upgrade', onUpgrade); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof SyntaxError); + assert.strictEqual( + err.message, + 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"' + ); + assert.strictEqual(ws._redirects, 1); + + server.removeListener('upgrade', onUpgrade); + ws.on('close', () => done()); + }); + }); }); describe('Connection with query string', () => { From ed2b803905289fc57616c3aef6b1690a3ca282b9 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 22 Nov 2021 21:32:18 +0100 Subject: [PATCH 054/207] [fix] Resume the socket in the `CLOSING` state When the value of the `readyState` attribute is `CLOSING`, the internal socket might still be open. Resume it to read any remaining data and to allow the connection to be closed cleanly. --- lib/stream.js | 5 ++++- test/create-websocket-stream.test.js | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/stream.js b/lib/stream.js index 0f85ba55d..9622a5ac6 100644 --- a/lib/stream.js +++ b/lib/stream.js @@ -155,7 +155,10 @@ function createWebSocketStream(ws, options) { }; duplex._read = function () { - if (ws.readyState === ws.OPEN && !resumeOnReceiverDrain) { + if ( + (ws.readyState === ws.OPEN || ws.readyState === ws.CLOSING) && + !resumeOnReceiverDrain + ) { resumeOnReceiverDrain = true; if (!ws._receiver._writableState.needDrain) ws._socket.resume(); } diff --git a/test/create-websocket-stream.test.js b/test/create-websocket-stream.test.js index 8aee1a18a..4d51958cd 100644 --- a/test/create-websocket-stream.test.js +++ b/test/create-websocket-stream.test.js @@ -568,5 +568,31 @@ describe('createWebSocketStream', () => { ws.close(); }); }); + + it('resumes the socket if `readyState` is `CLOSING`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + ws.on('message', () => { + assert.ok(ws._socket.isPaused()); + + duplex.on('close', () => { + wss.close(done); + }); + + duplex.end(); + + process.nextTick(() => { + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + duplex.resume(); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.send(randomBytes(16 * 1024)); + }); + }); }); }); From 0a8c7a9c4f5ae357b094fb586c90ee5db8793fcc Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 23 Nov 2021 14:50:20 +0100 Subject: [PATCH 055/207] [api] Add `WebSocket#pause()` and `WebSocket#resume()` Add ability to pause and resume a `WebSocket`. --- doc/ws.md | 20 +++++++ lib/stream.js | 28 +-------- lib/websocket.js | 47 ++++++++++++++- test/websocket.test.js | 130 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 27 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index fc14e7e97..20facffbb 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -31,15 +31,18 @@ - [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) @@ -409,6 +412,12 @@ following ways: Initiate a closing handshake. +### websocket.isPaused + +- {Boolean} + +Indicates whether the websocket is paused. + ### websocket.extensions - {Object} @@ -443,6 +452,12 @@ listener receives a `MessageEvent` named "message". An event listener to be called when the connection is established. The listener receives an `OpenEvent` named "open". +### websocket.pause() + +Pause the websocket causing it to stop emitting events. Some events can still be +emitted after this is called, until all buffered data is consumed. This method +is a noop if the ready state is `CONNECTING` or `CLOSED`. + ### websocket.ping([data[, mask]][, callback]) - `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The @@ -473,6 +488,11 @@ Send a pong. This method throws an error if the ready state is `CONNECTING`. 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} diff --git a/lib/stream.js b/lib/stream.js index 9622a5ac6..230734b79 100644 --- a/lib/stream.js +++ b/lib/stream.js @@ -47,23 +47,8 @@ function duplexOnError(err) { * @public */ function createWebSocketStream(ws, options) { - let resumeOnReceiverDrain = true; let terminateOnDestroy = true; - function receiverOnDrain() { - if (resumeOnReceiverDrain) ws._socket.resume(); - } - - if (ws.readyState === ws.CONNECTING) { - ws.once('open', function open() { - ws._receiver.removeAllListeners('drain'); - ws._receiver.on('drain', receiverOnDrain); - }); - } else { - ws._receiver.removeAllListeners('drain'); - ws._receiver.on('drain', receiverOnDrain); - } - const duplex = new Duplex({ ...options, autoDestroy: false, @@ -76,10 +61,7 @@ function createWebSocketStream(ws, options) { const data = !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; - if (!duplex.push(data)) { - resumeOnReceiverDrain = false; - ws._socket.pause(); - } + if (!duplex.push(data)) ws.pause(); }); ws.once('error', function error(err) { @@ -155,13 +137,7 @@ function createWebSocketStream(ws, options) { }; duplex._read = function () { - if ( - (ws.readyState === ws.OPEN || ws.readyState === ws.CLOSING) && - !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/websocket.js b/lib/websocket.js index 53aac9a57..130b3dc58 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -58,6 +58,7 @@ class WebSocket extends EventEmitter { this._closeMessage = EMPTY_BUFFER; this._closeTimer = null; this._extensions = {}; + this._paused = false; this._protocol = ''; this._readyState = WebSocket.CONNECTING; this._receiver = null; @@ -124,6 +125,13 @@ class WebSocket extends EventEmitter { return Object.keys(this._extensions).join(); } + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + /** * @type {Function} */ @@ -312,6 +320,23 @@ class WebSocket extends EventEmitter { ); } + /** + * Pause the socket. + * + * @public + */ + pause() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = true; + this._socket.pause(); + } + /** * Send a ping. * @@ -376,6 +401,23 @@ 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. * @@ -518,6 +560,7 @@ Object.defineProperty(WebSocket.prototype, 'CLOSED', { 'binaryType', 'bufferedAmount', 'extensions', + 'isPaused', 'protocol', 'readyState', 'url' @@ -1001,7 +1044,9 @@ function receiverOnConclude(code, reason) { * @private */ function receiverOnDrain() { - this[kWebSocket]._socket.resume(); + const websocket = this[kWebSocket]; + + if (!websocket.isPaused) websocket._socket.resume(); } /** diff --git a/test/websocket.test.js b/test/websocket.test.js index 4703debef..0d48887de 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -359,6 +359,39 @@ describe('WebSocket', () => { }); }); + describe('`isPaused`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'isPaused' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('indicates whether the websocket is paused', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.pause(); + assert.ok(ws.isPaused); + + ws.resume(); + assert.ok(!ws.isPaused); + + ws.close(); + wss.close(done); + }); + + assert.ok(!ws.isPaused); + }); + }); + }); + describe('`protocol`', () => { it('is enumerable and configurable', () => { const descriptor = Object.getOwnPropertyDescriptor( @@ -1109,6 +1142,51 @@ describe('WebSocket', () => { }); }); + describe('#pause', () => { + it('does nothing if `readyState` is `CONNECTING` or `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + assert.ok(!ws.isPaused); + + ws.pause(); + assert.ok(!ws.isPaused); + + ws.on('open', () => { + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.pause(); + assert.ok(!ws.isPaused); + + wss.close(done); + }); + + ws.close(); + }); + }); + }); + + it('pauses the socket', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); + + ws.pause(); + assert.ok(ws.isPaused); + assert.ok(ws._socket.isPaused()); + + ws.terminate(); + wss.close(done); + }); + }); + }); + describe('#ping', () => { it('throws an error if `readyState` is `CONNECTING`', () => { const ws = new WebSocket('ws://localhost', { @@ -1447,6 +1525,58 @@ describe('WebSocket', () => { }); }); + describe('#resume', () => { + it('does nothing if `readyState` is `CONNECTING` or `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + assert.ok(!ws.isPaused); + + // Verify that no exception is thrown. + ws.resume(); + + ws.on('open', () => { + ws.pause(); + assert.ok(ws.isPaused); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.resume(); + assert.ok(ws.isPaused); + + wss.close(done); + }); + + ws.terminate(); + }); + }); + }); + + it('resumes the socket', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); + + ws.pause(); + assert.ok(ws.isPaused); + assert.ok(ws._socket.isPaused()); + + ws.resume(); + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); + + ws.close(); + wss.close(done); + }); + }); + }); + describe('#send', () => { it('throws an error if `readyState` is `CONNECTING`', () => { const ws = new WebSocket('ws://localhost', { From c82b08737fbe142dd910fc7e429399e23b95c6d6 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 23 Nov 2021 15:21:00 +0100 Subject: [PATCH 056/207] [dist] 8.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5b9f7e41d..5e3d22197 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.2.3", + "version": "8.3.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From eb2e3a84a1f1c75428d7bde1578a00eed2809c88 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 19 Dec 2021 20:41:24 +0100 Subject: [PATCH 057/207] [feature] Introduce the `generateMask` option The `generateMask` option specifies a function that can be used to generate custom masking keys. Refs: https://github.com/websockets/ws/pull/1986 Refs: https://github.com/websockets/ws/pull/1988 Refs: https://github.com/websockets/ws/pull/1989 --- doc/ws.md | 4 ++++ lib/sender.js | 38 +++++++++++++++++++++++++++++++++++--- lib/websocket.js | 7 ++++++- test/websocket.test.js | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 20facffbb..d728ace82 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -270,6 +270,10 @@ This class represents a WebSocket. It extends the `EventEmitter`. - `options` {Object} - `followRedirects` {Boolean} Whether or not to follow redirects. Defaults to `false`. + - `generateMask` {Function} The function used to generate the masking key. It + takes a `Buffer` that must be filled synchronously and is called before a + message is sent, for each message. By default the buffer is filled with + cryptographically strong random bytes. - `handshakeTimeout` {Number} Timeout in milliseconds for the handshake request. This is reset after every redirection. - `maxPayload` {Number} The maximum allowed message size in bytes. diff --git a/lib/sender.js b/lib/sender.js index 4490a623b..82cc662d6 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -11,7 +11,7 @@ const { EMPTY_BUFFER } = require('./constants'); const { isValidStatusCode } = require('./validation'); const { mask: applyMask, toBuffer } = require('./buffer-util'); -const mask = Buffer.alloc(4); +const maskBuffer = Buffer.alloc(4); /** * HyBi Sender implementation. @@ -22,9 +22,17 @@ class Sender { * * @param {(net.Socket|tls.Socket)} socket The connection socket * @param {Object} [extensions] An object containing the negotiated extensions + * @param {Function} [generateMask] The function used to generate the masking + * key */ - constructor(socket, extensions) { + constructor(socket, extensions, generateMask) { this._extensions = extensions || {}; + + if (generateMask) { + this._generateMask = generateMask; + this._maskBuffer = Buffer.alloc(4); + } + this._socket = socket; this._firstFragment = true; @@ -42,8 +50,12 @@ class Sender { * @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 @@ -81,7 +93,13 @@ class Sender { if (!options.mask) return [target, data]; - randomFillSync(mask, 0, 4); + const mask = options.maskBuffer ? options.maskBuffer : maskBuffer; + + if (options.generateMask) { + options.generateMask(mask); + } else { + randomFillSync(mask, 0, 4); + } target[1] |= 0x80; target[offset - 4] = mask[0]; @@ -156,6 +174,8 @@ class Sender { rsv1: false, opcode: 0x08, mask, + maskBuffer: this._maskBuffer, + generateMask: this._generateMask, readOnly: false }), cb @@ -200,6 +220,8 @@ class Sender { rsv1: false, opcode: 0x09, mask, + maskBuffer: this._maskBuffer, + generateMask: this._generateMask, readOnly }), cb @@ -244,6 +266,8 @@ class Sender { rsv1: false, opcode: 0x0a, mask, + maskBuffer: this._maskBuffer, + generateMask: this._generateMask, readOnly }), cb @@ -299,6 +323,8 @@ class Sender { rsv1, opcode, mask: options.mask, + maskBuffer: this._maskBuffer, + generateMask: this._generateMask, readOnly: toBuffer.readOnly }; @@ -314,6 +340,8 @@ class Sender { rsv1: false, opcode, mask: options.mask, + maskBuffer: this._maskBuffer, + generateMask: this._generateMask, readOnly: toBuffer.readOnly }), cb @@ -331,8 +359,12 @@ class Sender { * @param {Number} options.opcode The opcode * @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 {Boolean} [options.readOnly=false] Specifies whether `data` can be * modified * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the diff --git a/lib/websocket.js b/lib/websocket.js index 130b3dc58..57710f4e1 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -192,6 +192,8 @@ class WebSocket extends EventEmitter { * server and client * @param {Buffer} head The first packet of the upgraded stream * @param {Object} options Options object + * @param {Function} [options.generateMask] The function used to generate the + * masking key * @param {Number} [options.maxPayload=0] The maximum allowed message size * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or * not to skip UTF-8 validation for text and close messages @@ -206,7 +208,7 @@ class WebSocket extends EventEmitter { skipUTF8Validation: options.skipUTF8Validation }); - this._sender = new Sender(socket, this._extensions); + this._sender = new Sender(socket, this._extensions, options.generateMask); this._receiver = receiver; this._socket = socket; @@ -613,6 +615,8 @@ module.exports = WebSocket; * @param {Object} [options] Connection options * @param {Boolean} [options.followRedirects=false] Whether or not to follow * redirects + * @param {Function} [options.generateMask] The function used to generate the + * masking key * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the * handshake request * @param {Number} [options.maxPayload=104857600] The maximum allowed message @@ -899,6 +903,7 @@ function initAsClient(websocket, address, protocols, options) { } websocket.setSocket(socket, head, { + generateMask: opts.generateMask, maxPayload: opts.maxPayload, skipUTF8Validation: opts.skipUTF8Validation }); diff --git a/test/websocket.test.js b/test/websocket.test.js index 0d48887de..5f9392d84 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -126,6 +126,41 @@ describe('WebSocket', () => { /^RangeError: Unsupported protocol version: 1000 \(supported versions: 8, 13\)$/ ); }); + + it('honors the `generateMask` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + generateMask() {} + }); + + ws.on('open', () => { + ws.send('foo'); + }); + + 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', () => { + assert.ok( + Buffer.concat(chunks).slice(2, 6).equals(Buffer.alloc(4)) + ); + + ws.close(); + }); + }); + }); }); }); From 35d45c2a4fead953654ae7bcf029cdf6d2590121 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 19 Dec 2021 20:49:13 +0100 Subject: [PATCH 058/207] [perf] Skip masking and unmasking if the masking key is zero --- lib/receiver.js | 8 +++++++- lib/sender.js | 32 ++++++++++++++++++++++---------- test/websocket.test.js | 11 +++++++---- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/lib/receiver.js b/lib/receiver.js index e11e26618..2d29d62bb 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -417,7 +417,13 @@ 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); diff --git a/lib/sender.js b/lib/sender.js index 82cc662d6..2417656d7 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -65,8 +65,26 @@ class Sender { * @public */ static frame(data, options) { - const merge = options.mask && options.readOnly; - let offset = options.mask ? 6 : 2; + let mask; + let merge = false; + let offset = 2; + let skipMasking = false; + + if (options.mask) { + mask = options.maskBuffer || maskBuffer; + + if (options.generateMask) { + options.generateMask(mask); + } else { + randomFillSync(mask, 0, 4); + } + + skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; + if (options.readOnly && !skipMasking) merge = true; + + offset = 6; + } + let payloadLength = data.length; if (data.length >= 65536) { @@ -93,20 +111,14 @@ class Sender { if (!options.mask) return [target, data]; - const mask = options.maskBuffer ? options.maskBuffer : maskBuffer; - - if (options.generateMask) { - options.generateMask(mask); - } else { - 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); return [target]; diff --git a/test/websocket.test.js b/test/websocket.test.js index 5f9392d84..16cb33c55 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -128,13 +128,14 @@ describe('WebSocket', () => { }); 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('foo'); + ws.send(data); }); ws.on('close', (code, reason) => { @@ -152,9 +153,11 @@ describe('WebSocket', () => { chunks.push(chunk); }); - ws.on('message', () => { - assert.ok( - Buffer.concat(chunks).slice(2, 6).equals(Buffer.alloc(4)) + ws.on('message', (message) => { + assert.deepStrictEqual(message, data); + assert.deepStrictEqual( + Buffer.concat(chunks).slice(2, 6), + Buffer.alloc(4) ); ws.close(); From 00c34d726dca1c558fe5ee5e346979159b2297fe Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 20 Dec 2021 21:07:56 +0100 Subject: [PATCH 059/207] [dist] 8.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e3d22197..813155221 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.3.0", + "version": "8.4.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 5edf1f4a1b1750109c1bb56eff7ad78902eee7dc Mon Sep 17 00:00:00 2001 From: jugglinmike Date: Thu, 30 Dec 2021 14:14:29 -0500 Subject: [PATCH 060/207] [doc] Clarify interpretation of `verifyClient` (#1994) Prior to this commit, the documentation for the `verifyClient` option was somewhat confusing due to its use of the passive voice and an uncommon interpretation of the term "arguments". Explain the way `ws` interprets the value of `verifyClient` using an active voice and more traditional meanings of "parameters" and "arguments". --- doc/ws.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index d728ace82..38e769b06 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -99,7 +99,7 @@ to share a single HTTP/S server between multiple WebSocket servers. > more details. If `verifyClient` is not set then the handshake is automatically accepted. If it -is provided with a single argument then that is: +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. @@ -110,7 +110,8 @@ is provided with a single argument then that is: The return value (`Boolean`) of the function determines whether or not to accept the handshake. -if `verifyClient` is provided with two arguments then those are: +If `verifyClient` has two parameters then `ws` will invoke it with the following +arguments: - `info` {Object} Same as above. - `cb` {Function} A callback that must be called by the user upon inspection of From d2c935a477fa6999c8fa85b89dfae27b85b807e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Nie=C3=9Fen?= Date: Tue, 4 Jan 2022 07:48:06 +0100 Subject: [PATCH 061/207] [doc] Fix typo in `WebSocketServer` description (#1996) --- doc/ws.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ws.md b/doc/ws.md index 38e769b06..62fd36ca5 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -91,7 +91,7 @@ must be provided or an error is thrown. An HTTP server is automatically created, started, and used if `port` is set. To use an external HTTP/S server instead, specify only `server` or `noServer`. In this case the HTTP/S server must be started manually. The "noServer" mode allows the WebSocket server to be -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 From 4081a368ffee896aa12bcc8c241523257ee34387 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 6 Jan 2022 15:36:53 +0100 Subject: [PATCH 062/207] [test] Do not call the `done` callback prematurely --- test/sender.test.js | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/test/sender.test.js b/test/sender.test.js index 662d2941d..845ff7ad1 100644 --- a/test/sender.test.js +++ b/test/sender.test.js @@ -50,12 +50,22 @@ describe('Sender', () => { describe('#send', () => { it('compresses data if compress option is enabled', (done) => { + const chunks = []; const perMessageDeflate = new PerMessageDeflate(); - let count = 0; 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, { @@ -74,10 +84,16 @@ describe('Sender', () => { describe('when context takeover is disabled', () => { it('honors the compression threshold', (done) => { + const chunks = []; const perMessageDeflate = new PerMessageDeflate(); const mockSocket = new MockSocket({ - write: (data) => { - assert.notStrictEqual(data[0] & 0x40, 0x40); + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 2) return; + + assert.strictEqual(chunks[0].length, 2); + assert.notStrictEqual(chunk[0][0] & 0x40, 0x40); + assert.deepStrictEqual(chunks[1], Buffer.from('hi')); done(); } }); From ad3fe6dc4fb4669b62e22a94d0ef336b5000bec7 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 6 Jan 2022 18:25:52 +0100 Subject: [PATCH 063/207] [test] Improve test title --- test/sender.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/sender.test.js b/test/sender.test.js index 845ff7ad1..ef69d6abb 100644 --- a/test/sender.test.js +++ b/test/sender.test.js @@ -2,9 +2,10 @@ const assert = require('assert'); -const PerMessageDeflate = require('../lib/permessage-deflate'); const extension = require('../lib/extension'); +const PerMessageDeflate = require('../lib/permessage-deflate'); const Sender = require('../lib/sender'); +const { EMPTY_BUFFER } = require('../lib/constants'); class MockSocket { constructor({ write } = {}) { @@ -35,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, From 91f3c07b26795cf3e4e221ae468d298ae5a3be2d Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 9 Jan 2022 15:32:28 +0100 Subject: [PATCH 064/207] [minor] Replace echo.websocket.org with websocket-echo.com The echo.websocket.org service is no longer available. --- README.md | 16 ++++++---------- test/websocket.integration.js | 6 ++---- test/websocket.test.js | 4 ++-- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 82ca8db49..cab335b7b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,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) @@ -324,14 +324,12 @@ wss.on('connection', function connection(ws) { }); ``` -### echo.websocket.org demo +### Round-trip time ```js 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('open', function open() { console.log('connected'); @@ -343,7 +341,7 @@ ws.on('close', function close() { }); ws.on('message', function message(data) { - console.log(`Roundtrip time: ${Date.now() - data} ms`); + console.log(`Round-trip time: ${Date.now() - data} ms`); setTimeout(function timeout() { ws.send(Date.now()); @@ -356,9 +354,7 @@ ws.on('message', function message(data) { ```js 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' }); @@ -457,7 +453,7 @@ function heartbeat() { }, 30000 + 1000); } -const client = new WebSocket('wss://echo.websocket.org/'); +const client = new WebSocket('wss://websocket-echo.com/'); client.on('open', heartbeat); client.on('ping', heartbeat); diff --git a/test/websocket.integration.js b/test/websocket.integration.js index 5ff87a640..e1fe7f558 100644 --- a/test/websocket.integration.js +++ b/test/websocket.integration.js @@ -6,8 +6,7 @@ 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(); @@ -27,8 +26,7 @@ describe('WebSocket', () => { }); 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(); diff --git a/test/websocket.test.js b/test/websocket.test.js index 16cb33c55..b46f89281 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -33,7 +33,7 @@ describe('WebSocket', () => { ); assert.throws( - () => new WebSocket('https://echo.websocket.org'), + () => new WebSocket('https://websocket-echo.com'), /^SyntaxError: The URL's protocol must be one of "ws:", "wss:", or "ws\+unix:"$/ ); @@ -43,7 +43,7 @@ describe('WebSocket', () => { ); assert.throws( - () => new WebSocket('wss://echo.websocket.org#foo'), + () => new WebSocket('wss://websocket-echo.com#foo'), /^SyntaxError: The URL contains a fragment identifier$/ ); }); From 8de448fbd105deeecada886344ca58237d423a8c Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 9 Jan 2022 16:10:24 +0100 Subject: [PATCH 065/207] [test] Fix failing tests Refs: https://github.com/websockets/ws/commit/e173423c --- test/websocket.integration.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/test/websocket.integration.js b/test/websocket.integration.js index e1fe7f558..abd96c61e 100644 --- a/test/websocket.integration.js +++ b/test/websocket.integration.js @@ -9,18 +9,22 @@ describe('WebSocket', () => { 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(); }); }); @@ -29,18 +33,22 @@ describe('WebSocket', () => { 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(); }); }); From 5b7fbb000972edd34b4882384e1185f033000220 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 13 Jan 2022 20:26:14 +0100 Subject: [PATCH 066/207] [perf] Reduce buffer allocations (#2000) Do not convert strings to `Buffer`s if data does not need to be masked. Refs: https://github.com/websockets/ws/pull/1998 --- lib/permessage-deflate.js | 4 +- lib/sender.js | 232 ++++++++++++++++++++------------------ test/sender.test.js | 29 +++-- 3 files changed, 146 insertions(+), 119 deletions(-) diff --git a/lib/permessage-deflate.js b/lib/permessage-deflate.js index 504069719..94603c98d 100644 --- a/lib/permessage-deflate.js +++ b/lib/permessage-deflate.js @@ -313,7 +313,7 @@ class PerMessageDeflate { /** * Compress data. Concurrency limited. * - * @param {Buffer} data Data to compress + * @param {(Buffer|String)} data Data to compress * @param {Boolean} fin Specifies whether or not this is the last fragment * @param {Function} callback Callback * @public @@ -395,7 +395,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 diff --git a/lib/sender.js b/lib/sender.js index 2417656d7..d331f653a 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -11,6 +11,7 @@ const { EMPTY_BUFFER } = require('./constants'); const { isValidStatusCode } = require('./validation'); const { mask: applyMask, toBuffer } = require('./buffer-util'); +const kByteLength = Symbol('kByteLength'); const maskBuffer = Buffer.alloc(4); /** @@ -46,7 +47,7 @@ class Sender { /** * 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 @@ -61,7 +62,7 @@ class Sender { * modified * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the * RSV1 bit - * @return {Buffer[]} The framed data as a list of `Buffer` instances + * @return {(Buffer|String)[]} The framed data * @public */ static frame(data, options) { @@ -80,12 +81,27 @@ class Sender { } skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; - if (options.readOnly && !skipMasking) merge = true; - offset = 6; } - let payloadLength = data.length; + 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 (data.length >= 65536) { offset += 8; @@ -95,7 +111,7 @@ class Sender { 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; @@ -103,10 +119,10 @@ class Sender { target[1] = payloadLength; if (payloadLength === 126) { - target.writeUInt16BE(data.length, 2); + target.writeUInt16BE(dataLength, 2); } else if (payloadLength === 127) { target[2] = target[3] = 0; - target.writeUIntBE(data.length, 4, 6); + target.writeUIntBE(dataLength, 4, 6); } if (!options.mask) return [target, data]; @@ -164,36 +180,24 @@ class Sender { } } + const options = { + [kByteLength]: buf.length, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x08, + readOnly: false, + rsv1: false + }; + if (this._deflating) { - this.enqueue([this.doClose, buf, mask, cb]); + this.enqueue([this.dispatch, buf, false, options, cb]); } else { - this.doClose(buf, mask, cb); + this.sendFrame(Sender.frame(buf, options), cb); } } - /** - * Frames and sends a close message. - * - * @param {Buffer} data The message to send - * @param {Boolean} [mask=false] Specifies whether or not to mask `data` - * @param {Function} [cb] Callback - * @private - */ - doClose(data, mask, cb) { - this.sendFrame( - Sender.frame(data, { - fin: true, - rsv1: false, - opcode: 0x08, - mask, - maskBuffer: this._maskBuffer, - generateMask: this._generateMask, - readOnly: false - }), - cb - ); - } - /** * Sends a ping message to the other peer. * @@ -203,43 +207,40 @@ class Sender { * @public */ ping(data, mask, cb) { - const buf = toBuffer(data); + let byteLength; + let readOnly; - if (buf.length > 125) { + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (byteLength > 125) { throw new RangeError('The data size must not be greater than 125 bytes'); } + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x09, + readOnly, + rsv1: false + }; + if (this._deflating) { - this.enqueue([this.doPing, buf, mask, toBuffer.readOnly, cb]); + this.enqueue([this.dispatch, data, false, options, cb]); } else { - this.doPing(buf, mask, toBuffer.readOnly, cb); + this.sendFrame(Sender.frame(data, options), cb); } } - /** - * Frames and sends a ping message. - * - * @param {Buffer} data The message to send - * @param {Boolean} [mask=false] Specifies whether or not to mask `data` - * @param {Boolean} [readOnly=false] Specifies whether `data` can be modified - * @param {Function} [cb] Callback - * @private - */ - doPing(data, mask, readOnly, cb) { - this.sendFrame( - Sender.frame(data, { - fin: true, - rsv1: false, - opcode: 0x09, - mask, - maskBuffer: this._maskBuffer, - generateMask: this._generateMask, - readOnly - }), - cb - ); - } - /** * Sends a pong message to the other peer. * @@ -249,43 +250,40 @@ class Sender { * @public */ pong(data, mask, cb) { - const buf = toBuffer(data); + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else { + 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'); } + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x0a, + readOnly, + rsv1: false + }; + if (this._deflating) { - this.enqueue([this.doPong, buf, mask, toBuffer.readOnly, cb]); + this.enqueue([this.dispatch, data, false, options, cb]); } else { - this.doPong(buf, mask, toBuffer.readOnly, cb); + this.sendFrame(Sender.frame(data, options), cb); } } - /** - * Frames and sends a pong message. - * - * @param {Buffer} data The message to send - * @param {Boolean} [mask=false] Specifies whether or not to mask `data` - * @param {Boolean} [readOnly=false] Specifies whether `data` can be modified - * @param {Function} [cb] Callback - * @private - */ - doPong(data, mask, readOnly, cb) { - this.sendFrame( - Sender.frame(data, { - fin: true, - rsv1: false, - opcode: 0x0a, - mask, - maskBuffer: this._maskBuffer, - generateMask: this._generateMask, - readOnly - }), - cb - ); - } - /** * Sends a data message to the other peer. * @@ -303,11 +301,22 @@ class Sender { * @public */ send(data, options, cb) { - const buf = toBuffer(data); const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; let opcode = options.binary ? 2 : 1; let rsv1 = options.compress; + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (this._firstFragment) { this._firstFragment = false; if ( @@ -319,7 +328,7 @@ class Sender { : 'client_no_context_takeover' ] ) { - rsv1 = buf.length >= perMessageDeflate._threshold; + rsv1 = byteLength >= perMessageDeflate._threshold; } this._compress = rsv1; } else { @@ -331,30 +340,32 @@ class Sender { if (perMessageDeflate) { const opts = { + [kByteLength]: byteLength, fin: options.fin, - rsv1, - opcode, + generateMask: this._generateMask, mask: options.mask, maskBuffer: this._maskBuffer, - generateMask: this._generateMask, - readOnly: toBuffer.readOnly + opcode, + readOnly, + rsv1 }; if (this._deflating) { - this.enqueue([this.dispatch, buf, this._compress, opts, cb]); + this.enqueue([this.dispatch, data, this._compress, opts, cb]); } else { - this.dispatch(buf, this._compress, opts, cb); + this.dispatch(data, this._compress, opts, cb); } } else { this.sendFrame( - Sender.frame(buf, { + Sender.frame(data, { + [kByteLength]: byteLength, fin: options.fin, - rsv1: false, - opcode, + generateMask: this._generateMask, mask: options.mask, maskBuffer: this._maskBuffer, - generateMask: this._generateMask, - readOnly: toBuffer.readOnly + opcode, + readOnly, + rsv1: false }), cb ); @@ -362,13 +373,12 @@ class Sender { } /** - * Dispatches a data message. + * Dispatches a message. * - * @param {Buffer} data The message to send + * @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 {Number} options.opcode The opcode * @param {Boolean} [options.fin=false] Specifies whether or not to set the * FIN bit * @param {Function} [options.generateMask] The function used to generate the @@ -377,6 +387,7 @@ class Sender { * `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 @@ -392,7 +403,7 @@ class Sender { const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; - this._bufferedBytes += data.length; + this._bufferedBytes += options[kByteLength]; this._deflating = true; perMessageDeflate.compress(data, options.fin, (_, buf) => { if (this._socket.destroyed) { @@ -403,7 +414,8 @@ class Sender { if (typeof cb === 'function') cb(err); for (let i = 0; i < this._queue.length; i++) { - const callback = this._queue[i][4]; + const params = this._queue[i]; + const callback = params[params.length - 1]; if (typeof callback === 'function') callback(err); } @@ -411,7 +423,7 @@ class Sender { return; } - this._bufferedBytes -= data.length; + this._bufferedBytes -= options[kByteLength]; this._deflating = false; options.readOnly = false; this.sendFrame(Sender.frame(buf, options), cb); @@ -428,7 +440,7 @@ class Sender { while (!this._deflating && 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)); } } @@ -440,7 +452,7 @@ class Sender { * @private */ enqueue(params) { - this._bufferedBytes += params[1].length; + this._bufferedBytes += params[3][kByteLength]; this._queue.push(params); } diff --git a/test/sender.test.js b/test/sender.test.js index ef69d6abb..532239fa1 100644 --- a/test/sender.test.js +++ b/test/sender.test.js @@ -47,6 +47,19 @@ 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', () => { @@ -94,7 +107,7 @@ describe('Sender', () => { assert.strictEqual(chunks[0].length, 2); assert.notStrictEqual(chunk[0][0] & 0x40, 0x40); - assert.deepStrictEqual(chunks[1], Buffer.from('hi')); + assert.strictEqual(chunks[1], 'hi'); done(); } }); @@ -246,11 +259,12 @@ describe('Sender', () => { if (count % 2) { assert.ok(data.equals(Buffer.from([0x89, 0x02]))); - } else { + } else if (count < 8) { assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + } else { + assert.strictEqual(data, 'hi'); + done(); } - - if (count === 8) done(); } }); const sender = new Sender(mockSocket, { @@ -278,11 +292,12 @@ describe('Sender', () => { if (count % 2) { assert.ok(data.equals(Buffer.from([0x8a, 0x02]))); - } else { + } else if (count < 8) { assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + } else { + assert.strictEqual(data, 'hi'); + done(); } - - if (count === 8) done(); } }); const sender = new Sender(mockSocket, { From 6ebfeb8be70aee060852134c953794864ccf31dd Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 13 Jan 2022 20:40:28 +0100 Subject: [PATCH 067/207] [dist] 8.4.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 813155221..c4ab7a8d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.4.0", + "version": "8.4.1", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 0c0754b897f62a68d7da3600f96924fab54e4f8f Mon Sep 17 00:00:00 2001 From: Robert Nagy Date: Fri, 14 Jan 2022 15:26:07 +0100 Subject: [PATCH 068/207] [fix] Use the byte length of the data (#2004) Ensure that the correct length is used when framing the data. --- lib/sender.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/sender.js b/lib/sender.js index d331f653a..c84885362 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -103,10 +103,10 @@ class Sender { let payloadLength = dataLength; - if (data.length >= 65536) { + if (dataLength >= 65536) { offset += 8; payloadLength = 127; - } else if (data.length > 125) { + } else if (dataLength > 125) { offset += 2; payloadLength = 126; } @@ -136,11 +136,11 @@ class Sender { 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]; } From 33fd1016ec4266464027ab8d4a6e06649c93e938 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 14 Jan 2022 15:28:24 +0100 Subject: [PATCH 069/207] [dist] 8.4.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c4ab7a8d4..723ec1b36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.4.1", + "version": "8.4.2", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 8a7016dc2fe4d9d63c428c67588d7c1f33a72e5c Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 30 Jan 2022 20:27:45 +0100 Subject: [PATCH 070/207] [test] Simplify test --- test/websocket.test.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index b46f89281..fe15ed7ca 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -2757,18 +2757,17 @@ describe('WebSocket', () => { requestCert: true }); - let success = false; - const wss = new WebSocket.Server({ - verifyClient: (info) => { - success = !!info.req.client.authorized; - return true; - }, - server - }); + const wss = new WebSocket.Server({ noServer: true }); - wss.on('connection', () => { - assert.ok(success); - server.close(done); + 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, () => { From e1ddacce06d302884e674f50c12cb376e000c6ea Mon Sep 17 00:00:00 2001 From: Sergey Bakulin Date: Tue, 1 Feb 2022 22:25:02 +0300 Subject: [PATCH 071/207] [feature] Introduce the `WebSocket` option (#2007) Add the ability to use a custom class that extends the `WebSocket` class. --- doc/ws.md | 2 ++ lib/websocket-server.js | 5 ++++- test/websocket-server.test.js | 26 ++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/doc/ws.md b/doc/ws.md index 62fd36ca5..3273c1f9e 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -84,6 +84,8 @@ This class represents a WebSocket server. It extends the `EventEmitter`. - `verifyClient` {Function} A function which can be used to validate incoming connections. See description below. (Usage is discouraged: see [Issue #337](https://github.com/websockets/ws/issues/377#issuecomment-462152231)) + - `WebSocket` {Function} Specifies the `WebSocket` class to be used. It must + be extended from the original `WebSocket`. Defaults to `WebSocket`. - `callback` {Function} Create a new server instance. One and only one of `port`, `server` or `noServer` diff --git a/lib/websocket-server.js b/lib/websocket-server.js index 3c7939f28..d0a29783f 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -49,6 +49,8 @@ class WebSocketServer extends EventEmitter { * @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) { @@ -67,6 +69,7 @@ class WebSocketServer extends EventEmitter { host: null, path: null, port: null, + WebSocket, ...options }; @@ -356,7 +359,7 @@ class WebSocketServer extends EventEmitter { `Sec-WebSocket-Accept: ${digest}` ]; - const ws = new WebSocket(null); + const ws = new this.options.WebSocket(null); if (protocols.size) { // diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index e3daf7e0b..fd494059f 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -89,6 +89,32 @@ 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('emits an error if http server bind fails', (done) => { From 75fdfa9a2bb0f6f0cba2cd9e1b77b90cc1f5a6ff Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 4 Feb 2022 22:17:50 +0100 Subject: [PATCH 072/207] [test] Fix nits --- test/websocket.test.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index fe15ed7ca..1b1293fc6 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1101,11 +1101,9 @@ describe('WebSocket', () => { }); it('emits an error if the redirect URL is invalid (1/2)', (done) => { - const onUpgrade = (req, socket) => { + server.once('upgrade', (req, socket) => { socket.end('HTTP/1.1 302 Found\r\nLocation: ws://\r\n\r\n'); - }; - - server.on('upgrade', onUpgrade); + }); const ws = new WebSocket(`ws://localhost:${server.address().port}`, { followRedirects: true @@ -1117,17 +1115,14 @@ describe('WebSocket', () => { assert.strictEqual(err.message, 'Invalid URL: ws://'); assert.strictEqual(ws._redirects, 1); - server.removeListener('upgrade', onUpgrade); ws.on('close', () => done()); }); }); it('emits an error if the redirect URL is invalid (2/2)', (done) => { - const onUpgrade = (req, socket) => { + server.once('upgrade', (req, socket) => { socket.end('HTTP/1.1 302 Found\r\nLocation: http://localhost\r\n\r\n'); - }; - - server.on('upgrade', onUpgrade); + }); const ws = new WebSocket(`ws://localhost:${server.address().port}`, { followRedirects: true @@ -1142,7 +1137,6 @@ describe('WebSocket', () => { ); assert.strictEqual(ws._redirects, 1); - server.removeListener('upgrade', onUpgrade); ws.on('close', () => done()); }); }); From 6946f5fe781bafe99a36ed954904966203422b3d Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 7 Feb 2022 20:21:37 +0100 Subject: [PATCH 073/207] [security] Drop sensitive headers when following redirects (#2013) Do not forward the `Authorization` and `Cookie` headers if the redirect host is different from the original host. --- lib/websocket.js | 39 ++++++++++++++ test/websocket.test.js | 113 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/lib/websocket.js b/lib/websocket.js index 57710f4e1..6fff935cb 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -766,6 +766,45 @@ function initAsClient(websocket, address, protocols, options) { opts.path = parts[1]; } + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalHost = 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 (parsedUrl.host !== websocket._originalHost) { + // + // 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; + 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'); + } + } + let req = (websocket._req = get(opts)); if (opts.timeout) { diff --git a/test/websocket.test.js b/test/websocket.test.js index 1b1293fc6..2f2f4f529 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1140,6 +1140,119 @@ describe('WebSocket', () => { 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\nLocation: /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://foo:bar@localhost:${port}/foo`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + describe('When the redirect host is different', () => { + 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\nLocation: 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', (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\nLocation: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + headers: { + Authorization: 'Basic Zm9vOmJhcg==', + Cookie: 'foo=bar', + Host: 'foo' + }, + followRedirects: true + }); + + assert.strictEqual( + ws._req.getHeader('Authorization'), + 'Basic Zm9vOmJhcg==' + ); + assert.strictEqual(ws._req.getHeader('Cookie'), 'foo=bar'); + assert.strictEqual(ws._req.getHeader('Host'), 'foo'); + + 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(); + }); + }); + }); }); describe('Connection with query string', () => { From c9d5436500fad16493a2cc62a0ce6daed83c9129 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 7 Feb 2022 20:23:55 +0100 Subject: [PATCH 074/207] [dist] 8.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 723ec1b36..57ddc7e95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.4.2", + "version": "8.5.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From a0524e15586c7a51546a772bd0be31d616254125 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 25 Feb 2022 21:34:18 +0100 Subject: [PATCH 075/207] [ci] Update actions/setup-node action to v3 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a3c50a32..a3beed066 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: os: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} architecture: ${{ matrix.arch }} From 7cf94db6290f86470ce2da87a88d05b604794fd9 Mon Sep 17 00:00:00 2001 From: Proxtx <39201896+Proxtx@users.noreply.github.com> Date: Sat, 5 Mar 2022 08:30:11 +0100 Subject: [PATCH 076/207] [doc] Fix typo in code snippet (#2018) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cab335b7b..97ab9fabe 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,8 @@ server.listen(8080); ### Client authentication ```js -import WebSocket from 'ws'; import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; const server = createServer(); const wss = new WebSocketServer({ noServer: true }); From 9c18b1e78617c0906c9ad51009b3a4b412c345da Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 6 Mar 2022 20:27:31 +0100 Subject: [PATCH 077/207] [ci] Update actions/checkout action to v3 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3beed066..4af8b6dfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - arch: x86 os: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} From dd3d5a710b490d7b4211f92e882b34b5114c8bc2 Mon Sep 17 00:00:00 2001 From: airtable-keyhanvakil <60900335+airtable-keyhanvakil@users.noreply.github.com> Date: Thu, 10 Mar 2022 07:12:29 +0000 Subject: [PATCH 078/207] [doc] document the default value of the `maxPayload` option (#2020) --- doc/ws.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 3273c1f9e..0489a772a 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -72,7 +72,8 @@ This class represents a WebSocket server. It extends the `EventEmitter`. - `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. + - `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. @@ -279,7 +280,8 @@ This class represents a WebSocket. It extends the `EventEmitter`. 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. + - `maxPayload` {Number} The maximum allowed message size in bytes. Defaults to + 100 MiB (104857600 bytes). - `maxRedirects` {Number} The maximum number of redirects allowed. Defaults to 10. - `origin` {String} Value of the `Origin` or `Sec-WebSocket-Origin` header From ff80d665f9ea9452f8b8ae69f2838bdc08fe85d6 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 24 Mar 2022 14:21:49 +0100 Subject: [PATCH 079/207] [test] Do not use a relative URL Use different username and password in the redirect URL. --- test/websocket.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index 2f2f4f529..e3e11437b 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1146,7 +1146,10 @@ describe('WebSocket', () => { const authorization = 'Basic Zm9vOmJhcg=='; server.once('upgrade', (req, socket) => { - socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + 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); @@ -1164,7 +1167,7 @@ describe('WebSocket', () => { ws.on('close', (code) => { assert.strictEqual(code, 1005); - assert.strictEqual(ws.url, `ws://foo:bar@localhost:${port}/foo`); + assert.strictEqual(ws.url, `ws://baz:qux@localhost:${port}/foo`); assert.strictEqual(ws._redirects, 1); wss.close(done); From d086f4bcbbe235f12f6fa2ddba5a8ce1342dac58 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 26 Mar 2022 20:32:45 +0100 Subject: [PATCH 080/207] [minor] Make `abortHandshake()` emit the error in the next tick --- lib/websocket.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 6fff935cb..dd67d7357 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -950,7 +950,7 @@ function initAsClient(websocket, address, protocols, options) { } /** - * Emit the `'error'` and `'close'` event. + * Emit the `'error'` and `'close'` events. * * @param {WebSocket} websocket The WebSocket instance * @param {Error} The error to emit @@ -1018,8 +1018,7 @@ function abortHandshake(websocket, stream, message) { stream.socket.destroy(); } - stream.once('abort', websocket.emitClose.bind(websocket)); - websocket.emit('error', err); + process.nextTick(emitErrorAndClose, websocket, err); } else { stream.destroy(err); stream.once('error', websocket.emit.bind(websocket, 'error')); From 2619c003cef5ffc9bff66debfcc52231b7995a5a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 6 Apr 2022 20:48:41 +0200 Subject: [PATCH 081/207] [minor] Fix nit in comment --- lib/websocket.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index dd67d7357..3a56ea069 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -861,8 +861,8 @@ 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; From 62e9b199ad96e6f7d4b44a7ed84669fdaf48ba65 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 7 Apr 2022 15:51:59 +0200 Subject: [PATCH 082/207] [doc] Fix nits --- doc/ws.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 0489a772a..ed8f04f1c 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -291,7 +291,7 @@ This class represents a WebSocket. It extends the `EventEmitter`. - `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()][]. + - 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. @@ -539,7 +539,7 @@ state is `CONNECTING`. ### websocket.terminate() -Forcibly close the connection. Internally this calls [socket.destroy()][]. +Forcibly close the connection. Internally this calls [`socket.destroy()`][]. ### websocket.url @@ -610,11 +610,11 @@ 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()]: +[`http.request()`]: https://nodejs.org/api/http.html#http_http_request_options_callback -[https.request()]: +[`https.request()`]: https://nodejs.org/api/https.html#https_https_request_options_callback [permessage-deflate]: https://tools.ietf.org/html/draft-ietf-hybi-permessage-compression-19 -[socket.destroy()]: https://nodejs.org/api/net.html#net_socket_destroy_error +[`socket.destroy()`]: https://nodejs.org/api/net.html#net_socket_destroy_error [zlib-options]: https://nodejs.org/api/zlib.html#zlib_class_options From 2cf6202f8a023d98c0ee3435ba03a4c9ad855278 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 12 Apr 2022 21:32:23 +0200 Subject: [PATCH 083/207] [example] Use the `WebSocket.WebSocket{,Server}` aliases Closes #2034 --- examples/express-session-parse/index.js | 4 ++-- examples/server-stats/index.js | 4 ++-- examples/ssl.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/express-session-parse/index.js b/examples/express-session-parse/index.js index 8fc4ce029..28ea15412 100644 --- a/examples/express-session-parse/index.js +++ b/examples/express-session-parse/index.js @@ -5,7 +5,7 @@ const express = require('express'); const http = require('http'); const uuid = require('uuid'); -const WebSocket = require('../..'); +const { WebSocketServer } = require('..'); const app = express(); const map = new Map(); @@ -56,7 +56,7 @@ 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) { console.log('Parsing session from request...'); diff --git a/examples/server-stats/index.js b/examples/server-stats/index.js index da1f95a3b..91d958d52 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 () { diff --git a/examples/ssl.js b/examples/ssl.js index ad08632b1..a5e750b79 100644 --- a/examples/ssl.js +++ b/examples/ssl.js @@ -3,14 +3,14 @@ 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('message', function message(msg) { From d062ded6225a1cd2593afdce0ecd91ef8317950a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 15 Apr 2022 13:10:21 +0200 Subject: [PATCH 084/207] [example] Fix require path --- examples/express-session-parse/index.js | 2 +- examples/server-stats/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/express-session-parse/index.js b/examples/express-session-parse/index.js index 28ea15412..b62a2e4a5 100644 --- a/examples/express-session-parse/index.js +++ b/examples/express-session-parse/index.js @@ -5,7 +5,7 @@ const express = require('express'); const http = require('http'); const uuid = require('uuid'); -const { WebSocketServer } = require('..'); +const { WebSocketServer } = require('../..'); const app = express(); const map = new Map(); diff --git a/examples/server-stats/index.js b/examples/server-stats/index.js index 91d958d52..e8754b5b2 100644 --- a/examples/server-stats/index.js +++ b/examples/server-stats/index.js @@ -4,7 +4,7 @@ const express = require('express'); const path = require('path'); const { createServer } = require('http'); -const { WebSocketServer } = require('..'); +const { WebSocketServer } = require('../..'); const app = express(); app.use(express.static(path.join(__dirname, '/public'))); From 0b6eb71665eabadc7d55584fdb3de7b56ae2439e Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 22 Apr 2022 08:33:54 +0200 Subject: [PATCH 085/207] [ci] Do not test on node 17 --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4af8b6dfb..a29f52e6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,6 @@ jobs: - 12 - 14 - 16 - - 17 os: - macOS-latest - ubuntu-latest From 69e682806df1913ad881643e36c8b999522b175f Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 22 Apr 2022 08:34:23 +0200 Subject: [PATCH 086/207] [ci] Test on node 18 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a29f52e6d..833347413 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: - 12 - 14 - 16 + - 18 os: - macOS-latest - ubuntu-latest From 8e3f1181f2b1c7a4de8ceb120165276d2b1ccfaf Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 24 Apr 2022 07:12:02 +0200 Subject: [PATCH 087/207] [feature] Introduce the `'redirect'` event (#2030) Add the ability to remove confidential headers on a per-redirect basis. Closes #2014 --- doc/ws.md | 17 ++ lib/websocket.js | 40 ++++- test/websocket.test.js | 344 +++++++++++++++++++++++++++++++++++------ 3 files changed, 350 insertions(+), 51 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index ed8f04f1c..744112018 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -24,6 +24,7 @@ - [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) @@ -361,6 +362,19 @@ Emitted when a ping is received from the server. Emitted when a pong is received from the server. +### Event: 'redirect' + +- `url` {String} +- `request` {http.ClientRequest} + +Emitted before a redirect is followed. `url` is the redirect URL. `request` is +the HTTP GET request with the headers queued. This event gives the ability to +inspect confidential headers and remove them on a per-redirect basis using the +[`request.getHeader()`][] and [`request.removeHeader()`][] API. The `request` +object should be used only for this purpose. When there is at least one listener +for this event, no header is removed by default, even if the redirect is to a +different domain. + ### Event: 'unexpected-response' - `request` {http.ClientRequest} @@ -616,5 +630,8 @@ as configured by the `maxPayload` option. https://nodejs.org/api/https.html#https_https_request_options_callback [permessage-deflate]: https://tools.ietf.org/html/draft-ietf-hybi-permessage-compression-19 +[`request.getheader()`]: https://nodejs.org/api/http.html#requestgetheadername +[`request.removeheader()`]: + https://nodejs.org/api/http.html#requestremoveheadername [`socket.destroy()`]: https://nodejs.org/api/net.html#net_socket_destroy_error [zlib-options]: https://nodejs.org/api/zlib.html#zlib_class_options diff --git a/lib/websocket.js b/lib/websocket.js index 3a56ea069..ca44bc344 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -30,10 +30,11 @@ const { const { format, parse } = require('./extension'); const { toBuffer } = require('./buffer-util'); +const closeTimeout = 30 * 1000; +const kAborted = Symbol('kAborted'); +const protocolVersions = [8, 13]; const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; const subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; -const protocolVersions = [8, 13]; -const closeTimeout = 30 * 1000; /** * Class representing a WebSocket. @@ -647,7 +648,7 @@ function initAsClient(websocket, address, protocols, options) { hostname: undefined, protocol: undefined, timeout: undefined, - method: undefined, + method: 'GET', host: undefined, path: undefined, port: undefined @@ -701,7 +702,7 @@ function initAsClient(websocket, address, protocols, options) { 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; @@ -766,6 +767,8 @@ function initAsClient(websocket, address, protocols, options) { opts.path = parts[1]; } + let req; + if (opts.followRedirects) { if (websocket._redirects === 0) { websocket._originalHost = parsedUrl.host; @@ -783,7 +786,10 @@ function initAsClient(websocket, address, protocols, options) { options.headers[key.toLowerCase()] = value; } } - } else if (parsedUrl.host !== websocket._originalHost) { + } else if ( + websocket.listenerCount('redirect') === 0 && + parsedUrl.host !== websocket._originalHost + ) { // // Match curl 7.77.0 behavior and drop the following headers. These // headers are also dropped when following a redirect to a subdomain. @@ -803,9 +809,24 @@ function initAsClient(websocket, address, protocols, options) { options.headers.authorization = 'Basic ' + Buffer.from(opts.auth).toString('base64'); } - } - let req = (websocket._req = get(opts)); + 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', () => { @@ -814,7 +835,7 @@ function initAsClient(websocket, address, protocols, options) { } req.on('error', (err) => { - if (req === null || req.aborted) return; + if (req === null || req[kAborted]) return; req = websocket._req = null; emitErrorAndClose(websocket, err); @@ -947,6 +968,8 @@ function initAsClient(websocket, address, protocols, options) { skipUTF8Validation: opts.skipUTF8Validation }); }); + + req.end(); } /** @@ -1007,6 +1030,7 @@ function abortHandshake(websocket, stream, message) { Error.captureStackTrace(err, abortHandshake); if (stream.setHeader) { + stream[kAborted] = true; stream.abort(); if (stream.socket && !stream.socket.destroyed) { diff --git a/test/websocket.test.js b/test/websocket.test.js index e3e11437b..a27b2c278 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -645,6 +645,38 @@ describe('WebSocket', () => { 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', () => { @@ -1175,83 +1207,237 @@ describe('WebSocket', () => { }); describe('When the redirect host is different', () => { - it('drops the `auth` option', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const port = wss.address().port; + 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` + ); + }); - server.once('upgrade', (req, socket) => { - socket.end( - `HTTP/1.1 302 Found\r\nLocation: 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); + }); }); - const ws = new WebSocket(`ws://localhost:${server.address().port}`, { - auth: 'foo:bar', - followRedirects: true + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + ws.close(); }); + }); - assert.strictEqual( - ws._req.getHeader('Authorization'), - 'Basic Zm9vOmJhcg==' - ); + it('drops the Authorization, Cookie, and Host headers', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; - ws.on('close', (code) => { - assert.strictEqual(code, 1005); - assert.strictEqual(ws.url, `ws://localhost:${port}/`); - assert.strictEqual(ws._redirects, 1); + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); - wss.close(done); + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { + headers: { + Authorization: 'Basic Zm9vOmJhcg==', + Cookie: 'foo=bar', + Host: 'foo' + }, + followRedirects: true + } + ); + + assert.strictEqual( + ws._req.getHeader('Authorization'), + 'Basic Zm9vOmJhcg==' + ); + assert.strictEqual(ws._req.getHeader('Cookie'), 'foo=bar'); + assert.strictEqual(ws._req.getHeader('Host'), 'foo'); + + 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(); }); }); + }); - wss.on('connection', (ws, req) => { - assert.strictEqual(req.headers.authorization, undefined); - ws.close(); + describe("If there is at least one 'redirect' event listener", () => { + it('does not drop any headers by default', (done) => { + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { headers, followRedirects: true } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/`); + assert.notStrictEqual(firstRequest, req); + assert.strictEqual( + req.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual(req.getHeader('Cookie'), headers.cookie); + assert.strictEqual(req.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual( + req.headers.authorization, + headers.authorization + ); + assert.strictEqual(req.headers.cookie, headers.cookie); + assert.strictEqual(req.headers.host, headers.host); + ws.close(); + }); }); }); + }); + + describe("In a listener of the 'redirect' event", () => { + it('allows to abort the request without swallowing errors', (done) => { + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + }); + + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/foo`); - it('drops the Authorization, Cookie, and Host headers', (done) => { + req.on('socket', () => { + req.abort(); + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'socket hang up'); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + done(); + }); + }); + }); + }); + + it('allows to remove headers', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const port = wss.address().port; server.once('upgrade', (req, socket) => { socket.end( - `HTTP/1.1 302 Found\r\nLocation: ws://localhost:${port}/\r\n\r\n` + '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}`, { - headers: { - Authorization: 'Basic Zm9vOmJhcg==', - Cookie: 'foo=bar', - Host: 'foo' - }, + headers, followRedirects: true }); - assert.strictEqual( - ws._req.getHeader('Authorization'), - 'Basic Zm9vOmJhcg==' - ); - assert.strictEqual(ws._req.getHeader('Cookie'), 'foo=bar'); - assert.strictEqual(ws._req.getHeader('Host'), 'foo'); - - ws.on('close', (code) => { - assert.strictEqual(code, 1005); - assert.strictEqual(ws.url, `ws://localhost:${port}/`); + 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); - wss.close(done); + 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); - assert.strictEqual( - req.headers.host, - `localhost:${wss.address().port}` - ); ws.close(); }); }); @@ -2172,6 +2358,42 @@ describe('WebSocket', () => { }); }).timeout(4000); + it("can be called from a listener of the 'redirect' event", (done) => { + const server = http.createServer(); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + }); + + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('open', () => { + done(new Error("Unexpected 'open' event")); + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + server.close(done); + }); + }); + + ws.on('redirect', () => { + ws.close(); + }); + }); + }); + it("can be called from a listener of the 'upgrade' event", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -2410,6 +2632,42 @@ describe('WebSocket', () => { }); }).timeout(4000); + it("can be called from a listener of the 'redirect' event", (done) => { + const server = http.createServer(); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + }); + + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('open', () => { + done(new Error("Unexpected 'open' event")); + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + server.close(done); + }); + }); + + ws.on('redirect', () => { + ws.terminate(); + }); + }); + }); + it("can be called from a listener of the 'upgrade' event", (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); From ba214d96939737304fb76f662700e9df01bb4418 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 1 May 2022 21:05:41 +0200 Subject: [PATCH 088/207] [dist] 8.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 57ddc7e95..df81cc170 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.5.0", + "version": "8.6.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From e56cdfe1ec54cdb04bc83ca91f8c4c983db6cde4 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 20 May 2022 15:21:06 +0200 Subject: [PATCH 089/207] [minor] Clarify why the handshake is aborted Add more details about why the handshake is aborted in the HTTP response. Refs: https://github.com/websockets/ws/issues/2045#issuecomment-1130081274 --- lib/websocket-server.js | 93 ++++++++++------- test/websocket-server.test.js | 181 ++++++++++++++++++++++++++++++++-- 2 files changed, 231 insertions(+), 43 deletions(-) diff --git a/lib/websocket-server.js b/lib/websocket-server.js index d0a29783f..059b0a572 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -230,21 +230,34 @@ 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'] - : false; + const key = req.headers['sec-websocket-key']; const version = +req.headers['sec-websocket-version']; - if ( - req.method !== 'GET' || - req.headers.upgrade.toLowerCase() !== 'websocket' || - !key || - !keyRegex.test(key) || - (version !== 8 && version !== 13) || - !this.shouldHandle(req) - ) { - return abortHandshake(socket, 400); + if (req.method !== 'GET') { + abortHandshake(socket, 405, 'The HTTP method is invalid'); + return; + } + + if (req.headers.upgrade.toLowerCase() !== 'websocket') { + abortHandshake(socket, 400, 'The Upgrade header is invalid'); + return; + } + + if (!key || !keyRegex.test(key)) { + const message = 'The Sec-WebSocket-Key header is missing or invalid'; + abortHandshake(socket, 400, message); + return; + } + + if (version !== 8 && version !== 13) { + const message = 'The Sec-WebSocket-Version header is missing or invalid'; + abortHandshake(socket, 400, message); + return; + } + + if (!this.shouldHandle(req)) { + abortHandshake(socket, 400); + return; } const secWebSocketProtocol = req.headers['sec-websocket-protocol']; @@ -254,7 +267,9 @@ class WebSocketServer extends EventEmitter { try { protocols = subprotocol.parse(secWebSocketProtocol); } catch (err) { - return abortHandshake(socket, 400); + const message = 'The Sec-WebSocket-Protocol header is invalid'; + abortHandshake(socket, 400, message); + return; } } @@ -279,7 +294,10 @@ class WebSocketServer extends EventEmitter { extensions[PerMessageDeflate.extensionName] = perMessageDeflate; } } catch (err) { - return abortHandshake(socket, 400); + const message = + 'The Sec-WebSocket-Extensions header is invalid or not acceptable'; + abortHandshake(socket, 400, message); + return; } } @@ -446,7 +464,7 @@ function emitClose(server) { } /** - * Handle premature socket errors. + * Handle socket errors. * * @private */ @@ -464,25 +482,30 @@ function socketOnError() { * @private */ function abortHandshake(socket, code, message, headers) { - if (socket.writable) { - message = message || http.STATUS_CODES[code]; - headers = { - Connection: 'close', - 'Content-Type': 'text/html', - 'Content-Length': Buffer.byteLength(message), - ...headers - }; + // + // The socket is writable unless the user destroyed or ended it before calling + // `server.handleUpgrade()` or in the `verifyClient` function, which is a user + // error. Handling this does not make much sense as the worst that can happen + // is that some of the data written by the user might be discarded due to the + // call to `socket.end()` below, which triggers an `'error'` event that in + // turn causes the socket to be destroyed. + // + message = message || http.STATUS_CODES[code]; + headers = { + Connection: 'close', + 'Content-Type': 'text/html', + 'Content-Length': Buffer.byteLength(message), + ...headers + }; - socket.write( - `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` + - Object.keys(headers) - .map((h) => `${h}: ${headers[h]}`) - .join('\r\n') + - '\r\n\r\n' + - message - ); - } + socket.once('finish', socket.destroy); - socket.removeListener('error', socketOnError); - socket.destroy(); + socket.end( + `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` + + Object.keys(headers) + .map((h) => `${h}: ${headers[h]}`) + .join('\r\n') + + '\r\n\r\n' + + message + ); } diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index fd494059f..5590d6c99 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -470,7 +470,9 @@ describe('WebSocketServer', () => { port: wss.address().port, headers: { Connection: 'Upgrade', - Upgrade: 'websocket' + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13 } }); @@ -496,7 +498,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(), + 'The Sec-WebSocket-Key header is missing or invalid' + ); + wss.close(done); + }); }); }); }); @@ -539,6 +554,77 @@ 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(), + 'The HTTP method is invalid' + ); + wss.close(done); + }); + }); + + req.end(); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the Upgrade header field value is not "websocket"', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'foo' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'The Upgrade header is invalid' + ); + 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({ @@ -551,7 +637,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(), + 'The Sec-WebSocket-Key header is missing or invalid' + ); + wss.close(done); + }); }); }); @@ -573,7 +672,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(), + 'The Sec-WebSocket-Key header is missing or invalid' + ); + wss.close(done); + }); }); }); @@ -595,7 +707,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(), + 'The Sec-WebSocket-Version header is missing or invalid' + ); + wss.close(done); + }); }); }); @@ -618,7 +743,20 @@ describe('WebSocketServer', () => { req.on('response', (res) => { assert.strictEqual(res.statusCode, 400); - wss.close(done); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'The Sec-WebSocket-Version header is missing or invalid' + ); + wss.close(done); + }); }); }); @@ -642,7 +780,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(), + 'The Sec-WebSocket-Protocol header is invalid' + ); + wss.close(done); + }); }); }); @@ -672,7 +823,21 @@ 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(), + 'The Sec-WebSocket-Extensions header is invalid or not ' + + 'acceptable' + ); + wss.close(done); + }); }); } ); From 0fdcc0af78c4d9a77f17faba48071d2f66a79662 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 21 May 2022 07:02:12 +0200 Subject: [PATCH 090/207] [fix] Abort the handshake if the Upgrade header is invalid Close the connection if the Upgrade header field in the HTTP response contains a value that is not an ASCII case-insensitive match for the value "websocket". --- lib/websocket.js | 5 +++++ test/websocket.test.js | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/lib/websocket.js b/lib/websocket.js index ca44bc344..8ea2f5e01 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -889,6 +889,11 @@ function initAsClient(websocket, address, protocols, options) { req = websocket._req = null; + if (res.headers.upgrade.toLowerCase() !== 'websocket') { + abortHandshake(websocket, socket, 'Invalid Upgrade header'); + return; + } + const digest = createHash('sha1') .update(key + GUID) .digest('base64'); diff --git a/test/websocket.test.js b/test/websocket.test.js index a27b2c278..beea20a76 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -685,6 +685,26 @@ describe('WebSocket', () => { beforeEach((done) => server.listen(0, done)); afterEach((done) => server.close(done)); + it('fails if the Upgrade header field value is not "websocket"', (done) => { + server.once('upgrade', (req, socket) => { + socket.on('end', socket.end); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Connection: Upgrade\r\n' + + 'Upgrade: foo\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid Upgrade header'); + done(); + }); + }); + it('fails if the Sec-WebSocket-Accept header is invalid', (done) => { server.once('upgrade', (req, socket) => { socket.on('end', socket.end); From fb658bdd11e05a89ad5dc48e126e53976c5bb67a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 21 May 2022 07:25:52 +0200 Subject: [PATCH 091/207] [minor] Use consistent error messages Make some server error messages consistent with the respective client error messages. --- lib/websocket-server.js | 12 ++++++------ test/websocket-server.test.js | 19 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/lib/websocket-server.js b/lib/websocket-server.js index 059b0a572..e22e9b212 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -234,23 +234,23 @@ class WebSocketServer extends EventEmitter { const version = +req.headers['sec-websocket-version']; if (req.method !== 'GET') { - abortHandshake(socket, 405, 'The HTTP method is invalid'); + abortHandshake(socket, 405, 'Invalid HTTP method'); return; } if (req.headers.upgrade.toLowerCase() !== 'websocket') { - abortHandshake(socket, 400, 'The Upgrade header is invalid'); + abortHandshake(socket, 400, 'Invalid Upgrade header'); return; } if (!key || !keyRegex.test(key)) { - const message = 'The Sec-WebSocket-Key header is missing or invalid'; + const message = 'Missing or invalid Sec-WebSocket-Key header'; abortHandshake(socket, 400, message); return; } if (version !== 8 && version !== 13) { - const message = 'The Sec-WebSocket-Version header is missing or invalid'; + const message = 'Missing or invalid Sec-WebSocket-Version header'; abortHandshake(socket, 400, message); return; } @@ -267,7 +267,7 @@ class WebSocketServer extends EventEmitter { try { protocols = subprotocol.parse(secWebSocketProtocol); } catch (err) { - const message = 'The Sec-WebSocket-Protocol header is invalid'; + const message = 'Invalid Sec-WebSocket-Protocol header'; abortHandshake(socket, 400, message); return; } @@ -295,7 +295,7 @@ class WebSocketServer extends EventEmitter { } } catch (err) { const message = - 'The Sec-WebSocket-Extensions header is invalid or not acceptable'; + 'Invalid or unacceptable Sec-WebSocket-Extensions header'; abortHandshake(socket, 400, message); return; } diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 5590d6c99..1ad63da64 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -508,7 +508,7 @@ describe('WebSocketServer', () => { res.on('end', () => { assert.strictEqual( Buffer.concat(chunks).toString(), - 'The Sec-WebSocket-Key header is missing or invalid' + 'Missing or invalid Sec-WebSocket-Key header' ); wss.close(done); }); @@ -577,7 +577,7 @@ describe('WebSocketServer', () => { res.on('end', () => { assert.strictEqual( Buffer.concat(chunks).toString(), - 'The HTTP method is invalid' + 'Invalid HTTP method' ); wss.close(done); }); @@ -613,7 +613,7 @@ describe('WebSocketServer', () => { res.on('end', () => { assert.strictEqual( Buffer.concat(chunks).toString(), - 'The Upgrade header is invalid' + 'Invalid Upgrade header' ); wss.close(done); }); @@ -647,7 +647,7 @@ describe('WebSocketServer', () => { res.on('end', () => { assert.strictEqual( Buffer.concat(chunks).toString(), - 'The Sec-WebSocket-Key header is missing or invalid' + 'Missing or invalid Sec-WebSocket-Key header' ); wss.close(done); }); @@ -682,7 +682,7 @@ describe('WebSocketServer', () => { res.on('end', () => { assert.strictEqual( Buffer.concat(chunks).toString(), - 'The Sec-WebSocket-Key header is missing or invalid' + 'Missing or invalid Sec-WebSocket-Key header' ); wss.close(done); }); @@ -717,7 +717,7 @@ describe('WebSocketServer', () => { res.on('end', () => { assert.strictEqual( Buffer.concat(chunks).toString(), - 'The Sec-WebSocket-Version header is missing or invalid' + 'Missing or invalid Sec-WebSocket-Version header' ); wss.close(done); }); @@ -753,7 +753,7 @@ describe('WebSocketServer', () => { res.on('end', () => { assert.strictEqual( Buffer.concat(chunks).toString(), - 'The Sec-WebSocket-Version header is missing or invalid' + 'Missing or invalid Sec-WebSocket-Version header' ); wss.close(done); }); @@ -790,7 +790,7 @@ describe('WebSocketServer', () => { res.on('end', () => { assert.strictEqual( Buffer.concat(chunks).toString(), - 'The Sec-WebSocket-Protocol header is invalid' + 'Invalid Sec-WebSocket-Protocol header' ); wss.close(done); }); @@ -833,8 +833,7 @@ describe('WebSocketServer', () => { res.on('end', () => { assert.strictEqual( Buffer.concat(chunks).toString(), - 'The Sec-WebSocket-Extensions header is invalid or not ' + - 'acceptable' + 'Invalid or unacceptable Sec-WebSocket-Extensions header' ); wss.close(done); }); From 8889e48018bc3381e3daa8398be7786c07f8db02 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 21 May 2022 07:46:20 +0200 Subject: [PATCH 092/207] [test] Increase code coverage --- test/websocket-server.test.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 1ad63da64..bcf82ba90 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -340,18 +340,12 @@ describe('WebSocketServer', () => { wss.close(); }); - it("emits the 'close' event if the server is already closed", (done) => { - let callbackCalled = false; + 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.on('close', () => { - callbackCalled = true; - }); - wss.close((err) => { - assert.ok(callbackCalled); assert.ok(err instanceof Error); assert.strictEqual(err.message, 'The server is not running'); done(); @@ -359,6 +353,17 @@ describe('WebSocketServer', () => { }); }); }); + + 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', () => { From a690791df31cede48e7bcbce9d56411483309246 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 21 May 2022 07:51:32 +0200 Subject: [PATCH 093/207] [ci] Exclude node 18 on Windows x86 from the test matrix --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 833347413..faf9bf56d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: os: macOS-latest - arch: x86 os: ubuntu-latest + - arch: x86 + node: 18 + os: windows-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 From d68ba9e1aa6a939fd7aa39894a4b9c2f020f9361 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 25 May 2022 15:33:05 +0200 Subject: [PATCH 094/207] [security] Drop sensitive headers when following insecure redirects Drop the `Authorization` and `Cookie` headers if the original request for the opening handshake is sent over HTTPS and the client is redirected to the same host over plain HTTP (wss: to ws:). If an HTTPS server redirects to same host over plain HTTP, the problem is on the server, but handling this condition is not hard and reduces the risk of leaking credentials due to MITM issues. Refs: https://github.com/websockets/ws/commit/6946f5fe --- lib/websocket.js | 28 +++--- test/websocket.test.js | 211 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 12 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 8ea2f5e01..c4b14907b 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -771,6 +771,7 @@ function initAsClient(websocket, address, protocols, options) { if (opts.followRedirects) { if (websocket._redirects === 0) { + websocket._originalSecure = isSecure; websocket._originalHost = parsedUrl.host; const headers = options && options.headers; @@ -786,18 +787,21 @@ function initAsClient(websocket, address, protocols, options) { options.headers[key.toLowerCase()] = value; } } - } else if ( - websocket.listenerCount('redirect') === 0 && - parsedUrl.host !== websocket._originalHost - ) { - // - // 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; - delete opts.headers.host; - opts.auth = undefined; + } else if (websocket.listenerCount('redirect') === 0) { + const isSameHost = parsedUrl.host === websocket._originalHost; + + 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; + } } // diff --git a/test/websocket.test.js b/test/websocket.test.js index beea20a76..1a3453601 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -6,6 +6,7 @@ const assert = require('assert'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); +const net = require('net'); const tls = require('tls'); const fs = require('fs'); const { URL } = require('url'); @@ -1226,6 +1227,216 @@ describe('WebSocket', () => { }); }); + 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:${server.address().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 headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + 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); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, 'foo'); + + ws.close(); + }); + + const ws = new WebSocket( + `wss://localhost:${server.address().port}`, + { headers, followRedirects: true, 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 headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + 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, + headers.authorization + ); + assert.strictEqual(req.headers.cookie, headers.cookie); + assert.strictEqual(req.headers.host, headers.host); + + ws.close(); + }); + + const ws = new WebSocket( + `wss://localhost:${server.address().port}`, + { headers, followRedirects: true, 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) => { From 903ec620117d95ed31cdec8c893b7c4fbe79314d Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 25 May 2022 20:43:21 +0200 Subject: [PATCH 095/207] [doc] Update the type of the `socket` argument --- doc/ws.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/ws.md b/doc/ws.md index 744112018..8a0c4ce64 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -230,7 +230,8 @@ 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` {net.Socket|tls.Socket} The network socket between the server and + client. - `head` {Buffer} The first packet of the upgraded stream. - `callback` {Function}. From 6e5a5ce341ffab5ea48542f0aa82c7f4eae80df9 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 26 May 2022 12:42:53 +0200 Subject: [PATCH 096/207] [feature] Introduce the `'wsClientError'` event (#2046) Add the ability to inspect the invalid handshake requests and respond to them with a custom HTTP response. Closes #2045 --- doc/ws.md | 16 ++++++++++++++++ lib/websocket-server.js | 36 +++++++++++++++++++++++++++++------ test/websocket-server.test.js | 34 +++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 8a0c4ce64..21984c60a 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -9,6 +9,7 @@ - [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) @@ -202,6 +203,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 diff --git a/lib/websocket-server.js b/lib/websocket-server.js index e22e9b212..bac30eb33 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -234,24 +234,26 @@ class WebSocketServer extends EventEmitter { const version = +req.headers['sec-websocket-version']; if (req.method !== 'GET') { - abortHandshake(socket, 405, 'Invalid HTTP method'); + const message = 'Invalid HTTP method'; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); return; } if (req.headers.upgrade.toLowerCase() !== 'websocket') { - abortHandshake(socket, 400, 'Invalid Upgrade header'); + const message = 'Invalid Upgrade header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); return; } if (!key || !keyRegex.test(key)) { const message = 'Missing or invalid Sec-WebSocket-Key header'; - abortHandshake(socket, 400, message); + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); return; } if (version !== 8 && version !== 13) { const message = 'Missing or invalid Sec-WebSocket-Version header'; - abortHandshake(socket, 400, message); + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); return; } @@ -268,7 +270,7 @@ class WebSocketServer extends EventEmitter { protocols = subprotocol.parse(secWebSocketProtocol); } catch (err) { const message = 'Invalid Sec-WebSocket-Protocol header'; - abortHandshake(socket, 400, message); + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); return; } } @@ -296,7 +298,7 @@ class WebSocketServer extends EventEmitter { } catch (err) { const message = 'Invalid or unacceptable Sec-WebSocket-Extensions header'; - abortHandshake(socket, 400, message); + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); return; } } @@ -509,3 +511,25 @@ function abortHandshake(socket, code, message, headers) { message ); } + +/** + * Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least + * one listener for it, otherwise call `abortHandshake()`. + * + * @param {WebSocketServer} server The WebSocket server + * @param {http.IncomingMessage} req The request object + * @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request + * @param {Number} code The HTTP response status code + * @param {String} message The HTTP response body + * @private + */ +function abortHandshakeOrEmitwsClientError(server, req, socket, code, message) { + if (server.listenerCount('wsClientError')) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + + server.emit('wsClientError', err, socket, req); + } else { + abortHandshake(socket, code, message); + } +} diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index bcf82ba90..12928ff49 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -851,6 +851,40 @@ describe('WebSocketServer', () => { }); }); + it("emits the 'wsClientError' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.request({ + method: 'POST', + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + wss.close(done); + }); + + req.end(); + }); + + wss.on('wsClientError', (err, socket, request) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid HTTP method'); + + assert.ok(request instanceof http.IncomingMessage); + assert.strictEqual(request.method, 'POST'); + + socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + it('fails if the WebSocket server is closing or closed', (done) => { const server = http.createServer(); const wss = new WebSocket.Server({ noServer: true }); From 4b62fbf945cee92f0b06535d8249b0a1d5037cea Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 26 May 2022 19:16:17 +0200 Subject: [PATCH 097/207] [dist] 8.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index df81cc170..2a176c8fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.6.0", + "version": "8.7.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 5e4149ec6a82e0367a925881abca5667667030e9 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 30 May 2022 17:32:18 +0200 Subject: [PATCH 098/207] [test] Fix typo --- test/websocket.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index 1a3453601..3ea9ea20b 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -2966,7 +2966,7 @@ describe('WebSocket', () => { ws.onmessage = 'foo'; assert.strictEqual(ws.onmessage, null); - assert.strictEqual(ws.listenerCount('onmessage'), 0); + assert.strictEqual(ws.listenerCount('message'), 0); }); it('works like the `EventEmitter` interface', (done) => { From a6dbd1c3846ce910a759118729cdb5366d8838be Mon Sep 17 00:00:00 2001 From: neilnaveen <42328488+neilnaveen@users.noreply.github.com> Date: Sat, 4 Jun 2022 01:53:40 -0500 Subject: [PATCH 099/207] [ci] Set permissions explicitly (#2051) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faf9bf56d..a3a787a65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: - push - pull_request +permissions: {} + jobs: test: runs-on: ${{ matrix.os }} From c1a126f2bd986c9fe6c1052a6be549506bc69bf5 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 8 Jun 2022 16:16:15 +0200 Subject: [PATCH 100/207] [doc] Rename WS Error Codes section to Error codes --- doc/ws.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 21984c60a..e3b03a4d3 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -49,7 +49,7 @@ - [websocket.terminate()](#websocketterminate) - [websocket.url](#websocketurl) - [createWebSocketStream(websocket[, options])](#createwebsocketstreamwebsocket-options) -- [WS Error Codes](#ws-error-codes) +- [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) @@ -353,7 +353,7 @@ been closed. - `error` {Error} Emitted when an error occurs. Errors may have a `.code` property, matching one -of the string values defined below under [WS Error Codes](#ws-error-codes). +of the string values defined below under [Error codes](#error-codes). ### Event: 'message' @@ -587,7 +587,7 @@ The URL of the WebSocket server. Server clients don't have this attribute. Returns a `Duplex` stream that allows to use the Node.js streams API on top of a given `WebSocket`. -## WS Error Codes +## Error codes Errors emitted by the websocket may have a `.code` property, describing the specific type of error that has occurred: From 0792742de13bfac0faecf044d290485808192665 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 8 Jun 2022 21:19:07 +0200 Subject: [PATCH 101/207] [doc] Fix nit --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 97ab9fabe..3c38a643c 100644 --- a/README.md +++ b/README.md @@ -485,5 +485,4 @@ 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 +[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback From becf237cef8924a765075ecbd45599bc10fb6d18 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 9 Jun 2022 19:00:23 +0200 Subject: [PATCH 102/207] [feature] Add the `WS_NO_{BUFFER_UTIL, UTF_8_VALIDATE}` variables When set to non empty values, the `WS_NO_BUFFER_UTIL` and `WS_NO_UTF_8_VALIDATE` environment variables, prevent the optional `bufferutil` and `utf-8-validate` dependencies from being required, respectively. These might be useful to enhance security in systems where a user can put a package in the package search path of an application of another user, due to how the Node.js resolver algorithm works. --- README.md | 7 +++++++ doc/ws.md | 15 +++++++++++++++ lib/buffer-util.js | 41 +++++++++++++++++++++-------------------- lib/validation.js | 29 +++++++++++++++-------------- 4 files changed, 58 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 3c38a643c..4ae71f6d0 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,13 @@ necessarily need to have a C++ compiler installed on your machine. - `npm install --save-optional utf-8-validate`: Allows to efficiently check if a message contains valid UTF-8. +To not even try to require and use these modules, use the +[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) and +[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment +variables. These might be useful to enhance security in systems where a user can +put a package in the package search path of an application of another user, due +to how the Node.js resolver algorithm works. + ## API docs See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and diff --git a/doc/ws.md b/doc/ws.md index e3b03a4d3..9496b7f5a 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -49,6 +49,9 @@ - [websocket.terminate()](#websocketterminate) - [websocket.url](#websocketurl) - [createWebSocketStream(websocket[, options])](#createwebsocketstreamwebsocket-options) +- [Environment variables](#environment-variables) + - [WS_NO_BUFFER_UTIL](#ws_no_buffer_util) + - [WS_NO_UTF_8_VALIDATE](#ws_no_utf_8_validate) - [Error codes](#error-codes) - [WS_ERR_EXPECTED_FIN](#ws_err_expected_fin) - [WS_ERR_EXPECTED_MASK](#ws_err_expected_mask) @@ -587,6 +590,18 @@ 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 diff --git a/lib/buffer-util.js b/lib/buffer-util.js index 1ba1d1beb..df7595546 100644 --- a/lib/buffer-util.js +++ b/lib/buffer-util.js @@ -99,28 +99,29 @@ function toBuffer(data) { return buf; } -try { - const bufferUtil = require('bufferutil'); - - module.exports = { - concat, - mask(source, mask, output, offset, length) { +module.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask +}; + +/* istanbul ignore else */ +if (!process.env.WS_NO_BUFFER_UTIL) { + try { + const bufferUtil = require('bufferutil'); + + module.exports.mask = function (source, mask, output, offset, length) { if (length < 48) _mask(source, mask, output, offset, length); else bufferUtil.mask(source, mask, output, offset, length); - }, - toArrayBuffer, - toBuffer, - unmask(buffer, mask) { + }; + + module.exports.unmask = function (buffer, mask) { if (buffer.length < 32) _unmask(buffer, mask); else bufferUtil.unmask(buffer, mask); - } - }; -} catch (e) /* istanbul ignore next */ { - module.exports = { - concat, - mask: _mask, - toArrayBuffer, - toBuffer, - unmask: _unmask - }; + }; + } catch (e) { + // Continue regardless of the error. + } } diff --git a/lib/validation.js b/lib/validation.js index ed98c7591..44fc20290 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -105,20 +105,21 @@ function _isValidUTF8(buf) { return true; } -try { - const isValidUTF8 = require('utf-8-validate'); +module.exports = { + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars +}; - module.exports = { - isValidStatusCode, - isValidUTF8(buf) { +/* istanbul ignore else */ +if (!process.env.WS_NO_UTF_8_VALIDATE) { + try { + const isValidUTF8 = require('utf-8-validate'); + + module.exports.isValidUTF8 = function (buf) { return buf.length < 150 ? _isValidUTF8(buf) : isValidUTF8(buf); - }, - tokenChars - }; -} catch (e) /* istanbul ignore next */ { - module.exports = { - isValidStatusCode, - isValidUTF8: _isValidUTF8, - tokenChars - }; + }; + } catch (e) { + // Continue regardless of the error. + } } From 982b7826f940b7caec5dd7ea82386dc531c5fdd4 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 9 Jun 2022 19:16:41 +0200 Subject: [PATCH 103/207] [dist] 8.8.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2a176c8fd..e09fae395 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.7.0", + "version": "8.8.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 3b6af82be91713fcc21cb2e56c500977fcb63f45 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 20 Jun 2022 15:35:00 +0200 Subject: [PATCH 104/207] [minor] Prevent opening handshake headers from being overridden Ensure that the `Connection`, `Sec-WebSocket-Key`, `Sec-WebSocket-Version`, and `Upgrade` headers are not overridden. Refs: https://github.com/websockets/ws/issues/2048#issuecomment-1159832034 --- lib/websocket.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index c4b14907b..68405dceb 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -713,11 +713,11 @@ function initAsClient(websocket, address, protocols, options) { ? 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; From 1117af6bf45e040fb51864e29c1c2c1a6b456ab7 Mon Sep 17 00:00:00 2001 From: Lee Trout Date: Thu, 30 Jun 2022 14:12:20 -0400 Subject: [PATCH 105/207] [doc] Fix typo (#2062) --- doc/ws.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ws.md b/doc/ws.md index 9496b7f5a..4888ee4c6 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -9,7 +9,7 @@ - [Event: 'error'](#event-error) - [Event: 'headers'](#event-headers) - [Event: 'listening'](#event-listening) - - [Event: 'wsClientError'](event-wsclienterror) + - [Event: 'wsClientError'](#event-wsclienterror) - [server.address()](#serveraddress) - [server.clients](#serverclients) - [server.close([callback])](#serverclosecallback) From 0ae302affd469f3f2c46718b5c0a7317f41f8dcb Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 14 Jul 2022 19:21:59 +0200 Subject: [PATCH 106/207] [test] Fix nits --- test/websocket.test.js | 96 ++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 46 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index 3ea9ea20b..64b5bf224 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1273,14 +1273,11 @@ describe('WebSocket', () => { ws.close(); }); - const ws = new WebSocket( - `wss://localhost:${server.address().port}`, - { - auth: 'foo:bar', - followRedirects: true, - rejectUnauthorized: false - } - ); + const ws = new WebSocket(`wss://localhost:${port}`, { + auth: 'foo:bar', + followRedirects: true, + rejectUnauthorized: false + }); assert.strictEqual( ws._req.getHeader('Authorization'), @@ -1297,13 +1294,7 @@ describe('WebSocket', () => { }); }); - it('drops the Authorization, and Cookie headers', (done) => { - const headers = { - authorization: 'Basic Zm9vOmJhcg==', - cookie: 'foo=bar', - host: 'foo' - }; - + it('drops the Authorization and Cookie headers', (done) => { const httpServer = http.createServer(); const httpsServer = https.createServer({ cert: fs.readFileSync('test/fixtures/certificate.pem'), @@ -1322,20 +1313,27 @@ describe('WebSocket', () => { ); }); + 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, 'foo'); + assert.strictEqual(req.headers.host, headers.host); ws.close(); }); - const ws = new WebSocket( - `wss://localhost:${server.address().port}`, - { headers, followRedirects: true, rejectUnauthorized: false } - ); + const ws = new WebSocket(`wss://localhost:${port}`, { + followRedirects: true, + headers, + rejectUnauthorized: false + }); const firstRequest = ws._req; @@ -1362,12 +1360,6 @@ describe('WebSocket', () => { describe("If there is at least one 'redirect' event listener", () => { it('does not drop any headers by default', (done) => { - const headers = { - authorization: 'Basic Zm9vOmJhcg==', - cookie: 'foo=bar', - host: 'foo' - }; - const httpServer = http.createServer(); const httpsServer = https.createServer({ cert: fs.readFileSync('test/fixtures/certificate.pem'), @@ -1386,6 +1378,12 @@ describe('WebSocket', () => { ); }); + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + const wss = new WebSocket.Server({ server: httpServer }); wss.on('connection', (ws, req) => { @@ -1399,10 +1397,11 @@ describe('WebSocket', () => { ws.close(); }); - const ws = new WebSocket( - `wss://localhost:${server.address().port}`, - { headers, followRedirects: true, rejectUnauthorized: false } - ); + const ws = new WebSocket(`wss://localhost:${port}`, { + followRedirects: true, + headers, + rejectUnauthorized: false + }); const firstRequest = ws._req; @@ -1478,7 +1477,7 @@ describe('WebSocket', () => { }); }); - it('drops the Authorization, Cookie, and Host headers', (done) => { + it('drops the Authorization, Cookie and Host headers', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const port = wss.address().port; @@ -1489,24 +1488,28 @@ describe('WebSocket', () => { ); }); + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + const ws = new WebSocket( `ws://localhost:${server.address().port}`, - { - headers: { - Authorization: 'Basic Zm9vOmJhcg==', - Cookie: 'foo=bar', - Host: 'foo' - }, - followRedirects: true - } + { followRedirects: true, headers } ); + const firstRequest = ws._req; + assert.strictEqual( - ws._req.getHeader('Authorization'), - 'Basic Zm9vOmJhcg==' + firstRequest.getHeader('Authorization'), + headers.authorization ); - assert.strictEqual(ws._req.getHeader('Cookie'), 'foo=bar'); - assert.strictEqual(ws._req.getHeader('Host'), 'foo'); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); ws.on('close', (code) => { assert.strictEqual(code, 1005); @@ -1524,6 +1527,7 @@ describe('WebSocket', () => { req.headers.host, `localhost:${wss.address().port}` ); + ws.close(); }); }); @@ -1549,7 +1553,7 @@ describe('WebSocket', () => { const ws = new WebSocket( `ws://localhost:${server.address().port}`, - { headers, followRedirects: true } + { followRedirects: true, headers } ); const firstRequest = ws._req; @@ -1643,8 +1647,8 @@ describe('WebSocket', () => { }; const ws = new WebSocket(`ws://localhost:${server.address().port}`, { - headers, - followRedirects: true + followRedirects: true, + headers }); ws.on('redirect', (url, req) => { From bc8bd34e4125a5edc37211fb384c17a4703e9000 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 15 Jul 2022 09:32:52 +0200 Subject: [PATCH 107/207] [security] Fix same host check for ws+unix: redirects Drop the `Authorization` and `Cookie` headers if the original request for the opening handshake is sent to an IPC server and the client is redirected to a TCP server (ws+unix: to ws: or wss:), and vice versa (ws: or wss: to ws+unix). Also drop the `Authorization` and `Cookie` headers if the original request for the opening handshake is sent to an IPC server and the client is redirected to another IPC server. Refs: https://github.com/websockets/ws/commit/6946f5fe --- lib/websocket.js | 13 ++- test/websocket.test.js | 221 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 3 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 68405dceb..3132cc150 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -771,8 +771,11 @@ function initAsClient(websocket, address, protocols, options) { if (opts.followRedirects) { if (websocket._redirects === 0) { + websocket._originalUnixSocket = isUnixSocket; websocket._originalSecure = isSecure; - websocket._originalHost = parsedUrl.host; + websocket._originalHostOrSocketPath = isUnixSocket + ? opts.socketPath + : parsedUrl.host; const headers = options && options.headers; @@ -788,7 +791,13 @@ function initAsClient(websocket, address, protocols, options) { } } } else if (websocket.listenerCount('redirect') === 0) { - const isSameHost = parsedUrl.host === websocket._originalHost; + const isSameHost = isUnixSocket + ? websocket._originalUnixSocket + ? opts.socketPath === websocket._originalHostOrSocketPath + : false + : websocket._originalUnixSocket + ? false + : parsedUrl.host === websocket._originalHostOrSocketPath; if (!isSameHost || (websocket._originalSecure && !isSecure)) { // diff --git a/test/websocket.test.js b/test/websocket.test.js index 64b5bf224..f5fbf1650 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -6,8 +6,10 @@ const assert = require('assert'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); +const path = require('path'); const net = require('net'); const tls = require('tls'); +const os = require('os'); const fs = require('fs'); const { URL } = require('url'); @@ -1477,7 +1479,9 @@ describe('WebSocket', () => { }); }); - it('drops the Authorization, Cookie and Host headers', (done) => { + 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; @@ -1531,6 +1535,221 @@ describe('WebSocket', () => { ws.close(); }); }); + + it('drops the Authorization, Cookie and Host headers (2/4)', function (done) { + if (process.platform === 'win32') return this.skip(); + + // Test the `ws:` to `ws+unix:` case. + + const socketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + server.once('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 302 Found\r\nLocation: ws+unix://${socketPath}\r\n\r\n` + ); + }); + + const redirectedServer = http.createServer(); + const wss = new WebSocket.Server({ server: redirectedServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, 'localhost'); + + ws.close(); + }); + + redirectedServer.listen(socketPath, () => { + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws+unix://${socketPath}`); + assert.strictEqual(ws._redirects, 1); + + redirectedServer.close(done); + }); + }); + }); + + it('drops the Authorization, Cookie and Host headers (3/4)', function (done) { + if (process.platform === 'win32') return this.skip(); + + // Test the `ws+unix:` to `ws+unix:` case. + + const redirectingServerSocketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + const redirectedServerSocketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + const redirectingServer = http.createServer(); + + redirectingServer.on('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws+unix://${redirectedServerSocketPath}\r\n\r\n` + ); + }); + + const redirectedServer = http.createServer(); + const wss = new WebSocket.Server({ server: redirectedServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, 'localhost'); + + ws.close(); + }); + + redirectingServer.listen(redirectingServerSocketPath, listening); + redirectedServer.listen(redirectedServerSocketPath, listening); + + let callCount = 0; + + function listening() { + if (++callCount !== 2) return; + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket( + `ws+unix://${redirectingServerSocketPath}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual( + ws.url, + `ws+unix://${redirectedServerSocketPath}` + ); + assert.strictEqual(ws._redirects, 1); + + redirectingServer.close(); + redirectedServer.close(done); + }); + } + }); + + it('drops the Authorization, Cookie and Host headers (4/4)', function (done) { + if (process.platform === 'win32') return this.skip(); + + // Test the `ws+unix:` to `ws:` case. + + const redirectingServer = http.createServer(); + const redirectedServer = http.createServer(); + const wss = new WebSocket.Server({ server: redirectedServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual( + req.headers.host, + `localhost:${redirectedServer.address().port}` + ); + + ws.close(); + }); + + const socketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + redirectingServer.listen(socketPath, listening); + redirectedServer.listen(0, listening); + + let callCount = 0; + + function listening() { + if (++callCount !== 2) return; + + const port = redirectedServer.address().port; + + redirectingServer.on('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 302 Found\r\nLocation: ws://localhost:${port}\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket(`ws+unix://${socketPath}`, { + followRedirects: true, + headers + }); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + redirectingServer.close(); + redirectedServer.close(done); + }); + } + }); }); describe("If there is at least one 'redirect' event listener", () => { From 975382178f8a9355a5a564bb29cb1566889da9ba Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 15 Jul 2022 13:34:15 +0200 Subject: [PATCH 108/207] [dist] 8.8.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e09fae395..27b9244a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.8.0", + "version": "8.8.1", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From c22cbc9e743a001b3fc55c1aa77b0afcc1018ee9 Mon Sep 17 00:00:00 2001 From: Dathan Date: Sun, 18 Sep 2022 14:48:05 -0400 Subject: [PATCH 109/207] [doc] Revise `WebSocket#send()` docs for clarity (#2077) Add reference to Node.js's own related documentation about the constraints on `Object` values. Fixes #2076 --- doc/ws.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/doc/ws.md b/doc/ws.md index 4888ee4c6..0b1ba7a37 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -554,7 +554,9 @@ only removes listeners added with ### websocket.send(data[, options][, callback]) - `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The - data to send. + 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} - `binary` {Boolean} Specifies whether `data` should be sent as a binary or not. Default is autodetected. @@ -656,6 +658,8 @@ 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 +[`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()`]: From 2995349f5d27d39e492a273796a3145d40ec11b3 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 20 Sep 2022 20:46:57 +0200 Subject: [PATCH 110/207] [minor] Simplify the ws+unix: URL form Remove the two forward slashes after the scheme. The URL is parsed in the same way and it is easier to grok. --- doc/ws.md | 6 +++--- test/websocket.test.js | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 0b1ba7a37..2c49c766f 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -326,17 +326,17 @@ Create a new WebSocket instance. #### UNIX Domain Sockets `ws` supports making requests to UNIX domain sockets. To make one, use the -following URL scheme: +following URL form: ``` -ws+unix:///absolute/path/to/uds_socket:/pathname?search_params +ws+unix:/absolute/path/to/uds_socket:/pathname?search_params ``` Note that `:` is the separator between the socket path and the URL path. If the URL path is omitted ``` -ws+unix:///absolute/path/to/uds_socket +ws+unix:/absolute/path/to/uds_socket ``` it defaults to `/`. diff --git a/test/websocket.test.js b/test/websocket.test.js index f5fbf1650..cf4cf10f2 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1548,7 +1548,7 @@ describe('WebSocket', () => { server.once('upgrade', (req, socket) => { socket.end( - `HTTP/1.1 302 Found\r\nLocation: ws+unix://${socketPath}\r\n\r\n` + `HTTP/1.1 302 Found\r\nLocation: ws+unix:${socketPath}\r\n\r\n` ); }); @@ -1589,7 +1589,7 @@ describe('WebSocket', () => { ws.on('close', (code) => { assert.strictEqual(code, 1005); - assert.strictEqual(ws.url, `ws+unix://${socketPath}`); + assert.strictEqual(ws.url, `ws+unix:${socketPath}`); assert.strictEqual(ws._redirects, 1); redirectedServer.close(done); @@ -1616,7 +1616,7 @@ describe('WebSocket', () => { redirectingServer.on('upgrade', (req, socket) => { socket.end( 'HTTP/1.1 302 Found\r\n' + - `Location: ws+unix://${redirectedServerSocketPath}\r\n\r\n` + `Location: ws+unix:${redirectedServerSocketPath}\r\n\r\n` ); }); @@ -1645,10 +1645,10 @@ describe('WebSocket', () => { host: 'foo' }; - const ws = new WebSocket( - `ws+unix://${redirectingServerSocketPath}`, - { followRedirects: true, headers } - ); + const ws = new WebSocket(`ws+unix:${redirectingServerSocketPath}`, { + followRedirects: true, + headers + }); const firstRequest = ws._req; @@ -1666,7 +1666,7 @@ describe('WebSocket', () => { assert.strictEqual(code, 1005); assert.strictEqual( ws.url, - `ws+unix://${redirectedServerSocketPath}` + `ws+unix:${redirectedServerSocketPath}` ); assert.strictEqual(ws._redirects, 1); @@ -1723,7 +1723,7 @@ describe('WebSocket', () => { host: 'foo' }; - const ws = new WebSocket(`ws+unix://${socketPath}`, { + const ws = new WebSocket(`ws+unix:${socketPath}`, { followRedirects: true, headers }); From 6b71a49d1d02e8220eaa983e9202517c9680d2b2 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 21 Sep 2022 10:01:04 +0200 Subject: [PATCH 111/207] [minor] Rename the `invalidURLMessage` variable Rename the `invalidURLMessage` variable to `invalidUrlMessage`. --- lib/websocket.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 3132cc150..8c4512d48 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -678,19 +678,19 @@ function initAsClient(websocket, address, protocols, options) { const isSecure = parsedUrl.protocol === 'wss:'; const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; - let invalidURLMessage; + let invalidUrlMessage; if (parsedUrl.protocol !== 'ws:' && !isSecure && !isUnixSocket) { - invalidURLMessage = + invalidUrlMessage = 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"'; } else if (isUnixSocket && !parsedUrl.pathname) { - invalidURLMessage = "The URL's pathname is empty"; + invalidUrlMessage = "The URL's pathname is empty"; } else if (parsedUrl.hash) { - invalidURLMessage = 'The URL contains a fragment identifier'; + invalidUrlMessage = 'The URL contains a fragment identifier'; } - if (invalidURLMessage) { - const err = new SyntaxError(invalidURLMessage); + if (invalidUrlMessage) { + const err = new SyntaxError(invalidUrlMessage); if (websocket._redirects === 0) { throw err; From 0da60366f36a38e93bb1a31da54f260a146eb86c Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 21 Sep 2022 12:05:53 +0200 Subject: [PATCH 112/207] [test] Remove leftover from test Refs: https://github.com/websockets/ws/commit/2995349f --- test/websocket-server.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 12928ff49..2ea81d319 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -210,8 +210,8 @@ describe('WebSocketServer', () => { } }); - const ws = new WebSocket(`ws+unix://${sockPath}:/foo?bar=bar`); - ws.on('open', () => new WebSocket(`ws+unix://${sockPath}`)); + const ws = new WebSocket(`ws+unix:${sockPath}:/foo?bar=bar`); + ws.on('open', () => new WebSocket(`ws+unix:${sockPath}`)); }); }); }); From 4ed2c8725ec796283498e4a9c90171668eef9117 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 21 Sep 2022 16:38:30 +0200 Subject: [PATCH 113/207] [doc] Remove "note that" --- doc/ws.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 2c49c766f..d857edf57 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -232,8 +232,8 @@ 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]) @@ -332,8 +332,8 @@ following URL form: ws+unix:/absolute/path/to/uds_socket:/pathname?search_params ``` -Note that `:` is the separator between the socket path and the URL path. If the -URL path is omitted +The character `:` is the separator between the socket path and the URL path. If +the URL path is omitted ``` ws+unix:/absolute/path/to/uds_socket From 7ff26d975a7c5e0012a6799ed15a7ef8211b59bb Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 21 Sep 2022 16:39:27 +0200 Subject: [PATCH 114/207] [doc] Fix nits --- doc/ws.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index d857edf57..ca608fde2 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -103,7 +103,7 @@ 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 @@ -170,7 +170,7 @@ 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 +`callback` will be added as a listener for the `'listening'` event on the HTTP server when not operating in "noServer" mode. ### Event: 'close' From e628f2bdb6277fa36e8607cf1c3fbaf713f7bcef Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 22 Sep 2022 20:37:20 +0200 Subject: [PATCH 115/207] [feature] Support Windows named pipes (#2079) Document how to connect to a named pipe endpoint and the limitations. Refs: https://github.com/websockets/ws/pull/1808 Refs: https://github.com/websockets/ws/pull/2075 --- doc/ws.md | 28 +++++++++---- lib/websocket.js | 18 ++++---- test/websocket-server.test.js | 24 ++++------- test/websocket.test.js | 79 ++++++++++++++++++----------------- 4 files changed, 78 insertions(+), 71 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index ca608fde2..e045a0db1 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -18,7 +18,7 @@ - [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) @@ -323,17 +323,27 @@ context takeover. Create a new WebSocket instance. -#### UNIX Domain Sockets +#### IPC connections -`ws` supports making requests to UNIX domain sockets. To make one, use the -following URL form: +`ws` supports IPC connections. To connect to an IPC endpoint, use the following +URL form: -``` -ws+unix:/absolute/path/to/uds_socket:/pathname?search_params -``` +- On Unices + + ``` + ws+unix:/absolute/path/to/uds_socket:/pathname?search_params + ``` + +- On Windows + + ``` + ws+unix:\\.\pipe\pipe_name:/pathname?search_params + ``` -The character `:` is the separator between the 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 diff --git a/lib/websocket.js b/lib/websocket.js index 8c4512d48..4391c73ab 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -677,13 +677,13 @@ function initAsClient(websocket, address, protocols, options) { } const isSecure = parsedUrl.protocol === 'wss:'; - const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; + const isIpcUrl = parsedUrl.protocol === 'ws+unix:'; let invalidUrlMessage; - if (parsedUrl.protocol !== 'ws:' && !isSecure && !isUnixSocket) { + if (parsedUrl.protocol !== 'ws:' && !isSecure && !isIpcUrl) { invalidUrlMessage = 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"'; - } else if (isUnixSocket && !parsedUrl.pathname) { + } else if (isIpcUrl && !parsedUrl.pathname) { invalidUrlMessage = "The URL's pathname is empty"; } else if (parsedUrl.hash) { invalidUrlMessage = 'The URL contains a fragment identifier'; @@ -760,7 +760,7 @@ 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]; @@ -771,9 +771,9 @@ function initAsClient(websocket, address, protocols, options) { if (opts.followRedirects) { if (websocket._redirects === 0) { - websocket._originalUnixSocket = isUnixSocket; + websocket._originalIpc = isIpcUrl; websocket._originalSecure = isSecure; - websocket._originalHostOrSocketPath = isUnixSocket + websocket._originalHostOrSocketPath = isIpcUrl ? opts.socketPath : parsedUrl.host; @@ -791,11 +791,11 @@ function initAsClient(websocket, address, protocols, options) { } } } else if (websocket.listenerCount('redirect') === 0) { - const isSameHost = isUnixSocket - ? websocket._originalUnixSocket + const isSameHost = isIpcUrl + ? websocket._originalIpc ? opts.socketPath === websocket._originalHostOrSocketPath : false - : websocket._originalUnixSocket + : websocket._originalIpc ? false : parsedUrl.host === websocket._originalHostOrSocketPath; diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 2ea81d319..abed1650a 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -178,22 +178,16 @@ describe('WebSocketServer', () => { }); }); - it('uses a precreated http server listening on unix socket', function (done) { - // - // Skip this test on Windows. The URL parser: - // - // - Throws an error if the named pipe uses backward slashes. - // - Incorrectly parses the path if the named pipe uses forward slashes. - // - if (process.platform === 'win32') return this.skip(); + it('uses a precreated http server listening on IPC', (done) => { + const randomString = crypto.randomBytes(16).toString('hex'); + const ipcPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\ws-pipe-${randomString}` + : path.join(os.tmpdir(), `ws-${randomString}.sock`); const server = http.createServer(); - const sockPath = path.join( - os.tmpdir(), - `ws.${crypto.randomBytes(16).toString('hex')}.sock` - ); - server.listen(sockPath, () => { + server.listen(ipcPath, () => { const wss = new WebSocket.Server({ server }); wss.on('connection', (ws, req) => { @@ -210,8 +204,8 @@ describe('WebSocketServer', () => { } }); - 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}`)); }); }); }); diff --git a/test/websocket.test.js b/test/websocket.test.js index cf4cf10f2..fad80ddb9 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1536,19 +1536,18 @@ describe('WebSocket', () => { }); }); - it('drops the Authorization, Cookie and Host headers (2/4)', function (done) { - if (process.platform === 'win32') return this.skip(); - + it('drops the Authorization, Cookie and Host headers (2/4)', (done) => { // Test the `ws:` to `ws+unix:` case. - const socketPath = path.join( - os.tmpdir(), - `ws.${crypto.randomBytes(16).toString('hex')}.sock` - ); + const randomString = crypto.randomBytes(16).toString('hex'); + const ipcPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\ws-pipe-${randomString}` + : path.join(os.tmpdir(), `ws-${randomString}.sock`); server.once('upgrade', (req, socket) => { socket.end( - `HTTP/1.1 302 Found\r\nLocation: ws+unix:${socketPath}\r\n\r\n` + `HTTP/1.1 302 Found\r\nLocation: ws+unix:${ipcPath}\r\n\r\n` ); }); @@ -1563,7 +1562,7 @@ describe('WebSocket', () => { ws.close(); }); - redirectedServer.listen(socketPath, () => { + redirectedServer.listen(ipcPath, () => { const headers = { authorization: 'Basic Zm9vOmJhcg==', cookie: 'foo=bar', @@ -1589,7 +1588,7 @@ describe('WebSocket', () => { ws.on('close', (code) => { assert.strictEqual(code, 1005); - assert.strictEqual(ws.url, `ws+unix:${socketPath}`); + assert.strictEqual(ws.url, `ws+unix:${ipcPath}`); assert.strictEqual(ws._redirects, 1); redirectedServer.close(done); @@ -1597,26 +1596,34 @@ describe('WebSocket', () => { }); }); - it('drops the Authorization, Cookie and Host headers (3/4)', function (done) { - if (process.platform === 'win32') return this.skip(); - + it('drops the Authorization, Cookie and Host headers (3/4)', (done) => { // Test the `ws+unix:` to `ws+unix:` case. - const redirectingServerSocketPath = path.join( - os.tmpdir(), - `ws.${crypto.randomBytes(16).toString('hex')}.sock` - ); - const redirectedServerSocketPath = path.join( - os.tmpdir(), - `ws.${crypto.randomBytes(16).toString('hex')}.sock` - ); + const randomString1 = crypto.randomBytes(16).toString('hex'); + const randomString2 = crypto.randomBytes(16).toString('hex'); + let redirectingServerIpcPath; + let redirectedServerIpcPath; + + if (process.platform === 'win32') { + redirectingServerIpcPath = `\\\\.\\pipe\\ws-pipe-${randomString1}`; + redirectedServerIpcPath = `\\\\.\\pipe\\ws-pipe-${randomString2}`; + } else { + redirectingServerIpcPath = path.join( + os.tmpdir(), + `ws-${randomString1}.sock` + ); + redirectedServerIpcPath = path.join( + os.tmpdir(), + `ws-${randomString2}.sock` + ); + } const redirectingServer = http.createServer(); redirectingServer.on('upgrade', (req, socket) => { socket.end( 'HTTP/1.1 302 Found\r\n' + - `Location: ws+unix:${redirectedServerSocketPath}\r\n\r\n` + `Location: ws+unix:${redirectedServerIpcPath}\r\n\r\n` ); }); @@ -1631,8 +1638,8 @@ describe('WebSocket', () => { ws.close(); }); - redirectingServer.listen(redirectingServerSocketPath, listening); - redirectedServer.listen(redirectedServerSocketPath, listening); + redirectingServer.listen(redirectingServerIpcPath, listening); + redirectedServer.listen(redirectedServerIpcPath, listening); let callCount = 0; @@ -1645,7 +1652,7 @@ describe('WebSocket', () => { host: 'foo' }; - const ws = new WebSocket(`ws+unix:${redirectingServerSocketPath}`, { + const ws = new WebSocket(`ws+unix:${redirectingServerIpcPath}`, { followRedirects: true, headers }); @@ -1664,10 +1671,7 @@ describe('WebSocket', () => { ws.on('close', (code) => { assert.strictEqual(code, 1005); - assert.strictEqual( - ws.url, - `ws+unix:${redirectedServerSocketPath}` - ); + assert.strictEqual(ws.url, `ws+unix:${redirectedServerIpcPath}`); assert.strictEqual(ws._redirects, 1); redirectingServer.close(); @@ -1676,9 +1680,7 @@ describe('WebSocket', () => { } }); - it('drops the Authorization, Cookie and Host headers (4/4)', function (done) { - if (process.platform === 'win32') return this.skip(); - + it('drops the Authorization, Cookie and Host headers (4/4)', (done) => { // Test the `ws+unix:` to `ws:` case. const redirectingServer = http.createServer(); @@ -1696,12 +1698,13 @@ describe('WebSocket', () => { ws.close(); }); - const socketPath = path.join( - os.tmpdir(), - `ws.${crypto.randomBytes(16).toString('hex')}.sock` - ); + const randomString = crypto.randomBytes(16).toString('hex'); + const ipcPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\ws-pipe-${randomString}` + : path.join(os.tmpdir(), `ws-${randomString}.sock`); - redirectingServer.listen(socketPath, listening); + redirectingServer.listen(ipcPath, listening); redirectedServer.listen(0, listening); let callCount = 0; @@ -1723,7 +1726,7 @@ describe('WebSocket', () => { host: 'foo' }; - const ws = new WebSocket(`ws+unix:${socketPath}`, { + const ws = new WebSocket(`ws+unix:${ipcPath}`, { followRedirects: true, headers }); From 966f9d47cd0ff5aa9db0b2aa262f9819d3f4d414 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 22 Sep 2022 20:45:50 +0200 Subject: [PATCH 116/207] [dist] 8.9.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 27b9244a4..135a345c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.8.1", + "version": "8.9.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From c4d6eb3907e8bf6e9d1526772a4778834f9b025c Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 20 Oct 2022 21:17:02 +0200 Subject: [PATCH 117/207] [ci] Do not use the set-output command The `set-output` command is deprecated. Use the `GITHUB_OUTPUT` environment file. Refs: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ --- .github/workflows/ci.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3a787a65..f21a98e9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,10 @@ jobs: matrix.os == 'ubuntu-latest' && matrix.node == 16 && matrix.arch == 'x64' - run: npm test - - run: - echo ::set-output name=job_id::$(node -e - "console.log(crypto.randomBytes(16).toString('hex'))") + - run: | + id=$(node -e "console.log(crypto.randomBytes(16).toString('hex'))") + + echo "job_id=$id" >> $GITHUB_OUTPUT id: get_job_id shell: bash - uses: coverallsapp/github-action@1.1.3 From 211d5d3833893bcfde30e17e4aa9d61e0b408ee5 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 24 Oct 2022 20:36:48 +0200 Subject: [PATCH 118/207] [pkg] Add package.json export Fixes #2091 --- package.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 135a345c3..b145d06b1 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,11 @@ "license": "MIT", "main": "index.js", "exports": { - "import": "./wrapper.mjs", - "require": "./index.js" + ".": { + "import": "./wrapper.mjs", + "require": "./index.js" + }, + "./package.json": "./package.json" }, "browser": "browser.js", "engines": { From cdca711ad434fe4f691392807cba3f83a515eebe Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 24 Oct 2022 20:59:36 +0200 Subject: [PATCH 119/207] [dist] 8.10.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b145d06b1..c5d663f3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.9.0", + "version": "8.10.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 38f78794ca68770e962d76fcdb50dd264696a5e5 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 2 Nov 2022 16:35:35 +0100 Subject: [PATCH 120/207] [ci] Test on node 19 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f21a98e9d..aee196f92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: - 14 - 16 - 18 + - 19 os: - macOS-latest - ubuntu-latest From 9ab743aa706be653e3b3c94d07960fe4342f9da5 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 4 Nov 2022 17:13:56 +0100 Subject: [PATCH 121/207] [feature] Add support for objets with a `handleEvent()` method Make `WebSocket.prototype.addEventListener()` support an event listener specified as an object with a `handleEvent()` method. Fixes #2092 --- doc/ws.md | 4 ++-- lib/event-target.js | 28 ++++++++++++++++++++++------ test/websocket.test.js | 23 +++++++++++++---------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index e045a0db1..abf0f0735 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -426,7 +426,7 @@ 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 @@ -555,7 +555,7 @@ 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. This method only removes listeners added with diff --git a/lib/event-target.js b/lib/event-target.js index d5abd83a0..6b185270a 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -178,7 +178,7 @@ const EventTarget = { * Register an event listener. * * @param {String} type A string representing the event type to listen for - * @param {Function} listener The listener to add + * @param {(Function|Object)} listener The listener to add * @param {Object} [options] An options object specifies characteristics about * the event listener * @param {Boolean} [options.once=false] A `Boolean` indicating that the @@ -196,7 +196,7 @@ const EventTarget = { }); event[kTarget] = this; - listener.call(this, event); + callListener(listener, this, event); }; } else if (type === 'close') { wrapper = function onClose(code, message) { @@ -207,7 +207,7 @@ const EventTarget = { }); event[kTarget] = this; - listener.call(this, event); + callListener(listener, this, event); }; } else if (type === 'error') { wrapper = function onError(error) { @@ -217,14 +217,14 @@ const EventTarget = { }); event[kTarget] = this; - listener.call(this, event); + callListener(listener, this, event); }; } else if (type === 'open') { wrapper = function onOpen() { const event = new Event('open'); event[kTarget] = this; - listener.call(this, event); + callListener(listener, this, event); }; } else { return; @@ -244,7 +244,7 @@ const EventTarget = { * Remove an event listener. * * @param {String} type A string representing the event type to remove - * @param {Function} handler The listener to remove + * @param {(Function|Object)} handler The listener to remove * @public */ removeEventListener(type, handler) { @@ -264,3 +264,19 @@ module.exports = { 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/test/websocket.test.js b/test/websocket.test.js index fad80ddb9..bbbc5ae9f 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -3264,14 +3264,15 @@ describe('WebSocket', () => { assert.strictEqual(ws.listenerCount('open'), 1); - ws.addEventListener( - 'message', - () => { + const listener = { + handleEvent() { events.push('message'); + assert.strictEqual(this, listener); assert.strictEqual(ws.listenerCount('message'), 0); - }, - { once: true } - ); + } + }; + + ws.addEventListener('message', listener, { once: true }); assert.strictEqual(ws.listenerCount('message'), 1); @@ -3318,17 +3319,19 @@ describe('WebSocket', () => { 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); - assert.strictEqual(ws.listeners('message')[0][kListener], 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][kListener], NOOP); + assert.strictEqual(ws.listeners('message')[0][kListener], listener); - ws.removeEventListener('message', NOOP); + ws.removeEventListener('message', listener); ws.removeEventListener('open', NOOP); assert.strictEqual(ws.listenerCount('message'), 0); From 1cec17da060ef1a4656a6d530c2b686039b7e094 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 4 Nov 2022 21:34:26 +0100 Subject: [PATCH 122/207] [fix] Add the same event listener only once Prevent `WebSocket.prototype.addEventListener()` from adding the same event listener multiple times. --- lib/event-target.js | 24 +++++++++++++++++------- test/websocket.test.js | 41 ++++++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/lib/event-target.js b/lib/event-target.js index 6b185270a..fea4cbc52 100644 --- a/lib/event-target.js +++ b/lib/event-target.js @@ -178,7 +178,7 @@ const EventTarget = { * Register an event listener. * * @param {String} type A string representing the event type to listen for - * @param {(Function|Object)} listener The listener to add + * @param {(Function|Object)} handler The listener to add * @param {Object} [options] An options object specifies characteristics about * the event listener * @param {Boolean} [options.once=false] A `Boolean` indicating that the @@ -186,7 +186,17 @@ const EventTarget = { * the listener would be automatically removed when invoked. * @public */ - addEventListener(type, listener, options = {}) { + addEventListener(type, handler, options = {}) { + for (const listener of this.listeners(type)) { + if ( + !options[kForOnEventAttribute] && + listener[kListener] === handler && + !listener[kForOnEventAttribute] + ) { + return; + } + } + let wrapper; if (type === 'message') { @@ -196,7 +206,7 @@ const EventTarget = { }); event[kTarget] = this; - callListener(listener, this, event); + callListener(handler, this, event); }; } else if (type === 'close') { wrapper = function onClose(code, message) { @@ -207,7 +217,7 @@ const EventTarget = { }); event[kTarget] = this; - callListener(listener, this, event); + callListener(handler, this, event); }; } else if (type === 'error') { wrapper = function onError(error) { @@ -217,21 +227,21 @@ const EventTarget = { }); event[kTarget] = this; - callListener(listener, this, event); + callListener(handler, this, event); }; } else if (type === 'open') { wrapper = function onOpen() { const event = new Event('open'); event[kTarget] = this; - callListener(listener, this, event); + callListener(handler, this, event); }; } else { return; } wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; - wrapper[kListener] = listener; + wrapper[kListener] = handler; if (options.once) { this.once(type, wrapper); diff --git a/test/websocket.test.js b/test/websocket.test.js index bbbc5ae9f..6b2f3ef5c 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -3257,10 +3257,13 @@ describe('WebSocket', () => { ws.addEventListener('foo', () => {}); assert.strictEqual(ws.listenerCount('foo'), 0); - ws.addEventListener('open', () => { + 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); @@ -3273,9 +3276,28 @@ describe('WebSocket', () => { }; 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); @@ -3353,21 +3375,6 @@ describe('WebSocket', () => { assert.strictEqual(ws.listenerCount('message'), 0); assert.strictEqual(ws.listenerCount('open'), 0); - // Multiple listeners. - ws.addEventListener('message', NOOP); - ws.addEventListener('message', NOOP); - - assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); - assert.strictEqual(ws.listeners('message')[1][kListener], NOOP); - - ws.removeEventListener('message', NOOP); - - assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); - - ws.removeEventListener('message', NOOP); - - assert.strictEqual(ws.listenerCount('message'), 0); - // Listeners not added with `websocket.addEventListener()`. ws.on('message', NOOP); From afd8c6269bf5056a052281c543e9f19c7d88673d Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 6 Nov 2022 19:30:29 +0100 Subject: [PATCH 123/207] [dist] 8.11.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c5d663f3c..24ffdc55a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.10.0", + "version": "8.11.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From ea761933702bde061c2f5ac8aed5f62f9d5439ea Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 25 Nov 2022 18:57:14 +0100 Subject: [PATCH 124/207] [doc] Improve doc for the `callback` argument of `WebSocketServer` Clarify that the `callback` argument is added as a listener for the `'listening'` event only when the `port` option is set. Fixes #2100 --- doc/ws.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ws.md b/doc/ws.md index abf0f0735..dd51eca9c 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -171,7 +171,7 @@ 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. +server when the `port` option is set. ### Event: 'close' From 8a8fc88430b2d32163d343ef507e201ffc48570a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 9 Dec 2022 12:34:03 +0100 Subject: [PATCH 125/207] [minor] Validate the payload length of the close frame sooner Avoid unnecessary work if the payload length is 1. --- lib/receiver.js | 13 ++++--------- test/receiver.test.js | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/receiver.js b/lib/receiver.js index 2d29d62bb..d9a54543c 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -259,7 +259,10 @@ class Receiver extends Writable { ); } - if (this._payloadLength > 0x7d) { + if ( + this._payloadLength > 0x7d || + (this._opcode === 0x08 && this._payloadLength === 1) + ) { this._loop = false; return error( RangeError, @@ -546,14 +549,6 @@ class Receiver extends Writable { if (data.length === 0) { this.emit('conclude', 1005, EMPTY_BUFFER); this.end(); - } else if (data.length === 1) { - return error( - RangeError, - 'invalid payload length 1', - true, - 1002, - 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' - ); } else { const code = data.readUInt16BE(0); diff --git a/test/receiver.test.js b/test/receiver.test.js index 7ee35f740..4ae279469 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -856,7 +856,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) => { From a6fa37a1409c12f6a8672e6759a91d0fadd1b409 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 9 Dec 2022 15:47:38 +0100 Subject: [PATCH 126/207] [license] Update copyright notice --- LICENSE | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/LICENSE b/LICENSE index 65ff176bf..1da5b96a1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,19 +1,20 @@ 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. From 9e0fd77799a0fcf16b8eb2f767358ef8bb834ec8 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 12 Dec 2022 11:18:17 +0100 Subject: [PATCH 127/207] [minor] Use `Buffer#subarray()` instead of `Buffer#slice()` `Buffer.prototype.slice()` is deprecated. --- lib/buffer-util.js | 2 +- lib/permessage-deflate.js | 2 +- lib/receiver.js | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/buffer-util.js b/lib/buffer-util.js index df7595546..eb27486ed 100644 --- a/lib/buffer-util.js +++ b/lib/buffer-util.js @@ -23,7 +23,7 @@ function concat(list, totalLength) { offset += buf.length; } - if (offset < totalLength) return target.slice(0, offset); + if (offset < totalLength) return target.subarray(0, offset); return target; } diff --git a/lib/permessage-deflate.js b/lib/permessage-deflate.js index 94603c98d..b03355805 100644 --- a/lib/permessage-deflate.js +++ b/lib/permessage-deflate.js @@ -437,7 +437,7 @@ class PerMessageDeflate { this._deflate[kTotalLength] ); - if (fin) data = data.slice(0, data.length - 4); + if (fin) data = data.subarray(0, data.length - 4); // // Ensure that the callback will not be called again in diff --git a/lib/receiver.js b/lib/receiver.js index d9a54543c..5adc208d9 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -97,8 +97,8 @@ 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] = buf.subarray(n); + return buf.subarray(0, n); } const dst = Buffer.allocUnsafe(n); @@ -111,7 +111,7 @@ 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] = buf.subarray(n); } n -= buf.length; @@ -562,7 +562,7 @@ class Receiver extends Writable { ); } - const buf = data.slice(2); + const buf = data.subarray(2); if (!this._skipUTF8Validation && !isValidUTF8(buf)) { return error( From e6a32f85a77c56f9bfe03e91b38b504192c8df5a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 13 Dec 2022 10:05:18 +0100 Subject: [PATCH 128/207] [perf] Use `FastBuffer` instead of `Buffer#subarray()` `Buffer.prototype.subarray()` performance is subpar on Node.js < 16.15.0. --- lib/buffer-util.js | 6 +++++- lib/permessage-deflate.js | 5 ++++- lib/receiver.js | 22 ++++++++++++++++++---- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/lib/buffer-util.js b/lib/buffer-util.js index eb27486ed..e70feaaca 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.subarray(0, offset); + if (offset < totalLength) { + return new FastBuffer(target.buffer, target.byteOffset, offset); + } return target; } diff --git a/lib/permessage-deflate.js b/lib/permessage-deflate.js index b03355805..77d918b55 100644 --- a/lib/permessage-deflate.js +++ b/lib/permessage-deflate.js @@ -6,6 +6,7 @@ const bufferUtil = require('./buffer-util'); const Limiter = require('./limiter'); 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'); @@ -437,7 +438,9 @@ class PerMessageDeflate { this._deflate[kTotalLength] ); - if (fin) data = data.subarray(0, data.length - 4); + if (fin) { + data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4); + } // // Ensure that the callback will not be called again in diff --git a/lib/receiver.js b/lib/receiver.js index 5adc208d9..96f572cb1 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -12,6 +12,7 @@ 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; @@ -97,8 +98,13 @@ class Receiver extends Writable { if (n < this._buffers[0].length) { const buf = this._buffers[0]; - this._buffers[0] = buf.subarray(n); - return buf.subarray(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); @@ -111,7 +117,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.subarray(n); + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); } n -= buf.length; @@ -562,7 +572,11 @@ class Receiver extends Writable { ); } - const buf = data.subarray(2); + const buf = new FastBuffer( + data.buffer, + data.byteOffset + 2, + data.length - 2 + ); if (!this._skipUTF8Validation && !isValidUTF8(buf)) { return error( From 1b057f98709b2b3be31827ec8a89549fa012d52a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 13 Dec 2022 11:32:20 +0100 Subject: [PATCH 129/207] [minor] Fix nit --- lib/buffer-util.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/buffer-util.js b/lib/buffer-util.js index e70feaaca..4a6e8e7cd 100644 --- a/lib/buffer-util.js +++ b/lib/buffer-util.js @@ -69,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); } /** From 83c72cfbe03ba9a5cc999776524f72f472c8ac29 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 13 Dec 2022 18:47:29 +0100 Subject: [PATCH 130/207] [perf] Make `toBuffer()` use `FastBuffer` Skip unnecessary parameter validation performed by `Buffer.from()` when possible. --- lib/buffer-util.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/buffer-util.js b/lib/buffer-util.js index 4a6e8e7cd..f7536e28e 100644 --- a/lib/buffer-util.js +++ b/lib/buffer-util.js @@ -92,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; From fb1dfd217861757d60c1d02c6e66b4da3630cc93 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 16 Dec 2022 10:30:38 +0100 Subject: [PATCH 131/207] [doc] Fix badge URL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ae71f6d0..9fcd51af3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ws: a Node.js WebSocket library [![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws) -[![CI](https://img.shields.io/github/workflow/status/websockets/ws/CI/master?label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) +[![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 From 2dc2812942c8a70d55012887e99723795590d448 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 20 Dec 2022 20:07:00 +0100 Subject: [PATCH 132/207] [minor] Make `sendAfterClose()` call the callback in the next tick --- lib/websocket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/websocket.js b/lib/websocket.js index 4391c73ab..de1b03b6a 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -1096,7 +1096,7 @@ function sendAfterClose(websocket, data, cb) { `WebSocket is not open: readyState ${websocket.readyState} ` + `(${readyStates[websocket.readyState]})` ); - cb(err); + process.nextTick(cb, err); } } From d412358ccb5320bc00c8993ecd5d9b992df0753e Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 20 Dec 2022 21:41:28 +0100 Subject: [PATCH 133/207] [minor] Fix nits --- lib/websocket.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index de1b03b6a..35a788ac4 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -282,7 +282,8 @@ class WebSocket extends EventEmitter { if (this.readyState === WebSocket.CLOSED) return; if (this.readyState === WebSocket.CONNECTING) { const msg = 'WebSocket was closed before the connection was established'; - return abortHandshake(this, this._req, msg); + abortHandshake(this, this._req, msg); + return; } if (this.readyState === WebSocket.CLOSING) { @@ -477,7 +478,8 @@ class WebSocket extends EventEmitter { if (this.readyState === WebSocket.CLOSED) return; if (this.readyState === WebSocket.CONNECTING) { const msg = 'WebSocket was closed before the connection was established'; - return abortHandshake(this, this._req, msg); + abortHandshake(this, this._req, msg); + return; } if (this._socket) { From ff63bba37fc0c052a609c7db88cc6d47bfc320e7 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 6 Jan 2023 21:31:34 +0100 Subject: [PATCH 134/207] [pkg] Update utf-8-validate to version 6.0.0 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 24ffdc55a..ee25d33c8 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -59,6 +59,6 @@ "mocha": "^8.4.0", "nyc": "^15.0.0", "prettier": "^2.0.5", - "utf-8-validate": "^5.0.2" + "utf-8-validate": "^6.0.0" } } From 42d79f60efb739b349b84b020c9d0ee062150633 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 7 Jan 2023 16:47:14 +0100 Subject: [PATCH 135/207] [minor] Use `buffer.isUtf8()` if possible Closes #2110 --- README.md | 11 ++++++++--- lib/validation.js | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9fcd51af3..4539df294 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,9 @@ npm install ws ### Opt-in for performance There are 2 optional modules that can be installed along side with the ws -module. These modules are binary addons which improve certain operations. -Prebuilt binaries are available for the most popular platforms so you don't -necessarily need to have a C++ compiler installed on your machine. +module. These modules are binary addons that improve the performance of certain +operations. Prebuilt binaries are available for the most popular platforms so +you don't necessarily need to have a C++ compiler installed on your machine. - `npm install --save-optional bufferutil`: Allows to efficiently perform operations such as masking and unmasking the data payload of the WebSocket @@ -75,6 +75,10 @@ variables. These might be useful to enhance security in systems where a user can put a package in the package search path of an application of another user, due to how the Node.js resolver algorithm works. +The `utf-8-validate` module is not needed and is not required, even if it is +already installed, regardless of the value of the `WS_NO_UTF_8_VALIDATE` +environment variable, if [`buffer.isUtf8()`][] is available. + ## API docs See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and @@ -482,6 +486,7 @@ We're using the GitHub [releases][changelog] for changelog entries. [MIT](LICENSE) +[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input [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 diff --git a/lib/validation.js b/lib/validation.js index 44fc20290..c352e6ea7 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -1,5 +1,7 @@ 'use strict'; +const { isUtf8 } = require('buffer'); + // // Allowed token characters: // @@ -111,13 +113,16 @@ module.exports = { tokenChars }; -/* istanbul ignore else */ -if (!process.env.WS_NO_UTF_8_VALIDATE) { +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 < 150 ? _isValidUTF8(buf) : isValidUTF8(buf); + return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf); }; } catch (e) { // Continue regardless of the error. From a3214d31b63acee8e31065be9f5ce3dd89203055 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 7 Jan 2023 20:27:46 +0100 Subject: [PATCH 136/207] [dist] 8.12.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ee25d33c8..a810d95fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.11.0", + "version": "8.12.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 2862c2fef1064940c1deb9c4aa961cb76c279feb Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 23 Jan 2023 21:32:38 +0100 Subject: [PATCH 137/207] [doc] Add error handlers to examples and code snippets Closes #2112 --- README.md | 36 +++++++++++++++++++++++++ examples/express-session-parse/index.js | 10 +++++++ examples/server-stats/index.js | 2 ++ examples/ssl.js | 4 +++ 4 files changed, 52 insertions(+) diff --git a/README.md b/README.md index 4539df294..a550ca1c7 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ 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'); }); @@ -171,6 +173,8 @@ 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); @@ -190,6 +194,8 @@ import { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { + ws.on('error', console.error); + ws.on('message', function message(data) { console.log('received: %s', data); }); @@ -212,6 +218,8 @@ const server = createServer({ const wss = new WebSocketServer({ server }); wss.on('connection', function connection(ws) { + ws.on('error', console.error); + ws.on('message', function message(data) { console.log('received: %s', data); }); @@ -234,10 +242,14 @@ 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); + // ... }); @@ -266,16 +278,24 @@ server.listen(8080); import { createServer } from 'http'; import { WebSocketServer } from 'ws'; +function onSocketError(err) { + console.error(err); +} + const server = createServer(); const wss = new WebSocketServer({ noServer: true }); wss.on('connection', function connection(ws, request, 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, function next(err, client) { if (err || !client) { @@ -284,6 +304,8 @@ server.on('upgrade', function upgrade(request, socket, head) { return; } + socket.removeListener('error', onSocketError); + wss.handleUpgrade(request, socket, head, function done(ws) { wss.emit('connection', ws, request, client); }); @@ -306,6 +328,8 @@ import WebSocket, { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { + ws.on('error', console.error); + ws.on('message', function message(data, isBinary) { wss.clients.forEach(function each(client) { if (client.readyState === WebSocket.OPEN) { @@ -325,6 +349,8 @@ import WebSocket, { WebSocketServer } from 'ws'; const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { + 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) { @@ -342,6 +368,8 @@ import WebSocket from 'ws'; const ws = new WebSocket('wss://websocket-echo.com/'); +ws.on('error', console.error); + ws.on('open', function open() { console.log('connected'); ws.send(Date.now()); @@ -369,6 +397,8 @@ const ws = new WebSocket('wss://websocket-echo.com/'); const duplex = createWebSocketStream(ws, { encoding: 'utf8' }); +duplex.on('error', console.error); + duplex.pipe(process.stdout); process.stdin.pipe(duplex); ``` @@ -393,6 +423,8 @@ const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws, req) { const ip = req.socket.remoteAddress; + + ws.on('error', console.error); }); ``` @@ -402,6 +434,8 @@ the `X-Forwarded-For` header. ```js wss.on('connection', function connection(ws, req) { const ip = req.headers['x-forwarded-for'].split(',')[0].trim(); + + ws.on('error', console.error); }); ``` @@ -425,6 +459,7 @@ const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', function connection(ws) { ws.isAlive = true; + ws.on('error', console.error); ws.on('pong', heartbeat); }); @@ -466,6 +501,7 @@ function heartbeat() { 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() { diff --git a/examples/express-session-parse/index.js b/examples/express-session-parse/index.js index b62a2e4a5..e0f214406 100644 --- a/examples/express-session-parse/index.js +++ b/examples/express-session-parse/index.js @@ -7,6 +7,10 @@ const uuid = require('uuid'); const { WebSocketServer } = require('../..'); +function onSocketError(err) { + console.error(err); +} + const app = express(); const map = new Map(); @@ -59,6 +63,8 @@ const server = http.createServer(app); 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/server-stats/index.js b/examples/server-stats/index.js index e8754b5b2..afab8363f 100644 --- a/examples/server-stats/index.js +++ b/examples/server-stats/index.js @@ -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 a5e750b79..83fb5f280 100644 --- a/examples/ssl.js +++ b/examples/ssl.js @@ -13,6 +13,8 @@ const server = https.createServer({ 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.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!'); }); From 0d114ef48d8baca790733dd2bce23938dd08cb10 Mon Sep 17 00:00:00 2001 From: Kalin Kostov Date: Mon, 13 Feb 2023 22:12:13 +0200 Subject: [PATCH 138/207] [pkg] Add browser condition (#2118) Fixes #2117 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a810d95fc..5f9a3de32 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "main": "index.js", "exports": { ".": { + "browser": "./browser.js", "import": "./wrapper.mjs", "require": "./index.js" }, From a04578e36611998d089fbb7c6057d1363a5d5754 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 13 Feb 2023 21:14:47 +0100 Subject: [PATCH 139/207] [dist] 8.12.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5f9a3de32..df8c648a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.12.0", + "version": "8.12.1", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 41dc56a4ba504243a6efd0eb614510320e32d4cf Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 14 Feb 2023 21:01:42 +0100 Subject: [PATCH 140/207] [doc] Remove misleading information --- doc/ws.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index dd51eca9c..ae7993e68 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -384,13 +384,13 @@ 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' @@ -495,8 +495,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 From b4b9d5a76e8c105fdeec64232fb6f12b6f88416d Mon Sep 17 00:00:00 2001 From: Matthijs van Duin Date: Thu, 9 Mar 2023 21:24:34 +0100 Subject: [PATCH 141/207] [test] Fix failing test when using the domain module (#2126) Fix a failure in `test/create-websocket-stream.test.js` if the domain module is loaded (e.g. due to `NODE_OPTIONS` in environment). The cause of the failure was that installing an `'uncaughtException'` event handler on `process` causes the domain module to prepend its own handler for the same event, which confused the test. Fixes #2124 --- test/create-websocket-stream.test.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/create-websocket-stream.test.js b/test/create-websocket-stream.test.js index 4d51958cd..572f5c4f2 100644 --- a/test/create-websocket-stream.test.js +++ b/test/create-websocket-stream.test.js @@ -295,11 +295,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( From cd89e077f68ba9a999d408cb4fdb3e91289096a7 Mon Sep 17 00:00:00 2001 From: Matthijs van Duin Date: Fri, 10 Mar 2023 15:16:35 +0100 Subject: [PATCH 142/207] [feature] Add option to support late addition of headers (#2123) This supports the use-case where headers need to be added that depend on the socket connection (e.g. for TLS channel binding). --- doc/ws.md | 16 +++++++++++++++- lib/websocket.js | 6 +++++- test/websocket.test.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index ae7993e68..0fc44d6e6 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -293,6 +293,8 @@ 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} + - `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 @@ -316,12 +318,24 @@ This class represents a WebSocket. It extends the `EventEmitter`. 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 + +- `request` {http.ClientRequest} +- `websocket` {WebSocket} + +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. #### IPC connections diff --git a/lib/websocket.js b/lib/websocket.js index 35a788ac4..b2b2b0926 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -989,7 +989,11 @@ function initAsClient(websocket, address, protocols, options) { }); }); - req.end(); + if (opts.finishRequest) { + opts.finishRequest(req, websocket); + } else { + req.end(); + } } /** diff --git a/test/websocket.test.js b/test/websocket.test.js index 6b2f3ef5c..f80acd3d5 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -3857,6 +3857,35 @@ describe('WebSocket', () => { agent }); }); + + it('honors the `finishRequest` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + finishRequest(request, websocket) { + process.nextTick(() => { + assert.strictEqual(request, ws._req); + assert.strictEqual(websocket, ws); + }); + request.on('socket', (socket) => { + socket.on('connect', () => { + request.setHeader('Cookie', 'foo=bar'); + request.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', () => { From 23acf8cfaff73fadf89c69be669b3baa29b60233 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 10 Mar 2023 16:00:24 +0100 Subject: [PATCH 143/207] [test] Fix nits --- test/websocket.test.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index f80acd3d5..cb5b434c0 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -3860,16 +3860,18 @@ describe('WebSocket', () => { it('honors the `finishRequest` option', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - finishRequest(request, websocket) { - process.nextTick(() => { - assert.strictEqual(request, ws._req); - assert.strictEqual(websocket, ws); - }); - request.on('socket', (socket) => { + 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', () => { - request.setHeader('Cookie', 'foo=bar'); - request.end(); + req.setHeader('Cookie', 'foo=bar'); + req.end(); }); }); } From 45e17acea791d865df6b255a55182e9c42e5877a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 10 Mar 2023 18:47:00 +0100 Subject: [PATCH 144/207] [pkg] 8.13.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index df8c648a2..4b5d92bdc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.12.1", + "version": "8.13.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 5bdc8803f2a2887b7dc81d0ad82aedb0a7ef0ea1 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 19 Apr 2023 21:24:12 +0200 Subject: [PATCH 145/207] [ci] Do not test on node 19 --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aee196f92..f21a98e9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,6 @@ jobs: - 14 - 16 - 18 - - 19 os: - macOS-latest - ubuntu-latest From d1bb536cbc35a9a1af15486d973ae05f1ff2f4b5 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 19 Apr 2023 21:25:01 +0200 Subject: [PATCH 146/207] [ci] Test on node 20 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f21a98e9d..69e74f00d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: - 14 - 16 - 18 + - 20 os: - macOS-latest - ubuntu-latest From 06728e444d8f54aa5602b51360f4f98794cb1754 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 25 Apr 2023 20:29:28 +0200 Subject: [PATCH 147/207] [ci] Update coverallsapp/github-action action to v2 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69e74f00d..23ae22b21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: echo "job_id=$id" >> $GITHUB_OUTPUT id: get_job_id shell: bash - - uses: coverallsapp/github-action@1.1.3 + - uses: coverallsapp/github-action@v2 with: flag-name: ${{ steps.get_job_id.outputs.job_id }} (Node.js ${{ matrix.node }} @@ -62,7 +62,7 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: coverallsapp/github-action@1.1.3 + - uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true From 0368beb23755462ab1a64dab7d8b9e28502f17f9 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 11 Jul 2023 14:05:48 +0200 Subject: [PATCH 148/207] [pkg] Update prettier to version 3.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4b5d92bdc..e7808e2ea 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "eslint-plugin-prettier": "^4.0.0", "mocha": "^8.4.0", "nyc": "^15.0.0", - "prettier": "^2.0.5", + "prettier": "^3.0.0", "utf-8-validate": "^6.0.0" } } From 12a0a9c65d095cf565086706dd676cd7c6976d01 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 11 Jul 2023 14:06:34 +0200 Subject: [PATCH 149/207] [pkg] Update eslint-plugin-prettier to version 5.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e7808e2ea..a42932fbf 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "bufferutil": "^4.0.1", "eslint": "^8.0.0", "eslint-config-prettier": "^8.1.0", - "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-prettier": "^5.0.0", "mocha": "^8.4.0", "nyc": "^15.0.0", "prettier": "^3.0.0", From 0b235e0f9b650b1bdcbdb974cbeaaaa6a0797855 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 11 Jul 2023 14:09:24 +0200 Subject: [PATCH 150/207] [ci] Run the lint step on node 20 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23ae22b21..c8209e348 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: - run: npm install - run: npm run lint if: - matrix.os == 'ubuntu-latest' && matrix.node == 16 && matrix.arch == + matrix.os == 'ubuntu-latest' && matrix.node == 20 && matrix.arch == 'x64' - run: npm test - run: | From 8f5cc9df0e9e930a021142f0dbd4d1a4878bf350 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 19 Aug 2023 10:30:15 +0200 Subject: [PATCH 151/207] [pkg] Update eslint-config-prettier to version 9.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a42932fbf..2f076d2f3 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "benchmark": "^2.1.4", "bufferutil": "^4.0.1", "eslint": "^8.0.0", - "eslint-config-prettier": "^8.1.0", + "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "mocha": "^8.4.0", "nyc": "^15.0.0", From 5299b0ee6cfbdc50991a2f78f3600c36df1d3f4d Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 20 Aug 2023 10:54:08 +0200 Subject: [PATCH 152/207] [test] Remove redundant tests --- test/websocket-server.test.js | 6 ++++-- test/websocket.test.js | 32 -------------------------------- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index abed1650a..176c29dbd 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -1213,7 +1213,9 @@ describe('WebSocketServer', () => { 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` + ); ws.on('open', ws.close); }); @@ -1225,7 +1227,7 @@ describe('WebSocketServer', () => { 'Connection: Upgrade' ]); assert.ok(request instanceof http.IncomingMessage); - assert.strictEqual(request.url, '/'); + assert.strictEqual(request.url, '/?foo=bar'); wss.on('connection', () => wss.close(done)); }); diff --git a/test/websocket.test.js b/test/websocket.test.js index cb5b434c0..fd68ba726 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1901,38 +1901,6 @@ describe('WebSocket', () => { }); }); - describe('Connection with query string', () => { - it('connects when pathname is not null', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const port = wss.address().port; - const ws = new WebSocket(`ws://localhost:${port}/?token=qwerty`); - - ws.on('open', () => { - wss.close(done); - }); - }); - - wss.on('connection', (ws) => { - ws.close(); - }); - }); - - it('connects when pathname is null', (done) => { - const wss = new WebSocket.Server({ port: 0 }, () => { - const port = wss.address().port; - const ws = new WebSocket(`ws://localhost:${port}?token=qwerty`); - - ws.on('open', () => { - wss.close(done); - }); - }); - - wss.on('connection', (ws) => { - ws.close(); - }); - }); - }); - describe('#pause', () => { it('does nothing if `readyState` is `CONNECTING` or `CLOSED`', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { From 8eb2c4754a9418a2dac56a5330322cc1d9721508 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 20 Aug 2023 11:12:01 +0200 Subject: [PATCH 153/207] [test] Fix nits --- test/websocket.test.js | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index fd68ba726..7b3978428 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -61,7 +61,7 @@ describe('WebSocket', () => { }); 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'); @@ -74,7 +74,7 @@ describe('WebSocket', () => { describe('options', () => { it('accepts the `options` object as 3rd argument', () => { - const agent = new CustomAgent(); + const agent = new http.Agent(); let count = 0; let ws; @@ -122,10 +122,8 @@ describe('WebSocket', () => { }); 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\)$/ ); }); @@ -3709,7 +3707,7 @@ describe('WebSocket', () => { describe('Request headers', () => { it('adds the authorization header if the url has userinfo', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); const userinfo = 'test:testpass'; agent.addRequest = (req) => { @@ -3724,7 +3722,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) => { @@ -3739,7 +3737,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'; @@ -3755,7 +3753,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'); @@ -3784,7 +3782,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); @@ -3795,7 +3793,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'); @@ -3809,7 +3807,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( @@ -3860,7 +3858,7 @@ describe('WebSocket', () => { describe('permessage-deflate', () => { it('is enabled by default', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); agent.addRequest = (req) => { assert.strictEqual( @@ -3874,7 +3872,7 @@ describe('WebSocket', () => { }); it('can be disabled', (done) => { - const agent = new CustomAgent(); + const agent = new http.Agent(); agent.addRequest = (req) => { assert.strictEqual( @@ -3891,7 +3889,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;' + From 67007fc8003a0a9822a559a6b0234227af382aee Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 21 Aug 2023 16:50:15 +0200 Subject: [PATCH 154/207] [test] Reduce message size from 20 MiB to 4 MiB --- test/websocket.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index 7b3978428..487b02acd 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -2424,7 +2424,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; From 79dab96227f1df55c93fc99569fc9d0b33240483 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 28 Aug 2023 15:05:11 +0200 Subject: [PATCH 155/207] [fix] Emit at most one event per microtask (#2160) To improve compatibility with the WHATWG standard, emit at most one of `'message'`, `'ping'`, and `'pong'` events per tick. Fixes #2159 --- lib/receiver.js | 29 +++++++++++-- test/receiver.test.js | 92 ++++++++++++++++++++++++++++++++++++++++++ test/websocket.test.js | 23 ++++++----- 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/lib/receiver.js b/lib/receiver.js index 96f572cb1..b5e9a8bca 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -13,12 +13,15 @@ const { concat, toArrayBuffer, unmask } = require('./buffer-util'); const { isValidStatusCode, isValidUTF8 } = require('./validation'); const FastBuffer = Buffer[Symbol.species]; +const promise = Promise.resolve(); + 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 WAIT_MICROTASK = 6; /** * HyBi Receiver implementation. @@ -157,9 +160,23 @@ class Receiver extends Writable { case GET_DATA: err = this.getData(cb); break; + case INFLATING: + this._loop = false; + return; default: - // `INFLATING` + // + // `WAIT_MICROTASK`. + // this._loop = false; + + // + // `queueMicrotask()` is not available in Node.js < 11 and is no + // better anyway. + // + promise.then(() => { + this._state = GET_INFO; + this.startLoop(cb); + }); return; } } while (this._loop); @@ -542,7 +559,7 @@ class Receiver extends Writable { } } - this._state = GET_INFO; + this._state = WAIT_MICROTASK; } /** @@ -559,6 +576,8 @@ class Receiver extends Writable { if (data.length === 0) { this.emit('conclude', 1005, EMPTY_BUFFER); this.end(); + + this._state = GET_INFO; } else { const code = data.readUInt16BE(0); @@ -590,14 +609,16 @@ class Receiver extends Writable { this.emit('conclude', code, buf); this.end(); + + this._state = GET_INFO; } } else if (this._opcode === 0x09) { this.emit('ping', data); + this._state = WAIT_MICROTASK; } else { this.emit('pong', data); + this._state = WAIT_MICROTASK; } - - this._state = GET_INFO; } } diff --git a/test/receiver.test.js b/test/receiver.test.js index 4ae279469..a4e1bb5ad 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -1083,4 +1083,96 @@ describe('Receiver', () => { receiver.write(Buffer.from([0x88, 0x03, 0x03, 0xe8, 0xf8])); }); + + it("waits a microtask after each 'message' event", (done) => { + const messages = []; + const receiver = new Receiver(); + + receiver.on('message', (data, isBinary) => { + assert.ok(!isBinary); + + const message = data.toString(); + messages.push(message); + + // `queueMicrotask()` is not available in Node.js < 11. + Promise.resolve().then(() => { + messages.push(`microtask ${message}`); + + if (messages.length === 6) { + assert.deepStrictEqual(messages, [ + '1', + 'microtask 1', + '2', + 'microtask 2', + '3', + 'microtask 3' + ]); + + done(); + } + }); + }); + + receiver.write(Buffer.from('810131810132810133', 'hex')); + }); + + it("waits a microtask after each 'ping' event", (done) => { + const actual = []; + const receiver = new Receiver(); + + receiver.on('ping', (data) => { + const message = data.toString(); + actual.push(message); + + // `queueMicrotask()` is not available in Node.js < 11. + Promise.resolve().then(() => { + actual.push(`microtask ${message}`); + + if (actual.length === 6) { + assert.deepStrictEqual(actual, [ + '1', + 'microtask 1', + '2', + 'microtask 2', + '3', + 'microtask 3' + ]); + + done(); + } + }); + }); + + receiver.write(Buffer.from('890131890132890133', 'hex')); + }); + + it("waits a microtask after each 'pong' event", (done) => { + const actual = []; + const receiver = new Receiver(); + + receiver.on('pong', (data) => { + const message = data.toString(); + actual.push(message); + + // `queueMicrotask()` is not available in Node.js < 11. + Promise.resolve().then(() => { + actual.push(`microtask ${message}`); + + if (actual.length === 6) { + assert.deepStrictEqual(actual, [ + '1', + 'microtask 1', + '2', + 'microtask 2', + '3', + 'microtask 3' + ]); + + done(); + } + }); + }); + + receiver.write(Buffer.from('8A01318A01328A0133', 'hex')); + }); }); diff --git a/test/websocket.test.js b/test/websocket.test.js index 487b02acd..4d45fad49 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -4075,18 +4075,18 @@ describe('WebSocket', () => { const messages = []; const ws = new WebSocket(`ws://localhost:${wss.address().port}`); - ws.on('open', () => { - ws._socket.on('end', () => { - assert.strictEqual(ws._receiver._state, 5); - }); - }); - ws.on('message', (message, isBinary) => { assert.ok(!isBinary); if (messages.push(message.toString()) > 1) return; - ws.close(1000); + // `queueMicrotask()` is not available in Node.js < 11. + Promise.resolve().then(() => { + process.nextTick(() => { + assert.strictEqual(ws._receiver._state, 5); + ws.close(1000); + }); + }); }); ws.on('close', (code, reason) => { @@ -4331,9 +4331,12 @@ describe('WebSocket', () => { if (messages.push(message.toString()) > 1) return; - process.nextTick(() => { - assert.strictEqual(ws._receiver._state, 5); - ws.terminate(); + // `queueMicrotask()` is not available in Node.js < 11. + Promise.resolve().then(() => { + process.nextTick(() => { + assert.strictEqual(ws._receiver._state, 5); + ws.terminate(); + }); }); }); From 347aab6cd1609797295f482ef4368e7ffbf4c53a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 28 Aug 2023 15:05:57 +0200 Subject: [PATCH 156/207] [feature] Allow http and https schemes (#2162) Allow HTTP(S) URLs to be used in the WebSocket constructor. They are immediately converted to the ws and wss schemes. Refs: https://github.com/whatwg/websockets/pull/45 --- lib/websocket.js | 12 ++++++--- test/websocket.test.js | 57 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index b2b2b0926..f71d3d8e7 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -667,24 +667,30 @@ function initAsClient(websocket, address, protocols, options) { if (address instanceof URL) { parsedUrl = address; - websocket._url = address.href; } else { try { parsedUrl = new URL(address); } catch (e) { throw new SyntaxError(`Invalid URL: ${address}`); } + } - websocket._url = address; + if (parsedUrl.protocol === 'http:') { + parsedUrl.protocol = 'ws:'; + } else if (parsedUrl.protocol === 'https:') { + parsedUrl.protocol = 'wss:'; } + websocket._url = parsedUrl.href; + const isSecure = parsedUrl.protocol === 'wss:'; const isIpcUrl = parsedUrl.protocol === 'ws+unix:'; let invalidUrlMessage; if (parsedUrl.protocol !== 'ws:' && !isSecure && !isIpcUrl) { invalidUrlMessage = - 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"'; + '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) { diff --git a/test/websocket.test.js b/test/websocket.test.js index 4d45fad49..24553a1fa 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -36,8 +36,16 @@ describe('WebSocket', () => { ); assert.throws( - () => new WebSocket('https://websocket-echo.com'), - /^SyntaxError: The URL's protocol must be one of "ws:", "wss:", or "ws\+unix:"$/ + () => 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( @@ -72,6 +80,30 @@ 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 http.Agent(); @@ -539,10 +571,18 @@ describe('WebSocket', () => { }); it('exposes the server url', () => { - const url = 'ws://localhost'; - const ws = new WebSocket(url, { agent: new CustomAgent() }); + const schemes = new Map([ + ['ws', 'ws'], + ['wss', 'wss'], + ['http', 'ws'], + ['https', 'wss'] + ]); + + for (const [key, value] of schemes) { + const ws = new WebSocket(`${key}://localhost/`, { lookup() {} }); - assert.strictEqual(ws.url, url); + assert.strictEqual(ws.url, `${value}://localhost/`); + } }); }); }); @@ -1174,7 +1214,9 @@ describe('WebSocket', () => { it('emits an error if the redirect URL is invalid (2/2)', (done) => { server.once('upgrade', (req, socket) => { - socket.end('HTTP/1.1 302 Found\r\nLocation: http://localhost\r\n\r\n'); + 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}`, { @@ -1186,7 +1228,8 @@ describe('WebSocket', () => { assert.ok(err instanceof SyntaxError); assert.strictEqual( err.message, - 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"' + 'The URL\'s protocol must be one of "ws:", "wss:", ' + + '"http:", "https", or "ws+unix:"' ); assert.strictEqual(ws._redirects, 1); From 31da41728ff5484bbc10e6f9b5487198d396fcb7 Mon Sep 17 00:00:00 2001 From: Tim Perry <1526883+pimterry@users.noreply.github.com> Date: Thu, 31 Aug 2023 21:09:31 +0200 Subject: [PATCH 157/207] [fix] Make `server.handleUpgrade()` work with any duplex stream (#2165) --- lib/websocket.js | 9 +++++++-- package.json | 1 + test/websocket-server.test.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index f71d3d8e7..15f61acee 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -223,8 +223,13 @@ class WebSocket extends EventEmitter { receiver.on('ping', receiverOnPing); receiver.on('pong', receiverOnPong); - socket.setTimeout(0); - socket.setNoDelay(); + // These methods may not be available if `socket` is actually just a stream: + if (socket.setTimeout) { + socket.setTimeout(0); + } + if (socket.setNoDelay) { + socket.setNoDelay(); + } if (head.length > 0) socket.unshift(head); diff --git a/package.json b/package.json index 2f076d2f3..0cc387471 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "mocha": "^8.4.0", + "native-duplexpair": "^1.0.0", "nyc": "^15.0.0", "prettier": "^3.0.0", "utf-8-validate": "^6.0.0" diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 176c29dbd..33d7a65f8 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -10,6 +10,7 @@ const path = require('path'); const net = require('net'); const fs = require('fs'); const os = require('os'); +const DuplexPair = require('native-duplexpair'); const Sender = require('../lib/sender'); const WebSocket = require('..'); @@ -514,6 +515,35 @@ describe('WebSocketServer', () => { }); }); }); + + it('can complete 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 { socket1: stream1, socket2: stream2 } = new DuplexPair(); + socket.pipe(stream1); + stream1.pipe(socket); + + // Pass the other side of the stream as the socket to upgrade: + wss.handleUpgrade(req, stream2, 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); + }); + }); + }); }); describe('#completeUpgrade', () => { From 62521f26d7d7b349ec4e532db85a4b0d2de1296a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 31 Aug 2023 21:37:11 +0200 Subject: [PATCH 158/207] [minor] Fix nits --- lib/sender.js | 7 +++---- lib/websocket-server.js | 16 ++++++---------- lib/websocket.js | 27 ++++++++++++--------------- test/websocket-server.test.js | 11 ++++++++--- 4 files changed, 29 insertions(+), 32 deletions(-) diff --git a/lib/sender.js b/lib/sender.js index c84885362..1ed04b027 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -1,9 +1,8 @@ -/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls$" }] */ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */ 'use strict'; -const net = require('net'); -const tls = require('tls'); +const { Duplex } = require('stream'); const { randomFillSync } = require('crypto'); const PerMessageDeflate = require('./permessage-deflate'); @@ -21,7 +20,7 @@ class Sender { /** * Creates a Sender instance. * - * @param {(net.Socket|tls.Socket)} socket The connection socket + * @param {Duplex} socket The connection socket * @param {Object} [extensions] An object containing the negotiated extensions * @param {Function} [generateMask] The function used to generate the masking * key diff --git a/lib/websocket-server.js b/lib/websocket-server.js index bac30eb33..b0ed7bd2e 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -1,12 +1,10 @@ -/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls|https$" }] */ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$" }] */ 'use strict'; const EventEmitter = require('events'); const http = require('http'); -const https = require('https'); -const net = require('net'); -const tls = require('tls'); +const { Duplex } = require('stream'); const { createHash } = require('crypto'); const extension = require('./extension'); @@ -221,8 +219,7 @@ class WebSocketServer extends EventEmitter { * Handle a HTTP Upgrade request. * * @param {http.IncomingMessage} req The request object - * @param {(net.Socket|tls.Socket)} socket The network socket between the - * server and client + * @param {Duplex} socket The network socket between the server and client * @param {Buffer} head The first packet of the upgraded stream * @param {Function} cb Callback * @public @@ -346,8 +343,7 @@ class WebSocketServer extends EventEmitter { * @param {String} key The value of the `Sec-WebSocket-Key` header * @param {Set} protocols The subprotocols * @param {http.IncomingMessage} req The request object - * @param {(net.Socket|tls.Socket)} socket The network socket between the - * server and client + * @param {Duplex} socket The network socket between the server and client * @param {Buffer} head The first packet of the upgraded stream * @param {Function} cb Callback * @throws {Error} If called more than once with the same socket @@ -477,7 +473,7 @@ function socketOnError() { /** * Close the connection when preconditions are not fulfilled. * - * @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request + * @param {Duplex} socket The socket of the upgrade request * @param {Number} code The HTTP response status code * @param {String} [message] The HTTP response body * @param {Object} [headers] Additional HTTP response headers @@ -518,7 +514,7 @@ function abortHandshake(socket, code, message, headers) { * * @param {WebSocketServer} server The WebSocket server * @param {http.IncomingMessage} req The request object - * @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request + * @param {Duplex} socket The socket of the upgrade request * @param {Number} code The HTTP response status code * @param {String} message The HTTP response body * @private diff --git a/lib/websocket.js b/lib/websocket.js index 15f61acee..8685ff73a 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -1,4 +1,4 @@ -/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Readable$" }] */ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$" }] */ 'use strict'; @@ -8,7 +8,7 @@ const http = require('http'); const net = require('net'); const tls = require('tls'); const { randomBytes, createHash } = require('crypto'); -const { Readable } = require('stream'); +const { Duplex, Readable } = require('stream'); const { URL } = require('url'); const PerMessageDeflate = require('./permessage-deflate'); @@ -189,8 +189,7 @@ class WebSocket extends EventEmitter { /** * Set up the socket and the internal resources. * - * @param {(net.Socket|tls.Socket)} socket The network socket between the - * server and client + * @param {Duplex} socket The network socket between the server and client * @param {Buffer} head The first packet of the upgraded stream * @param {Object} options Options object * @param {Function} [options.generateMask] The function used to generate the @@ -223,13 +222,11 @@ class WebSocket extends EventEmitter { receiver.on('ping', receiverOnPing); receiver.on('pong', receiverOnPong); - // These methods may not be available if `socket` is actually just a stream: - if (socket.setTimeout) { - socket.setTimeout(0); - } - if (socket.setNoDelay) { - socket.setNoDelay(); - } + // + // 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); @@ -1229,7 +1226,7 @@ function resume(stream) { } /** - * The listener of the `net.Socket` `'close'` event. + * The listener of the socket `'close'` event. * * @private */ @@ -1280,7 +1277,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 @@ -1292,7 +1289,7 @@ function socketOnData(chunk) { } /** - * The listener of the `net.Socket` `'end'` event. + * The listener of the socket `'end'` event. * * @private */ @@ -1305,7 +1302,7 @@ function socketOnEnd() { } /** - * The listener of the `net.Socket` `'error'` event. + * The listener of the socket `'error'` event. * * @private */ diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 33d7a65f8..b962edcb9 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -516,19 +516,24 @@ describe('WebSocketServer', () => { }); }); - it('can complete a WebSocket upgrade over any duplex stream', (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: + // + // Put a stream between the raw socket and our websocket processing. + // const { socket1: stream1, socket2: stream2 } = new DuplexPair(); + socket.pipe(stream1); stream1.pipe(socket); - // Pass the other side of the stream as the socket to upgrade: + // + // Pass the other side of the stream as the socket to upgrade. + // wss.handleUpgrade(req, stream2, head, (ws) => { ws.send('hello'); ws.close(); From 5b577fe6653f896859f936255d8e2b792a75c501 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 6 Sep 2023 15:06:15 +0200 Subject: [PATCH 159/207] [pkg] Remove native-duplexpair dev dependency It seems to be no longer maintained. --- package.json | 1 - test/duplex-pair.js | 73 +++++++++++++++++++++++++++++++++++ test/websocket-server.test.js | 10 ++--- 3 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 test/duplex-pair.js diff --git a/package.json b/package.json index 0cc387471..2f076d2f3 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "mocha": "^8.4.0", - "native-duplexpair": "^1.0.0", "nyc": "^15.0.0", "prettier": "^3.0.0", "utf-8-validate": "^6.0.0" 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/websocket-server.test.js b/test/websocket-server.test.js index b962edcb9..ace3cb650 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -10,8 +10,8 @@ const path = require('path'); const net = require('net'); const fs = require('fs'); const os = require('os'); -const DuplexPair = require('native-duplexpair'); +const makeDuplexPair = require('./duplex-pair'); const Sender = require('../lib/sender'); const WebSocket = require('..'); const { NOOP } = require('../lib/constants'); @@ -526,15 +526,15 @@ describe('WebSocketServer', () => { // // Put a stream between the raw socket and our websocket processing. // - const { socket1: stream1, socket2: stream2 } = new DuplexPair(); + const { clientSide, serverSide } = makeDuplexPair(); - socket.pipe(stream1); - stream1.pipe(socket); + socket.pipe(clientSide); + clientSide.pipe(socket); // // Pass the other side of the stream as the socket to upgrade. // - wss.handleUpgrade(req, stream2, head, (ws) => { + wss.handleUpgrade(req, serverSide, head, (ws) => { ws.send('hello'); ws.close(); }); From c1d26c372efb116e3339284f9b7d269b21790a8f Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 6 Sep 2023 15:30:33 +0200 Subject: [PATCH 160/207] [test] Fix failing test --- test/websocket-server.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index ace3cb650..5b6937cee 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -281,11 +281,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); From d30768405fc295f0365c4bad8b7e14a9ad54c64b Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 6 Sep 2023 15:35:25 +0200 Subject: [PATCH 161/207] [dist] 8.14.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2f076d2f3..b1b287efc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.13.0", + "version": "8.14.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From ddba690ab8c5da2da2fc9af3131d5e5629cbdbd4 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 6 Sep 2023 16:14:55 +0200 Subject: [PATCH 162/207] [doc] Fix the type of the `socket` argument --- doc/ws.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 0fc44d6e6..a0d9f88ad 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -249,8 +249,7 @@ receives an `Error` if the server is already closed. ### server.handleUpgrade(request, socket, head, callback) - `request` {http.IncomingMessage} The client HTTP GET request. -- `socket` {net.Socket|tls.Socket} The network socket between the server and - client. +- `socket` {stream.Duplex} The network socket between the server and client. - `head` {Buffer} The first packet of the upgraded stream. - `callback` {Function}. From 511aefece49ee38c6fcca19d230c115fbfeaefd8 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 6 Sep 2023 16:18:08 +0200 Subject: [PATCH 163/207] [pkg] Silence npm warning --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b1b287efc..ee94cb68c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ ], "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", From ae60ce0d1eaa239844bc8d60d220b47e302c3d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 8 Sep 2023 16:12:32 +0200 Subject: [PATCH 164/207] [ci] Cache downloaded npm dependencies (#2166) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8209e348..05cf9f59b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: with: node-version: ${{ matrix.node }} architecture: ${{ matrix.arch }} + cache: 'npm' + cache-dependency-path: ./package.json - run: npm install - run: npm run lint if: From fd3c64cbd60606f75763350133ba2757b6a64545 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 8 Sep 2023 16:03:26 +0200 Subject: [PATCH 165/207] [test] Fix flaky tests on Windows --- test/websocket.test.js | 56 ++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index 24553a1fa..d4dd762d8 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -2813,16 +2813,28 @@ describe('WebSocket', () => { }); it('can be called from an error listener while connecting', (done) => { - const ws = new WebSocket('ws://localhost:1337'); + const server = net.createServer(); - ws.on('open', () => done(new Error("Unexpected 'open' event"))); - ws.on('error', (err) => { - assert.ok(err instanceof Error); - assert.strictEqual(err.code, 'ECONNREFUSED'); - ws.close(); - ws.on('close', () => done()); + server.on('connection', (socket) => { + socket.on('end', socket.end); + socket.resume(); + socket.write(Buffer.from('foo\r\n')); }); - }).timeout(4000); + + 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("can be called from a listener of the 'redirect' event", (done) => { const server = http.createServer(); @@ -3087,16 +3099,28 @@ 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, function () { + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.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(); From 397b89e3db6782022bbcf328b1191f5a1eb7800f Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 8 Sep 2023 17:41:04 +0200 Subject: [PATCH 166/207] [ci] Update actions/checkout action to v4 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05cf9f59b..cf3901557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: node: 18 os: windows-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} From 7460049ff0a61bef8d5eda4b1d5c8170bc7d6b6f Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 8 Sep 2023 17:52:39 +0200 Subject: [PATCH 167/207] [dist] 8.14.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ee94cb68c..988d87565 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.14.0", + "version": "8.14.1", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 7f4e1a75afbcee162cff0d44000b4fda82008d05 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 19 Sep 2023 15:33:39 +0200 Subject: [PATCH 168/207] [fix] Add missing rejection handler Use `queueMicrotask()` when available and add a rejection handler to the shim for it. --- lib/receiver.js | 41 ++++++++++++++++++++++++++++++++++++----- test/receiver.test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/lib/receiver.js b/lib/receiver.js index b5e9a8bca..1d425ead0 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -15,6 +15,12 @@ const { isValidStatusCode, isValidUTF8 } = require('./validation'); const FastBuffer = Buffer[Symbol.species]; const promise = Promise.resolve(); +// +// `queueMicrotask()` is not available in Node.js < 11. +// +const queueTask = + typeof queueMicrotask === 'function' ? queueMicrotask : queueMicrotaskShim; + const GET_INFO = 0; const GET_PAYLOAD_LENGTH_16 = 1; const GET_PAYLOAD_LENGTH_64 = 2; @@ -169,11 +175,7 @@ class Receiver extends Writable { // this._loop = false; - // - // `queueMicrotask()` is not available in Node.js < 11 and is no - // better anyway. - // - promise.then(() => { + queueTask(() => { this._state = GET_INFO; this.startLoop(cb); }); @@ -646,3 +648,32 @@ function error(ErrorCtor, message, prefix, statusCode, errorCode) { err[kStatusCode] = statusCode; return err; } + +/** + * A shim for `queueMicrotask()`. + * + * @param {Function} cb Callback + */ +function queueMicrotaskShim(cb) { + promise.then(cb).catch(throwErrorNextTick); +} + +/** + * Throws an error. + * + * @param {Error} err The error to throw + * @private + */ +function throwError(err) { + throw err; +} + +/** + * Throws an error in the next tick. + * + * @param {Error} err The error to throw + * @private + */ +function throwErrorNextTick(err) { + process.nextTick(throwError, err); +} diff --git a/test/receiver.test.js b/test/receiver.test.js index a4e1bb5ad..40e0565ad 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -2,6 +2,7 @@ const assert = require('assert'); const crypto = require('crypto'); +const EventEmitter = require('events'); const PerMessageDeflate = require('../lib/permessage-deflate'); const Receiver = require('../lib/receiver'); @@ -1175,4 +1176,33 @@ describe('Receiver', () => { receiver.write(Buffer.from('8A01318A01328A0133', 'hex')); }); + + it('does not swallow errors thrown from event handlers', (done) => { + const receiver = new Receiver(); + let count = 0; + + receiver.on('message', function () { + 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(); + }); + + receiver.write(Buffer.from('82008200', 'hex')); + }); }); From d8dd4852b81982fc0a6d633673968dff90985000 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 19 Sep 2023 17:20:57 +0200 Subject: [PATCH 169/207] [dist] 8.14.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 988d87565..107c188d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.14.1", + "version": "8.14.2", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From a049674d936746c36fe928cc1baaaafd3029a83e Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 4 Nov 2023 20:49:31 +0100 Subject: [PATCH 170/207] [ci] Update actions/setup-node action to v4 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf3901557..7a6490630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: os: windows-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} architecture: ${{ matrix.arch }} From 726abc3d1e96a51eebb8d1460303dc68d9d3d4b4 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 8 Dec 2023 17:43:13 +0100 Subject: [PATCH 171/207] [test] Fix flaky test --- test/websocket.test.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/websocket.test.js b/test/websocket.test.js index d4dd762d8..ba1485478 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -626,15 +626,19 @@ describe('WebSocket', () => { }); }); - it('does not re-emit `net.Socket` errors', (done) => { - const codes = ['EPIPE', 'ECONNABORTED', 'ECANCELED', 'ECONNRESET']; + it('does not re-emit `net.Socket` errors', function (done) { + // + // `socket.resetAndDestroy()` is not available in Node.js < 16.17.0. + // + if (process.versions.modules < 93) return this.skip(); + const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); ws.on('open', () => { ws._socket.on('error', (err) => { assert.ok(err instanceof Error); - assert.ok(codes.includes(err.code), `Unexpected code: ${err.code}`); + assert.strictEqual(err.code, 'ECONNRESET'); ws.on('close', (code, message) => { assert.strictEqual(code, 1006); assert.strictEqual(message, EMPTY_BUFFER); @@ -642,9 +646,7 @@ describe('WebSocket', () => { }); }); - for (const client of wss.clients) client.terminate(); - ws.send('foo'); - ws.send('bar'); + wss.clients.values().next().value._socket.resetAndDestroy(); }); }); }); From 208220d018a3571b5bbac541b1e513d1027a6d66 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 8 Dec 2023 17:45:44 +0100 Subject: [PATCH 172/207] [lint] Fix prettier error --- lib/websocket.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 8685ff73a..60da37d4f 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -806,8 +806,8 @@ function initAsClient(websocket, address, protocols, options) { ? opts.socketPath === websocket._originalHostOrSocketPath : false : websocket._originalIpc - ? false - : parsedUrl.host === websocket._originalHostOrSocketPath; + ? false + : parsedUrl.host === websocket._originalHostOrSocketPath; if (!isSameHost || (websocket._originalSecure && !isSecure)) { // From dd1994df04670df521a3744af6e6ba435ede7cba Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 8 Dec 2023 21:13:55 +0100 Subject: [PATCH 173/207] [ci] Test on node 21 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a6490630..7ca1a776a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: - 16 - 18 - 20 + - 21 os: - macOS-latest - ubuntu-latest From 5a3036e3f502c07dc4fdd64e5d40b9280de139be Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 9 Dec 2023 08:38:10 +0100 Subject: [PATCH 174/207] [test] Merge some tests --- test/receiver.test.js | 93 +++++++++---------------------------------- 1 file changed, 19 insertions(+), 74 deletions(-) diff --git a/test/receiver.test.js b/test/receiver.test.js index 40e0565ad..8884703b4 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -1085,43 +1085,20 @@ describe('Receiver', () => { receiver.write(Buffer.from([0x88, 0x03, 0x03, 0xe8, 0xf8])); }); - it("waits a microtask after each 'message' event", (done) => { - const messages = []; - const receiver = new Receiver(); - - receiver.on('message', (data, isBinary) => { - assert.ok(!isBinary); - - const message = data.toString(); - messages.push(message); - - // `queueMicrotask()` is not available in Node.js < 11. - Promise.resolve().then(() => { - messages.push(`microtask ${message}`); - - if (messages.length === 6) { - assert.deepStrictEqual(messages, [ - '1', - 'microtask 1', - '2', - 'microtask 2', - '3', - 'microtask 3' - ]); - - done(); - } - }); - }); - - receiver.write(Buffer.from('810131810132810133', 'hex')); - }); - - it("waits a microtask after each 'ping' event", (done) => { + it("waits a microtask after the 'message', and 'p{i,o}ng' events", (done) => { const actual = []; - const receiver = new Receiver(); + const expected = [ + '1', + 'microtask 1', + '2', + 'microtask 2', + '3', + 'microtask 3', + '4', + 'microtask 4' + ]; - receiver.on('ping', (data) => { + function listener(data) { const message = data.toString(); actual.push(message); @@ -1129,52 +1106,20 @@ describe('Receiver', () => { Promise.resolve().then(() => { actual.push(`microtask ${message}`); - if (actual.length === 6) { - assert.deepStrictEqual(actual, [ - '1', - 'microtask 1', - '2', - 'microtask 2', - '3', - 'microtask 3' - ]); - + if (actual.length === 8) { + assert.deepStrictEqual(actual, expected); done(); } }); - }); - - receiver.write(Buffer.from('890131890132890133', 'hex')); - }); + } - it("waits a microtask after each 'pong' event", (done) => { - const actual = []; const receiver = new Receiver(); - receiver.on('pong', (data) => { - const message = data.toString(); - actual.push(message); - - // `queueMicrotask()` is not available in Node.js < 11. - Promise.resolve().then(() => { - actual.push(`microtask ${message}`); - - if (actual.length === 6) { - assert.deepStrictEqual(actual, [ - '1', - 'microtask 1', - '2', - 'microtask 2', - '3', - 'microtask 3' - ]); - - done(); - } - }); - }); + receiver.on('message', listener); + receiver.on('ping', listener); + receiver.on('pong', listener); - receiver.write(Buffer.from('8A01318A01328A0133', 'hex')); + receiver.write(Buffer.from('8101318901328a0133810134', 'hex')); }); it('does not swallow errors thrown from event handlers', (done) => { From c320738b1d2236900d2dd2fe391ab83bbed1e63f Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 9 Dec 2023 11:00:40 +0100 Subject: [PATCH 175/207] [test] Fix nits --- test/receiver.test.js | 2 +- test/websocket.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/receiver.test.js b/test/receiver.test.js index 8884703b4..0f82cf3ea 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -1126,7 +1126,7 @@ describe('Receiver', () => { const receiver = new Receiver(); let count = 0; - receiver.on('message', function () { + receiver.on('message', () => { if (++count === 2) { throw new Error('Oops'); } diff --git a/test/websocket.test.js b/test/websocket.test.js index ba1485478..4699ae5cd 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -3109,7 +3109,7 @@ describe('WebSocket', () => { socket.write(Buffer.from('foo\r\n')); }); - server.listen(0, function () { + server.listen(0, () => { const ws = new WebSocket(`ws://localhost:${server.address().port}`); ws.on('open', () => done(new Error("Unexpected 'open' event"))); From 603a0391de32732df415778ed32f311a21c82731 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 9 Dec 2023 13:23:48 +0100 Subject: [PATCH 176/207] [doc] Add JSDoc for the `finishRequest` option --- lib/websocket.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/websocket.js b/lib/websocket.js index 60da37d4f..312f6a237 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -618,6 +618,8 @@ module.exports = WebSocket; * @param {(String|URL)} address The URL to which to connect * @param {Array} protocols The subprotocols * @param {Object} [options] Connection options + * @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 From 93e3552e95ba5ad656c30b94f6be96afe22d4805 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 9 Dec 2023 14:21:18 +0100 Subject: [PATCH 177/207] [feature] Introduce the `allowMultipleEventsPerMicrotask` option The `allowMultipleEventsPerMicrotask` option allows the `'message'`, `'ping'`, and `'pong'` events to be emitted more than once per microtask. Refs: https://github.com/websockets/ws/pull/2160 --- doc/ws.md | 8 ++++++++ lib/receiver.js | 27 ++++++++++++++++----------- lib/websocket-server.js | 6 ++++++ lib/websocket.js | 9 +++++++++ test/receiver.test.js | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 11 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index a0d9f88ad..c39ac356c 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -72,6 +72,10 @@ This class represents a WebSocket server. It extends the `EventEmitter`. ### new WebSocketServer(options[, callback]) - `options` {Object} + - `allowMultipleEventsPerMicrotask` {Boolean} Specifies whether or not to + process more than one of the `'message'`, `'ping'`, and `'pong'` events per + microtask. To improve compatibility with the WHATWG standard, the default + value is `false`. Setting it to `true` improves performance slightly. - `backlog` {Number} The maximum length of the queue of pending connections. - `clientTracking` {Boolean} Specifies whether or not to track clients. - `handleProtocols` {Function} A function which can be used to handle the @@ -292,6 +296,10 @@ 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} + - `allowMultipleEventsPerMicrotask` {Boolean} Specifies whether or not to + process more than one of the `'message'`, `'ping'`, and `'pong'` events per + microtask. To improve compatibility with the WHATWG standard, the default + value is `false`. Setting it to `true` improves performance slightly. - `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 diff --git a/lib/receiver.js b/lib/receiver.js index 1d425ead0..d0c68432d 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -39,6 +39,9 @@ class Receiver extends Writable { * Creates a Receiver instance. * * @param {Object} [options] Options object + * @param {Boolean} [options.allowMultipleEventsPerMicrotask=false] Specifies + * whether or not to process more than one of the `'message'`, `'ping'`, + * and `'pong'` events per microtask * @param {String} [options.binaryType=nodebuffer] The type for binary data * @param {Object} [options.extensions] An object containing the negotiated * extensions @@ -51,6 +54,8 @@ class Receiver extends Writable { constructor(options = {}) { super(); + this._allowMultipleEventsPerMicrotask = + !!options.allowMultipleEventsPerMicrotask; this._binaryType = options.binaryType || BINARY_TYPES[0]; this._extensions = options.extensions || {}; this._isServer = !!options.isServer; @@ -561,7 +566,9 @@ class Receiver extends Writable { } } - this._state = WAIT_MICROTASK; + this._state = this._allowMultipleEventsPerMicrotask + ? GET_INFO + : WAIT_MICROTASK; } /** @@ -578,8 +585,6 @@ class Receiver extends Writable { if (data.length === 0) { this.emit('conclude', 1005, EMPTY_BUFFER); this.end(); - - this._state = GET_INFO; } else { const code = data.readUInt16BE(0); @@ -611,16 +616,16 @@ class Receiver extends Writable { this.emit('conclude', code, buf); this.end(); - - this._state = GET_INFO; } - } else if (this._opcode === 0x09) { - this.emit('ping', data); - this._state = WAIT_MICROTASK; - } else { - this.emit('pong', data); - this._state = WAIT_MICROTASK; + + this._state = GET_INFO; + return; } + + this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); + this._state = this._allowMultipleEventsPerMicrotask + ? GET_INFO + : WAIT_MICROTASK; } } diff --git a/lib/websocket-server.js b/lib/websocket-server.js index b0ed7bd2e..78c0bb289 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -29,6 +29,9 @@ class WebSocketServer extends EventEmitter { * Create a `WebSocketServer` instance. * * @param {Object} options Configuration options + * @param {Boolean} [options.allowMultipleEventsPerMicrotask=false] Specifies + * whether or not to process more than one of the `'message'`, `'ping'`, + * and `'pong'` events per microtask * @param {Number} [options.backlog=511] The maximum length of the queue of * pending connections * @param {Boolean} [options.clientTracking=true] Specifies whether or not to @@ -55,6 +58,7 @@ class WebSocketServer extends EventEmitter { super(); options = { + allowMultipleEventsPerMicrotask: false, maxPayload: 100 * 1024 * 1024, skipUTF8Validation: false, perMessageDeflate: false, @@ -409,6 +413,8 @@ class WebSocketServer extends EventEmitter { socket.removeListener('error', socketOnError); ws.setSocket(socket, head, { + allowMultipleEventsPerMicrotask: + this.options.allowMultipleEventsPerMicrotask, maxPayload: this.options.maxPayload, skipUTF8Validation: this.options.skipUTF8Validation }); diff --git a/lib/websocket.js b/lib/websocket.js index 312f6a237..d2c6a36fe 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -192,6 +192,9 @@ class WebSocket extends EventEmitter { * @param {Duplex} socket The network socket between the server and client * @param {Buffer} head The first packet of the upgraded stream * @param {Object} options Options object + * @param {Boolean} [options.allowMultipleEventsPerMicrotask=false] Specifies + * whether or not to process more than one of the `'message'`, `'ping'`, + * and `'pong'` events per microtask * @param {Function} [options.generateMask] The function used to generate the * masking key * @param {Number} [options.maxPayload=0] The maximum allowed message size @@ -201,6 +204,7 @@ class WebSocket extends EventEmitter { */ setSocket(socket, head, options) { const receiver = new Receiver({ + allowMultipleEventsPerMicrotask: options.allowMultipleEventsPerMicrotask, binaryType: this.binaryType, extensions: this._extensions, isServer: this._isServer, @@ -618,6 +622,9 @@ module.exports = WebSocket; * @param {(String|URL)} address The URL to which to connect * @param {Array} protocols The subprotocols * @param {Object} [options] Connection options + * @param {Boolean} [options.allowMultipleEventsPerMicrotask=false] Specifies + * whether or not to process more than one of the `'message'`, `'ping'`, + * and `'pong'` events per microtask * @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 @@ -642,6 +649,7 @@ module.exports = WebSocket; */ function initAsClient(websocket, address, protocols, options) { const opts = { + allowMultipleEventsPerMicrotask: false, protocolVersion: protocolVersions[1], maxPayload: 100 * 1024 * 1024, skipUTF8Validation: false, @@ -993,6 +1001,7 @@ function initAsClient(websocket, address, protocols, options) { } websocket.setSocket(socket, head, { + allowMultipleEventsPerMicrotask: opts.allowMultipleEventsPerMicrotask, generateMask: opts.generateMask, maxPayload: opts.maxPayload, skipUTF8Validation: opts.skipUTF8Validation diff --git a/test/receiver.test.js b/test/receiver.test.js index 0f82cf3ea..4e3ee923d 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -1150,4 +1150,41 @@ describe('Receiver', () => { receiver.write(Buffer.from('82008200', 'hex')); }); + + it('honors the `allowMultipleEventsPerMicrotask` option', (done) => { + const actual = []; + const expected = [ + '1', + '2', + '3', + '4', + 'microtask 1', + 'microtask 2', + 'microtask 3', + 'microtask 4' + ]; + + function listener(data) { + const message = data.toString(); + actual.push(message); + + // `queueMicrotask()` is not available in Node.js < 11. + Promise.resolve().then(() => { + actual.push(`microtask ${message}`); + + if (actual.length === 8) { + assert.deepStrictEqual(actual, expected); + done(); + } + }); + } + + const receiver = new Receiver({ allowMultipleEventsPerMicrotask: true }); + + receiver.on('message', listener); + receiver.on('ping', listener); + receiver.on('pong', listener); + + receiver.write(Buffer.from('8101318901328a0133810134', 'hex')); + }); }); From 297fff8eded6328e4386fda735002b9c4d17b537 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 9 Dec 2023 15:36:51 +0100 Subject: [PATCH 178/207] [dist] 8.15.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 107c188d2..78679492b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.14.2", + "version": "8.15.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From fccc580061a4a35e5f286babafe7416768fd777b Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 12 Dec 2023 08:29:34 +0100 Subject: [PATCH 179/207] [fix] Emit the event when the microtask is executed Emit the `'message'`, `'ping'`, and `'pong'` event when the microtask for that event is executed. --- lib/receiver.js | 337 +++++++++++++++++++++++++----------------- test/receiver.test.js | 2 +- 2 files changed, 199 insertions(+), 140 deletions(-) diff --git a/lib/receiver.js b/lib/receiver.js index d0c68432d..18bb9b54d 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -27,7 +27,7 @@ const GET_PAYLOAD_LENGTH_64 = 2; const GET_MASK = 3; const GET_DATA = 4; const INFLATING = 5; -const WAIT_MICROTASK = 6; +const DEFER_EVENT = 6; /** * HyBi Receiver implementation. @@ -78,8 +78,9 @@ class Receiver extends Writable { this._messageLength = 0; this._fragments = []; - this._state = GET_INFO; + this._errored = false; this._loop = false; + this._state = GET_INFO; } /** @@ -151,53 +152,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; case INFLATING: + case DEFER_EVENT: this._loop = false; return; - default: - // - // `WAIT_MICROTASK`. - // - this._loop = false; - - queueTask(() => { - this._state = GET_INFO; - this.startLoop(cb); - }); - 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; @@ -206,27 +196,31 @@ class Receiver extends Writable { const buf = this.consume(2); if ((buf[0] & 0x30) !== 0x00) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'RSV2 and RSV3 must be clear', true, 1002, 'WS_ERR_UNEXPECTED_RSV_2_3' ); + + cb(error); + return; } const compressed = (buf[0] & 0x40) === 0x40; if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'RSV1 must be clear', true, 1002, 'WS_ERR_UNEXPECTED_RSV_1' ); + + cb(error); + return; } this._fin = (buf[0] & 0x80) === 0x80; @@ -235,86 +229,100 @@ class Receiver extends Writable { if (this._opcode === 0x00) { if (compressed) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'RSV1 must be clear', true, 1002, 'WS_ERR_UNEXPECTED_RSV_1' ); + + cb(error); + return; } if (!this._fragmented) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'invalid opcode 0', true, 1002, 'WS_ERR_INVALID_OPCODE' ); + + cb(error); + return; } this._opcode = this._fragmented; } else if (this._opcode === 0x01 || this._opcode === 0x02) { if (this._fragmented) { - this._loop = false; - return error( + const error = this.createError( RangeError, `invalid opcode ${this._opcode}`, true, 1002, 'WS_ERR_INVALID_OPCODE' ); + + cb(error); + return; } this._compressed = compressed; } else if (this._opcode > 0x07 && this._opcode < 0x0b) { if (!this._fin) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'FIN must be set', true, 1002, 'WS_ERR_EXPECTED_FIN' ); + + cb(error); + return; } if (compressed) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'RSV1 must be clear', true, 1002, 'WS_ERR_UNEXPECTED_RSV_1' ); + + cb(error); + return; } if ( this._payloadLength > 0x7d || (this._opcode === 0x08 && this._payloadLength === 1) ) { - this._loop = false; - return error( + const error = this.createError( RangeError, `invalid payload length ${this._payloadLength}`, true, 1002, 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' ); + + cb(error); + return; } } else { - this._loop = false; - return error( + const error = this.createError( RangeError, `invalid opcode ${this._opcode}`, true, 1002, 'WS_ERR_INVALID_OPCODE' ); + + cb(error); + return; } if (!this._fin && !this._fragmented) this._fragmented = this._opcode; @@ -322,54 +330,58 @@ class Receiver extends Writable { if (this._isServer) { if (!this._masked) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'MASK must be set', true, 1002, 'WS_ERR_EXPECTED_MASK' ); + + cb(error); + return; } } else if (this._masked) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'MASK must be clear', true, 1002, 'WS_ERR_UNEXPECTED_MASK' ); + + cb(error); + return; } if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; - else return this.haveLength(); + else this.haveLength(cb); } /** * Gets extended payload length (7+16). * - * @return {(RangeError|undefined)} A possible error + * @param {Function} cb Callback * @private */ - getPayloadLength16() { + getPayloadLength16(cb) { if (this._bufferedBytes < 2) { this._loop = false; return; } this._payloadLength = this.consume(2).readUInt16BE(0); - return this.haveLength(); + this.haveLength(cb); } /** * Gets extended payload length (7+64). * - * @return {(RangeError|undefined)} A possible error + * @param {Function} cb Callback * @private */ - getPayloadLength64() { + getPayloadLength64(cb) { if (this._bufferedBytes < 8) { this._loop = false; return; @@ -383,38 +395,42 @@ class Receiver extends Writable { // if payload length is greater than this number. // if (num > Math.pow(2, 53 - 32) - 1) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'Unsupported WebSocket frame: payload length > 2^53 - 1', false, 1009, 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH' ); + + cb(error); + return; } this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); - return this.haveLength(); + this.haveLength(cb); } /** * Payload length has been read. * - * @return {(RangeError|undefined)} A possible error + * @param {Function} cb Callback * @private */ - haveLength() { + haveLength(cb) { if (this._payloadLength && this._opcode < 0x08) { this._totalPayloadLength += this._payloadLength; if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { - this._loop = false; - return error( + const error = this.createError( RangeError, 'Max payload size exceeded', false, 1009, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' ); + + cb(error); + return; } } @@ -441,7 +457,6 @@ class Receiver extends Writable { * Reads data bytes. * * @param {Function} cb Callback - * @return {(Error|RangeError|undefined)} A possible error * @private */ getData(cb) { @@ -463,7 +478,10 @@ class Receiver extends Writable { } } - if (this._opcode > 0x07) return this.controlMessage(data); + if (this._opcode > 0x07) { + this.controlMessage(data, cb); + return; + } if (this._compressed) { this._state = INFLATING; @@ -480,7 +498,7 @@ class Receiver extends Writable { this._fragments.push(data); } - return this.dataMessage(); + this.dataMessage(cb); } /** @@ -499,76 +517,101 @@ class Receiver extends Writable { if (buf.length) { this._messageLength += buf.length; if (this._messageLength > this._maxPayload && this._maxPayload > 0) { - return cb( - error( - RangeError, - 'Max payload size exceeded', - false, - 1009, - 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' - ) + const error = this.createError( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' ); + + cb(error); + return; } this._fragments.push(buf); } - const er = this.dataMessage(); - if (er) return cb(er); - - this.startLoop(cb); + this.dataMessage(cb); + if (this._state === GET_INFO) this.startLoop(cb); }); } /** * Handles a data message. * - * @return {(Error|undefined)} A possible error + * @param {Function} cb Callback * @private */ - dataMessage() { - if (this._fin) { - const messageLength = this._messageLength; - const fragments = this._fragments; - - this._totalPayloadLength = 0; - this._messageLength = 0; - this._fragmented = 0; - this._fragments = []; - - if (this._opcode === 2) { - let data; - - if (this._binaryType === 'nodebuffer') { - data = concat(fragments, messageLength); - } else if (this._binaryType === 'arraybuffer') { - data = toArrayBuffer(concat(fragments, messageLength)); - } else { - data = fragments; - } + dataMessage(cb) { + if (!this._fin) { + this._state = GET_INFO; + return; + } + + const messageLength = this._messageLength; + const fragments = this._fragments; + + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragmented = 0; + this._fragments = []; + if (this._opcode === 2) { + let data; + + if (this._binaryType === 'nodebuffer') { + data = concat(fragments, messageLength); + } else if (this._binaryType === 'arraybuffer') { + data = toArrayBuffer(concat(fragments, messageLength)); + } else { + data = fragments; + } + + // + // If the state is `INFLATING`, it means that the frame data was + // decompressed asynchronously, so there is no need to defer the event + // as it will be emitted asynchronously anyway. + // + if (this._state === INFLATING || this._allowMultipleEventsPerMicrotask) { this.emit('message', data, true); + this._state = GET_INFO; } else { - const buf = concat(fragments, messageLength); + this._state = DEFER_EVENT; + queueTask(() => { + this.emit('message', data, true); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } else { + const buf = concat(fragments, messageLength); - if (!this._skipUTF8Validation && !isValidUTF8(buf)) { - this._loop = false; - return error( - Error, - 'invalid UTF-8 sequence', - true, - 1007, - 'WS_ERR_INVALID_UTF8' - ); - } + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + + cb(error); + return; + } + if (this._state === INFLATING || this._allowMultipleEventsPerMicrotask) { this.emit('message', buf, false); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + queueTask(() => { + this.emit('message', buf, false); + this._state = GET_INFO; + this.startLoop(cb); + }); } } - - this._state = this._allowMultipleEventsPerMicrotask - ? GET_INFO - : WAIT_MICROTASK; } /** @@ -578,24 +621,26 @@ 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._loop = false; this.emit('conclude', 1005, EMPTY_BUFFER); this.end(); } else { const code = data.readUInt16BE(0); if (!isValidStatusCode(code)) { - return error( + const error = this.createError( RangeError, `invalid status code ${code}`, true, 1002, 'WS_ERR_INVALID_CLOSE_CODE' ); + + cb(error); + return; } const buf = new FastBuffer( @@ -605,15 +650,19 @@ class Receiver extends Writable { ); if (!this._skipUTF8Validation && !isValidUTF8(buf)) { - return error( + const error = this.createError( Error, 'invalid UTF-8 sequence', true, 1007, 'WS_ERR_INVALID_UTF8' ); + + cb(error); + return; } + this._loop = false; this.emit('conclude', code, buf); this.end(); } @@ -622,38 +671,48 @@ class Receiver extends Writable { return; } - this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); - this._state = this._allowMultipleEventsPerMicrotask - ? GET_INFO - : WAIT_MICROTASK; + if (this._allowMultipleEventsPerMicrotask) { + this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + queueTask(() => { + this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); + this._state = GET_INFO; + this.startLoop(cb); + }); + } } -} -module.exports = Receiver; + /** + * Builds an error object. + * + * @param {function(new:Error|RangeError)} ErrorCtor The error constructor + * @param {String} message The error message + * @param {Boolean} prefix Specifies whether or not to add a default prefix to + * `message` + * @param {Number} statusCode The status code + * @param {String} errorCode The exposed error code + * @return {(Error|RangeError)} The error + * @private + */ + createError(ErrorCtor, message, prefix, statusCode, errorCode) { + this._loop = false; + this._errored = true; -/** - * Builds an error object. - * - * @param {function(new:Error|RangeError)} ErrorCtor The error constructor - * @param {String} message The error message - * @param {Boolean} prefix Specifies whether or not to add a default prefix to - * `message` - * @param {Number} statusCode The status code - * @param {String} errorCode The exposed error code - * @return {(Error|RangeError)} The error - * @private - */ -function error(ErrorCtor, message, prefix, statusCode, errorCode) { - const err = new ErrorCtor( - prefix ? `Invalid WebSocket frame: ${message}` : message - ); - - Error.captureStackTrace(err, error); - err.code = errorCode; - err[kStatusCode] = statusCode; - return err; + 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; + /** * A shim for `queueMicrotask()`. * diff --git a/test/receiver.test.js b/test/receiver.test.js index 4e3ee923d..ab2d3c749 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -1085,7 +1085,7 @@ describe('Receiver', () => { receiver.write(Buffer.from([0x88, 0x03, 0x03, 0xe8, 0xf8])); }); - it("waits a microtask after the 'message', and 'p{i,o}ng' events", (done) => { + it('emits at most one event per microtask', (done) => { const actual = []; const expected = [ '1', From 4ed7fe58b42a87d06452b6bc19028d167262c30b Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 12 Dec 2023 18:48:02 +0100 Subject: [PATCH 180/207] [major] Rename the `allowMultipleEventsPerMicrotask` option Rename the `allowMultipleEventsPerMicrotask` option to `allowSynchronousEvents`. --- doc/ws.md | 16 ++++++++-------- lib/receiver.js | 15 +++++++-------- lib/websocket-server.js | 11 +++++------ lib/websocket.js | 18 +++++++++--------- test/receiver.test.js | 6 +++--- 5 files changed, 32 insertions(+), 34 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index c39ac356c..92eb2c23e 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -72,10 +72,10 @@ This class represents a WebSocket server. It extends the `EventEmitter`. ### new WebSocketServer(options[, callback]) - `options` {Object} - - `allowMultipleEventsPerMicrotask` {Boolean} Specifies whether or not to - process more than one of the `'message'`, `'ping'`, and `'pong'` events per - microtask. To improve compatibility with the WHATWG standard, the default - value is `false`. Setting it to `true` improves performance slightly. + - `allowSynchronousEvents` {Boolean} Specifies whether any of the `'message'`, + `'ping'`, and `'pong'` events can be emitted multiple times in the same + tick. To improve compatibility with the WHATWG standard, the default value + is `false`. Setting it to `true` improves performance slightly. - `backlog` {Number} The maximum length of the queue of pending connections. - `clientTracking` {Boolean} Specifies whether or not to track clients. - `handleProtocols` {Function} A function which can be used to handle the @@ -296,10 +296,10 @@ 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} - - `allowMultipleEventsPerMicrotask` {Boolean} Specifies whether or not to - process more than one of the `'message'`, `'ping'`, and `'pong'` events per - microtask. To improve compatibility with the WHATWG standard, the default - value is `false`. Setting it to `true` improves performance slightly. + - `allowSynchronousEvents` {Boolean} Specifies whether any of the `'message'`, + `'ping'`, and `'pong'` events can be emitted multiple times in the same + tick. To improve compatibility with the WHATWG standard, the default value + is `false`. Setting it to `true` improves performance slightly. - `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 diff --git a/lib/receiver.js b/lib/receiver.js index 18bb9b54d..9e87d811f 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -39,9 +39,9 @@ class Receiver extends Writable { * Creates a Receiver instance. * * @param {Object} [options] Options object - * @param {Boolean} [options.allowMultipleEventsPerMicrotask=false] Specifies - * whether or not to process more than one of the `'message'`, `'ping'`, - * and `'pong'` events per microtask + * @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 {String} [options.binaryType=nodebuffer] The type for binary data * @param {Object} [options.extensions] An object containing the negotiated * extensions @@ -54,8 +54,7 @@ class Receiver extends Writable { constructor(options = {}) { super(); - this._allowMultipleEventsPerMicrotask = - !!options.allowMultipleEventsPerMicrotask; + this._allowSynchronousEvents = !!options.allowSynchronousEvents; this._binaryType = options.binaryType || BINARY_TYPES[0]; this._extensions = options.extensions || {}; this._isServer = !!options.isServer; @@ -573,7 +572,7 @@ class Receiver extends Writable { // decompressed asynchronously, so there is no need to defer the event // as it will be emitted asynchronously anyway. // - if (this._state === INFLATING || this._allowMultipleEventsPerMicrotask) { + if (this._state === INFLATING || this._allowSynchronousEvents) { this.emit('message', data, true); this._state = GET_INFO; } else { @@ -600,7 +599,7 @@ class Receiver extends Writable { return; } - if (this._state === INFLATING || this._allowMultipleEventsPerMicrotask) { + if (this._state === INFLATING || this._allowSynchronousEvents) { this.emit('message', buf, false); this._state = GET_INFO; } else { @@ -671,7 +670,7 @@ class Receiver extends Writable { return; } - if (this._allowMultipleEventsPerMicrotask) { + if (this._allowSynchronousEvents) { this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); this._state = GET_INFO; } else { diff --git a/lib/websocket-server.js b/lib/websocket-server.js index 78c0bb289..58b63019d 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -29,9 +29,9 @@ class WebSocketServer extends EventEmitter { * Create a `WebSocketServer` instance. * * @param {Object} options Configuration options - * @param {Boolean} [options.allowMultipleEventsPerMicrotask=false] Specifies - * whether or not to process more than one of the `'message'`, `'ping'`, - * and `'pong'` events per microtask + * @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 {Number} [options.backlog=511] The maximum length of the queue of * pending connections * @param {Boolean} [options.clientTracking=true] Specifies whether or not to @@ -58,7 +58,7 @@ class WebSocketServer extends EventEmitter { super(); options = { - allowMultipleEventsPerMicrotask: false, + allowSynchronousEvents: false, maxPayload: 100 * 1024 * 1024, skipUTF8Validation: false, perMessageDeflate: false, @@ -413,8 +413,7 @@ class WebSocketServer extends EventEmitter { socket.removeListener('error', socketOnError); ws.setSocket(socket, head, { - allowMultipleEventsPerMicrotask: - this.options.allowMultipleEventsPerMicrotask, + allowSynchronousEvents: this.options.allowSynchronousEvents, maxPayload: this.options.maxPayload, skipUTF8Validation: this.options.skipUTF8Validation }); diff --git a/lib/websocket.js b/lib/websocket.js index d2c6a36fe..29e706ef5 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -192,9 +192,9 @@ class WebSocket extends EventEmitter { * @param {Duplex} socket The network socket between the server and client * @param {Buffer} head The first packet of the upgraded stream * @param {Object} options Options object - * @param {Boolean} [options.allowMultipleEventsPerMicrotask=false] Specifies - * whether or not to process more than one of the `'message'`, `'ping'`, - * and `'pong'` events per microtask + * @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 @@ -204,7 +204,7 @@ class WebSocket extends EventEmitter { */ setSocket(socket, head, options) { const receiver = new Receiver({ - allowMultipleEventsPerMicrotask: options.allowMultipleEventsPerMicrotask, + allowSynchronousEvents: options.allowSynchronousEvents, binaryType: this.binaryType, extensions: this._extensions, isServer: this._isServer, @@ -622,9 +622,9 @@ module.exports = WebSocket; * @param {(String|URL)} address The URL to which to connect * @param {Array} protocols The subprotocols * @param {Object} [options] Connection options - * @param {Boolean} [options.allowMultipleEventsPerMicrotask=false] Specifies - * whether or not to process more than one of the `'message'`, `'ping'`, - * and `'pong'` events per microtask + * @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.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 @@ -649,7 +649,7 @@ module.exports = WebSocket; */ function initAsClient(websocket, address, protocols, options) { const opts = { - allowMultipleEventsPerMicrotask: false, + allowSynchronousEvents: false, protocolVersion: protocolVersions[1], maxPayload: 100 * 1024 * 1024, skipUTF8Validation: false, @@ -1001,7 +1001,7 @@ function initAsClient(websocket, address, protocols, options) { } websocket.setSocket(socket, head, { - allowMultipleEventsPerMicrotask: opts.allowMultipleEventsPerMicrotask, + allowSynchronousEvents: opts.allowSynchronousEvents, generateMask: opts.generateMask, maxPayload: opts.maxPayload, skipUTF8Validation: opts.skipUTF8Validation diff --git a/test/receiver.test.js b/test/receiver.test.js index ab2d3c749..a88f29b9a 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -443,7 +443,7 @@ describe('Receiver', () => { buf[i + 1] = 0x00; } - const receiver = new Receiver(); + const receiver = new Receiver({ allowSynchronousEvents: true }); let counter = 0; receiver.on('message', (data, isBinary) => { @@ -1151,7 +1151,7 @@ describe('Receiver', () => { receiver.write(Buffer.from('82008200', 'hex')); }); - it('honors the `allowMultipleEventsPerMicrotask` option', (done) => { + it('honors the `allowSynchronousEvents` option', (done) => { const actual = []; const expected = [ '1', @@ -1179,7 +1179,7 @@ describe('Receiver', () => { }); } - const receiver = new Receiver({ allowMultipleEventsPerMicrotask: true }); + const receiver = new Receiver({ allowSynchronousEvents: true }); receiver.on('message', listener); receiver.on('ping', listener); From a57e963f946860f6418baaa55b307bfa7d0bc143 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 12 Dec 2023 19:02:46 +0100 Subject: [PATCH 181/207] [dist] 8.15.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 78679492b..1424de93b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.15.0", + "version": "8.15.1", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From d37756a973d48c4e924344916823a9189cbfa454 Mon Sep 17 00:00:00 2001 From: James <5511220+Zamiell@users.noreply.github.com> Date: Wed, 20 Dec 2023 03:17:04 -0500 Subject: [PATCH 182/207] [doc] Clarify legacy deps (#2184) --- README.md | 52 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a550ca1c7..82aa26a48 100644 --- a/README.md +++ b/README.md @@ -57,27 +57,37 @@ npm install ws ### Opt-in for performance -There are 2 optional modules that can be installed along side with the ws -module. These modules are binary addons that improve the performance of certain -operations. Prebuilt binaries are available for the most popular platforms so -you don't necessarily need to have a C++ compiler installed on your machine. - -- `npm install --save-optional bufferutil`: Allows to efficiently perform - operations such as masking and unmasking the data payload of the WebSocket - frames. -- `npm install --save-optional utf-8-validate`: Allows to efficiently check if a - message contains valid UTF-8. - -To not even try to require and use these modules, use the -[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) and -[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment -variables. These might be useful to enhance security in systems where a user can -put a package in the package search path of an application of another user, due -to how the Node.js resolver algorithm works. - -The `utf-8-validate` module is not needed and is not required, even if it is -already installed, regardless of the value of the `WS_NO_UTF_8_VALIDATE` -environment variable, if [`buffer.isUtf8()`][] is available. +`bufferutil` is an optional module that can be installed alongside the `ws` +module: + +``` +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` to not use `utf-8-validate`, use the +[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable. ## API docs From 3e230c16b70efa82fd28da7aca45c341a2b3efd8 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 20 Dec 2023 11:02:13 +0100 Subject: [PATCH 183/207] [doc] Fix nits --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 82aa26a48..40a9bba63 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ npm install ws ### Opt-in for performance -`bufferutil` is an optional module that can be installed alongside the `ws` +[bufferutil][] is an optional module that can be installed alongside the ws module: ``` @@ -69,7 +69,7 @@ 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 +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 @@ -77,8 +77,8 @@ 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: +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 @@ -86,7 +86,7 @@ npm install --save-optional utf-8-validate This contains a binary polyfill for [`buffer.isUtf8()`][]. -To force `ws` to not use `utf-8-validate`, use the +To force ws to not use utf-8-validate, use the [`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable. ## API docs @@ -533,6 +533,7 @@ 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 @@ -543,4 +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 +[utf-8-validate]: https://github.com/websockets/utf-8-validate [ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback From 527ec97264cf063bd9c75f33e6a085559fb7d1da Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 20 Dec 2023 18:21:29 +0100 Subject: [PATCH 184/207] [doc] Add missing subsubsection to TOC --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 40a9bba63..80d988655 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ can use one of the many wrappers available on npm, like - [Protocol support](#protocol-support) - [Installing](#installing) - [Opt-in for performance](#opt-in-for-performance) + - [Legacy opt-in for performance](#legacy-opt-in-for-performance) - [API docs](#api-docs) - [WebSocket compression](#websocket-compression) - [Usage examples](#usage-examples) From 01ba54edaeff0f3a58abd7cb9f8e1f3bf134d0fc Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 25 Dec 2023 16:00:21 +0100 Subject: [PATCH 185/207] [feature] Introduce the `autoPong` option Add the ability to disable the automatic sending of pong responses to pings. Fixes #2186 --- doc/ws.md | 4 +++ lib/websocket-server.js | 5 +++- lib/websocket.js | 8 +++++- test/websocket-server.test.js | 24 ++++++++++++++++++ test/websocket.test.js | 47 +++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 92eb2c23e..f79cfc901 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -72,6 +72,8 @@ This class represents a WebSocket server. It extends the `EventEmitter`. ### new WebSocketServer(options[, callback]) - `options` {Object} + - `autoPong` {Boolean} Specifies whether or not to automatically send a pong + in response to a ping. Defaults to `true`. - `allowSynchronousEvents` {Boolean} Specifies whether any of the `'message'`, `'ping'`, and `'pong'` events can be emitted multiple times in the same tick. To improve compatibility with the WHATWG standard, the default value @@ -296,6 +298,8 @@ This class represents a WebSocket. It extends the `EventEmitter`. - `address` {String|url.URL} The URL to which to connect. - `protocols` {String|Array} The list of subprotocols. - `options` {Object} + - `autoPong` {Boolean} Specifies whether or not to automatically send a pong + in response to a ping. Defaults to `true`. - `allowSynchronousEvents` {Boolean} Specifies whether any of the `'message'`, `'ping'`, and `'pong'` events can be emitted multiple times in the same tick. To improve compatibility with the WHATWG standard, the default value diff --git a/lib/websocket-server.js b/lib/websocket-server.js index 58b63019d..377c45a8b 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -32,6 +32,8 @@ class WebSocketServer extends EventEmitter { * @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 {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 @@ -59,6 +61,7 @@ class WebSocketServer extends EventEmitter { options = { allowSynchronousEvents: false, + autoPong: true, maxPayload: 100 * 1024 * 1024, skipUTF8Validation: false, perMessageDeflate: false, @@ -379,7 +382,7 @@ class WebSocketServer extends EventEmitter { `Sec-WebSocket-Accept: ${digest}` ]; - const ws = new this.options.WebSocket(null); + const ws = new this.options.WebSocket(null, undefined, this.options); if (protocols.size) { // diff --git a/lib/websocket.js b/lib/websocket.js index 29e706ef5..df5034cc7 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -84,6 +84,7 @@ class WebSocket extends EventEmitter { initAsClient(this, address, protocols, options); } else { + this._autoPong = options.autoPong; this._isServer = true; } } @@ -625,6 +626,8 @@ module.exports = WebSocket; * @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 {Boolean} [options.autoPong=true] Specifies whether or not to + * automatically send a pong in response to a ping * @param {Function} [options.finishRequest] A function which can be used to * customize the headers of each http request before it is sent * @param {Boolean} [options.followRedirects=false] Whether or not to follow @@ -650,6 +653,7 @@ module.exports = WebSocket; function initAsClient(websocket, address, protocols, options) { const opts = { allowSynchronousEvents: false, + autoPong: true, protocolVersion: protocolVersions[1], maxPayload: 100 * 1024 * 1024, skipUTF8Validation: false, @@ -668,6 +672,8 @@ function initAsClient(websocket, address, protocols, options) { port: undefined }; + websocket._autoPong = opts.autoPong; + if (!protocolVersions.includes(opts.protocolVersion)) { throw new RangeError( `Unsupported protocol version: ${opts.protocolVersion} ` + @@ -1212,7 +1218,7 @@ function receiverOnMessage(data, isBinary) { 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); } diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 5b6937cee..44c2c6709 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -116,6 +116,30 @@ describe('WebSocketServer', () => { wss.close(done); }); }); + + it('honors the `autoPong` option', (done) => { + const wss = new WebSocket.Server({ autoPong: false, port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.ping(); + }); + + ws.on('pong', () => { + done(new Error("Unexpected 'pong' event")); + }); + }); + + wss.on('connection', (ws) => { + ws.on('ping', () => { + ws.close(); + }); + + ws.on('close', () => { + wss.close(done); + }); + }); + }); }); it('emits an error if http server bind fails', (done) => { diff --git a/test/websocket.test.js b/test/websocket.test.js index 4699ae5cd..28dcb8808 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -197,6 +197,30 @@ describe('WebSocket', () => { }); }); }); + + 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(); + }); + }); }); }); @@ -2325,6 +2349,29 @@ describe('WebSocket', () => { 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', () => { From 391ddf3a9a8852ac70fed55a17fad803e27a77ee Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 26 Dec 2023 11:27:06 +0100 Subject: [PATCH 186/207] [test] Use `stream.getDefaultHighWaterMark()` when available Refs: https://github.com/nodejs/node/pull/50120 --- test/create-websocket-stream.test.js | 18 ++++++++++++++---- test/websocket.test.js | 21 +++++++++++++++------ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/test/create-websocket-stream.test.js b/test/create-websocket-stream.test.js index 572f5c4f2..0a83a45ea 100644 --- a/test/create-websocket-stream.test.js +++ b/test/create-websocket-stream.test.js @@ -3,7 +3,7 @@ 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'); @@ -11,6 +11,10 @@ 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', () => { assert.strictEqual(WebSocket.createWebSocketStream, createWebSocketStream); @@ -445,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)); }); @@ -494,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 }) ]; diff --git a/test/websocket.test.js b/test/websocket.test.js index 28dcb8808..e1b3bd239 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -11,6 +11,7 @@ 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'); @@ -23,6 +24,10 @@ const { } = require('../lib/event-target'); const { EMPTY_BUFFER, GUID, kListener, NOOP } = require('../lib/constants'); +const highWaterMark = getDefaultHighWaterMark + ? getDefaultHighWaterMark(false) + : 16 * 1024; + class CustomAgent extends http.Agent { addRequest() {} } @@ -4092,7 +4097,7 @@ describe('WebSocket', () => { ws.terminate(); }; - const payload1 = Buffer.alloc(15 * 1024); + const payload1 = Buffer.alloc(highWaterMark - 1024); const payload2 = Buffer.alloc(1); const opts = { @@ -4107,13 +4112,17 @@ describe('WebSocket', () => { ...Sender.frame(payload2, { rsv1: true, ...opts }) ]; - for (let i = 0; i < 399; i++) { + 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 - // 16 KiB will be sent as a single TCP packet. - push.call(ws._socket, Buffer.concat(list)); + // `highWaterMark` bytes will be sent as a single TCP packet. + push.call(ws._socket, data); wss.clients .values() @@ -4128,8 +4137,8 @@ describe('WebSocket', () => { ws.on('close', (code) => { assert.strictEqual(code, 1006); - assert.strictEqual(messageLengths.length, 402); - assert.strictEqual(messageLengths[0], 15360); + assert.strictEqual(messageLengths.length, 343); + assert.strictEqual(messageLengths[0], highWaterMark - 1024); assert.strictEqual(messageLengths[messageLengths.length - 1], 1); wss.close(done); }); From d343a0cf7bba29a4e14217cb010446bec8fdf444 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 26 Dec 2023 16:31:20 +0100 Subject: [PATCH 187/207] [dist] 8.16.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1424de93b..a2443200d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.15.1", + "version": "8.16.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 5e42cfdc5fa114659908eaad4d9ead7d5051d740 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 24 Jan 2024 07:50:08 +0100 Subject: [PATCH 188/207] [meta] Add FUNDING.json Refs: https://github.com/websockets/ws/issues/2194 --- FUNDING.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 FUNDING.json 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" + } + } +} From 8be840e0a93c9c90565c6f137834ecacba0f14bf Mon Sep 17 00:00:00 2001 From: Al-phonsio <160236920+Al-phonsio@users.noreply.github.com> Date: Sat, 16 Mar 2024 15:24:36 +0100 Subject: [PATCH 189/207] [doc] Replace `url.parse()` with `new URL()` (#2208) --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 80d988655..b6cacbe92 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,6 @@ server.listen(8080); ```js import { createServer } from 'http'; -import { parse } from 'url'; import { WebSocketServer } from 'ws'; const server = createServer(); @@ -265,7 +264,7 @@ wss2.on('connection', function connection(ws) { }); server.on('upgrade', function upgrade(request, socket, head) { - const { pathname } = parse(request.url); + const { pathname } = new URL(request.url, 'wss://base.url'); if (pathname === '/foo') { wss1.handleUpgrade(request, socket, head, function done(ws) { From 2405c17775fb57f5e07db123c6133733dd58bbab Mon Sep 17 00:00:00 2001 From: Jana R <94439978+grjan7@users.noreply.github.com> Date: Fri, 29 Mar 2024 17:23:43 +0530 Subject: [PATCH 190/207] [doc] Add punctuation for readability (#2213) --- README.md | 16 ++++++++-------- SECURITY.md | 14 +++++++------- doc/ws.md | 46 +++++++++++++++++++++++----------------------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index b6cacbe92..21f10df10 100644 --- a/README.md +++ b/README.md @@ -11,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 @@ -87,7 +87,7 @@ npm install --save-optional utf-8-validate This contains a binary polyfill for [`buffer.isUtf8()`][]. -To force ws to not use utf-8-validate, use the +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 @@ -146,7 +146,7 @@ const wss = new WebSocketServer({ ``` 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 @@ -451,11 +451,11 @@ wss.on('connection', function connection(ws, req) { ### 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 @@ -490,7 +490,7 @@ 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: diff --git a/SECURITY.md b/SECURITY.md index 0baf19a63..cbaf84de2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,21 +12,21 @@ blocked instantly. ## Exceptions -If you do not receive an acknowledgement within the said time frame please give +If you do not receive an acknowledgement within the said time frame, please give us the benefit of the doubt as it's possible that we haven't seen it yet. In -this case please send us a message **without details** using one of the +this case, please send us a message **without details** using one of the following methods: - Contact the lead developers of this project on their personal e-mails. You can - find the e-mails in the git logs, for example using the following command: + find the e-mails in the git logs, for example, using the following command: `git --no-pager show -s --format='%an <%ae>' ` where `` is the SHA1 of their latest commit in the project. - Create a GitHub issue stating contact details and the severity of the issue. -Once we have acknowledged receipt of your report and confirmed the bug ourselves -we will work with you to fix the vulnerability and publicly acknowledge your -responsible disclosure, if you wish. In addition to that we will create and -publish a security advisory to +Once we have acknowledged receipt of your report and confirmed the bug +ourselves, we will work with you to fix the vulnerability and publicly +acknowledge your responsible disclosure, if you wish. In addition to that, we +will create and publish a security advisory to [GitHub Security Advisories](https://github.com/websockets/ws/security/advisories?state=published). ## History diff --git a/doc/ws.md b/doc/ws.md index f79cfc901..017087f5f 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -103,7 +103,7 @@ This class represents a WebSocket server. It extends the `EventEmitter`. 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 completely detached from the HTTP/S server. This makes it possible, for example, to share a single HTTP/S server between multiple WebSocket servers. @@ -112,8 +112,8 @@ to share a single HTTP/S server between multiple WebSocket servers. > authentication in the `'upgrade'` event of the HTTP server. See examples for > more details. -If `verifyClient` is not set then the handshake is automatically accepted. If it -has a single parameter then `ws` will invoke it with the following argument: +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. @@ -124,19 +124,19 @@ has a single parameter then `ws` will invoke it with the following argument: The return value (`Boolean`) of the function determines whether or not to accept the handshake. -If `verifyClient` has two parameters then `ws` will invoke it with the following -arguments: +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: @@ -146,15 +146,15 @@ arguments: - `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 @@ -171,8 +171,8 @@ value). If an object is provided then that is extension parameters: 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. @@ -248,7 +248,7 @@ 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 +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. @@ -273,7 +273,7 @@ If the upgrade is successful, the `callback` is called with two arguments: - `request` {http.IncomingMessage} The client HTTP GET request. -See if a given request should be handled by this server. By default this method +See if a given request should be handled by this server. By default, this method validates the pathname of the request, matching it against the `path` option if provided. The return value, `true` or `false`, determines whether or not to accept the handshake. @@ -305,12 +305,12 @@ This class represents a WebSocket. It extends the `EventEmitter`. tick. To improve compatibility with the WHATWG standard, the default value is `false`. Setting it to `true` improves performance slightly. - `finishRequest` {Function} A function which can be used to customize the - headers of each http request before it is sent. See description below. + 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 + 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. @@ -343,7 +343,7 @@ context takeover. 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()` +`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. @@ -479,7 +479,7 @@ 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]]) @@ -610,7 +610,7 @@ state is `CONNECTING`. ### websocket.terminate() -Forcibly close the connection. Internally this calls [`socket.destroy()`][]. +Forcibly close the connection. Internally, this calls [`socket.destroy()`][]. ### websocket.url @@ -631,12 +631,12 @@ given `WebSocket`. ### WS_NO_BUFFER_UTIL -When set to a non empty value, prevents the optional `bufferutil` dependency +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 +When set to a non-empty value, prevents the optional `utf-8-validate` dependency from being required. ## Error codes From b119b41db3bde7c5929609b4a52aa95c3af06f04 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 9 Apr 2024 19:07:26 +0200 Subject: [PATCH 191/207] [pkg] Update eslint to version 9.0.0 --- .eslintrc.yaml | 19 ------------------- eslint.config.js | 28 ++++++++++++++++++++++++++++ lib/websocket-server.js | 2 +- lib/websocket.js | 2 +- package.json | 5 +++-- 5 files changed, 33 insertions(+), 23 deletions(-) delete mode 100644 .eslintrc.yaml create mode 100644 eslint.config.js diff --git a/.eslintrc.yaml b/.eslintrc.yaml deleted file mode 100644 index f3d983b9c..000000000 --- a/.eslintrc.yaml +++ /dev/null @@ -1,19 +0,0 @@ -env: - browser: true - es6: true - mocha: true - node: true -extends: - - eslint:recommended - - plugin:prettier/recommended -parserOptions: - ecmaVersion: latest - sourceType: module -rules: - no-console: off - no-var: error - prefer-const: error - quotes: - - error - - single - - avoidEscape: true diff --git a/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/lib/websocket-server.js b/lib/websocket-server.js index 377c45a8b..4873ad9fb 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -1,4 +1,4 @@ -/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$" }] */ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */ 'use strict'; diff --git a/lib/websocket.js b/lib/websocket.js index df5034cc7..f133d08fc 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -1,4 +1,4 @@ -/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$" }] */ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$", "caughtErrors": "none" }] */ 'use strict'; diff --git a/package.json b/package.json index a2443200d..74ae3c0c2 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "scripts": { "test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js", "integration": "mocha --throw-deprecation test/*.integration.js", - "lint": "eslint --ignore-path .gitignore . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\"" + "lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\"" }, "peerDependencies": { "bufferutil": "^4.0.1", @@ -57,9 +57,10 @@ "devDependencies": { "benchmark": "^2.1.4", "bufferutil": "^4.0.1", - "eslint": "^8.0.0", + "eslint": "^9.0.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "globals": "^15.0.0", "mocha": "^8.4.0", "nyc": "^15.0.0", "prettier": "^3.0.0", From 53a88881cf5da8307ecbd5020db0a8fb72cf0d20 Mon Sep 17 00:00:00 2001 From: Tim Perry <1526883+pimterry@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:20:52 +0200 Subject: [PATCH 192/207] [feature] Allow the `createConnection` option (#2219) Allow passing in a custom `createConnection` function. --- doc/ws.md | 4 ++++ lib/websocket.js | 7 +++++-- test/websocket.test.js | 28 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 017087f5f..37f4c9707 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -304,6 +304,10 @@ This class represents a WebSocket. It extends the `EventEmitter`. `'ping'`, and `'pong'` events can be emitted multiple times in the same tick. To improve compatibility with the WHATWG standard, the default value is `false`. Setting it to `true` improves performance slightly. + - `createConnection` {Function} An alternative function to use in place of + `tls.createConnection` or `net.createConnection`. This can be used to + manually control exactly how the connection to the server is made, or to + make a connection over an existing Duplex stream obtained elsewhere. - `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 diff --git a/lib/websocket.js b/lib/websocket.js index f133d08fc..a2c8edbec 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -628,6 +628,8 @@ module.exports = WebSocket; * times in the same tick * @param {Boolean} [options.autoPong=true] Specifies whether or not to * automatically send a pong in response to a ping + * @param {Function} [options.createConnection] An alternative function to use + * in place of `tls.createConnection` or `net.createConnection`. * @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 @@ -660,8 +662,8 @@ function initAsClient(websocket, address, protocols, options) { perMessageDeflate: true, followRedirects: false, maxRedirects: 10, - ...options, createConnection: undefined, + ...options, socketPath: undefined, hostname: undefined, protocol: undefined, @@ -732,7 +734,8 @@ function initAsClient(websocket, address, protocols, options) { 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('[') diff --git a/test/websocket.test.js b/test/websocket.test.js index e1b3bd239..fc41ae755 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1224,6 +1224,34 @@ describe('WebSocket', () => { }); }); + it('honors the `createConnection` option', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + server.once('upgrade', (req, socket, head) => { + assert.strictEqual(req.headers.host, 'google.com:22'); + wss.handleUpgrade(req, socket, head, NOOP); + }); + + const ws = new WebSocket('ws://google.com:22/foo', { + createConnection: (options) => { + assert.strictEqual(options.host, 'google.com'); + assert.strictEqual(options.port, '22'); + + // Ignore the invalid host address, and connect to the server manually: + 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('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'); From 2aa0405a5e96754b296fef6bd6ebdfb2f11967fc Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Mon, 15 Apr 2024 15:02:18 +0200 Subject: [PATCH 193/207] [minor] Fix nits --- doc/ws.md | 4 --- lib/websocket.js | 3 --- test/websocket.test.js | 57 +++++++++++++++++++++--------------------- 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 37f4c9707..017087f5f 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -304,10 +304,6 @@ This class represents a WebSocket. It extends the `EventEmitter`. `'ping'`, and `'pong'` events can be emitted multiple times in the same tick. To improve compatibility with the WHATWG standard, the default value is `false`. Setting it to `true` improves performance slightly. - - `createConnection` {Function} An alternative function to use in place of - `tls.createConnection` or `net.createConnection`. This can be used to - manually control exactly how the connection to the server is made, or to - make a connection over an existing Duplex stream obtained elsewhere. - `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 diff --git a/lib/websocket.js b/lib/websocket.js index a2c8edbec..56d6c6fe3 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -628,8 +628,6 @@ module.exports = WebSocket; * times in the same tick * @param {Boolean} [options.autoPong=true] Specifies whether or not to * automatically send a pong in response to a ping - * @param {Function} [options.createConnection] An alternative function to use - * in place of `tls.createConnection` or `net.createConnection`. * @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 @@ -662,7 +660,6 @@ function initAsClient(websocket, address, protocols, options) { perMessageDeflate: true, followRedirects: false, maxRedirects: 10, - createConnection: undefined, ...options, socketPath: undefined, hostname: undefined, diff --git a/test/websocket.test.js b/test/websocket.test.js index fc41ae755..7cb17f0ea 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -1158,6 +1158,35 @@ describe('WebSocket', () => { }); }); + it('honors the `createConnection` option', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + server.once('upgrade', (req, socket, head) => { + assert.strictEqual(req.headers.host, 'google.com:22'); + wss.handleUpgrade(req, socket, head, NOOP); + }); + + const ws = new WebSocket('ws://google.com:22/foo', { + createConnection: (options) => { + assert.strictEqual(options.host, 'google.com'); + assert.strictEqual(options.port, '22'); + + // Ignore the `options` argument, and use the correct hostname and + // port to connect to the server. + return net.createConnection({ + host: 'localhost', + port: server.address().port + }); + } + }); + + ws.on('open', () => { + assert.strictEqual(ws.url, 'ws://google.com:22/foo'); + ws.on('close', () => done()); + ws.close(); + }); + }); + it('does not follow redirects by default', (done) => { server.once('upgrade', (req, socket) => { socket.end( @@ -1224,34 +1253,6 @@ describe('WebSocket', () => { }); }); - it('honors the `createConnection` option', (done) => { - const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); - - server.once('upgrade', (req, socket, head) => { - assert.strictEqual(req.headers.host, 'google.com:22'); - wss.handleUpgrade(req, socket, head, NOOP); - }); - - const ws = new WebSocket('ws://google.com:22/foo', { - createConnection: (options) => { - assert.strictEqual(options.host, 'google.com'); - assert.strictEqual(options.port, '22'); - - // Ignore the invalid host address, and connect to the server manually: - 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('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'); From e5f32c7e1e6d3d19cd4a1fdec84890e154db30c1 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 24 Apr 2024 09:32:43 -0400 Subject: [PATCH 194/207] [fix] Emit at most one event per event loop iteration (#2218) Fixes #2216 --- lib/receiver.js | 49 ++++-------------------------------------- test/receiver.test.js | 28 +++++++++++++++--------- test/websocket.test.js | 6 ++---- 3 files changed, 24 insertions(+), 59 deletions(-) diff --git a/lib/receiver.js b/lib/receiver.js index 9e87d811f..4515e6887 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -13,13 +13,6 @@ const { concat, toArrayBuffer, unmask } = require('./buffer-util'); const { isValidStatusCode, isValidUTF8 } = require('./validation'); const FastBuffer = Buffer[Symbol.species]; -const promise = Promise.resolve(); - -// -// `queueMicrotask()` is not available in Node.js < 11. -// -const queueTask = - typeof queueMicrotask === 'function' ? queueMicrotask : queueMicrotaskShim; const GET_INFO = 0; const GET_PAYLOAD_LENGTH_16 = 1; @@ -567,17 +560,12 @@ class Receiver extends Writable { data = fragments; } - // - // If the state is `INFLATING`, it means that the frame data was - // decompressed asynchronously, so there is no need to defer the event - // as it will be emitted asynchronously anyway. - // - if (this._state === INFLATING || this._allowSynchronousEvents) { + if (this._allowSynchronousEvents) { this.emit('message', data, true); this._state = GET_INFO; } else { this._state = DEFER_EVENT; - queueTask(() => { + setImmediate(() => { this.emit('message', data, true); this._state = GET_INFO; this.startLoop(cb); @@ -604,7 +592,7 @@ class Receiver extends Writable { this._state = GET_INFO; } else { this._state = DEFER_EVENT; - queueTask(() => { + setImmediate(() => { this.emit('message', buf, false); this._state = GET_INFO; this.startLoop(cb); @@ -675,7 +663,7 @@ class Receiver extends Writable { this._state = GET_INFO; } else { this._state = DEFER_EVENT; - queueTask(() => { + setImmediate(() => { this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data); this._state = GET_INFO; this.startLoop(cb); @@ -711,32 +699,3 @@ class Receiver extends Writable { } module.exports = Receiver; - -/** - * A shim for `queueMicrotask()`. - * - * @param {Function} cb Callback - */ -function queueMicrotaskShim(cb) { - promise.then(cb).catch(throwErrorNextTick); -} - -/** - * Throws an error. - * - * @param {Error} err The error to throw - * @private - */ -function throwError(err) { - throw err; -} - -/** - * Throws an error in the next tick. - * - * @param {Error} err The error to throw - * @private - */ -function throwErrorNextTick(err) { - process.nextTick(throwError, err); -} diff --git a/test/receiver.test.js b/test/receiver.test.js index a88f29b9a..f3a0fa645 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -1085,17 +1085,21 @@ describe('Receiver', () => { receiver.write(Buffer.from([0x88, 0x03, 0x03, 0xe8, 0xf8])); }); - it('emits at most one event per microtask', (done) => { + it('emits at most one event per event loop iteration', (done) => { const actual = []; const expected = [ '1', - 'microtask 1', + '- 1', + '-- 1', '2', - 'microtask 2', + '- 2', + '-- 2', '3', - 'microtask 3', + '- 3', + '-- 3', '4', - 'microtask 4' + '- 4', + '-- 4' ]; function listener(data) { @@ -1104,12 +1108,16 @@ describe('Receiver', () => { // `queueMicrotask()` is not available in Node.js < 11. Promise.resolve().then(() => { - actual.push(`microtask ${message}`); + actual.push(`- ${message}`); - if (actual.length === 8) { - assert.deepStrictEqual(actual, expected); - done(); - } + Promise.resolve().then(() => { + actual.push(`-- ${message}`); + + if (actual.length === 12) { + assert.deepStrictEqual(actual, expected); + done(); + } + }); }); } diff --git a/test/websocket.test.js b/test/websocket.test.js index 7cb17f0ea..5570b1caf 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -4234,8 +4234,7 @@ describe('WebSocket', () => { if (messages.push(message.toString()) > 1) return; - // `queueMicrotask()` is not available in Node.js < 11. - Promise.resolve().then(() => { + setImmediate(() => { process.nextTick(() => { assert.strictEqual(ws._receiver._state, 5); ws.close(1000); @@ -4485,8 +4484,7 @@ describe('WebSocket', () => { if (messages.push(message.toString()) > 1) return; - // `queueMicrotask()` is not available in Node.js < 11. - Promise.resolve().then(() => { + setImmediate(() => { process.nextTick(() => { assert.strictEqual(ws._receiver._state, 5); ws.terminate(); From 96c9b3deddf56cacb2d756aaa918071e03cdbc42 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 24 Apr 2024 09:42:40 -0400 Subject: [PATCH 195/207] [major] Flip the default value of `allowSynchronousEvents` (#2221) Flip the default value of the `allowSynchronousEvents` option to `true`. Refs: https://github.com/websockets/ws/pull/2218 --- doc/ws.md | 8 +++---- lib/receiver.js | 7 ++++-- lib/websocket-server.js | 4 ++-- lib/websocket.js | 4 ++-- test/receiver.test.js | 47 ++++++----------------------------------- 5 files changed, 19 insertions(+), 51 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 017087f5f..1189fd02a 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -76,8 +76,8 @@ This class represents a WebSocket server. It extends the `EventEmitter`. in response to a ping. Defaults to `true`. - `allowSynchronousEvents` {Boolean} Specifies whether any of the `'message'`, `'ping'`, and `'pong'` events can be emitted multiple times in the same - tick. To improve compatibility with the WHATWG standard, the default value - is `false`. Setting it to `true` improves performance slightly. + tick. Defaults to `true`. Setting it to `false` improves compatibility with + the WHATWG standardbut may negatively impact performance. - `backlog` {Number} The maximum length of the queue of pending connections. - `clientTracking` {Boolean} Specifies whether or not to track clients. - `handleProtocols` {Function} A function which can be used to handle the @@ -302,8 +302,8 @@ This class represents a WebSocket. It extends the `EventEmitter`. in response to a ping. Defaults to `true`. - `allowSynchronousEvents` {Boolean} Specifies whether any of the `'message'`, `'ping'`, and `'pong'` events can be emitted multiple times in the same - tick. To improve compatibility with the WHATWG standard, the default value - is `false`. Setting it to `true` improves performance slightly. + tick. Defaults to `true`. Setting it to `false` improves compatibility with + the WHATWG standardbut may negatively impact performance. - `finishRequest` {Function} A function which can be used to customize the headers of each HTTP request before it is sent. See description below. - `followRedirects` {Boolean} Whether or not to follow redirects. Defaults to diff --git a/lib/receiver.js b/lib/receiver.js index 4515e6887..70dfd9933 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -32,7 +32,7 @@ class Receiver extends Writable { * Creates a Receiver instance. * * @param {Object} [options] Options object - * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * @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 @@ -47,7 +47,10 @@ class Receiver extends Writable { constructor(options = {}) { super(); - this._allowSynchronousEvents = !!options.allowSynchronousEvents; + this._allowSynchronousEvents = + options.allowSynchronousEvents !== undefined + ? options.allowSynchronousEvents + : true; this._binaryType = options.binaryType || BINARY_TYPES[0]; this._extensions = options.extensions || {}; this._isServer = !!options.isServer; diff --git a/lib/websocket-server.js b/lib/websocket-server.js index 4873ad9fb..40980f6e9 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -29,7 +29,7 @@ class WebSocketServer extends EventEmitter { * Create a `WebSocketServer` instance. * * @param {Object} options Configuration options - * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * @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 @@ -60,7 +60,7 @@ class WebSocketServer extends EventEmitter { super(); options = { - allowSynchronousEvents: false, + allowSynchronousEvents: true, autoPong: true, maxPayload: 100 * 1024 * 1024, skipUTF8Validation: false, diff --git a/lib/websocket.js b/lib/websocket.js index 56d6c6fe3..709ad825a 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -623,7 +623,7 @@ module.exports = WebSocket; * @param {(String|URL)} address The URL to which to connect * @param {Array} protocols The subprotocols * @param {Object} [options] Connection options - * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether any + * @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 @@ -652,7 +652,7 @@ module.exports = WebSocket; */ function initAsClient(websocket, address, protocols, options) { const opts = { - allowSynchronousEvents: false, + allowSynchronousEvents: true, autoPong: true, protocolVersion: protocolVersions[1], maxPayload: 100 * 1024 * 1024, diff --git a/test/receiver.test.js b/test/receiver.test.js index f3a0fa645..88a6326d1 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -443,7 +443,7 @@ describe('Receiver', () => { buf[i + 1] = 0x00; } - const receiver = new Receiver({ allowSynchronousEvents: true }); + const receiver = new Receiver(); let counter = 0; receiver.on('message', (data, isBinary) => { @@ -1085,7 +1085,7 @@ describe('Receiver', () => { receiver.write(Buffer.from([0x88, 0x03, 0x03, 0xe8, 0xf8])); }); - it('emits at most one event per event loop iteration', (done) => { + it('honors the `allowSynchronousEvents` option', (done) => { const actual = []; const expected = [ '1', @@ -1121,7 +1121,7 @@ describe('Receiver', () => { }); } - const receiver = new Receiver(); + const receiver = new Receiver({ allowSynchronousEvents: false }); receiver.on('message', listener); receiver.on('ping', listener); @@ -1156,43 +1156,8 @@ describe('Receiver', () => { done(); }); - receiver.write(Buffer.from('82008200', 'hex')); - }); - - it('honors the `allowSynchronousEvents` option', (done) => { - const actual = []; - const expected = [ - '1', - '2', - '3', - '4', - 'microtask 1', - 'microtask 2', - 'microtask 3', - 'microtask 4' - ]; - - function listener(data) { - const message = data.toString(); - actual.push(message); - - // `queueMicrotask()` is not available in Node.js < 11. - Promise.resolve().then(() => { - actual.push(`microtask ${message}`); - - if (actual.length === 8) { - assert.deepStrictEqual(actual, expected); - done(); - } - }); - } - - const receiver = new Receiver({ allowSynchronousEvents: true }); - - receiver.on('message', listener); - receiver.on('ping', listener); - receiver.on('pong', listener); - - receiver.write(Buffer.from('8101318901328a0133810134', 'hex')); + setImmediate(() => { + receiver.write(Buffer.from('82008200', 'hex')); + }); }); }); From 1817bac06e1204bfb578b8b3f4bafd0fa09623d0 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 26 Apr 2024 07:26:09 +0200 Subject: [PATCH 196/207] [ci] Do not test on node 21 --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ca1a776a..7a6490630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,6 @@ jobs: - 16 - 18 - 20 - - 21 os: - macOS-latest - ubuntu-latest From 934c9d6b938b93c045cb13e5f7c19c27a8dd925a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 26 Apr 2024 07:26:27 +0200 Subject: [PATCH 197/207] [ci] Test on node 22 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a6490630..04693fc7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: - 16 - 18 - 20 + - 22 os: - macOS-latest - ubuntu-latest From 29694a5905fa703e86667928e6bacac397469471 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 26 Apr 2024 07:53:26 +0200 Subject: [PATCH 198/207] [test] Use the `highWaterMark` variable Use the value of the `highWaterMark` variable instead of `16384`. --- test/create-websocket-stream.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/create-websocket-stream.test.js b/test/create-websocket-stream.test.js index 0a83a45ea..54a13c6c8 100644 --- a/test/create-websocket-stream.test.js +++ b/test/create-websocket-stream.test.js @@ -604,7 +604,7 @@ describe('createWebSocketStream', () => { }); wss.on('connection', (ws) => { - ws.send(randomBytes(16 * 1024)); + ws.send(randomBytes(highWaterMark)); }); }); }); From b73b11828d166e9692a9bffe9c01a7e93bab04a8 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 28 Apr 2024 07:41:02 +0200 Subject: [PATCH 199/207] [dist] 8.17.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74ae3c0c2..ed9c681db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.16.0", + "version": "8.17.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From ddfe4a804d79e7788ab136290e609f91cf68423f Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 18 May 2024 17:11:07 +0200 Subject: [PATCH 200/207] [perf] Reduce the amount of `crypto.randomFillSync()` calls Use a pool of random bytes to reduce the amount of `crypto.randomFillSync()` calls. Refs: https://github.com/nodejs/undici/pull/3204 --- lib/sender.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/sender.js b/lib/sender.js index 1ed04b027..5ea2986ee 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -12,6 +12,9 @@ const { mask: applyMask, toBuffer } = require('./buffer-util'); const kByteLength = Symbol('kByteLength'); const maskBuffer = Buffer.alloc(4); +const RANDOM_POOL_SIZE = 8 * 1024; +let randomPool; +let randomPoolPointer = RANDOM_POOL_SIZE; /** * HyBi Sender implementation. @@ -76,7 +79,19 @@ class Sender { if (options.generateMask) { options.generateMask(mask); } else { - randomFillSync(mask, 0, 4); + if (randomPoolPointer === RANDOM_POOL_SIZE) { + if (randomPool === undefined) { + 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++]; } skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; From 6a00029edd924499f892aed8003cef1fa724cfe5 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Thu, 13 Jun 2024 21:53:40 +0200 Subject: [PATCH 201/207] [test] Increase code coverage --- lib/sender.js | 5 +++++ test/receiver.test.js | 2 +- test/websocket.test.js | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/sender.js b/lib/sender.js index 5ea2986ee..c81ec66f6 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -80,7 +80,12 @@ class Sender { 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); } diff --git a/test/receiver.test.js b/test/receiver.test.js index 88a6326d1..1f9e75d3a 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -1127,7 +1127,7 @@ describe('Receiver', () => { receiver.on('ping', listener); receiver.on('pong', listener); - receiver.write(Buffer.from('8101318901328a0133810134', 'hex')); + receiver.write(Buffer.from('8101318901328a0133820134', 'hex')); }); it('does not swallow errors thrown from event handlers', (done) => { diff --git a/test/websocket.test.js b/test/websocket.test.js index 5570b1caf..aa53c3bc9 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -4423,6 +4423,7 @@ describe('WebSocket', () => { 'The socket was closed while data was being compressed' ); }); + ws.close(); }); } ); From e55e5106f10fcbaac37cfa89759e4cc0d073a52c Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 16 Jun 2024 11:30:42 +0200 Subject: [PATCH 202/207] [security] Fix crash when the Upgrade header cannot be read (#2231) It is possible that the Upgrade header is correctly received and handled (the `'upgrade'` event is emitted) without its value being returned to the user. This can happen if the number of received headers exceed the `server.maxHeadersCount` or `request.maxHeadersCount` threshold. In this case `incomingMessage.headers.upgrade` may not be set. Handle the case correctly and abort the handshake. Fixes #2230 --- lib/websocket-server.js | 5 ++-- lib/websocket.js | 4 +++- test/websocket-server.test.js | 44 +++++++++++++++++++++++++++++++++++ test/websocket.test.js | 26 +++++++++++++++++++++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/lib/websocket-server.js b/lib/websocket-server.js index 40980f6e9..67b52ffdd 100644 --- a/lib/websocket-server.js +++ b/lib/websocket-server.js @@ -235,6 +235,7 @@ class WebSocketServer extends EventEmitter { socket.on('error', socketOnError); const key = req.headers['sec-websocket-key']; + const upgrade = req.headers.upgrade; const version = +req.headers['sec-websocket-version']; if (req.method !== 'GET') { @@ -243,13 +244,13 @@ class WebSocketServer extends EventEmitter { return; } - if (req.headers.upgrade.toLowerCase() !== 'websocket') { + if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') { const message = 'Invalid Upgrade header'; abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); return; } - if (!key || !keyRegex.test(key)) { + if (key === undefined || !keyRegex.test(key)) { const message = 'Missing or invalid Sec-WebSocket-Key header'; abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); return; diff --git a/lib/websocket.js b/lib/websocket.js index 709ad825a..aa57bbade 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -928,7 +928,9 @@ function initAsClient(websocket, address, protocols, options) { req = websocket._req = null; - if (res.headers.upgrade.toLowerCase() !== 'websocket') { + const upgrade = res.headers.upgrade; + + if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') { abortHandshake(websocket, socket, 'Invalid Upgrade header'); return; } diff --git a/test/websocket-server.test.js b/test/websocket-server.test.js index 44c2c6709..34de4dcfa 100644 --- a/test/websocket-server.test.js +++ b/test/websocket-server.test.js @@ -653,6 +653,50 @@ describe('WebSocketServer', () => { }); }); + 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({ diff --git a/test/websocket.test.js b/test/websocket.test.js index aa53c3bc9..8a05f073b 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -757,6 +757,32 @@ describe('WebSocket', () => { 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); From 3c56601092872f7d7566989f0e379271afd0e4a1 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 16 Jun 2024 15:35:43 +0200 Subject: [PATCH 203/207] [dist] 8.17.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ed9c681db..4abcf2989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.17.0", + "version": "8.17.1", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", From 15f11a052a231d1f819fffef53bf4b287646d1ca Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 16 Jun 2024 21:00:17 +0200 Subject: [PATCH 204/207] [security] Add new DoS vulnerability to SECURITY.md --- SECURITY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index cbaf84de2..65c896975 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,3 +37,5 @@ will create and publish a security advisory to [DoS vulnerability](https://github.com/websockets/ws/releases/tag/3.3.1) - 25 May 2021: [ReDoS in `Sec-Websocket-Protocol` header](https://github.com/websockets/ws/releases/tag/7.4.6) +- 16 Jun 2024: + [DoS when handling a request with many HTTP headers](https://github.com/websockets/ws/releases/tag/8.17.1) From 0d1b5e6c4acad16a6b1a1904426eb266a5ba2f72 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 16 Jun 2024 21:14:45 +0200 Subject: [PATCH 205/207] [security] Use more descriptive text for 2017 vulnerability link --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 65c896975..fb492e834 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,7 +34,7 @@ will create and publish a security advisory to - 04 Jan 2016: [Buffer vulnerability](https://github.com/websockets/ws/releases/tag/1.0.1) - 08 Nov 2017: - [DoS vulnerability](https://github.com/websockets/ws/releases/tag/3.3.1) + [DoS in the `Sec-Websocket-Extensions` header parser](https://github.com/websockets/ws/releases/tag/3.3.1) - 25 May 2021: [ReDoS in `Sec-Websocket-Protocol` header](https://github.com/websockets/ws/releases/tag/7.4.6) - 16 Jun 2024: From 59b9629b78aa66bcf9acce20468004cd3751c136 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Tue, 2 Jul 2024 17:50:16 +0200 Subject: [PATCH 206/207] [feature] Add support for `Blob` (#2229) Closes #2206 --- doc/ws.md | 18 +- lib/constants.js | 8 +- lib/receiver.js | 2 + lib/sender.js | 197 ++++++++++++++++------ lib/validation.js | 22 +++ lib/websocket.js | 76 +++++++-- test/receiver.test.js | 40 ++++- test/sender.test.js | 142 ++++++++++++++-- test/websocket.test.js | 363 ++++++++++++++++++++++++++++++++++++++++- 9 files changed, 775 insertions(+), 93 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index 1189fd02a..f30ad4cae 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -466,10 +466,11 @@ does nothing if `type` is not one of `'close'`, `'error'`, `'message'`, or - {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 @@ -538,7 +539,8 @@ is a noop if the ready state is `CONNECTING` or `CLOSED`. ### websocket.ping([data[, mask]][, callback]) -- `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The +- `data` + {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray|Blob} The data to send in the ping frame. - `mask` {Boolean} Specifies whether `data` should be masked or not. Defaults to `true` when `websocket` is not a server client. @@ -550,7 +552,8 @@ Send a ping. This method throws an error if the ready state is `CONNECTING`. ### websocket.pong([data[, mask]][, callback]) -- `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The +- `data` + {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray|Blob} The data to send in the pong frame. - `mask` {Boolean} Specifies whether `data` should be masked or not. Defaults to `true` when `websocket` is not a server client. @@ -588,7 +591,8 @@ only removes listeners added with ### websocket.send(data[, options][, callback]) -- `data` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} The +- `data` + {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray|Blob} The data to send. `Object` values are only supported if they conform to the requirements of [`Buffer.from()`][]. If those constraints are not met, a `TypeError` is thrown. diff --git a/lib/constants.js b/lib/constants.js index d691b30a1..74214d466 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -1,9 +1,15 @@ 'use strict'; +const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments']; +const hasBlob = typeof Blob !== 'undefined'; + +if (hasBlob) BINARY_TYPES.push('blob'); + module.exports = { - BINARY_TYPES: ['nodebuffer', 'arraybuffer', 'fragments'], + BINARY_TYPES, EMPTY_BUFFER: Buffer.alloc(0), GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + hasBlob, kForOnEventAttribute: Symbol('kIsForOnEventAttribute'), kListener: Symbol('kListener'), kStatusCode: Symbol('status-code'), diff --git a/lib/receiver.js b/lib/receiver.js index 70dfd9933..54d9b4fad 100644 --- a/lib/receiver.js +++ b/lib/receiver.js @@ -559,6 +559,8 @@ class Receiver extends Writable { data = concat(fragments, messageLength); } else if (this._binaryType === 'arraybuffer') { data = toArrayBuffer(concat(fragments, messageLength)); + } else if (this._binaryType === 'blob') { + data = new Blob(fragments); } else { data = fragments; } diff --git a/lib/sender.js b/lib/sender.js index c81ec66f6..ee16cea5a 100644 --- a/lib/sender.js +++ b/lib/sender.js @@ -6,8 +6,8 @@ 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 kByteLength = Symbol('kByteLength'); @@ -16,6 +16,10 @@ 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. */ @@ -42,8 +46,10 @@ class Sender { this._compress = false; this._bufferedBytes = 0; - this._deflating = false; this._queue = []; + this._state = DEFAULT; + this.onerror = NOOP; + this[kWebSocket] = undefined; } /** @@ -210,7 +216,7 @@ class Sender { rsv1: false }; - if (this._deflating) { + if (this._state !== DEFAULT) { this.enqueue([this.dispatch, buf, false, options, cb]); } else { this.sendFrame(Sender.frame(buf, options), cb); @@ -232,6 +238,9 @@ class Sender { 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; @@ -253,7 +262,13 @@ class Sender { rsv1: false }; - if (this._deflating) { + 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.sendFrame(Sender.frame(data, options), cb); @@ -275,6 +290,9 @@ class Sender { 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; @@ -296,7 +314,13 @@ class Sender { rsv1: false }; - if (this._deflating) { + 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.sendFrame(Sender.frame(data, options), cb); @@ -330,6 +354,9 @@ class Sender { 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; @@ -357,40 +384,94 @@ class Sender { if (options.fin) this._firstFragment = true; - if (perMessageDeflate) { - const opts = { - [kByteLength]: byteLength, - fin: options.fin, - generateMask: this._generateMask, - mask: options.mask, - maskBuffer: this._maskBuffer, - opcode, - readOnly, - rsv1 - }; - - if (this._deflating) { - this.enqueue([this.dispatch, data, this._compress, opts, cb]); + 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(data, 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(data, { - [kByteLength]: byteLength, - fin: options.fin, - generateMask: this._generateMask, - mask: options.mask, - maskBuffer: this._maskBuffer, - opcode, - readOnly, - rsv1: false - }), - cb - ); + this.dispatch(data, this._compress, opts, cb); } } + /** + * 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. * @@ -423,27 +504,19 @@ class Sender { const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; this._bufferedBytes += options[kByteLength]; - this._deflating = true; + 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 params = this._queue[i]; - const callback = params[params.length - 1]; - - if (typeof callback === 'function') callback(err); - } - + callCallbacks(this, err, cb); return; } this._bufferedBytes -= options[kByteLength]; - this._deflating = false; + this._state = DEFAULT; options.readOnly = false; this.sendFrame(Sender.frame(buf, options), cb); this.dequeue(); @@ -456,7 +529,7 @@ 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[3][kByteLength]; @@ -495,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/validation.js b/lib/validation.js index c352e6ea7..4a2e68d51 100644 --- a/lib/validation.js +++ b/lib/validation.js @@ -2,6 +2,8 @@ const { isUtf8 } = require('buffer'); +const { hasBlob } = require('./constants'); + // // Allowed token characters: // @@ -107,7 +109,27 @@ function _isValidUTF8(buf) { 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 diff --git a/lib/websocket.js b/lib/websocket.js index aa57bbade..7fb402970 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -14,6 +14,8 @@ const { URL } = require('url'); const PerMessageDeflate = require('./permessage-deflate'); const Receiver = require('./receiver'); const Sender = require('./sender'); +const { isBlob } = require('./validation'); + const { BINARY_TYPES, EMPTY_BUFFER, @@ -58,6 +60,7 @@ class WebSocket extends EventEmitter { this._closeFrameSent = false; this._closeMessage = EMPTY_BUFFER; this._closeTimer = null; + this._errorEmitted = false; this._extensions = {}; this._paused = false; this._protocol = ''; @@ -90,9 +93,8 @@ class WebSocket extends EventEmitter { } /** - * 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} */ @@ -213,11 +215,14 @@ class WebSocket extends EventEmitter { skipUTF8Validation: options.skipUTF8Validation }); - this._sender = new Sender(socket, this._extensions, options.generateMask); + const sender = new Sender(socket, this._extensions, options.generateMask); + this._receiver = receiver; + this._sender = sender; this._socket = socket; receiver[kWebSocket] = this; + sender[kWebSocket] = this; socket[kWebSocket] = this; receiver.on('conclude', receiverOnConclude); @@ -227,6 +232,8 @@ class WebSocket extends EventEmitter { receiver.on('ping', receiverOnPing); receiver.on('pong', receiverOnPong); + sender.onerror = senderOnError; + // // These methods may not be available if `socket` is just a `Duplex`. // @@ -322,13 +329,7 @@ class WebSocket extends EventEmitter { } }); - // - // Specify a timeout for the closing handshake to complete. - // - this._closeTimer = setTimeout( - this._socket.destroy.bind(this._socket), - closeTimeout - ); + setCloseTimer(this); } /** @@ -1032,6 +1033,11 @@ function initAsClient(websocket, address, protocols, options) { */ 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(); } @@ -1112,7 +1118,7 @@ function abortHandshake(websocket, stream, message) { */ function sendAfterClose(websocket, data, cb) { if (data) { - const length = toBuffer(data).length; + const length = isBlob(data) ? data.size : toBuffer(data).length; // // The `_bufferedAmount` property is used only when the peer is a client and @@ -1188,7 +1194,10 @@ function receiverOnError(err) { websocket.close(err[kStatusCode]); } - websocket.emit('error', err); + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } } /** @@ -1244,6 +1253,47 @@ function resume(stream) { stream.resume(); } +/** + * The `Sender` error event handler. + * + * @param {Error} The error + * @private + */ +function senderOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket.readyState === WebSocket.CLOSED) return; + if (websocket.readyState === WebSocket.OPEN) { + websocket._readyState = WebSocket.CLOSING; + setCloseTimer(websocket); + } + + // + // `socket.end()` is used instead of `socket.destroy()` to allow the other + // peer to finish sending queued data. There is no need to set a timer here + // because `CLOSING` means that it is already set or not needed. + // + this._socket.end(); + + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } +} + +/** + * Set a timer to destroy the underlying raw socket of a WebSocket. + * + * @param {WebSocket} websocket The WebSocket instance + * @private + */ +function setCloseTimer(websocket) { + websocket._closeTimer = setTimeout( + websocket._socket.destroy.bind(websocket._socket), + closeTimeout + ); +} + /** * The listener of the socket `'close'` event. * diff --git a/test/receiver.test.js b/test/receiver.test.js index 1f9e75d3a..243a91606 100644 --- a/test/receiver.test.js +++ b/test/receiver.test.js @@ -7,7 +7,7 @@ const EventEmitter = require('events'); const PerMessageDeflate = require('../lib/permessage-deflate'); const Receiver = require('../lib/receiver'); const Sender = require('../lib/sender'); -const { EMPTY_BUFFER, kStatusCode } = require('../lib/constants'); +const { EMPTY_BUFFER, hasBlob, kStatusCode } = require('../lib/constants'); describe('Receiver', () => { it('parses an unmasked text message', (done) => { @@ -1061,6 +1061,44 @@ describe('Receiver', () => { }); }); + 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 }); diff --git a/test/sender.test.js b/test/sender.test.js index 532239fa1..df9057e8a 100644 --- a/test/sender.test.js +++ b/test/sender.test.js @@ -5,7 +5,7 @@ const assert = require('assert'); const extension = require('../lib/extension'); const PerMessageDeflate = require('../lib/permessage-deflate'); const Sender = require('../lib/sender'); -const { EMPTY_BUFFER } = require('../lib/constants'); +const { EMPTY_BUFFER, hasBlob } = require('../lib/constants'); class MockSocket { constructor({ write } = {}) { @@ -250,17 +250,15 @@ describe('Sender', () => { }); describe('#ping', () => { - it('works with multiple types of data', (done) => { + 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]))); - } else if (count < 8) { - assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + if (count === 3) { + assert.deepStrictEqual(data, Buffer.from([0x89, 0x02])); } else { assert.strictEqual(data, 'hi'); done(); @@ -273,27 +271,81 @@ describe('Sender', () => { perMessageDeflate.accept([{}]); + sender.send('foo', { compress: true, fin: true }); + sender.ping('hi', false); + }); + + it('can send a `TypedArray` as ping payload', (done) => { + let count = 0; + const mockSocket = new MockSocket({ + 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); 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); + }); + + 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([0x89, 0x02])); + } else { + assert.deepStrictEqual(data, Buffer.from([0x68, 0x69])); + done(); + } + } + }); + + const sender = new Sender(mockSocket); + const array = new Uint8Array([0x68, 0x69]); + + sender.ping(array.buffer, 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([0x89, 0x02])); + } else { + assert.deepStrictEqual(data, Buffer.from([0x68, 0x69])); + if (count === 4) done(); + } + } + }); + + const sender = new Sender(mockSocket); + const blob = new Blob(['hi']); + + sender.ping(blob, false); + sender.ping(blob, false); }); }); describe('#pong', () => { - it('works with multiple types of data', (done) => { + 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([0x8a, 0x02]))); - } else if (count < 8) { - assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + if (count === 3) { + assert.deepStrictEqual(data, Buffer.from([0x8a, 0x02])); } else { assert.strictEqual(data, 'hi'); done(); @@ -306,12 +358,68 @@ describe('Sender', () => { perMessageDeflate.accept([{}]); + sender.send('foo', { compress: true, fin: true }); + sender.pong('hi', false); + }); + + it('can send a `TypedArray` 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 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.pong(array.buffer, 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); }); }); diff --git a/test/websocket.test.js b/test/websocket.test.js index 8a05f073b..811a3e15b 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -22,7 +22,13 @@ const { Event, MessageEvent } = require('../lib/event-target'); -const { EMPTY_BUFFER, GUID, kListener, NOOP } = require('../lib/constants'); +const { + EMPTY_BUFFER, + GUID, + hasBlob, + kListener, + NOOP +} = require('../lib/constants'); const highWaterMark = getDefaultHighWaterMark ? getDefaultHighWaterMark(false) @@ -617,7 +623,7 @@ describe('WebSocket', () => { }); 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; @@ -655,6 +661,200 @@ describe('WebSocket', () => { }); }); + it("emits an 'error' event if an error occurs (2/2)", function (done) { + if (!fs.openAsBlob) return this.skip(); + + const randomString = crypto.randomBytes(16).toString('hex'); + const file = path.join(os.tmpdir(), `ws-${randomString}.txt`); + + fs.writeFileSync(file, 'x'.repeat(64)); + + fs.openAsBlob(file) + .then((blob) => { + fs.writeFileSync(file, 'x'.repeat(32)); + runTest(blob); + }) + .catch(done); + + function runTest(blob) { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + ws.send(blob); + + ws.on('error', (err) => { + try { + assert.ok(err instanceof DOMException); + assert.strictEqual(err.name, 'NotReadableError'); + assert.strictEqual(err.message, 'The blob could not be read'); + } finally { + fs.unlinkSync(file); + } + + ws.on('close', () => { + wss.close(done); + }); + }); + }); + } + }); + + it("emits the 'error' event only once (1/2)", function (done) { + if (!fs.openAsBlob) return this.skip(); + + const randomString = crypto.randomBytes(16).toString('hex'); + const file = path.join(os.tmpdir(), `ws-${randomString}.txt`); + + fs.writeFileSync(file, 'x'.repeat(64)); + + fs.openAsBlob(file) + .then((blob) => { + fs.writeFileSync(file, 'x'.repeat(32)); + runTest(blob); + }) + .catch(done); + + function runTest(blob) { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send('foo'); + ws.send(blob); + }); + + ws.on('error', (err) => { + try { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + } finally { + fs.unlinkSync(file); + } + + ws.on('close', () => { + wss.close(done); + }); + }); + } + ); + + wss.on('connection', (ws) => { + ws._socket.write(Buffer.from([0x85, 0x00])); + }); + } + }); + + it("emits the 'error' event only once (2/2)", function (done) { + if (!fs.openAsBlob) return this.skip(); + + const randomString = crypto.randomBytes(16).toString('hex'); + const file = path.join(os.tmpdir(), `ws-${randomString}.txt`); + + fs.writeFileSync(file, 'x'.repeat(64)); + + fs.openAsBlob(file) + .then((blob) => { + fs.writeFileSync(file, 'x'.repeat(32)); + runTest(blob); + }) + .catch(done); + + function runTest(blob) { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(blob); + }); + + ws.on('error', (err) => { + try { + assert.ok(err instanceof DOMException); + assert.strictEqual(err.name, 'NotReadableError'); + assert.strictEqual(err.message, 'The blob could not be read'); + } finally { + fs.unlinkSync(file); + } + + ws.on('close', () => { + wss.close(done); + }); + }); + } + ); + + wss.on('connection', (ws) => { + const buf = Buffer.from('c10100'.repeat(5) + '8500', 'hex'); + + ws._socket.write(buf); + }); + } + }); + + it("does not emit 'error' after 'close'", function (done) { + if (!fs.openAsBlob) return this.skip(); + + const randomString = crypto.randomBytes(16).toString('hex'); + const file = path.join(os.tmpdir(), `ws-${randomString}.bin`); + + fs.writeFileSync(file, crypto.randomBytes(1024 * 1024)); + fs.openAsBlob(file).then(runTest).catch(done); + + function runTest(blob) { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(blob, (err) => { + try { + assert.ok(err instanceof DOMException); + assert.strictEqual(err.name, 'NotReadableError'); + assert.strictEqual(err.message, 'The blob could not be read'); + } catch (e) { + ws.removeListener(onClose); + throw e; + } finally { + fs.unlinkSync(file); + } + + wss.close(done); + }); + }); + + ws.on('error', () => { + done(new Error("Unexpected 'error' event")); + }); + ws.on('close', onClose); + + function onClose() { + fs.writeFileSync(file, crypto.randomBytes(32)); + } + }); + + wss.on('connection', (ws) => { + ws._socket.end(); + }); + } + }); + it('does not re-emit `net.Socket` errors', function (done) { // // `socket.resetAndDestroy()` is not available in Node.js < 16.17.0. @@ -2120,6 +2320,11 @@ describe('WebSocket', () => { ws.ping(); assert.strictEqual(ws.bufferedAmount, 4); + if (hasBlob) { + ws.ping(new Blob(['hi'])); + assert.strictEqual(ws.bufferedAmount, 6); + } + done(); }); }); @@ -2289,6 +2494,11 @@ describe('WebSocket', () => { ws.pong(); assert.strictEqual(ws.bufferedAmount, 4); + if (hasBlob) { + ws.pong(new Blob(['hi'])); + assert.strictEqual(ws.bufferedAmount, 6); + } + done(); }); }); @@ -2533,6 +2743,11 @@ describe('WebSocket', () => { ws.send(); assert.strictEqual(ws.bufferedAmount, 4); + if (hasBlob) { + ws.send(new Blob(['hi'])); + assert.strictEqual(ws.bufferedAmount, 6); + } + done(); }); }); @@ -2750,6 +2965,39 @@ describe('WebSocket', () => { }); }); + 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); + }); + }); + }); + it('calls the callback when data is written out', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`); @@ -2767,6 +3015,48 @@ describe('WebSocket', () => { }); }); + 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}`); @@ -3671,25 +3961,41 @@ 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', () => { - ws.close(); - wss.close(done); + if (hasBlob) testType('blob', close); + else close(); }); }); }); @@ -4214,7 +4520,7 @@ describe('WebSocket', () => { ws.on('open', () => { ws._receiver.on('conclude', () => { - assert.ok(ws._sender._deflating); + assert.strictEqual(ws._sender._state, 1); }); ws.send('foo'); @@ -4392,6 +4698,47 @@ describe('WebSocket', () => { }); }); + it('can send a `Blob`', function (done) { + if (!hasBlob) return this.skip(); + + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + const messages = []; + + ws.on('open', () => { + ws.send(new Blob(['foo'])); + ws.send(new Blob(['bar'])); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + messages.push(message.toString()); + + if (messages.length === 2) { + assert.deepStrictEqual(messages, ['foo', 'bar']); + wss.close(done); + } + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message); + }); + }); + }); + it('ignores the `compress` option if the extension is disabled', (done) => { const wss = new WebSocket.Server({ port: 0 }, () => { const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { From 976c53c4065c49ede73bfba824caf5a6e0f290cb Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 3 Jul 2024 18:36:56 +0200 Subject: [PATCH 207/207] [dist] 8.18.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4abcf2989..4f7155deb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.17.1", + "version": "8.18.0", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi",