import Client from './Client.js';
import {
kClientContextManagerRegister,
} from './ClientContextManager.js';
import PromiseStore from '../common/PromiseStore.js';
import {
CONTEXT_ENTER_REQUEST,
CONTEXT_ENTER_RESPONSE,
CONTEXT_ENTER_ERROR,
CONTEXT_EXIT_REQUEST,
CONTEXT_EXIT_RESPONSE,
CONTEXT_EXIT_ERROR,
} from '../common/constants.js';
export const kClientContextStatus = Symbol('soundworks:client-context-status');
// share between all context, as channels are common to all contexts
const promiseStore = new PromiseStore('Context');
/**
* Base class to extend in order to implement a new Context.
*
* In the `soundworks` paradigm, a client has a "role" (e.g. _player_, _controller_)
* see {@link Client#role}) and can be in different "contexts" (e.g. different
* part of the experience such as sections of a music piece, etc.). This class
* provides a simple and unified way to model these reccuring aspects of an application.
*
* You can also think of a `Context` as a state of a state machine from which a
* client can `enter()` or `exit()` (be aware that `soundworks` does not provide
* an implementation for the state machine).
*
* Optionally, a `Context` can also have a server-side counterpart to perform
* some logic (e.g. updating some global shared state) when a client enters or exits
* the context. In such case, `soundworks` guarantees that the server-side
* logic is executed before the `enter()` and `exit()` promises are fulfilled.
*
* ```js
* import { Client, ClientContext } from '@soundworks/core/index.js'
*
* const client = new Client(config);
*
* class MyContext extends ClientContext {
* async enter() {
* await super.enter();
* console.log(`client ${this.client.id} entered my context`);
* }
*
* async exit() {
* await super.exit();
* console.log(`client ${this.client.id} exited my context`);
* }
* }
* const myContext = new MyContext(client);
*
* await client.start();
* await myContext.enter();
*
* await new Promise(resolve => setTimeout(resolve, 2000));
* await myContext.exit();
* ```
*/
class ClientContext {
#client = null;
/**
* @param {Client} client - The soundworks client instance.
* @throws Will throw if the first argument is not a soundworks client instance.
*/
constructor(client) {
if (!(client instanceof Client)) {
throw new Error(`[soundworks:ClientContext] Invalid argument, context "${this.name}" should receive a "soundworks.Client" instance as first argument`);
}
this.#client = client;
this[kClientContextStatus] = 'idle';
this.#client.socket.addListener(CONTEXT_ENTER_RESPONSE, (reqId, contextName) => {
if (contextName !== this.name) {
return;
}
promiseStore.resolve(reqId);
});
this.#client.socket.addListener(CONTEXT_ENTER_ERROR, (reqId, contextName, msg) => {
if (contextName !== this.name) {
return;
}
promiseStore.reject(reqId, msg);
});
this.#client.socket.addListener(CONTEXT_EXIT_RESPONSE, (reqId, contextName) => {
if (contextName !== this.name) {
return;
}
promiseStore.resolve(reqId);
});
this.#client.socket.addListener(CONTEXT_EXIT_ERROR, (reqId, contextName, msg) => {
if (contextName !== this.name) {
return;
}
promiseStore.reject(reqId, msg);
});
this.#client.contextManager[kClientContextManagerRegister](this);
}
/**
* The soundworks client instance.
* @type {Client}
*/
get client() {
return this.#client;
}
/**
* Status of the context.
* @type {'idle'|'inited'|'started'|'errored'}
*/
get status() {
return this[kClientContextStatus];
}
/**
* Optionnal user-defined name of the context (defaults to the class name).
*
* The context manager will match the client-side and server-side contexts based
* on this name. If the {@link ServerContextManager} don't find a corresponding
* user-defined context with the same name, it will use a default (noop) context.
*
* @readonly
* @type {string}
* @example
* // server-side and client-side contexts are matched based on their respective `name`
* class MyContext extends Context {
* get name() {
* return 'my-user-defined-context-name';
* }
* }
*/
get name() {
return this.constructor.name;
}
/**
* Start the context. This method is lazilly called when the client enters the
* context for the first time (cf. ${ClientContext#enter}). If you know some
* some heavy and/or potentially long job has to be done when starting the context
* (e.g. call to a web service) it may be a good practice to call it explicitely.
*
* This method should be implemented to perform operations that are valid for the
* whole lifetime of the context, regardless the client enters or exits the context.
*
* @example
* import { Context } from '@soundworks/core/client.js';
*
* class MyContext extends Context {
* async start() {
* await super.start();
* await this.doSomeLongJob();
* }
* }
*
* // Instantiate the context
* const myContext = new Context(client);
* // manually start the context to perform the long operation before the client
* // enters the context.
* await myContext.start();
*/
async start() {}
/**
* Stop the context. This method is automatically called when `await client.stop()`
* is called. It should be used to cleanup context wise operations made in `start()`
* (e.g. destroy the reusable audio graph).
*
* _WARNING: this method should never be called manually._
*/
async stop() {}
/**
* Enter the context. Implement this method to define the logic that should be
* done (e.g. creating a shared state, etc.) when the context is entered.
*
* If a server-side part of the context is defined (i.e. a context with the same
* {@link ClientContext#name}), the corresponding server-side `enter()` method
* will be executed before the returned Promise is fulfilled.
*
* If the context has not been started yet, the `start` method is implicitely executed.
*
* @returns {Promise<void>} - Promise that resolves when the context is entered.
* @example
* class MyContext extends Context {
* async enter() {
* await super.enter();
* this.state = await this.client.stateManager.create('my-context-state');
* }
*
* async exit() {
* await super.exit();
* await this.state.delete();
* }
* }
*/
async enter() {
// lazily start the context if registered after `client.start()`
if (this.status !== 'started' && this.status !== 'errored') {
await this.#client.contextManager.get(this.name);
}
// we need the try/catch block to change the promise rejection into proper error
try {
await new Promise((resolve, reject) => {
const reqId = promiseStore.add(resolve, reject, 'enter-context');
this.#client.socket.send(CONTEXT_ENTER_REQUEST, reqId, this.name);
});
} catch (err) {
throw new Error(err);
}
}
/**
* Exit the context. Implement this method to define the logic that should be
* done (e.g. delete a shared state, etc.) when the context is exited.
*
* If a server-side part of the context is defined (i.e. a context with the same
* {@link ClientContext#name}), the corresponding server-side `exit()` method
* will be executed before the returned Promise is fulfilled.
*
* @returns {Promise<void>} - Promise that resolves when the context is exited.
* @example
* class MyContext extends Context {
* async enter() {
* await super.enter();
* this.state = await this.client.stateManager.create('my-context-state');
* }
*
* async exit() {
* await super.exit();
* await this.state.delete();
* }
* }
*/
async exit() {
// we need the try/catch block to change the promise rejection into proper error
try {
await new Promise((resolve, reject) => {
const reqId = promiseStore.add(resolve, reject, 'exit-context');
this.#client.socket.send(CONTEXT_EXIT_REQUEST, reqId, this.name);
});
} catch (err) {
throw new Error(err);
}
}
}
export default ClientContext;