import ServerContext, {
kServerContextStatus,
} from './ServerContext.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 kServerContextManagerStart = Symbol('soundworks:server-context-manager-start');
export const kServerContextManagerStop = Symbol('soundworks:server-context-manager-stop');
export const kServerContextManagerAddClient = Symbol('soundworks:server-context-manager-add-client');
export const kServerContextManagerRemoveClient = Symbol('soundworks:server-context-manager-remove-client');
export const kServerContextManagerRegister = Symbol('soundworks:server-context-manager-register');
// for testing purposes
export const kServerContextManagerContexts = Symbol('soundworks:server-context-manager-contexts');
/**
* Create a dummy server side context if a proper server-side context has not
* been declared and registered. Ane DefaultContext is created per unknown
* contextName and associated to all known client types.
* @private
*/
function createNamedContextClass(contextName) {
return class DefaultContext extends ServerContext {
get name() {
return contextName;
}
};
}
/** @private */
class ContextCollection {
#inner = [];
// for testing purposes
get length() {
return this.#inner.length;
}
add(context) {
this.#inner.push(context);
}
has(name) {
return this.#inner.find(c => c.name === name) !== undefined;
}
get(name) {
return this.#inner.find(c => c.name === name);
}
map(func) {
return this.#inner.map(func);
}
filter(func) {
return this.#inner.filter(func);
}
}
/**
* Manage the different server-side contexts and their lifecycle.
*
* The `ServerContextManager` is automatically instantiated by the {@link Server}.
*
* _WARNING: Most of the time, you should not have to manipulate the context manager directly._
*
* @hideconstructor
*/
class ServerContextManager {
#server = null;
#contextStartPromises = new Map();
/**
* @param {Server} server - Instance of the soundworks server.
*/
constructor(server) {
this.#server = server;
this[kServerContextManagerContexts] = new ContextCollection();
}
/**
* Register a context in the manager.
* This method is called in the {@link ServerContext} constructor
*
* @param {ServerContext} context - Context instance to register.
*
* @private
*/
[kServerContextManagerRegister](context) {
// we must await the contructor initialization end to check the name and throw
if (this[kServerContextManagerContexts].has(context.name)) {
throw new Error(`[soundworks:ServerContextManager] Context "${context.name}" already registered`);
}
this[kServerContextManagerContexts].add(context);
}
/**
* Start all contexts registered before `await server.start()`.
* Called on {@link Server#start}
*
* @private
*/
async [kServerContextManagerStart]() {
const promises = this[kServerContextManagerContexts].map(context => this.get(context.name));
await Promise.all(promises);
}
/**
* Stop all contexts. Called on {@link Server#stop}
*
* @private
*/
async [kServerContextManagerStop]() {
const promises = this[kServerContextManagerContexts].map(context => context.stop());
await Promise.all(promises);
}
/**
* Called when a client connects to the server (websocket handshake)
*
* @param {ServerClient} client
*
* @private
*/
[kServerContextManagerAddClient](client) {
client.socket.addListener(CONTEXT_ENTER_REQUEST, async (reqId, contextName) => {
// if no context found, create a DefaultContext on the fly
if (!this[kServerContextManagerContexts].has(contextName)) {
// create default context for all client types
const ctor = createNamedContextClass(contextName);
// this will automatically register the context in the context manager
new ctor(this.#server);
}
// we ensure context is started, even lazilly after server.start()
let context;
try {
context = await this.get(contextName);
} catch (err) {
client.socket.send(
CONTEXT_ENTER_ERROR,
reqId,
contextName,
err.message,
);
return;
}
// don't do this check with default context to allow several parallel
// contexts client side with no server-side part.
if (context && context.clients.has(client)) {
client.socket.send(
CONTEXT_ENTER_ERROR,
reqId,
contextName,
`[soundworks:ServerContextManager] Client already in context (if only one context is created .enter() has been called automatically)`,
);
return;
}
// use default context if no server-side context part defined
if (context === undefined) {
context = this.defaultContext;
}
if (context.roles.size > 0 && !context.roles.has(client.role)) {
client.socket.send(
CONTEXT_ENTER_ERROR,
reqId,
contextName,
`[soundworks:ServerContextManager] Clients with role "${client.role}" are not declared as possible consumers of context "${contextName}"`,
);
return;
}
try {
await context.enter(client);
client.socket.send(CONTEXT_ENTER_RESPONSE, reqId, contextName);
} catch (err) {
client.socket.send(CONTEXT_ENTER_ERROR, reqId, contextName, err.message);
}
});
client.socket.addListener(CONTEXT_EXIT_REQUEST, async (reqId, contextName) => {
if (!this[kServerContextManagerContexts].has(contextName)) {
client.socket.send(
CONTEXT_EXIT_ERROR,
reqId,
contextName,
`[soundworks:ServerContextManager] Cannot exit(), context ${contextName} does not exists`,
);
return;
}
let context = await this.get(contextName);
if (context.clients.has(client)) {
try {
await context.exit(client);
client.socket.send(CONTEXT_EXIT_RESPONSE, reqId, contextName);
} catch (err) {
client.socket.send(CONTEXT_EXIT_ERROR, reqId, contextName, err.message);
}
} else {
client.socket.send(
CONTEXT_EXIT_ERROR,
reqId,
contextName,
`[soundworks:ServerContextManager] Client with role "${client.role}" is not in context "${contextName}"`,
);
}
});
}
/**
* Called when a client connects to the server (websocket 'close' event)
*
* @param {ServerClient} client
*
* @private
*/
async [kServerContextManagerRemoveClient](client) {
client.socket.removeAllListeners(CONTEXT_ENTER_REQUEST);
client.socket.removeAllListeners(CONTEXT_EXIT_REQUEST);
// exit from all contexts
const promises = this[kServerContextManagerContexts]
.filter(context => context.clients.has(client))
.map(context => context.exit(client));
await Promise.all(promises);
}
/**
* Retrieve a started context from its name.
*
* _WARNING: Most of the time, you should not have to call this method manually._
*
* @param {ServerContext#name} contextName - Name of the context.
*/
async get(contextName) {
if (!this[kServerContextManagerContexts].has(contextName)) {
throw new Error(`[soundworks:ServerContextManager] Can't get context "${contextName}", not registered`);
}
const context = this[kServerContextManagerContexts].get(contextName);
if (this.#contextStartPromises.has(contextName)) {
const startPromise = this.#contextStartPromises.get(contextName);
await startPromise;
} else {
context[kServerContextStatus] = 'inited';
try {
const startPromise = context.start();
this.#contextStartPromises.set(contextName, startPromise);
await startPromise;
context[kServerContextStatus] = 'started';
} catch (err) {
context[kServerContextStatus] = 'errored';
throw new Error(err);
}
}
return context;
}
}
export default ServerContextManager;