lib/ravel.js
'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;