import { default as AssetsPlugin } from 'assets-webpack-plugin'; import { default as ExtractTextPlugin } from 'extract-text-webpack-plugin'; import webpackNodeExternals from 'webpack-node-externals'; import { resolve as pathResolve } from 'path'; import { resolve as urlResolve } from 'url'; import * as fs from 'fs-extra'; import webpack from 'webpack'; // tslint:disable-next-line import-name import UglifyJsPlugin from 'uglifyjs-webpack-plugin'; import { getProjectPath, getBuildPath, getSourcePath, getConfigPath, } from '@erect/core/paths'; import { Log } from '@spine/logger'; import { default as ForkTsCheckerWebpackPlugin } from 'fork-ts-checker-webpack-plugin'; import { ifElse } from '@erect/core/shared/utils/logic'; import { removeNil } from '@erect/core/shared/utils/arrays'; import { bundleAssetHref } from '@erect/core/shared/uri'; import { config, AppClientBundle, AppBundle, Bundle } from '@erect/core/config'; import { happyPackPlugin } from '../utils/index'; import { webpackConfigFactoryHook } from '../hooks/webpackConfigFactory'; import { babelConfigHook } from '../hooks/babelConfig'; import { babelConfigPresetsHook } from '../hooks/babelConfigPresets'; import { babelConfigPluginsHook } from '../hooks/babelConfigPlugins'; import { webpackNodeExternalsWhitelistHook } from '../hooks/webpackNodeExternalsWhitelist'; import { createBuildEnv } from './createBuildEnv'; import { BuildOptionsHookFilterValue } from '../hooks/buildOptions'; import './plugins/offlineServiceWorker'; const clientDevServerHost = config.host.replace(/(\:[0-9]+)?$/, `:${config.clientDevServerPort}`); export interface WebpackConfigFilterHandler { (config: webpack.Configuration): webpack.Configuration; } export interface WebpackFactory { register(handler: WebpackConfigFilterHandler): this; compile(): webpack.Compiler; log: Log; } export interface BundleCaller { (target: 'client'): AppClientBundle; (target: 'server'): AppBundle; (target: string): Bundle; } export const getBundle: BundleCaller = ((target: string) => { if (target === 'client') { return config.bundles.client; } if (target === 'server') { return config.bundles.server; } if (config.additionalNodeBundles[target] == null) { throw new Error(`No additional bundle with name "${target}"`); } return config.additionalNodeBundles[target]; }); export function webpackConfigEnv(buildOptions: BuildOptionsHookFilterValue) { const { target, development = false, } = buildOptions; const isClient = target === 'client'; const isServer = target === 'server'; const isNode = !isClient; return createBuildEnv({ buildOptions }); } /** * Generates a webpack configuration for the target configuration. * * This function has been configured to support one "client/web" bundle, and any * number of additional "node" bundles (el.g. our "server"). You can define * additional node bundles by editing the project confuguration. * * @param {Object} buildOptions - The build options. * @param {target} buildOptions.target - The bundle target (el.g 'clinet' || 'server'). * @param {target} buildOptions.optimize - Build an optimised version of the bundle? * * @return {Object} The webpack configuration. */ export function webpackConfigFactory(buildOptions: BuildOptionsHookFilterValue, log: Log) { const { target, development = false, optimize = false, } = buildOptions; const bundleConfig = target === 'client' || target === 'server' ? config.bundles[target] : config.additionalNodeBundles[target]; let webpackTsCheckPlugin: ForkTsCheckerWebpackPlugin | void; const { enabled: webpackTsCheckPluginEnabled, ...webpackTsCheckerPluginOptions } = bundleConfig.webpackTsCheckerPlugin; if (target === 'client' && webpackTsCheckPluginEnabled) { webpackTsCheckPlugin = new ForkTsCheckerWebpackPlugin({ ...webpackTsCheckerPluginOptions, logger: log.logger.child('typescript'), }); } const extraAliases: any = {}; const isDev = development; const isProd = !isDev; const isClient = target === 'client'; const isServer = target === 'server'; const isNode = !isClient; // Preconfigure some ifElse helper instnaces. See the util docs for more // information on how this util works. const ifDev = ifElse(isDev); const ifProd = ifElse(isProd); const ifNode = ifElse(isNode); const ifClient = ifElse(isClient); const ifDevClient = ifElse(isDev && isClient); const ifProdClient = ifElse(isProd && isClient); log.info( `Creating ${ isProd ? 'an optimised' : 'a development' } bundle configuration for the "${target}"`, ); const includeSrcPaths: (string | void | null)[] = []; const isSourceProject = getProjectPath() === pathResolve(__dirname, '../../..'); const ifSourceProject = ifElse(isSourceProject); if (isSourceProject) { extraAliases['@erect/core'] = getProjectPath(); extraAliases['@erect/core/bootstrap'] = pathResolve(getProjectPath(), 'bootstrap'); extraAliases['@erect/static'] = pathResolve(getProjectPath(), 'modules/static'); extraAliases['@erect/cli'] = pathResolve(getProjectPath(), 'modules/cli'); if (isNode) { extraAliases['@erect/server'] = pathResolve(getProjectPath(), 'modules/server'); } if (isClient) { extraAliases['@erect/client'] = pathResolve(getProjectPath(), 'modules/client'); } includeSrcPaths.push(...removeNil([ './shared', './config', './bootstrap', './modules', ])); } if (!bundleConfig) { throw new Error(`No bundle configuration exists for target: ${target}`); } const clientBundlePublicPath = development ? ( // As we run a seperate development server for our client and server // bundles we need to use an absolute http path for the public path. urlResolve(`//${clientDevServerHost}${config.basePath}`, config.clientBundleAssets.webPath) ) : ( // Otherwise we expect our bundled client to be served from this path. bundleAssetHref() ); const entries: { [chunkName: string]: string[] } = {}; const resolveEntry = (file: string) => { if (file.match(/^[\.|\/]/)) { return pathResolve(getSourcePath(), file); } return file; }; if (target === 'client' || target === 'server') { const bundleConfig = config.bundles[target]; const polyfillEntry = removeNil([ ifProdClient( config.includeSourceMapsForOptimisedClientBundle ? '@erect/client/sourceMapCheck' : undefined, ), // We are using polyfill.io instead of the very heavy babel-polyfill. // Therefore we need to add the regenerator-runtime as polyfill.io // doesn't support this. 'regenerator-runtime/runtime', // Extends hot reloading with the ability to hot path route files. // This should always be at the top of your entries list. Only put // polyfills above it. // ifDevClient('react-hot-loader/patch'), // Required to support hot reloading of our client. (development && isClient ? `webpack-hot-middleware/client?reload=true&path=//${clientDevServerHost}/__webpack_hmr` : undefined ), ...bundleConfig.polyfillEntry.map(resolveEntry), ]); const vendorEntry = removeNil([ '@erect/core', getConfigPath(), ifNode('@erect/server'), ifClient('@erect/client'), ...bundleConfig.vendorEntry.map(resolveEntry), ]); const appEntry = removeNil([ ...bundleConfig.entry.map(resolveEntry), ]); const bootstrapEntry = removeNil([ ...bundleConfig.bootstrapEntry.map(resolveEntry), ]); if (bundleConfig.entry.length === 0) { throw new Error(`Make sure you set at least one entry point in \`config.bundles.${target}.entry\``); } if (target === 'client') { const clientBundleConfig = config.bundles.client; if (polyfillEntry.length > 0) { entries.polyfill = polyfillEntry; } if (vendorEntry.length > 0) { entries.vendor = vendorEntry; } entries.app = appEntry; if (bootstrapEntry.length > 0) { entries.bootstrap = bootstrapEntry; } const asyncChunkNames = Object.keys(clientBundleConfig.asyncChunkEntries); if (asyncChunkNames.length) { for (const chunkName of asyncChunkNames) { const chunkEntries = removeNil( clientBundleConfig.asyncChunkEntries[chunkName].map(resolveEntry) ); if (entries.length) { if (!entries.hasOwnProperty(chunkName)) { entries[chunkName] = []; } entries[chunkName].push(...chunkEntries); } } } } else { entries.index = [ ...polyfillEntry, ...vendorEntry, ...appEntry, ...bootstrapEntry, ]; } } else { entries.index = bundleConfig.entry.map(resolveEntry); if (bundleConfig.entry.length === 0) { throw new Error(`Make sure you set at least one entry point in \`config.additionalBundles.${target}.entry\``); } } const babelConfig = babelConfigHook.filter({ // We need to ensure that we do this otherwise the babelrc will // get interpretted and for the current configuration this will mean // that it will kill our webpack treeshaking feature as the modules // transpilation has not been disabled within in. babelrc: false, passPerPreset: true, comments: true, presets: babelConfigPresetsHook.filter(removeNil([ // '@babel/typescript', ifClient(['@babel/env', { modules: 'commonjs', targets: { browsers: ['last 2 versions'] }, useBuiltIns: 'usage' }]), ifNode(['@babel/env', { modules: 'commonjs', targets: { node: 'current', }, useBuiltIns: 'usage' }]), ]), { target, optimize, development, }), plugins: babelConfigPluginsHook.filter(removeNil([ ifClient(['@babel/transform-runtime', {}]), // Required to support react hot loader. // ifDevClient('react-hot-loader/babel'), '@babel/syntax-dynamic-import', '@babel/proposal-class-properties', '@babel/proposal-object-rest-spread', ifClient('babel-plugin-smart-webpack-import'), ]), { target, optimize, development, }), }, { target, optimize, development, }); const env = webpackConfigEnv(buildOptions); let webpackConfig = webpackConfigFactoryHook.filter({ mode: isDev ? 'development' : 'production', // Define our entry chunks for our bundle. entry: entries, // Application output configuration. output: { // The dir in which our bundle should be output. path: pathResolve(getBuildPath(), bundleConfig.buildOutputDir), // The filename format for our bundle's entries. filename: ifProdClient( // For our production client bundles we include a hash in the filename. // That way we won't hit any browser caching issues when our bundle // output changes. // Note: as we are using the WebpackMd5Hash plugin, the hashes will // only change when the file contents change. This means we can // set very aggressive caching strategies on our bundle output. '[name]-[hash].js', // For any other bundle (typically a server/node) bundle we want a // determinable output name to allow for easier importing/execution // of the bundle by our scripts. '[name].js' ), // The name format for any additional chunks produced for the bundle. chunkFilename: ifProdClient('[name]-[chunkhash].chunk.js', '[name].chunk.js'), // When targetting node we will output our bundle as a commonjs2 module. libraryTarget: ifNode('commonjs2', 'var'), // This is the web path under which our webpack bundled client should // be considered as being served from. publicPath: clientBundlePublicPath, }, context: getProjectPath(), target: isClient ? // Only our client bundle will target the web as a runtime. 'web' : // Any other bundle must be targetting node as a runtime. 'node', // Ensure that webpack polyfills the following node features for use // within any bundles that are targetting node as a runtime. This will be // ignored otherwise. node: { __dirname: true, __filename: true, }, // Source map settings. devtool: ifElse( // Include source maps for ANY node bundle so that we can support // nice stack traces for errors (the source maps get consumed by // the `node-source-map-support` module to allow for this). isNode || // Always include source maps for any development build. isDev || // Allow for the following flag to force source maps even for production // builds. config.includeSourceMapsForOptimisedClientBundle, )( // Produces an external source map (lives next to bundle output files). 'source-map', // Produces no source map. 'hidden-source-map' ), // Performance budget feature. // This enables checking of the output bundle size, which will result in // warnings/errors if the bundle sizes are too large. // We only want this enabled for our production client. Please // see the webpack docs on how you can configure this to your own needs: // https://webpack.js.org/configuration/performance/ performance: ifProdClient( // Enable webpack's performance hints for production client builds. { hints: 'warning' }, // Else we have to set a value of "false" if we don't want the feature. false ), resolve: { // These extensions are tried when resolving a file. extensions: config.bundleSrcTypes.map((ext: string) => `.${ext}`), // This is required for the modernizr-loader // @see https://github.com/peerigon/modernizr-loader alias: { modernizr$: pathResolve(getProjectPath(), './.modernizrrc'), ...extraAliases, }, }, // We don't want our node_modules to be bundled with any bundle that is // targetting the node environment, prefering them to be resolved via // native node module system. Therefore we use the `webpack-node-externals` // library to help us generate an externals configuration that will // ignore all the node_modules. externals: removeNil([ ifNode(() => webpackNodeExternals( // Some of our node_modules may contain files that depend on our // webpack loaders, el.g. CSS or SASS. // For these cases please make sure that the file extensions are // registered within the following configuration setting. { whitelist: webpackNodeExternalsWhitelistHook.filter( removeNil([ // We always want the source-map-support included in // our node target bundles. 'source-map-support/register', ]) // And any items that have been whitelisted in the config need // to be included in the bundling process too. .concat(config.nodeExternalsFileTypeWhitelist), { buildOptions } ), } ) ), ]), ...(isClient && { optimization: { // For our production client we need to make sure we pass the required // configuration to ensure that the output is minimized/optimized. ...(isProd && { minimizer: [ new UglifyJsPlugin({ sourceMap: config.includeSourceMapsForOptimisedClientBundle, uglifyOptions: { ie8: true, compress: {}, mangle: true, output: { comments: false, }, }, }), ], }), splitChunks: { minSize: 0, minChunks: 1, }, runtimeChunk: { name: 'manifest', }, }, }), ...(isNode && { optimization: { splitChunks: false, minimize: false, }, }), plugins: removeNil([ /* ifClient(new webpack.optimize.CommonsChunkPlugin({ names: [ // 'bootstrap', 'app', 'vendor', 'polyfill', ], })), ifClient(new (webpack).optimize.CommonsChunkPlugin(config.bundles.client.manifestFilename)), */ // This grants us source map support, which combined with our webpack // source maps will give us nice stack traces for our node executed // bundles. // We use the BannerPlugin to make sure all of our chunks will get the // source maps support installed. ifNode( () => new webpack.BannerPlugin({ banner: `require('source-map-support').install();`, raw: true, entryOnly: false, }) ), // Implement webpack 3 scope hoisting that will remove function wrappers // around your modules you may see some small size improvements. However, // the significant improvement will be how fast the JavaScript loads in the browser. ifProdClient(new webpack.optimize.ModuleConcatenationPlugin()), // We use this so that our generated [chunkhash]'s are only different if // the content for our respective chunks have changed. This optimises // our long term browser caching strategy for our client bundle, avoiding // cases where browsers end up having to download all the client chunks // even though 1 or 2 may have only changed. // ifClient(() => new WebpackMd5Hash()), // These are process.env flags that you can use in your code in order to // have advanced control over what is included/excluded in your bundles. // For example you may only want certain parts of your code to be // included/ran under certain conditions. // // Any process.env.X values that are matched will be code substituted for // the associated values below. // // For example you may have the following in your code: // if (process.env.BUILD_FLAG_IS_CLIENT === 'true') { // console.log('Foo'); // } // // If the BUILD_FLAG_IS_CLIENT was assigned a value of `false` the above // code would be converted to the following by the webpack bundling // process: // if ('false' === 'true') { // console.log('Foo'); // } // // When your bundle is built using the UglifyJsPlugin unreachable code // blocks like in the example above will be removed from the bundle // final output. This is helpful for extreme cases where you want to // ensure that code is only included/executed on specific targets, or for // doing debugging. // // NOTE: We are stringifying the values to keep them in line with the // expected type of a typical process.env member (i.el. string). // @see https://github.com/ctrlplusb/react-universally/issues/395 new webpack.EnvironmentPlugin({ ...process.env, ...env, }), // Generates a JSON file containing a map of all the output files for // our webpack bundle. A necessisty for our server rendering process // as we need to interogate these files in order to know what JS/CSS // we need to inject into our HTML. We only need to know the assets for // our client bundle. ifClient( () => new AssetsPlugin({ filename: config.bundleAssetsFileName, path: pathResolve(getBuildPath(), bundleConfig.buildOutputDir), }) ), // We don't want webpack errors to occur during development as it will // kill our dev servers. ifDev(() => new webpack.NoEmitOnErrorsPlugin()), // We need this plugin to enable hot reloading of our client. ifDevClient(() => new webpack.HotModuleReplacementPlugin()), // For our production client we need to make sure we pass the required // configuration to ensure that the output is minimized/optimized. ifProdClient( () => new webpack.LoaderOptionsPlugin({ minimize: true, }) ), // For the production build of the client we need to extract the CSS into // CSS files. // TODO use mini instead https://github.com/webpack-contrib/mini-css-extract-plugin ifProdClient( () => new ExtractTextPlugin({ filename: '[name]-[hash].css', allChunks: true, }) ), // ----------------------------------------------------------------------- // START: HAPPY PACK PLUGINS // // @see https://github.com/amireh/happypack/ // // HappyPack allows us to use threads to fetch our loaders. This means // that we can get parallel execution of our loaders, significantly // improving build and recompile times. // // This may not be an issue for you whilst your project is small, but // the compile times can be signficant when the project scales. A lengthy // compile time can significantly impare your development experience. // Therefore we employ HappyPack to do threaded execution of our // "heavy-weight" loaders. // HappyPack 'javascript' instance. happyPackPlugin({ name: 'happypack-javascript', // We will use babel to do all our JS processing. loaders: [ { path: 'babel-loader', // We will create a babel config and pass it through the plugin // defined in the project configuration, allowing additional // items to be added. query: { ...babelConfig }, }, ], }), happyPackPlugin({ name: 'happypack-typescript', // We will use babel to do all our JS processing. loaders: [ { path: 'babel-loader', // We will create a babel config and pass it through the plugin // defined in the project configuration, allowing additional // items to be added. query: { ...babelConfig, }, }, { path: 'ts-loader', query: { compilerOptions: { ...require(pathResolve(getProjectPath(), 'tsconfig.json')).compilerOptions, module: 'esnext', target: 'es5', }, happyPackMode: true, }, }, ], }), webpackTsCheckPlugin, happyPackPlugin({ name: 'happypack-css', loaders: removeNil([ { loader: isSourceProject ? require.resolve('../../css-loader') : '@erect/css-loader', options: { typings: isClient, typingsBanner: config.cssTypingsBanner, file: { // What is the web path that the client bundle will be served from? // The same value has to be used for both the client and the // server bundles in order to ensure that SSR paths match the // paths used on the client. publicPath: clientBundlePublicPath, emitFile: isClient, }, }, }, ]), }), // END: HAPPY PACK PLUGINS // ----------------------------------------------------------------------- ]), module: { // Use strict export presence so that a missing export becomes a compile error. strictExportPresence: true, rules: [ { // "oneOf" will traverse all imports with following loaders until one will // match the requirements. When no loader matches it will fallback to the // "file" loader at the end of the loader list. oneOf: removeNil([ // JAVASCRIPT { test: /\.jsx?$/, // We will defer all our js processing to the happypack plugin // named "happypack-javascript". // See the respective plugin within the plugins section for full // details on what loader is being implemented. loader: 'happypack/loader?id=happypack-javascript', include: removeNil([ ...bundleConfig.srcPaths.map((srcPath: string) => pathResolve(getSourcePath(), srcPath), ), ...includeSrcPaths, ]).map((srcPath: string) => pathResolve(getProjectPath(), srcPath), ), }, { test: /\.tsx?$/, // We will defer all our js processing to the happypack plugin // named "happypack-javascript". // See the respective plugin within the plugins section for full // details on what loader is being implemented. loader: 'happypack/loader?id=happypack-typescript', include: removeNil([ ...bundleConfig.srcPaths.map((srcPath: string) => pathResolve(getSourcePath(), srcPath), ), ...includeSrcPaths, ]).map((srcPath: string) => pathResolve(getProjectPath(), srcPath), ), }, // CSS // This is bound to our server/client bundles as we only expect to be // serving the client bundle as a Single Page Application through the // server. ifElse(isClient || isServer)({ test: /\.css$/, loader: [ 'happypack/loader?id=happypack-css', ], }), // MODERNIZR // This allows you to do feature detection. // @see https://modernizr.com/docs // @see https://github.com/peerigon/modernizr-loader ifClient({ test: /\.modernizrrc.js$/, loader: 'modernizr-loader', }), ifClient({ test: /\.modernizrrc(\.json)?$/, loader: 'modernizr-loader!json-loader', }), // ASSETS (Images/Fonts/etc) // This is bound to our server/client bundles as we only expect to be // serving the client bundle as a Single Page Application through the // server. ifElse(isClient || isServer)(() => ({ loader: 'file-loader', exclude: [/\.jsx?$/, /\.tsx?$/, /\.html$/, /\.json$/], query: { // What is the web path that the client bundle will be served from? // The same value has to be used for both the client and the // server bundles in order to ensure that SSR paths match the // paths used on the client. publicPath: clientBundlePublicPath, // We only emit files when building a web bundle, for the server // bundle we only care about the file loader being able to create // the correct asset URLs. emitFile: isClient, }, })), // Do not add any loader after file loader (fallback loader) // Make sure to add the new loader(s) before the "file" loader. ]), }, ], }, }, { buildOptions, }); const webpackFactory: WebpackFactory = { register(handler: WebpackConfigFilterHandler) { webpackConfig = handler(webpackConfig); return webpackFactory; }, log, compile: () => { const compiler = webpack(webpackConfig); fs.writeJsonSync( pathResolve(getBuildPath(), bundleConfig.buildOutputDir, 'buildEnv.json'), env, { spaces: 2, }, ); return compiler; }, }; return webpackFactory; }