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;