/*!
* clout-js
* Copyright(c) 2015 - 2016 Muhammad Dadu
* MIT Licensed
*/
/**
* Clout
* @module clout-js/lib/Clout
*/
const path = require('path');
const fs = require('fs-extra');
const EventEmitter = require('events').EventEmitter;
const util = require('util');
const debug = require('debug')('clout:core');
const async = require('async');
const _ = require('lodash');
const utils = require('./utils');
const Logger = require('./Logger');
const Config = require('./Config');
/**
* Priority for core hooks
* @typedef {(number|string)} priority
* @property {number} CONFIG 5
* @property {number} MIDDLEWARE 10
* @property {number} MODEL 15
* @property {number} API 20
* @property {number} CONTROLLER 25
* @const
*/
const CORE_PRIORITY = {
CONFIG: 5,
MIDDLEWARE: 10,
MODEL: 15,
API: 20,
CONTROLLER: 25
};
const CLOUT_MODULE_PATH = path.join(__dirname, '..');
/**
* Clout application
* @class
*/
class Clout extends EventEmitter {
/**
* @constructor
* @param {path} rootDirectory application directory
*/
constructor(rootDirectory) {
super();
this.handleProcess();
this.rootDirectory = null;
this.package = {};
this.applicationPackage = {};
this.config = {};
this.logger = {debug: debug};
this.app = null;
this.server = {};
this.modules = [];
this.moduleCache = [];
// expose core libraries
this.utils = utils;
this.async = async;
this._ = _;
this.fs = fs;
// allow application hooks (Synchronous)
this.CORE_PRIORITY = CORE_PRIORITY;
this.hooks = {
start: [],
stop: [],
reload: []
};
// Load clout configuration
this.config = new Config();
this.config.loadFromDir(path.join(__dirname, '../resources/conf'));
this.applicationPackage = {};
// load clout package.json
this.package = require(path.join(__dirname, '../package.json'));
// load clout modules
if (this.package.modules) {
this.addModules(this.package.modules);
}
if (rootDirectory) {
// set application root directory
this.rootDirectory = path.resolve(rootDirectory);
// load application manifest
['package.json', 'clout.json'].forEach((fileName) => {
let filePath = path.resolve(this.rootDirectory, fileName);
if (!fs.existsSync(filePath)) {
return debug(`${fileName} not found`);
}
_.merge(this.applicationPackage, require(filePath));
});
process.title = `[clout-js v${this.package.version}] ${this.applicationPackage.name}`;
// add rootdir to node_modules
module.paths.unshift(path.join(this.rootDirectory, 'node_modules'));
// load modules from application manifest
if (this.applicationPackage.modules) {
this.addModules(this.applicationPackage.modules);
}
}
// append module configuration
this.modules.forEach((module) => this.config.loadFromDir(path.join(module.path, 'conf')));
// append application configuration (Overrides module conf)
this.config.loadFromDir(path.join(this.rootDirectory, 'conf'));
// initialize logger
this.logger = new Logger(this);
// 1) load core hooks
// 2) load application hooks
// 3) load module hooks
this.loadHooksFromDir(CLOUT_MODULE_PATH)
.then(this.loadHooksFromDir(this.rootDirectory))
.then(() => new Promise((resolve, reject) => {
async.each(this.modules, (module, next) => {
this.loadHooksFromDir(module.path)
.then(() => next())
.catch((err) => {
console.error(err);
next()
});
}, (err) => {
if (err) { return reject(err); }
resolve();
});
this.initialized = true;
}))
.catch((err) => console.error(err));
}
/**
* hook into clout runtime
* @param {string} event event name
* @param {Function} fn function to execute
* @param {String} fn._name hook name
* @param {String} fn.group hook group
* @param {Number} priority function priority
* @param {Boolean} override override existing
* @example
* // register a function to the hook
* clout.registerHook('start', function (next) {
* next();
* });
* // invoking an error in clout runtime
* clout.registerHook('start', function (next) {
* next(new Error('Error executing function'));
* });
*/
registerHook(event, fn, priority, override) {
debug('registerHook:event=%s:fn:priority=%s', event, priority);
if (!this.hooks.hasOwnProperty(event)) {
throw new Error('Invalid Hook Event');
}
typeof priority !== 'undefined' && (fn.priority = priority);
// find existing, override
if (override === true) {
debug('override');
for (var i = 0, l = this.hooks[event].length; i < l; ++i) {
var hook = this.hooks[event][i];
if (hook._name !== null && hook._name === fn._name && hook.group === fn.group) {
debug('match found, overriden');
this.hooks[event][i] = fn;
return;
}
}
}
// push is no priority
if (!fn.priority) {
debug('push hook (no priority)');
return this.hooks[event].push(fn);
}
// find the correct place to register hook
for (var i = 0, l = this.hooks[event].length; i < l; ++i) {
var tmp_p = this.hooks[event][i].priority || 99999;
if (fn.priority < tmp_p) {
debug('push hook at index %s', String(i));
return this.hooks[event].splice(i, 0, fn);
}
}
debug('push hook (lowest priority yet)');
return this.hooks[event].push(fn);
}
/**
* Loads hooks from directory
* @param {Path} dir directory
* @return {Promise} promise
*/
loadHooksFromDir(dir) {
var glob = path.join(dir, '/hooks/**/*.js'),
files = utils.getGlobbedFiles(glob);
debug('loadHooksFromDir: %s', dir);
return new Promise((resolve, reject) => {
async.each(files, (file, next) => {
debug('loading hooks from file: %s', String(file));
let hooks = require(file);
let keys = Object.keys(hooks);
keys.forEach((key) => {
let hook = hooks[key];
let args = [];
debug('Loading hook: %s', key);
// create args
if (!hook.event || !hook.fn) {
throw new Error('Hook missing attributes');
}
hook.fn.group = file.split('hooks/')[1].replace('.js', '');
hook.fn._name = key;
args.push(hook.event);
args.push(hook.fn);
if (typeof hook.priority !== 'undefined') {
if (typeof hook.priority === 'string') {
if (!this.CORE_PRIORITY.hasOwnProperty(hook.priority)) {
throw "Invalid priority type";
}
hook.priority = this.CORE_PRIORITY[hook.priority];
}
args.push(hook.priority);
} else {
args.push(null);
}
if (hook.override) {
args.push(true);
}
this.registerHook.apply(this, args);
});
next();
}, function done(err) {
if (err) {
debug(err);
return reject(err);
}
debug('all hooks loaded from %s', dir);
resolve();
});
});
}
addModules(modules) {
debug('loading modules', JSON.stringify(modules));
modules.forEach((moduleName) => this.addModule(moduleName));
}
/**
* Load clout-js node module
* @param {string} moduleName clout node module name
*/
addModule(moduleName) {
if (!!~this.moduleCache.indexOf(moduleName)) {
debug('module: %s already loaded', moduleName);
return;
}
this.logger.debug('loading module: %s', moduleName);
this.moduleCache.push(moduleName);
let cloutModule = {
name: moduleName,
path: path.dirname(require.resolve(moduleName)),
manifest: {}
};
this.modules.push(cloutModule);
debug(cloutModule);
// load module manifest
['package.json', 'clout.json'].forEach((fileName) => {
let filePath = path.resolve(cloutModule.path, fileName);
if (!fs.existsSync(filePath)) {
return debug(`${fileName} not found`);
}
_.merge(cloutModule.manifest, require(filePath));
});
// load module modules
if (cloutModule.manifest.modules) {
debug('%s loading modules %s', moduleName, manifest.modules);
this.addModules(manifest.modules);
}
}
/**
* Start clout
* @return {Promise} returns a promise
*/
start() {
this.emit('initialized');
if (!this.initialized) {
return new Promise((resolve) => {
setTimeout(() => resolve(this.start()), 100);
});
}
this.emit('start');
return new Promise((resolve, reject) => {
process.nextTick(() => {
async.eachLimit(this.hooks.start, 1, (hook, next) => {
debug('executing', hook.name || hook._name, hook.group);
let hookResponse = hook.apply(this, [next]);
// support promises
if (typeof hookResponse === 'object') {
hookResponse.then(next, (err) => next(null, err));
}
}, (err) => {
if (err) {
debug(err);
return reject(err);
}
resolve();
this.emit('started');
});
});
});
}
// TODO:- investigate if we still need this?
/**
* Add API
* @param {string} path api path
* @param {function} fn express function
*/
addApi(path, fn) {
this.app.use(path, function (req, resp, next) {
let promiseResponse = fn.apply(this, arguments);
// support for prmises
// bind to app.use
if (String(promiseResponse) === '[object Promise]') {
promiseResponse
.then((payload) => {
switch (Object.prototype.toString.call(payload)) {
case '[object Object]':
case '[object String]':
resp.success(payload);
break;
case '[object Undefined]':
break;
default:
console.error('type not supported');
resp.error('response type is invalid');
break;
}
next();
})
.catch((payload) => {
switch (Object.prototype.toString.call(payload)) {
case '[object Object]':
case '[object String]':
resp.error(payload);
break;
case '[object Undefined]':
break;
default:
console.error('type not supported');
resp.error('response type is invalid');
break;
}
next();
});
}
});
return new Promise.resolve();
}
/**
* Stop clout
* @return {Promise} returns a promise
*/
stop() {
this.emit('stop');
return new Promise((resolve, reject) => {
async.eachLimit(this.hooks.stop, 1, (hook, next) => {
hook.apply(this, [next]);
}, (err) => {
if (err) {
debug(err);
return reject(err);
}
resolve();
this.emit('stopped');
});
});
}
/**
* Reload clout
* @return {Promise} returns a promise
*/
reload() {
this.emit('reload');
return this.stop()
.then(this.start)
.then(() => {
deferred.resolve();
this.emit('reloaded');
});
}
handleProcess() {
process.on('unhandledRejection', (err) => {
console.error(err);
});
process.on('uncaughtException', (err) => {
console.log(err);
process.exit(0);
});
}
}
module.exports = Clout;
module.exports.PRIORITY = CORE_PRIORITY;