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