webpack.config.client.js
/* 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 { DuplicatesPlugin } = require('inspectpack/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,
},
}),
],
// 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
new CopyWebpackPlugin({
patterns: [{ from: 'public' }],
}),
new DuplicatesPlugin({
// Emit compilation warning or error? (Default: `false`)
emitErrors: true,
// Display full duplicates information? (Default: `false`)
verbose: true,
}),
/*
* 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;
};