client/Client.js

import { isBrowser, isPlainObject } from '@ircam/sc-utils';

import ContextManager from './ContextManager.js';
import PluginManager from './PluginManager.js';
import Socket from './Socket.js';
import StateManager from './StateManager.js';
import {
  CLIENT_HANDSHAKE_REQUEST,
  CLIENT_HANDSHAKE_RESPONSE,
  CLIENT_HANDSHAKE_ERROR,
  AUDIT_STATE_NAME,
} from '../common/constants.js';
import logger from '../common/logger.js';

/**
 * Configuration object for a client running in a browser runtime.
 *
 * @typedef BrowserClientConfig
 * @memberof client
 * @type {object}
 * @property {string} role - Role of the client in the application (e.g. 'player', 'controller').
 * @property {object} [app] - Application configration object.
 * @property {string} [app.name=''] - Name of the application.
 * @property {string} [app.author=''] - Name of the author.
 * @property {object} [env] - Environment configration object.
 * @property {string} [env.websockets={}] - Configuration options for websockets.
 * @property {string} [env.subpath=''] - If running behind a proxy, path to the application.
 */

/**
 * Configuration object for a client running in a node runtime.
 *
 * @typedef NodeClientConfig
 * @memberof client
 * @type {object}
 * @property {string} role - Role of the client in the application (e.g. 'player', 'controller').
 * @property {object} [app] - Application configration object.
 * @property {string} [app.name=''] - Name of the application.
 * @property {string} [app.author=''] - Name of the author.
 * @property {object} env - Environment configration object.
 * @property {boolean} env.serverAddress - Domain name or IP of the server.
 * @property {boolean} env.useHttps - Define is the server run in http or in https.
 * @property {boolean} env.port - Port on which the server is listening.
 * @property {string} [env.websockets={}] - Configuration options for websockets.
 * @property {string} [env.subpath=''] - If running behind a proxy, path to the application.
 */

/**
 * The `Client` class is the main entry point for the client-side of a soundworks
 * application.
 *
 * A `Client` instance allows to access soundworks components such as {@link client.StateManager},
 * {@link client.PluginManager},{@link client.Socket} or {@link client.ContextManager}.
 * Its is also responsible for handling the initialization lifecycles of the different
 * soundworks components.
 *
 * ```
 * import { Client } from '@soundworks/core/client.js';
 * // create a new soundworks `Client` instance
 * const client = new Client({ role: 'player' });
 * // init and start the client
 * await client.start();
 * ```
 *
 * @memberof client
 */
class Client {
  /**
   * @param {client.BrowserClientConfig|client.NodeClientConfig} config -
   *  Configuration of the soundworks client.
   * @throws Will throw if the given config object is invalid.
   */
  constructor(config) {
    if (!isPlainObject(config)) {
      throw new Error(`[soundworks:Client] Invalid argument for Client constructor, config should be an object`);
    }

    if (!('role' in config)) {
      throw new Error('[soundworks:Client] Invalid config object, "config.role" should be defined');
    }

    // for node clients env.https is requires to open the websocket
    if (!isBrowser()) {
      if (!('env' in config)) {
        throw new Error('[soundworks:Client] Invalid config object, "config.env" { useHttps, serverAddress, port } should be defined');
      }

      let missing = [];

      if (!('useHttps' in config.env)) {
        missing.push('useHttps');
      }

      if (!('serverAddress' in config.env)) {
        missing.push('serverAddress');
      }

      if (!('port' in config.env)) {
        missing.push('port');
      }

      if (missing.length) {
        throw new Error(`[soundworks:Client] Invalid config object, "config.env" is missing: ${missing.join(', ')}`);
      }
    }

    /**
     * Role of the client in the application.
     *
     * @type {string}
     */
    this.role = config.role;

    /**
     * Configuration object.
     *
     * @type {client.BrowserClientConfig|client.NodeClientConfig}
     */
    this.config = config;

    if (!this.config.env) {
      this.config.env = {};
    }

    // minimal configuration for websockets
    this.config.env.websockets = Object.assign({
      path: 'socket',
      pingInterval: 5000,
    }, config.env.websockets);

    /**
     * Session id of the client (incremeted positive number), generated and
     * retrieved by the server during `client.init`. The counter is reset when
     * the server restarts.
     *
     * @type {number}
     */
    this.id = null;

    /**
     * Unique session uuid of the client (uuidv4), generated and retrieved by
     * the server during {@link client.Client#init}.
     *
     * @type {string}
     */
    this.uuid = null;

    /**
     * Instance of the {@link client.Socket} class that handle websockets communications with
     * the server.
     *
     * @see {@link client.Socket}
     * @type {client.Socket}
     */
    this.socket = new Socket();

    /**
     * Runtime platform on which the client is executed, i.e. 'browser' or 'node'.
     *
     * @type {string}
     */
    this.target = isBrowser() ? 'browser' : 'node';

    /**
     * Instance of the {@link client.ContextManager} class.
     *
     * The context manager can be safely used after `client.init()` has been fulfilled.
     *
     * @see {@link client.ContextManager}
     * @type {client.ContextManager}
     */
    this.contextManager = new ContextManager();

    /**
     * Instance of the {@link client.PluginManager} class.
     *
     * The context manager can be safely used after `client.init()` has been fulfilled.
     *
     * @see {@link client.PluginManager}
     * @type {client.PluginManager}
     */
    this.pluginManager = new PluginManager(this);

    /**
     * Instance of the {@link client.StateManager} class.
     *
     * The context manager can be safely used after `client.init()` has been fulfilled.
     *
     * @see {@link client.StateManager}
     * @type {client.StateManager}
     */
    this.stateManager = null;

    /**
     * Status of the client, 'idle', 'inited', 'started' or 'errored'.
     *
     * @type {string}
     */
    this.status = 'idle';

    /**
     * Token of the client if connected through HTTP authentication.
     * @private
     */
    this.token = null;

    /** @private */
    this._onStatusChangeCallbacks = new Set();
    /** @private */
    this._auditState = null;

    logger.configure(!!config.env.verbose);
  }

  /**
   * The `init` method is part of the initialization lifecycle of the `soundworks`
   * client. Most of the time, the `init` method will be implicitly called by the
   * {@link client.Client#start} method.
   *
   * In some situations you might want to call this method manually, in such cases
   * the method should be called before the {@link client.Client#start} method.
   *
   * What it does:
   * - connect the sockets to be server
   * - perform the handshake with soundworks server (retrieve id, etc.)
   * - launch the state manager
   * - initialize all registered plugin
   *
   * After `await client.init()` is fulfilled, the {@link client.Client#stateManager},
   * the {@link client.Client#pluginManager} and the {@link client.Client#socket}
   * can be safely used.
   *
   * @example
   * import { Client } from '@soundworks/core/client.js'
   *
   * const client = new Client(config);
   * // optionnal explicit call of `init` before `start`
   * await client.init();
   * await client.start();
   */
  async init() {
    // init socket communications (string and binary)
    await this.socket.init(this.role, this.config);

    // we need the try/catch block to change the promise rejection into proper error
    try {
      await new Promise((resolve, reject) => {
        // wait for handshake response before starting stateManager and pluginManager
        this.socket.addListener(CLIENT_HANDSHAKE_RESPONSE, async ({ id, uuid, token }) => {
          this.id = id;
          this.uuid = uuid;
          this.token = token;

          resolve();
        });

        this.socket.addListener(CLIENT_HANDSHAKE_ERROR, (err) => {
          let msg = ``;

          switch (err.type) {
            case 'invalid-client-type':
              msg = `[soundworks:Client] ${err.message}`;
              break;
            case 'invalid-plugin-list':
              msg = `[soundworks:Client] ${err.message}`;
              break;
            default:
              msg = `Undefined error`;
              break;
          }

          this.socket.terminate();
          reject(msg);
        });

        // send handshake request
        const payload = {
          role: this.role,
          registeredPlugins: this.pluginManager.getRegisteredPlugins(),
        };

        this.socket.send(CLIENT_HANDSHAKE_REQUEST, payload);
      });
    } catch (err) {
      throw new Error(err);
    }

    // ------------------------------------------------------------
    // CREATE STATE MANAGER
    // ------------------------------------------------------------
    this.stateManager = new StateManager(this.id, {
      emit: this.socket.send.bind(this.socket), // need to alias this
      addListener: this.socket.addListener.bind(this.socket),
      removeAllListeners: this.socket.removeAllListeners.bind(this.socket),
    });

    // ------------------------------------------------------------
    // INIT PLUGIN MANAGER
    // ------------------------------------------------------------
    await this.pluginManager.start();

    await this._dispatchStatus('inited');
  }

  /**
   * The `start` method is part of the initialization lifecycle of the `soundworks`
   * client. The `start` method will implicitly call the {@link client.Client#init}
   * method if it has not been called manually.
   *
   * What it does:
   * - implicitly call {@link client.Client#init} if not done manually
   * - start all created contexts. For that to happen, you will have to call `client.init`
   * manually and instantiate the contexts between `client.init()` and `client.start()`
   *
   * @example
   * import { Client } from '@soundworks/core/client.js'
   *
   * const client = new Client(config);
   * await client.start();
   */
  async start() {
    if (this.status === 'idle') {
      await this.init();
    }

    if (this.status === 'started') {
      throw new Error(`[soundworks:Server] Cannot call "client.start()" twice`);
    }

    if (this.status !== 'inited') {
      throw new Error(`[soundworks:Server] Cannot "client.start()" before "client.init()"`);
    }

    // ------------------------------------------------------------
    // START CONTEXT MANAGER
    // ------------------------------------------------------------
    await this.contextManager.start();

    await this._dispatchStatus('started');
  }

  /**
   * Stops all started contexts, plugins and terminates the socket connections.
   *
   * In most situations, you might not need to call this method. However, it can
   * be usefull for unit testing or similar situations where you want to create
   * and delete several clients in the same process.
   *
   * @example
   * import { Client } from '@soundworks/core/client.js'
   *
   * const client = new Client(config);
   * await client.start();
   *
   * await new Promise(resolve => setTimeout(resolve, 1000));
   * await client.stop();
   */
  async stop() {
    if (this.status !== 'started') {
      throw new Error(`[soundworks:Client] Cannot "client.stop()" before "client.start()"`);
    }

    await this.contextManager.stop();
    await this.pluginManager.stop();
    await this.socket.terminate();

    await this._dispatchStatus('stopped');
  }

  /**
   * Attach and retrieve the global audit state of the application.
   *
   * The audit state is a {@link client.SharedState} instance that keeps track of
   * global informations about the application such as, the number of connected
   * clients, network latency estimation, etc. It is usefull for controller client
   * roles to give the user an overview about the state of the application.
   *
   * The audit state is lazily attached to the client only if this method is called.
   *
   * @returns {Promise<client.SharedState>}
   * @throws Will throw if called before `client.init()`
   * @see {@link client.SharedState}
   * @example
   * const auditState = await client.getAuditState();
   * auditState.onUpdate(() => console.log(auditState.getValues()), true);
   */
  async getAuditState() {
    if (this.status === 'idle') {
      throw new Error(`[soundworks.Client] Cannot access audit state before "client.init()"`);
    }

    if (this._auditState === null) {
      this._auditState = await this.stateManager.attach(AUDIT_STATE_NAME);
    }

    return this._auditState;
  }

  /**
   * Listen for the status change ('inited', 'started', 'stopped') of the client.
   *
   * @param {Function} callback - Listener to the status change.
   * @returns {Function} Delete the listener.
   */
  onStatusChange(callback) {
    this._onStatusChangeCallbacks.add(callback);

    return () => this._onStatusChangeCallbacks.delete(callback);
  }

  /** @private */
  async _dispatchStatus(status) {
    this.status = status;

    // if node target and launched in a child process, forward status to parent process
    if (this.target === 'node' && process.send !== undefined) {
      process.send(`soundworks:client:${status}`);
    }

    // execute all callbacks in parallel
    const promises = [];

    for (let callback of this._onStatusChangeCallbacks) {
      promises.push(callback(status));
    }

    await Promise.all(promises);
  }
}

export default Client;