sc.js

const WebSocket = require('ws');
const { get } = require('axios');
const { EventEmitter } = require('events');

class SC extends EventEmitter {
  /**
   * StreamCompanion constructor
   * @param {Object} config
   * @param {string} config.host SC Host address
   * @param {Number} config.port SC Port number
   * @param {'http'|'https'} config.proto SC HTTP protocol. This will also change the websocket protocol to `wss` if `https` is used here
   * @param {string[]} config.watchTokens Default tokens to watch/get
   * @param {boolean} [config.initializeAutomatically=true] Automatically create and connect to WebSockets
   * @param {Object} config.listeners
   * @param {boolean} [config.listeners.tokens=true] Toggle token websocket
   * @param {boolean} [config.listeners.mapData=true] Toggle map data websocket
   * @param {boolean} [config.listeners.liveData=false] Toggle live data websocket
   * @param {Object} config.ws
   * @param {Number} config.ws.reconnectInterval Reconnect interval
   * @param {Number} config.ws.maxTries Max tries to reconnect before emitting an error
   */
  constructor (config) {
    super();
    this.config = Object.assign({
      host: 'localhost',
      port: 20727,
      proto: 'http',
      watchTokens: [],
      initializeAutomatically: true,
      listeners: {
        tokens: true,
        mapData: true,
        liveData: false
      },
      ws: {
        reconnectInterval: 3000,
        maxTries: 5
      }
    }, config);

    this.url = `${this.config.proto}://${this.config.host}:${this.config.port}`;
    this.wsUrl = `${this.config.proto === 'http' ? 'ws' : 'wss'}://${this.config.host}:${this.config.port}`;
    this.ws = {};
    this.initialized = false;
    if (this.config.initializeAutomatically) this.initialize();
  }

  /**
   * Initialize/Start creating WebSocket clients  
   * You need to run this if `config.initializeAutomatically` is false
   * @method
   */
  initialize () {
    this.initialized = true;
    if (this.config.listeners.tokens) {
      this.tokens = {};
      this.watchedTokens = this.config.watchTokens;
      if (this.ws.token instanceof WebSocket) this.ws.token.removeAllListeners();
      this.ws.token = this._createWS(`${this.wsUrl}/tokens`, 'token');
    }

    if (this.config.listeners.mapData) {
      this.data = {};
      if (this.ws.mapData instanceof WebSocket) this.ws.mapData.removeAllListeners();
      this.ws.mapData = this._createWS(`${this.wsUrl}/mapData`, 'mapData');
    }

    if (this.config.listeners.liveData) {
      this.live = {};
      if (this.ws.liveData instanceof WebSocket) this.ws.liveData.removeAllListeners();
      this.ws.liveData = this._createWS(`${this.wsUrl}/liveData`, 'liveData');
    }
  }

  /**
   * Create new WebSocket client
   * @private
   * @param {string} url WebSocket URI
   * @param {string} type WebSocket Type
   * @param {number=} reconnectTries Reconnect tries count
   */
  _createWS (url, type, reconnectTries = 0) {
    const ws = new WebSocket(url);
    ws.type = type;
    ws.reconnectTries = reconnectTries;
    this._listenHandler(ws);
    return ws;
  }

  /**
   * WebSocket listener handler
   * @private
   * @param {WebSocket} ws
   */
  _listenHandler (ws) {
    ws.on('open', () => {
      /**
       * Emitted when a websocket is open
       * @event SC#ready
       *
       * @type {string}
       */
      this.emit('ready', ws.type);
      if (ws.reconnectTries > 0) ws.reconnectTries = 0;
      if (ws.type === 'token') ws.send(JSON.stringify(this.watchedTokens));
    });

    ws.on('message', (data) => {
      data = data.trim();
      if (data.length === 0) return;

      data = JSON.parse(data);
      if (ws.type === 'token') this.tokens = data;
      else if (ws.type === 'mapData') this.data = data;
      else this.live = data;

      /**
       * Emitted when data is received from any of the enabled websockets
       * @event SC#data
       *
       * @type {object}
       * @property {string} type WebSocket type
       * @property {any} data Received data
       */
      this.emit('data', {
        type: ws.type,
        data
      });
    });

    ws.on('error', (error) => {
      /**
       * Emitted when an expected or unexpected error has occured
       * @event SC#error
       *
       * @type {object}
       * @property {WebSocket | string | null} ws WebSocket
       * @property {Error | TypeError} error Error
       */
      if ((ws.reconnectTries === 0 && !error.message.includes('connect')) || ws.reconnectTries >= this.config.ws.maxTries) this.emit('error', { ws, error });
    });
    ws.on('close', () => {
      /**
       * Emitted when a websocket is disconnected from StreamCompanion
       * @event SC#disconnect
       *
       * @type {string}
       */
      this.emit('disconnect', ws.type);
      if (ws.reconnectTries >= this.config.ws.maxTries) {
        return this.emit('error', {
          ws,
          error: new Error(`Failed to reconnect WebSocket after retrying ${this.config.ws.maxTries} times.`)
        });
      }

      setTimeout(() => {
        /**
         * @event SC#reconnecting
         * @type {string}
         */
        this.emit('reconnecting', ws.type);
        ws.removeAllListeners();
        this.ws[ws.type] = this._createWS(ws.url, ws.type, ws.reconnectTries + 1);
      }, this.config.ws.reconnectInterval);
    });
  }

  /**
   * Get JSON data of a map
   * @method
   * @returns {Promise<object>}
   */
  getJson () {
    return new Promise((resolve, reject) => {
      get(`${this.url}/json`)
        .then(res => resolve(res.data))
        .catch(err => reject(err));
    });
  }

  /**
   * Get map background image data
   * @method
   * @returns {Promise<object>}
   */
  getBackground () {
    return new Promise((resolve, reject) => {
      get(`${this.url}/backgroundImage`)
        .then(res => resolve(res.data))
        .catch(err => reject(err));
    });
  }

  /**
   * Get web overlay list
   * @method
   * @returns {Promise<object>}
   */
  getOverlayList () {
    return new Promise((resolve, reject) => {
      get(`${this.url}/overlayList`)
        .then(res => resolve(res.data))
        .catch(err => reject(err));
    });
  }

  /**
   * Get SC settings
   * @method
   * @returns {Promise<object>}
   */
  getSettings () {
    return new Promise((resolve, reject) => {
      get(`${this.url}/settings`)
        .then(res => resolve(res.data))
        .catch(err => reject(err));
    });
  }

  /**
   * Get name value from live listener cache if exist.
   * Returns all cached live data if no name is given
   * @method
   * @param {string=} name Live data key/name
   * @returns {?string|object} Live data value `string`, entire live data cache `object` or `null` if the live data name doesn't exist
   */
  getLiveData (name) {
    if (!this.initialized) {
      this.emit('error', {
        ws: null,
        error: new Error('Not initialized')
      });
      return;
    }
    if (!this.config.listeners.liveData) {
      this.emit('error', {
        ws: 'liveData',
        error: new Error('Live data listener is not active!')
      });
      return;
    }
    if (!name) return this.live;
    return this.live[name];
  }

  /**
   * Get name value from map data listener cache if exist.
   * Returns all cached map data if no name is given
   * @method
   * @param {string=} name Map data key/name
   * @returns {?string|object} Data value `string`, entire data cache `object` or `null` if the data name doesn't exist
   */
  getData (name) {
    if (!this.initialized) {
      this.emit('error', {
        ws: null,
        error: new Error('Not initialized')
      });
      return;
    }
    if (!this.config.listeners.mapData) {
      this.emit('error', {
        ws: 'mapData',
        error: new Error('Map data listener is not active!')
      });
      return;
    }
    if (!name) return this.data;
    return this.data[name];
  }

  /**
   * Get name value from token listener cache if exist.
   * Returns all cached tokens if no name is given
   * @method
   * @param {string=} token Token key/name
   * @returns {?string|object} Token value `string`, entire token cache `object` or `null` if the token doesn't exist
   */
  getToken (token) {
    if (!this.initialized) {
      this.emit('error', {
        ws: null,
        error: new Error('Not initialized')
      });
      return;
    }
    if (!this.config.listeners.tokens) {
      this.emit('error', {
        ws: 'token',
        error: new Error('Tokens listener is not active!')
      });
      return;
    }
    if (!token) return this.tokens;
    if (this.watchedTokens.indexOf(token) === -1) this.addToken(token);
    return this.tokens[token];
  }

  /**
   * Add/register token to the listener
   * @method
   * @param {string} token Token key/name
   * @returns {Boolean}
   */
  addToken (token) {
    if (!this.initialized) {
      this.emit('error', {
        ws: null,
        error: new Error('Not initialized')
      });
      return false;
    }

    if (!this.config.listeners.tokens) {
      this.emit('error', {
        ws: 'token',
        error: new Error('Tokens listener is not active!')
      });
      return false;
    }

    if (!token || typeof token !== 'string') {
      this.emit('error', {
        ws: 'token',
        error: new TypeError('Token name is not string!')
      });
      return false;
    }

    if (this.watchedTokens.includes(token)) return false;
    this.watchedTokens.push(token);
    if (this.ws.token.readyState === 1) {
      this.ws.token.send(JSON.stringify(this.watchedTokens));
      return true;
    }
    return false;
  }

  /**
   * Remove/unregister token from the listener
   * @method
   * @param {string} token Token key/name
   * @returns {Boolean}
   */
  removeToken (token) {
    if (!this.initialized) {
      this.emit('error', {
        ws: null,
        error: new Error('Not initialized')
      });
      return false;
    }

    if (!this.config.listeners.tokens) {
      this.emit('error', {
        ws: 'token',
        error: new Error('Tokens listener is not active!')
      });
      return false;
    }

    if (!token || typeof token !== 'string') {
      this.emit('error', {
        ws: 'token',
        error: new TypeError('Token name is not string!')
      });
      return false;
    }
    const i = this.watchedTokens.indexOf(token);
    if (i === -1) return false;
    this.watchedTokens.splice(i, 1);
    if (this.ws.token.readyState === 1) {
      this.ws.token.send(JSON.stringify(this.watchedTokens));
      return true;
    }
    return false;
  }

  /**
   * @static
   */
  static get osuStatus () {
    return {
      Null: 0,
      Listening: 1,
      Playing: 2,
      Watching: 8,
      Editing: 16,
      ResultsScreen: 32
    };
  }

  /**
   * @static
   */
  static get rawOsuStatus () {
    return {
      Unknown: -2,
      NotRunning: -1,
      MainMenu: 0,
      EditingMap: 1,
      Playing: 2,
      GameShutdownAnimation: 3,
      SongSelectEdit: 4,
      SongSelect: 5,
      ResultsScreen: 7,
      GameStartupAnimation: 10,
      MultiplayerRooms: 11,
      MultiplayerRoom: 12,
      MultiplayerSongSelect: 13,
      MultiplayerResultsscreen: 14,
      OsuDirect: 15,
      RankingTagCoop: 17,
      RankingTeam: 18,
      ProcessingBeatmaps: 19,
      Tourney: 22
    };
  }

  /**
   * @static
   */
  static get osuGrade () {
    return {
      0: 'SSH',
      1: 'SH',
      2: 'SS',
      3: 'S',
      4: 'A',
      5: 'B',
      6: 'C',
      7: 'D',
      8: 'F',
      9: ''
    };
  }
}

module.exports = SC;