/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Readable$" }] */ 'use strict'; const EventEmitter = require('events'); const https = require('https'); const http = require('http'); const net = require('net'); const tls = require('tls'); const { randomBytes, createHash } = require('crypto'); const { Readable } = require('stream'); const { URL } = require('url'); const PerMessageDeflate = require('./permessage-deflate'); const Receiver = require('./receiver'); const Sender = require('./sender'); const { BINARY_TYPES, EMPTY_BUFFER, GUID, kStatusCode, kWebSocket, NOOP } = require('./constants'); const { addEventListener, removeEventListener } = require('./event-target'); const { format, parse } = require('./extension'); const { toBuffer } = require('./buffer-util'); const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; const protocolVersions = [8, 13]; const closeTimeout = 30 * 1000; /** * Class representing a WebSocket. * * @extends EventEmitter */ class WebSocket extends EventEmitter { /** * Create a new `WebSocket`. * * @param {(String|URL)} address The URL to which to connect * @param {(String|String[])} [protocols] The subprotocols * @param {Object} [options] Connection options */ constructor(address, protocols, options) { super(); this._binaryType = BINARY_TYPES[0]; this._closeCode = 1006; this._closeFrameReceived = false; this._closeFrameSent = false; this._closeMessage = ''; this._closeTimer = null; this._extensions = {}; this._protocol = ''; this._readyState = WebSocket.CONNECTING; this._receiver = null; this._sender = null; this._socket = null; if (address !== null) { this._bufferedAmount = 0; this._isServer = false; this._redirects = 0; if (Array.isArray(protocols)) { protocols = protocols.join(', '); } else if (typeof protocols === 'object' && protocols !== null) { options = protocols; protocols = undefined; } initAsClient(this, address, protocols, options); } else { this._isServer = true; } } /** * This deviates from the WHATWG interface since ws doesn't support the * required default "blob" type (instead we define a custom "nodebuffer" * type). * * @type {String} */ get binaryType() { return this._binaryType; } set binaryType(type) { if (!BINARY_TYPES.includes(type)) return; this._binaryType = type; // // Allow to change `binaryType` on the fly. // if (this._receiver) this._receiver._binaryType = type; } /** * @type {Number} */ get bufferedAmount() { if (!this._socket) return this._bufferedAmount; return this._socket._writableState.length + this._sender._bufferedBytes; } /** * @type {String} */ get extensions() { return Object.keys(this._extensions).join(); } /** * @type {Function} */ /* istanbul ignore next */ get onclose() { return undefined; } /* istanbul ignore next */ set onclose(listener) {} /** * @type {Function} */ /* istanbul ignore next */ get onerror() { return undefined; } /* istanbul ignore next */ set onerror(listener) {} /** * @type {Function} */ /* istanbul ignore next */ get onopen() { return undefined; } /* istanbul ignore next */ set onopen(listener) {} /** * @type {Function} */ /* istanbul ignore next */ get onmessage() { return undefined; } /* istanbul ignore next */ set onmessage(listener) {} /** * @type {String} */ get protocol() { return this._protocol; } /** * @type {Number} */ get readyState() { return this._readyState; } /** * @type {String} */ get url() { return this._url; } /** * Set up the socket and the internal resources. * * @param {(net.Socket|tls.Socket)} socket The network socket between the * server and client * @param {Buffer} head The first packet of the upgraded stream * @param {Number} [maxPayload=0] The maximum allowed message size * @private */ setSocket(socket, head, maxPayload) { const receiver = new Receiver( this.binaryType, this._extensions, this._isServer, maxPayload ); this._sender = new Sender(socket, this._extensions); this._receiver = receiver; this._socket = socket; receiver[kWebSocket] = this; socket[kWebSocket] = this; receiver.on('conclude', receiverOnConclude); receiver.on('drain', receiverOnDrain); receiver.on('error', receiverOnError); receiver.on('message', receiverOnMessage); receiver.on('ping', receiverOnPing); receiver.on('pong', receiverOnPong); socket.setTimeout(0); socket.setNoDelay(); if (head.length > 0) socket.unshift(head); socket.on('close', socketOnClose); socket.on('data', socketOnData); socket.on('end', socketOnEnd); socket.on('error', socketOnError); this._readyState = WebSocket.OPEN; this.emit('open'); } /** * Emit the `'close'` event. * * @private */ emitClose() { if (!this._socket) { this._readyState = WebSocket.CLOSED; this.emit('close', this._closeCode, this._closeMessage); return; } if (this._extensions[PerMessageDeflate.extensionName]) { this._extensions[PerMessageDeflate.extensionName].cleanup(); } this._receiver.removeAllListeners(); this._readyState = WebSocket.CLOSED; this.emit('close', this._closeCode, this._closeMessage); } /** * Start a closing handshake. * * +----------+ +-----------+ +----------+ * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - * | +----------+ +-----------+ +----------+ | * +----------+ +-----------+ | * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING * +----------+ +-----------+ | * | | | +---+ | * +------------------------+-->|fin| - - - - * | +---+ | +---+ * - - - - -|fin|<---------------------+ * +---+ * * @param {Number} [code] Status code explaining why the connection is closing * @param {String} [data] A string explaining why the connection is closing * @public */ close(code, data) { if (this.readyState === WebSocket.CLOSED) return; if (this.readyState === WebSocket.CONNECTING) { const msg = 'WebSocket was closed before the connection was established'; return abortHandshake(this, this._req, msg); } if (this.readyState === WebSocket.CLOSING) { if ( this._closeFrameSent && (this._closeFrameReceived || this._receiver._writableState.errorEmitted) ) { this._socket.end(); } return; } this._readyState = WebSocket.CLOSING; this._sender.close(code, data, !this._isServer, (err) => { // // This error is handled by the `'error'` listener on the socket. We only // want to know if the close frame has been sent here. // if (err) return; this._closeFrameSent = true; if ( this._closeFrameReceived || this._receiver._writableState.errorEmitted ) { this._socket.end(); } }); // // Specify a timeout for the closing handshake to complete. // this._closeTimer = setTimeout( this._socket.destroy.bind(this._socket), closeTimeout ); } /** * Send a ping. * * @param {*} [data] The data to send * @param {Boolean} [mask] Indicates whether or not to mask `data` * @param {Function} [cb] Callback which is executed when the ping is sent * @public */ ping(data, mask, cb) { if (this.readyState === WebSocket.CONNECTING) { throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); } if (typeof data === 'function') { cb = data; data = mask = undefined; } else if (typeof mask === 'function') { cb = mask; mask = undefined; } if (typeof data === 'number') data = data.toString(); if (this.readyState !== WebSocket.OPEN) { sendAfterClose(this, data, cb); return; } if (mask === undefined) mask = !this._isServer; this._sender.ping(data || EMPTY_BUFFER, mask, cb); } /** * Send a pong. * * @param {*} [data] The data to send * @param {Boolean} [mask] Indicates whether or not to mask `data` * @param {Function} [cb] Callback which is executed when the pong is sent * @public */ pong(data, mask, cb) { if (this.readyState === WebSocket.CONNECTING) { throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); } if (typeof data === 'function') { cb = data; data = mask = undefined; } else if (typeof mask === 'function') { cb = mask; mask = undefined; } if (typeof data === 'number') data = data.toString(); if (this.readyState !== WebSocket.OPEN) { sendAfterClose(this, data, cb); return; } if (mask === undefined) mask = !this._isServer; this._sender.pong(data || EMPTY_BUFFER, mask, cb); } /** * Send a data message. * * @param {*} data The message to send * @param {Object} [options] Options object * @param {Boolean} [options.compress] Specifies whether or not to compress * `data` * @param {Boolean} [options.binary] Specifies whether `data` is binary or * text * @param {Boolean} [options.fin=true] Specifies whether the fragment is the * last one * @param {Boolean} [options.mask] Specifies whether or not to mask `data` * @param {Function} [cb] Callback which is executed when data is written out * @public */ send(data, options, cb) { if (this.readyState === WebSocket.CONNECTING) { throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); } if (typeof options === 'function') { cb = options; options = {}; } if (typeof data === 'number') data = data.toString(); if (this.readyState !== WebSocket.OPEN) { sendAfterClose(this, data, cb); return; } const opts = { binary: typeof data !== 'string', mask: !this._isServer, compress: true, fin: true, ...options }; if (!this._extensions[PerMessageDeflate.extensionName]) { opts.compress = false; } this._sender.send(data || EMPTY_BUFFER, opts, cb); } /** * Forcibly close the connection. * * @public */ terminate() { if (this.readyState === WebSocket.CLOSED) return; if (this.readyState === WebSocket.CONNECTING) { const msg = 'WebSocket was closed before the connection was established'; return abortHandshake(this, this._req, msg); } if (this._socket) { this._readyState = WebSocket.CLOSING; this._socket.destroy(); } } } /** * @constant {Number} CONNECTING * @memberof WebSocket */ Object.defineProperty(WebSocket, 'CONNECTING', { enumerable: true, value: readyStates.indexOf('CONNECTING') }); /** * @constant {Number} CONNECTING * @memberof WebSocket.prototype */ Object.defineProperty(WebSocket.prototype, 'CONNECTING', { enumerable: true, value: readyStates.indexOf('CONNECTING') }); /** * @constant {Number} OPEN * @memberof WebSocket */ Object.defineProperty(WebSocket, 'OPEN', { enumerable: true, value: readyStates.indexOf('OPEN') }); /** * @constant {Number} OPEN * @memberof WebSocket.prototype */ Object.defineProperty(WebSocket.prototype, 'OPEN', { enumerable: true, value: readyStates.indexOf('OPEN') }); /** * @constant {Number} CLOSING * @memberof WebSocket */ Object.defineProperty(WebSocket, 'CLOSING', { enumerable: true, value: readyStates.indexOf('CLOSING') }); /** * @constant {Number} CLOSING * @memberof WebSocket.prototype */ Object.defineProperty(WebSocket.prototype, 'CLOSING', { enumerable: true, value: readyStates.indexOf('CLOSING') }); /** * @constant {Number} CLOSED * @memberof WebSocket */ Object.defineProperty(WebSocket, 'CLOSED', { enumerable: true, value: readyStates.indexOf('CLOSED') }); /** * @constant {Number} CLOSED * @memberof WebSocket.prototype */ Object.defineProperty(WebSocket.prototype, 'CLOSED', { enumerable: true, value: readyStates.indexOf('CLOSED') }); [ 'binaryType', 'bufferedAmount', 'extensions', 'protocol', 'readyState', 'url' ].forEach((property) => { Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); }); // // Add the `onopen`, `onerror`, `onclose`, and `onmessage` attributes. // See https://html.spec.whatwg.org/multipage/comms.html#the-websocket-interface // ['open', 'error', 'close', 'message'].forEach((method) => { Object.defineProperty(WebSocket.prototype, `on${method}`, { enumerable: true, get() { const listeners = this.listeners(method); for (let i = 0; i < listeners.length; i++) { if (listeners[i]._listener) return listeners[i]._listener; } return undefined; }, set(listener) { const listeners = this.listeners(method); for (let i = 0; i < listeners.length; i++) { // // Remove only the listeners added via `addEventListener`. // if (listeners[i]._listener) this.removeListener(method, listeners[i]); } this.addEventListener(method, listener); } }); }); WebSocket.prototype.addEventListener = addEventListener; WebSocket.prototype.removeEventListener = removeEventListener; module.exports = WebSocket; /** * Initialize a WebSocket client. * * @param {WebSocket} websocket The client to initialize * @param {(String|URL)} address The URL to which to connect * @param {String} [protocols] The subprotocols * @param {Object} [options] Connection options * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable * permessage-deflate * @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 * @private */ function initAsClient(websocket, address, protocols, options) { const opts = { protocolVersion: protocolVersions[1], maxPayload: 100 * 1024 * 1024, perMessageDeflate: true, followRedirects: false, maxRedirects: 10, ...options, createConnection: undefined, socketPath: undefined, hostname: undefined, protocol: undefined, timeout: undefined, method: undefined, host: undefined, path: undefined, port: undefined }; if (!protocolVersions.includes(opts.protocolVersion)) { throw new RangeError( `Unsupported protocol version: ${opts.protocolVersion} ` + `(supported versions: ${protocolVersions.join(', ')})` ); } let parsedUrl; if (address instanceof URL) { parsedUrl = address; websocket._url = address.href; } else { parsedUrl = new URL(address); websocket._url = address; } const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; if (!parsedUrl.host && (!isUnixSocket || !parsedUrl.pathname)) { throw new Error(`Invalid URL: ${websocket.url}`); } 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; let perMessageDeflate; opts.createConnection = isSecure ? tlsConnect : netConnect; opts.defaultPort = opts.defaultPort || defaultPort; opts.port = parsedUrl.port || defaultPort; opts.host = parsedUrl.hostname.startsWith('[') ? parsedUrl.hostname.slice(1, -1) : parsedUrl.hostname; opts.headers = { 'Sec-WebSocket-Version': opts.protocolVersion, 'Sec-WebSocket-Key': key, Connection: 'Upgrade', Upgrade: 'websocket', ...opts.headers }; opts.path = parsedUrl.pathname + parsedUrl.search; opts.timeout = opts.handshakeTimeout; if (opts.perMessageDeflate) { perMessageDeflate = new PerMessageDeflate( opts.perMessageDeflate !== true ? opts.perMessageDeflate : {}, false, opts.maxPayload ); opts.headers['Sec-WebSocket-Extensions'] = format({ [PerMessageDeflate.extensionName]: perMessageDeflate.offer() }); } if (protocols) { opts.headers['Sec-WebSocket-Protocol'] = protocols; } if (opts.origin) { if (opts.protocolVersion < 13) { opts.headers['Sec-WebSocket-Origin'] = opts.origin; } else { opts.headers.Origin = opts.origin; } } if (parsedUrl.username || parsedUrl.password) { opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; } if (isUnixSocket) { const parts = opts.path.split(':'); opts.socketPath = parts[0]; opts.path = parts[1]; } let req = (websocket._req = get(opts)); if (opts.timeout) { req.on('timeout', () => { abortHandshake(websocket, req, 'Opening handshake has timed out'); }); } req.on('error', (err) => { if (req === null || req.aborted) return; req = websocket._req = null; websocket._readyState = WebSocket.CLOSING; websocket.emit('error', err); websocket.emitClose(); }); req.on('response', (res) => { const location = res.headers.location; const statusCode = res.statusCode; if ( location && opts.followRedirects && statusCode >= 300 && statusCode < 400 ) { if (++websocket._redirects > opts.maxRedirects) { abortHandshake(websocket, req, 'Maximum redirects exceeded'); return; } req.abort(); const addr = new URL(location, address); initAsClient(websocket, addr, protocols, options); } else if (!websocket.emit('unexpected-response', req, res)) { abortHandshake( websocket, req, `Unexpected server response: ${res.statusCode}` ); } }); req.on('upgrade', (res, socket, head) => { websocket.emit('upgrade', res); // // The user may have closed the connection from a listener of the `upgrade` // event. // if (websocket.readyState !== WebSocket.CONNECTING) return; req = websocket._req = null; const digest = createHash('sha1') .update(key + GUID) .digest('base64'); if (res.headers['sec-websocket-accept'] !== digest) { abortHandshake(websocket, socket, 'Invalid Sec-WebSocket-Accept header'); return; } const serverProt = res.headers['sec-websocket-protocol']; const protList = (protocols || '').split(/, */); let protError; if (!protocols && serverProt) { protError = 'Server sent a subprotocol but none was requested'; } else if (protocols && !serverProt) { protError = 'Server sent no subprotocol'; } else if (serverProt && !protList.includes(serverProt)) { protError = 'Server sent an invalid subprotocol'; } if (protError) { abortHandshake(websocket, socket, protError); return; } if (serverProt) websocket._protocol = serverProt; const secWebSocketExtensions = res.headers['sec-websocket-extensions']; if (secWebSocketExtensions !== undefined) { if (!perMessageDeflate) { const message = 'Server sent a Sec-WebSocket-Extensions header but no extension ' + 'was requested'; abortHandshake(websocket, socket, message); return; } let extensions; try { extensions = parse(secWebSocketExtensions); } catch (err) { const message = 'Invalid Sec-WebSocket-Extensions header'; abortHandshake(websocket, socket, message); return; } const extensionNames = Object.keys(extensions); if (extensionNames.length) { if ( extensionNames.length !== 1 || extensionNames[0] !== PerMessageDeflate.extensionName ) { const message = 'Server indicated an extension that was not requested'; abortHandshake(websocket, socket, message); return; } try { perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); } catch (err) { const message = 'Invalid Sec-WebSocket-Extensions header'; abortHandshake(websocket, socket, message); return; } websocket._extensions[PerMessageDeflate.extensionName] = perMessageDeflate; } } websocket.setSocket(socket, head, opts.maxPayload); }); } /** * Create a `net.Socket` and initiate a connection. * * @param {Object} options Connection options * @return {net.Socket} The newly created socket used to start the connection * @private */ function netConnect(options) { options.path = options.socketPath; return net.connect(options); } /** * Create a `tls.TLSSocket` and initiate a connection. * * @param {Object} options Connection options * @return {tls.TLSSocket} The newly created socket used to start the connection * @private */ function tlsConnect(options) { options.path = undefined; if (!options.servername && options.servername !== '') { options.servername = net.isIP(options.host) ? '' : options.host; } return tls.connect(options); } /** * Abort the handshake and emit an error. * * @param {WebSocket} websocket The WebSocket instance * @param {(http.ClientRequest|net.Socket|tls.Socket)} stream The request to * abort or the socket to destroy * @param {String} message The error message * @private */ function abortHandshake(websocket, stream, message) { websocket._readyState = WebSocket.CLOSING; const err = new Error(message); Error.captureStackTrace(err, abortHandshake); if (stream.setHeader) { stream.abort(); if (stream.socket && !stream.socket.destroyed) { // // On Node.js >= 14.3.0 `request.abort()` does not destroy the socket if // called after the request completed. See // https://github.com/websockets/ws/issues/1869. // stream.socket.destroy(); } stream.once('abort', websocket.emitClose.bind(websocket)); websocket.emit('error', err); } else { stream.destroy(err); stream.once('error', websocket.emit.bind(websocket, 'error')); stream.once('close', websocket.emitClose.bind(websocket)); } } /** * Handle cases where the `ping()`, `pong()`, or `send()` methods are called * when the `readyState` attribute is `CLOSING` or `CLOSED`. * * @param {WebSocket} websocket The WebSocket instance * @param {*} [data] The data to send * @param {Function} [cb] Callback * @private */ function sendAfterClose(websocket, data, cb) { if (data) { const length = toBuffer(data).length; // // The `_bufferedAmount` property is used only when the peer is a client and // the opening handshake fails. Under these circumstances, in fact, the // `setSocket()` method is not called, so the `_socket` and `_sender` // properties are set to `null`. // if (websocket._socket) websocket._sender._bufferedBytes += length; else websocket._bufferedAmount += length; } if (cb) { const err = new Error( `WebSocket is not open: readyState ${websocket.readyState} ` + `(${readyStates[websocket.readyState]})` ); cb(err); } } /** * The listener of the `Receiver` `'conclude'` event. * * @param {Number} code The status code * @param {String} reason The reason for closing * @private */ function receiverOnConclude(code, reason) { const websocket = this[kWebSocket]; websocket._closeFrameReceived = true; websocket._closeMessage = reason; websocket._closeCode = code; if (websocket._socket[kWebSocket] === undefined) return; websocket._socket.removeListener('data', socketOnData); process.nextTick(resume, websocket._socket); if (code === 1005) websocket.close(); else websocket.close(code, reason); } /** * The listener of the `Receiver` `'drain'` event. * * @private */ function receiverOnDrain() { this[kWebSocket]._socket.resume(); } /** * The listener of the `Receiver` `'error'` event. * * @param {(RangeError|Error)} err The emitted error * @private */ function receiverOnError(err) { const websocket = this[kWebSocket]; if (websocket._socket[kWebSocket] !== undefined) { websocket._socket.removeListener('data', socketOnData); // // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See // https://github.com/websockets/ws/issues/1940. // process.nextTick(resume, websocket._socket); websocket.close(err[kStatusCode]); } websocket.emit('error', err); } /** * The listener of the `Receiver` `'finish'` event. * * @private */ function receiverOnFinish() { this[kWebSocket].emitClose(); } /** * The listener of the `Receiver` `'message'` event. * * @param {(String|Buffer|ArrayBuffer|Buffer[])} data The message * @private */ function receiverOnMessage(data) { this[kWebSocket].emit('message', data); } /** * The listener of the `Receiver` `'ping'` event. * * @param {Buffer} data The data included in the ping frame * @private */ function receiverOnPing(data) { const websocket = this[kWebSocket]; websocket.pong(data, !websocket._isServer, NOOP); websocket.emit('ping', data); } /** * The listener of the `Receiver` `'pong'` event. * * @param {Buffer} data The data included in the pong frame * @private */ function receiverOnPong(data) { this[kWebSocket].emit('pong', data); } /** * Resume a readable stream * * @param {Readable} stream The readable stream * @private */ function resume(stream) { stream.resume(); } /** * The listener of the `net.Socket` `'close'` event. * * @private */ function socketOnClose() { const websocket = this[kWebSocket]; this.removeListener('close', socketOnClose); this.removeListener('data', socketOnData); this.removeListener('end', socketOnEnd); websocket._readyState = WebSocket.CLOSING; let chunk; // // The close frame might not have been received or the `'end'` event emitted, // for example, if the socket was destroyed due to an error. Ensure that the // `receiver` stream is closed after writing any remaining buffered data to // it. If the readable side of the socket is in flowing mode then there is no // buffered data as everything has been already written and `readable.read()` // will return `null`. If instead, the socket is paused, any possible buffered // data will be read as a single chunk. // if ( !this._readableState.endEmitted && !websocket._closeFrameReceived && !websocket._receiver._writableState.errorEmitted && (chunk = websocket._socket.read()) !== null ) { websocket._receiver.write(chunk); } websocket._receiver.end(); this[kWebSocket] = undefined; clearTimeout(websocket._closeTimer); if ( websocket._receiver._writableState.finished || websocket._receiver._writableState.errorEmitted ) { websocket.emitClose(); } else { websocket._receiver.on('error', receiverOnFinish); websocket._receiver.on('finish', receiverOnFinish); } } /** * The listener of the `net.Socket` `'data'` event. * * @param {Buffer} chunk A chunk of data * @private */ function socketOnData(chunk) { if (!this[kWebSocket]._receiver.write(chunk)) { this.pause(); } } /** * The listener of the `net.Socket` `'end'` event. * * @private */ function socketOnEnd() { const websocket = this[kWebSocket]; websocket._readyState = WebSocket.CLOSING; websocket._receiver.end(); this.end(); } /** * The listener of the `net.Socket` `'error'` event. * * @private */ function socketOnError() { const websocket = this[kWebSocket]; this.removeListener('error', socketOnError); this.on('error', NOOP); if (websocket) { websocket._readyState = WebSocket.CLOSING; this.destroy(); } }