Home Reference Source

src/services/server/middleware.js

const path = require('path');
const rollup = require('rollup');
const fs = require('fs-extra');
const mime = require('mime');
const { provider } = require('jimple');
const { deferred } = require('wootils/shared');
/**
 * This service creates, configures and manages an Express-like middleware for bundling Rollup.
 */
class RollupMiddleware {
  /**
   * Class constructor.
   * @param {Logger}              appLogger           To log information messages.
   * @param {Events}              events              To reduce the middlewares configuration.
   * @param {Targets}             targets             To get targets information.
   * @param {RollupConfiguration} rollupConfiguration To get a target Rollup configuration.
   */
  constructor(appLogger, events, targets, rollupConfiguration) {
    /**
     * A local reference for the `appLogger` service.
     * @type {Logger}
     */
    this.appLogger = appLogger;
    /**
     * A local reference for the `events` service.
     * @type {Events}
     */
    this.events = events;
    /**
     * A local reference for the `targets` service.
     * @type {Targets}
     */
    this.targets = targets;
    /**
     * A local reference for the `rollupConfiguration` service.
     * @type {RollupConfiguration}
     */
    this.rollupConfiguration = rollupConfiguration;
    // Set the default mime type for the `mime` module.
    mime.default_type = 'text/plain';
    /**
     * The instance of the Rollup watcher.
     * @type {?Object}
     */
    this._watcher = null;
    /**
     * A dictionary of directories the middleware use as root for their file system.
     * It uses the targets names as the keys.
     * @type {Object}
     * @ignore
     * @access protected
     */
    this._directories = {};
    /**
     * A dictionary of flags that indicate if a target middleware finished bundling and if the
     * file system can be accessed..
     * The idea is that file system can't be used until Rollup finishes its process.
     * It uses the targets names as the keys.
     * @type {Object}
     * @ignore
     * @access protected
     */
    this._fileSystemsReady = {};
    /**
     * A dictionary of deferred promises the service uses to return when asked for a file system
     * while its Rollup hasn't finished compiling.
     * It uses the targets names as the keys.
     * @type {Object}
     * @ignore
     * @access protected
     */
    this._fileSystemsDeferreds = {};
  }
  /**
   * Generate the middleware for a given target.
   * @param {string} targetToBuild The name of the target that will be builded on the middleware.
   * @param {string} targetToServe The name of the target that will implement the middleware.
   *                               When the other target is builded, it will assume that is on the
   *                               distribution directory, and if the target serving it is being
   *                               executed from the source directory it won't be able to use the
   *                               file system without hardcoding some relatives paths from the
   *                               build to the source; to avoid that, the method gets the build
   *                               path of this target, so when using `getDirectory()`, it
   *                               will think they are both on the distribution directory and the
   *                               paths can be created relative to that.
   * @return {MiddlewareInformation}
   */
  generate(targetToBuild, targetToServe) {
    // Get the target information.
    const target = this.targets.getTarget(targetToBuild);
    // Set the target working directory as the target that serves it build folder
    this._directories[target.name] = this.targets.getTarget(targetToServe).paths.build;
    // Set the flag indicating the file system is not ready.
    this._fileSystemsReady[target.name] = false;
    // Create the deferred promise for when the file system is ready.
    this._fileSystemsDeferreds[target.name] = deferred();
    // Define the functions to get the file system promise and the middleware root directory.
    const getDirectory = () => this._directories[target.name];
    const getFileSystem = () => this._fileSystem(target);
    // Define the function to get the target middleware.
    const middleware = () => this._middleware(target);

    return {
      getDirectory,
      getFileSystem,
      middleware,
    };
  }
  /**
   * Gets access to file system. This returns a promise so the file system can't be accessed until
   * Rollup finishes the bundling.
   * @param {Target} target The target owner of the middleware.
   * @return {Promise<FileSystem,Error>}
   * @access protected
   * @ignore
   */
  _fileSystem(target) {
    return this._fileSystemsReady[target.name] ?
      Promise.resolve(fs) :
      this._fileSystemsDeferreds[target.name].promise;
  }
  /**
   * Gets the middleware the will serve the bundled files.
   * @param {Target} target The information of the target to bundle.
   * @return {Middleware}
   * @access protected
   * @ignore
   */
  _middleware(target) {
    // Bundle the target.
    this._compile(target);
    return (req, res, next) => {
      // If the file system is ready...
      if (this._fileSystemsReady[target.name]) {
        // Remove any query string from the requested path.
        const urlPath = decodeURI(req.url.split('?').shift());
        // Get the file absolute path.
        const filepath = path.join(target.paths.build, urlPath);
        // If the file exists...
        if (fs.pathExistsSync(filepath)) {
          // Set the type header with the file mime type.
          res.setHeader('Content-Type', mime.getType(filepath));
          // Try to respond with the file contents.
          res.sendFile(filepath, (error) => {
            /**
             * If sending the file fails, move to the next middleware with an error, otherwise,
             * end the response.
             */
            if (error) {
              next(error);
            } else {
              res.end();
            }
          });
        } else {
          // If the file doesn't exist, move to the next middleware.
          next();
        }
      } else {
        /**
         * If the file system is not ready, log a message, wait for the file system and then
         * move to the next middleware.
         */
        this.appLogger.warning(`Request on hold until the build is ready: ${req.originalUrl}`);
        this._fileSystem(target)
        .then(() => {
          next();
        });
      }
    };
  }
  /**
   * Compile a target and watch for changes.
   * @param {Target} target The target information.
   * @access protected
   * @ignore
   */
  _compile(target) {
    // Make sure there's not an instance already.
    if (!this._watcher) {
      // Get the Rollup configuration for the target.
      const configuration = this.rollupConfiguration.getConfig(target, 'development');
      // Create the watcher instance.
      this._watcher = rollup.watch(configuration);
      // Listen for the watcher events.
      this._watcher.on('event', (event) => {
        // eslint-disable-next-line default-case
        switch (event.code) {
        case 'START':
          this._onStart(target);
          break;
        case 'END':
          this._onFinish(target);
          break;
        case 'ERROR':
          this._onError(target, event);
          break;
        case 'FATAL':
          this._onError(target, event, true);
          break;
        }
      });
    }
  }
  /**
   * This is called when Rollup starts the bundling process. It logs an information message and
   * resets the file system flag for a target.
   * @param {Target} target The target information.
   * @access protected
   * @ignore
   */
  _onStart(target) {
    setTimeout(() => {
      this.appLogger.warning('Creating the Rollup build...');
    }, 1);
    if (!this._fileSystemsDeferreds[target.name]) {
      this._fileSystemsReady[target.name] = false;
      this._fileSystemsDeferreds[target.name] = deferred();
    }
  }
  /**
   * This is called when Rollup finishes the bundling process. It logs an information message
   * and resolves the target file system promise.
   * @param {Target} target The target information.
   * @access protected
   * @ignore
   */
  _onFinish(target) {
    setTimeout(() => {
      this.appLogger.success('The Rollup build is ready');
    }, 1);
    this._fileSystemsReady[target.name] = true;
    this._fileSystemsDeferreds[target.name].resolve(fs);
    delete this._fileSystemsDeferreds[target.name];
  }
  /**
   * This is called when Rollup finds an error on the build process. It just logs an error message.
   * @access protected
   * @ignore
   */
  _onError(target, event, fatal = false) {
    const message = fatal ?
      'The Rollup build can\'t be created' :
      'There was a problem while creating the Rollup build';
    this.appLogger.error(message);
    if (event.error) {
      this.appLogger.error(event.error);
    }
    if (fatal) {
      process.exit(1);
    }
  }
}
/**
 * The service provider that once registered on the app container will set an instance of
 * `RollupMiddleware` as the `rollupMiddleware` service.
 * @example
 * // Register it on the container
 * container.register(rollupMiddleware);
 * // Getting access to the service instance
 * const rollupMiddleware = container.get('rollupMiddleware');
 * @type {Provider}
 */
const rollupMiddleware = provider((app) => {
  app.set('rollupMiddleware', () => new RollupMiddleware(
    app.get('appLogger'),
    app.get('events'),
    app.get('targets'),
    app.get('rollupConfiguration')
  ));
});

module.exports = {
  RollupMiddleware,
  rollupMiddleware,
};