UNPKG

@soundworks/core

Version:

Open-source creative coding framework for distributed applications based on Web technologies

482 lines (422 loc) 13.8 kB
import { isBrowser, isPlainObject, isString } from '@ircam/sc-utils'; import ClientContextManager, { kClientContextManagerStart, kClientContextManagerStop, } from './ClientContextManager.js'; import ClientPluginManager from './ClientPluginManager.js'; import { kPluginManagerStart, kPluginManagerStop, } from '../common/BasePluginManager.js'; import ClientSocket, { kSocketTerminate, } from './ClientSocket.js'; import ClientStateManager from './ClientStateManager.js'; import { kStateManagerInit, } from '../common/BaseStateManager.js'; import { CLIENT_HANDSHAKE_REQUEST, CLIENT_HANDSHAKE_RESPONSE, CLIENT_HANDSHAKE_ERROR, AUDIT_STATE_NAME, } from '../common/constants.js'; import logger from '../common/logger.js'; import VERSION from '../common/version.js'; // for testing purposes export const kClientVersionTest = Symbol('soundworks:client-version-test'); export const kClientOnStatusChangeCallbacks = Symbol('soundworks:client-on-status-change-callbacks'); /** * The `Client` class is the main entry point for the client-side of a soundworks * application. * * A `soundworks` client can run seamlessly in a browser or in a Node.js runtime. * * It provides an access to the different soundworks components such as the {@link ClientStateManager}, * {@link ClientPluginManager}, {@link ClientSocket} and the {@link ClientContextManager}. * * ``` * import { Client } from '@soundworks/core/client.js'; * // create a `Client` instance * const client = new Client({ * role: 'player', * env: { * useHttps: false, * serverAddress: 'localhost', * port: 8000, * }, * }); * // start the client * await client.start(); * ``` */ class Client { #config = null; #version = null; #role = null; #id = null; #uuid = null; #token = null; #runtime = null; #socket = null; #contextManager = null; #pluginManager = null; #stateManager = null; #status = 'idle'; #auditState = null; /** * @param {ClientConfig} config - Configuration of the soundworks client. * @throws Will throw if the given config object is invalid. */ constructor(config) { if (!isPlainObject(config)) { throw new Error(`Cannot construct Client: argument 1 must be an object`); } if (!('role' in config) || !isString(config.role)) { throw new Error('Cannot construct Client: Invalid ClientConfig object: Property "role" must be a string'); } // for node clients env.https is required to open the websocket if (!isBrowser()) { if (!('env' in config)) { throw new Error(`Cannot construct Client: The 'env' property with type 'ClientEnvConfig' must be defined`); } let missing = []; if (!('useHttps' in config.env)) { missing.push('useHttps'); } if (!('serverAddress' in config.env)) { missing.push('serverAddress'); } if (!('port' in config.env)) { missing.push('port'); } if (missing.length) { throw new Error(`Cannot construct Client: Invalid 'env' property of type 'ClientEnvConfig': Fields ${missing.join(', ')} are missing`); } } this.#config = config; if (!this.#config.env) { this.#config.env = {}; } this.#version = VERSION; // allow override though config for testing if (config[kClientVersionTest]) { this.#version = config[kClientVersionTest]; } this.#role = config.role; this.#runtime = isBrowser() ? 'browser' : 'node'; this.#socket = new ClientSocket(this.#role, this.#config, { path: 'socket', retryConnectionRefusedTimeout: 1000, }); this.#contextManager = new ClientContextManager(); this.#pluginManager = new ClientPluginManager(this); this.#stateManager = new ClientStateManager(); this.#status = 'idle'; this[kClientOnStatusChangeCallbacks] = new Set(); logger.configure(!!config.env.verbose); } /** * Package version. * * @type {string} */ get version() { return this.#version; } /** * Role of the client in the application. * * @type {string} */ get role() { return this.#role; } /** * Configuration object. * * @type {ClientConfig} */ get config() { return this.#config; } /** * Session id of the client. * * Incremented positive integer generated and retrieved by the server during * {@link Client#init}. The counter is reset when the server restarts. * * @type {number} */ get id() { return this.#id; } /** * Unique session uuid of the client (uuidv4). * * Generated and retrieved by the server during {@link Client#init}. * @type {string} */ get uuid() { return this.#uuid; } /** * Authentication token generated by the server. * * Only set if client has gone through authentication process and is considered trusted. * * Generated and retrieved by the server during {@link Client#init}. * @type {string} */ get token() { return this.#token; } /** * Runtime platform on which the client is executed, i.e. 'browser' or 'node'. * * @type {'node'|'browser'} */ get runtime() { return this.#runtime; } /** * @deprecated Use {@link Client#runtime} instead. */ get target() { logger.deprecated('Client#target', 'Client#runtime', '4.0.0-alpha.29'); return this.runtime; } /** * Instance of the {@link client.Socket} class. * * @see {@link ClientSocket} * @type {ClientSocket} */ get socket() { return this.#socket; } /** * Instance of the {@link ClientContextManager} class. * * The context manager can be safely used after `client.init()` has been fulfilled. * * @see {@link ClientContextManager} * @type {ClientContextManager} */ get contextManager() { return this.#contextManager; } /** * Instance of the {@link ClientPluginManager} class. * * The context manager can be safely used after `client.init()` has been fulfilled. * * @see {@link ClientPluginManager} * @type {ClientPluginManager} */ get pluginManager() { return this.#pluginManager; } /** * Instance of the {@link ClientStateManager} class. * * The context manager can be safely used after `client.init()` has been fulfilled. * * @see {@link ClientStateManager} * @type {ClientStateManager} */ get stateManager() { return this.#stateManager; } /** * Status of the client. * * @type {'idle'|'inited'|'started'|'errored'} */ get status() { return this.#status; } /** @private */ async #dispatchStatus(status) { this.#status = status; // if node target and launched in a child process, forward status to parent process if (this.#runtime === 'node' && process.send !== undefined) { process.send(`soundworks:client:${status}`); } // execute all callbacks in parallel const promises = []; for (let callback of this[kClientOnStatusChangeCallbacks]) { promises.push(callback(status)); } await Promise.all(promises); } /** * The `init` method is part of the initialization lifecycle of the `soundworks` * client. Most of the time, this method will be implicitly executed by the * {@link Client#start} method. * * In some situations you might want to call this method manually, in such cases * the method should be called before the {@link Client#start} method. * * What it does: * - connect the sockets to be server * - perform the handshake with soundworks server (retrieve id, etc.) * - launch the state manager * - initialize all registered plugin * * After `await client.init()` is fulfilled, the {@link Client#stateManager}, * the {@link Client#pluginManager} and the {@link Client#socket} * can be safely used. * * @example * import { Client } from '@soundworks/core/client.js' * * const client = new Client(config); * // optional explicit call of `init` before `start` * await client.init(); * await client.start(); */ async init() { if (this.#status !== 'idle') { throw new DOMException(`Cannot execute 'init' on Client: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError'); } // init socket communications await this.#socket.init(); // we need the try/catch block to change the promise rejection into proper error try { await new Promise((resolve, reject) => { // wait for handshake response before starting stateManager and pluginManager this.#socket.addListener(CLIENT_HANDSHAKE_RESPONSE, async ({ id, uuid, token, version }) => { this.#id = id; this.#uuid = uuid; this.#token = token; if (version !== this.#version) { logger.warnVersionDiscrepancies(this.#role, this.#version, version); } resolve(); }); this.#socket.addListener(CLIENT_HANDSHAKE_ERROR, (err) => { let msg = `Cannot execute 'init' on Client: ${err.message}`; // These are development errors, we can just hang. If we terminate the // socket, a reload is triggered by the launcher which is not good DX. reject(msg); }); // send handshake request const payload = { role: this.#role, version: this.#version, registeredPlugins: this.#pluginManager.getRegisteredPlugins(), }; this.#socket.send(CLIENT_HANDSHAKE_REQUEST, payload); }); } catch (msg) { throw new Error(msg); } // init state manager this.#stateManager[kStateManagerInit](this.id, { emit: this.#socket.send.bind(this.#socket), // need to alias this addListener: this.#socket.addListener.bind(this.#socket), removeAllListeners: this.#socket.removeAllListeners.bind(this.#socket), }); await this.#pluginManager[kPluginManagerStart](); await this.#dispatchStatus('inited'); } /** * The `start` method is part of the initialization lifecycle of the `soundworks` * client. This method will implicitly execute {@link Client#init} method if it * has not been called manually. * * What it does: * - implicitly call {@link Client#init} if not done manually * - start all created contexts. For that to happen, you will have to call `client.init` * manually and instantiate the contexts between `client.init()` and `client.start()` * * @example * import { Client } from '@soundworks/core/client.js' * * const client = new Client(config); * await client.start(); */ async start() { // lazily call init for convenience if (this.#status === 'idle') { await this.init(); } if (this.#status !== 'inited') { throw new DOMException(`Cannot execute 'start' on Client: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError'); } await this.#contextManager[kClientContextManagerStart](); await this.#dispatchStatus('started'); } /** * Stops all started contexts, plugins and terminates the socket connections. * * In most situations, you might not need to call this method. However, it can * be useful for unit testing or similar situations where you want to create * and delete several clients in the same process. * * @example * import { Client } from '@soundworks/core/client.js' * * const client = new Client(config); * await client.start(); * * await new Promise(resolve => setTimeout(resolve, 1000)); * await client.stop(); */ async stop() { if (this.#status !== 'started') { throw new DOMException(`Cannot execute 'stop' on Client: Lifecycle methods must be called in following order: init, start, stop`, 'InvalidAccessError'); } await this.#contextManager[kClientContextManagerStop](); await this.#pluginManager[kPluginManagerStop](); await this.#socket[kSocketTerminate](); await this.#dispatchStatus('stopped'); } /** * Attach and retrieve the global audit state of the application. * * The audit state is a {@link SharedState} instance that keeps track of * global information about the application such as, the number of connected * clients, network latency estimation, etc. It is useful for controller client * roles to give the user an overview about the state of the application. * * The audit state is lazily attached to the client only if this method is called. * * @returns {Promise<SharedState>} * @throws Will throw if called before `client.init()` * @see {@link SharedState} * @example * const auditState = await client.getAuditState(); * auditState.onUpdate(() => console.log(auditState.getValues()), true); */ async getAuditState() { if (this.#status === 'idle') { throw new DOMException(`Cannot execute 'getAuditState' on Server: 'init' must be called first`, 'InvalidAccessError'); } if (this.#auditState === null) { this.#auditState = await this.#stateManager.attach(AUDIT_STATE_NAME); } return this.#auditState; } /** * Listen for the status change ('inited', 'started', 'stopped') of the client. * * @param {Function} callback - Listener to the status change. * @returns {Function} Function that delete the listener when executed. */ onStatusChange(callback) { this[kClientOnStatusChangeCallbacks].add(callback); return () => this[kClientOnStatusChangeCallbacks].delete(callback); } } Object.defineProperties(Client.prototype, { [Symbol.toStringTag]: { __proto__: null, writable: false, enumerable: false, configurable: true, value: 'Client', }, }); export default Client;