/* 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( `(? 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; };