server/Server.js

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 Client from './Client.js';
import ContextManager from './ContextManager.js';
import PluginManager from './PluginManager.js';
import StateManager from './StateManager.js';
import Sockets from './Sockets.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';


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.subpath=''] - If running behind a proxy, path to the application.
 */

/** @private */
const DEFAULT_CONFIG = {
  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: {},
  },
};

const TOKEN_VALID_DURATION = 20; // sec

// set terminal title
/** @private */
function setTerminalTitle(server) {
  let title = '';

  if (server._auditState !== null) {
    const numClients = server._auditState.get('numClients');
    let numClientStrings = [];
    for (let name in numClients) {
      numClientStrings.push(`${name}: ${numClients[name]}`);
    }

    title = `${server.config.app.name} | ${numClientStrings.join(' - ')}`;
  } else {
    title = `${server.config.app.name}`;
  }

  const msg = String.fromCharCode(27) + ']0;' + title + String.fromCharCode(7);
  process.stdout.write(msg);
}

/**
 * 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 server.StateManager},
 * {@link server.PluginManager},{@link server.Socket} or {@link server.ContextManager}.
 * 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.
 *
 * @memberof server
 */
class Server {
  /**
   * @param {server.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`);
    }
    /**
     * @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);

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

    // 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') {
        hasNodeClient = true;
      }
    }

    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 (!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`);
      }
    }

    /**
     * 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')));
     */
    // @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();

    /**
     * Instance of the {@link server.PluginManager} class.
     *
     * @see {@link server.PluginManager}
     * @type {server.PluginManager}
     */
    this.pluginManager = new PluginManager(this);

    /**
     * Instance of the {@link server.StateManager} class.
     *
     * @see {@link server.StateManager}
     * @type {server.StateManager}
     */
    this.stateManager = new StateManager();

    /**
     * Instance of the {@link server.ContextManager} class.
     *
     * @see {@link server.ContextManager}
     * @type {server.ContextManager}
     */
    this.contextManager = new ContextManager(this);

    /**
     * If `https` is required, hold informations about the certificates, e.g. if
     * self-signed, the dates of validity of the certificates, etc.
     */
    this.httpsInfos = null;

    /**
     * Status of the server, 'idle', 'inited', 'started' or 'errored'.
     *
     * @type {string}
     */
    this.status = 'idle';

    /**
     * Simple key / value database with Promise based Map API store on filesystem,
     * basically a tiny wrapper around the `kvey` package.
     *
     * @private
     * @see {@link https://github.com/lukechilds/keyv}
     */
    this.db = this.createNamespacedDb('core');

    /** @private */
    this._applicationTemplateOptions = {
      templateEngine: null,
      templatePath: null,
      clientConfigFunction: null,
    };

    /** @private */
    this._onStatusChangeCallbacks = new Set();
    /** @private */
    this._onClientConnectCallbacks = new Set();
    /** @private */
    this._onClientDisconnectCallbacks = new Set();
    /** @private */
    this._auditState = null;
    /** @private */
    this._pendingConnectionTokens = new Set();
    /** @private */
    this._trustedClients = new Set();

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

    logger.configure(this.config.env.verbose);
    setTerminalTitle(this);
  }

  /**
   * Id of the server, a constant set to -1
   * @type {Number}
   * @readonly
   */
  get id() {
    return SERVER_ID;
  }

  /**
   * 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.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.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.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() {
    const numClients = {};
    for (let name in this.config.app.clients) {
      numClients[name] = 0;
    }
    /** @private */
    this._auditState = await this.stateManager.create(AUDIT_STATE_NAME, { numClients });
    this._auditState.onUpdate(() => setTerminalTitle(this));

    // 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.isProtected(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._applicationTemplateOptions.templateEngine === null
        || this._applicationTemplateOptions.templatePath === null
        || this._applicationTemplateOptions.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.setDefaultTemplateConfig()` 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.start();

    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.Server#init}
   * method if it has not been called manually.
   *
   * What it does:
   * - implicitely call {@link server.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.start();

    // ------------------------------------------------------------
    // START SOCKET SERVER
    // ------------------------------------------------------------
    await this.sockets.start(
      this,
      this.config.env.websockets,
      (...args) => this._onSocketConnection(...args),
    );

    // ------------------------------------------------------------
    // 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.stop();
    await this.pluginManager.stop();

    this.sockets.terminate();
    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._applicationTemplateOptions;

    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
   */
  _onSocketConnection(role, socket, connectionToken) {
    const client = new Client(role, socket);
    const roles = Object.keys(this.config.app.clients);

    // this has been validated
    if (this.isProtected(role) && this.isValidConnectionToken(connectionToken)) {
      const { ip } = decryptData(connectionToken);
      const newData = {
        ip: ip,
        id: client.id,
      };

      const newToken = encryptData(newData);

      client.token = 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.removeClient(client);
          // remove client from pluginManager
          await this.pluginManager.removeClient(client);
          // clean state manager
          await this.stateManager.removeClient(client.id);

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

      // clean sockets
      socket.terminate();
    });

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

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

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

      try {
        this.pluginManager.checkRegisteredPlugins(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 });

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

      this._onClientConnectCallbacks.forEach(callback => callback(client));

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

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

  onStatusChange(callback) {
    this._onStatusChangeCallbacks.add(callback);

    return () => this._onStatusChangeCallbacks.delete(callback);
  }

  /** @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._onStatusChangeCallbacks) {
      promises.push(callback(status));
    }

    await Promise.all(promises);
  }

  /**
   * 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._applicationTemplateOptions = {
      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,
            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._applicationTemplateOptions, options);
  }

  /**
   * Attach and retrieve the global audit state of the application.
   *
   * The audit state is a {@link server.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<server.SharedState>}
   * @throws Will throw if called before `server.init()`
   * @see {@link server.SharedState}
   * @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;
  }

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

    return false;
  }

  /** @private */
  isValidConnectionToken(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 {server.Client} 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.token === token) {
          // check that given token is consistent with client ip and id
          const { id, ip } = decryptData(client.token);

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

      }

      return false;
    }
  }
}

export default Server;