Home Reference Source

src/plugins/devServer/index.js

const https = require('https');
const http = require('http');
const path = require('path');
const opener = require('opener');
const fs = require('fs-extra');
const extend = require('extend');
const mime = require('mime');
const statuses = require('statuses');
const ProjextRollupUtils = require('../utils');
/**
 * @ignore
 */
const createHTTPSServer = https.createServer;
/**
 * @ignore
 */
const createHTTPServer = http.createServer;
/**
 * This a Rollup plugin that runs a dev server for a bundled application.
 */
class ProjextRollupDevServerPlugin {
  /**
   * @param {ProjextRollupDevServerPluginOptions} [options={}]
   * The options to customize the plugin behaviour.
   * @param {string} [name='projext-rollup-plugin-dev-server']
   * The name of the plugin's instance.
   */
  constructor(options = {}, name = 'projext-rollup-plugin-dev-server') {
    /**
     * The plugin options.
     * @type {ProjextRollupDevServerPluginOptions}
     * @access protected
     * @ignore
     */
    this._options = extend(
      true,
      {
        host: 'localhost',
        port: 8080,
        contentBase: [],
        historyApiFallback: false,
        https: null,
        open: true,
        logger: null,
        proxied: null,
        onStart: () => {},
        onStop: () => {},
      },
      options
    );
    // Normalize the received `contentBase` option into an array.
    this._options.contentBase = this._normalizeContentBase();
    /**
     * The name of the plugin's instance.
     * @type {string}
     */
    this.name = name;
    /**
     * The server URL.
     * @type {string}
     */
    this.url = this._createServerURL();
    // Set the default mime type for the server respones.
    mime.default_type = 'text/plain';
    /**
     * Validate the options and either create or assign a {@link Logger} instance for the plugin.
     * @type {Logger}
     * @access protected
     * @ignore
     */
    this._logger = ProjextRollupUtils.createLogger(this.name, this._options.logger);
    /**
     * This is the property that will hold the server instance after it gets created.
     * @type {?Object}
     * @access protected
     * @ignore
     */
    this._instance = null;
    /**
     * The list of events that the plugin will listen for in order to stop the server before
     * exiting the process.
     * @type {Array}
     * @access protected
     * @ignore
     */
    this._terminationEvents = ['SIGINT', 'SIGTERM'];
    /**
     * Whether or not the browser was already openend.
     * @type {boolean}
     * @access protected
     * @ignore
     */
    this._alreadyOpen = false;
    /**
     * The message of the error thrown when a requested file can't be found. It's on a property
     * because the plugin validates it more than once, and we don't want to have to write it
     * more than once.
     * @type {string}
     * @access protected
     * @ignore
     */
    this._NOT_FOUND_ERROR = 'ENOENT: no such file or directory';
    /**
     * The path for the plugin's favicon. The browsers usually try to fetch an app favicon by
     * requesting `/favicon.ico`, so when the plugin detects that request but there's no file, it
     * will respond with a default favicon with the Rollup logo.
     * @type {string}
     * @access protected
     * @ignore
     */
    this._defaultFaviconPath = path.join(path.dirname(__filename), 'favicon.ico');
    /**
     * @ignore
     */
    this.writeBundle = this.writeBundle.bind(this);
    /**
     * @ignore
     */
    this._handler = this._handler.bind(this);
    /**
     * @ignore
     */
    this._terminate = this._terminate.bind(this);
  }
  /**
   * Gets the plugin options
   * @return {ProjextRollupDevServerPluginOptions}
   */
  getOptions() {
    return this._options;
  }
  /**
   * Gets a _"sub plugin"_ that logs the dev server URL. The idea is to put this at the end of
   * the plugins queue so the final feedback the user gets is the URL.
   * @return {Object}
   */
  showURL() {
    return {
      writeBundle: () => {
        // A small _"timeout-hack"_ to show the message after Rollup's output
        setTimeout(() => this._logger.success(`Your app is running on ${this.url}`), 0);
      },
    };
  }
  /**
   * This is called after Rollup finishes writing the files on the file system. It checks if
   * there's an instance of the server running and if there isn't, it creates a new one.
   */
  writeBundle() {
    // Validate that there's no instance already running.
    if (!this._instance) {
      // Get the server basic options.
      const { https: httpsSettings, port } = this._options;
      // Create the server instance.
      this._instance = httpsSettings ?
        createHTTPSServer(httpsSettings, this._handler) :
        createHTTPServer(this._handler);

      // Start listening for requests.
      this._instance.listen(port);
      // Log some information messages.
      this._logger.warning(`Starting on ${this.url}`);
      this._logger.warning('waiting for Rollup...');
      // Start listening for process events that require the sever instance to be terminated.
      this._startListeningForTermination();
      // Open the browser.
      this._open();
      // Invoke the `onStart` callback.
      this._options.onStart(this);
    }
  }
  /**
   * Creates the server full URL using the specified protocol, hostname and port.
   * @return {string}
   * @access protected
   * @ignore
   */
  _createServerURL() {
    let url;
    const {
      https: useHTTPS,
      host,
      port,
      proxied,
    } = this._options;
    /**
     * If the server is being proxied and the host is different form the one on the base config,
     * build the URL using the _"proxied settings"_.
     */
    if (proxied && proxied.host !== host) {
      const proxiedProtocol = proxied.https ? 'https' : 'http';
      url = `${proxiedProtocol}://${proxied.host}`;
    } else {
      // ...otherwise, use the base config.
      const protocol = useHTTPS ? 'https' : 'http';
      url = `${protocol}://${host}:${port}`;
    }

    return url;
  }
  /**
   * Normalizes the `contentBase` option into an array.
   * @return {Array}
   * @access protected
   * @ignore
   */
  _normalizeContentBase() {
    // Define the Array that will be the option new value.
    const newContentBase = [];
    // Get the current option value.
    const { contentBase } = this._options;
    // If the option is already an Array...
    if (Array.isArray(contentBase)) {
      // If it has contents, which means the implementation overwrote the default value...
      if (contentBase.length) {
        // ...push the current items to the new Array.
        newContentBase.push(...contentBase);
      } else {
        // ...otherwise, push the current directory.
        newContentBase.push('./');
      }
    } else {
      // ...but if the option is an string, push it as the only item of the new Array.
      newContentBase.push(contentBase);
    }

    // Return the new option value.
    return newContentBase;
  }
  /**
   * This method gets called when one of the termination events the plugin listens for is emitted.
   * If stops the server, deletes the instance and exits the process.
   * @access protected
   * @ignore
   */
  _terminate() {
    if (this._instance) {
      this._instance.close();
      this._instance = null;
      this._stopListeningForTermination();
      this._options.onStop(this);
    }

    process.exit();
  }
  /**
   * This is called when the the server instance is created. It starts listening for termination
   * events that require the server to be stopped.
   * @access protected
   * @ignore
   */
  _startListeningForTermination() {
    this._terminationEvents.forEach((eventName) => {
      process.on(eventName, this._terminate);
    });
  }
  /**
   * This is called when the server is stopped. It removes the listeners for termination events.
   * @access protected
   * @ignore
   */
  _stopListeningForTermination() {
    this._terminationEvents.forEach((eventName) => {
      process.removeListener(eventName, this._terminate);
    });
  }
  /**
   * This is called when the server instance is created. It opens the browser after validating that
   * the option to do it is `true` and the browser was not already opened.
   * @access protected
   * @ignore
   */
  _open() {
    if (!this._alreadyOpen && this._options.open) {
      this._alreadyOpen = true;
      opener(this.url);
    }
  }
  /**
   * This method gets called every time the server needs to resolve a request. It validates the
   * request and tries to serve a file from the file system.
   * @param {HTTPRequest}  req The request information.
   * @param {HTTPResponse} res The response information.
   * @return {Promise<undefined,Error>}
   * @access protected
   * @ignore
   */
  _handler(req, res) {
    // Remove any query string from the URL.
    const urlPath = decodeURI(req.url.split('?').shift());
    // Get the file contents.
    return this._readFileFromContentBase(urlPath)
    // Serve the file.
    .then((contents) => this._serveFile(res, urlPath, contents))
    // In case of failure...
    .catch((error) => {
      let result = null;
      // If the file couldn't be found...
      if (error.message && error.message === this._NOT_FOUND_ERROR) {
        // If the request was for the favicon, serve the plugin's favicon.
        if (req.url === '/favicon.ico') {
          result = this._serveDefaultFavicon(res);
        } else if (this._options.historyApiFallback) {
          // If `historyApiFallback` is enabled, try to serve the `index.html`.
          result = this._serveFallback(res, urlPath);
        } else {
          // Otherwise, respond with a Not Found.
          result = this._notFound(res, urlPath);
        }
      } else {
        // If the error is unknown, respond with an Internal Error.
        result = this._internalError(res, error);
      }

      return result;
    });
  }
  /**
   * This method tries to find a file on the list of `contentBase` directories and return its
   * contents so it can be served.
   * If the method doesn't find a file on directory, it will call itself recursively with the next
   * directory until it finds it or returns a Not Found error.
   * @param {string} urlPath              The filepath received by the server.
   * @param {number} [contentBaseIndex=0] The index of the dictionary it should try on the
   *                                      `contentBase` list.
   * @return {Promise<string,Error>}
   * @access protected
   * @ignore
   */
  _readFileFromContentBase(urlPath, contentBaseIndex = 0) {
    let result;
    // Get the directory to test from the list using the received index.
    const contentBase = this._options.contentBase[contentBaseIndex];
    // If there's a directory for the received index...
    if (contentBase) {
      // Build the path to the file.
      let filepath = path.join(contentBase, urlPath);
      // If the path ends with a `/`, automatically append an `index.html` to it.
      if (filepath.endsWith('/')) {
        filepath = `${filepath}index.html`;
      }

      // If the file exists...
      if (fs.pathExistsSync(filepath)) {
        // ...set to return the promise with the file contents.
        result = fs.readFile(filepath);
      } else {
        // ...otherwise, continue to the next directory.
        result = this._readFileFromContentBase(urlPath, contentBaseIndex + 1);
      }
    } else {
      // If there are no more directories to test, set to return a Not Found error.
      result = Promise.reject(new Error(this._NOT_FOUND_ERROR));
    }

    return result;
  }
  /**
   * This is the method that actually serves a file.
   * @param {HTTPResponse} res     The response information.
   * @param {string}       urlPath The path to the requested file.
   * @param {string}       file    The contents to serve.
   * @access protected
   * @ignore
   */
  _serveFile(res, urlPath, file) {
    /**
     * If the request ended with `/`, assume that `_readFileFromContentBase` appended an
     * `index.html` and the file mime type is for HTML, otherwise, use `mime` to obtain the right
     * type.
     */
    const mimeType = urlPath.endsWith('/') ?
      'text/html' :
      mime.getType(urlPath);
    // Add the mime type to the response headers.
    res.writeHead(statuses.ok, {
      'Content-Type': mimeType,
    });
    // Send the file contents and end the response.
    res.end(file, 'utf-8');
  }
  /**
   * This method gets called when the server recived a request for `/favicon.ico` and the file
   * doesn't exist. It tries to load the plugin's favicon and serve it.
   * @param {HTTPResponse} res The response information.
   * @return {Promise<undefined,Error>}
   * @access protected
   * @ignore
   */
  _serveDefaultFavicon(res) {
    // Get the plugin's favicon file contents.
    return fs.readFile(this._defaultFaviconPath)
    // Serve it.
    .then((favicon) => {
      this._serveFile(res, '/favicon.ico', favicon);
    })
    // If something went wrong, respond with an Internal Error.
    .catch((error) => {
      this._internalError(res, error);
    });
  }
  /**
   * This method gets called when the server received a request for a path that doesn't exist and
   * `historyApiFallback` is enabled. The method will try to serve the contents of `/index.html`.
   * @param {HTTPResponse} res          The response information.
   * @param {string}       originalPath The file path originally requested.
   * @return {Promise<undefined,Error>}
   * @access protected
   * @ignore
   */
  _serveFallback(res, originalPath) {
    const urlPath = 'index.html';
    return this._readFileFromContentBase(urlPath)
    // Serve the file.
    .then((contents) => {
      this._serveFile(res, urlPath, contents);
    })
    // In case of failure...
    .catch((error) => {
      let result = null;
      // If the file couldn't be found...
      if (error.message && error.message === this._NOT_FOUND_ERROR) {
        result = this._notFound(res, originalPath);
      } else {
        // If the error is unknown, respond with an Internal Error.
        result = this._internalError(res, error);
      }

      return result;
    });
  }
  /**
   * Sends a response with a Not Found error.
   * @param {HTTPResponse} res The response information.
   * @param {string}       urlPath The path to the requested file.
   * @access protected
   * @ignore
   */
  _notFound(res, urlPath) {
    this._responsdWithError(res, statuses['not found'], urlPath);
  }
  /**
   * Sends a response with an Internal Error.
   * @param {HTTPResponse} res   The response information.
   * @param {Error|*}      error The unexpected error.
   * @access protected
   * @ignore
   */
  _internalError(res, error) {
    let message;
    // If the received error is an actual Error...
    if (error instanceof Error) {
      // ...format the error stack information.
      const stackList = error.stack.split('\n');
      stackList.shift();
      const stackText = stackList.map((line) => `  -> ${line.trim()}`).join('\n');
      // Append the stack information to the error message.
      message = `${error.message}\n\n${stackText}`;
    } else {
      // ...otherwise, just assume that the received error is the message.
      message = error;
    }
    // Serve the error.
    this._responsdWithError(res, statuses['internal server error'], message);
  }
  /**
   * Sends an error response.
   * @param {HTTPResponse} res     The response information.
   * @param {number}       status  The HTTP status for the response.
   * @param {string}       message The error message.
   * @access protected
   * @ignore
   */
  _responsdWithError(res, status, message) {
    const title = statuses[status];
    /**
     * Define the response text by prefixing the received message with the response status and
     * adding the plugin's name at the end.
     */
    const text = `${status} ${title}\n\n` +
      `${message}\n\n` +
      `${this.name}`;

    // Set the response HTTP status.
    res.writeHead(status);
    // Send the error message and end the response.
    res.end(text, 'utf-8');
  }
}
/**
 * Shorthand method to create an instance of {@link ProjextRollupDevServerPlugin}.
 * @param {ProjextRollupDevServerPluginOptions} options
 * The options to customize the plugin behaviour.
 * @param {string} name
 * The name of the plugin's instance.
 * @return {ProjextRollupDevServerPlugin}
 */
const devServer = (options, name) => new ProjextRollupDevServerPlugin(options, name);

module.exports = {
  ProjextRollupDevServerPlugin,
  devServer,
};