diff options
Diffstat (limited to 'vanilla/node_modules/undici/lib/web/websocket/websocket.js')
| -rw-r--r-- | vanilla/node_modules/undici/lib/web/websocket/websocket.js | 739 |
1 files changed, 739 insertions, 0 deletions
diff --git a/vanilla/node_modules/undici/lib/web/websocket/websocket.js b/vanilla/node_modules/undici/lib/web/websocket/websocket.js new file mode 100644 index 0000000..3314976 --- /dev/null +++ b/vanilla/node_modules/undici/lib/web/websocket/websocket.js @@ -0,0 +1,739 @@ +'use strict' + +const { isArrayBuffer } = require('node:util/types') +const { webidl } = require('../webidl') +const { URLSerializer } = require('../fetch/data-url') +const { environmentSettingsObject } = require('../fetch/util') +const { staticPropertyDescriptors, states, sentCloseFrameState, sendHints, opcodes } = require('./constants') +const { + isConnecting, + isEstablished, + isClosing, + isClosed, + isValidSubprotocol, + fireEvent, + utf8Decode, + toArrayBuffer, + getURLRecord +} = require('./util') +const { establishWebSocketConnection, closeWebSocketConnection, failWebsocketConnection } = require('./connection') +const { ByteParser } = require('./receiver') +const { kEnumerableProperty } = require('../../core/util') +const { getGlobalDispatcher } = require('../../global') +const { ErrorEvent, CloseEvent, createFastMessageEvent } = require('./events') +const { SendQueue } = require('./sender') +const { WebsocketFrameSend } = require('./frame') +const { channels } = require('../../core/diagnostics') + +/** + * @typedef {object} Handler + * @property {(response: any, extensions?: string[]) => void} onConnectionEstablished + * @property {(opcode: number, data: Buffer) => void} onMessage + * @property {(error: Error) => void} onParserError + * @property {() => void} onParserDrain + * @property {(chunk: Buffer) => void} onSocketData + * @property {(err: Error) => void} onSocketError + * @property {() => void} onSocketClose + * @property {(body: Buffer) => void} onPing + * @property {(body: Buffer) => void} onPong + * + * @property {number} readyState + * @property {import('stream').Duplex} socket + * @property {Set<number>} closeState + * @property {import('../fetch/index').Fetch} controller + * @property {boolean} [wasEverConnected=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 = '' + + /** @type {SendQueue} */ + #sendQueue + + /** @type {Handler} */ + #handler = { + onConnectionEstablished: (response, extensions) => this.#onConnectionEstablished(response, extensions), + onMessage: (opcode, data) => this.#onMessage(opcode, data), + onParserError: (err) => failWebsocketConnection(this.#handler, null, err.message), + onParserDrain: () => this.#onParserDrain(), + onSocketData: (chunk) => { + if (!this.#parser.write(chunk)) { + this.#handler.socket.pause() + } + }, + onSocketError: (err) => { + this.#handler.readyState = states.CLOSING + + if (channels.socketError.hasSubscribers) { + channels.socketError.publish(err) + } + + this.#handler.socket.destroy() + }, + onSocketClose: () => this.#onSocketClose(), + onPing: (body) => { + if (channels.ping.hasSubscribers) { + channels.ping.publish({ + payload: body, + websocket: this + }) + } + }, + onPong: (body) => { + if (channels.pong.hasSubscribers) { + channels.pong.publish({ + payload: body, + websocket: this + }) + } + }, + + readyState: states.CONNECTING, + socket: null, + closeState: new Set(), + controller: null, + wasEverConnected: false + } + + #url + #binaryType + /** @type {import('./receiver').ByteParser} */ + #parser + + /** + * @param {string} url + * @param {string|string[]} protocols + */ + constructor (url, protocols = []) { + super() + + webidl.util.markAsUncloneable(this) + + const prefix = 'WebSocket constructor' + webidl.argumentLengthCheck(arguments, 1, prefix) + + const options = webidl.converters['DOMString or sequence<DOMString> or WebSocketInit'](protocols, prefix, 'options') + + url = webidl.converters.USVString(url) + protocols = options.protocols + + // 1. Let baseURL be this's relevant settings object's API base URL. + const baseURL = environmentSettingsObject.settingsObject.baseUrl + + // 2. Let urlRecord be the result of getting a URL record given url and baseURL. + const urlRecord = getURLRecord(url, baseURL) + + // 3. If protocols is a string, set protocols to a sequence consisting + // of just that string. + if (typeof protocols === 'string') { + protocols = [protocols] + } + + // 4. 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') + } + + // 5. Set this's url to urlRecord. + this.#url = new URL(urlRecord.href) + + // 6. Let client be this's relevant settings object. + const client = environmentSettingsObject.settingsObject + + // 7. Run this step in parallel: + // 7.1. Establish a WebSocket connection given urlRecord, protocols, + // and client. + this.#handler.controller = establishWebSocketConnection( + urlRecord, + protocols, + client, + this.#handler, + 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.#handler.readyState = WebSocket.CONNECTING + + // 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.#binaryType = '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) + + const prefix = 'WebSocket.close' + + if (code !== undefined) { + code = webidl.converters['unsigned short'](code, prefix, 'code', webidl.attributes.Clamp) + } + + if (reason !== undefined) { + reason = webidl.converters.USVString(reason) + } + + // 1. If code is the special value "missing", then set code to null. + code ??= null + + // 2. If reason is the special value "missing", then set reason to the empty string. + reason ??= '' + + // 3. Close the WebSocket with this, code, and reason. + closeWebSocketConnection(this.#handler, code, reason, true) + } + + /** + * @see https://websockets.spec.whatwg.org/#dom-websocket-send + * @param {NodeJS.TypedArray|ArrayBuffer|Blob|string} data + */ + send (data) { + webidl.brandCheck(this, WebSocket) + + const prefix = 'WebSocket.send' + webidl.argumentLengthCheck(arguments, 1, prefix) + + data = webidl.converters.WebSocketSendData(data, prefix, 'data') + + // 1. If this's ready state is CONNECTING, then throw an + // "InvalidStateError" DOMException. + if (isConnecting(this.#handler.readyState)) { + 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.#handler.readyState) || isClosing(this.#handler.readyState)) { + return + } + + // 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 buffer = Buffer.from(data) + + this.#bufferedAmount += buffer.byteLength + this.#sendQueue.add(buffer, () => { + this.#bufferedAmount -= buffer.byteLength + }, sendHints.text) + } else if (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. + + this.#bufferedAmount += data.byteLength + this.#sendQueue.add(data, () => { + this.#bufferedAmount -= data.byteLength + }, sendHints.arrayBuffer) + } 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. + + this.#bufferedAmount += data.byteLength + this.#sendQueue.add(data, () => { + this.#bufferedAmount -= data.byteLength + }, sendHints.typedArray) + } else if (webidl.is.Blob(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. + + this.#bufferedAmount += data.size + this.#sendQueue.add(data, () => { + this.#bufferedAmount -= data.size + }, sendHints.blob) + } + } + + get readyState () { + webidl.brandCheck(this, WebSocket) + + // The readyState getter steps are to return this's ready state. + return this.#handler.readyState + } + + 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.#url) + } + + 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) + } + + const listener = webidl.converters.EventHandlerNonNull(fn) + + if (listener !== null) { + this.addEventListener('open', listener) + this.#events.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) + } + + const listener = webidl.converters.EventHandlerNonNull(fn) + + if (listener !== null) { + this.addEventListener('error', listener) + this.#events.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) + } + + const listener = webidl.converters.EventHandlerNonNull(fn) + + if (listener !== null) { + this.addEventListener('close', listener) + this.#events.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) + } + + const listener = webidl.converters.EventHandlerNonNull(fn) + + if (listener !== null) { + this.addEventListener('message', listener) + this.#events.message = fn + } else { + this.#events.message = null + } + } + + get binaryType () { + webidl.brandCheck(this, WebSocket) + + return this.#binaryType + } + + set binaryType (type) { + webidl.brandCheck(this, WebSocket) + + if (type !== 'blob' && type !== 'arraybuffer') { + this.#binaryType = 'blob' + } else { + this.#binaryType = type + } + } + + /** + * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol + */ + #onConnectionEstablished (response, parsedExtensions) { + // processResponse is called when the "response’s header list has been received and initialized." + // once this happens, the connection is open + this.#handler.socket = response.socket + + const parser = new ByteParser(this.#handler, parsedExtensions) + parser.on('drain', () => this.#handler.onParserDrain()) + parser.on('error', (err) => this.#handler.onParserError(err)) + + this.#parser = parser + this.#sendQueue = new SendQueue(response.socket) + + // 1. Change the ready state to OPEN (1). + this.#handler.readyState = 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) + + if (channels.open.hasSubscribers) { + // Convert headers to a plain object for the event + const headers = response.headersList.entries + channels.open.publish({ + address: response.socket.address(), + protocol: this.#protocol, + extensions: this.#extensions, + websocket: this, + handshakeResponse: { + status: response.status, + statusText: response.statusText, + headers + } + }) + } + } + + #onMessage (type, data) { + // 1. If ready state is not OPEN (1), then return. + if (this.#handler.readyState !== states.OPEN) { + return + } + + // 2. Let dataForEvent be determined by switching on type and binary type: + let dataForEvent + + if (type === opcodes.TEXT) { + // -> type indicates that the data is Text + // a new DOMString containing data + try { + dataForEvent = utf8Decode(data) + } catch { + failWebsocketConnection(this.#handler, 1007, 'Received invalid UTF-8 in text frame.') + return + } + } else if (type === opcodes.BINARY) { + if (this.#binaryType === 'blob') { + // -> type indicates that the data is Binary and binary type is "blob" + // a new Blob object, created in the relevant Realm of the WebSocket + // object, that represents data as its raw data + dataForEvent = new Blob([data]) + } else { + // -> type indicates that the data is Binary and binary type is "arraybuffer" + // a new ArrayBuffer object, created in the relevant Realm of the + // WebSocket object, whose contents are data + dataForEvent = toArrayBuffer(data) + } + } + + // 3. Fire an event named message at the WebSocket object, using MessageEvent, + // with the origin attribute initialized to the serialization of the WebSocket + // object’s url's origin, and the data attribute initialized to dataForEvent. + fireEvent('message', this, createFastMessageEvent, { + origin: this.#url.origin, + data: dataForEvent + }) + } + + #onParserDrain () { + this.#handler.socket.resume() + } + + /** + * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol + * @see https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.4 + */ + #onSocketClose () { + // If the TCP connection was closed after the + // WebSocket closing handshake was completed, the WebSocket connection + // is said to have been closed _cleanly_. + const wasClean = + this.#handler.closeState.has(sentCloseFrameState.SENT) && + this.#handler.closeState.has(sentCloseFrameState.RECEIVED) + + let code = 1005 + let reason = '' + + const result = this.#parser?.closingInfo + + if (result && !result.error) { + code = result.code ?? 1005 + reason = result.reason + } + + // 1. Change the ready state to CLOSED (3). + this.#handler.readyState = states.CLOSED + + // 2. If the user agent was required to fail the WebSocket + // connection, or if the WebSocket connection was closed + // after being flagged as full, fire an event named error + // at the WebSocket object. + if (!this.#handler.closeState.has(sentCloseFrameState.RECEIVED)) { + // If _The WebSocket + // Connection is Closed_ and no Close control frame was received by the + // endpoint (such as could occur if the underlying transport connection + // is lost), _The WebSocket Connection Close Code_ is considered to be + // 1006. + code = 1006 + + fireEvent('error', this, (type, init) => new ErrorEvent(type, init), { + error: new TypeError(reason) + }) + } + + // 3. Fire an event named close at the WebSocket object, + // using CloseEvent, with the wasClean attribute + // initialized to true if the connection closed cleanly + // and false otherwise, the code attribute initialized to + // the WebSocket connection close code, and the reason + // attribute initialized to the result of applying UTF-8 + // decode without BOM to the WebSocket connection close + // reason. + // TODO: process.nextTick + fireEvent('close', this, (type, init) => new CloseEvent(type, init), { + wasClean, code, reason + }) + + if (channels.close.hasSubscribers) { + channels.close.publish({ + websocket: this, + code, + reason + }) + } + } + + /** + * @param {WebSocket} ws + * @param {Buffer|undefined} buffer + */ + static ping (ws, buffer) { + if (Buffer.isBuffer(buffer)) { + if (buffer.length > 125) { + throw new TypeError('A PING frame cannot have a body larger than 125 bytes.') + } + } else if (buffer !== undefined) { + throw new TypeError('Expected buffer payload') + } + + // An endpoint MAY send a Ping frame any time after the connection is + // established and before the connection is closed. + const readyState = ws.#handler.readyState + + if (isEstablished(readyState) && !isClosing(readyState) && !isClosed(readyState)) { + const frame = new WebsocketFrameSend(buffer) + ws.#handler.socket.write(frame.createFrame(opcodes.PING)) + } + } +} + +const { ping } = WebSocket +Reflect.deleteProperty(WebSocket, 'ping') + +// 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<DOMString>'] = webidl.sequenceConverter( + webidl.converters.DOMString +) + +webidl.converters['DOMString or sequence<DOMString>'] = function (V, prefix, argument) { + if (webidl.util.Type(V) === webidl.util.Types.OBJECT && Symbol.iterator in V) { + return webidl.converters['sequence<DOMString>'](V) + } + + return webidl.converters.DOMString(V, prefix, argument) +} + +// This implements the proposal made in https://github.com/whatwg/websockets/issues/42 +webidl.converters.WebSocketInit = webidl.dictionaryConverter([ + { + key: 'protocols', + converter: webidl.converters['DOMString or sequence<DOMString>'], + defaultValue: () => [] + }, + { + key: 'dispatcher', + converter: webidl.converters.any, + defaultValue: () => getGlobalDispatcher() + }, + { + key: 'headers', + converter: webidl.nullableConverter(webidl.converters.HeadersInit) + } +]) + +webidl.converters['DOMString or sequence<DOMString> or WebSocketInit'] = function (V) { + if (webidl.util.Type(V) === webidl.util.Types.OBJECT && !(Symbol.iterator in V)) { + return webidl.converters.WebSocketInit(V) + } + + return { protocols: webidl.converters['DOMString or sequence<DOMString>'](V) } +} + +webidl.converters.WebSocketSendData = function (V) { + if (webidl.util.Type(V) === webidl.util.Types.OBJECT) { + if (webidl.is.Blob(V)) { + return V + } + + if (webidl.is.BufferSource(V)) { + return V + } + } + + return webidl.converters.USVString(V) +} + +module.exports = { + WebSocket, + ping +} |
