UNPKG

@stoplight/moleculer

Version:

Fast & powerful microservices framework for Node.JS

538 lines (479 loc) 13.9 kB
/* moleculer * Copyright (c) 2019 MoleculerJS (https://github.com/moleculerjs/moleculer) * MIT Licensed */ "use strict"; const ServiceBroker = require("./service-broker"); const utils = require("./utils"); const fs = require("fs"); const path = require("path"); const glob = require("glob").sync; const _ = require("lodash"); const Args = require("args"); const os = require("os"); const cluster = require("cluster"); const kleur = require("kleur"); const stopSignals = [ "SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGTRAP", "SIGABRT", "SIGBUS", "SIGFPE", "SIGUSR1", "SIGSEGV", "SIGUSR2", "SIGTERM" ]; /* eslint-disable no-console */ /** * Logger helper * */ const logger = { info(message) { console.log(kleur.grey("[Runner]"), kleur.green().bold(message)); }, error(err) { if (err instanceof Error) console.error(kleur.grey("[Runner]"), kleur.red().bold(err.message), err); else console.error(kleur.grey("[Runner]"), kleur.red().bold(err)); } }; class MoleculerRunner { constructor() { this.watchFolders = []; this.flags = null; this.configFile = null; this.config = null; this.servicePaths = null; this.broker = null; this.worker = null; } /** * Process command line arguments * * Available options: -c, --config Load the configuration from a file -e, --env Load .env file from the current directory -E, --envfile Load a specified .env file -h, --help Output usage information -H, --hot Hot reload services if changed (disabled by default) -i, --instances Launch [number] instances node (load balanced) -m, --mask Filemask for service loading -r, --repl Start REPL mode (disabled by default) -s, --silent Silent mode. No logger (disabled by default) -v, --version Output the version number */ processFlags(procArgs) { Args.option("config", "Load the configuration from a file") .option("repl", "Start REPL mode", false) .option(["H", "hot"], "Hot reload services if changed", false) .option("silent", "Silent mode. No logger", false) .option("env", "Load .env file from the current directory") .option("envfile", "Load a specified .env file") .option("instances", "Launch [number] instances node (load balanced)") .option("mask", "Filemask for service loading"); this.flags = Args.parse(procArgs, { mri: { alias: { c: "config", r: "repl", H: "hot", s: "silent", e: "env", E: "envfile", i: "instances", m: "mask" }, boolean: ["repl", "silent", "hot", "env"], string: ["config", "envfile", "mask"] } }); this.servicePaths = Args.sub; } /** * Load environment variables from '.env' file */ loadEnvFile() { if (this.flags.env || this.flags.envfile) { try { const dotenv = require("dotenv"); if (this.flags.envfile) dotenv.config({ path: this.flags.envfile }); else dotenv.config(); } catch (err) { throw new Error( "The 'dotenv' package is missing! Please install it with 'npm install dotenv --save' command." ); } } } /** * Fix Uppercase drive letter issue on Windows. It causes problem on custom modules detection (XY instanceof Base) * Unused currently, because it causes problem: https://github.com/moleculerjs/moleculer/issues/788 * * More info: https://github.com/nodejs/node/issues/6978 * @param {String} s * @returns {String} */ fixDriveLetterCase(s) { if (s && process.platform === "win32" && s.match(/^[A-Z]:/g)) { return s.charAt(0).toLowerCase() + s.slice(1); } return s; } /** * Load configuration file * * Try to load a configuration file in order to: * * - load file defined in MOLECULER_CONFIG env var * - try to load file which is defined in CLI option with --config * - try to load the `moleculer.config.js` file if exist in the cwd * - try to load the `moleculer.config.json` file if exist in the cwd */ loadConfigFile() { let filePath; // Env vars have priority over the flags if (process.env["MOLECULER_CONFIG"]) { filePath = path.isAbsolute(process.env["MOLECULER_CONFIG"]) ? process.env["MOLECULER_CONFIG"] : path.resolve(process.cwd(), process.env["MOLECULER_CONFIG"]); } else if (this.flags.config) { filePath = path.isAbsolute(this.flags.config) ? this.flags.config : path.resolve(process.cwd(), this.flags.config); } if (!filePath && fs.existsSync(path.resolve(process.cwd(), "moleculer.config.js"))) { filePath = path.resolve(process.cwd(), "moleculer.config.js"); } if (!filePath && fs.existsSync(path.resolve(process.cwd(), "moleculer.config.json"))) { filePath = path.resolve(process.cwd(), "moleculer.config.json"); } if (filePath) { if (!fs.existsSync(filePath)) return Promise.reject(new Error(`Config file not found: ${filePath}`)); const ext = path.extname(filePath); switch (ext) { case ".json": case ".js": case ".ts": { const content = require(filePath); return Promise.resolve() .then(() => { if (utils.isFunction(content)) return content.call(this); else return content; }) .then( res => (this.configFile = res.default != null && res.__esModule ? res.default : res) ); } default: return Promise.reject(new Error(`Not supported file extension: ${ext}`)); } } } normalizeEnvValue(value) { if (value.toLowerCase() === "true" || value.toLowerCase() === "false") { // Convert to boolean value = value === "true"; } else if (!isNaN(value)) { // Convert to number value = Number(value); } return value; } overwriteFromEnv(obj, prefix) { Object.keys(obj).forEach(key => { const envName = ((prefix ? prefix + "_" : "") + key).toUpperCase(); if (process.env[envName]) { obj[key] = this.normalizeEnvValue(process.env[envName]); } if (utils.isPlainObject(obj[key])) obj[key] = this.overwriteFromEnv(obj[key], (prefix ? prefix + "_" : "") + key); }); // Process MOL_ env vars only the root level if (prefix == null) { const moleculerPrefix = "MOL_"; Object.keys(process.env) .filter(key => key.startsWith(moleculerPrefix)) .map(key => ({ key, withoutPrefix: key.substr(moleculerPrefix.length) })) .forEach(variable => { const dotted = variable.withoutPrefix .split("__") .map(level => level.toLocaleLowerCase()) .map(level => level .split("_") .map((value, index) => { if (index == 0) { return value; } else { return value[0].toUpperCase() + value.substring(1); } }) .join("") ) .join("."); obj = utils.dotSet( obj, dotted, this.normalizeEnvValue(process.env[variable.key]) ); }); } return obj; } /** * Merge broker options * * Merge options from environment variables and config file. First * load the config file if exists. After it overwrite the vars from * the environment values. * * Example options: * * Original broker option: `logLevel` * Config file property: `logLevel` * Env variable: `LOGLEVEL` * * Original broker option: `circuitBreaker.enabled` * Config file property: `circuitBreaker.enabled` * Env variable: `CIRCUITBREAKER_ENABLED` * */ mergeOptions() { this.config = _.defaultsDeep(this.configFile, ServiceBroker.defaultOptions); this.config = this.overwriteFromEnv(this.config); if (this.flags.silent) this.config.logger = false; if (this.flags.hot) this.config.hotReload = true; // console.log("Merged configuration", this.config); } /** * Check the given path whether directory or not * * @param {String} p * @returns {Boolean} */ isDirectory(p) { try { return fs.lstatSync(p).isDirectory(); } catch (_) { // ignore } return false; } /** * Check the given path whether a file or not * * @param {String} p * @returns {Boolean} */ isServiceFile(p) { try { return !fs.lstatSync(p).isDirectory(); } catch (_) { // ignore } return false; } /** * Load services from files or directories * * 1. If find `SERVICEDIR` env var and not find `SERVICES` env var, load all services from the `SERVICEDIR` directory * 2. If find `SERVICEDIR` env var and `SERVICES` env var, load the specified services from the `SERVICEDIR` directory * 3. If not find `SERVICEDIR` env var but find `SERVICES` env var, load the specified services from the current directory * 4. check the CLI arguments. If it find filename(s), load it/them * 5. If find directory(ies), load it/them * * Please note: you can use shorthand names for `SERVICES` env var. * E.g. * SERVICES=posts,users * * It will be load the `posts.service.js` and `users.service.js` files * * */ loadServices() { this.watchFolders.length = 0; const fileMask = this.flags.mask || "**/*.service.js"; const serviceDir = process.env.SERVICEDIR || ""; const svcDir = path.isAbsolute(serviceDir) ? serviceDir : path.resolve(process.cwd(), serviceDir); let patterns = this.servicePaths; if (process.env.SERVICES || process.env.SERVICEDIR) { if (this.isDirectory(svcDir) && !process.env.SERVICES) { // Load all services from directory (from subfolders too) this.broker.loadServices(svcDir, fileMask); if (this.config.hotReload) { this.watchFolders.push(svcDir); } } else if (process.env.SERVICES) { // Load services from env list patterns = Array.isArray(process.env.SERVICES) ? process.env.SERVICES : process.env.SERVICES.split(","); } } if (patterns.length > 0) { let serviceFiles = []; patterns .map(s => s.trim()) .forEach(p => { const skipping = p[0] == "!"; if (skipping) p = p.slice(1); if (p.startsWith("npm:")) { // Load NPM module this.loadNpmModule(p.slice(4)); } else { let files; const svcPath = path.isAbsolute(p) ? p : path.resolve(svcDir, p); // Check is it a directory? if (this.isDirectory(svcPath)) { if (this.config.hotReload) { this.watchFolders.push(svcPath); } files = glob(svcPath + "/" + fileMask, { absolute: true }); if (files.length == 0) return this.broker.logger.warn( kleur .yellow() .bold( `There is no service files in directory: '${svcPath}'` ) ); } else if (this.isServiceFile(svcPath)) { files = [svcPath.replace(/\\/g, "/")]; } else if (this.isServiceFile(svcPath + ".service.js")) { files = [svcPath.replace(/\\/g, "/") + ".service.js"]; } else { // Load with glob files = glob(p, { cwd: svcDir, absolute: true }); if (files.length == 0) this.broker.logger.warn( kleur .yellow() .bold(`There is no matched file for pattern: '${p}'`) ); } if (files && files.length > 0) { if (skipping) serviceFiles = serviceFiles.filter(f => files.indexOf(f) === -1); else serviceFiles.push(...files); } } }); _.uniq(serviceFiles).forEach(f => this.broker.loadService(f)); } } /** * Start cluster workers */ startWorkers(instances) { let stopping = false; cluster.on("exit", function (worker, code) { if (!stopping) { // only restart the worker if the exit was by an error if (process.env.NODE_ENV === "production" && code !== 0) { logger.info(`The worker #${worker.id} has disconnected`); logger.info(`Worker #${worker.id} restarting...`); cluster.fork(); logger.info(`Worker #${worker.id} restarted`); } else { process.exit(code); } } }); const workerCount = Number.isInteger(instances) && instances > 0 ? instances : os.cpus().length; logger.info(`Starting ${workerCount} workers...`); for (let i = 0; i < workerCount; i++) { cluster.fork(); } stopSignals.forEach(function (signal) { process.on(signal, () => { logger.info(`Got ${signal}, stopping workers...`); stopping = true; cluster.disconnect(function () { logger.info("All workers stopped, exiting."); process.exit(0); }); }); }); } /** * Load service from NPM module * * @param {String} name * @returns {Service} */ loadNpmModule(name) { let svc = require(name); return this.broker.createService(svc); } /** * Start Moleculer broker */ startBroker() { this.worker = cluster.worker; if (this.worker) { Object.assign(this.config, { nodeID: (this.config.nodeID || utils.getNodeID()) + "-" + this.worker.id }); } // Create service broker this.broker = new ServiceBroker(Object.assign({}, this.config)); this.broker.runner = this; this.loadServices(); if (this.watchFolders.length > 0) this.broker.runner.folders = this.watchFolders; return this.broker.start().then(() => { if (this.flags.repl && (!this.worker || this.worker.id === 1)) this.broker.repl(); return this.broker; }); } /** * Running */ _run() { return Promise.resolve() .then(() => this.loadEnvFile()) .then(() => this.loadConfigFile()) .then(() => this.mergeOptions()) .then(() => this.startBroker()) .catch(err => { logger.error(err); process.exit(1); }); } restartBroker() { if (this.broker && this.broker.started) { return this.broker .stop() .catch(err => { logger.error("Error while stopping ServiceBroker", err); }) .then(() => this._run()); } else { return this._run(); } } start(args) { return Promise.resolve() .then(() => this.processFlags(args)) .then(() => { if (this.flags.instances !== undefined && cluster.isMaster) { return this.startWorkers(this.flags.instances); } return this._run(); }); } } module.exports = MoleculerRunner;