repo_description: The BBC's Open Source Web Application. Contributions welcome! Used on some of our biggest websites, e.g.
repo_topics: article bbc express news nextjs react reactjs typescript
code: /* eslint-disable global-require, no-console */
  /*
    A high level overview of our client-side JavaScript bundling strategy can be found here:
    https://github.com/bbc/simorgh/blob/latest/docs/JavaScript-Bundling-Strategy.md
   */
  
  const fs = require('fs');
  const crypto = require('crypto');
  const CopyWebpackPlugin = require('copy-webpack-plugin');
  const TerserPlugin = require('terser-webpack-plugin');
  const LoadablePlugin = require('@loadable/webpack-plugin');
  const webpack = require('webpack');
  const dotenv = require('dotenv');
  const DuplicatePackageCheckerPlugin = require('duplicate-package-checker-webpack-plugin');
  const { getClientEnvVars } = require('./src/clientEnvVars');
  
  const FRAMEWORK_BUNDLES = ['react', 'react-dom'];
  const TOTAL_PAGE_TYPES = fs
    .readdirSync('./src/app/pages')
    .filter(file => file.match(/[A-Z].+?Page$/)).length;
  
  const DOT_ENV_CONFIG = dotenv.config();
  
  if (DOT_ENV_CONFIG.error) {
    throw DOT_ENV_CONFIG.error;
  }
  
  module.exports = ({
    resolvePath,
    IS_PROD,
    START_DEV_SERVER,
    IS_PROD_PROFILE,
    BUNDLE_TYPE,
  }) => {
    const {
      SIMORGH_APP_ENV,
      SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN,
      SIMORGH_PUBLIC_STATIC_ASSETS_PATH,
    } = process.env;
    const APP_ENV = SIMORGH_APP_ENV || 'live';
    const IS_LEGACY_WEB = BUNDLE_TYPE === 'legacy';
    const webpackDevServerPort = 1124; // arbitrarily picked. Has to be different to server port (7080)
    const prodPublicPath =
      SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN + SIMORGH_PUBLIC_STATIC_ASSETS_PATH;
  
    const clientConfig = {
      name: BUNDLE_TYPE,
      target: ['web', IS_LEGACY_WEB ? 'es5' : 'es2017'], // compile for browser environment
      entry: START_DEV_SERVER
        ? ['webpack/hot/only-dev-server', './src/client']
        : [
            IS_LEGACY_WEB ? './src/poly/legacy.js' : './src/poly/modern.js',
            './src/client',
          ],
      devServer: {
        host: 'localhost',
        port: webpackDevServerPort,
        historyApiFallback: true,
        hot: true,
        headers: {
          'Access-Control-Allow-Origin': '*',
        },
        allowedHosts: 'all',
      },
      resolve: {
        fallback: {
          // Override webpacks default handling for these as they arnt availible on the client.
          fs: false,
          crypto: false,
          stream: require.resolve('stream-browserify'),
          https: false,
          http: false,
          tls: false,
        },
      },
      experiments: {
        outputModule: !IS_LEGACY_WEB,
      },
      output: {
        module: !IS_LEGACY_WEB,
        path: resolvePath('build/public'),
        /**
         * Need unhashed client bundle when running dev server.
         * Though we're no longer using Razzle, there is a good explanation here:
         * https://github.com/jaredpalmer/razzle/tree/master/packages/create-razzle-app/templates/default#how-razzle-works-the-secret-sauce
         */
        filename: START_DEV_SERVER
          ? `static/js/${BUNDLE_TYPE}.[name].js`
          : `static/js/${BUNDLE_TYPE}.[name].[chunkhash:8].js`, // hash based on the contents of the file
        // need full URL for dev server & HMR: https://github.com/webpack/docs/wiki/webpack-dev-server#combining-with-an-existing-server
        publicPath: START_DEV_SERVER
          ? `http://localhost:${webpackDevServerPort}/`
          : prodPublicPath,
      },
      optimization: {
        moduleIds: 'deterministic',
        minimizer: [
          new TerserPlugin({
            terserOptions: {
              // These options are enabled in production profile builds only and
              // prevent the discarding or mangling of class and function names.
              ecma: IS_LEGACY_WEB ? 5 : 2017,
              keep_classnames: IS_PROD_PROFILE,
              keep_fnames: IS_PROD_PROFILE,
              compress: {
                passes: 2,
              },
            },
          }),
        ],
        // specify min/max file sizes for each JS chunk for optimal performance
        splitChunks: {
          chunks: 'all',
          automaticNameDelimiter: '-',
          maxSize: 245760, // 240kb
          cacheGroups: {
            default: false,
            defaultVendors: false,
            framework: {
              name: 'framework',
              chunks: 'all',
              // This regex ignores nested copies of framework libraries so they're bundled with their issuer.
              test: new RegExp(
                `(?<!node_modules.*)[\\\\/]node_modules[\\\\/](${FRAMEWORK_BUNDLES.join(
                  `|`,
                )})[\\\\/]`,
              ),
              priority: 40,
              // Don't let webpack eliminate this chunk (prevents this chunk from becoming a part of the commons chunk)
              enforce: true,
            },
            commons: {
              name: 'commons',
              // if a chunk is used on all pages we put it in commons
              minChunks: TOTAL_PAGE_TYPES,
              priority: 20,
            },
            lib: {
              // if a module is bigger than 160kb from node_modules we make a separate chunk for it
              test(module) {
                return (
                  module.size() > 160000 &&
                  /node_modules[/\\]/.test(module.identifier())
                );
              },
              name(module) {
                const rawRequest =
                  module.rawRequest &&
                  module.rawRequest.replace(/^@(\w+)[/\\]/, '$1-');
                if (rawRequest) return `${rawRequest}-lib`;
  
                const identifier = module.identifier();
                const trimmedIdentifier = /(?:^|[/\\])node_modules[/\\](.*)/.exec(
                  identifier,
                );
                const processedIdentifier =
                  trimmedIdentifier &&
                  trimmedIdentifier[1].replace(/^@(\w+)[/\\]/, '$1-');
  
                return `${processedIdentifier || identifier}-lib`;
              },
              priority: 30,
              minChunks: 1,
              reuseExistingChunk: true,
            },
            shared: {
              name(module, chunks) {
                const chunkName = chunks.map(({ name }) => name).join('-');
                const cryptoName = crypto
                  .createHash('sha1')
                  .update(chunkName)
                  .digest('base64');
  
                return [
                  'shared',
                  chunkName === 'russian-ukrainian' ? chunkName : cryptoName,
                ]
                  .join('-')
                  .replace(/[=+/]/g, '');
              },
              priority: 10,
              minChunks: 2,
              reuseExistingChunk: true,
            },
          },
          // Keep maximum initial requests to 25
          maxInitialRequests: 25,
          // A chunk should be at least 20kb before using splitChunks
          minSize: 20000,
        },
      },
      node: {
        __filename: 'mock',
      },
      plugins: [
        // copy static files otherwise untouched by Webpack, e.g. favicon
        ...(BUNDLE_TYPE === 'modern'
          ? [
              new CopyWebpackPlugin({
                patterns: [{ from: 'public' }],
              }),
            ]
          : []),
        new DuplicatePackageCheckerPlugin({
          // Emit compilation warning or error? (Default: `false`)
          emitError: true,
          // Display full duplicates information? (Default: `false`)
          verbose: true,
          exclude({ name, path }) {
            return (
              name === '@babel/runtime' &&
              [
                './~/@emotion/react/~/@babel/runtime',
                './~/@loadable/component/~/@babel/runtime',
                './~/react-router-dom/~/@babel/runtime',
              ].includes(path)
            );
          },
        }),
        /*
         * webpack 5 does no longer includes a polyfill for the Node.js process variable in
         * frontend code. webpack advise to avoid using it in the frontend code however the
         * following plugin will enable the process variable in frontend code until we find
         * an alternative for this sort of thing.
         */
        new webpack.ProvidePlugin({
          process: 'process/browser',
        }),
        new webpack.DefinePlugin({
          'process.env': getClientEnvVars(DOT_ENV_CONFIG),
        }),
        /*
         * This replaces calls to logger.node.js with logger.web.js, a client
         * side replacement, when building the bundle code for the client.
         * This avoids the weight of winston being included in the bundles and
         * issues arising from it trying to the use the file system
         */
        new webpack.NormalModuleReplacementPlugin(
          /(.*)logger.node(\.*)/,
          resource => {
            // eslint-disable-next-line no-param-reassign
            resource.request = resource.request.replace(
              /logger.node/,
              `logger.web`,
            );
          },
        ),
        /*
         * Exclude all moment locales so they can be included within service bundles
         */
        new webpack.IgnorePlugin({
          resourceRegExp: /^\.\/locale$/,
          contextRegExp: /moment$/,
        }),
        // keep track of the generated chunks
        // this determines what scripts get put in the footer of the page
        new LoadablePlugin({
          filename: `${BUNDLE_TYPE}-loadable-stats-${APP_ENV}.json`,
          writeToDisk: true,
        }),
      ],
    };
  
    if (IS_PROD) {
      const CompressionPlugin = require('compression-webpack-plugin');
  
      clientConfig.plugins.push(
        /**
         * Compresses Webpack assets with gzip Content-Encoding.
         * https://github.com/webpack-contrib/compression-webpack-plugin
         */
        new CompressionPlugin({
          algorithm: 'gzip',
          test: /\.js$/,
          threshold: 10240,
          minRatio: 0.8,
        }),
      );
    }
    if (IS_PROD) {
      const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); // eslint-disable-line
      /**
       * Visualize size of webpack output files with an interactive zoomable treemap.
       * https://github.com/webpack-contrib/webpack-bundle-analyzer
       */
      clientConfig.plugins.push(
        new BundleAnalyzerPlugin({
          analyzerMode: 'static',
          defaultSizes: 'gzip',
          generateStatsFile: true,
          openAnalyzer: false,
          reportFilename: `../../reports/${BUNDLE_TYPE}.webpackBundleReport.html`,
          statsFilename: `../../reports/${BUNDLE_TYPE}.webpackBundleReport.json`,
        }),
      );
    }
    return clientConfig;
  };
  