import { isPlainObject, isString } from '@ircam/sc-utils';
import logger from './logger.js';
/**
* Shared functionnality between server-side and client-size plugin manager
*
* @private
*/
class BasePluginManager {
constructor(node) {
// node may be either a soundworks server or a soundworks client
/** @private */
this._node = node;
/** @private */
this._dependencies = new Map();
/** @private */
this._instances = new Map();
/** @private */
this._instanceStartPromises = new Map();
/** @private */
this._onStateChangeCallbacks = new Set();
this.status = 'idle';
}
/**
* Register a plugin into soundworks.
*
* _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} factory - Factory function that returns the Plugin class.
* @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 start only after the plugins it depends on are
* fully started themselves.
* @see {@link client.PluginManager#register}
* @see {@link server.PluginManager#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._instances.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._instances.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._instances.keys());
}
/**
* Initialize all the registered plugin. Executed during the `Client.init()` or
* `Server.init()` initialization step.
* @private
*/
async start() {
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 [_id, instance] of this._instances.entries()) {
instance.onStateChange(_values => this._propagateStateChange(instance));
}
// propagate all 'idle' statuses before start
this._propagateStateChange();
const promises = Array.from(this._instances.keys()).map(id => this.unsafeGet(id));
try {
await Promise.all(promises);
this.status = 'started';
} catch (err) {
this.status = 'errored';
throw err; // throw initial error
}
}
/** @private */
async stop() {
for (let instance of this._instances.values()) {
await instance.stop();
}
}
/**
* Retrieve an fully started instance of a registered plugin, without checking
* that the pluginManager has started. This is required for starting the plugin
* manager itself and to require a plugin from within another plugin
*
* @private
*/
async unsafeGet(id) {
if (!isString(id)) {
throw new Error(`[soundworks.PluginManager] Invalid argument, "pluginManager.get(name)" argument should be a string`);
}
if (!this._instances.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._instances.has(id)) {
// const { ctor, options } = this._dependencies.get(id);
// const instance = new ctor(this._node, id, options);
// this._instances.set(id, instance);
// }
const instance = this._instances.get(id);
// recursively get the dependency chain
const deps = this._dependencies.get(id);
const promises = deps.map(id => this.unsafeGet(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;
}
/**
* 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 {client.PluginManager~onStateChangeCallback|server.PluginManager~onStateChangeCallback} callback
* Callback to be executed on state change
* @param {client.PluginManager~deleteOnStateChangeCallback|client.PluginManager~deleteOnStateChangeCallback}
* Function to execute to listening for changes.
* @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);
}
/** @private */
_propagateStateChange(instance = null, status = null) {
if (instance !== null) {
// status is null if wew forward some inner state change from the instance
if (status !== null) {
instance.status = status;
}
const fullState = Object.fromEntries(this._instances);
this._onStateChangeCallbacks.forEach(callback => callback(fullState, instance));
} else {
const fullState = Object.fromEntries(this._instances);
this._onStateChangeCallbacks.forEach(callback => callback(fullState, null));
}
}
}
export default BasePluginManager;