server.js

import bodyparser from 'koa-bodyparser';
import compress from 'koa-compress';
import cors from 'koa-cors';
import fs from 'fs';
import helmet from 'koa-helmet';
import https from 'https';
import Koa from 'koa';

import accessLogger from 'treehouse/middleware/accessLogger';
import errorMiddleware from 'treehouse/middleware/error';
import Exception from 'treehouse/exception';
import Logger from 'treehouse/utils/logger';
import sigInitHandler from 'treehouse/utils/sigInitHandler';
import transactionMiddleware from 'treehouse/middleware/transaction';
import uncaughtExceptionHandler from 'treehouse/utils/uncaughtExceptionHandler';
import unhandledRejectionHandler from 'treehouse/utils/unhandledRejectionHandler';
import { ILLEGAL_STATE_EXCEPTION } from 'treehouse/exception/codes';

/**
 * This class encapsulates a <code>Koa</code> application and provides an API
 * for controlling the configuration and life-cycle of application server.
 *
 * <code>Server</code> contains the following public variables:
 * - `app`     The instantiated Koa application
 * - `config`  The application-specific configuration object
 * - `logger`  A reference to the app logger
 *
 * @class
 */
export default class Server {
  app: Function;

  config: Object;

  logger: Object;

  stopProcedures = [];

  startProcedures = [];

  /**
   * Configures and initializes the <code>Server</code> instance.
   * Calls <code>initialize</code> after instantiating a <code>Koa</code>
   * app, setting the <code>config</code> object to the instance, and attaching
   * the `app` <code>Logger</code> to the instance.
   *
   * Instantiation will also attach listeners to the process on the following
   * events to provide a graceful shutdown experience:
   *
   * - <code>exit</code>
   * - <code>SIGINT</code>
   *
   * @constructor
   * @param {Object}      config The application configuration object
   * @param {Object|null} logger (optional) The logger to use for all logging in treehouse
   * @return {void}
   *
   * @see {@link https://nodejs.org/api/process.html|process}
   * @see {@link https://koajs.com/|Koa}
   * @see {@link Logger}
   */
  constructor(config: Object, logger: ?Object = null): void {
    this.app = new Koa();
    this.config = config;

    this.logger = this.configureLogger(this.config, logger);

    this.configureHooks(this.logger);

    this.initialize(this.app, this.config);
  }

  /**
   * Sets up all appropriate hooks for graceful life-cycle handling.
   *
   * @param {Object} logger The logger instance to use in the hooks
   * @returns {void}
   */
  configureHooks(logger: Object): void {
    // atexit handler
    process.on('exit', this.stop);

    // Catches ctrl+c event
    process.on('SIGINT', sigInitHandler(logger));

    // Catches uncaught exceptions and rejections
    process.on('uncaughtException', uncaughtExceptionHandler(logger));
    process.on('unhandledRejection', unhandledRejectionHandler(logger));

    // pm2 graceful shutdown compatibility
    process.once('SIGINT', () => {
      this.stop();
      process.kill(process.pid, 'SIGINT');
    });
  }

  /**
   * Set the logger configuration from the Server config.
   * If a <code>logger</code> is provided, configure the Logger to use the
   * provided <code>logger</code> as the single, default logger.
   *
   * @param {Object}      config The application configuration object
   * @param {Object|null} logger (optional) The logger to use for all logging in treehouse
   * @returns {void}
   */
  configureLogger(config: Object, logger: ?Object = null): void {
    if (config.loggers) {
      Logger.configure(config.loggers);
    }

    if (logger) {
      Logger.setDefaultLogger(logger);
    }

    // Create the logger
    return Logger.getLogger();
  }

  /**
   * Creates and makes the NodeJS HTTP(s) server available.
   * If the <code>secure</code> configuration option is true, then this method
   * calls <code>createHttpsServer</code>; otherwise the default HTTP Koa
   * server is used.
   *
   * @see {@link createHttpsServer}
   * @see {@link start}
   * @return {void}
   */
  createServer(secure): Object {
    return (secure) ? this.createHttpsServer() : this.app;
  }

  /**
   * Creates a NodeJS HTTPS server using the <code>ssl</code> configuration option.
   * Setups a HTTP redirect to force all traffic to HTTP.
   *
   * @return {void}
   */
  createHttpsServer(): void {
    this.app.use((ctx: Object, next: Function) => {
      if (ctx.secure) {
        return next();
      }

      return ctx.redirect(`https://${ctx.hostname}:${this.config.server.port}${ctx.url}`);
    });

    const sslConfig = this.config.server.ssl;
    const httpsConfig = Object.assign({}, sslConfig, {
      key: fs.readFileSync(sslConfig.key),
      cert: fs.readFileSync(sslConfig.cert),
    });

    return https.createServer(httpsConfig, this.app.callback());
  }

  /**
   * Returns a Function to be used as a callback to the server start.
   * The callback function will notify any watching processes via
   * <code>process.send('ready')</code>, if <code>send</code> is available on
   * <code>process</code>, and finally log a start message.
   *
   * @see {@link start}
   * @return {Function}
   */
  getListenCallback(): Function {
    const listenCallback = () => {
      if (process.send) {
        process.send('ready');
      }

      this.logger.info(`Server listening at ${this.config.server.hostname}:${this.config.server.port}...`);
    };

    return listenCallback;
  }

  /**
   * Initializes and attaches common middleware to the provided
   * <code>Koa</code> app instance.
   *
   * For custom implementations looking to override or adjust the order of the
   * default middleware added to the app, it is recommended to extend
   * <code>Server</code> and override this method.
   *
   * For custom implementations looking to add middleware before the first
   * default middleware is attached, it is recommended to extend
   * <code>Server</code>, override this method, and call
   * <code>super.initialize();</code> after adding the custom middleware.
   *
   * @param   {Koa}     app
   * @param   {Object}  config
   * @return  {void}
   */
  initialize(app: Object, config: Object): void {
    // Add common request security measures
    app.use(helmet());

    // Enabled CORS (cors-origin resource sharing)
    app.use(cors(config && config.cors));

    // response compression
    app.use(compress(config && config.compress));

    // Initialize body parser before routes or body will be undefined
    app.use(bodyparser(config && config.bodyparser));

    // Trace a single request process (including over async)
    app.use(transactionMiddleware);

    // Configure Request logging
    app.use(accessLogger());

    // Configure the request error handling
    app.use(errorMiddleware);
  }

  /**
   * Provides a functional means to attach custom middleware or routers to the
   * <code>Koa</code> app instance by executing the provided callback function.
   * The callback is provided the <code>Koa</code> app instance.
   * The output of the call to the provided callback MUST be a koa middleware
   * function as the return value is passed to <code>koa.use</code>.
   *
   * @param  {Function|null}  fn
   * @return {Server}         The server instance
   */
  use(fn: Function): Server {
    this.app.use(fn(this.app));

    return this;
  }

  /**
   * Adds the provided function to the list of startup procedures to be
   * executed when `start` is called.
   * Each procedure is provided the server instance when executed.
   *
   * @param {Function} callback
   * @returns {void}
   */
  onStart(callback: Function): void {
    this.stopProcedures.push(callback);
  }

  /**
   * Starts the server. The last thing that is done before starting the server
   * is to mount the router(s) by calling <code>mountRouters</code>.
   *
   * Starting the server will create an HTTP or HTTPS server, depending on
   * configuration, with the provided callback and begin listening on the
   * configured hostname/port.
   *
   * If any errors are encountered while starting the server, the error is
   * logged and the process exits.
   * Returns the created server instance upon successful startup; otherwise
   * <code>null</code> is returned.
   *
   * @see {@link https://nodejs.org/api/http.html|http}
   * @see {@link createServer}
   * @see {@link getListenCallback}
   *
   * @return {Object | null}
   */
  start(): Object | null {
    if (!this.app) {
      const message = {
        ...ILLEGAL_STATE_EXCEPTION(),
        details: 'Cannot start server: the koa instance is not defined',
      };

      throw new Exception(message);
    }

    try {
      this.app.tcp = this.createServer(this.config.server.secure);

      this.startProcedures.forEach((procedure) => {
        procedure(this.app);
      });

      this.app.tcp.listen(
        this.config.server.port,
        this.config.server.hostname,
        this.config.server.backlog,
        this.getListenCallback(),
      );

      return this.app.tcp;
    } catch (e) {
      this.logger.error(e);
    }

    return null;
  }

  /**
   * Adds the provided function to the list of shutdown procedures to be
   * executed when `stop` is called.
   * Each procedure is provided the server instance when executed.
   *
   * @param {Function} callback
   * @returns {void}
   */
  onStop(callback: Function): void {
    this.stopProcedures.push(callback);
  }

  /**
   * Invokes the provided callback, if one is provided, and then stops the
   * server from accepting any new connections.
   *
   * @see {@link https://nodejs.org/api/net.html#net_server_close_callback}
   * @return {void}
   */
  stop(): void {
    this.logger.info(`Server (${this.config.server.hostname}:${this.config.server.port}) stopping...`);

    this.stopProcedures.forEach((procedure) => {
      procedure(this.app);
    });

    this.app.tcp.close();
  }
}