From 15bdb5f6e96a2769157c746528ccb9490539de96 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 17 Mar 2018 18:38:24 +0100 Subject: [PATCH] [feature] Allow all options accepted by `http{,s}.request()` Do not use an agent by default and add ability to use all options allowed in `http.request()` and `https.request()`. --- doc/ws.md | 20 +--- lib/websocket.js | 203 +++++++++++++++++------------------------ test/websocket.test.js | 73 --------------- 3 files changed, 89 insertions(+), 207 deletions(-) diff --git a/doc/ws.md b/doc/ws.md index cc634ad98..6f1546bb2 100644 --- a/doc/ws.md +++ b/doc/ws.md @@ -186,28 +186,12 @@ This class represents a WebSocket. It extends the `EventEmitter`. - `address` {String|url.Url|url.URL} The URL to which to connect. - `protocols` {String|Array} The list of subprotocols. - `options` {Object} - - `protocol` {String} Value of the `Sec-WebSocket-Protocol` header. - `handshakeTimeout` {Number} Timeout in milliseconds for the handshake request. - `perMessageDeflate` {Boolean|Object} Enable/disable permessage-deflate. - - `localAddress` {String} Local interface to bind for network connections. - `protocolVersion` {Number} Value of the `Sec-WebSocket-Version` header. - - `headers` {Object} An object with custom headers to send along with the - request. - `origin` {String} Value of the `Origin` or `Sec-WebSocket-Origin` header depending on the `protocolVersion`. - - `agent` {http.Agent|https.Agent} Use the specified Agent. - - `host` {String} Value of the `Host` header. - - `family` {Number} IP address family to use during hostname lookup (4 or 6). - - `checkServerIdentity` {Function} A function to validate the server hostname. - - `rejectUnauthorized` {Boolean} Verify or not the server certificate. - - `passphrase` {String} The passphrase for the private key or pfx. - - `ecdhCurve` {String} A named curve or a colon separated list of curve NIDs - or names to use for ECDH key agreement. - - `ciphers` {String} The ciphers to use or exclude - - `cert` {String|Array|Buffer} The certificate key. - - `key` {String|Array|Buffer} The private key. - - `pfx` {String|Buffer} The private key, certificate, and CA certs. - - `ca` {Array} Trusted certificates. + - Any other option allowed in [http.request()][] or [https.request()][]. `perMessageDeflate` default value is `true`. When using an object, parameters are the same of the server. The only difference is the direction of requests. @@ -425,3 +409,5 @@ The URL of the WebSocket server. Server clients don't have this attribute. [concurrency-limit]: https://github.com/websockets/ws/issues/1202 [permessage-deflate]: https://tools.ietf.org/html/draft-ietf-hybi-permessage-compression-19 [zlib-options]: https://nodejs.org/api/zlib.html#zlib_class_options +[http.request()]: https://nodejs.org/api/http.html#http_http_request_options_callback +[https.request()]: https://nodejs.org/api/https.html#https_https_request_options_callback diff --git a/lib/websocket.js b/lib/websocket.js index 53aa1ebc9..c603675a9 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -4,6 +4,8 @@ const EventEmitter = require('events'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); +const net = require('net'); +const tls = require('tls'); const url = require('url'); const PerMessageDeflate = require('./permessage-deflate'); @@ -50,13 +52,11 @@ class WebSocket extends EventEmitter { this._socket = null; if (address !== null) { - if (!protocols) { - protocols = []; - } else if (typeof protocols === 'string') { - protocols = [protocols]; - } else if (!Array.isArray(protocols)) { + if (Array.isArray(protocols)) { + protocols = protocols.join(', '); + } else if (typeof protocols === 'object' && protocols !== null) { options = protocols; - protocols = []; + protocols = undefined; } initAsClient.call(this, address, protocols, options); @@ -405,55 +405,30 @@ module.exports = WebSocket; * Initialize a WebSocket client. * * @param {(String|url.Url|url.URL)} address The URL to which to connect - * @param {String[]} protocols The list of subprotocols + * @param {String} protocols The subprotocols * @param {Object} options Connection options - * @param {String} options.protocol Value of the `Sec-WebSocket-Protocol` header * @param {(Boolean|Object)} options.perMessageDeflate Enable/disable permessage-deflate * @param {Number} options.handshakeTimeout Timeout in milliseconds for the handshake request - * @param {String} options.localAddress Local interface to bind for network connections * @param {Number} options.protocolVersion Value of the `Sec-WebSocket-Version` header - * @param {Object} options.headers An object containing request headers * @param {String} options.origin Value of the `Origin` or `Sec-WebSocket-Origin` header - * @param {http.Agent} options.agent Use the specified Agent - * @param {String} options.host Value of the `Host` header - * @param {Number} options.family IP address family to use during hostname lookup (4 or 6). - * @param {Function} options.checkServerIdentity A function to validate the server hostname - * @param {Boolean} options.rejectUnauthorized Verify or not the server certificate - * @param {String} options.passphrase The passphrase for the private key or pfx - * @param {String} options.ciphers The ciphers to use or exclude - * @param {String} options.ecdhCurve The curves for ECDH key agreement to use or exclude - * @param {(String|String[]|Buffer|Buffer[])} options.cert The certificate key - * @param {(String|String[]|Buffer|Buffer[])} options.key The private key - * @param {(String|Buffer)} options.pfx The private key, certificate, and CA certs - * @param {(String|String[]|Buffer|Buffer[])} options.ca Trusted certificates * @private */ function initAsClient (address, protocols, options) { options = Object.assign({ protocolVersion: protocolVersions[1], - protocol: protocols.join(','), - perMessageDeflate: true, - handshakeTimeout: null, - localAddress: null, - headers: null, - family: null, - origin: null, - agent: null, - host: null, - - // - // SSL options. - // - checkServerIdentity: null, - rejectUnauthorized: null, - passphrase: null, - ciphers: null, - ecdhCurve: null, - cert: null, - key: null, - pfx: null, - ca: null - }, options); + perMessageDeflate: true + }, options, { + createConnection: undefined, + socketPath: undefined, + hostname: undefined, + protocol: undefined, + timeout: undefined, + method: undefined, + auth: undefined, + host: undefined, + path: undefined, + port: undefined + }); if (protocolVersions.indexOf(options.protocolVersion) === -1) { throw new RangeError( @@ -464,114 +439,84 @@ function initAsClient (address, protocols, options) { this._isServer = false; - var serverUrl; + var parsedUrl; if (typeof address === 'object' && address.href !== undefined) { - serverUrl = address; + parsedUrl = address; this.url = address.href; } else { - serverUrl = url.parse(address); + parsedUrl = url.parse(address); this.url = address; } - const isUnixSocket = serverUrl.protocol === 'ws+unix:'; + const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; - if (!serverUrl.host && (!isUnixSocket || !serverUrl.pathname)) { + if (!parsedUrl.host && (!isUnixSocket || !parsedUrl.pathname)) { throw new Error(`Invalid URL: ${this.url}`); } - const isSecure = serverUrl.protocol === 'wss:' || serverUrl.protocol === 'https:'; + const isSecure = parsedUrl.protocol === 'wss:' || parsedUrl.protocol === 'https:'; const key = crypto.randomBytes(16).toString('base64'); const httpObj = isSecure ? https : http; - const path = serverUrl.search - ? `${serverUrl.pathname || '/'}${serverUrl.search}` - : serverUrl.pathname || '/'; + const path = parsedUrl.search + ? `${parsedUrl.pathname || '/'}${parsedUrl.search}` + : parsedUrl.pathname || '/'; var perMessageDeflate; - const requestOptions = { - port: serverUrl.port || (isSecure ? 443 : 80), - host: serverUrl.hostname, - path: path, - headers: { - 'Sec-WebSocket-Version': options.protocolVersion, - 'Sec-WebSocket-Key': key, - 'Connection': 'Upgrade', - 'Upgrade': 'websocket' - } - }; + options.createConnection = isSecure ? tlsConnect : netConnect; + options.port = parsedUrl.port || (isSecure ? 443 : 80); + options.host = parsedUrl.hostname; + options.headers = Object.assign({ + 'Sec-WebSocket-Version': options.protocolVersion, + 'Sec-WebSocket-Key': key, + 'Connection': 'Upgrade', + 'Upgrade': 'websocket' + }, options.headers); + options.path = path; - if (options.headers) Object.assign(requestOptions.headers, options.headers); if (options.perMessageDeflate) { perMessageDeflate = new PerMessageDeflate( options.perMessageDeflate !== true ? options.perMessageDeflate : {}, false ); - requestOptions.headers['Sec-WebSocket-Extensions'] = extension.format({ + options.headers['Sec-WebSocket-Extensions'] = extension.format({ [PerMessageDeflate.extensionName]: perMessageDeflate.offer() }); } - if (options.protocol) { - requestOptions.headers['Sec-WebSocket-Protocol'] = options.protocol; + if (protocols) { + options.headers['Sec-WebSocket-Protocol'] = protocols; } if (options.origin) { if (options.protocolVersion < 13) { - requestOptions.headers['Sec-WebSocket-Origin'] = options.origin; + options.headers['Sec-WebSocket-Origin'] = options.origin; } else { - requestOptions.headers.Origin = options.origin; + options.headers.Origin = options.origin; } } - if (options.host) requestOptions.headers.Host = options.host; - if (serverUrl.auth) requestOptions.auth = serverUrl.auth; - else if (serverUrl.username || serverUrl.password) { - requestOptions.auth = `${serverUrl.username}:${serverUrl.password}`; + if (parsedUrl.auth) { + options.auth = parsedUrl.auth; + } else if (parsedUrl.username || parsedUrl.password) { + options.auth = `${parsedUrl.username}:${parsedUrl.password}`; } - if (options.localAddress) requestOptions.localAddress = options.localAddress; - if (options.family) requestOptions.family = options.family; - if (isUnixSocket) { const parts = path.split(':'); - requestOptions.socketPath = parts[0]; - requestOptions.path = parts[1]; - } - - var agent = options.agent; - - // - // A custom agent is required for these options. - // - if ( - options.rejectUnauthorized != null || - options.checkServerIdentity || - options.passphrase || - options.ciphers || - options.ecdhCurve || - options.cert || - options.key || - options.pfx || - options.ca - ) { - if (options.passphrase) requestOptions.passphrase = options.passphrase; - if (options.ciphers) requestOptions.ciphers = options.ciphers; - if (options.ecdhCurve) requestOptions.ecdhCurve = options.ecdhCurve; - if (options.cert) requestOptions.cert = options.cert; - if (options.key) requestOptions.key = options.key; - if (options.pfx) requestOptions.pfx = options.pfx; - if (options.ca) requestOptions.ca = options.ca; - if (options.checkServerIdentity) { - requestOptions.checkServerIdentity = options.checkServerIdentity; - } - if (options.rejectUnauthorized != null) { - requestOptions.rejectUnauthorized = options.rejectUnauthorized; + if (options.agent == null && process.versions.modules < 57) { + // + // Setting `socketPath` in conjunction with `createConnection` without an + // agent throws an error on Node.js < 8. Work around the issue by using a + // different property. + // + options._socketPath = parts[0]; + } else { + options.socketPath = parts[0]; } - if (!agent) agent = new httpObj.Agent(requestOptions); + options.path = parts[1]; } - if (agent) requestOptions.agent = agent; - - var req = this._req = httpObj.get(requestOptions); + var req = this._req = httpObj.get(options); if (options.handshakeTimeout) { req.setTimeout( @@ -616,12 +561,12 @@ function initAsClient (address, protocols, options) { } const serverProt = res.headers['sec-websocket-protocol']; - const protList = (options.protocol || '').split(/, */); + const protList = (protocols || '').split(/, */); var protError; - if (!options.protocol && serverProt) { + if (!protocols && serverProt) { protError = 'Server sent a subprotocol but none was requested'; - } else if (options.protocol && !serverProt) { + } else if (protocols && !serverProt) { protError = 'Server sent no subprotocol'; } else if (serverProt && protList.indexOf(serverProt) === -1) { protError = 'Server sent an invalid subprotocol'; @@ -656,6 +601,30 @@ function initAsClient (address, protocols, options) { }); } +/** + * Create a `net.Socket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {net.Socket} The newly created socket used to start the connection + * @private + */ +function netConnect (options) { + options.path = options.socketPath || options._socketPath || undefined; + return net.connect(options); +} + +/** + * Create a `tls.TLSSocket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {tls.TLSSocket} The newly created socket used to start the connection + * @private + */ +function tlsConnect (options) { + options.path = options.socketPath || options._socketPath || undefined; + return tls.connect(options); +} + /** * Abort the handshake and emit an error. * diff --git a/test/websocket.test.js b/test/websocket.test.js index 79d0addec..a33187480 100644 --- a/test/websocket.test.js +++ b/test/websocket.test.js @@ -6,10 +6,8 @@ const assert = require('assert'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); -const dns = require('dns'); const url = require('url'); const fs = require('fs'); -const os = require('os'); const constants = require('../lib/constants'); const WebSocket = require('..'); @@ -52,16 +50,6 @@ describe('WebSocket', function () { }); describe('options', function () { - it('accepts an `agent` option', function (done) { - const agent = new CustomAgent(); - - agent.addRequest = () => { - done(); - }; - - const ws = new WebSocket('ws://localhost', { agent }); - }); - it('accepts the `options` object as 3rd argument', function () { const agent = new CustomAgent(); let count = 0; @@ -84,67 +72,6 @@ describe('WebSocket', function () { /^RangeError: Unsupported protocol version: 1000 \(supported versions: 8, 13\)$/ ); }); - - it('accepts the `localAddress` option', function (done) { - const wss = new WebSocket.Server({ host: '127.0.0.1', port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - localAddress: '127.0.0.2' - }); - - ws.on('error', (err) => { - wss.close(() => { - // - // Skip this test on machines where 127.0.0.2 is disabled. - // - if (err.code === 'EADDRNOTAVAIL') return this.skip(); - - done(err); - }); - }); - }); - - wss.on('connection', (ws, req) => { - assert.strictEqual(req.connection.remoteAddress, '127.0.0.2'); - wss.close(done); - }); - }); - - it('accepts the `family` option', function (done) { - const re = process.platform === 'win32' ? /Loopback Pseudo-Interface/ : /lo/; - const ifaces = os.networkInterfaces(); - const hasIPv6 = Object.keys(ifaces).some((name) => { - return re.test(name) && ifaces[name].some((info) => info.family === 'IPv6'); - }); - - // - // Skip this test on machines where IPv6 is not supported. - // - if (!hasIPv6) return this.skip(); - - dns.lookup('localhost', { family: 6, all: true }, (err, addresses) => { - // - // Skip this test if localhost does not resolve to ::1. - // - if (err) { - return err.code === 'ENOTFOUND' || err.code === 'EAI_AGAIN' - ? this.skip() - : done(err); - } - - if (!addresses.some((val) => val.address === '::1')) return this.skip(); - - const wss = new WebSocket.Server({ host: '::1', port: 0 }, () => { - const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { - family: 6 - }); - }); - - wss.on('connection', (ws, req) => { - assert.strictEqual(req.connection.remoteAddress, '::1'); - wss.close(done); - }); - }); - }); }); });