'use strict' const { webidl } = require('../fetch/webidl') const { URLSerializer } = require('../fetch/data-url') const { getGlobalOrigin } = require('../fetch/global') const { staticPropertyDescriptors, states, sentCloseFrameState, opcodes, emptyBuffer } = require('./constants') const { kWebSocketURL, kReadyState, kController, kBinaryType, kResponse, kSentClose, kByteParser } = require('./symbols') const { isConnecting, isEstablished, isClosed, isClosing, isValidSubprotocol, failWebsocketConnection, fireEvent } = require('./util') const { establishWebSocketConnection } = require('./connection') const { WebsocketFrameSend } = require('./frame') const { ByteParser } = require('./receiver') const { kEnumerableProperty, isBlobLike } = require('../../core/util') const { getGlobalDispatcher } = require('../../global') const { types } = require('node:util') let experimentalWarned = false // https://websockets.spec.whatwg.org/#interface-definition class WebSocket extends EventTarget { #events = { open: null, error: null, close: null, message: null } #bufferedAmount = 0 #protocol = '' #extensions = '' /** * @param {string} url * @param {string|string[]} protocols */ constructor (url, protocols = []) { super() webidl.argumentLengthCheck(arguments, 1, { header: 'WebSocket constructor' }) if (!experimentalWarned) { experimentalWarned = true process.emitWarning('WebSockets are experimental, expect them to change at any time.', { code: 'UNDICI-WS' }) } const options = webidl.converters['DOMString or sequence or WebSocketInit'](protocols) url = webidl.converters.USVString(url) protocols = options.protocols // 1. Let baseURL be this's relevant settings object's API base URL. const baseURL = getGlobalOrigin() // 1. Let urlRecord be the result of applying the URL parser to url with baseURL. let urlRecord try { urlRecord = new URL(url, baseURL) } catch (e) { // 3. If urlRecord is failure, then throw a "SyntaxError" DOMException. throw new DOMException(e, 'SyntaxError') } // 4. If urlRecord’s scheme is "http", then set urlRecord’s scheme to "ws". if (urlRecord.protocol === 'http:') { urlRecord.protocol = 'ws:' } else if (urlRecord.protocol === 'https:') { // 5. Otherwise, if urlRecord’s scheme is "https", set urlRecord’s scheme to "wss". urlRecord.protocol = 'wss:' } // 6. If urlRecord’s scheme is not "ws" or "wss", then throw a "SyntaxError" DOMException. if (urlRecord.protocol !== 'ws:' && urlRecord.protocol !== 'wss:') { throw new DOMException( `Expected a ws: or wss: protocol, got ${urlRecord.protocol}`, 'SyntaxError' ) } // 7. If urlRecord’s fragment is non-null, then throw a "SyntaxError" // DOMException. if (urlRecord.hash || urlRecord.href.endsWith('#')) { throw new DOMException('Got fragment', 'SyntaxError') } // 8. If protocols is a string, set protocols to a sequence consisting // of just that string. if (typeof protocols === 'string') { protocols = [protocols] } // 9. If any of the values in protocols occur more than once or otherwise // fail to match the requirements for elements that comprise the value // of `Sec-WebSocket-Protocol` fields as defined by The WebSocket // protocol, then throw a "SyntaxError" DOMException. if (protocols.length !== new Set(protocols.map(p => p.toLowerCase())).size) { throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') } if (protocols.length > 0 && !protocols.every(p => isValidSubprotocol(p))) { throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') } // 10. Set this's url to urlRecord. this[kWebSocketURL] = new URL(urlRecord.href) // 11. Let client be this's relevant settings object. // 12. Run this step in parallel: // 1. Establish a WebSocket connection given urlRecord, protocols, // and client. this[kController] = establishWebSocketConnection( urlRecord, protocols, this, (response) => this.#onConnectionEstablished(response), options ) // Each WebSocket object has an associated ready state, which is a // number representing the state of the connection. Initially it must // be CONNECTING (0). this[kReadyState] = WebSocket.CONNECTING this[kSentClose] = sentCloseFrameState.NOT_SENT // The extensions attribute must initially return the empty string. // The protocol attribute must initially return the empty string. // Each WebSocket object has an associated binary type, which is a // BinaryType. Initially it must be "blob". this[kBinaryType] = 'blob' } /** * @see https://websockets.spec.whatwg.org/#dom-websocket-close * @param {number|undefined} code * @param {string|undefined} reason */ close (code = undefined, reason = undefined) { webidl.brandCheck(this, WebSocket) if (code !== undefined) { code = webidl.converters['unsigned short'](code, { clamp: true }) } if (reason !== undefined) { reason = webidl.converters.USVString(reason) } // 1. If code is present, but is neither an integer equal to 1000 nor an // integer in the range 3000 to 4999, inclusive, throw an // "InvalidAccessError" DOMException. if (code !== undefined) { if (code !== 1000 && (code < 3000 || code > 4999)) { throw new DOMException('invalid code', 'InvalidAccessError') } } let reasonByteLength = 0 // 2. If reason is present, then run these substeps: if (reason !== undefined) { // 1. Let reasonBytes be the result of encoding reason. // 2. If reasonBytes is longer than 123 bytes, then throw a // "SyntaxError" DOMException. reasonByteLength = Buffer.byteLength(reason) if (reasonByteLength > 123) { throw new DOMException( `Reason must be less than 123 bytes; received ${reasonByteLength}`, 'SyntaxError' ) } } // 3. Run the first matching steps from the following list: if (isClosing(this) || isClosed(this)) { // If this's ready state is CLOSING (2) or CLOSED (3) // Do nothing. } else if (!isEstablished(this)) { // If the WebSocket connection is not yet established // Fail the WebSocket connection and set this's ready state // to CLOSING (2). failWebsocketConnection(this, 'Connection was closed before it was established.') this[kReadyState] = WebSocket.CLOSING } else if (this[kSentClose] === sentCloseFrameState.NOT_SENT) { // If the WebSocket closing handshake has not yet been started // Start the WebSocket closing handshake and set this's ready // state to CLOSING (2). // - If neither code nor reason is present, the WebSocket Close // message must not have a body. // - If code is present, then the status code to use in the // WebSocket Close message must be the integer given by code. // - If reason is also present, then reasonBytes must be // provided in the Close message after the status code. this[kSentClose] = sentCloseFrameState.PROCESSING const frame = new WebsocketFrameSend() // If neither code nor reason is present, the WebSocket Close // message must not have a body. // If code is present, then the status code to use in the // WebSocket Close message must be the integer given by code. if (code !== undefined && reason === undefined) { frame.frameData = Buffer.allocUnsafe(2) frame.frameData.writeUInt16BE(code, 0) } else if (code !== undefined && reason !== undefined) { // If reason is also present, then reasonBytes must be // provided in the Close message after the status code. frame.frameData = Buffer.allocUnsafe(2 + reasonByteLength) frame.frameData.writeUInt16BE(code, 0) // the body MAY contain UTF-8-encoded data with value /reason/ frame.frameData.write(reason, 2, 'utf-8') } else { frame.frameData = emptyBuffer } /** @type {import('stream').Duplex} */ const socket = this[kResponse].socket socket.write(frame.createFrame(opcodes.CLOSE), (err) => { if (!err) { this[kSentClose] = sentCloseFrameState.SENT } }) // Upon either sending or receiving a Close control frame, it is said // that _The WebSocket Closing Handshake is Started_ and that the // WebSocket connection is in the CLOSING state. this[kReadyState] = states.CLOSING } else { // Otherwise // Set this's ready state to CLOSING (2). this[kReadyState] = WebSocket.CLOSING } } /** * @see https://websockets.spec.whatwg.org/#dom-websocket-send * @param {NodeJS.TypedArray|ArrayBuffer|Blob|string} data */ send (data) { webidl.brandCheck(this, WebSocket) webidl.argumentLengthCheck(arguments, 1, { header: 'WebSocket.send' }) data = webidl.converters.WebSocketSendData(data) // 1. If this's ready state is CONNECTING, then throw an // "InvalidStateError" DOMException. if (isConnecting(this)) { throw new DOMException('Sent before connected.', 'InvalidStateError') } // 2. Run the appropriate set of steps from the following list: // https://datatracker.ietf.org/doc/html/rfc6455#section-6.1 // https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 if (!isEstablished(this) || isClosing(this)) { return } /** @type {import('stream').Duplex} */ const socket = this[kResponse].socket // If data is a string if (typeof data === 'string') { // If the WebSocket connection is established and the WebSocket // closing handshake has not yet started, then the user agent // must send a WebSocket Message comprised of the data argument // using a text frame opcode; if the data cannot be sent, e.g. // because it would need to be buffered but the buffer is full, // the user agent must flag the WebSocket as full and then close // the WebSocket connection. Any invocation of this method with a // string argument that does not throw an exception must increase // the bufferedAmount attribute by the number of bytes needed to // express the argument as UTF-8. const value = Buffer.from(data) const frame = new WebsocketFrameSend(value) const buffer = frame.createFrame(opcodes.TEXT) this.#bufferedAmount += value.byteLength socket.write(buffer, () => { this.#bufferedAmount -= value.byteLength }) } else if (types.isArrayBuffer(data)) { // If the WebSocket connection is established, and the WebSocket // closing handshake has not yet started, then the user agent must // send a WebSocket Message comprised of data using a binary frame // opcode; if the data cannot be sent, e.g. because it would need // to be buffered but the buffer is full, the user agent must flag // the WebSocket as full and then close the WebSocket connection. // The data to be sent is the data stored in the buffer described // by the ArrayBuffer object. Any invocation of this method with an // ArrayBuffer argument that does not throw an exception must // increase the bufferedAmount attribute by the length of the // ArrayBuffer in bytes. const value = Buffer.from(data) const frame = new WebsocketFrameSend(value) const buffer = frame.createFrame(opcodes.BINARY) this.#bufferedAmount += value.byteLength socket.write(buffer, () => { this.#bufferedAmount -= value.byteLength }) } else if (ArrayBuffer.isView(data)) { // If the WebSocket connection is established, and the WebSocket // closing handshake has not yet started, then the user agent must // send a WebSocket Message comprised of data using a binary frame // opcode; if the data cannot be sent, e.g. because it would need to // be buffered but the buffer is full, the user agent must flag the // WebSocket as full and then close the WebSocket connection. The // data to be sent is the data stored in the section of the buffer // described by the ArrayBuffer object that data references. Any // invocation of this method with this kind of argument that does // not throw an exception must increase the bufferedAmount attribute // by the length of data’s buffer in bytes. const ab = Buffer.from(data, data.byteOffset, data.byteLength) const frame = new WebsocketFrameSend(ab) const buffer = frame.createFrame(opcodes.BINARY) this.#bufferedAmount += ab.byteLength socket.write(buffer, () => { this.#bufferedAmount -= ab.byteLength }) } else if (isBlobLike(data)) { // If the WebSocket connection is established, and the WebSocket // closing handshake has not yet started, then the user agent must // send a WebSocket Message comprised of data using a binary frame // opcode; if the data cannot be sent, e.g. because it would need to // be buffered but the buffer is full, the user agent must flag the // WebSocket as full and then close the WebSocket connection. The data // to be sent is the raw data represented by the Blob object. Any // invocation of this method with a Blob argument that does not throw // an exception must increase the bufferedAmount attribute by the size // of the Blob object’s raw data, in bytes. const frame = new WebsocketFrameSend() data.arrayBuffer().then((ab) => { const value = Buffer.from(ab) frame.frameData = value const buffer = frame.createFrame(opcodes.BINARY) this.#bufferedAmount += value.byteLength socket.write(buffer, () => { this.#bufferedAmount -= value.byteLength }) }) } } get readyState () { webidl.brandCheck(this, WebSocket) // The readyState getter steps are to return this's ready state. return this[kReadyState] } get bufferedAmount () { webidl.brandCheck(this, WebSocket) return this.#bufferedAmount } get url () { webidl.brandCheck(this, WebSocket) // The url getter steps are to return this's url, serialized. return URLSerializer(this[kWebSocketURL]) } get extensions () { webidl.brandCheck(this, WebSocket) return this.#extensions } get protocol () { webidl.brandCheck(this, WebSocket) return this.#protocol } get onopen () { webidl.brandCheck(this, WebSocket) return this.#events.open } set onopen (fn) { webidl.brandCheck(this, WebSocket) if (this.#events.open) { this.removeEventListener('open', this.#events.open) } if (typeof fn === 'function') { this.#events.open = fn this.addEventListener('open', fn) } else { this.#events.open = null } } get onerror () { webidl.brandCheck(this, WebSocket) return this.#events.error } set onerror (fn) { webidl.brandCheck(this, WebSocket) if (this.#events.error) { this.removeEventListener('error', this.#events.error) } if (typeof fn === 'function') { this.#events.error = fn this.addEventListener('error', fn) } else { this.#events.error = null } } get onclose () { webidl.brandCheck(this, WebSocket) return this.#events.close } set onclose (fn) { webidl.brandCheck(this, WebSocket) if (this.#events.close) { this.removeEventListener('close', this.#events.close) } if (typeof fn === 'function') { this.#events.close = fn this.addEventListener('close', fn) } else { this.#events.close = null } } get onmessage () { webidl.brandCheck(this, WebSocket) return this.#events.message } set onmessage (fn) { webidl.brandCheck(this, WebSocket) if (this.#events.message) { this.removeEventListener('message', this.#events.message) } if (typeof fn === 'function') { this.#events.message = fn this.addEventListener('message', fn) } else { this.#events.message = null } } get binaryType () { webidl.brandCheck(this, WebSocket) return this[kBinaryType] } set binaryType (type) { webidl.brandCheck(this, WebSocket) if (type !== 'blob' && type !== 'arraybuffer') { this[kBinaryType] = 'blob' } else { this[kBinaryType] = type } } /** * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol */ #onConnectionEstablished (response) { // processResponse is called when the "response’s header list has been received and initialized." // once this happens, the connection is open this[kResponse] = response const parser = new ByteParser(this) parser.on('drain', function onParserDrain () { this.ws[kResponse].socket.resume() }) response.socket.ws = this this[kByteParser] = parser // 1. Change the ready state to OPEN (1). this[kReadyState] = states.OPEN // 2. Change the extensions attribute’s value to the extensions in use, if // it is not the null value. // https://datatracker.ietf.org/doc/html/rfc6455#section-9.1 const extensions = response.headersList.get('sec-websocket-extensions') if (extensions !== null) { this.#extensions = extensions } // 3. Change the protocol attribute’s value to the subprotocol in use, if // it is not the null value. // https://datatracker.ietf.org/doc/html/rfc6455#section-1.9 const protocol = response.headersList.get('sec-websocket-protocol') if (protocol !== null) { this.#protocol = protocol } // 4. Fire an event named open at the WebSocket object. fireEvent('open', this) } } // https://websockets.spec.whatwg.org/#dom-websocket-connecting WebSocket.CONNECTING = WebSocket.prototype.CONNECTING = states.CONNECTING // https://websockets.spec.whatwg.org/#dom-websocket-open WebSocket.OPEN = WebSocket.prototype.OPEN = states.OPEN // https://websockets.spec.whatwg.org/#dom-websocket-closing WebSocket.CLOSING = WebSocket.prototype.CLOSING = states.CLOSING // https://websockets.spec.whatwg.org/#dom-websocket-closed WebSocket.CLOSED = WebSocket.prototype.CLOSED = states.CLOSED Object.defineProperties(WebSocket.prototype, { CONNECTING: staticPropertyDescriptors, OPEN: staticPropertyDescriptors, CLOSING: staticPropertyDescriptors, CLOSED: staticPropertyDescriptors, url: kEnumerableProperty, readyState: kEnumerableProperty, bufferedAmount: kEnumerableProperty, onopen: kEnumerableProperty, onerror: kEnumerableProperty, onclose: kEnumerableProperty, close: kEnumerableProperty, onmessage: kEnumerableProperty, binaryType: kEnumerableProperty, send: kEnumerableProperty, extensions: kEnumerableProperty, protocol: kEnumerableProperty, [Symbol.toStringTag]: { value: 'WebSocket', writable: false, enumerable: false, configurable: true } }) Object.defineProperties(WebSocket, { CONNECTING: staticPropertyDescriptors, OPEN: staticPropertyDescriptors, CLOSING: staticPropertyDescriptors, CLOSED: staticPropertyDescriptors }) webidl.converters['sequence'] = webidl.sequenceConverter( webidl.converters.DOMString ) webidl.converters['DOMString or sequence'] = function (V) { if (webidl.util.Type(V) === 'Object' && Symbol.iterator in V) { return webidl.converters['sequence'](V) } return webidl.converters.DOMString(V) } // This implements the propsal made in https://github.com/whatwg/websockets/issues/42 webidl.converters.WebSocketInit = webidl.dictionaryConverter([ { key: 'protocols', converter: webidl.converters['DOMString or sequence'], get defaultValue () { return [] } }, { key: 'dispatcher', converter: (V) => V, get defaultValue () { return getGlobalDispatcher() } }, { key: 'headers', converter: webidl.nullableConverter(webidl.converters.HeadersInit) } ]) webidl.converters['DOMString or sequence or WebSocketInit'] = function (V) { if (webidl.util.Type(V) === 'Object' && !(Symbol.iterator in V)) { return webidl.converters.WebSocketInit(V) } return { protocols: webidl.converters['DOMString or sequence'](V) } } webidl.converters.WebSocketSendData = function (V) { if (webidl.util.Type(V) === 'Object') { if (isBlobLike(V)) { return webidl.converters.Blob(V, { strict: false }) } if (ArrayBuffer.isView(V) || types.isArrayBuffer(V)) { return webidl.converters.BufferSource(V) } } return webidl.converters.USVString(V) } module.exports = { WebSocket }