import ServerClient from './ServerClient.js';
import Server from './Server.js';
import {
kServerContextManagerRegister,
} from './ServerContextManager.js';
export const kServerContextStatus = Symbol('soundworks:server-context-status');
/**
* Base class to extend in order to implment the optionnal server-side counterpart
* of a {@link ClientContext}. If not defined, a default context will be created
* and used by the server.
*
* 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.). The
* {@link ClientContext} and optionnal {@link ServerContext} abstractions provide
* a simple and unified way to model these reccuring aspects of an application.
*
* If a `ServerContext` is recognized as the server-side counterpart of a
* {@link ClientContext}, based on their respective `name` (see {@link ClientContext#name}
* and {@link ServerContext#name}), `soundworks` will ensure the logic defined
* by the ServerContext will be executed at the beginning of the
* {@link ClientContext#enter} and {@link ClientContext#exit} methods.
*
* The example above shows how soundwords handles (and guarantees) the order of
* the `enter()` steps between the client-side and the server-side parts of the
* context. The same goes for the `exit()` method.
*
* ```js
* // client-side
* import { ClientContext } from '@soundworks/core/client.js';
*
* class MyContext extends ClientContext {
* async enter() {
* // 1. client side context enter() starts
* // server-side logic is triggered first
* await super.enter();
* // 4. server-side context enter() is fully done
* // some async job can be done here
* await new Promise(resolve => setTimeout(resolve, 1000));
* // 5. client-side context enter() ends
* }
* }
*
* // Instanciate the context (assuming the `client.role` is 'test')
* const myContext = new MyContext(client);
*
* // At some point in the application, the client enters the context trigerring
* // the steps 1 to 5 described in the client-side and server-side `enter()`
* // implementations. Note that the server-side `enter()` is never called manually.
* await myContext.enter();
* ```
*
* ```js
* // server-side
* import { ServerContext } from '@soundworks/core/server.js';
*
* class MyContext extends ServerContext {
* async enter(client) {
* // 2. server-side context enter() starts
* await super.enter(client);
* // some async job can be done here
* await new Promise(resolve => setTimeout(resolve, 1000));
* // 3. server-side context enter() ends
* }
* }
*
* // Instantiate the context
* const myContext = new MyContext(server);
* ```
*/
class ServerContext {
#server = null;
#clients = new Set();
#roles = null;
/**
* @param {Server} server - The soundworks server instance.
* @param {string|string[]} [roles=[]] - Optionnal list of client roles that can
* use this context. In large applications, this may be usefull to guarantee
* that a context can be consumed only by specific client roles, throwing an
* error if any other client role tries to use it. If empty, no access policy
* will be used.
* @throws Will throw if the first argument is not a soundworks server instance.
*/
constructor(server, roles = []) {
if (!(server instanceof Server)) {
throw new Error(`[soundworks:Context] Invalid argument, context "${this.constructor.name}" should receive a "soundworks.Server" instance as first argument`);
}
roles = Array.isArray(roles) ? roles : [roles];
this.#server = server;
this.#roles = new Set(roles);
/** @private */
this[kServerContextStatus] = 'idle';
// register in context manager
this.#server.contextManager[kServerContextManagerRegister](this);
}
/**
* The soundworks server instance.
*
* @type {Server}
*/
get server() {
return this.#server;
}
/**
* List of clients that are currently in this context.
*
* @type {Set<ServerClient>}
*/
get clients() {
return this.#clients;
}
/**
* List of client roles that can use this context. No access policy if empty.
*
* @type {Set<string>}
*/
get roles() {
return this.#roles;
}
/**
* Status of the context ('idle', 'inited', 'started' or 'errored')
*
* @type {string}
*/
get status() {
return this[kServerContextStatus];
}
/**
* 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.
*
* @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 a client enters the
* context for the first time (cf. ${ServerContext#enter}). If you know some
* some heavy and/or potentially long job has to be done when starting the context
* (e.g. connect to a database, parsing a long file) 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 a client enters or exits the context.
*
* @example
* import { Context } from '@soundworks/core/server.js';
*
* class MyContext extends Context {
* async start() {
* await super.start();
* await this.doSomeLongJob();
* }
* }
*
* // Instantiate the context
* const myContext = new Context(server, ['test']);
* // manually start the context to perform the long operation before the first
* // client enters the context
* await myContext.start();
* ```
*/
async start() {}
/**
* Stop the context. The method that is automatically called when the server
* stops. It should be used to cleanup context wise operations made in `start()`
* (e.g. disconnect from a database, release a file handle).
*
* _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 a client enters the context.
*
* If the context has not been started yet, the `start` method is implicitely executed.
*
* _WARNING: this method should never be called manually._
*
* @param {ServerClient} client - Server-side representation of the client
* that enters the context.
* @returns {Promise} - Promise that resolves when the context is entered.
* @example
* class MyContext extends Context {
* async enter(client) {
* await super.enter(client);
* registerTheClientSomewhere(client);
* }
*
* async exit(client) {
* await super.exit(client);
* unregisterTheClientSomewhere(client);
* }
* }
*/
async enter(client) {
if (!(client instanceof ServerClient)) {
throw new Error(`[soundworks.Context] Invalid argument, ${this.name} context ".enter()" method should receive a "ServerClient" instance argument`);
}
this.#clients.add(client);
}
/**
* Exit the context. Implement this method to define the logic that should be
* done (e.g. delete a shared state, etc.) when a client exits the context.
*
* * _WARNING: this method should never be called manually._
*
* @param {ServerClient} client - Server-side representation of the client
* that exits the context.
* @returns {Promise} - Promise that resolves when the context is exited.
* @example
* class MyContext extends Context {
* async enter(client) {
* await super.enter(client);
* this.state = await this.client.stateManager.create('my-context-state');
* }
*
* async exit(client) {
* await super.exit(client);
* await this.state.delete();
* }
* }
*/
async exit(client) {
if (!(client instanceof ServerClient)) {
throw new Error(`[soundworks.Context] Invalid argument, ${this.name}.exit() should receive a "ServerClient" instance argument`);
}
this.#clients.delete(client);
}
}
export default ServerContext;