common/BasePluginManager.js

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

import {
  kBasePluginStatus,
} from './BasePlugin.js';

import logger from './logger.js';

export const kPluginManagerStart = Symbol('soundworks:plugin-manager-start');
export const kPluginManagerStop = Symbol('soundworks:plugin-manager-stop');
export const kPluginManagerInstances = Symbol('soundworks:plugin-manager-instances');

/**
 * Callback executed when a plugin internal state is updated.
 *
 * @callback pluginManagerOnStateChangeCallback
 * @param {object<string, ClientPlugin|ServerPlugin>} fullState - List of all plugins.
 * @param {ClientPlugin|ServerPlugin|null} initiator - Plugin that initiated the
 *  update. The value is `null` if the change was initiated by the state manager
 *  (e.g. when the initialization of the plugins starts).
 */

/**
 * Delete the registered {@link pluginManagerOnStateChangeCallback}.
 *
 * @callback pluginManagerDeleteOnStateChangeCallback
 */

/** @private */
class BasePluginManager {
  // node may be either the server or a client
  #node = null;
  #status = 'idle';
  #dependencies = new Map();
  #instanceStartPromises = new Map();
  #onStateChangeCallbacks = new Set();

  constructor(node) {
    this.#node = node;
    /** #private */
    this[kPluginManagerInstances] = new Map();
  }

  #propagateStateChange(instance = null, status = null) {
    if (instance !== null) {
      // status is null if we forward some inner state change from the instance
      if (status !== null) {
        instance[kBasePluginStatus] = status;
      }

      const fullState = Object.fromEntries(this[kPluginManagerInstances]);
      this.#onStateChangeCallbacks.forEach(callback => callback(fullState, instance));
    } else {
      const fullState = Object.fromEntries(this[kPluginManagerInstances]);
      this.#onStateChangeCallbacks.forEach(callback => callback(fullState, null));
    }
  }

  /**
   * Initialize all registered plugins.
   *
   * Executed during the `Client.init()` or `Server.init()` initialization step.
   *
   * @private
   */
  async [kPluginManagerStart]() {
    logger.title('starting registered plugins');

    if (this.#status !== 'idle') {
      throw new Error(`[soundworks:PluginManager] Cannot call "pluginManager.init()" twice`);
    }

    this.#status = 'inited';
    // instanciate all plugins
    for (let instance of this[kPluginManagerInstances].values()) {
      instance.onStateChange(() => this.#propagateStateChange(instance, null));
    }

    // propagate all 'idle' status before start
    this.#propagateStateChange(null, null);

    const promises = Array.from(this[kPluginManagerInstances].keys()).map(id => this.getUnsafe(id));

    try {
      await Promise.all(promises);
      this.#status = 'started';
    } catch (err) {
      this.#status = 'errored';
      throw err; // throw initial error
    }
  }

  /** @private */
  async [kPluginManagerStop]() {
    for (let instance of this[kPluginManagerInstances].values()) {
      await instance.stop();
    }
  }

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

  /**
   * Alias for existing plugins (i.e. plugin-scriptin), remove once updated
   * @private
   */
  async unsafeGet(id) {
    return this.getUnsafe(id);
  }

  /**
   * Retrieve an fully started instance of a registered plugin without checking
   * that the pluginManager is started.
   *
   * This method is required for starting the plugin manager itself and to require
   * a plugin from within another plugin.
   *
   * _Warning: Unless you are developing your own plugins, you should not have to use
   * this method_
   */
  async getUnsafe(id) {
    if (!isString(id)) {
      throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.get(name)" argument should be a string`);
    }

    if (!this[kPluginManagerInstances].has(id)) {
      throw new Error(`[soundworks:PluginManager] Cannot get plugin "${id}", plugin is not registered`);
    }

    // @note - For now, all instances are created at the beginning of `start()`
    // to be able to properly propagate the states. The code bellow should allow
    // to dynamically register and launch plugins at runtime.
    //
    // if (!this[kPluginManagerInstances].has(id)) {
    //   const { ctor, options } = this.#dependencies.get(id);
    //   const instance = new ctor(this.#node, id, options);
    //   this[kPluginManagerInstances].set(id, instance);
    // }

    const instance = this[kPluginManagerInstances].get(id);

    // recursively get the dependency chain
    const deps = this.#dependencies.get(id);
    const promises = deps.map(id => this.getUnsafe(id));

    await Promise.all(promises);

    // 'plugin.start' has already been called, just await the start promise
    if (this.#instanceStartPromises.has(id)) {
      await this.#instanceStartPromises.get(id);
    } else {
      this.#propagateStateChange(instance, 'inited');
      let errored = false;

      try {
        const startPromise = instance.start();
        this.#instanceStartPromises.set(id, startPromise);

        await startPromise;
      } catch (err) {
        errored = true;
        this.#propagateStateChange(instance, 'errored');
        throw err;
      }

      // this looks silly but it prevents the try / catch to catch errors that could
      // be triggered by the propagate status callback, putting the plugin in errored state
      if (!errored) {
        this.#propagateStateChange(instance, 'started');
      }
    }

    return instance;
  }

  /**
   * Register a plugin into the manager.
   *
   * _A plugin must always be registered both on client-side and on server-side_
   *
   * Refer to the plugin documentation to check its options and proper way of
   * registering it.
   *
   * @param {string} id - Unique id of the plugin. Enables the registration of the
   *  same plugin factory under different ids.
   * @param {Function} ctor - The class returned by the plugin factory method.
   * @param {object} [options={}] - Options to configure the plugin.
   * @param {array} [deps=[]] - List of plugins' names the plugin depends on, i.e.
   *  the plugin initialization will begin only after the plugins it depends on are
   *  fully started themselves.
   * @see {@link ClientPluginManager#register}
   * @see {@link ServerPluginManager#register}
   * @example
   * // client-side
   * client.pluginManager.register('user-defined-id', pluginFactory);
   * // server-side
   * server.pluginManager.register('user-defined-id', pluginFactory);
   */
  register(id, ctor, options = {}, deps = []) {
    // For now we don't allow to register a plugin after `client|server.init()`.
    // This is subject to change in the future as we may want to dynamically
    // register new plugins during application lifetime.
    if (this.#node.status === 'inited') {
      throw new Error(`[soundworks.PluginManager] Cannot register plugin (${id}) after "client.init()"`);
    }

    if (!isString(id)) {
      throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.register" first argument should be a string`);
    }

    if (!isPlainObject(options)) {
      throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.register" third optionnal argument should be an object`);
    }

    if (!Array.isArray(deps)) {
      throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.register" fourth optionnal argument should be an array`);
    }

    if (this[kPluginManagerInstances].has(id)) {
      throw new Error(`[soundworks:PluginManager] Plugin "${id}" already registered`);
    }

    // We instanciate the plugin here, so that a plugin can register another one
    // in its own constructor.
    //
    // The dependencies must be created first, so that the instance can call
    // addDependency in its constructor
    this.#dependencies.set(id, deps);

    const instance = new ctor(this.#node, id, options);
    this[kPluginManagerInstances].set(id, instance);
  }

  /**
   * Manually add a dependency to a given plugin.
   *
   * Usefull to require a plugin within a plugin
   */
  addDependency(pluginId, dependencyId) {
    const deps = this.#dependencies.get(pluginId);
    deps.push(dependencyId);
  }

  /**
   * Returns the list of the registered plugins ids
   * @returns {string[]}
   */
  getRegisteredPlugins() {
    return Array.from(this[kPluginManagerInstances].keys());
  }

  /**
   * Propagate a notification each time a plugin is updated (status or inner state).
   * The callback will receive the list of all plugins as first parameter, and the
   * plugin instance that initiated the state change event as second parameter.
   *
   * _In most cases, you should not have to rely on this method._
   *
   * @param {pluginManagerOnStateChangeCallback} callback - Callback to execute on state change
   * @returns {pluginManagerDeleteOnStateChangeCallback} - Clear the subscription when executed
   * @example
   * const unsubscribe = client.pluginManager.onStateChange(pluginList, initiator => {
   *   // log the current status of all plugins
   *   for (let name in pluginList) {
   *     console.log(name, pluginList[name].status);
   *   }
   *   // if the change was initiated by a plugin, log its status and state
   *   if (initiator !== null) {
   *.    console.log(initiator.name, initiator.status, initiator.state);
   *   }
   * });
   * // stop listening for updates later
   * unsubscribe();
   */
  onStateChange(callback) {
    this.#onStateChangeCallbacks.add(callback);
    return () => this.#onStateChangeCallbacks.delete(callback);
  }
}

export default BasePluginManager;