UNPKG

@apployees-nx/webserver

Version:

A create-react-app inspired plugin for Nx, with SSR and PWA capabilities.

473 lines (438 loc) 16.6 kB
/******************************************************************************* * © Apployees Inc., 2019 * All Rights Reserved. ******************************************************************************/ import webpack, { Configuration } from "webpack"; import path, { dirname } from "path"; import { LicenseWebpackPlugin } from "license-webpack-plugin"; import TerserWebpackPlugin from "terser-webpack-plugin"; import TsConfigPathsPlugin from "tsconfig-paths-webpack-plugin"; import { getOutputHashFormat } from "../common/hash-format"; import { IBuildWebserverBuilderOptions } from "../common/webserver-types"; import _ from "lodash"; import { BuilderContext } from "@angular-devkit/architect"; import { getBaseLoaders } from "../common/common-loaders"; import { getAssetsUrl, getWebserverEnvironmentVariables } from "../common/env"; import { getClientLoaders } from "./client-loaders"; import { getPlugins } from "../common/plugins"; import { extensions, FILENAMES, getAliases, getStatsConfig } from "../common/common-config"; import CircularDependencyPlugin from "circular-dependency-plugin"; import isWsl from "is-wsl"; import OptimizeCSSAssetsPlugin from "optimize-css-assets-webpack-plugin"; import safePostCssParser from "postcss-safe-parser"; import PnpWebpackPlugin from "pnp-webpack-plugin"; import ManifestPlugin from "webpack-manifest-plugin"; import InlineChunkHtmlPlugin from "react-dev-utils/InlineChunkHtmlPlugin"; import InterpolateHtmlPlugin from "react-dev-utils/InterpolateHtmlPlugin"; import HtmlWebpackPlugin from "html-webpack-plugin"; import ScriptExtHtmlWebpackPlugin from "script-ext-html-webpack-plugin"; // @ts-ignore import FaviconsWebpackPlugin from "favicons-webpack-plugin-ex"; import HtmlWebpackInjector from "html-webpack-injector"; import { readJsonFile } from "@nrwl/workspace"; import WorkboxWebpackPlugin from "workbox-webpack-plugin"; import WorkerPlugin from "worker-plugin"; import "worker-loader"; import { IProcessedEnvironmentVariables } from "@apployees-nx/common-build-utils"; import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer"; import WebpackBar from "webpackbar"; import ThreadsPlugin from "threads-plugin"; import * as os from "os"; export function getClientConfig( options: IBuildWebserverBuilderOptions, context: BuilderContext, esm?: boolean, ): Configuration { const isEnvDevelopment = options.dev; const isEnvProduction = !options.dev; const isScriptOptimizeOn = isEnvProduction; const mainFields = ["browser", ...(esm ? ["es2015"] : []), "module", "main"]; const hashFormat = getOutputHashFormat(options.outputHashing); const suffixFormat = esm ? ".esm" : ".es5"; const filename = isScriptOptimizeOn ? `static/js/[name]${hashFormat.script}${suffixFormat}.js` : "static/js/[name].js"; const chunkFilename = isScriptOptimizeOn ? `static/js/[name]${hashFormat.chunk}${suffixFormat}.js` : "static/js/[name].js"; const shouldUseSourceMap = isEnvDevelopment; // clients never have source maps in production code const webserverEnvironmentVariables = getWebserverEnvironmentVariables(options, context, true); const publicPath = getAssetsUrl(options); const otherEntries = options.clientOtherEntries || {}; if (otherEntries["main"]) { throw new Error( `clientOtherEntries cannot have an entry with key 'main' (currently set to '${otherEntries["main"]}').`, ); } otherEntries["main"] = options.clientMain; const entries = Object.keys(otherEntries).reduce((acc, key) => { acc[key] = [ isEnvDevelopment && key === "main" && `@apployees-nx/webserver/utils/client/webpackHotDevClient?devPort=${options.devWebpackPort}`, otherEntries[key], ].filter(Boolean); return acc; }, {}); const webpackConfig: Configuration = { name: "client", target: "web", mode: isEnvProduction ? "production" : "development", // Stop compilation early in production bail: isEnvProduction, devtool: shouldUseSourceMap ? "eval-source-map" : false, entry: entries, output: { // The build folder. path: options.publicOutputFolder_calculated, // Add /* filename */ comments to generated require()s in the output. pathinfo: isEnvDevelopment, filename, chunkFilename, // We use "/" in development, can be configured in production publicPath: publicPath, // Point sourcemap entries to original disk location (format as URL on Windows) devtoolModuleFilenameTemplate: isEnvProduction ? (info) => path.relative(path.resolve(options.root, options.sourceRoot), info.absoluteResourcePath).replace(/\\/g, "/") : isEnvDevelopment && ((info) => path.resolve(info.absoluteResourcePath).replace(/\\/g, "/")), // Prevents conflicts when multiple Webpack runtimes (from different apps) // are used on the same page. jsonpFunction: `webpackJsonp${context.target.project}`, }, optimization: { minimize: isEnvProduction, minimizer: isScriptOptimizeOn ? [createTerserPlugin(shouldUseSourceMap), createOptimizeCssAssetsPlugin(shouldUseSourceMap)] : [], // Automatically split vendor and commons // https://twitter.com/wSokra/status/969633336732905474 // https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366 splitChunks: { chunks: "all", name: false, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: "vendors", chunks: "all", }, }, }, runtimeChunk: true, // see https://github.com/webpack/webpack/issues/7128 namedModules: false, }, resolve: { extensions, alias: getAliases(options.clientFileReplacements), plugins: [ new TsConfigPathsPlugin({ configFile: options.tsConfig, extensions, mainFields, }), // Adds support for installing with Plug'n'Play, leading to faster installs and adding // guards against forgotten dependencies and such. PnpWebpackPlugin, ], mainFields, }, resolveLoader: { plugins: [ // Also related to Plug'n'Play, but this time it tells Webpack to load its loaders // from the current package. PnpWebpackPlugin.moduleLoader(module), ], }, module: { strictExportPresence: true, rules: [ ...getBaseLoaders( options, dirname(options.clientMain), esm, options.verbose, isEnvDevelopment, false, // isEnvServer ), { test: /\.worker\.js$/, use: { loader: "worker-loader" }, }, { // "oneOf" will traverse all following loaders until one will // match the requirements. When no loader matches it will fall // back to the "file" loader at the end of the loader list. oneOf: getClientLoaders(options), }, ], }, plugins: [ ...getPlugins(options, context, true), // Generates an `app.html` file with the <script> injected. new HtmlWebpackPlugin( Object.assign( {}, { inject: true, filename: FILENAMES.appHtml, template: options.appHtml, attributes: { crossorigin: "anonymous", }, }, isEnvProduction ? { minify: { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true, }, } : undefined, ), ), new ScriptExtHtmlWebpackPlugin({ custom: { test: /\.js$/, attribute: "crossorigin", value: "anonymous", }, preload: { test: /\.js$/, }, }), options.favicon && new FaviconsWebpackPlugin({ logo: options.favicon, cache: true, outputPath: "static/favicons/", prefix: "static/favicons/", excludeManifestInjection: true, }), new HtmlWebpackInjector(), // Inlines the webpack runtime script. This script is too small to warrant // a network request. isEnvProduction && options.inlineRuntimeChunk && new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime~.+[.]js/]), // Makes some environment variables available in index.html. // The public URL is available as %ASSETS_URL% in index.html, e.g.: // <link rel="shortcut icon" href="%ASSETS_URL%favicon.ico"> new InterpolateHtmlPlugin(HtmlWebpackPlugin, webserverEnvironmentVariables.raw), // add support for service workers // Generate a service worker script that will precache, and keep up to date, // the HTML & assets that are part of the Webpack build. isEnvProduction && new WorkboxWebpackPlugin.GenerateSW({ clientsClaim: true, exclude: [/\.map$/, /asset-manifest\.json$/], importWorkboxFrom: "cdn", navigateFallback: publicPath + FILENAMES.appHtml, navigateFallbackBlacklist: [ // Exclude URLs starting with /_, as they're likely an API call new RegExp("^/_"), // Exclude any URLs whose last part seems to be a file extension // as they're likely a resource and not a SPA route. // URLs containing a "?" character won't be blacklisted as they're likely // a route with query params (e.g. auth callbacks). new RegExp("/[^/?]+\\.[^/]+$"), ], }), // Generate a manifest file which contains a mapping of all asset filenames // to their corresponding output file so that tools can pick it up without // having to parse `index.html`. new ManifestPlugin({ fileName: FILENAMES.manifestJson, publicPath: publicPath, generate: (seed, files) => { return generateManifestContents(publicPath, files, seed, options, webserverEnvironmentVariables); }, }), ].filter(Boolean), // Some libraries import Node modules but don't use them in the browser. // Tell Webpack to provide empty mocks for them so importing them works. node: { module: "empty", dgram: "empty", dns: "mock", fs: "empty", net: "empty", tls: "empty", // eslint-disable-next-line @typescript-eslint/camelcase child_process: "empty", }, stats: getStatsConfig(options), // Turn off performance processing because we utilize // our own hints via the FileSizeReporter performance: false, }; const extraPlugins: webpack.Plugin[] = []; if (options.progress) { extraPlugins.push( new WebpackBar({ name: "client", fancy: isEnvDevelopment, basic: !isEnvDevelopment, }), ); } if (options.extractLicenses) { extraPlugins.push( new LicenseWebpackPlugin({ pattern: /.*/, suppressErrors: true, perChunkOutput: false, outputFilename: FILENAMES.thirdPartyLicenses, }), ); } if (options.showCircularDependencies) { extraPlugins.push( new CircularDependencyPlugin({ // eslint-disable-next-line no-useless-escape exclude: /[\\\/]node_modules[\\\/]/, }), ); } if (isEnvDevelopment && options.devClientBundleAnalyzer) { extraPlugins.push(new BundleAnalyzerPlugin()); } const plugins = [...webpackConfig.plugins, ...extraPlugins]; webpackConfig.plugins = [ options.useThreadsPlugin ? new ThreadsPlugin({ globalObject: "self", // this includes hard source as well, but we just want the DefinePlugin // Needs further investigation // plugins: plugins, }) : new WorkerPlugin({ globalObject: "self", }), ...plugins, ]; return webpackConfig; } export function createOptimizeCssAssetsPlugin(shouldUseSourceMap: boolean) { return new OptimizeCSSAssetsPlugin({ cssProcessorOptions: { parser: safePostCssParser, map: shouldUseSourceMap ? { // `inline: false` forces the sourcemap to be output into a // separate file inline: false, // `annotation: true` appends the sourceMappingURL to the end of // the css file, helping the browser find the sourcemap annotation: true, } : false, }, }); } export function createTerserPlugin(shouldUseSourceMap: boolean) { return new TerserWebpackPlugin({ terserOptions: { parse: { // We want terser to parse ecma 8 code. However, we don't want it // to apply any minification steps that turns valid ecma 5 code // into invalid ecma 5 code. This is why the 'compress' and 'output' // sections only apply transformations that are ecma 5 safe // https://github.com/facebook/create-react-app/pull/4234 ecma: 8, }, compress: { ecma: 5, warnings: false, // Disabled because of an issue with Uglify breaking seemingly valid code: // https://github.com/facebook/create-react-app/issues/2376 // Pending further investigation: // https://github.com/mishoo/UglifyJS2/issues/2011 comparisons: false, // Disabled because of an issue with Terser breaking valid code: // https://github.com/facebook/create-react-app/issues/5250 // Pending further investigation: // https://github.com/terser-js/terser/issues/120 inline: 2, }, mangle: { safari10: true, }, output: { ecma: 5, comments: false, // Turned on because emoji and regex is not minified properly using default // https://github.com/facebook/create-react-app/issues/2488 // eslint-disable-next-line @typescript-eslint/camelcase ascii_only: true, }, }, // Use multi-process parallel running to improve the build speed // Default number of concurrent runs: os.cpus().length - 1 // On some CI systems, os.cpus() return many more CPUs than available, so we // cap it. parallel: Math.max(os.cpus().length - 1, 8), // Enable file caching cache: true, sourceMap: shouldUseSourceMap, }); } /** * We need to take all the icons from FaviconsWebpackPlugin and inject * them into the manifest file. Even though there is already a manifest.json * created by FaviconsWebpackPlugin, the problem is that it is in a completely * different folder and is separate. We just want the icons from that file. * Unfortunately, at this point, that file is not saved so we can't just load it * here. Thus, we will search for all the relevant icons ourselves and then * inject them into the manifest. * * @param publicPath * @param files * @param seed * @param options */ function generateManifestContents( publicPath, files, seed, options: IBuildWebserverBuilderOptions, envVars: IProcessedEnvironmentVariables, ) { const icons: any = []; const iconToSearch = publicPath + "static/favicons/android-chrome"; const manifestFiles = files.reduce(function (manifest, file) { // skip the manifests generated by favicon plugin if (file.name.startsWith("static/favicons/manifest")) { return manifest; } manifest[file.name] = file.path; if (file.path.indexOf(iconToSearch) >= 0) { icons.push({ src: file.path, sizes: file.path.substring(iconToSearch.length + 1, file.path.indexOf(".png")), type: "image/png", }); } return manifest; }, seed); let suppliedManifest: any = { icons: icons }; if (options.manifestJson) { suppliedManifest = _.merge(suppliedManifest, readJsonFile(options.manifestJson)); } if (!suppliedManifest.start_url && envVars.raw["PUBLIC_URL"]) { // eslint-disable-next-line @typescript-eslint/camelcase suppliedManifest.start_url = envVars.raw["PUBLIC_URL"]; } return _.merge(suppliedManifest, { files: manifestFiles, }); }