raveljs/ravel

View on GitHub
lib/ravel.js

Summary

Maintainability
B
4 hrs
Test Coverage
A
100%
'use strict';

const AsyncEventEmitter = require('./util/event_emitter');
const coreSymbols = require('./core/symbols');
const Rest = require('./util/rest');

const sInitialized = Symbol('isInitialized');
const sListening = Symbol('isListening');
const sLog = Symbol('$log');
const sErr = Symbol('$err');
const sCWD = Symbol('cwd');
const sCallback = Symbol('callback');
const sServer = Symbol('server');
const sUncaughtRejections = Symbol('uncaughtRejections');
const sKeygripKeys = Symbol('keygripKeys');
const sKeygrip = Symbol('keygrip');

/**
 * This class provides Ravel, a lightweight but powerful framework
 * for the rapid creation of enterprise Node.js applications which
 * scale horizontally with ease and support the latest in web technology.
 *
 * @example
 * const Ravel = require('ravel');
 * const app = new Ravel();
 */
class Ravel extends AsyncEventEmitter {
  /**
   * Creates a new Ravel app instance.
   *
   * @example
   * const Ravel = require('ravel');
   * const app = new Ravel();
   */
  constructor () {
    super();

    this[sUncaughtRejections] = 0;
    this[sInitialized] = false;
    this[sListening] = false;
    this[sKeygripKeys] = [];

    // how we store modules for dependency injection
    this[coreSymbols.modules] = Object.create(null);
    // how we store routes for later access
    this[coreSymbols.routes] = Object.create(null);
    // how we store resources for later access
    this[coreSymbols.resource] = Object.create(null);
    // how we store middleware for dependency injection
    this[coreSymbols.middleware] = Object.create(null);

    // current working directory of the app using the
    // ravel library, so that client modules can be
    // loaded with relative paths.
    this[sCWD] = process.cwd();

    this[coreSymbols.knownParameters] = Object.create(null);
    this[coreSymbols.params] = Object.create(null);
    // a list of registered module factories which haven't yet been instantiated
    this[coreSymbols.moduleFactories] = Object.create(null);
    this[coreSymbols.resourceFactories] = Object.create(null);
    this[coreSymbols.routesFactories] = Object.create(null);
    // Allows us to detect duplicate binds
    this[coreSymbols.endpoints] = new Map();
    // a list of known modules, resources and routes so that metadata can be retrieved from them
    this[coreSymbols.knownComponents] = Object.create(null);

    // init errors
    this[sErr] = require('./util/application_error');

    // init logging system
    this[sLog] = new (require('./util/log'))(this);

    // init dependency injection utility, which is used by most everything else
    this[coreSymbols.injector] = new (require('./core/injector'))(this,
      module.parent !== null ? module.parent : module);

    // Register known ravel parameters
    // websocket parameters
    this.registerParameter('enable websockets', true, true);
    this.registerParameter('max websocket payload bytes', true, 100 * 1024 * 1024);
    this.registerParameter('redis websocket channel prefix', true, 'ravel.ws');
    // redis parameters
    this.registerParameter('redis host', false);
    this.registerParameter('redis port', true, 6379);
    this.registerParameter('redis password');
    this.registerParameter('redis max retries', true, 10);
    this.registerParameter('redis keepalive interval', true, 1000);
    // Node/koa parameters
    this.registerParameter('port', true, 8080);
    this.registerParameter('https', true, false);
    // supports any option from https://nodejs.org/api/tls.html#tls_tls_createserver_options_secureconnectionlistener
    this.registerParameter('https options', true, {});
    this.registerParameter('public directory');
    this.registerParameter('favicon path');
    this.registerParameter('keygrip keys', true);
    // session parameters
    this.registerParameter('session key', true, 'ravel.sid');
    this.registerParameter('session max age', true, null);
    this.registerParameter('session secure', true, true);
    this.registerParameter('session rolling', true, false);
    this.registerParameter('session samesite', true, null);
    // Passport parameters
    this.registerParameter('app route', false, '/');
    this.registerParameter('login route', false, '/login');
  }

  /**
   * The current working directory of the app using the `ravel` library.
   *
   * @type String
   */
  get cwd () {
    return this[sCWD];
  }

  /**
   * Return the app instance of Logger.
   * See [`Logger`](#logger) for more information.
   *
   * @type Logger
   * @example
   * app.$log.error('Something terrible happened!');
   */
  get $log () {
    return this[sLog];
  }

  /**
   * A hash of all built-in error types. See [`$err`](#$err) for more information.
   *
   * @type Object
   */
  get $err () {
    return this[sErr];
  }

  /**
   * Value is `true` iff init() or start() has been called on this app instance.
   *
   * @type boolean
   */
  get initialized () {
    return this[sInitialized];
  }

  /**
   * Value is `true` iff `listen()` or `start()` has been called on this app instance.
   *
   * @type boolean
   */
  get listening () {
    return this[sListening];
  }

  /**
   * The underlying koa callback for this Ravel instance.
   * Useful for [testing](#testing-ravel-applications) with `supertest`.
   * Only available after `init()` (i.e. Use `@postinit`).
   *
   * @type {Function}
   */
  get callback () {
    return this[sCallback];
  }

  /**
   * The underlying HTTP server for this Ravel instance.
   * Only available after `init()` (i.e. Use `@postinit`).
   * Useful for [testing](#testing-ravel-applications) with `supertest`.
   *
   * @type {http.Server}
   */
  get server () {
    return this[sServer];
  }

  /**
   * The underlying Keygrip instance for cookie signing.
   *
   * @type {Keygrip}
   */
  get keys () {
    return this[sKeygrip];
  }

  /**
   * Register a provider, such as a DatabaseProvider or AuthenticationProvider.
   *
   * @param {Function} ProviderClass - The provider class.
   * @param {...any} args - Arguments to be provided to the provider's constructor
   *                        (prepended automatically with a reference to app).
   */
  registerProvider (ProviderClass, ...args) {
    return new ProviderClass(this, ...args);
  }

  /**
   * Initializes the application, when the client is finished
   * supplying parameters and registering modules, resources
   * routes and rooms.
   *
   * @example
   * await app.init();
   */
  async init () {
    // application configuration is completed in constructor
    await this.emit('pre init');

    // log uncaught errors to prevent ES6 promise error swallowing
    // https://www.hacksrus.net/blog/2015/08/a-solution-to-swallowed-exceptions-in-es6s-promises/
    process.removeAllListeners('unhandledRejection');
    process.on('unhandledRejection', (err) => {
      this[sUncaughtRejections] += 1;
      const message = `Detected uncaught error in promise: \n ${err ? err.stack : err}`;
      if (this[sUncaughtRejections] >= 10) {
        this.$log.error(message);
        this.$log.error(`Encountered ${this[sUncaughtRejections]} or more uncaught rejections. Re-run ` +
          'node and ravel with full logging output for more information:' +
          '\n- node --trace-warnings app.js' +
          '\n- app.set(\'log level\', app.$log.TRACE);');
      } else {
        this.$log.debug(message);
      }
    });

    // load parameters from .ravelrc.json file, if any
    await this.emit('pre load parameters');
    this[coreSymbols.loadParameters]();
    await this.emit('post load parameters');

    this[sInitialized] = true;

    this.$db = require('./db/database')(this);
    this.$kvstore = require('./util/kvstore')(this);

    // App dependencies.
    const http = require('http');
    const https = require('https');
    const upath = require('upath');
    const Koa = require('koa');
    const session = require('koa-session');
    const compression = require('koa-compress');
    const favicon = require('koa-favicon');
    const router = new (require('./core/router'))();

    // configure koa
    const app = new Koa();
    app.proxy = true;

    // the first piece of middleware is the exception handler
    // catch all errors and return appropriate error codes
    // to the client
    app.use((new Rest(this)).errorHandler());

    // enable gzip compression
    app.use(compression());

    // configure redis session store
    this[sKeygripKeys] = [...this.get('keygrip keys')];
    this[sKeygrip] = require('keygrip')(this[sKeygripKeys], 'sha256', 'base64');
    app.keys = this[sKeygrip];

    // configure session options
    if (!this.get('https') && !this.get('session secure')) {
      this.$log.warn("app.set('session secure', false) results in insecure sessions" +
                     ' over HTTP and introduces critical security vulnerabilities.' +
                     ' This is intended for development purposes only, or for use' +
                     ' behind a TLS termination proxy.');
    }
    const sessionOptions = {
      store: new (require('./util/redis_session_store'))(this),
      key: this.get('session key'),
      maxAge: Number(this.get('session max age')),
      overwrite: true, /* (boolean) can overwrite or not (default true) */
      httpOnly: true, /* (boolean) httpOnly or not (default true) */
      signed: true, /* (boolean) signed or not (default true) */
      secure: this.get('https') || this.get('session secure'), /* (boolean) secure or not (default true) */
      rolling: this.get('session rolling'), /* (boolean) Force a session identifier cookie to be set on every response.
                         The expiration is reset to the original maxAge, resetting the expiration
                         countdown. default is false */
      sameSite: this.get('session samesite') /* (string) session cookie sameSite options (default null) */
    };
    app.use(session(sessionOptions, app));

    // favicon
    if (this.get('favicon path')) {
      app.use(favicon(upath.toUnix(upath.posix.join(this.cwd, this.get('favicon path')))));
    }

    // static file serving
    if (this.get('public directory')) {
      const koaStatic = require('koa-static');
      const root = upath.toUnix(upath.posix.join(this.cwd, this.get('public directory')));
      app.use(koaStatic(root, {
        gzip: false // this should be handled by koa-compressor?
      }));
    }

    // initialize authentication/authentication
    require('./auth/passport_init.js')(this, router);

    // create websocket broker
    const WebsocketBroker = require('./core/websocket.js');
    this[coreSymbols.websocketBroker] = new WebsocketBroker(this);

    // basic koa configuration is completed
    await this.emit('post config koa', app);

    // create registered modules using factories
    await this[coreSymbols.moduleInit]();

    await this.emit('post module init');

    await this.emit('pre routes init', app);

    // create registered resources using factories
    this[coreSymbols.resourceInit](router);

    // create routes using factories
    this[coreSymbols.routesInit](router);

    // include routes as middleware
    app.use(router.middleware());

    // Create koa callback and server
    this[sCallback] = app.callback();
    this[sServer] = this.get('https')
      ? https.createServer(this.get('https options'), this[sCallback])
      : http.createServer(this[sCallback]);

    // application configuration is completed
    await this.emit('post init');
  }

  /**
   * Rotate a new cookie signing key in, and the oldest key out.
   *
   * @param {string} newKey - The new key.
   */
  rotateKeygripKey (newKey) {
    this[sKeygripKeys].unshift(newKey);
    this[sKeygripKeys].pop();
  }

  /**
   * Starts the application. Must be called after `initialize()`.
   *
   * @example
   * await app.listen();
   */
  async listen () {
    if (!this[sInitialized]) {
      throw new this.$err.NotAllowed('Cannot call Ravel.listen() before Ravel.init()');
    } else {
      await this.emit('pre listen');
      return new Promise((resolve, reject) => {
        // validate parameters
        this[coreSymbols.validateParameters]();
        // Start Koa server
        this[sServer].listen(this.get('port'), () => {
          /* eslint-disable max-len */
          this.$log.info(`Application server listening for ${this.get('https') ? 'https' : 'http'} traffic on port ${this.get('port')}`);
          /* eslint-enable max-len */
          this[sListening] = true;
          this.emit('post listen');
          resolve();
        });
      });
    }
  }

  /**
   * Intializes and starts the application.
   *
   * @example
   * await app.start();
   */
  async start () {
    await this.init();
    return this.listen();
  }

  /**
   * Stops the application. A no op if the server isn't running.
   *
   * @example
   * await app.close();
   */
  async close () {
    await this.emit('end');
    if (this[sServer] && this[sListening]) {
      return new Promise((resolve) => {
        this[sServer].close(() => {
          this.$log.info('Application server terminated.');
          this[sListening] = false;
          resolve();
        });
      });
    }
  }
}

/**
 * The base class of Ravel `Error`s, which associate
 * HTTP status codes with your custom errors.
 *
 * @example
 * const Ravel = require('ravel');
 * class NotFoundError extends Ravel.Error {
 *   constructor (msg) {
 *     super(msg, Ravel.httpCodes.NOT_FOUND);
 *   }
 * }
 */
Ravel.Error = require('./util/application_error').General;

/**
 * The base class for Ravel `DatabaseProvider`s. See
 * [`DatabaseProvider`](#databaseprovider) for more information.
 *
 * @example
 * const DatabaseProvider = require('ravel').DatabaseProvider;
 * class MySQLProvider extends DatabaseProvider {
 *   // ...
 * }
 */
Ravel.DatabaseProvider = require('./db/database_provider').DatabaseProvider;

/**
 * Return a list of all registered `DatabaseProvider`s. See
 * [`DatabaseProvider`](#databaseprovider) for more information.
 *
 * @returns {Array} A list of `DatabaseProvider`s.
 * @private
 */
require('./db/database_provider')(Ravel);

/**
 * The base class for Ravel `AuthenticationProvider`s. See
 * [`AuthenticationProvider`](#authenticationprovider) for more information.
 *
 * @example
 * const AuthenticationProvider = require('ravel').AuthenticationProvider;
 * class GoogleOAuth2Provider extends AuthenticationProvider {
 *   // ...
 * }
 */
Ravel.AuthenticationProvider = require('./auth/authentication_provider').AuthenticationProvider;

/**
 * Return a list of all registered `AuthenticationProvider`s. See
 * [`AuthenticationProvider`](#authenticationprovider) for more information.
 *
 * @returns {Array} A list of `AuthenticationProvider`s.
 * @private
 */
require('./auth/authentication_provider')(Ravel);

/*
 * Makes the `@inject` decorator available as `Ravel.inject`
 * @example
 * const Ravel = require('ravel');
 * const inject = Ravel.inject;
 */
Ravel.inject = require('./core/decorators/inject');

/*
 * Makes the `@autoinject` decorator available as `Ravel.autoinject`
 * @example
 * const Ravel = require('ravel');
 * const autoinject = Ravel.autoinject;
 */
Ravel.autoinject = require('./core/decorators/autoinject');

/**
 * A dictionary of useful http status codes. See [HTTPCodes](#httpcodes) for more information.
 *
 * @example
 * const Ravel = require('ravel');
 * console.log(Ravel.httpCodes.NOT_FOUND);
 */
Ravel.httpCodes = require('./util/http_codes');

/**
 * Requires Ravel's parameter system
 * See `core/params` for more information.
 *
 * @example
 * app.registerParameter('my parameter', true, 'default value');
 * const value = app.get('my parameter');
 * app.set('my parameter', 'another value');
 * @private
 */
require('./core/params')(Ravel);

/**
 * The base decorator for Ravel `Module`s. See
 * [`Module`](#module) for more information.
 *
 * @example
 * const Module = require('ravel').Module;
 *
 * class MyModule {
 *   // ...
 * }
 * module.exports = MyModule;
 */
Ravel.Module = require('./core/module').Module;

/**
 * Requires Ravel's `Module` retrieval and registration system
 * See [`Module`](#module) for more information.
 *
 * @example
 * app.scan('./modules/mymodule');
 * @private
 */
require('./core/module')(Ravel);

/**
 * Requires Ravel's recursive component registration system.
 *
 * See [`Module`](#module), [`Resource`](#resource) and [`Routes`](#routes) for more information.
 *
 * @example
 * // recursively load all Modules in a directory
 * app.scan('./modules');
 * // a Module 'modules/test.js' in ./modules can be injected as `@inject('test')`
 * // a Module 'modules/stuff/test.js' in ./modules can be injected as `@inject('stuff.test')`
 * @example
 * // recursively load all Resources in a directory
 * app.scan('./resources');
 * // recursively load all Resources in a directory
 * app.scan('./routes');
 * @private
 */
require('./core/scan')(Ravel);

/**
 * The base class for Ravel `Routes`. See
 * [`Routes`](#routes) for more information.
 *
 * @example
 * const Routes = require('ravel').Routes;
 * // @Routes('/')
 * class MyRoutes {
 *   // ...
 * }
 * module.exports = MyRoutes;
 */
Ravel.Routes = require('./core/routes').Routes;

/**
 * Requires Ravel's `Routes` registration system
 * See [`Routes`](#routes) for more information.
 *
 * @example
 * app.routes('./routes/myroutes');
 * @private
 */
require('./core/routes')(Ravel);

/**
 * The base class for Ravel `Resource`. See
 * [`Resource`](#resource) for more information.
 *
 * @example
 * const Resource = require('ravel').Resource;
 * // @Resource('/')
 * class MyResource {
 *   // ...
 * }
 * module.exports = MyResource;
 */
Ravel.Resource = require('./core/resource').Resource;

/**
 * Requires Ravel's `Resource` registration system
 * See [`Resource`](#resource) for more information.
 *
 * @example
 * app.resource('./resources/myresource');
 * @private
 */
require('./core/resource')(Ravel);

/**
 * Requires Ravel's lightweight reflection/metadata system.
 *
 * See `core/reflect` for more information.
 *
 * @example
 * // examine a registered Module, Resource or Route by file path
 * app.reflect('./modules/mymodule.js');
 * @private
 */
require('./core/reflect')(Ravel);

module.exports = Ravel;