/************************************************************************* * * Troven CONFIDENTIAL * __________________ * * (c) 2017-2020 Troven Ventures Pty Ltd * All Rights Reserved. * * NOTICE: All information contained herein is, and remains * the property of Troven Pty Ltd and its licensors, * if any. The intellectual and technical concepts contained * herein are proprietary to Troven Pty Ltd * and its suppliers and may be covered by International and Regional Patents, * patents in process, and are protected by trade secret or copyright law. * Dissemination of this information or reproduction of this material * is strictly forbidden unless prior written permission is obtained * from Troven Pty Ltd. */ import "source-map-support/register"; import * as _ from "lodash"; import * as assert from "assert"; import * as express from "express"; import * as Logger from "./Logger"; require("source-map-support").install(); import * as EventEmitter from "events"; import { IChassisContext, IChassisPlugin, IChassisMiddleware, IPackage, IChassisMetrics, IChassisSecrets } from "../interfaces"; import { Utils, Vars } from "../helpers/"; import { Registry } from "./Registry"; import { IChassisConfig } from "../interfaces/IChassisConfig"; import * as path from "path"; import * as Debug from "debug"; const NODE_ENV = process.env.NODE_ENV || "development"; const IS_DEV_ENV = NODE_ENV == "development"; export class Chassis { plugins: Registry = new Registry(); middleware: Registry = new Registry< IChassisMiddleware >(); booted: boolean = false; context: IChassisContext; config: any; constructor(config: IChassisConfig, bootFn?: Function) { this.config = config; this.context = this.init(config); this.boot(bootFn); } metrics: IChassisMetrics; secrets: IChassisSecrets; uuid: string; bus: EventEmitter.EventEmitter; app: express.Application; api: express.Router; /** * Launch the Chassis. * Initializes the Chassis (process event listeners, etc) * * Installs all of the registered plugins. * * Starts listening for HTTP/S requests on a port. * * Emits "api:init" event to announce it's availability * * @returns {IChassisContext} */ start(): IChassisContext { let context: any = this.context; // unstructured - so we can mutute our context // sanity checks assert(!context.server, "already started"); assert(context.api, "missing context.api"); assert(context.app.get, "missing context.api.get"); assert(context.app.listen, "missing context.api.listen"); assert(context.logger, "missing context.logger"); assert(context.config, "missing context.config"); assert(context.config.name, "missing context.config.name"); let config = context.config; let port = context.app.get("port"); assert(port, "context.api port not found"); this.installPlugins(this.context, this.config); let _started = { code: "api:start", name: this.config.name, port: this.config.port, package: context.pkg.name || false, version: context.pkg.version || false, features: _.keys(this.config.features), plugins: _.keys(this.context.plugins.features), middleware: _.keys(this.context.middleware.features), environment: NODE_ENV, }; // process exceptions config.process_guard && this.process_guard(); // start lisenting for HTTP(S) requests on port context.server = context.app.listen(port, function () { // announce Chassis state context.log(_started); context.bus.emit(_started.code, { _started }); // for humans console.log( "%s v%s on port %s (%s)", _started.package, _started.version, config.port, NODE_ENV ); }); return context; } pkg(): IPackage { try { return Vars.load(process.cwd() + "/package.json"); } catch (e) { console.log("suggest: add package.json"); let name = path.dirname(process.cwd()).split(path.sep).pop(); return { name: name, version: "0.0.0" }; } } /** * Initialize a ChassisContext from a given configuration object * * Create the IChassisContext and binds the runtime instances * * Must be called before start() * * @param config * @returns {IChassisContext} */ private init(config: any): IChassisContext { let debug = Debug(config.name); Vars.resolve_externals(config); // resolve @ references // generate a unique ID let uuid = Utils.uuid( config.name + "_" + config.port + "_" + Date.now() ); // define logging sub-system for re-use across project config.logging = _.extend({}, config.logging); let logger = Logger.define_logger(config.logging); assert(logger, "invalid logger"); // define audit logs config.auditing = _.extend({}, config.auditing); let auditor = logger; if (config.auditing) { auditor = Logger.define_logger(config.auditing); assert(auditor, "invalid auditor"); } // we're event driven by default let bus = new EventEmitter(); // initialize express - to serve our HTTP/S requests let app = express(); // define the port that Express will listen on app.set("port", config.port); bus.on("error", function (err) { console.error(err); logger.error({ code: "api:bus:error", message: "event error: " + err, stack: err.stack || null, }); }); // create our Chassis context - bind our instances into the object let pkg = this.pkg(); let self = this; let router = express.Router(); app.use(router); let context = { uuid: uuid, bus: bus, app: app, api: router, pkg: pkg, resolve: (item: any) => { return Vars.resolve_externals(item); }, config: config, // openapi: null, secrets: null, logger: logger, auditor: auditor, plugins: this.plugins, middleware: this.middleware, metrics: null, error: function (_log) { assert(_log, "missing error.message"); _log.message = _log.message || _log.code; self._stamp(context, _log); debug(_log.code); bus.emit("api:error", _log); logger.error(_log); return true; }, warn: function (_log) { assert(_log, "missing log.message"); _log.message = _log.message || _log.code; self._stamp(context, _log); debug(_log.code); bus.emit("api:warn", _log); logger.warn(_log); return true; }, log: function (_log) { assert(_log, "missing log.message"); _log.message = _log.message || _log.code; self._stamp(context, _log); debug(_log.code); logger.info(_log); return true; }, trace: function (_log) { assert(_log, "missing log.message"); _log.message = _log.message || _log.code; self._stamp(context, _log); debug(_log.code); logger.debug(_log); return true; }, audit: function (_audit) { _audit.code = _audit.code || _audit.action; assert(_audit.code, "missing audit.code"); _audit.message = _audit.message || _audit.code; self._stamp(context, _audit); bus.emit("api:audit", _audit); debug(_audit.code); auditor.info(_audit); return true; }, }; return context as IChassisContext; } _stamp(context: IChassisContext, _log: any) { if (!IS_DEV_ENV) { _log.serviceId = context.pkg.name; _log.serviceVersion = context.pkg.version; _log.serviceUUID = context.uuid; _log.timestamp = Date.now(); } return _log; } boot(bootFn?: Function) { if (this.booted) { return; } bootFn && bootFn.apply(this); this.booted = true; // publish our first (local) event - since our context is ready this.emit_log("api:boot", true, true, { port: this.config.port, environment: NODE_ENV, }); } emit_log( code: string, verbose_context: boolean, do_emit: boolean, _logged: any ) { _logged = _.extend({ code: code, name: this.config.name }, _logged); if (this.context.pkg) { _logged.package = this.context.pkg.name; _logged.version = this.context.pkg.version; } if (verbose_context) { _logged.plugins = _.keys(this.context.plugins.features); _logged.middleware = _.keys(this.context.middleware.features); } if (do_emit) { this.context.bus.emit(_logged.code, _logged); } this.context.log(_logged); return _logged; } installPlugins(context: IChassisContext, config: any) { let features = _.extend( { pipeline: { enabled: true }, openapi: { enabled: true } }, config.features ); for (let name in features) { let options = _.extend({ enabled: true }, config.features[name]); let plugin = this.context.plugins.get(name); let enabled = options && options.enabled !== false; // enabled by default if (!plugin) { context.error({ code: "feature:plugin:missing", plugin: name, message: "plugin not registered", }); throw new Error("feature:plugin:missing:" + name); } else if (enabled) { delete options.enabled; // clean-up so options can be consumed by plugins more easily let _log = { code: "feature:plugin:enabled", plugin: name }; context.log(_log); plugin.install(context, options); context.bus.emit(_log.code, { plugin: name, options: options }); } else { context.warn({ code: "feature:plugin:disabled", plugin: name, message: "plugin disabled", }); } } } /** * Register Plugin * @param {IChassisPlugin} plugin */ registerPlugin(plugin: IChassisPlugin): IChassisPlugin { this.context.trace({ code: "feature:plugin:register", name: plugin.name, message: plugin.title, }); this.plugins.set(plugin); return plugin; } /** * Register Middleware * @param {IChassisPlugin} plugin */ registerFn(plugin: IChassisMiddleware): IChassisMiddleware { this.context.trace({ code: "feature:fn:register", name: plugin.name, message: plugin.title, }); this.middleware.set(plugin); return plugin; } process_guard() { let context = this.context; // process crashes process.on("uncaughtException", function (err: any) { context && context.error({ code: "api:error:uncaughtException", message: err.message, stack: err.stack, }); console.error(err); process.exit(1); }); // Promise rejections process.on("unhandledRejection", function (err: any) { context && context.error({ code: "api:error:unhandledRejection", message: err.message, stack: err.stac || "none", }); console.error(err); process.exit(2); }); context && context.log({ code: "api:process_guard" }); } }