BohemiaInteractive/bi-service

View on GitHub
lib/service.js

Summary

Maintainability
B
4 hrs
Test Coverage
'use strict';

module.exports = Service;
module.exports.Service = Service;

const logger       = require('bi-logger');
const path         = require('path');
const fs           = require('fs');
const EventEmitter = require('events-bluebird');
const _            = require('lodash');
const Promise      = require('bluebird');
const debug        = require('debug')('bi-service');

const ResourceManager      = require('./resourceManager.js');
const RemoteServiceManager = require('./remoteServiceManager.js');
const AppManager           = require('./appManager.js');
const AppStatus            = require('./common/appStatus.js');
const configSchema         = require('./configSchema.js');
const utils                = require('./utils.js');

/**
 * The main representation of a service as a whole.
 * Holds {@link AppManager}, {@link ResourceManager}, {@link RemoteServiceManager} as well as global
 * service configuration (Config) object. Optionally can hold references to
 * Model managers of various ODMs/ORMs
 *
 * @public
 * @constructor
 * @param {Config} config - npm bi-config object
 * @emits Service#error
 * @emits Service#listening
 * @emits Service#set-up
 * @emits Service#shell-cmd
 * @emits Service#static:service
 * @emits Service#static:set-up
 * @emits Service#static:app
 * @emits Service#static:shell-cmd
 * @extends {EventEmitter} - patched with `emitAsyncSeries` & `emitAsyncParallel` methods
 */
function Service(config) {
    const service = this;

    /**
     * see affiliated `bi-config` npm package
     * @name Service#config
     * @instance
     * @readonly
     * @type {Config}
     */
    this.config               = config;
    /**
     * {@link ResourceManager} is used for any resource that can be inspected for
     * its integrity eg. storage connections, remote dependent API services,
     * config provider etc...
     *
     * @name Service#resourceManager
     * @instance
     * @readonly
     * @type {ResourceManager}
     */
    this.resourceManager      = new ResourceManager;
    /**
     * @name Service#appManager
     * @instance
     * @readonly
     * @type {AppManager}
     */
    this.appManager           = new AppManager(this);
    /**
     * @name Service#remoteServiceManager
     * @instance
     * @type {RemoteServiceManager}
     */
    this.remoteServiceManager = null; //bi-service-sdk manager
    /**
     * may be any service specific model manager of ORM  
     * Mentioned purely because of convention. Not required. Populated by the user.
     * @name Service#sqlModelManager
     * @instance
     * @type {null|Object}
     */
    this.sqlModelManager      = null; //sql (sequelize) model manager
    /**
     * couchbase model manager. See affiliated `kouchbase-odm` npm package.  
     * Mentioned purely because of convention. Not required. Populated by the user.
     * @name Service#cbModelManager
     * @instance
     * @type {null|Object}
     */
    this.cbModelManager       = null;

    if (this.config.get('exitOnInitError') === undefined) {
        this.config.set('exitOnInitError', true);
    }

    this.$setProjectRoot();
    this.$setProjectMeta();
    //if an uncaughException is "caugth" prior sucessfull service initialization
    //we want to exit the process with status code > 0 by default
    this.$initLogger({exitOnError: this.config.get('exitOnInitError')});
    this.$initAppWatcher();

    this.config.setInspectionSchema(configSchema);
    this.resourceManager.add('config', this.config);
    //assign shell tag so that the config is inspected
    //prior a shell command is dispatched
    this.resourceManager.tag('config', 'shell');

    process.nextTick(function() {
        Service.emit('service', service);
    });

    //once the service is sucessfully initialized, set the proper
    //logger exitOnError option
    this.once('listening', function() {
        logger.exitOnError = this.config.get('logs:exitOnError');
    });
}

/**
 * self reference
 * @name Service#Service
 * @instance
 * @readonly
 * @type {Service}
 */
Service.prototype.Service = Service;
Service.prototype = Object.create(EventEmitter.prototype);
Service.prototype.constructor = Service;

//public gateway for bi-service plugins
Service.emitter           = new EventEmitter;
Service.on                = Service.emitter.on.bind(Service.emitter);
Service.once              = Service.emitter.once.bind(Service.emitter);
Service.emit              = Service.emitter.emit.bind(Service.emitter);
Service.emitAsyncSeries   = Service.emitter.emitAsyncSeries.bind(Service.emitter);
Service.emitAsyncParallel = Service.emitter.emitAsyncParallel.bind(Service.emitter);

/**
 * `listening` event is emitted on the Service instance once all registered Apps
 *  are sucessfully initialized (Status.INIT -> Status.OK)
 *
 * @private
 * @emits Service#error
 * @emits Service#listening
 * @return {undefined}
 */
Service.prototype.$initAppWatcher = function() {
    const service     = this
    let   numOfOKApps = 0;

    this.appManager.on('build-app', onBuildApp);

    function onBuildApp(app) {
        app.on('status-changed', onAppStatusChanged);
    }

    function onAppStatusChanged(status) {
        if (status === AppStatus.OK) {
            this.removeListener('status-changed', onAppStatusChanged);
            if (++numOfOKApps == this.appManager.apps.length) {
                this.appManager.removeListener('build-app', onBuildApp);
                service.emit('listening');
            }
        } else if (status === AppStatus.ERROR) {
            this.removeListener('status-changed', onAppStatusChanged);
            this.appManager.removeListener('build-app', onBuildApp);
            service.emit('error', this.statusReason);
        }
    }
};

/**
 * returns RemoteServiceManager or constructs one if there isn't any. This
 * requires `bi-service-sdk` npm module to be available
 *
 * @public
 * @return {RemoteServiceManager}
 */
Service.prototype.getRemoteServiceManager = function() {
    if (this.remoteServiceManager === null) {
        this.remoteServiceManager = new RemoteServiceManager(
            this.config.get('services')
        );
    }

    return this.remoteServiceManager;
};

/**
 * syntax sugar for building an application (defaults to `express` based {@link App}) via {@link AppManager}
 *
 * @public
 * @param {String}   name - application name as defined in `apps.<name>` of service configuration file (config.json5)
 * @param {Object}   [options] - see {@link App} constructor options
 * @param {Function} [Constructor={@link App}] - a constructor which implements {@link AppInterface}
 *
 * @return {AppInterface}
 */
Service.prototype.buildApp = function(name, options, Constructor) {

    let defaults = {
        name: name
    };

    let conf = this.config.getOrFail(`apps:${name}`);

    conf = this.config.createLiteralProvider(conf);
    options = _.assign(defaults, options);

    if (typeof Constructor === 'function') {
        return this.appManager.$buildApp(Constructor, conf, options);
    }
    return this.appManager.buildApp(conf, options);
};

/**
 * checks integrity of connected resources and then emits `set-up` events which
 * are expected to register all Apps with Router & Route definitions.
 * `set-up` events are handled asynchronously and so can deal with Promises
 * returned from within event listeners
 *
 * @param {Object} [options]
 * @param {Array}  [options.integrity] - ResourceManager.inspectIntegrity args
 * @private
 * @return {Promise}
 */
Service.prototype.$setup = function(options) {
    return this.resourceManager.inspectIntegrity.apply(
        this.resourceManager,
        (options && options.integrity) || []
    ).bind(this).then(function() {
        //public hook up for bi-service plugins
        return Service.emitAsyncSeries('set-up', this.appManager, this.config);
    }).then(function() {
        //last chance to prepare the environment before app definitions are
        //loaded
        return this.emitAsyncSeries('set-up');
    });
};

/**
 * @public
 * @return {Promise<Service>}
 */
Service.prototype.listen = function() {
    const self = this;

    return new Promise(function(resolve, reject) {

        process.nextTick(tick);

        function tick() {
            let setup = false; //whether the service is "setup" and ready
            self.once('listening', onServiceListening);
            self.once('error', onServiceError);

            self.appManager.on('build-app', function(app) {
                app.once('post-init', function app$postInit() {
                    if (setup === false) {
                        return setImmediate(app$postInit);
                    }
                    Service.onPostInit.call(app, app);
                });
            });

            return self.$setup().then(function() {
                /*
                 * this is important! as it ensures that the apps are NOT allowed
                 * to complete its initialization procedure till we get clear
                 * signal of $setup status.
                 *
                 * Without this, it could be a case that all apps will get
                 * successfully initialized before $setup Error gets propagated
                 * up to the catch handler. In that case we can NOT
                 * abort the service process thus the service would remain
                 * running half initialized.
                 *
                 * This comes into a practive when more than one instance of
                 * Bluebird Promise package is used within the $setup execution
                 * chain.
                 * For more information:
                 * https://github.com/petkaantonov/bluebird/issues/1533
                 */
                setup = true;
            }).catch(function(err) {
                self.emit('error', err);
            });

            function onServiceListening() {
                self.removeListener('error', onServiceError);
                return resolve(self);
            }

            function onServiceError(err) {
                self.removeListener('listening', onServiceListening);
                logger.error(err, function() {
                    //TODO next:major process abortion should be handled by
                    //bi-service run command
                    if (self.config.get('exitOnInitError')) {
                        utils._stderr(err);
                        return process.exit(1);
                    }
                    reject(err);
                });
            }
        }
    });
};

/**
 * calls {@link App#close} on each {@link App} in the {@link AppManager}
 *
 * @public
 * @return {Promise}
 */
Service.prototype.close = function() {
    return Promise.map(this.appManager.apps, function(app) {
        return app.close();
    }).return(this);
};

/**
 * @private
 * @param {Object} [override]
 * @return {Boolean}
 */
Service.prototype.$initLogger = function(override) {
    let cfg = this.config.get('logs');
    if (cfg) {
        logger.reinitialize(_.assign(cfg, override));
        return true;
    }
    return false;
};

/**
 * Starts at the path of main node module the process was started with and goes up
 * in directory hiearchy. As the project root is considered first directory which
 * constains package.json file.
 * Makes synchronous fs calls. ment to be called only within a period of
 * service initialization window.
 *
 * @private
 * @param {String} [dir] - starting point of package.json lookup
 * @return {String}
 */
Service.prototype.$setProjectRoot = function(dir) {
    var p = dir || process.cwd();

    while ((fs.statSync(p)).isDirectory()) {
        try {
            p = require.resolve(p + '/package.json');
            break;
        } catch (e) {
            if (e.code !== 'MODULE_NOT_FOUND') {
                throw e;
            }

            var _p = path.resolve(p + '/../');

            if (_p == p)  break;

            p = _p;
        }
    }

    this.config.set('root', path.dirname(p));
    return this.config.get('root');
};

/**
 * @private
 * @return {String}
 */
Service.prototype.$setProjectMeta = function() {
    let pkg = module.require(this.config.getOrFail('root') + '/package.json');
    this.config.set('npmName', pkg.name);
    this.config.set('version', pkg.version);
};

/**
 * default app.on('post-init') listener
 *
 * @param {App} app
 */
Service.onPostInit = function onPostInit(app) {
    process.nextTick(function() {
        //public hook up for bi-service plugins
        Service.emit('app', app);
        app.once('listening', Service.onListening);
        app.build();
        if (app.status !== AppStatus.ERROR) {
            app.listen(app.config.get('listen'));
        }
    });
};

/**
 * default app.on('listening') listener
 *
 * @param {App} app
 */
Service.onListening = function onListening(app) {
    let port = app.server && app.server.address() && app.server.address().port;
    process.stdout.write(`${app.name} app listening on port: ${port}\n`);
};

/**
 * one of the registered apps failed to initialize
 * @event Service#error
 * @type {Error}
 */

/**
 * all registered apps are successfully initialized and at this point already can receive connections
 * @event Service#listening
 * @type {undefined}
 */

/**
 * The event is emitted after service configuration and all registered resources are validated and ready for use.  
 * Listeners are expected to do any initial asynchronous processing if needed and then register Apps with Router & Route definitions.  
 * The listeners are handled asynchronously and so can deal with Promises
 * @example
 * //$PROJECT_ROOT/index.js
 * const Service = require('bi-service');
 * const service = new Service(require('bi-config'));
 *
 * service.once('set-up', function() {
 *     require('./lib/app.js');
 * });
 *
 * @event Service#set-up
 * @type {}
 */

/**
 * emitted once prior a shell command dispatching. Allows to register
 * custom user-defined shell commands.
 * See [bi-service-shell](https://github.com/BohemiaInteractive/bi-service-shell)
 * plugin
 *
 * @event Service#shell-cmd
 * @property {Object} yargs - preconfigured instance of [yargs](https://github.com/yargs/yargs) npm package
 */

/**
 * for `bi-service` plugins.  
 * emitted once for each instantiated Service object.
 *
 * @example
 * const Service = require('bi-service');
 *
 * Service.on('service', function(service) {
 *     //do stuff
 * });
 *
 * @event Service#static:service
 * @property {Service} service
 */

/**
 * this event allows to hook-up `bi-service` plugins and is static version of {@link Service#event:set-up} event.
 * @example
 * const Service = require('bi-service');
 *
 * Service.on('set-up', function(appManager, config) {
 *     //do stuff
 * });
 *
 * @event Service#static:set-up
 * @type {}
 * @property {AppManager} appManager - service.appManager
 * @property {Config} config - service.config
 */

/**
 * for `bi-service` plugins
 * emitted once for each acknowledged {@link App} by {@link Service}.
 * The App is not necessarily initialized yet
 *
 * @event Service#static:app
 * @property {App} app
 */

/**
 * for `bi-service` plugins.  
 * emitted once prior a shell command is dispatched. Allows to register
 * custom user-defined shell commands.
 * See [bi-service-shell](https://github.com/BohemiaInteractive/bi-service-shell)
 * plugin
 *
 * @event Service#static:shell-cmd
 * @property {Object} yargs
 */