thoov/mock-socket

View on GitHub
src/websocket.js

Summary

Maintainability
D
1 day
Test Coverage
import delay from './helpers/delay';
import logger from './helpers/logger';
import EventTarget from './event/target';
import networkBridge from './network-bridge';
import proxyFactory from './helpers/proxy-factory';
import lengthInUtf8Bytes from './helpers/byte-length';
import { CLOSE_CODES, ERROR_PREFIX } from './constants';
import urlVerification from './helpers/url-verification';
import normalizeSendData from './helpers/normalize-send';
import protocolVerification from './helpers/protocol-verification';
import { createEvent, createMessageEvent, createCloseEvent } from './event/factory';
import { closeWebSocketConnection, failWebSocketConnection } from './algorithms/close';

/*
 * The main websocket class which is designed to mimick the native WebSocket class as close
 * as possible.
 *
 * https://html.spec.whatwg.org/multipage/web-sockets.html
 */
class WebSocket extends EventTarget {
  constructor(url, protocols) {
    super();

    this._onopen = null;
    this._onmessage = null;
    this._onerror = null;
    this._onclose = null;

    this.url = urlVerification(url);
    protocols = protocolVerification(protocols);
    this.protocol = protocols[0] || '';

    this.binaryType = 'blob';
    this.readyState = WebSocket.CONNECTING;

    const client = proxyFactory(this);
    const server = networkBridge.attachWebSocket(client, this.url);

    /*
     * This delay is needed so that we dont trigger an event before the callbacks have been
     * setup. For example:
     *
     * var socket = new WebSocket('ws://localhost');
     *
     * If we dont have the delay then the event would be triggered right here and this is
     * before the onopen had a chance to register itself.
     *
     * socket.onopen = () => { // this would never be called };
     *
     * and with the delay the event gets triggered here after all of the callbacks have been
     * registered :-)
     */
    delay(function delayCallback() {
      if (this.readyState !== WebSocket.CONNECTING) {
        return;
      }
      if (server) {
        if (
          server.options.verifyClient &&
          typeof server.options.verifyClient === 'function' &&
          !server.options.verifyClient()
        ) {
          this.readyState = WebSocket.CLOSED;

          logger(
            'error',
            `WebSocket connection to '${this.url}' failed: HTTP Authentication failed; no valid credentials available`
          );

          networkBridge.removeWebSocket(client, this.url);
          this.dispatchEvent(createEvent({ type: 'error', target: this }));
          this.dispatchEvent(createCloseEvent({ type: 'close', target: this, code: CLOSE_CODES.CLOSE_NORMAL }));
        } else {
          if (server.options.selectProtocol && typeof server.options.selectProtocol === 'function') {
            const selectedProtocol = server.options.selectProtocol(protocols);
            const isFilled = selectedProtocol !== '';
            const isRequested = protocols.indexOf(selectedProtocol) !== -1;
            if (isFilled && !isRequested) {
              this.readyState = WebSocket.CLOSED;

              logger('error', `WebSocket connection to '${this.url}' failed: Invalid Sub-Protocol`);

              networkBridge.removeWebSocket(client, this.url);
              this.dispatchEvent(createEvent({ type: 'error', target: this }));
              this.dispatchEvent(createCloseEvent({ type: 'close', target: this, code: CLOSE_CODES.CLOSE_NORMAL }));
              return;
            }
            this.protocol = selectedProtocol;
          }
          this.readyState = WebSocket.OPEN;
          this.dispatchEvent(createEvent({ type: 'open', target: this }));
          server.dispatchEvent(createEvent({ type: 'connection' }), client);
        }
      } else {
        this.readyState = WebSocket.CLOSED;
        this.dispatchEvent(createEvent({ type: 'error', target: this }));
        this.dispatchEvent(createCloseEvent({ type: 'close', target: this, code: CLOSE_CODES.CLOSE_NORMAL }));

        logger('error', `WebSocket connection to '${this.url}' failed`);
      }
    }, this);
  }

  get onopen() {
    return this._onopen;
  }

  get onmessage() {
    return this._onmessage;
  }

  get onclose() {
    return this._onclose;
  }

  get onerror() {
    return this._onerror;
  }

  set onopen(listener) {
    this.removeEventListener('open', this._onopen);
    this._onopen = listener;
    this.addEventListener('open', listener);
  }

  set onmessage(listener) {
    this.removeEventListener('message', this._onmessage);
    this._onmessage = listener;
    this.addEventListener('message', listener);
  }

  set onclose(listener) {
    this.removeEventListener('close', this._onclose);
    this._onclose = listener;
    this.addEventListener('close', listener);
  }

  set onerror(listener) {
    this.removeEventListener('error', this._onerror);
    this._onerror = listener;
    this.addEventListener('error', listener);
  }

  send(data) {
    if (this.readyState === WebSocket.CONNECTING) {
      // TODO: node>=17 replace with DOMException
      throw new Error("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state");
    }

    // TODO: handle bufferedAmount

    const messageEvent = createMessageEvent({
      type: 'server::message',
      origin: this.url,
      data: normalizeSendData(data)
    });

    const server = networkBridge.serverLookup(this.url);

    if (server) {
      delay(() => {
        this.dispatchEvent(messageEvent, data);
      }, server);
    }
  }

  close(code, reason) {
    if (code !== undefined) {
      if (typeof code !== 'number' || (code !== 1000 && (code < 3000 || code > 4999))) {
        throw new TypeError(
          `${ERROR_PREFIX.CLOSE_ERROR} The code must be either 1000, or between 3000 and 4999. ${code} is neither.`
        );
      }
    }

    if (reason !== undefined) {
      const length = lengthInUtf8Bytes(reason);

      if (length > 123) {
        throw new SyntaxError(`${ERROR_PREFIX.CLOSE_ERROR} The message must not be greater than 123 bytes.`);
      }
    }

    if (this.readyState === WebSocket.CLOSING || this.readyState === WebSocket.CLOSED) {
      return;
    }

    const client = proxyFactory(this);
    if (this.readyState === WebSocket.CONNECTING) {
      failWebSocketConnection(client, code || CLOSE_CODES.CLOSE_ABNORMAL, reason);
    } else {
      closeWebSocketConnection(client, code || CLOSE_CODES.CLOSE_NO_STATUS, reason);
    }
  }
}

WebSocket.CONNECTING = 0;
WebSocket.prototype.CONNECTING = WebSocket.CONNECTING;
WebSocket.OPEN = 1;
WebSocket.prototype.OPEN = WebSocket.OPEN;
WebSocket.CLOSING = 2;
WebSocket.prototype.CLOSING = WebSocket.CLOSING;
WebSocket.CLOSED = 3;
WebSocket.prototype.CLOSED = WebSocket.CLOSED;

export default WebSocket;