server/Server.js

import EventEmitter from 'node:events';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import os from 'node:os';
import {
  X509Certificate,
  createPrivateKey,
} from 'node:crypto';

import {
  isPlainObject,
  idGenerator,
  getTime,
} from '@ircam/sc-utils';
import chalk from 'chalk';
import compression from 'compression';
import express from 'express';
import equal from 'fast-deep-equal';
import Keyv from 'keyv';
import { KeyvFile } from 'keyv-file';
import merge from 'lodash/merge.js';
import pem from 'pem';
import compile from 'template-literal';

import auditSchema from './audit-schema.js';
import {
  encryptData,
  decryptData,
} from './crypto.js';
import ServerClient, {
  kServerClientToken,
} from './ServerClient.js';
import ServerContextManager, {
  kServerContextManagerStart,
  kServerContextManagerStop,
  kServerContextManagerAddClient,
  kServerContextManagerRemoveClient,
} from './ServerContextManager.js';
import ServerPluginManager, {
  kServerPluginManagerCheckRegisteredPlugins,
  kServerPluginManagerAddClient,
  kServerPluginManagerRemoveClient,
} from './ServerPluginManager.js';
import {
  kPluginManagerStart,
  kPluginManagerStop,
} from '../common/BasePluginManager.js';
import ServerStateManager, {
  kServerStateManagerAddClient,
  kServerStateManagerRemoveClient,
} from './ServerStateManager.js';
import {
  kStateManagerInit,
} from '../common/BaseStateManager.js';
import {
  kSocketClientId,
  kSocketTerminate,
} from './ServerSocket.js';
import ServerSockets, {
  kSocketsStart,
  kSocketsStop,
} from './ServerSockets.js';
import logger from '../common/logger.js';
import {
  SERVER_ID,
  CLIENT_HANDSHAKE_REQUEST,
  CLIENT_HANDSHAKE_RESPONSE,
  CLIENT_HANDSHAKE_ERROR,
  AUDIT_STATE_NAME,
} from '../common/constants.js';
import VERSION from '../common/version.js';

let _dbNamespaces = new Set();

/** @private */
const DEFAULT_CONFIG = {
  env: {
    type: 'development',
    port: 8000,
    serverAddress: '',
    subpath: '',
    useHttps: false,
    httpsInfos: null,
    crossOriginIsolated: true,
    verbose: true,
  },
  app: {
    name: 'soundworks',
    clients: {},
  },
};

const TOKEN_VALID_DURATION = 20; // sec

export const kServerOnSocketConnection = Symbol('soundworks:server-on-socket-connection');
export const kServerIsProtectedRole = Symbol('soundworks:server-is-protected-role');
export const kServerIsValidConnectionToken = Symbol('soundworks:server-is-valid-connection-token');
// for testing purposes
export const kServerOnStatusChangeCallbacks = Symbol('soundworks:server-on-status-change-callbacks');
export const kServerApplicationTemplateOptions = Symbol('soundworks:server-application-template-options');

/**
 * The `Server` class is the main entry point for the server-side of a soundworks
 * application.
 *
 * The `Server` instance allows to access soundworks components such as {@link ServerStateManager},
 * {@link ServerPluginManager}, {@link ServerSocket} or {@link ServerContextManager}.
 * Its is also responsible for handling the initialization lifecycles of the different
 * soundworks components.
 *
 * ```
 * import { Server } from '@soundworks/core/server';
 *
 * const server = new Server({
 *   app: {
 *     name: 'my-example-app',
 *     clients: {
 *       player: { target: 'browser', default: true },
 *       controller: { target: 'browser' },
 *       thing: { target: 'node' }
 *     },
 *   },
 *   env: {
 *     port: 8000,
 *   },
 * });
 *
 * await server.start();
 * ```
 *
 * According to the clients definitions provided in `config.app.clients`, the
 * server will automatically create a dedicated route for each browser client role.
 * For example, given the config object of the example above that defines two
 * different client roles for browser targets (i.e. `player` and `controller`):
 *
 * ```
 * config.app.clients = {
 *   player: { target: 'browser', default: true },
 *   controller: { target: 'browser' },
 * }
 * ```
 *
 * 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.
 */
class Server {
  #config = null;
  #version = null;
  #status = null;
  #router = null;
  #httpServer = null;
  #db = null;

  #sockets = null;
  #pluginManager = null;
  #stateManager = null;
  #contextManager = null;

  // If `https` is required, hold informations about the certificates, e.g. if
  // self-signed, the dates of validity of the certificates, etc.
  #httpsInfos = null;
  #onClientConnectCallbacks = new Set();
  #onClientDisconnectCallbacks = new Set();
  #auditState = null;
  #pendingConnectionTokens = new Set();
  #trustedClients = new Set();

  /**
   * @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.
   * - if `config.env.useHttps` is `true` and `config.env.httpsInfos` is not `null`
   *   (which generates self signed certificated), `config.env.httpsInfos.cert` and
   *   `config.env.httpsInfos.key` should point to valid cert files.
   */
  constructor(config) {
    if (!isPlainObject(config)) {
      throw new Error(`[soundworks:Server] Invalid argument for Server constructor, config should be an object`);
    }

    this.#config = merge({}, DEFAULT_CONFIG, config);

    // parse config
    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`);
    }

    // @peeka - remove this check
    // [2024-05-29] Override default `config.env.serverAddress`` provided from
    // template `loadConfig` to '' so that browser clients can default to
    // window.location.hostname and node clients to `127.0.0.1`
    if (process.env.ENV === undefined && this.config.env.serverAddress === '127.0.0.1') {
      this.config.env.serverAddress = '';
    }

    if (this.#config.env.useHttps && this.#config.env.httpsInfos !== null) {
      const httpsInfos = 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 }`);
      }

      if (!('cert' in httpsInfos) || !('key' in httpsInfos)) {
        throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, should contain both "cert" and "key" entries`);
      }
      // @todo - move that to constructor
      if (httpsInfos.cert !== null && !fs.existsSync(httpsInfos.cert)) {
        throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "cert" file not found`);
      }

      if (httpsInfos.key !== null && !fs.existsSync(httpsInfos.key)) {
        throw new Error(`[soundworks:Server] Invalid "env.httpsInfos" config, "key" file not found`);
      }
    }

    this.#version = VERSION;
    // @note: we use express() instead of express.Router() because all 404 and
    // error stuff is handled by default
    // compression must be set before `express.static()`
    this.#router = express();
    this.#router.use(compression());
    this.#sockets = new ServerSockets(this, { path: 'socket' });
    this.#pluginManager = new ServerPluginManager(this);
    this.#stateManager = new ServerStateManager();
    this.#contextManager = new ServerContextManager(this);
    this.#status = 'idle';
    this.#db = this.createNamespacedDb('core');

    this[kServerOnStatusChangeCallbacks] = new Set();
    this[kServerApplicationTemplateOptions] = {
      templateEngine: null,
      templatePath: null,
      clientConfigFunction: null,
    };

    // register audit state schema
    this.#stateManager.registerSchema(AUDIT_STATE_NAME, auditSchema);

    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}
   * @readonly
   */
  get id() {
    return SERVER_ID;
  }

  /**
   * Status of the server.
   *
   * @type {'idle'|'inited'|'started'|'errored'}
   */
  get status() {
    return this.#status;
  }

  /**
   * 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;
  }


  /**
   * Simple key / value filesystem database with Promise based Map API.
   *
   * Basically a tiny wrapper around the {@link https://github.com/lukechilds/keyv} package.
   */
  get db() {
    return this.#db;
  }

  /**
   * Instance of the {@link ServerSockets} class.
   *
   * @type {ServerSockets}
   */
  get sockets() {
    return this.#sockets;
  }

  /**
   * Instance of the {@link ServerPluginManager} class.
   *
   * @type {ServerPluginManager}
   */
  get pluginManager() {
    return this.#pluginManager;
  }

  /**
   * Instance of the {@link ServerStateManager} class.
   *
   * @type {ServerStateManager}
   */
  get stateManager() {
    return this.#stateManager;
  }

  /**
   * Instance of the {@link ServerContextManager} class.
   *
   * @type {ServerContextManager}
   */
  get contextManager() {
    return this.#contextManager;
  }

  /** @private */
  async #dispatchStatus(status) {
    this.#status = status;

    // if launched in a child process, forward status to parent process
    if (process.send !== undefined) {
      process.send(`soundworks:server:${status}`);
    }

    // execute all callbacks in parallel
    const promises = [];

    for (let callback of this[kServerOnStatusChangeCallbacks]) {
      promises.push(callback(status));
    }

    await Promise.all(promises);
  }

  /**
   * Register a callback to execute when status change
   *
   * @param {function} callback
   */
  onStatusChange(callback) {
    this[kServerOnStatusChangeCallbacks].add(callback);
    return () => this[kServerOnStatusChangeCallbacks].delete(callback);
  }

  /**
   * Attach and retrieve the global audit state of the application.
   *
   * The audit state is a {@link SharedState} instance that keeps track of
   * global informations about the application such as, the number of connected
   * clients, network latency estimation, etc.
   *
   * The audit state is created by the server on start up.
   *
   * @returns {Promise<SharedState>}
   * @throws Will throw if called before `server.init()`
   *
   * @example
   * const auditState = await server.getAuditState();
   * auditState.onUpdate(() => console.log(auditState.getValues()), true);
   */
  async getAuditState() {
    if (this.#status === 'idle') {
      throw new Error(`[soundworks.Server] Cannot access audit state before init`);
    }

    return this.#auditState;
  }

  /**
   * 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
   * {@link Server#start} method.
   *
   * In some situations you might want to call this method manually, in such cases
   * the method should be called before the {@link Server#start} method.
   *
   * What it does:
   * - create the audit state
   * - prepapre http(s) server and routing according to the informations
   * declared in `config.app.clients`
   * - initialize all registered plugins
   *
   * After `await server.init()` is fulfilled, the {@link Server#stateManager}
   * and all registered plugins can be safely used.
   *
   * @example
   * const server = new Server(config);
   * await server.init();
   * await server.start();
   * // or implicitly called by start
   * const server = new Server(config);
   * await server.start(); // init is called implicitely
   */
  async init() {
    this.#stateManager[kStateManagerInit](SERVER_ID, new EventEmitter());

    const numClients = {};
    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) {
      const ids = idGenerator();

      const soundworksAuth = (req, res, next) => {
        let role = null;

        for (let [_role, config] of Object.entries(this.#config.app.clients)) {
          if (req.path === config.route) {
            role = _role;
          }
        }

        // route that are not client entry points just pass through the middleware
        if (role === null) {
          next();
          return;
        }

        const isProtected  = this[kServerIsProtectedRole](role);

        if (isProtected) {
          // authentication middleware
          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(':');

          // verify login and password are set and correct
          if (login && password && login === auth.login && password === auth.password) {
            // -> access granted...
            // generate token for web socket to check connections
            const id = ids.next().value;
            const ip = req.ip;
            const time = getTime();
            const token = { id, ip, time };
            const encryptedToken = encryptData(token);

            this.#pendingConnectionTokens.add(encryptedToken);

            setTimeout(() => {
              this.#pendingConnectionTokens.delete(encryptedToken);
            }, TOKEN_VALID_DURATION * 1000);

            // pass to the response object to be send to the client
            res.swToken = encryptedToken;

            return next();
          }

          // show login / password modal
          res.writeHead(401, {
            'WWW-Authenticate':'Basic',
            'Content-Type':'text/plain',
          });

          res.end('Authentication required.');
        } else {
          // route is not protected
          return next();
        }
      };

      this.#router.use(soundworksAuth);
    }

    // ------------------------------------------------------------
    // create HTTP(S) SERVER
    // ------------------------------------------------------------
    const useHttps = this.#config.env.useHttps || false;

    if (!useHttps) {
      this.#httpServer = http.createServer(this.#router);
    } else {
      const httpsInfos = this.#config.env.httpsInfos;
      let useSelfSigned = false;

      if (!httpsInfos || equal(httpsInfos, { cert: null, key: null })) {
        useSelfSigned = true;
      }

      if (!useSelfSigned) {
        try {
          // existance of file is checked in contructor
          let cert = fs.readFileSync(httpsInfos.cert);
          let key = fs.readFileSync(httpsInfos.key);

          let x509 = null;
          // this fails with self-signed certificates for whatever reason...
          try {
            x509 = new X509Certificate(cert);
          } catch (err) {
            this.#dispatchStatus('errored');
            throw new Error(`[soundworks:Server] Invalid https cert file`);
          }

          try {
            const keyObj = createPrivateKey(key);

            if (!x509.checkPrivateKey(keyObj)) {
              this.#dispatchStatus('errored');
              throw new Error(`[soundworks:Server] Invalid https key file`);
            }
          } catch (err) {
            this.#dispatchStatus('errored');
            throw new Error(`[soundworks:Server] Invalid https key file`);
          }

          // check is certificate is still valid
          const now = Date.now();
          const certExpire = Date.parse(x509.validTo);
          const isValid = now < certExpire;

          const diff = certExpire - now;
          const daysRemaining = Math.round(diff / 1000 / 60 / 60 / 24);

          this.#httpsInfos = {
            selfSigned: false,
            CN: x509.subject.split('=')[1],
            altNames: x509.subjectAltName.split(',').map(e => e.trim().split(':')[1]),
            validFrom: x509.validFrom,
            validTo: x509.validTo,
            isValid: isValid,
            daysRemaining: daysRemaining,
          };

          this.#httpServer = https.createServer({ key, cert }, this.#router);
        } catch (err) {
          logger.error(`
Invalid certificate files, please check your:
- key file: ${httpsInfos.key}
- cert file: ${httpsInfos.cert}
          `);

          this.#dispatchStatus('errored');
          throw err;
        }
      } else {
        // generate certs
        // --------------------------------------------------------
        const cert = await this.#db.get('httpsCert');
        const key = await this.#db.get('httpsKey');

        if (key && cert) {
          this.#httpsInfos = { selfSigned: true };
          this.#httpServer = https.createServer({ cert, key }, this.#router);
        } else {
          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) {
                logger.error(err.stack);
                this.#dispatchStatus('errored');

                reject(err);
                return;
              }

              const cert = keys.certificate;
              const key = keys.serviceKey;

              this.#httpsInfos = { selfSigned: true };
              // we store the generated cert so that we don't have to re-accept
              // the cert each time the server restarts in development
              await this.#db.set('httpsCert', cert);
              await this.#db.set('httpsKey', key);

              const httpsServer = https.createServer({ cert, key }, this.#router);

              resolve(httpsServer);
            });
          });
        }
      }
    }

    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') {
        nodeOnly = false;
      }
    }

    if (!nodeOnly) {
      if (this[kServerApplicationTemplateOptions].templateEngine === null
        || this[kServerApplicationTemplateOptions].templatePath === null
        || this[kServerApplicationTemplateOptions].clientConfigFunction === null
      ) {
        throw new Error('[soundworks:Server] A browser client has been found in "config.app.clients" but configuration for html templating is missing. You should probably call `server.useDefaultApplicationTemplate()` if you use the soundworks-template and/or refer (at your own risks) to the documentation of `setCustomTemplateConfig()`');
      }
    }

    // ------------------------------------------------------------
    // INIT ROUTING
    // ------------------------------------------------------------
    logger.title(`configured clients and routing`);

    const routes = [];
    const clientsConfig = [];

    for (let role in this.#config.app.clients) {
      const config = Object.assign({}, this.#config.app.clients[role]);
      config.role = role;
      clientsConfig.push(config);
    }

    // sort default client last to open the route at the end
    clientsConfig
      .sort(a => a.default === true ? 1 : -1)
      .forEach(config => {
        const path = this.#openClientRoute(this.#router, config);
        routes.push({ role: config.role, path });
      });

    logger.clientConfigAndRouting(routes, this.#config);

    // ------------------------------------------------------------
    // START PLUGIN MANAGER
    // ------------------------------------------------------------
    await this.#pluginManager[kPluginManagerStart]();

    await this.#dispatchStatus('inited');

    return Promise.resolve();
  }

  /**
   * The `start` method is part of the initialization lifecycle of the `soundworks`
   * server. The `start` method will implicitly call the {@link Server#init}
   * method if it has not been called manually.
   *
   * What it does:
   * - implicitely call {@link Server#init} if not done manually
   * - launch the HTTP and WebSocket servers
   * - start all created contexts. To this end, you will have to call `server.init`
   * manually and instantiate the contexts between `server.init()` and `server.start()`
   *
   * After `await server.start()` the server is ready to accept incoming connections
   *
   * @example
   * import { Server } from '@soundworks/core/server.js'
   *
   * const server = new Server(config);
   * await server.start();
   */
  async start() {
    if (this.#status === 'idle') {
      await this.init();
    }

    if (this.#status === 'started') {
      throw new Error(`[soundworks:Server] Cannot call "server.start()" twice`);
    }

    if (this.#status !== 'inited') {
      throw new Error(`[soundworks:Server] Cannot "server.start()" before "server.init()"`);
    }

    // ------------------------------------------------------------
    // START CONTEXT MANAGER
    // ------------------------------------------------------------
    await this.#contextManager[kServerContextManagerStart]();

    // ------------------------------------------------------------
    // START SOCKET SERVER
    // ------------------------------------------------------------
    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 protocol = useHttps ? 'https' : 'http';
      const ifaces = os.networkInterfaces();

      this.#httpServer.listen(port, async () => {
        logger.title(`${protocol} server listening on`);

        Object.keys(ifaces).forEach(dev => {
          ifaces[dev].forEach(details => {
            if (details.family === 'IPv4') {
              logger.ip(protocol, details.address, port);
            }
          });
        });

        if (this.#httpsInfos !== null) {
          logger.title(`https certificates infos`);

          // this.#httpsInfos.selfSigned = true;
          if (this.#httpsInfos.selfSigned) {
            logger.log(`    self-signed: ${this.#httpsInfos.selfSigned ? 'true' : 'false'}`);
            logger.log(chalk.yellow`    > INVALID CERTIFICATE (self-signed)`);

          } else {
            logger.log(`    valid from: ${this.#httpsInfos.validFrom}`);
            logger.log(`    valid to:   ${this.#httpsInfos.validTo}`);

            // this.#httpsInfos.isValid = false; // for testing
            if (!this.#httpsInfos.isValid) {
              logger.error(chalk.red`    -------------------------------------------`);
              logger.error(chalk.red`    > INVALID CERTIFICATE                      `);
              logger.error(chalk.red`    i.e. you pretend to be safe but you are not`);
              logger.error(chalk.red`    -------------------------------------------`);
            } else {
              // this.#httpsInfos.daysRemaining = 2; // for testing
              if (this.#httpsInfos.daysRemaining < 5) {
                logger.log(chalk.red`    > CERTIFICATE IS VALID... BUT ONLY ${this.#httpsInfos.daysRemaining} DAYS LEFT, PLEASE CONSIDER UPDATING YOUR CERTS!`);
              } else if (this.#httpsInfos.daysRemaining < 15) {
                logger.log(chalk.yellow`    > CERTIFICATE IS VALID - only ${this.#httpsInfos.daysRemaining} days left, be careful...`);
              } else {
                logger.log(chalk.green`    > CERTIFICATE IS VALID (${this.#httpsInfos.daysRemaining} days left)`);
              }
            }

          }
        }

        await this.#dispatchStatus('started');

        if (this.#config.env.type === 'development') {
          logger.log(`\n> press "${chalk.bold('Ctrl + C')}" to exit`);
        }

        resolve();
      });
    });
  }

  /**
   * Stops all started contexts, plugins, close all the socket connections and
   * the http(s) server.
   *
   * In most situations, you might not need to call this method. However, it can
   * be usefull for unit testing or similar situations where you want to create
   * and delete several servers in the same process.
   *
   * @example
   * import { Server } from '@soundworks/core/server.js'
   *
   * const server = new Server(config);
   * await server.start();
   *
   * await new Promise(resolve => setTimeout(resolve, 1000));
   * await server.stop();
   */
  async stop() {
    if (this.#status !== 'started') {
      throw new Error(`[soundworks:Server] Cannot stop() before start()`);
    }

    await this.#contextManager[kServerContextManagerStop]();
    await this.#pluginManager[kPluginManagerStop]();

    this.#sockets[kSocketsStop]();

    this.#httpServer.close(err => {
      if (err) {
        throw new Error(err.message);
      }
    });

    await this.#dispatchStatus('stopped');
  }

  /**
   * Open the route for a given client.
   * @private
   */
  #openClientRoute(router, config) {
    const { role, target } = config;
    const isDefault = (config.default === true);
    // only browser targets need a route
    if (target === 'node') {
      return;
    }

    let route = '/';

    if (!isDefault) {
      route += `${role}`;
    }

    this.#config.app.clients[role].route = route;

    // define template filename: `${role}.html` or `default.html`
    const {
      templatePath,
      templateEngine,
      clientConfigFunction,
    } = this[kServerApplicationTemplateOptions];

    const clientTmpl = path.join(templatePath, `${role}.tmpl`);
    const defaultTmpl = path.join(templatePath, `default.tmpl`);

    // make it sync
    let template;

    try {
      const stats = fs.statSync(clientTmpl);
      template = stats.isFile() ? clientTmpl : defaultTmpl;
    } catch (err) {
      template = defaultTmpl;
    }

    let tmplString;

    try {
      tmplString = fs.readFileSync(template, 'utf8');
    } catch (err) {
      throw new Error(`[soundworks:Server] html template file "${template}" not found`);
    }

    const tmpl = templateEngine.compile(tmplString);

    const soundworksClientHandler = (req, res) => {
      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
      if (res.swToken) {
        data.token = res.swToken;
      }

      // 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) {
        res.writeHead(200, {
          'Cross-Origin-Resource-Policy': 'same-origin',
          'Cross-Origin-Embedder-Policy': 'require-corp',
          'Cross-Origin-Opener-Policy': 'same-origin',
        });
      }

      const appIndex = tmpl(data);
      res.end(appIndex);
    };

    // http request
    router.get(route, soundworksClientHandler);

    // return route infos for logging on server start
    return route;
  }

  onClientConnect(callback) {
    this.#onClientConnectCallbacks.add(callback);

    return () => this.#onClientConnectCallbacks.delete(callback);
  }

  onClientDisconnect(callback) {
    this.#onClientDisconnectCallbacks.add(callback);

    return () => this.#onClientDisconnectCallbacks.delete(callback);
  }

  /**
   * Socket connection callback.
   * @private
   */
  [kServerOnSocketConnection](role, socket, connectionToken) {
    const client = new ServerClient(role, socket);
    socket[kSocketClientId] = client.id;
    const roles = Object.keys(this.#config.app.clients);

    // this has been validated
    if (this[kServerIsProtectedRole](role) && this[kServerIsValidConnectionToken](connectionToken)) {
      const { ip } = decryptData(connectionToken);
      const newData = { ip, id: client.id };
      const newToken = encryptData(newData);

      client[kServerClientToken] = newToken;

      this.#pendingConnectionTokens.delete(connectionToken);
      this.#trustedClients.add(client);
    }

    socket.addListener('close', async () => {
      // do nothing if client role is invalid
      if (roles.includes(role)) {
        // decrement audit state counter
        const numClients = this.#auditState.get('numClients');
        numClients[role] -= 1;
        this.#auditState.set({ numClients });

        // delete token
        if (this.#trustedClients.has(client)) {
          this.#trustedClients.delete(client);
        }

        // if something goes wrong here, the 'close' event is called again and
        // again and again... let's just log the error and terminate the socket
        try {
          // clean context manager, await before cleaning state manager
          await this.#contextManager[kServerContextManagerRemoveClient](client);
          // remove client from pluginManager
          await this.#pluginManager[kServerPluginManagerRemoveClient](client);
          // clean state manager
          await this.#stateManager[kServerStateManagerRemoveClient](client.id);

          this.#onClientDisconnectCallbacks.forEach(callback => callback(client));
        } catch (err) {
          console.error(err);
        }
      }

      // clean sockets
      socket[kSocketTerminate]();
    });

    socket.addListener(CLIENT_HANDSHAKE_REQUEST, async payload => {
      const { role, version, registeredPlugins } = payload;

      if (!roles.includes(role)) {
        console.error(`[soundworks.Server] A client with invalid role ("${role}") attempted to connect`);

        socket.send(CLIENT_HANDSHAKE_ERROR, {
          type: 'invalid-client-type',
          message: `Invalid client role, please check server configuration (valid client roles are: ${roles.join(', ')})`,
        });
        return;
      }

      if (version !== this.#version) {
        logger.warnVersionDiscepancies(role, version, this.#version);
      }

      try {
        this.#pluginManager[kServerPluginManagerCheckRegisteredPlugins](registeredPlugins);
      } catch (err) {
        socket.send(CLIENT_HANDSHAKE_ERROR, {
          type: 'invalid-plugin-list',
          message: err.message,
        });
        return;
      }

      // increment audit state
      const numClients = this.#auditState.get('numClients');
      numClients[role] += 1;
      this.#auditState.set({ numClients });

      const transport = {
        emit: client.socket.send.bind(client.socket),
        addListener: client.socket.addListener.bind(client.socket),
        removeAllListeners: client.socket.removeAllListeners.bind(client.socket),
      };
      // add client to state manager
      await this.#stateManager[kServerStateManagerAddClient](client.id, transport);
      // add client to plugin manager
      // server-side, all plugins are active for the lifetime of the client
      await this.#pluginManager[kServerPluginManagerAddClient](client, registeredPlugins);
      // add client to context manager
      await this.#contextManager[kServerContextManagerAddClient](client);

      this.#onClientConnectCallbacks.forEach(callback => callback(client));

      const { id, uuid, token } = client;
      socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token, version: this.#version });
    });
  }

  /**
   * Create namespaced databases for core and plugins
   * (kind of experimental API do not expose in doc for now)
   *
   * @note - introduced in v3.1.0-beta.1
   * @note - used by core and plugin-audio-streams
   * @private
   */
  createNamespacedDb(namespace = null) {
    if (namespace === null || !(typeof namespace === 'string')) {
      throw new Error(`[soundworks:Server] Invalid namespace for ".createNamespacedDb(namespace)", namespace is mandatory and should be a string`);
    }

    if (_dbNamespaces.has(namespace)) {
      throw new Error(`[soundworks:Server] Invalid namespace for ".createNamespacedDb(namespace)", namespace "${namespace}" already exists`);
    }

    // KeyvFile uses fs-extra.outputFile internally so we don't need to create
    // the directory, it will be lazily created if something is written in the db
    // @see https://github.com/zaaack/keyv-file/blob/52502077c78226b3d69a615c80b88e53be096979/index.ts#L157
    const filename = path.join(process.cwd(), '.data', `soundworks-${namespace}.db`);
    // @note - keyv-file doesn't seems to works
    const store = new KeyvFile({ filename });
    const db = new Keyv({ namespace, store });
    db.on('error', err => logger.error(`[soundworks:Server] db ${namespace} error: ${err}`));

    return db;
  }

  /**
   * Configure the server to work _out-of-the-box_ within the soundworks application
   * template provided by `@soundworks/create.
   *
   * - uses [template-literal](https://www.npmjs.com/package/template-literal) package
   * as html templateEngine
   * - define `.build/server/tmpl` as the directory in which html template can be
   * found
   * - define the `clientConfigFunction` function that return client compliant
   * config object to be injected in the html template.
   *
   * Also expose two public directory:
   * - the `public` directory which is exposed behind the root path
   * - the `./.build/public` directory which is exposed behind the `build` path
   *
   * _Note: except in very rare cases (so rare that they are quite difficult to imagine),
   * you should rely on these defaults._
   */
  useDefaultApplicationTemplate() {
    const buildDirectory = path.join('.build', 'public');

    const useMinifiedFile = {};
    const roles = Object.keys(this.#config.app.clients);

    roles.forEach(role => {
      if (this.#config.env.type === 'production') {
        // check if minified file exists
        const minifiedFilePath = path.join(buildDirectory, `${role}.min.js`);

        if (fs.existsSync(minifiedFilePath)) {
          useMinifiedFile[role] = true;
        } else {
          console.log(chalk.yellow(`    > Minified file not found for client "${role}", falling back to normal build file (use \`npm run build:production && npm start\` to use minified files)`));
          useMinifiedFile[role] = false;
        }
      } else {
        useMinifiedFile[role] = false;
      }
    });

    this[kServerApplicationTemplateOptions] = {
      templateEngine: { compile },
      templatePath: path.join('.build', 'server', 'tmpl'),
      clientConfigFunction: (role, config, _httpRequest) => {
        return {
          role: role,
          app: {
            name: config.app.name,
            author: config.app.author,
          },
          env: {
            type: config.env.type,
            // use to configure the socket if the server is running on a different
            // location than the one the client was served from (cf. #90)
            useHttps: config.env.useHttps,
            serverAddress: config.env.serverAddress,
            port: config.env.port,
            // other config, to review
            websockets: config.env.websockets,
            subpath: config.env.subpath,
            useMinifiedFile: useMinifiedFile[role],
          },
        };
      },
    };

    this.#router.use(express.static('public'));
    this.#router.use('/build', express.static(buildDirectory));
  }

  /**
   * Define custom template path, template engine, and clientConfig function.
   * This method is proposed for very advanced use-cases and should very probably
   * be improved. If you consider using this for some reason, please get in touch
   * first to explain your use-case :)
   */
  setCustomApplicationTemplateOptions(options) {
    Object.assign(this[kServerApplicationTemplateOptions], options);
  }



//
  /** @private */
  [kServerIsProtectedRole](role) {
    if (this.#config.env.auth && Array.isArray(this.#config.env.auth.clients)) {
      return this.#config.env.auth.clients.includes(role);
    }

    return false;
  }

  /** @private */
  [kServerIsValidConnectionToken](token) {
    // token should be in pending token list
    if (!this.#pendingConnectionTokens.has(token)) {
      return false;
    }

    // check the token is not too old
    const data = decryptData(token);
    const now = getTime();

    // token is valid only for 30 seconds (this is arbitrary)
    if (now > data.time + TOKEN_VALID_DURATION) {
      // delete the token, is too old
      this.#pendingConnectionTokens.delete(token);
      return false;
    } else {
      return true;
    }
  }

  /**
   * Check if the given client is trusted, i.e. config.env.type == 'production'
   * and the client is protected behind a password.
   *
   * @param {ServerClient} client - Client to be tested
   * @returns {Boolean}
   */
  isTrustedClient(client) {
    if (this.#config.env.type !== 'production') {
      return true;
    } else {
      return this.#trustedClients.has(client);
    }
  }

  /**
   * Check if the token from a client is trusted, i.e. config.env.type == 'production'
   * and the client is protected behind a password.
   *
   * @param {Number} clientId - Id of the client
   * @param {Number} clientIp - Ip of the client
   * @param {String} token - Token to be tested
   * @returns {Boolean}
   */
  // for stateless interactions, e.g. POST files
  isTrustedToken(clientId, clientIp, token) {
    if (this.#config.env.type !== 'production') {
      return true;
    } else {
      for (let client of this.#trustedClients) {
        if (client.id === clientId && client[kServerClientToken] === token) {
          // check that given token is consistent with client ip and id
          const { id, ip } = decryptData(client[kServerClientToken]);

          if (clientId === id && clientIp === ip) {
            return true;
          }
        }

      }

      return false;
    }
  }
}

export default Server;