import {
isString,
isFunction,
isPlainObject,
} from '@ircam/sc-utils';
import SharedState from './SharedState.js';
import SharedStateCollection, {
kSharedStateCollectionInit,
} from './SharedStateCollection.js';
import BatchedTransport from './BatchedTransport.js';
import ParameterBag from './ParameterBag.js';
import PromiseStore from './PromiseStore.js';
import {
CREATE_REQUEST,
CREATE_RESPONSE,
CREATE_ERROR,
ATTACH_REQUEST,
ATTACH_RESPONSE,
ATTACH_ERROR,
OBSERVE_REQUEST,
OBSERVE_RESPONSE,
OBSERVE_ERROR,
OBSERVE_NOTIFICATION,
UNOBSERVE_NOTIFICATION,
GET_CLASS_DESCRIPTION_REQUEST,
GET_CLASS_DESCRIPTION_RESPONSE,
GET_CLASS_DESCRIPTION_ERROR,
} from './constants.js';
import warnings from './logs/warnings.js';
export const kStateManagerInit = Symbol('soundworks:state-manager-init');
export const kStateManagerDeleteState = Symbol('soundworks:state-manager-delete-state');
export const kPendingSharedStateConstructionData = Symbol('soundworks:state-manager-pending-shared-state-construction-data');
// for testing purposes
export const kStateManagerClient = Symbol('soundworks:state-manager-client');
/**
* Callback executed when a state is created on the network.
*
* @callback stateManagerObserveCallback
* @async
* @param {string} className - name of the class
* @param {number} stateId - id of the state
* @param {number} nodeId - id of the node that created the state
*/
/**
* Callback to execute in order to remove a {@link stateManagerObserveCallback}
* from the list of observer.
*
* @callback stateManagerDeleteObserveCallback
*/
/** @private */
class BaseStateManager {
#statesById = new Map();
#observeListeners = new Set(); // Set <[observedClassName, callback, options]>
#observeRequestCallbacks = new Map(); // Map <reqId, [observedClassName, callback, options]>
#promiseStore = null;
#status = 'idle';
constructor() {
this.#promiseStore = new PromiseStore('BaseStateManager');
/** @private */
this[kStateManagerClient] = null;
}
/** @private */
[kStateManagerDeleteState](stateId) {
this.#statesById.delete(stateId);
}
/**
* Executed on `client.init`
* @param {Number} id - Id of the node.
* @param {Object} transport - Transport to use for synchronizing the states.
* Must implement a basic EventEmitter API.
*
* @private
*/
[kStateManagerInit](id, transport) {
const batchedTransport = new BatchedTransport(transport);
this[kStateManagerClient] = { id, transport: batchedTransport };
this[kStateManagerClient].transport.addListener(CREATE_RESPONSE, this.#onCreateResponse);
this[kStateManagerClient].transport.addListener(CREATE_ERROR, this.#onCreateError);
this[kStateManagerClient].transport.addListener(ATTACH_RESPONSE, this.#onAttachResponse);
this[kStateManagerClient].transport.addListener(ATTACH_ERROR, this.#onAttachError);
this[kStateManagerClient].transport.addListener(OBSERVE_RESPONSE, this.#onObserveResponse);
this[kStateManagerClient].transport.addListener(OBSERVE_ERROR, this.#onObserveError);
this[kStateManagerClient].transport.addListener(OBSERVE_NOTIFICATION, this.#onObserveNotification);
this[kStateManagerClient].transport.addListener(GET_CLASS_DESCRIPTION_RESPONSE, this.#onGetClassDescriptionResponse);
this[kStateManagerClient].transport.addListener(GET_CLASS_DESCRIPTION_ERROR, this.#onGetClassDescriptionError);
// ---------------------------------------------
// note 2025-05-05: caching of class descriptions has been removed because it
// could create concurrency issues
// cf. `should be able to recreate a class with the same name` unit test
// ---------------------------------------------
// this[kStateManagerClient].transport.addListener(DELETE_SHARED_STATE_CLASS, _className => {});
this.#status = 'inited';
}
// This pattern allows to have a clean SharedState constructor signature for which
// which will provide a cleaner user facing API when derived.
// cf. instantiation pattern from AudioWorkletProcessor
// <https://webaudio.github.io/web-audio-api/#AudioWorkletProcessor-instantiation>
#buildSharedState(options) {
globalThis[kPendingSharedStateConstructionData] = {
manager: this,
className: options.className,
classDescription: options.classDescription,
stateId: options.stateId,
instanceId: options.instanceId,
isOwner: options.isOwner,
initValues: options.initValues,
filter: options.filter,
};
const state = new SharedState();
// cleanup global scope
delete globalThis[kPendingSharedStateConstructionData];
return state;
}
/**
* Validates the given class description.
*
* @throws Throws if the class description is invalid
* @param {*} classDescription
* @returns undefined
*/
validateClassDescription(classDescription) {
return ParameterBag.validateDescription(classDescription);
}
/**
* Return a class description from a given class name
*
* @param {SharedStateClassName} className - Name of the shared state class.
* (cf. ServerStateManager)
* @return {SharedStateClassDescription}
* @example
* const classDescription = await client.stateManager.getClassDescription('my-class');
*/
async getClassDescription(className) {
if (this.#status !== 'inited') {
throw new DOMException(`Cannot execute 'getClassDescription' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
}
const { id: reqId, promise } = this.#promiseStore.createPromise();
this[kStateManagerClient].transport.emit(GET_CLASS_DESCRIPTION_REQUEST, reqId, className);
return promise;
}
#onGetClassDescriptionResponse = (reqId, _className, classDescription) => {
const fullDescription = ParameterBag.getFullDescription(classDescription);
this.#promiseStore.resolve(reqId, fullDescription);
};
#onGetClassDescriptionError = (reqId, msg) => {
msg = `Cannot execute 'getClassDescription' on BaseStateManager: ${msg}`;
this.#promiseStore.reject(reqId, msg);
};
/**
* Create a {@link SharedState} instance from a registered class.
*
* @param {SharedStateClassName} className - Name of the class.
* @param {Object.<string, any>} [initValues={}] - Default values of the created shared state.
* @returns {Promise<SharedState>}
* @example
* const state = await client.stateManager.create('my-class');
*/
async create(className, initValues = {}) {
if (this.#status !== 'inited') {
throw new DOMException(`Cannot execute 'create' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
}
const { id: reqId, promise } = this.#promiseStore.createPromise();
this[kStateManagerClient].transport.emit(CREATE_REQUEST, reqId, className, initValues);
return promise;
}
#onCreateResponse = (reqId, stateId, instanceId, className, classDescription, initValues) => {
const state = this.#buildSharedState({
className,
classDescription,
stateId,
instanceId,
isOwner: true,
initValues,
filter: null, // owner cannot filter parameters
});
this.#statesById.set(state.id, state);
this.#promiseStore.resolve(reqId, state);
};
#onCreateError = (reqId, msg) => {
msg = `Cannot execute 'create' on BaseStateManager: ${msg}`;
this.#promiseStore.reject(reqId, msg);
};
/**
* Attach to an existing {@link SharedState} instance.
*
* @overload
* @param {SharedStateClassName} className - Name of the class.
* @returns {Promise<SharedState>}
*
* @example
* const state = await client.stateManager.attach('my-class');
*/
/**
* Attach to an existing {@link SharedState} instance.
*
* @overload
* @param {SharedStateClassName} className - Name of the class.
* @param {number} stateId - Id of the state
* @returns {Promise<SharedState>}
*
* @example
* const state = await client.stateManager.attach('my-class', stateId);
*/
/**
* Attach to an existing {@link SharedState} instance.
*
* @overload
* @param {SharedStateClassName} className - Name of the class.
* @param {Object} [options={}]
* @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
* @param {string[]} [options.blackList] - Black list of parameter names to ignore.
* @returns {Promise<SharedState>}
*
* @example
* const state = await client.stateManager.attach('my-class', ['some-param']);
*/
/**
* Attach to an existing {@link SharedState} instance.
*
* @overload
* @param {SharedStateClassName} className - Name of the class.
* @param {number} stateId - Id of the state
* @param {object} [options={}]
* @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
* @param {string[]} [options.blackList] - Black list of parameter names to ignore.
* @returns {Promise<SharedState>}
*
* @example
* const state = await client.stateManager.attach('my-class', stateId, ['some-param']);
*/
/**
* Attach to an existing {@link SharedState} instance.
*
* Alternative signatures:
* - `stateManager.attach(className)`
* - `stateManager.attach(className, stateId)`
* - `stateManager.attach(className, options)`
* - `stateManager.attach(className, stateId, options)`
*
* @param {SharedStateClassName} className - Name of the class.
* @param {number} [stateId] - Id of the state to attach to. If `null`,
* attach to the first state found with the given class name (useful for
* globally shared states owned by the server).
* @param {object} [options={}]
* @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
* @param {string[]} [options.blackList] - Black list of parameter names to ignore.
* @returns {Promise<SharedState>}
*
* @example
* const state = await client.stateManager.attach('my-class');
*/
async attach(className, stateIdOrOptions = null, options = null) {
if (this.#status !== 'inited') {
throw new DOMException(`Cannot execute 'attach' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
}
let stateId = null;
if (!isString(className)) {
throw new TypeError(`Cannot execute 'attach' on BaseStateManager: argument 1 must be a string`);
}
if (arguments.length === 2) {
if (stateIdOrOptions === null) {
stateId = null;
options = null;
} else if (Number.isFinite(stateIdOrOptions)) {
stateId = stateIdOrOptions;
options = null;
} else if (Array.isArray(stateIdOrOptions)) {
// backward compatibility for legacy filter array argument
warnings.deprecated(
'argument `filter: string[]` of BaseStateManager#attach',
'argument `options { whiteList: string[] }`',
'5.6.0',
);
stateId = null;
options = { whiteList: stateIdOrOptions };
} else if (isPlainObject(stateIdOrOptions)) {
stateId = null;
options = stateIdOrOptions;
} else {
throw new TypeError(`Cannot execute 'attach' on BaseStateManager: argument 2 must be either null, a number or an object`);
}
}
if (arguments.length === 3) {
stateId = stateIdOrOptions;
if (stateId !== null && !Number.isFinite(stateId)) {
throw new TypeError(`Cannot execute 'attach' on BaseStateManager: argument 2 must be either null or a number`);
}
if (options !== null) {
if (Array.isArray(options)) {
// backward compatibility for legacy filter array argument
warnings.deprecated(
'argument `filter: string[]` of BaseStateManager#attach',
'argument `options { whiteList: string[] }`',
'5.6.0',
);
options = { whiteList: options };
} else if (!isPlainObject(options)) {
throw new TypeError(`Cannot execute 'attach' on BaseStateManager: argument 3 must be either null or an object`);
}
// keep option object as is
}
}
const { id: reqId, promise } = this.#promiseStore.createPromise();
this[kStateManagerClient].transport.emit(ATTACH_REQUEST, reqId, className, stateId, options);
return promise;
}
#onAttachResponse = (reqId, stateId, instanceId, className, classDescription, currentValues, filter) => {
const state = this.#buildSharedState({
manager: this,
className,
classDescription,
stateId,
instanceId,
isOwner: false,
initValues: currentValues,
filter,
});
this.#statesById.set(state.id, state);
this.#promiseStore.resolve(reqId, state);
};
#onAttachError = (reqId, msg) => {
msg = `Cannot execute 'attach' on BaseStateManager: ${msg}`;
this.#promiseStore.reject(reqId, msg);
};
/**
* Observe all the {@link SharedState} instances that are created on the network.
*
* @overload
* @param {stateManagerObserveCallback} callback - Function to execute when a
* new {@link SharedState} is created on the network.
* @example
* client.stateManager.observe(async (className, stateId) => {
* if (className === 'my-shared-state-class') {
* const attached = await client.stateManager.attach(className, stateId);
* }
* });
*/
/**
* Observe all the {@link SharedState} instances of given {@link SharedStateClassName}
* that are created on the network.
*
* @overload
* @param {SharedStateClassName} className - Observe only ${@link SharedState}
* of given name.
* @param {stateManagerObserveCallback} callback - Function to execute when a
* new {@link SharedState} is created on the network.
* @example
* client.stateManager.observe('my-shared-state-class', async (className, stateId) => {
* const attached = await client.stateManager.attach(className, stateId);
* });
*/
/**
* Observe all the {@link SharedState} instances of given excluding the ones
* created by the current node.
*
* @overload
* @param {stateManagerObserveCallback} callback - Function to execute when a
* new {@link SharedState} is created on the network.
* @param {object} options - Options.
* @param {boolean} [options.excludeLocal=false] - If set to true, exclude states
* created by the same node from the collection.
* @example
* client.stateManager.observe(async (className, stateId) => {
* if (className === 'my-shared-state-class') {
* const attached = await client.stateManager.attach(className, stateId);
* }
* }, { excludeLocal: true });
*/
/**
* Observe all the {@link SharedState} instances of given {@link SharedStateClassName}
* that are created on the network, excluding the ones created by the current node.
*
* @overload
* @param {SharedStateClassName} className - Observe only ${@link SharedState}
* of given name.
* @param {stateManagerObserveCallback} callback - Function to execute when a
* new {@link SharedState} is created on the network.
* @param {object} options - Options.
* @param {boolean} [options.excludeLocal=false] - If set to true, exclude states
* created by the same node from the collection.
* @example
* client.stateManager.observe('my-shared-state-class', async (className, stateId) => {
* const attached = await client.stateManager.attach(className, stateId);
* }, { excludeLocal: true });
*/
/**
* Observe all the {@link SharedState} instances that are created on the network.
*
* Notes:
* - The order of execution is not guaranteed between nodes, i.e. a state attached
* in the `observe` callback can be instantiated before the `async create` method
* resolves on the creator node.
* - Filtering, i.e. `observedClassName` and `options.excludeLocal` are handled
* on the node side, the server just notify all state creation activity and
* the node executes the given callbacks according to the different filter rules.
* Such strategy allows to simply share the observe notifications between all observers.
*
* Alternative signatures:
* - `stateManager.observe(callback)`
* - `stateManager.observe(className, callback)`
* - `stateManager.observe(callback, options)`
* - `stateManager.observe(className, callback, options)`
*
* @param {SharedStateClassName} [className] - Optional class name to filter the observed
* states.
* @param {stateManagerObserveCallback}
* callback - Function to be called when a new state is created on the network.
* @param {object} options - Options.
* @param {boolean} [options.excludeLocal = false] - If set to true, exclude states
* created locally, i.e. by the same node, from the collection.
* @returns {Promise<stateManagerDeleteObserveCallback>} - Returns a Promise that resolves when the given
* callback as been executed on each existing states. The promise value is a
* function which allows to stop observing the states on the network.
*
* @example
* client.stateManager.observe(async (className, stateId) => {
* if (className === 'my-shared-state-class') {
* const attached = await client.stateManager.attach(className, stateId);
* }
* });
*/
async observe(...args) {
if (this.#status !== 'inited') {
throw new DOMException(`Cannot execute 'observe' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
}
const defaultOptions = {
excludeLocal: false,
};
let observedClassName;
let callback;
let options;
switch (args.length) {
case 1: {
// variation: .observe(callback)
if (!isFunction(args[0])) {
throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 1 should be a function`);
}
observedClassName = null;
callback = args[0];
options = defaultOptions;
break;
}
case 2: {
// variation: .observe(className, callback)
if (isString(args[0])) {
if (!isFunction(args[1])) {
throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 2 should be a function`);
}
observedClassName = args[0];
callback = args[1];
options = defaultOptions;
// variation: .observe(callback, options)
} else if (isFunction(args[0])) {
if (!isPlainObject(args[1])) {
throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 2 should be an object`);
}
observedClassName = null;
callback = args[0];
options = Object.assign(defaultOptions, args[1]);
} else {
throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Invalid signature, refer to the documentation`);
}
break;
}
case 3: {
// variation: .observe(className, callback, options)
if (!isString(args[0])) {
throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 1 should be a string`);
}
if (!isFunction(args[1])) {
throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 2 should be a function`);
}
if (!isPlainObject(args[2])) {
throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Argument 3 should be an object`);
}
observedClassName = args[0];
callback = args[1];
options = Object.assign(defaultOptions, args[2]);
break;
}
// throw in all other cases
default: {
throw new TypeError(`Cannot execute 'observe' on BaseStateManager: Invalid signature, refer to the documentation`);
}
}
// resend request to get updated list of states
const { id: reqId, promise } = this.#promiseStore.createPromise();
// store the callback for execution on the response. the returned Promise
// is fulfilled once callback has been executed with each existing states
const observeInfos = [observedClassName, callback, options];
this.#observeRequestCallbacks.set(reqId, observeInfos);
// NOTE: do not store in `_observeListeners` yet as it can produce race
// conditions, e.g.:
// ```
// await client.stateManager.observe(async (className, stateId, nodeId) => {});
// // client now receives OBSERVE_NOTIFICATIONS
// await otherClient.stateManager.create('a');
// // second observer added in between
// client.stateManager.observe(async (className, stateId, nodeId) => {});
// ````
// OBSERVE_NOTIFICATION is received before the OBSERVE_RESPONSE, then the
// second observer is called twice:
// - OBSERVE_RESPONSE 1 []
// - OBSERVE_NOTIFICATION [ 'a', 1, 0 ]
// - OBSERVE_NOTIFICATION [ 'a', 1, 0 ] // this should not happen
// - OBSERVE_RESPONSE 1 [ [ 'a', 1, 0 ] ]
//
// cf. unit test `observe should properly behave in race condition`
this[kStateManagerClient].transport.emit(OBSERVE_REQUEST, reqId, observedClassName);
return promise;
}
/** @private */
#filterObserve(observedClassName, className, ownerId, options) {
let filter = true;
if (observedClassName === null || observedClassName === className) {
filter = false;
}
// filter states created by this client if excludeLocal is true
if (options.excludeLocal === true && ownerId === this[kStateManagerClient].id) {
filter = true;
}
return filter;
}
/** @private */
#onObserveResponse = async (reqId, ...list) => {
// retrieve the callback that have been stored in `observe()` to make sure
// we don't call another callback that may have been registered earlier.
const observeInfos = this.#observeRequestCallbacks.get(reqId);
const [observedClassName, callback, options] = observeInfos;
// move observeInfos from `_observeRequestCallbacks` to `_observeListeners`
// to guarantee future order of execution
this.#observeRequestCallbacks.delete(reqId);
this.#observeListeners.add(observeInfos);
const promises = list.map(([className, stateId, ownerId]) => {
const filter = this.#filterObserve(observedClassName, className, ownerId, options);
if (!filter) {
return callback(className, stateId, ownerId);
} else {
return Promise.resolve();
}
});
await Promise.all(promises);
const unsubscribe = () => {
this.#observeListeners.delete(observeInfos);
// no more listeners, we can stop receiving notifications from the server
if (this.#observeListeners.size === 0) {
this[kStateManagerClient].transport.emit(UNOBSERVE_NOTIFICATION);
}
};
this.#promiseStore.resolve(reqId, unsubscribe);
};
/** @private */
#onObserveError = (reqId, msg) => {
msg = `Cannot execute 'observe' on BaseStateManager: ${msg}`;
this.#observeRequestCallbacks.delete(reqId);
this.#promiseStore.reject(reqId, msg);
};
/** @private */
#onObserveNotification = (className, stateId, ownerId) => {
this.#observeListeners.forEach(observeInfos => {
const [observedClassName, callback, options] = observeInfos;
const filter = this.#filterObserve(observedClassName, className, ownerId, options);
if (!filter) {
callback(className, stateId, ownerId);
}
});
};
/**
* Returns a collection of all the states created from a given shared state class.
*
* @overload
* @param {SharedStateClassName} className - Name of the shared state class.
* @returns {Promise<SharedStateCollection>}
*
* @example
* const collection = await client.stateManager.getCollection(className);
*/
/**
* Returns a collection of all the states created from a given shared state class.
*
* @overload
* @param {SharedStateClassName} className - Name of the shared state class.
* @param {object} [options={}] - Options.
* @param {boolean} [options.excludeLocal=false] - If set to true, exclude states
* created by the same node from the collection.
* @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
* @param {string[]} [options.blackList] - Black list of parameter names to ignore.
* @returns {Promise<SharedStateCollection>}
*
* @example
* const collection = await client.stateManager.getCollection(className, {
* blackList: ['my-param'],
* excludeLocal: true,
* });
*/
/**
* Returns a collection of all the states created from a given shared state class.
*
* Alternative signatures:
* - `stateManager.getCollection(className)`
* - `stateManager.getCollection(className, options)`
*
* @param {SharedStateClassName} className - Name of the shared state class.
* @param {object} [options={}] - Options.
* @param {boolean} [options.excludeLocal=false] - If set to true, exclude states
* created by the same node from the collection.
* @param {string[]} [options.whiteList] - White list of parameter names to track (as precedence over `blackList`).
* @param {string[]} [options.blackList] - Black list of parameter names to ignore.
* @returns {Promise<SharedStateCollection>}
*
* @example
* const collection = await client.stateManager.getCollection(className);
*/
async getCollection(className, options = {}) {
if (this.#status !== 'inited') {
throw new DOMException(`Cannot execute 'getCollection' on BaseStateManager: BaseStateManager is not inited`, 'InvalidStateError');
}
if (!isString(className)) {
throw new TypeError(`Cannot execute 'getCollection' on BaseStateManager: Argument 1 should be a string"`);
}
if (arguments.length === 2) {
if (Array.isArray(options)) {
// backward compatibility for legacy filter array argument
warnings.deprecated(
'argument `filter: string[]` of BaseStateManager#getCollection',
'argument `options { whiteList: string[] }`',
'5.6.0',
);
options = { whiteList: options };
} else if (!isPlainObject(options)) {
throw new TypeError(`Cannot execute 'getCollection' on BaseStateManager: Argument 2 should be either null or an object"`);
}
}
// backward compatibility for legacy filter array argument
if (arguments.length === 3) {
// backward compatibility for legacy filter array argument
warnings.deprecated(
'argument `filter: string[]` of BaseStateManager#getCollection',
'argument `options { whiteList: string[] }`',
'5.6.0',
);
const filter = options;
options = arguments[2];
if (filter !== null && !Array.isArray(filter)) {
throw new TypeError(`Cannot execute 'getCollection' on BaseStateManager: Argument 2 should be either an array or null"`);
}
if (options === null || !isPlainObject(options)) {
throw new TypeError(`Cannot execute 'getCollection' on BaseStateManager: Argument 3 should be either an object"`);
}
options.whiteList = filter;
}
const collection = new SharedStateCollection(this, className, options);
try {
await collection[kSharedStateCollectionInit]();
} catch (err) {
throw new ReferenceError(`Cannot execute 'getCollection' on BaseStateManager: ${err.message}`);
}
return collection;
}
// ---------------------------------------------------------------------------
// DEPRECATED
// ---------------------------------------------------------------------------
/**
* @deprecated Use {@link BaseStateManager#getClassDescription} instead.
*/
async getSchema(className) {
warnings.deprecated('BaseStateManager#getSchema', 'BaseStateManager#getClassDescription', '4.0.0-alpha.29');
return this.getClassDescription(className);
}
}
export default BaseStateManager;