Home Reference Source Repository

src/component/npm/npm-module.js

'use strict';

const packageHash = require('package-hash');
const path = require('path');
const fse = require('fs-extra');
const Spinner = require('../helper/spinner');
const { spawn } = require('child_process');
const md5Hex = require('md5-hex');
const SequentialPromise = require('../helper/sequential-promise');

/**
 * Abstraction over an NPM module
 */
class NpmModule {
  /**
   * @param {string} rootDir
   * @param {Cache} cache
   * @param {*} logger
   */
  constructor(rootDir, cache, logger) {
    this._rootDir = rootDir;
    this._cache = cache;
    this._logger = logger;
  }
  
  /**
   * @returns {*}
   */
  get logger() {
    return this._logger;
  }
  
  /**
   * @returns {string}
   */
  get rootDir() {
    return this._rootDir;
  }
  
  /**
   * @param {Cache} cache
   */
  get cache() {
    return this._cache;
  }
  
  /**
   * @returns {string}
   */
  get packageFileRelative() {
    return path.relative(process.cwd(), this.packageFile);
  }
  
  /**
   * @returns {string}
   */
  get packageFile() {
    return path.join(this.rootDir, NpmModule.PACKAGE_FILE);
  }
  
  /**
   * @returns {string}
   */
  get modulesDir() {
    return path.join(this.rootDir, NpmModule.MODULES_DIR);
  }
  
  /**
   * @returns {string}
   */
  get debugFile() {
    return path.join(this.rootDir, NpmModule.NPM_DEBUG_FILE);
  }
  
  /**
   * @param {*} deps
   * @param {array} scripts
   *
   * @returns {Promise}
   */
  install(deps = {}, scripts = []) {
    let cacheKey;
    const packageFile = this.packageFile;
    const modulesDir = this.modulesDir;
    
    return fse.ensureDir(modulesDir)
      .then(() => this._packageHash(packageFile, deps))
      .then(hash => {
        cacheKey = hash;
        
        return this.cache.has(hash);
      })
      .then(inCache => {
        if (inCache) {
          this.logger.debug(`Restore ${ this.rootDir } cache from #${ cacheKey }`);
          
          return this.cache.restore(cacheKey, modulesDir)
            .then(() => this._runScripts(scripts));
        }
        
        this.logger.debug(`Install dependencies in ${ this.rootDir }`);
        
        return this._install(packageFile, deps)
          .then(() => this._runScripts(scripts))
          .then(() => {
            this.logger.debug(`Save ${ this.rootDir } cache to #${ cacheKey }`);
            
            return this.cache.flush()
              .then(() => this.cache.save(cacheKey, modulesDir));
          });
      });
  }
  
  /**
   * @param {array} scripts
   * 
   * @returns {Promise}
   *
   * @private
   */
  _runScripts(scripts) {
    if (scripts.length <= 0) {
      return Promise.resolve();
    }
    
    return SequentialPromise.all(scripts.map(script => {
      return () => this._runScript(script);
    }));
  }
  
  /**
   * @param {string} script
   * 
   * @returns {Promise}
   *
   * @private
   */
  _runScript(script) {
    return (new Spinner(
      `Running ${ script } script in ${ this.rootDir }`
    )).then(
      `Script ${ script } execution succeed in ${ this.rootDir }`
    ).catch(
      `Script ${ script } execution failed in ${ this.rootDir }`
    ).promise(new Promise((resolve, reject) => {
      const options = {
        cwd: this.rootDir, 
        stdio: 'ignore',
      };

      const npmRunScript = spawn('npm', [ 'run', script ], options);
      
      npmRunScript.on('close', code => {
        if (code !== 0) {          
          return reject(new Error(
            `Failed to run script ${ script } in ${ this.rootDir }.\n` +
            `To open logs type: 'open ${ this.debugFile }'`
          ));
        }
        
        resolve();
      });
    }));
  }
  
  /**
   * @param {string} packageFile
   * @param {*} additionalDeps
   *
   * @returns {Promise}
   *
   * @private
   */
  _install(packageFile, additionalDeps) {
    return fse.pathExists(packageFile)
      .then(hasPackageFile => {
        return hasPackageFile ? this._doInstall() : Promise.resolve();
      })
      .then(() => {
        const depsVector = Object.keys(additionalDeps)
          .map(depName => {
            return `${ depName }@${ additionalDeps[depName] }`;
          });

        return depsVector.length > 0 
          ? this._doInstall(depsVector) 
          : Promise.resolve();
      });
  }
  
  /**
   * @param {string} depsDebug
   *
   * @returns {string}
   * 
   * @private
   */
  _trimDepsDebugInfo(depsDebug) {
    if (depsDebug.length > 25) {
      return depsDebug.substr(0, 25) + '...';
    }
    
    return depsDebug;
  }
  
  /**
   * @param {array} deps
   *
   * @returns {Promise}
   *
   * @private
   */
  _doInstall(deps = []) {
    const depsDebug = this._trimDepsDebugInfo(deps.length > 0 ? deps.join(', ') : 'MAIN');
    
    return (new Spinner(
      `Installing dependencies in ${ this.rootDir } (${ depsDebug })`
    )).then(
      `Dependencies installation succeed in ${ this.rootDir } (${ depsDebug })`
    ).catch(
      `Dependencies installation failed in ${ this.rootDir } (${ depsDebug })`
    ).promise(new Promise((resolve, reject) => {
      const options = {
        cwd: this.rootDir, 
        stdio: 'ignore',
      };
      
      // ignore running 'npm install' scripts
      if (deps.length <= 0) {
        deps = [ '--ignore-scripts' ];
      }

      const npmInstall = spawn('npm', [ 'install', '--no-shrinkwrap' ].concat(deps), options);
      
      npmInstall.on('close', code => {
        if (code !== 0) {          
          return reject(new Error(
            `Failed to install dependencies in ${ this.rootDir }.\n` +
            `To open logs type: 'open ${ this.debugFile }'`
          ));
        }
        
        resolve();
      });
    }));
  }
  
  /**
   * @param {string} packageFile
   * @param {*} deps
   *
   * @returns {Promise}
   *
   * @private
   */
  _packageHash(packageFile, deps) {
    return fse.pathExists(packageFile)
      .then(hasPackageFile => {
        const depsHash = this._depsHash(deps);
        const packageDebug = hasPackageFile ? 'exists' : 'missing';
        
        this.logger.debug(
          `File ${ NpmModule.PACKAGE_FILE } ${ packageDebug } in ${ this.rootDir }`
        );
        
        if (!hasPackageFile) {
          return Promise.resolve(`${ depsHash }-${ NpmModule.DEFAULT_HASH }`);
        }
        
        return packageHash(packageFile)
          .then(hash => {
            return Promise.resolve(`${ depsHash }-${ hash }`);
          });
      });
  }
  
  /**
   * @param {*} deps
   *
   * @returns {string}
   *
   * @private
   */
  _depsHash(deps) {
    const normalizedDeps = {};
    
    Object.keys(deps).sort().map(key => {
      normalizedDeps[key] = deps[key];
    });
    
    return md5Hex(JSON.stringify(normalizedDeps));
  }
  
  /**
   * @returns {string}
   */
  static get DEFAULT_HASH() {
    return 'x'.repeat(32);
  }
  
  /**
   * @returns {string}
   */
  static get NPM_DEBUG_FILE() {
    return 'npm-debug.log';
  }
  
  /**
   * @returns {string}
   */
  static get PACKAGE_FILE() {
    return 'package.json';
  }
  
  /**
   * @returns {string}
   */
  static get MODULES_DIR() {
    return 'node_modules';
  }
}

module.exports = NpmModule;