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