diff --git a/src/client/Client.js b/src/client/Client.js index 9a421ac7..ff757af7 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -14,7 +14,7 @@ import { AUDIT_STATE_NAME, } from '../common/constants.js'; import logger from '../common/logger.js'; -import version from '../common/version.js'; +import VERSION from '../common/version.js'; // for testing purposes export const kClientVersionTest = Symbol('soundworks:client-version-test'); @@ -61,9 +61,9 @@ export const kClientVersionTest = Symbol('soundworks:client-version-test'); * ``` */ class Client { + #config = null; #version = null; #role = null; - #config = null; #id = null; #uuid = null; #target = null; @@ -121,13 +121,7 @@ class Client { this.#config.env = {}; } - // minimal configuration for websockets - this.#config.env.websockets = Object.assign({ - path: 'socket', - pingInterval: 5000, - }, config.env.websockets); - - this.#version = version; + this.#version = VERSION; // allow override though config for testing if (config[kClientVersionTest]) { this.#version = config[kClientVersionTest]; @@ -135,7 +129,7 @@ class Client { this.#role = config.role; this.#target = isBrowser() ? 'browser' : 'node'; - this.#socket = new ClientSocket(); + this.#socket = new ClientSocket(this.#role, this.#config, { path: 'socket' }); this.#contextManager = new ClientContextManager(); this.#pluginManager = new ClientPluginManager(this); this.#stateManager = new ClientStateManager(); @@ -305,7 +299,7 @@ class Client { */ async init() { // init socket communications - await this.#socket.init(this.#role, this.#config); + await this.#socket.init(); // we need the try/catch block to change the promise rejection into proper error try { diff --git a/src/client/ClientSocket.js b/src/client/ClientSocket.js index 4f27429a..d6b149ba 100644 --- a/src/client/ClientSocket.js +++ b/src/client/ClientSocket.js @@ -34,10 +34,17 @@ export const kSocketTerminate = Symbol('soundworks:socket-terminate'); * @hideconstructor */ class ClientSocket { + #role = null; + #config = null; + #socketOptions = null; #socket = null; #listeners = new Map(); - constructor() {} + constructor(role, config, socketOptions) { + this.#role = role; + this.#config = config; + this.#socketOptions = socketOptions; + } /** * Initialize a websocket connection with the server. Automatically called @@ -47,39 +54,39 @@ class ClientSocket { * @param {object} config - Configuration of the sockets * @private */ - async init(role, config) { - let { path } = config.env.websockets; + async init() { + let { path } = this.#socketOptions; // cf. https://github.com/collective-soundworks/soundworks/issues/35 - if (config.env.subpath) { - path = `${config.env.subpath}/${path}`; + if (this.#config.env.subpath) { + path = `${this.#config.env.subpath}/${path}`; } - const protocol = config.env.useHttps ? 'wss:' : 'ws:'; - const port = config.env.port; + const protocol = this.#config.env.useHttps ? 'wss:' : 'ws:'; + const port = this.#config.env.port; let serverAddress; let webSocketOptions; if (isBrowser()) { // if a server address is given in config, use it, else fallback to URL hostname - if (config.env.serverAddress !== '') { - serverAddress = config.env.serverAddress; + if (this.#config.env.serverAddress !== '') { + serverAddress = this.#config.env.serverAddress; } else { serverAddress = window.location.hostname; } webSocketOptions = []; } else { - serverAddress = config.env.serverAddress; + serverAddress = this.#config.env.serverAddress; webSocketOptions = { rejectUnauthorized: false, }; } - let queryParams = `role=${role}`; + let queryParams = `role=${this.#role}`; - if (config.token) { - queryParams += `&token=${config.token}`; + if (this.#config.token) { + queryParams += `&token=${this.#config.token}`; } const url = `${protocol}//${serverAddress}:${port}/${path}?${queryParams}`; diff --git a/src/server/Server.js b/src/server/Server.js index f2b21e00..396b4b6b 100644 --- a/src/server/Server.js +++ b/src/server/Server.js @@ -37,7 +37,10 @@ import { kSocketClientId, kSocketTerminate, } from './Socket.js'; -import Sockets from './Sockets.js'; +import ServerSockets, { + kSocketsStart, + kSocketsStop, +} from './ServerSockets.js'; import logger from '../common/logger.js'; import { SERVER_ID, @@ -46,7 +49,7 @@ import { CLIENT_HANDSHAKE_ERROR, AUDIT_STATE_NAME, } from '../common/constants.js'; -import version from '../common/version.js'; +import VERSION from '../common/version.js'; let _dbNamespaces = new Set(); @@ -54,19 +57,18 @@ let _dbNamespaces = new Set(); * Configuration object for the server. * * @typedef ServerConfig - * @memberof server * @type {object} * @property {object} [app] - Application configration object. * @property {object} app.clients - Definition of the application clients. * @property {string} [app.name=''] - Name of the application. * @property {string} [app.author=''] - Name of the author. * @property {object} [env] - Environment configration object. - * @property {boolean} env.port - Port on which the server is listening. * @property {boolean} env.useHttps - Define is the server run in http or in https. - * @property {boolean} [env.httpsInfos={}] - Path to cert files for https. - * @property {boolean} env.serverAddress - Domain name or IP of the server. - * Mandatory if node clients are defined - * @property {string} [env.websockets={}] - Configuration options for websockets. + * @property {string} env.serverAddress - Domain name or IP of the server. + * Mandatory when node clients are declared + * @property {number} env.port - Port on which the server is listening. + * @property {obj} [env.httpsInfos=null] - Path to cert files ( cert, key } for https. + * If not given and useHttps is true self certifified certificates will be created. * @property {string} [env.subpath=''] - If running behind a proxy, path to the application. */ @@ -77,10 +79,6 @@ const DEFAULT_CONFIG = { port: 8000, serverAddress: null, subpath: '', - websockets: { - path: 'socket', - pingInterval: 5000, - }, useHttps: false, httpsInfos: null, crossOriginIsolated: true, @@ -94,6 +92,8 @@ const DEFAULT_CONFIG = { const TOKEN_VALID_DURATION = 20; // sec +export const kServerOnSocketConnection = Symbol('soundworks:server-on-socket-connection'); + /** * The `Server` class is the main entry point for the server-side of a soundworks * application. @@ -138,12 +138,16 @@ const TOKEN_VALID_DURATION = 20; // sec * The server will listen to the following URLs: * - `http://127.0.0.1:8000/` for the `player` role, which is defined as the default client. * - `http://127.0.0.1:8000/controller` for the `controller` role. - * - * @memberof server */ class Server { + #config = null; + #version = null; + #router = null; + #httpServer = null; + #sockets = null; + /** - * @param {server.ServerConfig} config - Configuration object for the server. + * @param {ServerConfig} config - Configuration object for the server. * @throws * - If `config.app.clients` is empty. * - If a `node` client is defined but `config.env.serverAddress` is not defined. @@ -156,53 +160,29 @@ class Server { throw new Error(`[soundworks:Server] Invalid argument for Server constructor, config should be an object`); } - /** - * @description Given config object merged with the following defaults: - * @example - * { - * env: { - * type: 'development', - * port: 8000, - * serverAddress: null, - * subpath: '', - * websockets: { - * path: 'socket', - * pingInterval: 5000, - * }, - * useHttps: false, - * httpsInfos: null, - * crossOriginIsolated: true, - * verbose: true, - * }, - * app: { - * name: 'soundworks', - * clients: {}, - * } - * } - */ - this.config = merge({}, DEFAULT_CONFIG, config); + this.#config = merge({}, DEFAULT_CONFIG, config); // parse config - if (Object.keys(this.config.app.clients).length === 0) { + if (Object.keys(this.#config.app.clients).length === 0) { throw new Error(`[soundworks:Server] Invalid "app.clients" config, at least one client should be declared`); } // if a node client is defined, serverAddress should be defined let hasNodeClient = false; - for (let name in this.config.app.clients) { - if (this.config.app.clients[name].target === 'node') { + for (let name in this.#config.app.clients) { + if (this.#config.app.clients[name].target === 'node') { hasNodeClient = true; } } - if (hasNodeClient && this.config.env.serverAddress === null) { + if (hasNodeClient && this.#config.env.serverAddress === null) { throw new Error(`[soundworks:Server] Invalid "env.serverAddress" config, is mandatory when a node client target is defined`); } - if (this.config.env.useHttps && this.config.env.httpsInfos !== null) { - const httpsInfos = this.config.env.httpsInfos; + if (this.#config.env.useHttps && this.#config.env.httpsInfos !== null) { + const httpsInfos = this.#config.env.httpsInfos; - if (!isPlainObject(this.config.env.httpsInfos)) { + if (!isPlainObject(this.#config.env.httpsInfos)) { throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, should be null or object { cert, key }`); } @@ -219,46 +199,13 @@ class Server { } } - this.version = version; - - /** - * Instance of the express router. - * - * The router can be used to open new route, for example to expose a directory - * of static assets (in default soundworks applications only the `public` is exposed). - * - * @see {@link https://github.com/expressjs/express} - * @example - * import { Server } from '@soundworks/core/server.js'; - * import express from 'express'; - * - * // create the soundworks server instance - * const server = new Server(config); - * - * // expose assets located in the `soundfiles` directory on the network - * server.router.use('/soundfiles', express.static('soundfiles'))); - */ + this.#version = VERSION; // @note: we use express() instead of express.Router() because all 404 and // error stuff is handled by default - this.router = express(); - // compression (must be set before express.static()) - this.router.use(compression()); - - /** - * Raw Node.js `http` or `https` instance - * - * @see {@link https://nodejs.org/api/http.html} - * @see {@link https://nodejs.org/api/https.html} - */ - this.httpServer = null; - - /** - * Instance of the {@link server.Sockets} class. - * - * @see {@link server.Sockets} - * @type {server.Sockets} - */ - this.sockets = new Sockets(); + this.#router = express(); + // compression - must be set before express.static() + this.#router.use(compression()); + this.#sockets = new ServerSockets(this, { path: 'socket' }); /** * Instance of the {@link server.PluginManager} class. @@ -329,18 +276,92 @@ class Server { // register audit state schema this.stateManager.registerSchema(AUDIT_STATE_NAME, auditSchema); - logger.configure(this.config.env.verbose); + logger.configure(this.#config.env.verbose); + } + + /** + * Given config object merged with the following defaults: + * @example + * { + * env: { + * type: 'development', + * port: 8000, + * serverAddress: null, + * subpath: '', + * useHttps: false, + * httpsInfos: null, + * crossOriginIsolated: true, + * verbose: true, + * }, + * app: { + * name: 'soundworks', + * clients: {}, + * } + * } + * @type {ServerConfig} + */ + get config() { + return this.#config; + } + + /** + * Package version. + * + * @type {string} + */ + get version() { + return this.#version; } /** * Id of the server, a constant set to -1 - * @type {Number} + * @type {number} * @readonly */ get id() { return SERVER_ID; } + /** + * Instance of the express router. + * + * The router can be used to open new route, for example to expose a directory + * of static assets (in default soundworks applications only the `public` is exposed). + * + * @see {@link https://github.com/expressjs/express} + * @example + * import { Server } from '@soundworks/core/server.js'; + * import express from 'express'; + * + * // create the soundworks server instance + * const server = new Server(config); + * + * // expose assets located in the `soundfiles` directory on the network + * server.router.use('/soundfiles', express.static('soundfiles'))); + */ + get router() { + return this.#router; + } + + /** + * Raw Node.js `http` or `https` instance + * + * @see {@link https://nodejs.org/api/http.html} + * @see {@link https://nodejs.org/api/https.html} + */ + get httpServer() { + return this.#httpServer; + } + + /** + * Instance of the {@link ServerSockets} class. + * + * @type {ServerSockets} + */ + get sockets() { + return this.#sockets; + } + /** * The `init` method is part of the initialization lifecycle of the `soundworks` * server. Most of the time, the `init` method will be implicitly called by the @@ -373,20 +394,20 @@ class Server { this.stateManager.init(SERVER_ID, new EventEmitter()); const numClients = {}; - for (let name in this.config.app.clients) { + for (let name in this.#config.app.clients) { numClients[name] = 0; } /** @private */ this._auditState = await this.stateManager.create(AUDIT_STATE_NAME, { numClients }); // basic http authentication - if (this.config.env.auth) { + if (this.#config.env.auth) { const ids = idGenerator(); const soundworksAuth = (req, res, next) => { let role = null; - for (let [_role, config] of Object.entries(this.config.app.clients)) { + for (let [_role, config] of Object.entries(this.#config.app.clients)) { if (req.path === config.route) { role = _role; } @@ -402,7 +423,7 @@ class Server { if (isProtected) { // authentication middleware - const auth = this.config.env.auth; + const auth = this.#config.env.auth; // parse login and password from headers const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; const [login, password] = Buffer.from(b64auth, 'base64').toString().split(':'); @@ -442,18 +463,18 @@ class Server { } }; - this.router.use(soundworksAuth); + this.#router.use(soundworksAuth); } // ------------------------------------------------------------ // create HTTP(S) SERVER // ------------------------------------------------------------ - const useHttps = this.config.env.useHttps || false; + const useHttps = this.#config.env.useHttps || false; if (!useHttps) { - this.httpServer = http.createServer(this.router); + this.#httpServer = http.createServer(this.#router); } else { - const httpsInfos = this.config.env.httpsInfos; + const httpsInfos = this.#config.env.httpsInfos; let useSelfSigned = false; if (!httpsInfos || equal(httpsInfos, { cert: null, key: null })) { @@ -505,7 +526,7 @@ class Server { daysRemaining: daysRemaining, }; - this.httpServer = https.createServer({ key, cert }, this.router); + this.#httpServer = https.createServer({ key, cert }, this.#router); } catch (err) { logger.error(` Invalid certificate files, please check your: @@ -524,9 +545,9 @@ Invalid certificate files, please check your: if (key && cert) { this.httpsInfos = { selfSigned: true }; - this.httpServer = https.createServer({ cert, key }, this.router); + this.#httpServer = https.createServer({ cert, key }, this.#router); } else { - this.httpServer = await new Promise((resolve, reject) => { + this.#httpServer = await new Promise((resolve, reject) => { // generate certificate on the fly (for development purposes) pem.createCertificate({ days: 1, selfSigned: true }, async (err, keys) => { if (err) { @@ -546,7 +567,7 @@ Invalid certificate files, please check your: await this.db.set('httpsCert', cert); await this.db.set('httpsKey', key); - const httpsServer = https.createServer({ cert, key }, this.router); + const httpsServer = https.createServer({ cert, key }, this.#router); resolve(httpsServer); }); @@ -558,8 +579,8 @@ Invalid certificate files, please check your: let nodeOnly = true; // do not throw if no browser clients are defined, very usefull for // cleaning tests in particular - for (let role in this.config.app.clients) { - if (this.config.app.clients[role].target === 'browser') { + for (let role in this.#config.app.clients) { + if (this.#config.app.clients[role].target === 'browser') { nodeOnly = false; } } @@ -581,8 +602,8 @@ Invalid certificate files, please check your: const routes = []; const clientsConfig = []; - for (let role in this.config.app.clients) { - const config = Object.assign({}, this.config.app.clients[role]); + for (let role in this.#config.app.clients) { + const config = Object.assign({}, this.#config.app.clients[role]); config.role = role; clientsConfig.push(config); } @@ -591,11 +612,11 @@ Invalid certificate files, please check your: clientsConfig .sort(a => a.default === true ? 1 : -1) .forEach(config => { - const path = this._openClientRoute(this.router, config); + const path = this._openClientRoute(this.#router, config); routes.push({ role: config.role, path }); }); - logger.clientConfigAndRouting(routes, this.config); + logger.clientConfigAndRouting(routes, this.#config); // ------------------------------------------------------------ // START PLUGIN MANAGER @@ -647,22 +668,18 @@ Invalid certificate files, please check your: // ------------------------------------------------------------ // START SOCKET SERVER // ------------------------------------------------------------ - await this.sockets.start( - this, - this.config.env.websockets, - (...args) => this._onSocketConnection(...args), - ); + await this.#sockets[kSocketsStart](); // ------------------------------------------------------------ // START HTTP SERVER // ------------------------------------------------------------ return new Promise(resolve => { - const port = this.config.env.port; - const useHttps = this.config.env.useHttps || false; + const port = this.#config.env.port; + const useHttps = this.#config.env.useHttps || false; const protocol = useHttps ? 'https' : 'http'; const ifaces = os.networkInterfaces(); - this.httpServer.listen(port, async () => { + this.#httpServer.listen(port, async () => { logger.title(`${protocol} server listening on`); Object.keys(ifaces).forEach(dev => { @@ -707,7 +724,7 @@ Invalid certificate files, please check your: await this._dispatchStatus('started'); - if (this.config.env.type === 'development') { + if (this.#config.env.type === 'development') { logger.log(`\n> press "${chalk.bold('Ctrl + C')}" to exit`); } @@ -741,8 +758,9 @@ Invalid certificate files, please check your: await this.contextManager.stop(); await this.pluginManager.stop(); - this.sockets.terminate(); - this.httpServer.close(err => { + this.#sockets[kSocketsStop](); + + this.#httpServer.close(err => { if (err) { throw new Error(err.message); } @@ -769,7 +787,7 @@ Invalid certificate files, please check your: route += `${role}`; } - this.config.app.clients[role].route = route; + this.#config.app.clients[role].route = route; // define template filename: `${role}.html` or `default.html` const { @@ -802,7 +820,7 @@ Invalid certificate files, please check your: const tmpl = templateEngine.compile(tmplString); const soundworksClientHandler = (req, res) => { - const data = clientConfigFunction(role, this.config, req); + const data = clientConfigFunction(role, this.#config, req); // if the client has gone through the connection middleware (add succedeed), // add the token to the data object @@ -813,7 +831,7 @@ Invalid certificate files, please check your: // CORS / COOP / COEP headers for `crossOriginIsolated pages, // enables `sharedArrayBuffers` and high precision timers // cf. https://web.dev/why-coop-coep/ - if (this.config.env.crossOriginIsolated) { + if (this.#config.env.crossOriginIsolated) { res.writeHead(200, { 'Cross-Origin-Resource-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', @@ -848,10 +866,10 @@ Invalid certificate files, please check your: * Socket connection callback. * @private */ - _onSocketConnection(role, socket, connectionToken) { + [kServerOnSocketConnection](role, socket, connectionToken) { const client = new Client(role, socket); socket[kSocketClientId] = client.id; - const roles = Object.keys(this.config.app.clients); + const roles = Object.keys(this.#config.app.clients); // this has been validated if (this.isProtected(role) && this.isValidConnectionToken(connectionToken)) { @@ -911,8 +929,8 @@ Invalid certificate files, please check your: return; } - if (version !== this.version) { - logger.warnVersionDiscepancies(role, version, this.version); + if (version !== this.#version) { + logger.warnVersionDiscepancies(role, version, this.#version); } try { @@ -945,7 +963,7 @@ Invalid certificate files, please check your: this._onClientConnectCallbacks.forEach(callback => callback(client)); const { id, uuid, token } = client; - socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token, version: this.version }); + socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token, version: this.#version }); }); } @@ -1025,10 +1043,10 @@ Invalid certificate files, please check your: const buildDirectory = path.join('.build', 'public'); const useMinifiedFile = {}; - const roles = Object.keys(this.config.app.clients); + const roles = Object.keys(this.#config.app.clients); roles.forEach(role => { - if (this.config.env.type === 'production') { + if (this.#config.env.type === 'production') { // check if minified file exists const minifiedFilePath = path.join(buildDirectory, `${role}.min.js`); @@ -1069,8 +1087,8 @@ Invalid certificate files, please check your: }, }; - this.router.use(express.static('public')); - this.router.use('/build', express.static(buildDirectory)); + this.#router.use(express.static('public')); + this.#router.use('/build', express.static(buildDirectory)); } /** @@ -1109,8 +1127,8 @@ Invalid certificate files, please check your: /** @private */ isProtected(role) { - if (this.config.env.auth && Array.isArray(this.config.env.auth.clients)) { - return this.config.env.auth.clients.includes(role); + if (this.#config.env.auth && Array.isArray(this.#config.env.auth.clients)) { + return this.#config.env.auth.clients.includes(role); } return false; @@ -1145,7 +1163,7 @@ Invalid certificate files, please check your: * @returns {Boolean} */ isTrustedClient(client) { - if (this.config.env.type !== 'production') { + if (this.#config.env.type !== 'production') { return true; } else { return this._trustedClients.has(client); @@ -1163,7 +1181,7 @@ Invalid certificate files, please check your: */ // for stateless interactions, e.g. POST files isTrustedToken(clientId, clientIp, token) { - if (this.config.env.type !== 'production') { + if (this.#config.env.type !== 'production') { return true; } else { for (let client of this._trustedClients) { diff --git a/src/server/Sockets.js b/src/server/ServerSockets.js similarity index 86% rename from src/server/Sockets.js rename to src/server/ServerSockets.js index 498bd8c3..457ce0e9 100644 --- a/src/server/Sockets.js +++ b/src/server/ServerSockets.js @@ -8,6 +8,9 @@ import { WebSocketServer, } from 'ws'; +import { + kServerOnSocketConnection, +} from './Server.js'; import Socket, { kSocketTerminate, } from './Socket.js'; @@ -15,6 +18,9 @@ import Socket, { // @note - fs.readFileSync creates some cwd() issues... import networkLatencyWorker from './audit-network-latency.worker.js'; +export const kSocketsStart = Symbol('soundworks:sockets-start'); +export const kSocketsStop = Symbol('soundworks:sockets-stop'); + export const kSocketsRemoveFromAllRooms = Symbol('soundworks:sockets-remove-from-all-rooms'); export const kSocketsLatencyStatsWorker = Symbol('soundworks:sockets-latency-stats-worker'); export const kSocketsDebugPreventHeartBeat = Symbol('soundworks:sockets-debug-prevent-heartbeat'); @@ -24,14 +30,16 @@ export const kSocketsDebugPreventHeartBeat = Symbol('soundworks:sockets-debug-pr * * _Important: In most cases, you should consider using a {@link client.SharedState} * rather than directly using the Socket instance._ - * - * @memberof server */ -class Sockets { +class ServerSockets { + #server = null; + #config = null; #wsServer = null; #rooms = new Map(); - constructor() { + constructor(server, config) { + this.#server = server; + this.#config = config; // Init special `'*'` room which stores all current connections. this.#rooms.set('*', new Set()); @@ -43,15 +51,14 @@ class Sockets { * Initialize sockets, all sockets are added to two rooms by default: * - to the room corresponding to the client `role` * - to the '*' room that holds all connected sockets - * * @private */ - async start(server, config, onConnectionCallback) { + async [kSocketsStart]() { // Audit for network latency estimation, the worker is written in cjs so that we // can make builds for Max, move back to modules once Max support modules this[kSocketsLatencyStatsWorker] = new Worker(networkLatencyWorker, { eval: true }); - const auditState = await server.getAuditState(); + const auditState = await this.#server.getAuditState(); auditState.onUpdate(updates => { if ('averageNetworkLatencyWindow' in updates || 'averageNetworkLatencyPeriod' in updates) { @@ -74,7 +81,7 @@ class Sockets { // Init ws server this.#wsServer = new WebSocketServer({ noServer: true, - path: `/${config.path}`, + path: `/${this.#config.path}`, }); this.#wsServer.on('connection', (ws, req) => { @@ -84,17 +91,17 @@ class Sockets { socket.addToRoom('*'); socket.addToRoom(role); - onConnectionCallback(role, socket, token); + this.#server[kServerOnSocketConnection](role, socket, token); }); - // Prevent socket with protected role to connect is token is invalid - server.httpServer.on('upgrade', async (req, socket, head) => { + // Prevent socket with protected role to connect if token is invalid + this.#server.httpServer.on('upgrade', async (req, socket, head) => { const { role, token } = querystring.parse(req.url.split('?')[1]); - if (server.isProtected(role)) { + if (this.#server.isProtected(role)) { // we don't have any IP in the upgrade request object, // so we just check the connection token is pending and valid - if (!server.isValidConnectionToken(token)) { + if (!this.#server.isValidConnectionToken(token)) { socket.destroy('not allowed'); } } @@ -109,7 +116,7 @@ class Sockets { * Terminate all existing sockets. * @private */ - terminate() { + [kSocketsStop]() { // terminate stat worker thread this[kSocketsLatencyStatsWorker].terminate(); // clean sockets @@ -119,6 +126,7 @@ class Sockets { /** * Remove given socket from all rooms. + * @private */ [kSocketsRemoveFromAllRooms](socket) { for (let [_, room] of this.#rooms) { @@ -198,4 +206,4 @@ class Sockets { } } -export default Sockets; +export default ServerSockets; diff --git a/src/server/Socket.js b/src/server/Socket.js index 4674e5e5..e14f5bee 100644 --- a/src/server/Socket.js +++ b/src/server/Socket.js @@ -12,7 +12,7 @@ import { kSocketsLatencyStatsWorker, kSocketsDebugPreventHeartBeat, kSocketsRemoveFromAllRooms, -} from './Sockets.js'; +} from './ServerSockets.js'; export const kSocketClientId = Symbol('soundworks:socket-client-id'); export const kSocketTerminate = Symbol('soundworks:socket-terminate'); diff --git a/src/server/StateManager.js b/src/server/StateManager.js index 76afa154..af8673ea 100644 --- a/src/server/StateManager.js +++ b/src/server/StateManager.js @@ -4,7 +4,10 @@ import clonedeep from 'lodash/cloneDeep.js'; import BaseStateManager from '../common/BaseStateManager.js'; import BatchedTransport from '../common/BatchedTransport.js'; import ParameterBag from '../common/ParameterBag.js'; -import SharedStatePrivate from '../common/SharedStatePrivate.js'; +import SharedStatePrivate, { + kSharedStatePrivateAttachClient, + kSharedStatePrivateDetachClient, +} from '../common/SharedStatePrivate.js'; import { CREATE_REQUEST, CREATE_RESPONSE, @@ -29,8 +32,6 @@ import { const generateStateId = idGenerator(); const generateRemoteId = idGenerator(); -const kIsObservableState = Symbol('StateManager::isObservableState'); - /** * @typedef {object} server.StateManager~schema * @@ -320,18 +321,18 @@ class StateManager extends BaseStateManager { this._hooksBySchemaName = new Map(); // protected } + #isObservableState(state) { + const { schemaName, isCollectionController } = state; + // is observable only if not in private state and not a controller state + return !PRIVATE_STATES.includes(schemaName) && !isCollectionController; + } + init(id, transport) { super.init(id, transport); // add itself as client of the state manager server this.addClient(id, transport); } - [kIsObservableState](state) { - const { schemaName, isCollectionController } = state; - // is observable only if not in private state and not a controller state - return !PRIVATE_STATES.includes(schemaName) && !isCollectionController; - } - /** * Add a client to the manager. * @@ -369,11 +370,11 @@ class StateManager extends BaseStateManager { // attach client to the state as owner const isOwner = true; const filter = null; - state._attachClient(remoteId, client, isOwner, filter); + state[kSharedStatePrivateAttachClient](remoteId, client, isOwner, filter); this._sharedStatePrivateById.set(stateId, state); - const currentValues = state._parameters.getValues(); + const currentValues = state.parameters.getValues(); const schemaOption = requireSchema ? schema : null; client.transport.emit( @@ -381,7 +382,7 @@ class StateManager extends BaseStateManager { reqId, stateId, remoteId, schemaName, schemaOption, currentValues, ); - const isObservable = this[kIsObservableState](state); + const isObservable = this.#isObservableState(state); if (isObservable) { this._observers.forEach(observer => { @@ -433,7 +434,7 @@ class StateManager extends BaseStateManager { // i.e. same state -> several remote attach on the same node const remoteId = generateRemoteId.next().value; const isOwner = false; - const currentValues = state._parameters.getValues(); + const currentValues = state.parameters.getValues(); const schema = this._schemas.get(schemaName); const schemaOption = requireSchema ? schema : null; @@ -451,7 +452,7 @@ class StateManager extends BaseStateManager { } } - state._attachClient(remoteId, client, isOwner, filter); + state[kSharedStatePrivateAttachClient](remoteId, client, isOwner, filter); client.transport.emit( ATTACH_RESPONSE, @@ -481,11 +482,11 @@ class StateManager extends BaseStateManager { const statesInfos = []; this._sharedStatePrivateById.forEach(state => { - const isObservable = this[kIsObservableState](state); + const isObservable = this.#isObservableState(state); if (isObservable) { - const { schemaName, id, _creatorId } = state; - statesInfos.push([schemaName, id, _creatorId]); + const { schemaName, id, creatorId } = state; + statesInfos.push([schemaName, id, creatorId]); } }); @@ -534,23 +535,23 @@ class StateManager extends BaseStateManager { // define if the client is the creator of the state, in which case // everybody must delete it - for (let [remoteId, clientInfos] of state._attachedClients) { + for (let [remoteId, clientInfos] of state.attachedClients) { const attachedClient = clientInfos.client; - if (nodeId === attachedClient.id && remoteId === state._creatorRemoteId) { + if (nodeId === attachedClient.id && remoteId === state.creatorRemoteId) { deleteState = true; } } - for (let [remoteId, clientInfos] of state._attachedClients) { + for (let [remoteId, clientInfos] of state.attachedClients) { const attachedClient = clientInfos.client; if (nodeId === attachedClient.id) { - state._detachClient(remoteId, attachedClient); + state[kSharedStatePrivateDetachClient](remoteId, attachedClient); } if (deleteState) { - if (remoteId !== state._creatorRemoteId) { + if (remoteId !== state.creatorRemoteId) { // send notification to other attached nodes attachedClient.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${remoteId}`); } @@ -624,9 +625,9 @@ class StateManager extends BaseStateManager { // @note: deleting schema for (let [_id, state] of this._sharedStatePrivateById) { if (state.schemaName === schemaName) { - for (let [remoteId, clientInfos] of state._attachedClients) { + for (let [remoteId, clientInfos] of state.attachedClients) { const attached = clientInfos.client; - state._detachClient(remoteId, attached); + state[kSharedStatePrivateDetachClient](remoteId, attached); attached.transport.emit(`${DELETE_NOTIFICATION}-${state.id}-${remoteId}`); } diff --git a/src/server/index.js b/src/server/index.js index 19ff999c..52b97477 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -3,29 +3,5 @@ * Copyright (c) 2014-present IRCAM – Centre Pompidou (France, Paris) * SPDX-License-Identifier: BSD-3-Clause */ - -/** - * Server-side part of the *soundworks* framework. - * - * ``` - * import '@soundworks/helpers/polyfills.js'; - * import { Server } from '@soundworks/core/server.js'; - * import { loadConfig } from '../utils/load-config.js'; - * - * // - General documentation: https://soundworks.dev/ - * // - API documentation: https://soundworks.dev/api - * // - Issue Tracker: https://github.com/collective-soundworks/soundworks/issues - * // - Wizard & Tools: `npx soundworks` - * - * const config = loadConfig(process.env.ENV, import.meta.url); - * - * const server = new Server(config); - * server.setDefaultTemplateConfig(); - * - * await server.start(); - * ``` - * - * @namespace server - */ export { default as Server } from './Server.js'; export { default as Context } from './Context.js'; diff --git a/tests/misc/PromiseStore.spec.js b/tests/misc/PromiseStore.spec.js index b0d0b594..f0ac2987 100644 --- a/tests/misc/PromiseStore.spec.js +++ b/tests/misc/PromiseStore.spec.js @@ -1,6 +1,6 @@ import { assert } from 'chai'; import PromiseStore from '../../src/common/PromiseStore.js'; -// import { kStateManagerPromiseStore } from '../../src/common/BaseStateManager.js'; +import { kSharedStatePromiseStore } from '../../src/common/SharedState.js'; import { Server } from '../../src/server/index.js'; import { Client } from '../../src/client/index.js'; @@ -62,24 +62,24 @@ describe('# PromiseStore', () => { { // update promise const promise = state.set({ bool: true }); - assert.equal(state._promiseStore.store.size, 1); - assert.equal(attached._promiseStore.store.size, 0); + assert.equal(state[kSharedStatePromiseStore].store.size, 1); + assert.equal(attached[kSharedStatePromiseStore].store.size, 0); await promise; - assert.equal(state._promiseStore.store.size, 0); - assert.equal(attached._promiseStore.store.size, 0); + assert.equal(state[kSharedStatePromiseStore].store.size, 0); + assert.equal(attached[kSharedStatePromiseStore].store.size, 0); } { // update promise const promise = attached.set({ int: 42 }); - assert.equal(state._promiseStore.store.size, 0); - assert.equal(attached._promiseStore.store.size, 1); + assert.equal(state[kSharedStatePromiseStore].store.size, 0); + assert.equal(attached[kSharedStatePromiseStore].store.size, 1); await promise; - assert.equal(state._promiseStore.store.size, 0); - assert.equal(attached._promiseStore.store.size, 0); + assert.equal(state[kSharedStatePromiseStore].store.size, 0); + assert.equal(attached[kSharedStatePromiseStore].store.size, 0); } { // update fail promise, fail early, no promise created @@ -89,28 +89,28 @@ describe('# PromiseStore', () => { console.log(err.message); } - assert.equal(state._promiseStore.store.size, 0); - assert.equal(attached._promiseStore.store.size, 0); + assert.equal(state[kSharedStatePromiseStore].store.size, 0); + assert.equal(attached[kSharedStatePromiseStore].store.size, 0); } { // detach request const promise = attached.detach(); - assert.equal(state._promiseStore.store.size, 0); - assert.equal(attached._promiseStore.store.size, 1); + assert.equal(state[kSharedStatePromiseStore].store.size, 0); + assert.equal(attached[kSharedStatePromiseStore].store.size, 1); await promise; - assert.equal(state._promiseStore.store.size, 0); - assert.equal(attached._promiseStore.store.size, 0); + assert.equal(state[kSharedStatePromiseStore].store.size, 0); + assert.equal(attached[kSharedStatePromiseStore].store.size, 0); } { // detach request const promise = state.delete(); - assert.equal(state._promiseStore.store.size, 1); + assert.equal(state[kSharedStatePromiseStore].store.size, 1); await promise; - assert.equal(state._promiseStore.store.size, 0); + assert.equal(state[kSharedStatePromiseStore].store.size, 0); } await server.stop(); @@ -129,13 +129,13 @@ describe('# PromiseStore', () => { const promises = []; for (let i = 0; i < 1e4; i++) { let promise = state.set({ int: Math.floor(Math.random() * 1e12) }); - assert.equal(state._promiseStore.store.size, i + 1); + assert.equal(state[kSharedStatePromiseStore].store.size, i + 1); promises.push(promise); } - assert.equal(state._promiseStore.store.size, 1e4); + assert.equal(state[kSharedStatePromiseStore].store.size, 1e4); await Promise.all(promises); - assert.equal(state._promiseStore.store.size, 0); + assert.equal(state[kSharedStatePromiseStore].store.size, 0); await server.stop(); }); diff --git a/tests/states/StateManager.spec.js b/tests/states/StateManager.spec.js index 26f93251..0558ff68 100644 --- a/tests/states/StateManager.spec.js +++ b/tests/states/StateManager.spec.js @@ -7,6 +7,9 @@ import { OBSERVE_RESPONSE, OBSERVE_NOTIFICATION, } from '../../src/common/constants.js'; +import { + kStateManagerClient +} from '../../src/common/BaseStateManager.js'; import config from '../utils/config.js'; import { a, b } from '../utils/schemas.js'; @@ -385,7 +388,7 @@ describe(`# StateManager`, () => { let notificationReceived = false; // check low level transport messages - server.stateManager.client.transport.addListener(OBSERVE_NOTIFICATION, () => { + server.stateManager[kStateManagerClient].transport.addListener(OBSERVE_NOTIFICATION, () => { notificationReceived = true; }); @@ -406,13 +409,13 @@ describe(`# StateManager`, () => { let responsesReceived = 0; - other.stateManager.client.transport.addListener(OBSERVE_RESPONSE, () => { + other.stateManager[kStateManagerClient].transport.addListener(OBSERVE_RESPONSE, () => { responsesReceived += 1; }); let notificationsReceived = 0; - other.stateManager.client.transport.addListener(OBSERVE_NOTIFICATION, () => { + other.stateManager[kStateManagerClient].transport.addListener(OBSERVE_NOTIFICATION, () => { notificationsReceived += 1; }); @@ -485,12 +488,12 @@ describe(`# StateManager`, () => { let responsesReceived = 0; let notificationsReceived = 0; - other.stateManager.client.transport.addListener(OBSERVE_RESPONSE, (...args) => { + other.stateManager[kStateManagerClient].transport.addListener(OBSERVE_RESPONSE, (...args) => { // console.log('OBSERVE_RESPONSE', ...args); responsesReceived += 1; }); - other.stateManager.client.transport.addListener(OBSERVE_NOTIFICATION, (...args) => { + other.stateManager[kStateManagerClient].transport.addListener(OBSERVE_NOTIFICATION, (...args) => { // console.log('OBSERVE_NOTIFICATION', args); notificationsReceived += 1; }); diff --git a/types/client/ClientSocket.d.ts b/types/client/ClientSocket.d.ts index 7d766682..f6ee7d66 100644 --- a/types/client/ClientSocket.d.ts +++ b/types/client/ClientSocket.d.ts @@ -21,6 +21,7 @@ export default ClientSocket; * @hideconstructor */ declare class ClientSocket { + constructor(role: any, config: any, socketOptions: any); /** * Initialize a websocket connection with the server. Automatically called * during {@link Client#init} diff --git a/types/server/Server.d.ts b/types/server/Server.d.ts index ccd3c34b..148867b9 100644 --- a/types/server/Server.d.ts +++ b/types/server/Server.d.ts @@ -1,8 +1,28 @@ +export const kServerOnSocketConnection: unique symbol; export default Server; /** * Configuration object for the server. */ -export type ServerConfig = any; +export type ServerConfig = { + /** + * - Application configration object. + */ + app?: { + clients: object; + name?: string; + author?: string; + }; + /** + * - Environment configration object. + */ + env?: { + useHttps: boolean; + serverAddress: string; + port: number; + httpsInfos?: obj; + subpath?: string; + }; +}; /** * The `Server` class is the main entry point for the server-side of a soundworks * application. @@ -47,12 +67,10 @@ export type ServerConfig = any; * The server will listen to the following URLs: * - `http://127.0.0.1:8000/` for the `player` role, which is defined as the default client. * - `http://127.0.0.1:8000/controller` for the `controller` role. - * - * @memberof server */ declare class Server { /** - * @param {server.ServerConfig} config - Configuration object for the server. + * @param {ServerConfig} config - Configuration object for the server. * @throws * - If `config.app.clients` is empty. * - If a `node` client is defined but `config.env.serverAddress` is not defined. @@ -60,65 +78,7 @@ declare class Server { * (which generates self signed certificated), `config.env.httpsInfos.cert` and * `config.env.httpsInfos.key` should point to valid cert files. */ - constructor(config: server.ServerConfig); - /** - * @description Given config object merged with the following defaults: - * @example - * { - * env: { - * type: 'development', - * port: 8000, - * serverAddress: null, - * subpath: '', - * websockets: { - * path: 'socket', - * pingInterval: 5000, - * }, - * useHttps: false, - * httpsInfos: null, - * crossOriginIsolated: true, - * verbose: true, - * }, - * app: { - * name: 'soundworks', - * clients: {}, - * } - * } - */ - config: any; - version: string; - /** - * Instance of the express router. - * - * The router can be used to open new route, for example to expose a directory - * of static assets (in default soundworks applications only the `public` is exposed). - * - * @see {@link https://github.com/expressjs/express} - * @example - * import { Server } from '@soundworks/core/server.js'; - * import express from 'express'; - * - * // create the soundworks server instance - * const server = new Server(config); - * - * // expose assets located in the `soundfiles` directory on the network - * server.router.use('/soundfiles', express.static('soundfiles'))); - */ - router: any; - /** - * Raw Node.js `http` or `https` instance - * - * @see {@link https://nodejs.org/api/http.html} - * @see {@link https://nodejs.org/api/https.html} - */ - httpServer: any; - /** - * Instance of the {@link server.Sockets} class. - * - * @see {@link server.Sockets} - * @type {server.Sockets} - */ - sockets: server.Sockets; + constructor(config: ServerConfig); /** * Instance of the {@link server.PluginManager} class. * @@ -197,12 +157,71 @@ declare class Server { private _pendingConnectionTokens; /** @private */ private _trustedClients; + /** + * Given config object merged with the following defaults: + * @example + * { + * env: { + * type: 'development', + * port: 8000, + * serverAddress: null, + * subpath: '', + * useHttps: false, + * httpsInfos: null, + * crossOriginIsolated: true, + * verbose: true, + * }, + * app: { + * name: 'soundworks', + * clients: {}, + * } + * } + * @type {ServerConfig} + */ + get config(): ServerConfig; + /** + * Package version. + * + * @type {string} + */ + get version(): string; /** * Id of the server, a constant set to -1 - * @type {Number} + * @type {number} * @readonly */ readonly get id(): number; + /** + * Instance of the express router. + * + * The router can be used to open new route, for example to expose a directory + * of static assets (in default soundworks applications only the `public` is exposed). + * + * @see {@link https://github.com/expressjs/express} + * @example + * import { Server } from '@soundworks/core/server.js'; + * import express from 'express'; + * + * // create the soundworks server instance + * const server = new Server(config); + * + * // expose assets located in the `soundfiles` directory on the network + * server.router.use('/soundfiles', express.static('soundfiles'))); + */ + get router(): any; + /** + * Raw Node.js `http` or `https` instance + * + * @see {@link https://nodejs.org/api/http.html} + * @see {@link https://nodejs.org/api/https.html} + */ + get httpServer(): any; + /** + * Instance of the {@link ServerSockets} class. + * + * @type {ServerSockets} + */ + get sockets(): ServerSockets; /** * The `init` method is part of the initialization lifecycle of the `soundworks` * server. Most of the time, the `init` method will be implicitly called by the @@ -274,11 +293,6 @@ declare class Server { private _openClientRoute; onClientConnect(callback: any): () => boolean; onClientDisconnect(callback: any): () => boolean; - /** - * Socket connection callback. - * @private - */ - private _onSocketConnection; /** * Create namespaced databases for core and plugins * (kind of experimental API do not expose in doc for now) @@ -356,4 +370,11 @@ declare class Server { * @returns {Boolean} */ isTrustedToken(clientId: number, clientIp: number, token: string): boolean; + /** + * Socket connection callback. + * @private + */ + private [kServerOnSocketConnection]; + #private; } +import ServerSockets from './ServerSockets.js'; diff --git a/types/server/Sockets.d.ts b/types/server/ServerSockets.d.ts similarity index 81% rename from types/server/Sockets.d.ts rename to types/server/ServerSockets.d.ts index 8f63ba33..71f944ab 100644 --- a/types/server/Sockets.d.ts +++ b/types/server/ServerSockets.d.ts @@ -1,29 +1,17 @@ +export const kSocketsStart: unique symbol; +export const kSocketsStop: unique symbol; export const kSocketsRemoveFromAllRooms: unique symbol; export const kSocketsLatencyStatsWorker: unique symbol; export const kSocketsDebugPreventHeartBeat: unique symbol; -export default Sockets; +export default ServerSockets; /** * Manage all {@link server.Socket} instances. * * _Important: In most cases, you should consider using a {@link client.SharedState} * rather than directly using the Socket instance._ - * - * @memberof server */ -declare class Sockets { - /** - * Initialize sockets, all sockets are added to two rooms by default: - * - to the room corresponding to the client `role` - * - to the '*' room that holds all connected sockets - * - * @private - */ - private start; - /** - * Terminate all existing sockets. - * @private - */ - private terminate; +declare class ServerSockets { + constructor(server: any, config: any); /** * Add a socket to a room. * diff --git a/types/server/StateManager.d.ts b/types/server/StateManager.d.ts index 8af1d89e..5ac2428f 100644 --- a/types/server/StateManager.d.ts +++ b/types/server/StateManager.d.ts @@ -399,7 +399,6 @@ declare class StateManager extends BaseStateManager { * assert.deepEqual(result, { name: 'test', numUpdates: 1 }); */ registerUpdateHook(schemaName: string, updateHook: any): Fuction; - [kIsObservableState](state: any): boolean; + #private; } import BaseStateManager from '../common/BaseStateManager.js'; -declare const kIsObservableState: unique symbol;