1'use strict' 2 3const { webidl } = require('../fetch/webidl') 4const { DOMException } = require('../fetch/constants') 5const { URLSerializer } = require('../fetch/dataURL') 6const { getGlobalOrigin } = require('../fetch/global') 7const { staticPropertyDescriptors, states, opcodes, emptyBuffer } = require('./constants') 8const { 9 kWebSocketURL, 10 kReadyState, 11 kController, 12 kBinaryType, 13 kResponse, 14 kSentClose, 15 kByteParser 16} = require('./symbols') 17const { isEstablished, isClosing, isValidSubprotocol, failWebsocketConnection, fireEvent } = require('./util') 18const { establishWebSocketConnection } = require('./connection') 19const { WebsocketFrameSend } = require('./frame') 20const { ByteParser } = require('./receiver') 21const { kEnumerableProperty, isBlobLike } = require('../core/util') 22const { getGlobalDispatcher } = require('../global') 23const { types } = require('util') 24 25let experimentalWarned = false 26 27// https://websockets.spec.whatwg.org/#interface-definition 28class WebSocket extends EventTarget { 29 #events = { 30 open: null, 31 error: null, 32 close: null, 33 message: null 34 } 35 36 #bufferedAmount = 0 37 #protocol = '' 38 #extensions = '' 39 40 /** 41 * @param {string} url 42 * @param {string|string[]} protocols 43 */ 44 constructor (url, protocols = []) { 45 super() 46 47 webidl.argumentLengthCheck(arguments, 1, { header: 'WebSocket constructor' }) 48 49 if (!experimentalWarned) { 50 experimentalWarned = true 51 process.emitWarning('WebSockets are experimental, expect them to change at any time.', { 52 code: 'UNDICI-WS' 53 }) 54 } 55 56 const options = webidl.converters['DOMString or sequence<DOMString> or WebSocketInit'](protocols) 57 58 url = webidl.converters.USVString(url) 59 protocols = options.protocols 60 61 // 1. Let baseURL be this's relevant settings object's API base URL. 62 const baseURL = getGlobalOrigin() 63 64 // 1. Let urlRecord be the result of applying the URL parser to url with baseURL. 65 let urlRecord 66 67 try { 68 urlRecord = new URL(url, baseURL) 69 } catch (e) { 70 // 3. If urlRecord is failure, then throw a "SyntaxError" DOMException. 71 throw new DOMException(e, 'SyntaxError') 72 } 73 74 // 4. If urlRecord’s scheme is "http", then set urlRecord’s scheme to "ws". 75 if (urlRecord.protocol === 'http:') { 76 urlRecord.protocol = 'ws:' 77 } else if (urlRecord.protocol === 'https:') { 78 // 5. Otherwise, if urlRecord’s scheme is "https", set urlRecord’s scheme to "wss". 79 urlRecord.protocol = 'wss:' 80 } 81 82 // 6. If urlRecord’s scheme is not "ws" or "wss", then throw a "SyntaxError" DOMException. 83 if (urlRecord.protocol !== 'ws:' && urlRecord.protocol !== 'wss:') { 84 throw new DOMException( 85 `Expected a ws: or wss: protocol, got ${urlRecord.protocol}`, 86 'SyntaxError' 87 ) 88 } 89 90 // 7. If urlRecord’s fragment is non-null, then throw a "SyntaxError" 91 // DOMException. 92 if (urlRecord.hash || urlRecord.href.endsWith('#')) { 93 throw new DOMException('Got fragment', 'SyntaxError') 94 } 95 96 // 8. If protocols is a string, set protocols to a sequence consisting 97 // of just that string. 98 if (typeof protocols === 'string') { 99 protocols = [protocols] 100 } 101 102 // 9. If any of the values in protocols occur more than once or otherwise 103 // fail to match the requirements for elements that comprise the value 104 // of `Sec-WebSocket-Protocol` fields as defined by The WebSocket 105 // protocol, then throw a "SyntaxError" DOMException. 106 if (protocols.length !== new Set(protocols.map(p => p.toLowerCase())).size) { 107 throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') 108 } 109 110 if (protocols.length > 0 && !protocols.every(p => isValidSubprotocol(p))) { 111 throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') 112 } 113 114 // 10. Set this's url to urlRecord. 115 this[kWebSocketURL] = new URL(urlRecord.href) 116 117 // 11. Let client be this's relevant settings object. 118 119 // 12. Run this step in parallel: 120 121 // 1. Establish a WebSocket connection given urlRecord, protocols, 122 // and client. 123 this[kController] = establishWebSocketConnection( 124 urlRecord, 125 protocols, 126 this, 127 (response) => this.#onConnectionEstablished(response), 128 options 129 ) 130 131 // Each WebSocket object has an associated ready state, which is a 132 // number representing the state of the connection. Initially it must 133 // be CONNECTING (0). 134 this[kReadyState] = WebSocket.CONNECTING 135 136 // The extensions attribute must initially return the empty string. 137 138 // The protocol attribute must initially return the empty string. 139 140 // Each WebSocket object has an associated binary type, which is a 141 // BinaryType. Initially it must be "blob". 142 this[kBinaryType] = 'blob' 143 } 144 145 /** 146 * @see https://websockets.spec.whatwg.org/#dom-websocket-close 147 * @param {number|undefined} code 148 * @param {string|undefined} reason 149 */ 150 close (code = undefined, reason = undefined) { 151 webidl.brandCheck(this, WebSocket) 152 153 if (code !== undefined) { 154 code = webidl.converters['unsigned short'](code, { clamp: true }) 155 } 156 157 if (reason !== undefined) { 158 reason = webidl.converters.USVString(reason) 159 } 160 161 // 1. If code is present, but is neither an integer equal to 1000 nor an 162 // integer in the range 3000 to 4999, inclusive, throw an 163 // "InvalidAccessError" DOMException. 164 if (code !== undefined) { 165 if (code !== 1000 && (code < 3000 || code > 4999)) { 166 throw new DOMException('invalid code', 'InvalidAccessError') 167 } 168 } 169 170 let reasonByteLength = 0 171 172 // 2. If reason is present, then run these substeps: 173 if (reason !== undefined) { 174 // 1. Let reasonBytes be the result of encoding reason. 175 // 2. If reasonBytes is longer than 123 bytes, then throw a 176 // "SyntaxError" DOMException. 177 reasonByteLength = Buffer.byteLength(reason) 178 179 if (reasonByteLength > 123) { 180 throw new DOMException( 181 `Reason must be less than 123 bytes; received ${reasonByteLength}`, 182 'SyntaxError' 183 ) 184 } 185 } 186 187 // 3. Run the first matching steps from the following list: 188 if (this[kReadyState] === WebSocket.CLOSING || this[kReadyState] === WebSocket.CLOSED) { 189 // If this's ready state is CLOSING (2) or CLOSED (3) 190 // Do nothing. 191 } else if (!isEstablished(this)) { 192 // If the WebSocket connection is not yet established 193 // Fail the WebSocket connection and set this's ready state 194 // to CLOSING (2). 195 failWebsocketConnection(this, 'Connection was closed before it was established.') 196 this[kReadyState] = WebSocket.CLOSING 197 } else if (!isClosing(this)) { 198 // If the WebSocket closing handshake has not yet been started 199 // Start the WebSocket closing handshake and set this's ready 200 // state to CLOSING (2). 201 // - If neither code nor reason is present, the WebSocket Close 202 // message must not have a body. 203 // - If code is present, then the status code to use in the 204 // WebSocket Close message must be the integer given by code. 205 // - If reason is also present, then reasonBytes must be 206 // provided in the Close message after the status code. 207 208 const frame = new WebsocketFrameSend() 209 210 // If neither code nor reason is present, the WebSocket Close 211 // message must not have a body. 212 213 // If code is present, then the status code to use in the 214 // WebSocket Close message must be the integer given by code. 215 if (code !== undefined && reason === undefined) { 216 frame.frameData = Buffer.allocUnsafe(2) 217 frame.frameData.writeUInt16BE(code, 0) 218 } else if (code !== undefined && reason !== undefined) { 219 // If reason is also present, then reasonBytes must be 220 // provided in the Close message after the status code. 221 frame.frameData = Buffer.allocUnsafe(2 + reasonByteLength) 222 frame.frameData.writeUInt16BE(code, 0) 223 // the body MAY contain UTF-8-encoded data with value /reason/ 224 frame.frameData.write(reason, 2, 'utf-8') 225 } else { 226 frame.frameData = emptyBuffer 227 } 228 229 /** @type {import('stream').Duplex} */ 230 const socket = this[kResponse].socket 231 232 socket.write(frame.createFrame(opcodes.CLOSE), (err) => { 233 if (!err) { 234 this[kSentClose] = true 235 } 236 }) 237 238 // Upon either sending or receiving a Close control frame, it is said 239 // that _The WebSocket Closing Handshake is Started_ and that the 240 // WebSocket connection is in the CLOSING state. 241 this[kReadyState] = states.CLOSING 242 } else { 243 // Otherwise 244 // Set this's ready state to CLOSING (2). 245 this[kReadyState] = WebSocket.CLOSING 246 } 247 } 248 249 /** 250 * @see https://websockets.spec.whatwg.org/#dom-websocket-send 251 * @param {NodeJS.TypedArray|ArrayBuffer|Blob|string} data 252 */ 253 send (data) { 254 webidl.brandCheck(this, WebSocket) 255 256 webidl.argumentLengthCheck(arguments, 1, { header: 'WebSocket.send' }) 257 258 data = webidl.converters.WebSocketSendData(data) 259 260 // 1. If this's ready state is CONNECTING, then throw an 261 // "InvalidStateError" DOMException. 262 if (this[kReadyState] === WebSocket.CONNECTING) { 263 throw new DOMException('Sent before connected.', 'InvalidStateError') 264 } 265 266 // 2. Run the appropriate set of steps from the following list: 267 // https://datatracker.ietf.org/doc/html/rfc6455#section-6.1 268 // https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 269 270 if (!isEstablished(this) || isClosing(this)) { 271 return 272 } 273 274 /** @type {import('stream').Duplex} */ 275 const socket = this[kResponse].socket 276 277 // If data is a string 278 if (typeof data === 'string') { 279 // If the WebSocket connection is established and the WebSocket 280 // closing handshake has not yet started, then the user agent 281 // must send a WebSocket Message comprised of the data argument 282 // using a text frame opcode; if the data cannot be sent, e.g. 283 // because it would need to be buffered but the buffer is full, 284 // the user agent must flag the WebSocket as full and then close 285 // the WebSocket connection. Any invocation of this method with a 286 // string argument that does not throw an exception must increase 287 // the bufferedAmount attribute by the number of bytes needed to 288 // express the argument as UTF-8. 289 290 const value = Buffer.from(data) 291 const frame = new WebsocketFrameSend(value) 292 const buffer = frame.createFrame(opcodes.TEXT) 293 294 this.#bufferedAmount += value.byteLength 295 socket.write(buffer, () => { 296 this.#bufferedAmount -= value.byteLength 297 }) 298 } else if (types.isArrayBuffer(data)) { 299 // If the WebSocket connection is established, and the WebSocket 300 // closing handshake has not yet started, then the user agent must 301 // send a WebSocket Message comprised of data using a binary frame 302 // opcode; if the data cannot be sent, e.g. because it would need 303 // to be buffered but the buffer is full, the user agent must flag 304 // the WebSocket as full and then close the WebSocket connection. 305 // The data to be sent is the data stored in the buffer described 306 // by the ArrayBuffer object. Any invocation of this method with an 307 // ArrayBuffer argument that does not throw an exception must 308 // increase the bufferedAmount attribute by the length of the 309 // ArrayBuffer in bytes. 310 311 const value = Buffer.from(data) 312 const frame = new WebsocketFrameSend(value) 313 const buffer = frame.createFrame(opcodes.BINARY) 314 315 this.#bufferedAmount += value.byteLength 316 socket.write(buffer, () => { 317 this.#bufferedAmount -= value.byteLength 318 }) 319 } else if (ArrayBuffer.isView(data)) { 320 // If the WebSocket connection is established, and the WebSocket 321 // closing handshake has not yet started, then the user agent must 322 // send a WebSocket Message comprised of data using a binary frame 323 // opcode; if the data cannot be sent, e.g. because it would need to 324 // be buffered but the buffer is full, the user agent must flag the 325 // WebSocket as full and then close the WebSocket connection. The 326 // data to be sent is the data stored in the section of the buffer 327 // described by the ArrayBuffer object that data references. Any 328 // invocation of this method with this kind of argument that does 329 // not throw an exception must increase the bufferedAmount attribute 330 // by the length of data’s buffer in bytes. 331 332 const ab = Buffer.from(data, data.byteOffset, data.byteLength) 333 334 const frame = new WebsocketFrameSend(ab) 335 const buffer = frame.createFrame(opcodes.BINARY) 336 337 this.#bufferedAmount += ab.byteLength 338 socket.write(buffer, () => { 339 this.#bufferedAmount -= ab.byteLength 340 }) 341 } else if (isBlobLike(data)) { 342 // If the WebSocket connection is established, and the WebSocket 343 // closing handshake has not yet started, then the user agent must 344 // send a WebSocket Message comprised of data using a binary frame 345 // opcode; if the data cannot be sent, e.g. because it would need to 346 // be buffered but the buffer is full, the user agent must flag the 347 // WebSocket as full and then close the WebSocket connection. The data 348 // to be sent is the raw data represented by the Blob object. Any 349 // invocation of this method with a Blob argument that does not throw 350 // an exception must increase the bufferedAmount attribute by the size 351 // of the Blob object’s raw data, in bytes. 352 353 const frame = new WebsocketFrameSend() 354 355 data.arrayBuffer().then((ab) => { 356 const value = Buffer.from(ab) 357 frame.frameData = value 358 const buffer = frame.createFrame(opcodes.BINARY) 359 360 this.#bufferedAmount += value.byteLength 361 socket.write(buffer, () => { 362 this.#bufferedAmount -= value.byteLength 363 }) 364 }) 365 } 366 } 367 368 get readyState () { 369 webidl.brandCheck(this, WebSocket) 370 371 // The readyState getter steps are to return this's ready state. 372 return this[kReadyState] 373 } 374 375 get bufferedAmount () { 376 webidl.brandCheck(this, WebSocket) 377 378 return this.#bufferedAmount 379 } 380 381 get url () { 382 webidl.brandCheck(this, WebSocket) 383 384 // The url getter steps are to return this's url, serialized. 385 return URLSerializer(this[kWebSocketURL]) 386 } 387 388 get extensions () { 389 webidl.brandCheck(this, WebSocket) 390 391 return this.#extensions 392 } 393 394 get protocol () { 395 webidl.brandCheck(this, WebSocket) 396 397 return this.#protocol 398 } 399 400 get onopen () { 401 webidl.brandCheck(this, WebSocket) 402 403 return this.#events.open 404 } 405 406 set onopen (fn) { 407 webidl.brandCheck(this, WebSocket) 408 409 if (this.#events.open) { 410 this.removeEventListener('open', this.#events.open) 411 } 412 413 if (typeof fn === 'function') { 414 this.#events.open = fn 415 this.addEventListener('open', fn) 416 } else { 417 this.#events.open = null 418 } 419 } 420 421 get onerror () { 422 webidl.brandCheck(this, WebSocket) 423 424 return this.#events.error 425 } 426 427 set onerror (fn) { 428 webidl.brandCheck(this, WebSocket) 429 430 if (this.#events.error) { 431 this.removeEventListener('error', this.#events.error) 432 } 433 434 if (typeof fn === 'function') { 435 this.#events.error = fn 436 this.addEventListener('error', fn) 437 } else { 438 this.#events.error = null 439 } 440 } 441 442 get onclose () { 443 webidl.brandCheck(this, WebSocket) 444 445 return this.#events.close 446 } 447 448 set onclose (fn) { 449 webidl.brandCheck(this, WebSocket) 450 451 if (this.#events.close) { 452 this.removeEventListener('close', this.#events.close) 453 } 454 455 if (typeof fn === 'function') { 456 this.#events.close = fn 457 this.addEventListener('close', fn) 458 } else { 459 this.#events.close = null 460 } 461 } 462 463 get onmessage () { 464 webidl.brandCheck(this, WebSocket) 465 466 return this.#events.message 467 } 468 469 set onmessage (fn) { 470 webidl.brandCheck(this, WebSocket) 471 472 if (this.#events.message) { 473 this.removeEventListener('message', this.#events.message) 474 } 475 476 if (typeof fn === 'function') { 477 this.#events.message = fn 478 this.addEventListener('message', fn) 479 } else { 480 this.#events.message = null 481 } 482 } 483 484 get binaryType () { 485 webidl.brandCheck(this, WebSocket) 486 487 return this[kBinaryType] 488 } 489 490 set binaryType (type) { 491 webidl.brandCheck(this, WebSocket) 492 493 if (type !== 'blob' && type !== 'arraybuffer') { 494 this[kBinaryType] = 'blob' 495 } else { 496 this[kBinaryType] = type 497 } 498 } 499 500 /** 501 * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol 502 */ 503 #onConnectionEstablished (response) { 504 // processResponse is called when the "response’s header list has been received and initialized." 505 // once this happens, the connection is open 506 this[kResponse] = response 507 508 const parser = new ByteParser(this) 509 parser.on('drain', function onParserDrain () { 510 this.ws[kResponse].socket.resume() 511 }) 512 513 response.socket.ws = this 514 this[kByteParser] = parser 515 516 // 1. Change the ready state to OPEN (1). 517 this[kReadyState] = states.OPEN 518 519 // 2. Change the extensions attribute’s value to the extensions in use, if 520 // it is not the null value. 521 // https://datatracker.ietf.org/doc/html/rfc6455#section-9.1 522 const extensions = response.headersList.get('sec-websocket-extensions') 523 524 if (extensions !== null) { 525 this.#extensions = extensions 526 } 527 528 // 3. Change the protocol attribute’s value to the subprotocol in use, if 529 // it is not the null value. 530 // https://datatracker.ietf.org/doc/html/rfc6455#section-1.9 531 const protocol = response.headersList.get('sec-websocket-protocol') 532 533 if (protocol !== null) { 534 this.#protocol = protocol 535 } 536 537 // 4. Fire an event named open at the WebSocket object. 538 fireEvent('open', this) 539 } 540} 541 542// https://websockets.spec.whatwg.org/#dom-websocket-connecting 543WebSocket.CONNECTING = WebSocket.prototype.CONNECTING = states.CONNECTING 544// https://websockets.spec.whatwg.org/#dom-websocket-open 545WebSocket.OPEN = WebSocket.prototype.OPEN = states.OPEN 546// https://websockets.spec.whatwg.org/#dom-websocket-closing 547WebSocket.CLOSING = WebSocket.prototype.CLOSING = states.CLOSING 548// https://websockets.spec.whatwg.org/#dom-websocket-closed 549WebSocket.CLOSED = WebSocket.prototype.CLOSED = states.CLOSED 550 551Object.defineProperties(WebSocket.prototype, { 552 CONNECTING: staticPropertyDescriptors, 553 OPEN: staticPropertyDescriptors, 554 CLOSING: staticPropertyDescriptors, 555 CLOSED: staticPropertyDescriptors, 556 url: kEnumerableProperty, 557 readyState: kEnumerableProperty, 558 bufferedAmount: kEnumerableProperty, 559 onopen: kEnumerableProperty, 560 onerror: kEnumerableProperty, 561 onclose: kEnumerableProperty, 562 close: kEnumerableProperty, 563 onmessage: kEnumerableProperty, 564 binaryType: kEnumerableProperty, 565 send: kEnumerableProperty, 566 extensions: kEnumerableProperty, 567 protocol: kEnumerableProperty, 568 [Symbol.toStringTag]: { 569 value: 'WebSocket', 570 writable: false, 571 enumerable: false, 572 configurable: true 573 } 574}) 575 576Object.defineProperties(WebSocket, { 577 CONNECTING: staticPropertyDescriptors, 578 OPEN: staticPropertyDescriptors, 579 CLOSING: staticPropertyDescriptors, 580 CLOSED: staticPropertyDescriptors 581}) 582 583webidl.converters['sequence<DOMString>'] = webidl.sequenceConverter( 584 webidl.converters.DOMString 585) 586 587webidl.converters['DOMString or sequence<DOMString>'] = function (V) { 588 if (webidl.util.Type(V) === 'Object' && Symbol.iterator in V) { 589 return webidl.converters['sequence<DOMString>'](V) 590 } 591 592 return webidl.converters.DOMString(V) 593} 594 595// This implements the propsal made in https://github.com/whatwg/websockets/issues/42 596webidl.converters.WebSocketInit = webidl.dictionaryConverter([ 597 { 598 key: 'protocols', 599 converter: webidl.converters['DOMString or sequence<DOMString>'], 600 get defaultValue () { 601 return [] 602 } 603 }, 604 { 605 key: 'dispatcher', 606 converter: (V) => V, 607 get defaultValue () { 608 return getGlobalDispatcher() 609 } 610 }, 611 { 612 key: 'headers', 613 converter: webidl.nullableConverter(webidl.converters.HeadersInit) 614 } 615]) 616 617webidl.converters['DOMString or sequence<DOMString> or WebSocketInit'] = function (V) { 618 if (webidl.util.Type(V) === 'Object' && !(Symbol.iterator in V)) { 619 return webidl.converters.WebSocketInit(V) 620 } 621 622 return { protocols: webidl.converters['DOMString or sequence<DOMString>'](V) } 623} 624 625webidl.converters.WebSocketSendData = function (V) { 626 if (webidl.util.Type(V) === 'Object') { 627 if (isBlobLike(V)) { 628 return webidl.converters.Blob(V, { strict: false }) 629 } 630 631 if (ArrayBuffer.isView(V) || types.isAnyArrayBuffer(V)) { 632 return webidl.converters.BufferSource(V) 633 } 634 } 635 636 return webidl.converters.USVString(V) 637} 638 639module.exports = { 640 WebSocket 641} 642