Home Reference Source

src/services/configurations/browserDevelopmentConfiguration.js

/* eslint-disable complexity */
const path = require('path');
const ObjectUtils = require('wootils/shared/objectUtils');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const ExtraWatchWebpackPlugin = require('extra-watch-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const {
  NoEmitOnErrorsPlugin,
  HotModuleReplacementPlugin,
  NamedModulesPlugin,
} = require('webpack');
const { provider } = require('jimple');
const ConfigurationFile = require('../../abstracts/configurationFile');
const {
  ProjextWebpackOpenDevServer,
  ProjextWebpackRuntimeDefinitions,
} = require('../../plugins');
/**
 * Creates the specifics of a Webpack configuration for a browser target development build.
 * @extends {ConfigurationFile}
 */
class WebpackBrowserDevelopmentConfiguration extends ConfigurationFile {
  /**
   * Class constructor.
   * @param {Logger}                   appLogger                To send to the dev server plugin
   *                                                            in order to log its events.
   * @param {Events}                   events                   To reduce the configuration.
   * @param {PathUtils}                pathUtils                Required by `ConfigurationFile`
   *                                                            in order to build the path to the
   *                                                            overwrite file.
   * @param {TargetsHTML}              targetsHTML              The service in charge of generating
   *                                                            a default HTML file in case the
   *                                                            target doesn't have one.
   * @param {WebpackBaseConfiguration} webpackBaseConfiguration The configuration this one will
   *                                                            extend.
   * @param {WebpackPluginInfo}        webpackPluginInfo        To get the name of the plugin and
   *                                                            use it on the webpack hook that
   *                                                            logs the dev server URL when it
   *                                                            finishes bundling.
   */
  constructor(
    appLogger,
    events,
    pathUtils,
    targetsHTML,
    webpackBaseConfiguration,
    webpackPluginInfo
  ) {
    super(
      pathUtils,
      [
        'config/webpack/browser.development.config.js',
        'config/webpack/browser.config.js',
      ],
      true,
      webpackBaseConfiguration
    );
    /**
     * 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 `targetsHTML` service.
     * @type {TargetsHTML}
     */
    this.targetsHTML = targetsHTML;
    /**
     * A local reference for the plugin information.
     * @type {WebpackPluginInfo}
     */
    this.webpackPluginInfo = webpackPluginInfo;
  }
  /**
   * Create the configuration with the `entry`, the `output` and the plugins specifics for a
   * browser target development build. It also checks if it should enable source map and the
   * dev server based on the target information.
   * This method uses the reducer events `webpack-browser-development-configuration` and
   * `webpack-browser-configuration`. It sends the configuration, the received `params` and
   * expects a configuration on return.
   * @param {WebpackConfigurationParams} params A dictionary generated by the top service building
   *                                            the configuration and that includes things like the
   *                                            target information, its entry settings, output
   *                                            paths, etc.
   * @return {object}
   */
  createConfig(params) {
    const {
      definitions,
      copy,
      entry,
      target,
      output,
      additionalWatch,
      analyze,
    } = params;
    // Define the basic stuff: entry, output and mode.
    const config = {
      entry: ObjectUtils.copy(entry),
      output: {
        path: `./${target.folders.build}`,
        filename: output.js,
        chunkFilename: output.jsChunks,
        publicPath: '/',
      },
      mode: 'development',
    };
    // If the target has source maps enabled...
    if (target.sourceMap.development) {
      // ...configure the devtool
      config.devtool = 'source-map';
    }
    // Setup the plugins.
    config.plugins = [
      // To automatically inject the `script` tag on the target `html` file.
      new HtmlWebpackPlugin(Object.assign({}, target.html, {
        template: this.targetsHTML.getFilepath(target, false, 'development'),
        inject: 'body',
      })),
      // To add the `async` attribute to the  `script` tag.
      new ScriptExtHtmlWebpackPlugin({
        defaultAttribute: 'async',
      }),
      // If the target uses hot replacement, add the plugin.
      ...(target.hot ? [new NamedModulesPlugin(), new HotModuleReplacementPlugin()] : []),
      // To avoid pushing assets with errors.
      new NoEmitOnErrorsPlugin(),
      // To add the _'browser env variables'_.
      new ProjextWebpackRuntimeDefinitions(
        Object.keys(entry).reduce(
          (current, key) => [...current, ...entry[key].filter((file) => path.isAbsolute(file))],
          []
        ),
        definitions
      ),
      // To optimize the SCSS and remove repeated declarations.
      new OptimizeCssAssetsPlugin(),
      // Copy the files the target specified on its settings.
      new CopyWebpackPlugin(copy),
      /**
       * If the target doesn't inject the styles on runtime, add the plugin to push them all on
       * a single file.
       */
      ...(
        target.css.inject ?
          [] :
          [new MiniCssExtractPlugin({
            filename: output.css,
          })]
      ),
      // If there are additionals files to watch, add the plugin for it.
      ...(
        additionalWatch.length ?
          [new ExtraWatchWebpackPlugin({ files: additionalWatch })] :
          []
      ),
      // If the the bundle should be analyzed, add the plugin for it.
      ...(
        analyze ?
          [new BundleAnalyzerPlugin()] :
          []
      ),
    ];
    // Define a list of extra entries that may be need depending on the target HMR configuration.
    const hotEntries = [];
    // If the target needs to run on development...
    if (!analyze && target.runOnDevelopment) {
      const devServerConfig = this._normalizeTargetDevServerSettings(target);
      // Add the dev server information to the configuration.
      config.devServer = {
        port: devServerConfig.port,
        inline: !!devServerConfig.reload,
        open: false,
        historyApiFallback: devServerConfig.historyApiFallback,
      };
      // If the configuration has a custom host, set it.
      if (devServerConfig.host !== 'localhost') {
        config.devServer.host = devServerConfig.host;
      }
      // If there are SSL files, set them on the server.
      if (devServerConfig.ssl) {
        config.devServer.https = {
          key: devServerConfig.ssl.key,
          cert: devServerConfig.ssl.cert,
          ca: devServerConfig.ssl.ca,
        };
      }
      // If the server is being proxied, add the public host.
      if (devServerConfig.proxied) {
        config.devServer.public = devServerConfig.proxied.host;
      }
      // If the target will run with the dev server and it requires HMR...
      if (target.hot) {
        // Disable the `inline` mode.
        config.devServer.inline = false;
        // Set the public path to `/`, as required by HMR.
        config.devServer.publicPath = '/';
        // Enable the dev server `hot` setting.
        config.devServer.hot = true;
        // Push the required entries to enable HMR on the dev server.
        hotEntries.push(...[
          `webpack-dev-server/client?${devServerConfig.url}`,
          'webpack/hot/only-dev-server',
        ]);
      }
      // Push the plugin that logs the dev server statuses and opens the browser.
      config.plugins.push(new ProjextWebpackOpenDevServer(
        (devServerConfig.proxied ? devServerConfig.proxied.url : devServerConfig.url),
        {
          logger: this.appLogger,
          openBrowser: devServerConfig.open,
        }
      ));
    } else if (target.hot) {
      /**
       * If the target requires HMR but is not running with the dev server, it means that there's
       * an Express or Jimpex target that implements the `webpack-hot-middleware`, so we push it
       * required entry to the list.
       */
      hotEntries.push('webpack-hot-middleware/client?reload=true');
    } else if (target.watch.development) {
      /**
       * If the target is not running nor it requires HMR (which means is not being served either),
       * and the watch parameter is `true`, enable the watch mode.
       */
      config.watch = true;
    }
    // If there are entries for HMR...
    if (hotEntries.length) {
      // Get target entry name.
      const [entryName] = Object.keys(entry);
      // Get the list of entries for the target.
      const entries = config.entry[entryName];
      // and push all the _"hot entries"_ on top of the existing entries.
      entries.unshift(...hotEntries);
    }

    // Reduce the configuration
    return this.events.reduce(
      [
        'webpack-browser-development-configuration',
        'webpack-browser-configuration',
      ],
      config,
      params
    );
  }
  /**
   * Check a target dev server settings in order to validate those that needs to be removed or
   * completed with their default values.
   * @param {Target} target The target information.
   * @return {TargetDevServerSettings}
   * @access protected
   * @ignore
   */
  _normalizeTargetDevServerSettings(target) {
    // Get a new copy of the config to work with.
    const config = ObjectUtils.copy(target.devServer);
    /**
     * Set a flag to know if at least one SSL file was sent.
     * This flag is also used when reading the `proxied` settings to determine the default
     * behaviour of `proxied.https`.
     */
    let hasASSLFile = false;
    // Loop all the SSL files...
    Object.keys(config.ssl).forEach((name) => {
      const file = config.ssl[name];
      // If there's an actual path...
      if (typeof file === 'string') {
        // ...set the flag to `true`.
        hasASSLFile = true;
        // Generate the path to the file.
        config.ssl[name] = this.pathUtils.join(file);
      }
    });
    // If no SSL file was sent, just remove the settings.
    if (!hasASSLFile) {
      delete config.ssl;
    }
    /**
     * Define whether to build a proxied URL for the plugin that opens the browser or not. The
     * reason for this is that when the server is proxied but the host is not defined, it will use
     * the dev server host, and in that case, it should include the port too, something that
     * wouldn't be necessary when the proxied host is specified.
     */
    let buildProxiedURL = true;
    // If the server is being proxied...
    if (config.proxied.enabled) {
      // ...if no `host` was specified, use the one defined for the server.
      if (config.proxied.host === null) {
        config.proxied.host = config.host;
        buildProxiedURL = false;
      }
      // If no `https` option was specified, set it to `true` if at least one SSL file was sent.
      if (config.proxied.https === null) {
        config.proxied.https = hasASSLFile;
      }
      // If a custom proxied host was specified, build the new URL.
      if (buildProxiedURL) {
        // Build the proxied URL.
        const proxiedProtocol = config.proxied.https ? 'https' : 'http';
        config.proxied.url = `${proxiedProtocol}://${config.proxied.host}`;
      }
    } else {
      // ...otherwise, just remove the setting.
      delete config.proxied;
    }

    const protocol = config.ssl ? 'https' : 'http';
    config.url = `${protocol}://${config.host}:${config.port}`;
    /**
     * If the server is proxied, but without a custom host, copy the dev server URL into the
     * proxied settings.
     */
    if (config.proxied && !buildProxiedURL) {
      config.proxied.url = config.url;
    }

    return config;
  }
}
/**
 * The service provider that once registered on the app container will set an instance of
 * `WebpackBrowserDevelopmentConfiguration` as the `webpackBrowserDevelopmentConfiguration` service.
 * @example
 * // Register it on the container
 * container.register(webpackBrowserDevelopmentConfiguration);
 * // Getting access to the service instance
 * const webpackBrowserDevConfig = container.get('webpackBrowserDevelopmentConfiguration');
 * @type {Provider}
 */
const webpackBrowserDevelopmentConfiguration = provider((app) => {
  app.set(
    'webpackBrowserDevelopmentConfiguration',
    () => new WebpackBrowserDevelopmentConfiguration(
      app.get('appLogger'),
      app.get('events'),
      app.get('pathUtils'),
      app.get('targetsHTML'),
      app.get('webpackBaseConfiguration'),
      app.get('webpackPluginInfo')
    )
  );
});

module.exports = {
  WebpackBrowserDevelopmentConfiguration,
  webpackBrowserDevelopmentConfiguration,
};