| 'use strict' |
| |
| const { webidl } = require('../fetch/webidl') |
| const { DOMException } = require('../fetch/constants') |
| const { URLSerializer } = require('../fetch/dataURL') |
| const { getGlobalOrigin } = require('../fetch/global') |
| const { staticPropertyDescriptors, states, opcodes, emptyBuffer } = require('./constants') |
| const { |
| kWebSocketURL, |
| kReadyState, |
| kController, |
| kBinaryType, |
| kResponse, |
| kSentClose, |
| kByteParser |
| } = require('./symbols') |
| const { isEstablished, 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('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<DOMString> 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 |
| |
| // 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 (this[kReadyState] === WebSocket.CLOSING || this[kReadyState] === WebSocket.CLOSED) { |
| // 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 (!isClosing(this)) { |
| // 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. |
| |
| 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] = true |
| } |
| }) |
| |
| // 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 (this[kReadyState] === WebSocket.CONNECTING) { |
| 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<DOMString>'] = webidl.sequenceConverter( |
| webidl.converters.DOMString |
| ) |
| |
| webidl.converters['DOMString or sequence<DOMString>'] = function (V) { |
| if (webidl.util.Type(V) === 'Object' && Symbol.iterator in V) { |
| return webidl.converters['sequence<DOMString>'](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<DOMString>'], |
| 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<DOMString> 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<DOMString>'](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.isAnyArrayBuffer(V)) { |
| return webidl.converters.BufferSource(V) |
| } |
| } |
| |
| return webidl.converters.USVString(V) |
| } |
| |
| module.exports = { |
| WebSocket |
| } |