src/core/Server.js

/** ****************************************************************************************************
 * @file: server.js
 * @project: template-api
 * @author Nick Soggin <iSkore@users.noreply.github.com> on 30-Oct-2017
 *******************************************************************************************************/
'use strict';

const
	config      = require( 'config' ),
	{ join }    = require( 'path' ),
	express     = require( 'express' ),
	cors        = require( 'cors' ),
	bodyParser  = require( 'body-parser' ),
	logger      = require( 'pino' )( {
		enabled: !process.env.TESTING,
		level: process.env.NODE_ENV === 'production' ? 'error' : 'trace'
	} ),
	expressPino = require( 'express-pino-logger' );

const
	setupRoute               = require( './setupRoute' ),
	captureErrors            = require( './middleware/captureErrors' ),
	recursivelyReadDirectory = require( '../utils/recursivelyReadDirectory' );

/**
 * Server
 */
class Server
{
	constructor()
	{
		this.isClosed = false;
	}

	/**
	 * bindProcess
	 * @description
	 * bind the process to `SIGINT`, `SIGQUIT`, `SIGTERM`, `unhandledRejection`, `uncaughtException`, `beforeExit`,
	 * and `exit` program POSIX signal events to assist with safe shutdown
	 */
	bindProcess()
	{
		logger.trace( 'bindProcess' );

		// catch all the ways node might exit
		process
			.on( 'SIGINT', ( msg, code ) => ( logger.info( 'SIGINT' ), process.exit( code || 0 ) ) )
			.on( 'SIGQUIT', ( msg, code ) => ( logger.info( 'SIGQUIT' ), process.exit( code || 0 ) ) )
			.on( 'SIGTERM', ( msg, code ) => ( logger.info( 'SIGTERM' ), process.exit( code || 0 ) ) )
			.on( 'unhandledRejection', ( err ) => logger.error( 'unhandledRejection', err ) )
			.on( 'uncaughtException', ( err ) => logger.error( 'uncaughtException', err ) )
			.on( 'beforeExit', () => logger.info( 'beforeExit' ) )
			.on( 'exit', () => logger.info( 'exit' ) );
	}

	/**
	 * expressInitialize
	 * @description
	 * Initialize express middleware and hook the middleware
	 */
	expressInitialize()
	{
		logger.trace( 'expressInitialize' );

		this.app = express();

		this.app.use( expressPino( { logger } ) );
		this.app.set( 'trust proxy', 1 );
		this.app.disable( 'x-powered-by' );

		this.app.use( cors() );
		this.app.use( bodyParser.raw( { limit: '5gb' } ) );
		this.app.use( bodyParser.urlencoded( { extended: false } ) );
		this.app.use( bodyParser.text() );
		this.app.use( bodyParser.json() );

		this.app.use( '/docs', express.static( 'docs' ) );
	}

	/**
	 * hookRoute
	 * @param {object} item - item from the api config
	 * @returns {*} - returns item with required execution function
	 */
	hookRoute( item )
	{
		logger.trace( `hookRoute ${ item.method } ${ item.route }` );

		// TODO::: add a hook to check for authentication if the handler file requires it
		const exec = [
			( req, res, next ) => ( this.meters.reqMeter.mark(), next() )
		];

		setupRoute( item, exec );

		// hook route to express
		this.app[ item.method ]( item.route, exec );

		return item;
	}

	routerInitialize()
	{
		logger.trace( 'routerInitialize' );

		this.app.get( '/routes', ( req, res ) => {
			const routes = this.app._router.stack
				.filter( ( r ) => r.route && r.route.methods )
				.map( ( r ) => ( {
					path: r.route.path,
					methods: r.route.methods
				} ) );

			res.status( 200 ).json( routes );
		} );

		this.routes.map( ( item ) => this.hookRoute( item ) );

		// capture all unhandled routes
		this.app.all( '*', ( req, res ) => {
			res.status( 405 ).json( {
				type: 'CLIENT_ERROR',
				name: 'METHOD_NOT_ALLOWED',
				msg: `Method: ${ req.method } on ${ req.path } not allowed`
			} );
		} );

		// capture all unhandled errors that might occur
		this.app.use( captureErrors() );
	}

	async loadRoutes()
	{
		logger.trace( 'loadRoutes' );

		this.routes = await recursivelyReadDirectory( join( process.cwd(), 'src', 'routes' ) );
		this.routes = this.routes.filter( ( route ) => /([a-z0-9\s_\\.\-():])+(.m?t?jsx?)$/i.test( route ) );
		this.routes = this.routes.map( ( route ) => require( `${ route }` ) );
		this.routes = this.routes.sort( ( a ) => a.method === 'HEAD' ? -1 : 0 );
	}

	/**
	 * initialize
	 * @description
	 * Hook `process` variables `uncaughtException`, `unhandledRejection`, and `exit` to handle any potential errors
	 * that may occur. This will allow us to properly handle exit and log all non-V8 level errors without the program
	 * crashing.
	 * @returns {Server} - this
	 */
	async initialize()
	{
		logger.trace( 'initialize' );

		// override process handlers to handle failures
		if ( !process.env.TESTING ) {
			this.bindProcess();
		}

		// setup express
		this.expressInitialize();
		await this.loadRoutes();
		this.routerInitialize();
	}

	/**
	 * onStart
	 * @description
	 * create instance of an http server and start listening on the port
	 * @param {function} cb - pm2 callback
	 */
	onStart( cb )
	{
		logger.trace( 'onStart' );

		this.server = this.app.listen(
			config.get( 'server.port' ),
			config.get( 'server.host' ),
			() => {
				logger.info( {
					name: config.get( 'name' ),
					version: config.get( 'version' ),
					host: config.get( 'server.host' ),
					port: config.get( 'server.port' )
				} );

				logger.info( 'started' );

				cb();
			}
		);
	}

	/**
	 * sensors
	 * @description
	 * PM2 managed function
	 * @param {*} io - PM2 managed argument
	 */
	sensors( io )
	{
		logger.trace( 'sensors' );

		this.meters          = {};
		this.meters.reqMeter = io.meter( 'req/min' );
	}

	/**
	 * actuators
	 * @description
	 * PM2 managed function
	 * @param {*} io - PM2 managed argument
	 * @example
	 * pm2 trigger <app_id> process
	 */
	actuators( io )
	{
		logger.trace( 'actuators' );

		// TODO::: add system info as actuator
		io.action( 'process', ( reply ) => reply( { env: process.env } ) );
		io.action( 'server', ( reply ) => reply( { server: this.server } ) );
		io.action( 'config', ( reply ) => reply( { config: config } ) );
	}

	/**
	 * onStop
	 * @param {*} err - error
	 * @param {function} cb - pm2 callback
	 * @param {number} code - exit code
	 * @param {string} signal - kill signal
	 */
	onStop( err, cb, code, signal )
	{
		logger.trace( 'onStop' );

		if ( this.server ) {
			this.server.close();
		}

		if ( err ) {
			logger.error( err );
		}

		if ( this.isClosed ) {
			logger.debug( 'Shutdown after SIGINT, forced shutdown...' );
		}

		this.isClosed = true;

		logger.debug( `server exiting with code: ${ code } ${ signal }` );
		cb();
	}
}

/**
 * module.exports
 * @description
 * export a singleton instance of Server
 * @type {Server}
 */
module.exports = Server;