Home Manual Reference Source

src/services/building/buildNodeRunnerProcess.js

const path = require('path');
const fs = require('fs-extra');
const ObjectUtils = require('wootils/shared/objectUtils');
const nodemon = require('nodemon');
const nodemonBus = require('nodemon/lib/utils/bus');
const { provider } = require('jimple');
const NodeWatcher = require('../../abstracts/nodeWatcher');

/**
 * This service implements `nodemon` and {@link NodeWatcher} in order to run Node apps while
 * watching, transpiling and copying files.
 * @extends {NodeWatcher}
 */
class BuildNodeRunnerProcess extends NodeWatcher {
  /**
   * @param {Logger}                       appLogger            The inform on the CLI of the events
   *                                                            of the runner.
   * @param {BuildTranspiler}              buildTranspiler      To transpile files if needed.
   * @param {ProjectConfigurationSettings} projectConfiguration To read the watch settings.
   */
  constructor(appLogger, buildTranspiler, projectConfiguration) {
    super({
      poll: projectConfiguration.others.watch.poll,
    });
    /**
     * A local reference for the `appLogger` service.
     * @type {Logger}
     */
    this.appLogger = appLogger;
    /**
     * A local reference for the `buildTranspiler` service.
     * @type {BuildTranspiler}
     */
    this.buildTranspiler = buildTranspiler;
    /**
     * A simple flag to check whether the process is running or not.
     * @type {boolean}
     */
    this.running = false;
    /**
     * The default values for the options that can be customized when calling `run`.
     * @property {string}                executable     The path to the file `nodemon` will
     *                                                  execute.
     * @property {NodeInspectorSettings} inspectOptions The settings for the Node inspector.
     * @property {Array}                 watch          The list of directories `nodemon` will
     *                                                  watch in orderto reset the execution.
     * @property {Array}                 ignore         A list of patterns `nodemon` will ignore
     *                                                  while watching directories.
     * @property {Object}                envVars        A dictionary of environment variables to
     *                                                  send to the execution process.
     * @property {boolean}               legacyWatch    Whether or not to enable `nodemon` legacy
     *                                                  watch mode.
     */
    this.defaultOptions = {
      executable: '',
      inspectOptions: {
        enabled: false,
        host: '0.0.0.0',
        port: 9229,
        command: 'inspect',
        ndb: false,
      },
      watch: [],
      ignore: [],
      envVars: {},
      legacyWatch: false,
    };
    /**
     * This dictionary is where the parameters sent to the `run` method and the `defaultOptions`
     * will be merged.
     * @type {Object}
     */
    this.options = {};
    /**
     * Whether or not the process logged the starting message.
     * @type {boolean}
     * @access protected
     * @ignore
     */
    this._started = false;
    /**
     * Whether or not the process is currently being restarted.
     * @type {boolean}
     * @access protected
     * @ignore
     */
    this._restaring = false;
    /**
     * Bind the method to send it to the `nodemon` events listener.
     * @ignore
     */
    this._onNodemonStart = this._onNodemonStart.bind(this);
    /**
     * Bind the method to send it to the `nodemon` events listener.
     * @ignore
     */
    this._onNodemonRestart = this._onNodemonRestart.bind(this);
    /**
     * Bind the method to send it to the `nodemon` events listener.
     * @ignore
     */
    this._onNodemonCrash = this._onNodemonCrash.bind(this);
    /**
     * Bind the method to send it to the `nodemon` events listener.
     * @ignore
     */
    this._onNodemonQuit = this._onNodemonQuit.bind(this);
  }
  /**
   * Enables `nodemon` legacy watch mode.
   * @see https://github.com/remy/nodemon#application-isnt-restarting
   */
  enableLegacyWatch() {
    this.options.legacyWatch = true;
  }
  /**
   * Run a Node application.
   * @param {string} executable
   * The path to the file to execute.
   * @param {Array} watch
   * The list of directories to watch in order to restart the application.
   * @param {NodeInspectorSettings} inspectOptions
   * The settings for the Node inspector.
   * @param {Array} [transpilationPaths=[]]
   * A list of dictionaries with `from` and `to` paths the service will use for transpilation
   * when files change during the execution, in order to restart the application.
   * @param {Array} [copyPaths=[]]
   * A list of dictionaries with `from` and `to` paths the service will use for copying files
   * when they change during the execution, in order to restart the application.
   * @param {Object} [envVars={}]
   * A dictionary with extra environment variables to send to the execution process.
   * @param {Array} [ignore=['.test.js']]
   * A list of file name patterns the service that will be ignored by the `nodemon` watcher.
   * @param {Function(instance:BuildNodeRunnerProcess)} [setupFn=()=>{}]
   * A custom callback that will be executed before starting (and restaring) a Node application.
   * It can be used to "modify the environment" before the application runs.
   * @return {Nodemon}
   * @throws {Error} if the process is already running.
   * @throws {Error} if the executable doesn't exist.
   * @todo refactor the parameters into a single "options object".
   * @todo watch the .env files.
   */
  run(
    executable,
    watch,
    inspectOptions,
    transpilationPaths = [],
    copyPaths = [],
    envVars = {},
    ignore = ['*.test.js'],
    setupFn = () => {}
  ) {
    // Check that is not already running and that the executable exists.
    if (this.running) {
      throw new Error(
        'The process is already running, you can\'t start it more than once'
      );
    } else if (!fs.pathExistsSync(executable)) {
      throw new Error(`The target executable doesn't exist (${executable})`);
    }
    // Turn on the flag that tells the service the process is running.
    this.running = true;
    // Merge the default options with the parameters.
    this.options = ObjectUtils.merge(this.defaultOptions, this.options, {
      executable,
      watch,
      inspectOptions,
      envVars,
      ignore,
    });
    /**
     * This part is tricky...
     * First, make sure there's at least one item on the transpilation paths list, because that
     * means that the files are being executed from a different path than its source directory.
     * If the files change location, and the application depends on files outside its directory,
     * then the service will watch the transpilation paths, for files that need to be moved and
     * transpiled, and the copy files, for files that just need to be moved.
     * The reason this is _"tricky"_ is because the copy paths are only added if there's
     * transpilation, because there's no need to copy files if the code doesn't change locations.
     */
    if (transpilationPaths.length) {
      this.watch(
        [
          ...transpilationPaths.map(({ from }) => from),
          ...copyPaths.map(({ from }) => from),
        ],
        transpilationPaths,
        copyPaths
      );
    }
    // Run the callback that sets up the environment.
    setupFn(this);
    // Get the command for `nodemon`.
    const command = this._getNodemonCommand();
    // Start `nodemon`.
    nodemon(command);
    // Inject the function that sets up the environment.
    this._injectSetupFnOnNodemon(setupFn);
    // Add the `nodemon` listeners.
    nodemon.on('start', this._onNodemonStart);
    nodemon.on('restart', this._onNodemonRestart);
    nodemon.on('crash', this._onNodemonCrash);
    nodemon.on('quit', this._onNodemonQuit);

    return nodemon;
  }
  /**
   * Generates the `nodemon` command. The reason there's an specific method for generating it is
   * because the service needs to validate the different options in order to enable or not the
   * Node inspector (or ndb).
   * @return {string}
   * @access protected
   * @ignore
   */
  _getNodemonCommand() {
    const {
      executable,
      watch,
      ignore,
      envVars,
      inspectOptions,
      legacyWatch,
    } = this.options;
    // Prefix the command with all the environment variables.
    const command = [
      ...Object.keys(envVars).map((varName) => {
        const varValue = envVars[varName];
        return `${varName}=${varValue}`;
      }),
    ];
    // Add the `nodemon` command in the format required by the library.
    command.push('node nodemon');
    // If the native inspector is enabled, push the required flag.
    if (inspectOptions.enabled && !inspectOptions.ndb) {
      const { host, port, command: inspectCommand } = inspectOptions;
      command.push(`--${inspectCommand}=${host}:${port}`);
    }
    // Add the path to the executable.
    command.push(executable);
    // If `ndb` is enabled, change the executable.
    if (inspectOptions.enabled && inspectOptions.ndb) {
      command.push('--exec "ndb node"');
    }
    // Push the paths to watch and ignore.
    command.push(...[
      ...watch.map((watchPath) => `--watch ${watchPath}`),
      ...ignore.map((ignorePath) => `--ignore ${ignorePath}`),
    ]);
    // If required, enable the legacy watch mode.
    if (legacyWatch) {
      command.push('--legacy-watch');
    }
    // Transform the list into a string and return it.
    return command.join(' ').trim();
  }
  /**
   * This is called when a source file changes and it's detected by the service, not `nodemon`.
   * The overwrite is just to show a log message saying that the process will be restarted, as the
   * parent class will end up transpiling or copying a file into one the directories `nodemon`
   * watches.
   * @param {string} file The path to the file that changed.
   * @access protected
   * @ignore
   */
  _onChange(file) {
    this.appLogger.warning(`Restarting because a file was modified: ${file}`);
    super._onChange(file);
  }
  /**
   * This is called when a source file changes and the service can't find a matching path on neither
   * the transpilation paths nor the copy paths.
   * The method will just show an error message explaning the problem and call the method that shows
   * the error when `nodemon` crashes.
   * @access protected
   * @ignore
   */
  _onInvalidPathForChange() {
    this.appLogger.error('Error: The file directory is not on the list of allowed paths');
    this._onNodemonCrash();
  }
  /**
   * Transpiles a file from a source directory into a build directory, which `nodemon` watches.
   * @param {string} source The path to the source file.
   * @param {string} output The path for the source file once transpiled.
   * @access protected
   * @ignore
   */
  _transpileFile(source, output) {
    try {
      // Make sure the path to the directory exists.
      fs.ensureDirSync(path.dirname(output));
      // Transpile the file.
      this.buildTranspiler.transpileFileSync({ source, output });
      this.appLogger.success('The file was successfully copied and transpiled');
    } catch (error) {
      this.appLogger.error('Error: The file couldn\'t be updated');
      this.appLogger.error(error);
      this._onNodemonCrash();
    }
  }
  /**
   * Copies a file from a source directory into a build directory, which `nodemon` watches.
   * @param {string} from The original path of the file.
   * @param {string} to   The new path for the file.
   * @access protected
   * @ignore
   */
  _copyFile(from, to) {
    try {
      // Make sure the path to the directory exists.
      fs.ensureDirSync(path.dirname(to));
      // Copy the file.
      fs.copySync(from, to);
      this.appLogger.success('The file was successfully copied');
    } catch (error) {
      this.appLogger.error('Error: The file couldn\'t be copied');
      this.appLogger.error(error);
      this._onNodemonCrash();
    }
  }
  /**
   * This is called when `nodemon` starts the process and after each time it restarts it. The
   * method just prints information messages and turn on the `_started` flag.
   * @param {boolean} [forceLog=false] By default, it only logs the messages the first time, but
   *                                   if this flag is `true`, it will do it anyways. This is
   *                                   used from the `_onNodemonRestart` to make sure the restart
   *                                   messages are shown before the start.
   * @ignore
   * @access protected
   */
  _onNodemonStart(forceLog = false) {
    // Only log the messages if it is the first time or if the force flag is `true.`
    if (!this._started || forceLog) {
      this.appLogger.success(`Starting ${this.options.executable}`);
      this.appLogger.info([
        'to restart at any time, enter \'rs\'',
        ...this.options.watch.map((directory) => `watching: ${directory}`),
      ]);
      // Turn on the flag that informs the service this method was executed at least once.
      this._started = true;
    }
  }
  /**
   * This is called when `nodemon` restarts a process, because a file changed or because the user
   * requested it. It only prints information messages.
   * @param {?Array} files A list of files that changed, thus triggering the restart.
   * @ignore
   * @access protected
   */
  _onNodemonRestart(files) {
    /**
     * If the code requires transpilation (which means that the service is watching directories)
     * and this was triggered by file changes, the restart message was already printed by the
     * `_onChange` method, so no need to print anything else.
     */
    if (!this.watching) {
      if (files && files.length) {
        const [file] = files;
        this.appLogger.warning(`Restarting because file was modified: ${file}`);
      } else {
        this.appLogger.warning('Restarting');
      }
    } else if (!files) {
      /**
       * If the code requires transpilation but the change was triggered by the user, then is ok to
       * show a message.
       */
      this.appLogger.warning('Restarting');
    }
    /**
     * After showing the restart messages, show the start messages again.
     * This is done this way because for some reason, the events were being triggered before the
     * `start` and then the `restart`, showing the messages out of order. This way, the `restart`
     * triggers the `start`, so the order of the message is always correct.
     */
    this._onNodemonStart(true);
  }
  /**
   * This is called when `nodemon` crashes and just prints a message saying that it is still
   * watching.
   * @ignore
   * @access protected
   */
  _onNodemonCrash() {
    this.appLogger.error('Crash - waiting for file changes before starting...');
  }
  /**
   * This is called when the `nodemon` process is stopeed. It first checks if it needs to turn off
   * the watcher and then exits the current process.
   * @ignore
   * @access protected
   */
  _onNodemonQuit() {
    // If the service is watching directories...
    if (this.watching) {
      // ...then it should be stopped.
      this.stop();
    }

    // eslint-disable-next-line no-process-exit
    process.exit();
  }
  /**
   * Disclaimer: This is a hack... there's no other way around it.
   * The class needs for a function to be executed right before the Nodemon process spawns, but
   * Nodemon uses its own listeners to setup that part and once `nodemon()` is called, it's too
   * late to set anything, the internal listeners are already in place.
   * After some debugging, I found that right after `nodemon()` is called, the last registered
   * listener for the `restart` event is the one that actually does the restart; so, this method
   * injects a listener right before that one in order for it to be called just before Nodemon
   * does stops and starts the application.
   * The function also checks if, by any chance, there's no other "hack function" already
   * registered so it can replace it instead of adding one more.
   * @param {Function(instance:BuildNodeRunnerProcess)} setupFn The function to call before
   *                                                            starting the application.
   * @access protected
   * @ignore
   */
  _injectSetupFnOnNodemon(setupFn) {
    const idKey = 'buildNodeRunnerProcessSetupFn';
    const newFn = () => setupFn(this);
    newFn[idKey] = true;
    const { _events: { restart: events } } = nodemonBus;
    const existingIndex = events.findIndex((fn) => fn[idKey] === true);
    if (existingIndex > -1) {
      events[existingIndex] = newFn;
    } else {
      events.splice(events.length - 1, 0, newFn);
    }
  }
}
/**
 * The service provider that once registered on the app container will set an instance of
 * `BuildNodeRunnerProcess` as the `buildNodeRunnerProcess` service.
 * @example
 * // Register it on the container
 * container.register(buildNodeRunnerProcess);
 * // Getting access to the service instance
 * const buildNodeRunnerProcess = container.get('buildNodeRunnerProcess');
 * @type {Provider}
 */
const buildNodeRunnerProcess = provider((app) => {
  app.set('buildNodeRunnerProcess', () => new BuildNodeRunnerProcess(
    app.get('appLogger'),
    app.get('buildTranspiler'),
    app.get('projectConfiguration').getConfig()
  ));
});

module.exports = {
  BuildNodeRunnerProcess,
  buildNodeRunnerProcess,
};