Home Manual Reference Source Test Repository

src/application.js

import {access, readFile} from 'fs';
import {createServer} from 'http';
import {createServer as createSecureServer} from 'https';
import cors from 'kcors';
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import compress from 'koa-compress';
import favicon from 'koa-favicon';
import serve from 'koa-static';
import sslify from 'koa-sslify';
import {createTransport} from 'nodemailer';
import {Sequelize} from 'sequelize';

import {getAlias, setAlias} from './alias';
import {createObject} from './di';
import {securityHandler} from './middleware';

/**
 * Represents an application providing functionalities specific to Web requests.
 */
export class Application extends Koa {

  /**
   * The default address that the application is listening on.
   * @type {string}
   */
  static get DEFAULT_ADDRESS() {
    return '0.0.0.0';
  }

  /**
   * The default port that the application is listening on.
   * @type {number}
   */
  static get DEFAULT_PORT() {
    return 3000;
  }

  /**
   * Initializes a new instance of the class.
   * @param {string} [rootPath] The root directory of the application package. Defaults to the current working directory.
   */
  constructor(rootPath = '') {
    super();

    global._app = this;
    if (rootPath.length) setAlias('@root', rootPath);

    /**
     * Custom application parameters.
     * @type {object}
     */
    this.params = Object.create(null);

    /**
     * The list of the loaded component instances.
     * @type {Map}
     */
    this._components = new Map;

    /**
     * The underlying HTTP(S) service listening for requests.
     * @type {http.Server|https.Server}
     */
    this._httpService = null;

    /**
     * Various application settings.
     * @type {Map}
     */
    this._options = new Map;
  }

  /**
   * The address that the application is listening on.
   * @type {string}
   */
  get address() {
    if (this.listening) return this._httpService.address().address;
    return this._options.has('address') ? this._options.get('address') : Application.DEFAULT_ADDRESS;
  }

  /**
   * The base path of the application files.
   * @type {string}
   */
  get basePath() {
    return getAlias('@app');
  }

  /**
   * Sets the base path of the application files.
   * @param {string} value The new directory path. This can be either a directory name or a path alias.
   */
  set basePath(value) {
    setAlias('@app', value);
  }

  /**
   * The directory that contains the controller classes.
   * @type {string}
   */
  get controllerPath() {
    return this._options.has('controllerPath') ? this._options.get('controllerPath') : getAlias('@app/controllers');
  }

  /**
   * Sets the directory that contains the controller classes.
   * @param {string} value The new directory path. This can be either a directory name or a path alias.
   */
  set controllerPath(value) {
    this._options.set('controllerPath', getAlias(value));
  }

  /**
   * Value indicating whether the application runs in debug mode.
   * @type {boolean}
   */
  get debug() {
    return this._options.has('debug') ? this._options.get('debug') : ['development', 'test'].includes(this.env);
  }

  /**
   * Value indicating whether the application must use a secure connection.
   * @type {boolean}
   */
  get forceSSL() {
    return this._options.has('forceSSL') ? this._options.get('forceSSL') : false;
  }

  /**
   * Value indicating whether the application is currently listening.
   * @type {boolean}
   */
  get listening() {
    return Boolean(this._httpService && this._httpService.listening);
  }

  /**
   * The mailer application component.
   * @type {Mailer}
   */
  get mailer() {
    return this.get('mailer');
  }

  /**
   * The application name.
   * @type {string}
   */
  get name() {
    return this._options.has('name') ? this._options.get('name') : 'My Application';
  }

  /**
   * The port that the application is listening on.
   * @type {number}
   */
  get port() {
    if (this.listening) return this._httpService.address().port;
    return this._options.has('port') ? this._options.get('port') : Application.DEFAULT_PORT;
  }

  /**
   * The Web root directory that contains the static files.
   * @type {string}
   */
  get publicPath() {
    return getAlias('@webroot');
  }

  /**
   * Sets the Web root directory that contains the static files.
   * @param {string} value The new directory path. This can be either a directory name or a path alias.
   */
  set publicPath(value) {
    setAlias('@webroot', value);
  }

  /**
   * The directory that stores runtime files.
   * @type {string}
   */
  get runtimePath() {
    return getAlias('@runtime');
  }

  /**
   * Sets the directory that stores runtime files.
   * @param {string} value The new directory path. This can be either a directory name or a path alias.
   */
  set runtimePath(value) {
    setAlias('@runtime', value);
  }

  /**
   * The version number of this application.
   * @type {string}
   */
  get version() {
    return this._options.has('version') ? this._options.get('version') : '1.0';
  }

  /**
   * The directory that contains the view files.
   * @type {string}
   */
  get viewPath() {
    return this._options.has('viewPath') ? this._options.get('viewPath') : getAlias('@app/views');
  }

  /**
   * Sets the directory that contains the view files.
   * @param {string} value The new directory path. This can be either a directory name or a path alias.
   */
  set viewPath(value) {
    this._options.set('viewPath', getAlias(value));
  }

  /**
   * Stops the application from accepting new connections. It does nothing if the application is already closed.
   * @return {Promise} Completes when the application is finally closed.
   * @emits {*} The "close" event.
   */
  async close() {
    return !this.listening ? null : new Promise(resolve => this._httpService.close(() => {
      this._httpService = null;
      this.emit('close');
      resolve(null);
    }));
  }

  /**
   * Terminates the application.
   * This method replaces the `process.exit()` method by ensuring the application life cycle is completed before terminating the application.
   * @param {number} [status] The exit status (value `0` means normal exit while other values mean abnormal exit).
   * @emits {*} The "end" event.
   */
  async end(status = 0) {
    await this.close();
    this.emit('end');
    process.exit(status);
  }

  /**
   * Gets the component instance with the specified identifier.
   * @param {string} id The component identifier.
   * @param {boolean} [throwError] Value indicating whether to throw an error if the specified identifier is not registered before.
   * @return {*} The component of the specified identifier, or a `null` reference if component identifier is not previously registered.
   * @throws {TypeError} The specified identifier refers to a nonexistent component while `throwError` is `true`.
   */
  get(id, throwError = true) {
    if (!this.has(id)) {
      if (throwError) throw new TypeError(`The identifier "${id}" refers to a nonexistent component.`);
      return null;
    }

    return this._components.get(id);
  }

  /**
   * Gets a value indicating whether the application has instantiated the component with the specified identifier.
   * @param {string} id The component identifier.
   * @return {boolean} `true` if the application has instantiated the component, otherwise `false`.
   */
  has(id) {
    return this._components.has(id);
  }

  /**
   * Initializes the application.
   * @param {object} [config] An object specifying the application configuration.
   */
  init(config = {}) {
    if ('aliases' in config) {
      let aliases = config.aliases && typeof config.aliases == 'object' ? config.aliases : {};
      for (let alias in aliases) setAlias(alias, aliases[alias]);
      delete config.aliases;
    }

    for (let key of ['basePath', 'controllerPath', 'publicPath', 'runtimePath', 'viewPath']) {
      if (key in config) this[key] = config[key];
      delete config[key];
    }

    this._registerComponents(config.components && typeof config.components == 'object' ? config.components : {});
    delete config.components;

    Object.assign(this.params, config.params && typeof config.params == 'object' ? config.params : {});
    delete config.params;

    for (let key in config) this._options.set(key, config[key]);
  }

  /**
   * Removes the component instance with the specified identifier.
   * @param {string} id The component identifier.
   */
  remove(id) {
    this._components.delete(id);
  }

  /**
   * Begin accepting connections. It does nothing if the application is already started.
   * @param {number} [port] The port that the application should run on.
   * @param {string} [address] The address that the application should run on.
   * @return {Promise<number>} The port that the application is running on.
   * @emits {*} The "listening" event.
   */
  async listen(port = -1, address = '') {
    if (this.listening) return this.port;

    if (!this._options.has('ssl')) this._httpService = createServer(this.callback());
    else {
      const loadCert = file => new Promise((resolve, reject) => readFile(file, (err, data) => {
        if (err) reject(err);
        else resolve(data);
      }));

      let options = this._options.get('ssl');
      let keys = ['ca', 'cert', 'key', 'pfx'].filter(cert => cert in options);
      let certs = await Promise.all(keys.map(cert => loadCert(options[cert])));
      for (let i = 0; i < keys.length; i++) options[keys[i]] = certs[i];

      this._httpService = createSecureServer(options, this.callback());
    }

    return new Promise(resolve => this._httpService.listen(port >= 0 ? port : this.port, address.length ? address : this.address, () => {
      this.emit('listening');
      resolve(this.port);
    }));
  }

  /**
   * Runs the application.
   * @param {object} [options] An object specifying the application settings.
   * @return {Promise} Completes when the application has been started.
   * @throws {Error} The application is already started.
   * @emits {*} The "begin" event.
   */
  async run(options = {}) {
    if (this.listening) throw new Error('The application is already started.');

    this.init(options);
    await this._registerMiddleware();

    this.emit('begin');
    return this.listen();
  }

  /**
   * Registers a component instance with this application.
   * @param {string} id The component identifier.
   * @param {*} value The component instance to be registered.
   */
  set(id, value) {
    this._components.set(id, value);
  }

  /**
   * Registers the application components.
   * @param {object} config An object providing the component configuration.
   */
  _registerComponents(config) {
    for (let [id, component] of Object.entries(config))
      switch (id) {
        case 'db':
          this.set(id, 'class' in component ? createObject(component) : new Sequelize(component));
          break;

        case 'mailer':
          this.set(id, 'class' in component ? createObject(component) : createTransport(component));
          break;

        default:
          this.set(id, createObject(component));
          break;
      }
  }

  /**
   * Registers the request handlers.
   */
  async _registerMiddleware() {
    const exists = file => new Promise(resolve => access(file, err => resolve(!err)));
    // TODO this._koa.engine('html', hogan);

    // Phase "initial:before".
    let icon = getAlias('@webroot/favicon.ico');
    if (this.forceSSL) this.use(sslify({trustProtoHeader: this.proxy}));
    if (await exists(icon)) this.use(favicon(icon));
    // TODO: this.use(morgan(this.debug ? 'dev' : 'combined'));

    // Phase "initial".
    // TODO let origin = this._options.get('cors');
    // TODO this.use(errorHandler());
    this.use(compress());
    this.use(cors()); // TODO {origin: () => send the proper origin }
    this.use(securityHandler());

    // Phase "session".
    // TODO ???

    // Phase "auth".
    // TODO ???

    // Phase "parse".
    this.use(bodyParser());

    // Phase "routes".
    let routes = this._options.get('routes');
    this._registerRoutes(Array.isArray(routes) ? routes : []);

    // Phase "files".
    let webroot = getAlias('@webroot');
    if (await exists(webroot)) this.use(serve(webroot));

    // Phase "final".
    // TODO ???
  }

  /**
   * Registers the routing table.
   * @param {Array} routes The list of routes to be registered.
   */
  _registerRoutes(routes) {
    // TODO
  }
}

/**
 * Gets the instance of the currently running application.
 * @return {Application} The application instance.
 */
export function app() {
  return global._app instanceof Application ? global._app : null;
}