client/Client.js

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

import ClientContextManager, {
  kClientContextManagerStart,
  kClientContextManagerStop,
} from './ClientContextManager.js';
import ClientPluginManager from './ClientPluginManager.js';
import {
  kPluginManagerStart,
  kPluginManagerStop,
} from '../common/BasePluginManager.js';
import ClientSocket, {
  kSocketTerminate,
} from './ClientSocket.js';
import ClientStateManager from './ClientStateManager.js';
import {
  kStateManagerInit,
} from '../common/BaseStateManager.js';

import {
  CLIENT_HANDSHAKE_REQUEST,
  CLIENT_HANDSHAKE_RESPONSE,
  CLIENT_HANDSHAKE_ERROR,
  AUDIT_STATE_NAME,
} from '../common/constants.js';
import logger from '../common/logger.js';
import VERSION from '../common/version.js';

// for testing purposes
export const kClientVersionTest = Symbol('soundworks:client-version-test');
export const kClientOnStatusChangeCallbacks = Symbol('soundworks:client-on-status-change-callbacks');

/**
 * The `Client` class is the main entry point for the client-side of a soundworks
 * application.
 *
 * A `soundworks` client can run seamlessly in a browser or in a Node.js runtime.
 *
 * It provides an access to the different soundworks components such as the {@link ClientStateManager},
 * {@link ClientPluginManager}, {@link ClientSocket} and the {@link ClientContextManager}.
 *
 * ```
 * import { Client } from '@soundworks/core/client.js';
 * // create a `Client` instance
 * const client = new Client({
 *   role: 'player',
 *   env: {
 *     useHttps: false,
 *     serverAddress: 'localhost',
 *     port: 8000,
 *   },
 * });
 * // start the client
 * await client.start();
 * ```
 */
class Client {
  #config = null;
  #version = null;
  #role = null;
  #id = null;
  #uuid = null;
  #target = null;
  #socket = null;
  #contextManager = null;
  #pluginManager = null;
  #stateManager = null;
  #status = 'idle';
  // Token of the client if connected through HTTP authentication.
  #token = null;
  #auditState = null;

  /**
   * @param {ClientConfig} 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(', ')}`);
      }
    }

    this.#config = config;

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

    this.#version = VERSION;
    // allow override though config for testing
    if (config[kClientVersionTest]) {
      this.#version = config[kClientVersionTest];
    }

    this.#role = config.role;
    this.#target = isBrowser() ? 'browser' : 'node';
    this.#socket = new ClientSocket(this.#role, this.#config, {
      path: 'socket',
      retryConnectionRefusedTimeout: 1000,
    });
    this.#contextManager = new ClientContextManager();
    this.#pluginManager = new ClientPluginManager(this);
    this.#stateManager = new ClientStateManager();
    this.#status = 'idle';

    this[kClientOnStatusChangeCallbacks] = new Set();

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

  /**
   * Package version.
   *
   * @type {string}
   */
  get version() {
    return this.#version;
  }

  /**
   * Role of the client in the application.
   *
   * @type {string}
   */
  get role() {
    return this.#role;
  }

  /**
   * Configuration object.
   *
   * @type {ClientConfig}
   */
  get config() {
    return this.#config;
  }

  /**
   * Session id of the client.
   *
   * Incremeted positive integer generated and retrieved by the server during
   * `client.init`. The counter is reset when the server restarts.
   *
   * @type {number}
   */
  get id() {
    return this.#id;
  }

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


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

  /**
   * Instance of the {@link client.Socket} class.
   *
   * @see {@link ClientSocket}
   * @type {ClientSocket}
   */
  get socket() {
    return this.#socket;
  }

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

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

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

  /**
   * Status of the client.
   *
   * @type {'idle'|'inited'|'started'|'errored'}
   */
  get status() {
    return this.#status;
  }

    /** @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[kClientOnStatusChangeCallbacks]) {
      promises.push(callback(status));
    }

    await Promise.all(promises);
  }

  /**
   * The `init` method is part of the initialization lifecycle of the `soundworks`
   * client. Most of the time, this method will be implicitly executed by the
   * {@link 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#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#stateManager},
   * the {@link Client#pluginManager} and the {@link 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
    await this.#socket.init();

    // 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, version }) => {
          this.#id = id;
          this.#uuid = uuid;
          this.#token = token;

          if (version !== this.#version) {
            logger.warnVersionDiscepancies(this.#role, this.#version, version);
          }

          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 = `[soundworks:Client] Undefined error: ${err.message}`;
              break;
          }

          // These are development errors, we can just hang. If we terminate the
          // socket, a reload is triggered by the launcher which is bad in terms of DX
          reject(msg);
        });

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

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

    // init state manager
    this.#stateManager[kStateManagerInit](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),
    });

    await this.#pluginManager[kPluginManagerStart]();

    await this.#dispatchStatus('inited');
  }

  /**
   * The `start` method is part of the initialization lifecycle of the `soundworks`
   * client. This method will implicitly execute {@link Client#init} method if it
   * has not been called manually.
   *
   * What it does:
   * - implicitly call {@link 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`);
    }

    await this.#contextManager[kClientContextManagerStart]();
    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[kClientContextManagerStop]();
    await this.#pluginManager[kPluginManagerStop]();
    await this.#socket[kSocketTerminate]();

    await this.#dispatchStatus('stopped');
  }

  /**
   * Attach and retrieve the global audit state of the application.
   *
   * The audit state is a {@link 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<SharedState>}
   * @throws Will throw if called before `client.init()`
   * @see {@link 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} Function that delete the listener when executed.
   */
  onStatusChange(callback) {
    this[kClientOnStatusChangeCallbacks].add(callback);
    return () => this[kClientOnStatusChangeCallbacks].delete(callback);
  }
}

Object.defineProperties(Client.prototype, {
  [Symbol.toStringTag]: {
    __proto__: null,
    writable: false,
    enumerable: false,
    configurable: true,
    value: 'Client',
  },
});

export default Client;