feat(webpack): add NxWebpackPlugin that works with normal Webpack configuration (#19984)

This commit is contained in:
Jack Hsu 2023-11-08 11:03:34 -05:00 committed by GitHub
parent 304a6d14d6
commit 395eb70336
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1642 additions and 1172 deletions

View File

@ -1,6 +1,7 @@
import {
cleanupProject,
newProject,
packageInstall,
rmDist,
runCLI,
runCommand,
@ -11,8 +12,8 @@ import {
import { join } from 'path';
describe('Webpack Plugin', () => {
beforeEach(() => newProject());
afterEach(() => cleanupProject());
beforeAll(() => newProject());
afterAll(() => cleanupProject());
it('should be able to setup project to build node programs with webpack and different compilers', async () => {
const myPkg = uniq('my-pkg');
@ -86,7 +87,7 @@ module.exports = composePlugins(withNx(), (config) => {
updateFile(
`libs/${myPkg}/.babelrc`,
`{ "presets": ["@nx/js/babel", "./custom-preset"] } `
`{ 'presets': ['@nx/js/babel', './custom-preset'] } `
);
updateFile(
`libs/${myPkg}/custom-preset.js`,
@ -106,4 +107,32 @@ module.exports = composePlugins(withNx(), (config) => {
});
expect(output).toContain('Babel env is babelEnv');
}, 500_000);
it('should be able to build with NxWebpackPlugin and a standard webpack config file', () => {
const appName = uniq('app');
runCLI(`generate @nx/web:app ${appName} --bundler webpack`);
updateFile(`apps/${appName}/src/main.ts`, `console.log('Hello');\n`);
updateFile(
`apps/${appName}/webpack.config.js`,
`
const path = require('path');
const { NxWebpackPlugin } = require('@nx/webpack');
module.exports = {
target: 'node',
output: {
path: path.join(__dirname, '../../dist/${appName}')
},
plugins: [
new NxWebpackPlugin()
]
};`
);
runCLI(`build ${appName} --outputHashing none`);
let output = runCommand(`node dist/${appName}/main.js`);
expect(output).toMatch(/Hello/);
}, 500_000);
});

View File

@ -22,3 +22,4 @@ export { componentTestGenerator } from './src/generators/component-test/componen
export { setupTailwindGenerator } from './src/generators/setup-tailwind/setup-tailwind';
export type { SupportedStyles } from './typings/style';
export * from './plugins/with-react';
export { NxReactWebpackPlugin } from './plugins/nx-react-webpack-plugin/nx-react-webpack-plugin';

View File

@ -0,0 +1,80 @@
import { Compiler, Configuration, WebpackOptionsNormalized } from 'webpack';
export function applyReactConfig(
options: { svgr?: boolean },
config: Partial<WebpackOptionsNormalized | Configuration> = {}
): void {
addHotReload(config);
if (options.svgr !== false) {
removeSvgLoaderIfPresent(config);
config.module.rules.push({
test: /\.svg$/,
issuer: /\.(js|ts|md)x?$/,
use: [
{
loader: require.resolve('@svgr/webpack'),
options: {
svgo: false,
titleProp: true,
ref: true,
},
},
{
loader: require.resolve('file-loader'),
options: {
name: '[name].[hash].[ext]',
},
},
],
});
}
// enable webpack node api
config.node = {
__dirname: true,
__filename: true,
};
}
function addHotReload(
config: Partial<WebpackOptionsNormalized | Configuration>
) {
if (config.mode === 'development' && config['devServer']?.hot) {
// add `react-refresh/babel` to babel loader plugin
const babelLoader = config.module.rules.find(
(rule) =>
rule &&
typeof rule !== 'string' &&
rule.loader?.toString().includes('babel-loader')
);
if (babelLoader && typeof babelLoader !== 'string') {
babelLoader.options['plugins'] = [
...(babelLoader.options['plugins'] || []),
[
require.resolve('react-refresh/babel'),
{
skipEnvCheck: true,
},
],
];
}
const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
config.plugins.push(new ReactRefreshPlugin());
}
}
// We remove potentially conflicting rules that target SVGs because we use @svgr/webpack loader
// See https://github.com/nrwl/nx/issues/14383
function removeSvgLoaderIfPresent(
config: Partial<WebpackOptionsNormalized | Configuration>
) {
const svgLoaderIdx = config.module.rules.findIndex(
(rule) => typeof rule === 'object' && rule.test.toString().includes('svg')
);
if (svgLoaderIdx === -1) return;
config.module.rules.splice(svgLoaderIdx, 1);
}

View File

@ -0,0 +1,10 @@
import { Compiler } from 'webpack';
import { applyReactConfig } from './lib/apply-react-config';
export class NxReactWebpackPlugin {
constructor(private options: { svgr?: boolean } = {}) {}
apply(compiler: Compiler): void {
applyReactConfig(this.options, compiler.options);
}
}

View File

@ -1,6 +1,6 @@
import type { Configuration } from 'webpack';
import type { WithWebOptions } from '@nx/webpack';
import type { NxWebpackExecutionContext } from '@nx/webpack';
import type { NxWebpackExecutionContext, WithWebOptions } from '@nx/webpack';
import { applyReactConfig } from './nx-react-webpack-plugin/lib/apply-react-config';
const processed = new Set();
@ -8,45 +8,6 @@ interface WithReactOptions extends WithWebOptions {
svgr?: false;
}
function addHotReload(config: Configuration) {
if (config.mode === 'development' && config['devServer']?.hot) {
// add `react-refresh/babel` to babel loader plugin
const babelLoader = config.module.rules.find(
(rule) =>
rule &&
typeof rule !== 'string' &&
rule.loader?.toString().includes('babel-loader')
);
if (babelLoader && typeof babelLoader !== 'string') {
babelLoader.options['plugins'] = [
...(babelLoader.options['plugins'] || []),
[
require.resolve('react-refresh/babel'),
{
skipEnvCheck: true,
},
],
];
}
const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
config.plugins.push(new ReactRefreshPlugin());
}
}
// We remove potentially conflicting rules that target SVGs because we use @svgr/webpack loader
// See https://github.com/nrwl/nx/issues/14383
function removeSvgLoaderIfPresent(config: Configuration) {
const svgLoaderIdx = config.module.rules.findIndex(
(rule) => typeof rule === 'object' && rule.test.toString().includes('svg')
);
if (svgLoaderIdx === -1) return;
config.module.rules.splice(svgLoaderIdx, 1);
}
/**
* @param {WithReactOptions} pluginOptions
* @returns {NxWebpackPlugin}
@ -63,38 +24,7 @@ export function withReact(pluginOptions: WithReactOptions = {}) {
// Apply web config for CSS, JSX, index.html handling, etc.
config = withWeb(pluginOptions)(config, context);
addHotReload(config);
if (pluginOptions?.svgr !== false) {
removeSvgLoaderIfPresent(config);
config.module.rules.push({
test: /\.svg$/,
issuer: /\.(js|ts|md)x?$/,
use: [
{
loader: require.resolve('@svgr/webpack'),
options: {
svgo: false,
titleProp: true,
ref: true,
},
},
{
loader: require.resolve('file-loader'),
options: {
name: '[name].[hash].[ext]',
},
},
],
});
}
// enable webpack node api
config.node = {
__dirname: true,
__filename: true,
};
applyReactConfig(pluginOptions, config);
processed.add(config);
return config;

View File

@ -23,3 +23,5 @@ export * from './src/utils/get-css-module-local-ident';
export * from './src/utils/with-nx';
export * from './src/utils/with-web';
export * from './src/utils/module-federation/public-api';
export { NxWebpackPlugin } from './src/plugins/nx-webpack-plugin/nx-webpack-plugin';
export { NxTsconfigPathsWebpackPlugin } from './src/plugins/nx-typescript-webpack-plugin/nx-tsconfig-paths-webpack-plugin';

View File

@ -75,11 +75,23 @@ export async function* devServerExecutor(
customWebpack = await customWebpack;
}
config = await customWebpack(config, {
options: buildOptions,
context,
configuration: serveOptions.buildTarget.split(':')[2],
});
if (typeof customWebpack === 'function') {
// Old behavior, call the webpack function that is specific to Nx
config = await customWebpack(config, {
options: buildOptions,
context,
configuration: serveOptions.buildTarget.split(':')[2],
});
} else if (customWebpack) {
// New behavior, use the config object as is with devServer defaults
config = {
devServer: {
...customWebpack.devServer,
...config.devServer,
},
...customWebpack,
};
}
}
return yield* eachValueFrom(

View File

@ -1,10 +1,7 @@
import { basename, dirname, relative, resolve } from 'path';
import { statSync } from 'fs';
import { normalizePath } from '@nx/devkit';
import { resolve } from 'path';
import { normalizeAssets } from '../../../plugins/nx-webpack-plugin/lib/normalize-options';
import type {
AssetGlobPattern,
FileReplacement,
NormalizedWebpackExecutorOptions,
WebpackExecutorOptions,
} from '../schema';
@ -21,11 +18,7 @@ export function normalizeOptions(
projectRoot,
sourceRoot,
target: options.target ?? 'web',
main: resolve(root, options.main),
outputPath: resolve(root, options.outputPath),
outputFileName: options.outputFileName ?? 'main.js',
tsConfig: resolve(root, options.tsConfig),
fileReplacements: normalizeFileReplacements(root, options.fileReplacements),
assets: normalizeAssets(options.assets, root, sourceRoot),
webpackConfig: normalizePluginPath(options.webpackConfig, root),
optimization:
@ -35,20 +28,9 @@ export function normalizeOptions(
styles: options.optimization,
}
: options.optimization,
polyfills: options.polyfills ? resolve(root, options.polyfills) : undefined,
};
}
function normalizeFileReplacements(
root: string,
fileReplacements: FileReplacement[]
): FileReplacement[] {
return fileReplacements.map((fileReplacement) => ({
replace: resolve(root, fileReplacement.replace),
with: resolve(root, fileReplacement.with),
}));
}
export function normalizePluginPath(pluginPath: void | string, root: string) {
if (!pluginPath) {
return '';
@ -59,50 +41,3 @@ export function normalizePluginPath(pluginPath: void | string, root: string) {
return resolve(root, pluginPath);
}
}
export function normalizeAssets(
assets: any[],
root: string,
sourceRoot: string
): AssetGlobPattern[] {
return assets.map((asset) => {
if (typeof asset === 'string') {
const assetPath = normalizePath(asset);
const resolvedAssetPath = resolve(root, assetPath);
const resolvedSourceRoot = resolve(root, sourceRoot);
if (!resolvedAssetPath.startsWith(resolvedSourceRoot)) {
throw new Error(
`The ${resolvedAssetPath} asset path must start with the project source root: ${sourceRoot}`
);
}
const isDirectory = statSync(resolvedAssetPath).isDirectory();
const input = isDirectory
? resolvedAssetPath
: dirname(resolvedAssetPath);
const output = relative(resolvedSourceRoot, resolve(root, input));
const glob = isDirectory ? '**/*' : basename(resolvedAssetPath);
return {
input,
output,
glob,
};
} else {
if (asset.output.startsWith('..')) {
throw new Error(
'An asset cannot be written to a location outside of the output path.'
);
}
const assetPath = normalizePath(asset.input);
const resolvedAssetPath = resolve(root, assetPath);
return {
...asset,
input: resolvedAssetPath,
// Now we remove starting slash to make Webpack place it from the output root.
output: asset.output.replace(/^\//, ''),
};
}
});
}

View File

@ -86,8 +86,8 @@ export interface WebpackExecutorOptions {
export interface NormalizedWebpackExecutorOptions
extends WebpackExecutorOptions {
outputFileName: string;
assets?: AssetGlobPattern[];
root?: string;
projectRoot?: string;
sourceRoot?: string;
assets: AssetGlobPattern[];
root: string;
projectRoot: string;
sourceRoot: string;
}

View File

@ -1,9 +1,4 @@
import {
ExecutorContext,
logger,
stripIndents,
workspaceRoot,
} from '@nx/devkit';
import { ExecutorContext, logger, stripIndents } from '@nx/devkit';
import { eachValueFrom } from '@nx/devkit/src/utils/rxjs-for-await';
import type { Configuration, Stats } from 'webpack';
import { from, of } from 'rxjs';
@ -42,7 +37,7 @@ async function getWebpackConfigs(
}
let customWebpack = null;
if (options.webpackConfig) {
if (options.webpackConfig && options.tsConfig) {
customWebpack = resolveCustomWebpackConfig(
options.webpackConfig,
options.tsConfig.startsWith(context.root)
@ -59,14 +54,18 @@ async function getWebpackConfigs(
? {}
: getWebpackConfig(context, options);
if (customWebpack) {
if (typeof customWebpack === 'function') {
// Old behavior, call the Nx-specific webpack config function that user exports
return await customWebpack(config, {
options,
context,
configuration: context.configurationName, // backwards compat
});
} else if (customWebpack) {
// New behavior, we want the webpack config to export object
return customWebpack;
} else {
// If the user has no webpackConfig specified then we always have to apply
// Fallback case, if we cannot find a webpack config path
return config;
}
}
@ -87,6 +86,9 @@ export async function* webpackExecutor(
_options: WebpackExecutorOptions,
context: ExecutorContext
): AsyncGenerator<WebpackExecutorEvent, WebpackExecutorEvent, undefined> {
// Pass to NxWebpackPlugin so we can get the CLI overrides.
process.env['NX_WEBPACK_EXECUTOR_RAW_OPTIONS'] = JSON.stringify(_options);
const metadata = context.projectsConfigurations.projects[context.projectName];
const sourceRoot = metadata.sourceRoot;
const options = normalizeOptions(
@ -144,7 +146,7 @@ export async function* webpackExecutor(
}
// Delete output path before bundling
if (options.deleteOutputPath) {
if (options.deleteOutputPath && options.outputPath) {
deleteOutputDir(context.root, options.outputPath);
}

View File

@ -1,29 +1,31 @@
import { type Compiler, sources, type WebpackPluginInstance } from 'webpack';
import { createLockFile, createPackageJson } from '@nx/js';
import {
detectPackageManager,
ExecutorContext,
type ProjectGraph,
serializeJson,
} from '@nx/devkit';
import {
createLockFile,
createPackageJson,
getHelperDependenciesFromProjectGraph,
getLockFileName,
HelperDependency,
readTsConfig,
} from '@nx/js';
import {
detectPackageManager,
type ProjectGraph,
serializeJson,
} from '@nx/devkit';
const pluginName = 'GeneratePackageJsonPlugin';
export class GeneratePackageJsonPlugin implements WebpackPluginInstance {
private readonly projectGraph: ProjectGraph;
constructor(
private readonly options: { tsConfig: string; outputFileName: string },
private readonly context: ExecutorContext
) {
this.projectGraph = context.projectGraph;
}
private readonly options: {
tsConfig: string;
outputFileName: string;
root: string;
projectName: string;
targetName: string;
projectGraph: ProjectGraph;
}
) {}
apply(compiler: Compiler): void {
compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
@ -34,9 +36,9 @@ export class GeneratePackageJsonPlugin implements WebpackPluginInstance {
},
() => {
const helperDependencies = getHelperDependenciesFromProjectGraph(
this.context.root,
this.context.projectName,
this.projectGraph
this.options.root,
this.options.projectName,
this.options.projectGraph
);
const importHelpers = !!readTsConfig(this.options.tsConfig).options
@ -50,17 +52,17 @@ export class GeneratePackageJsonPlugin implements WebpackPluginInstance {
if (shouldAddHelperDependency) {
helperDependencies.push({
type: 'static',
source: this.context.projectName,
source: this.options.projectName,
target: HelperDependency.tsc,
});
}
const packageJson = createPackageJson(
this.context.projectName,
this.projectGraph,
this.options.projectName,
this.options.projectGraph,
{
target: this.context.targetName,
root: this.context.root,
target: this.options.targetName,
root: this.options.root,
isProduction: true,
helperDependencies: helperDependencies.map((dep) => dep.target),
}
@ -71,11 +73,15 @@ export class GeneratePackageJsonPlugin implements WebpackPluginInstance {
'package.json',
new sources.RawSource(serializeJson(packageJson))
);
const packageManager = detectPackageManager(this.context.root);
const packageManager = detectPackageManager(this.options.root);
compilation.emitAsset(
getLockFileName(packageManager),
new sources.RawSource(
createLockFile(packageJson, this.projectGraph, packageManager)
createLockFile(
packageJson,
this.options.projectGraph,
packageManager
)
)
);
}

View File

@ -0,0 +1,37 @@
import * as path from 'path';
import { Compiler } from 'webpack';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
import { workspaceRoot } from '@nx/devkit';
export class NxTsconfigPathsWebpackPlugin {
constructor(
private options: {
tsConfig: string;
}
) {
if (!this.options.tsConfig)
throw new Error(
`Missing "tsConfig" option. Set this option in your Nx webpack plugin.`
);
}
apply(compiler: Compiler): void {
const extensions = new Set([
...['.ts', '.tsx', '.mjs', '.js', '.jsx'],
...(compiler.options?.resolve?.extensions ?? []),
]);
compiler.options.resolve = {
...compiler.options.resolve,
plugins: compiler.options.resolve?.plugins ?? [],
};
compiler.options.resolve.plugins.push(
new TsconfigPathsPlugin({
configFile: !path.isAbsolute(this.options.tsConfig)
? path.join(workspaceRoot, this.options.tsConfig)
: this.options.tsConfig,
extensions: Array.from(extensions),
mainFields: ['module', 'main'],
})
);
}
}

View File

@ -0,0 +1,341 @@
import * as path from 'path';
import { ExecutorContext } from 'nx/src/config/misc-interfaces';
import { LicenseWebpackPlugin } from 'license-webpack-plugin';
import * as CopyWebpackPlugin from 'copy-webpack-plugin';
import {
Configuration,
ProgressPlugin,
WebpackOptionsNormalized,
WebpackPluginInstance,
} from 'webpack';
import { StatsJsonPlugin } from '../../stats-json-plugin';
import { GeneratePackageJsonPlugin } from '../../generate-package-json-plugin';
import { getOutputHashFormat } from '../../../utils/hash-format';
import { NxTsconfigPathsWebpackPlugin } from '../../nx-typescript-webpack-plugin/nx-tsconfig-paths-webpack-plugin';
import { getTerserEcmaVersion } from './get-terser-ecma-version';
import { createLoaderFromCompiler } from './compiler-loaders';
import { NormalizedNxWebpackPluginOptions } from '../nx-webpack-plugin-options';
import TerserPlugin = require('terser-webpack-plugin');
import nodeExternals = require('webpack-node-externals');
import ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const IGNORED_WEBPACK_WARNINGS = [
/The comment file/i,
/could not find any license/i,
];
const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx'];
const mainFields = ['module', 'main'];
export function applyBaseConfig(
options: NormalizedNxWebpackPluginOptions,
config: Partial<WebpackOptionsNormalized | Configuration> = {}
): void {
const plugins: WebpackPluginInstance[] = [
new NxTsconfigPathsWebpackPlugin(options),
];
const executorContext: Partial<ExecutorContext> = {
projectName: options.projectName,
targetName: options.targetName,
projectGraph: options.projectGraph,
configurationName: options.configurationName,
root: options.root,
};
if (!options?.skipTypeChecking) {
plugins.push(
new ForkTsCheckerWebpackPlugin({
typescript: {
configFile: path.isAbsolute(options.tsConfig)
? options.tsConfig
: path.join(options.root, options.tsConfig),
memoryLimit: options.memoryLimit || 2018,
},
})
);
}
const entries: Array<{ name: string; import: string[] }> = [];
if (options.main) {
const mainEntry = options.outputFileName
? path.parse(options.outputFileName).name
: 'main';
entries.push({
name: mainEntry,
import: [path.resolve(options.root, options.main)],
});
}
if (options.additionalEntryPoints) {
for (const { entryName, entryPath } of options.additionalEntryPoints) {
entries.push({
name: entryName,
import: [path.resolve(options.root, entryPath)],
});
}
}
if (options.polyfills) {
entries.push({
name: 'polyfills',
import: [path.resolve(options.root, options.polyfills)],
});
}
config.entry ??= {};
entries.forEach((entry) => {
config.entry[entry.name] = { import: entry.import };
});
if (options.progress) {
plugins.push(new ProgressPlugin({ profile: options.verbose }));
}
if (options.extractLicenses) {
plugins.push(
new LicenseWebpackPlugin({
stats: {
warnings: false,
errors: false,
},
perChunkOutput: false,
outputFilename: `3rdpartylicenses.txt`,
}) as unknown as WebpackPluginInstance
);
}
if (Array.isArray(options.assets) && options.assets.length > 0) {
plugins.push(
new CopyWebpackPlugin({
patterns: options.assets.map((asset) => {
return {
context: asset.input,
// Now we remove starting slash to make Webpack place it from the output root.
to: asset.output,
from: asset.glob,
globOptions: {
ignore: [
'.gitkeep',
'**/.DS_Store',
'**/Thumbs.db',
...(asset.ignore ?? []),
],
dot: true,
},
};
}),
})
);
}
if (options.generatePackageJson && executorContext) {
plugins.push(new GeneratePackageJsonPlugin(options));
}
if (options.statsJson) {
plugins.push(new StatsJsonPlugin());
}
const externals = [];
if (options.target === 'node' && options.externalDependencies === 'all') {
const modulesDir = `${options.root}/node_modules`;
externals.push(nodeExternals({ modulesDir }));
} else if (Array.isArray(options.externalDependencies)) {
externals.push(function (ctx, callback: Function) {
if (options.externalDependencies.includes(ctx.request)) {
// not bundled
return callback(null, `commonjs ${ctx.request}`);
}
// bundled
callback();
});
}
const hashFormat = getOutputHashFormat(options.outputHashing as string);
config.context = path.join(options.root, options.projectRoot);
config.target ??= options.target;
config.node = false;
config.mode =
// When the target is Node avoid any optimizations, such as replacing `process.env.NODE_ENV` with build time value.
config.target === 'node'
? 'none'
: // Otherwise, make sure it matches `process.env.NODE_ENV`.
// When mode is development or production, webpack will automatically
// configure DefinePlugin to replace `process.env.NODE_ENV` with the
// build-time value. Thus, we need to make sure it's the same value to
// avoid conflicts.
//
// When the NODE_ENV is something else (e.g. test), then set it to none
// to prevent extra behavior from webpack.
process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'production'
? (process.env.NODE_ENV as 'development' | 'production')
: 'none';
// When target is Node, the Webpack mode will be set to 'none' which disables in memory caching and causes a full rebuild on every change.
// So to mitigate this we enable in memory caching when target is Node and in watch mode.
config.cache =
options.target === 'node' && options.watch ? { type: 'memory' } : undefined;
config.devtool =
options.sourceMap === 'hidden'
? 'hidden-source-map'
: options.sourceMap
? 'source-map'
: false;
config.output = {
...config.output,
path:
config.output?.path ??
(options.outputPath
? path.join(options.root, options.outputPath)
: undefined),
filename:
config.output?.filename ?? options.outputHashing
? `[name]${hashFormat.script}.js`
: '[name].js',
chunkFilename:
config.output?.chunkFilename ?? options.outputHashing
? `[name]${hashFormat.chunk}.js`
: '[name].js',
hashFunction: config.output?.hashFunction ?? 'xxhash64',
// Disabled for performance
pathinfo: config.output?.pathinfo ?? false,
// Use CJS for Node since it has the widest support.
scriptType:
config.output?.scriptType ?? options.target === 'node'
? undefined
: 'module',
};
config.watch = options.watch;
config.watchOptions = {
poll: options.poll,
};
config.profile = options.statsJson;
config.resolve = {
...config.resolve,
extensions: [...extensions, ...(config?.resolve?.extensions ?? [])],
alias: options.fileReplacements.reduce(
(aliases, replacement) => ({
...aliases,
[replacement.replace]: replacement.with,
}),
{}
),
mainFields,
};
config.externals = externals;
config.optimization = {
...config.optimization,
sideEffects: true,
minimize:
typeof options.optimization === 'object'
? !!options.optimization.scripts
: !!options.optimization,
minimizer: [
options.compiler !== 'swc'
? new TerserPlugin({
parallel: true,
terserOptions: {
keep_classnames: true,
ecma: getTerserEcmaVersion(
path.join(options.root, options.projectRoot)
),
safari10: true,
format: {
ascii_only: true,
comments: false,
webkit: true,
},
},
extractComments: false,
})
: new TerserPlugin({
minify: TerserPlugin.swcMinify,
// `terserOptions` options will be passed to `swc`
terserOptions: {
module: true,
mangle: false,
},
}),
],
runtimeChunk: false,
concatenateModules: true,
};
config.performance = {
...config.performance,
hints: false,
};
config.experiments = { ...config.experiments, cacheUnaffected: true };
config.ignoreWarnings = [
(x) =>
IGNORED_WEBPACK_WARNINGS.some((r) =>
typeof x === 'string' ? r.test(x) : r.test(x.message)
),
];
config.module = {
...config.module,
// Enabled for performance
unsafeCache: true,
rules: [
...(config?.module?.rules ?? []),
options.sourceMap && {
test: /\.js$/,
enforce: 'pre' as const,
loader: require.resolve('source-map-loader'),
},
{
// There's an issue resolving paths without fully specified extensions
// See: https://github.com/graphql/graphql-js/issues/2721
// TODO(jack): Add a flag to turn this option on like Next.js does via experimental flag.
// See: https://github.com/vercel/next.js/pull/29880
test: /\.m?jsx?$/,
resolve: {
fullySpecified: false,
},
},
// There's an issue when using buildable libs and .js files (instead of .ts files),
// where the wrong type is used (commonjs vs esm) resulting in export-imports throwing errors.
// See: https://github.com/nrwl/nx/issues/10990
{
test: /\.js$/,
type: 'javascript/auto',
},
createLoaderFromCompiler(options),
].filter((r) => !!r),
};
config.stats = {
hash: true,
timings: false,
cached: false,
cachedAssets: false,
modules: false,
warnings: true,
errors: true,
colors: !options.verbose && !options.statsJson,
chunks: !options.verbose,
assets: !!options.verbose,
chunkOrigins: !!options.verbose,
chunkModules: !!options.verbose,
children: !!options.verbose,
reasons: !!options.verbose,
version: !!options.verbose,
errorDetails: !!options.verbose,
moduleTrace: !!options.verbose,
usedExports: !!options.verbose,
};
config.plugins ??= [];
config.plugins.push(...plugins);
}

View File

@ -0,0 +1,402 @@
import * as path from 'path';
import { SubresourceIntegrityPlugin } from 'webpack-subresource-integrity';
import {
Configuration,
DefinePlugin,
ids,
RuleSetRule,
WebpackOptionsNormalized,
WebpackPluginInstance,
} from 'webpack';
import { WriteIndexHtmlPlugin } from '../../write-index-html-plugin';
import { NormalizedNxWebpackPluginOptions } from '../nx-webpack-plugin-options';
import { getOutputHashFormat } from '../../../utils/hash-format';
import { getClientEnvironment } from '../../../utils/get-client-environment';
import { normalizeExtraEntryPoints } from '../../../utils/webpack/normalize-entry';
import {
getCommonLoadersForCssModules,
getCommonLoadersForGlobalCss,
getCommonLoadersForGlobalStyle,
} from './stylesheet-loaders';
import { instantiateScriptPlugins } from './instantiate-script-plugins';
import CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
import MiniCssExtractPlugin = require('mini-css-extract-plugin');
export function applyWebConfig(
options: NormalizedNxWebpackPluginOptions,
config: Partial<WebpackOptionsNormalized | Configuration> = {}
): void {
const plugins: WebpackPluginInstance[] = [];
const stylesOptimization =
typeof options.optimization === 'object'
? options.optimization.styles
: options.optimization;
if (Array.isArray(options.scripts)) {
plugins.push(...instantiateScriptPlugins(options));
}
if (options.index && options.generateIndexHtml) {
plugins.push(
new WriteIndexHtmlPlugin({
crossOrigin: options.crossOrigin,
sri: options.subresourceIntegrity,
outputPath: path.basename(options.index),
indexPath: path.join(options.root, options.index),
baseHref: options.baseHref,
deployUrl: options.deployUrl,
scripts: options.scripts,
styles: options.styles,
})
);
}
if (options.subresourceIntegrity) {
plugins.push(new SubresourceIntegrityPlugin());
}
const minimizer: WebpackPluginInstance[] = [new ids.HashedModuleIdsPlugin()];
if (stylesOptimization) {
minimizer.push(
new CssMinimizerPlugin({
test: /\.(?:css|scss|sass|less|styl)$/,
})
);
}
if (!options.ssr) {
plugins.push(
new DefinePlugin(getClientEnvironment(process.env.NODE_ENV).stringified)
);
}
const entry: { [key: string]: { import: string[] } } = {};
const globalStylePaths: string[] = [];
// Determine hashing format.
const hashFormat = getOutputHashFormat(options.outputHashing as string);
const includePaths: string[] = [];
if (options?.stylePreprocessorOptions?.includePaths?.length > 0) {
options.stylePreprocessorOptions.includePaths.forEach(
(includePath: string) =>
includePaths.push(path.resolve(options.root, includePath))
);
}
let lessPathOptions: { paths?: string[] } = {};
if (includePaths.length > 0) {
lessPathOptions = {
paths: includePaths,
};
}
// Process global styles.
if (options.styles.length > 0) {
normalizeExtraEntryPoints(options.styles, 'styles').forEach((style) => {
const resolvedPath = path.resolve(options.root, style.input);
// Add style entry points.
if (entry[style.bundleName]) {
entry[style.bundleName].import.push(resolvedPath);
} else {
entry[style.bundleName] = { import: [resolvedPath] };
}
// Add global css paths.
globalStylePaths.push(resolvedPath);
});
}
const cssModuleRules: RuleSetRule[] = [
{
test: /\.module\.css$/,
exclude: globalStylePaths,
use: getCommonLoadersForCssModules(options, includePaths),
},
{
test: /\.module\.(scss|sass)$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForCssModules(options, includePaths),
{
loader: require.resolve('sass-loader'),
options: {
implementation: require('sass'),
sassOptions: {
fiber: false,
precision: 8,
includePaths,
},
},
},
],
},
{
test: /\.module\.less$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForCssModules(options, includePaths),
{
loader: require.resolve('less-loader'),
options: {
lessOptions: {
paths: includePaths,
},
},
},
],
},
{
test: /\.module\.styl$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForCssModules(options, includePaths),
{
loader: path.join(__dirname, 'webpack/deprecated-stylus-loader.js'),
options: {
stylusOptions: {
include: includePaths,
},
},
},
],
},
];
const globalCssRules: RuleSetRule[] = [
{
test: /\.css$/,
exclude: globalStylePaths,
use: getCommonLoadersForGlobalCss(options, includePaths),
},
{
test: /\.scss$|\.sass$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForGlobalCss(options, includePaths),
{
loader: require.resolve('sass-loader'),
options: {
implementation: require('sass'),
sourceMap: !!options.sourceMap,
sassOptions: {
fiber: false,
// bootstrap-sass requires a minimum precision of 8
precision: 8,
includePaths,
},
},
},
],
},
{
test: /\.less$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForGlobalCss(options, includePaths),
{
loader: require.resolve('less-loader'),
options: {
sourceMap: !!options.sourceMap,
lessOptions: {
javascriptEnabled: true,
...lessPathOptions,
},
},
},
],
},
{
test: /\.styl$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForGlobalCss(options, includePaths),
{
loader: path.join(__dirname, 'webpack/deprecated-stylus-loader.js'),
options: {
sourceMap: !!options.sourceMap,
stylusOptions: {
include: includePaths,
},
},
},
],
},
];
const globalStyleRules: RuleSetRule[] = [
{
test: /\.css$/,
include: globalStylePaths,
use: getCommonLoadersForGlobalStyle(options, includePaths),
},
{
test: /\.scss$|\.sass$/,
include: globalStylePaths,
use: [
...getCommonLoadersForGlobalStyle(options, includePaths),
{
loader: require.resolve('sass-loader'),
options: {
implementation: require('sass'),
sourceMap: !!options.sourceMap,
sassOptions: {
fiber: false,
// bootstrap-sass requires a minimum precision of 8
precision: 8,
includePaths,
},
},
},
],
},
{
test: /\.less$/,
include: globalStylePaths,
use: [
...getCommonLoadersForGlobalStyle(options, includePaths),
{
loader: require.resolve('less-loader'),
options: {
sourceMap: !!options.sourceMap,
lessOptions: {
javascriptEnabled: true,
...lessPathOptions,
},
},
},
],
},
{
test: /\.styl$/,
include: globalStylePaths,
use: [
...getCommonLoadersForGlobalStyle(options, includePaths),
{
loader: require.resolve('stylus-loader'),
options: {
sourceMap: !!options.sourceMap,
stylusOptions: {
include: includePaths,
},
},
},
],
},
];
const rules: RuleSetRule[] = [
{
test: /\.css$|\.scss$|\.sass$|\.less$|\.styl$/,
oneOf: [...cssModuleRules, ...globalCssRules, ...globalStyleRules],
},
];
plugins.push(
// extract global css from js files into own css file
new MiniCssExtractPlugin({
filename: `[name]${hashFormat.extract}.css`,
})
);
config.output = {
...config.output,
crossOriginLoading: options.subresourceIntegrity
? ('anonymous' as const)
: (false as const),
};
// In case users customize their webpack config with unsupported entry.
if (typeof config.entry === 'function')
throw new Error('Entry function is not supported. Use an object.');
if (typeof config.entry === 'string')
throw new Error('Entry string is not supported. Use an object.');
if (Array.isArray(config.entry))
throw new Error('Entry array is not supported. Use an object.');
config.entry = { ...config.entry, ...entry };
config.optimization = {
...config.optimization,
minimizer: [...config.optimization.minimizer, ...minimizer],
emitOnErrors: false,
moduleIds: 'deterministic' as const,
runtimeChunk: options.runtimeChunk ? { name: 'runtime' } : false,
splitChunks: {
defaultSizeTypes:
config.optimization.splitChunks !== false
? config.optimization.splitChunks?.defaultSizeTypes
: ['...'],
maxAsyncRequests: Infinity,
cacheGroups: {
default: !!options.commonChunk && {
chunks: 'async' as const,
minChunks: 2,
priority: 10,
},
common: !!options.commonChunk && {
name: 'common',
chunks: 'async' as const,
minChunks: 2,
enforce: true,
priority: 5,
},
vendors: false as const,
vendor: !!options.vendorChunk && {
name: 'vendor',
chunks: (chunk) => chunk.name === 'main',
enforce: true,
test: /[\\/]node_modules[\\/]/,
},
},
},
};
config.resolve.mainFields = ['browser', 'module', 'main'];
config.module = {
...config.module,
rules: [
...(config.module.rules ?? []),
// Images: Inline small images, and emit a separate file otherwise.
{
test: /\.(avif|bmp|gif|ico|jpe?g|png|webp)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10_000, // 10 kB
},
},
generator: {
filename: `[name]${hashFormat.file}[ext]`,
},
},
// SVG: same as image but we need to separate it so it can be swapped for SVGR in the React plugin.
{
test: /\.svg$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10_000, // 10 kB
},
},
generator: {
filename: `[name]${hashFormat.file}[ext]`,
},
},
// Fonts: Emit separate file and export the URL.
{
test: /\.(eot|otf|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: `[name]${hashFormat.file}[ext]`,
},
},
...rules,
],
};
config.plugins ??= [];
config.plugins.push(...plugins);
}

View File

@ -0,0 +1,87 @@
import * as path from 'path';
import { readTsConfig } from '@nx/js';
import { NormalizedNxWebpackPluginOptions } from '../nx-webpack-plugin-options';
export function createLoaderFromCompiler(
options: NormalizedNxWebpackPluginOptions
) {
switch (options.compiler) {
case 'swc':
return {
test: /\.([jt])sx?$/,
loader: require.resolve('swc-loader'),
exclude: /node_modules/,
options: {
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
loose: true,
},
},
};
case 'tsc':
const { loadTsTransformers } = require('@nx/js');
const { compilerPluginHooks, hasPlugin } = loadTsTransformers(
options.transformers
);
return {
test: /\.([jt])sx?$/,
loader: require.resolve(`ts-loader`),
exclude: /node_modules/,
options: {
configFile: options.tsConfig,
transpileOnly: !hasPlugin,
// https://github.com/TypeStrong/ts-loader/pull/685
experimentalWatchApi: true,
getCustomTransformers: (program) => ({
before: compilerPluginHooks.beforeHooks.map((hook) =>
hook(program)
),
after: compilerPluginHooks.afterHooks.map((hook) => hook(program)),
afterDeclarations: compilerPluginHooks.afterDeclarationsHooks.map(
(hook) => hook(program)
),
}),
},
};
case 'babel':
const tsConfig = readTsConfig(options.tsConfig);
const babelConfig = {
test: /\.([jt])sx?$/,
loader: path.join(__dirname, '../../../utils/web-babel-loader'),
exclude: /node_modules/,
options: {
cwd: path.join(options.root, options.sourceRoot),
emitDecoratorMetadata: tsConfig.options.emitDecoratorMetadata,
isModern: true,
isTest: process.env.NX_CYPRESS_COMPONENT_TEST === 'true',
envName: process.env.BABEL_ENV ?? process.env.NODE_ENV,
cacheDirectory: true,
cacheCompression: false,
},
};
if (options.babelUpwardRootMode) {
babelConfig.options['rootMode'] = 'upward';
babelConfig.options['babelrc'] = true;
} else {
babelConfig.options['configFile'] = options.babelConfig
? path.join(options.root, options.babelConfig)
: path.join(options.root, options.projectRoot, '.babelrc');
}
return babelConfig;
default:
return null;
}
}

View File

@ -0,0 +1,36 @@
import * as path from 'path';
import * as fs from 'fs';
import browserslist = require('browserslist');
const VALID_BROWSERSLIST_FILES = ['.browserslistrc', 'browserslist'];
const ES5_BROWSERS = [
'ie 10',
'ie 11',
'safari 11',
'safari 11.1',
'safari 12',
'safari 12.1',
'safari 13',
'ios_saf 13.0',
'ios_saf 13.3',
];
export function getTerserEcmaVersion(projectRoot: string): 2020 | 5 {
let pathToBrowserslistFile = '';
for (const browserslistFile of VALID_BROWSERSLIST_FILES) {
const fullPathToFile = path.join(projectRoot, browserslistFile);
if (fs.existsSync(fullPathToFile)) {
pathToBrowserslistFile = fullPathToFile;
break;
}
}
if (!pathToBrowserslistFile) {
return 2020;
}
const env = browserslist.loadConfig({ path: pathToBrowserslistFile });
const browsers = browserslist(env);
return browsers.some((b) => ES5_BROWSERS.includes(b)) ? 5 : 2020;
}

View File

@ -0,0 +1,58 @@
import * as path from 'path';
import { WebpackPluginInstance } from 'webpack';
import { getOutputHashFormat } from '../../../utils/hash-format';
import { ScriptsWebpackPlugin } from '../../../utils/webpack/plugins/scripts-webpack-plugin';
import { normalizeExtraEntryPoints } from '../../../utils/webpack/normalize-entry';
import { NormalizedNxWebpackPluginOptions } from '../nx-webpack-plugin-options';
export function instantiateScriptPlugins(
options: NormalizedNxWebpackPluginOptions
): WebpackPluginInstance[] {
// process global scripts
const globalScriptsByBundleName = normalizeExtraEntryPoints(
options.scripts || [],
'scripts'
).reduce(
(
prev: { inject: boolean; bundleName: string; paths: string[] }[],
curr
) => {
const bundleName = curr.bundleName;
const resolvedPath = path.resolve(options.root, curr.input);
const existingEntry = prev.find((el) => el.bundleName === bundleName);
if (existingEntry) {
existingEntry.paths.push(resolvedPath);
} else {
prev.push({
inject: curr.inject,
bundleName,
paths: [resolvedPath],
});
}
return prev;
},
[]
);
const hashFormat = getOutputHashFormat(options.outputHashing as string);
const plugins = [];
// Add a new asset for each entry.
globalScriptsByBundleName.forEach((script) => {
const hash = script.inject ? hashFormat.script : '';
const bundleName = script.bundleName;
plugins.push(
new ScriptsWebpackPlugin({
name: bundleName,
sourceMap: !!options.sourceMap,
filename: `${path.basename(bundleName)}${hash}.js`,
scripts: script.paths,
basePath: options.sourceRoot,
})
);
});
return plugins;
}

View File

@ -0,0 +1,155 @@
import { basename, dirname, relative, resolve } from 'path';
import { statSync } from 'fs';
import {
normalizePath,
readCachedProjectGraph,
workspaceRoot,
} from '@nx/devkit';
import {
AssetGlobPattern,
FileReplacement,
NormalizedNxWebpackPluginOptions,
NxWebpackPluginOptions,
} from '../nx-webpack-plugin-options';
export function normalizeOptions(
options: NxWebpackPluginOptions
): NormalizedNxWebpackPluginOptions {
const combinedOptions: Partial<NormalizedNxWebpackPluginOptions> = {};
const isProd = process.env.NODE_ENV === 'production';
const projectName = process.env.NX_TASK_TARGET_PROJECT;
const targetName = process.env.NX_TASK_TARGET_TARGET;
const configurationName = process.env.NX_TASK_TARGET_CONFIGURATION;
// Since this is invoked by the executor, the graph has already been created and cached.
const projectGraph = readCachedProjectGraph();
const projectNode = projectGraph.nodes[projectName];
const targetConfig = projectNode.data.targets[targetName];
// Merge options from `@nx/webpack:webpack` into plugin options.
// Options from `@nx/webpack:webpack` take precedence.
const originalTargetOptions = targetConfig.options;
if (configurationName) {
Object.assign(
originalTargetOptions,
targetConfig.configurations?.[configurationName]
);
}
// This could be called from dev-server which means we need to read `buildTarget` to get actual build options.
// Otherwise, the options are passed from the `@nx/webpack:webpack` executor.
if (originalTargetOptions.buildTarget) {
const buildTargetOptions = targetConfig.options;
if (configurationName) {
Object.assign(
buildTargetOptions,
targetConfig.configurations?.[configurationName]
);
}
Object.assign(combinedOptions, buildTargetOptions);
} else {
Object.assign(combinedOptions, originalTargetOptions, options);
}
const sourceRoot = projectNode.data.sourceRoot ?? projectNode.data.root;
if (!options.main)
throw new Error(
`Missing "main" option for the entry file. Set this option in your Nx webpack plugin.`
);
return {
...options,
assets: options.assets
? normalizeAssets(options.assets, workspaceRoot, sourceRoot)
: [],
baseHref: options.baseHref ?? '/',
commonChunk: options.commonChunk ?? true,
compiler: options.compiler ?? 'babel',
configurationName,
deleteOutputPath: options.deleteOutputPath ?? true,
extractCss: options.extractCss ?? true,
fileReplacements: normalizeFileReplacements(
workspaceRoot,
options.fileReplacements
),
generateIndexHtml: options.generateIndexHtml ?? true,
main: options.main,
namedChunks: options.namedChunks ?? !isProd,
optimization: options.optimization ?? isProd,
outputFileName: options.outputFileName ?? 'main.js',
outputHashing: options.outputHashing ?? (isProd ? 'all' : 'none'),
outputPath: options.outputPath,
projectGraph,
projectName,
projectRoot: projectNode.data.root,
root: workspaceRoot,
runtimeChunk: options.runtimeChunk ?? true,
scripts: options.scripts ?? [],
sourceMap: options.sourceMap ?? !isProd,
sourceRoot,
styles: options.styles ?? [],
target: options.target ?? 'web',
targetName,
vendorChunk: options.vendorChunk ?? !isProd,
};
}
export function normalizeAssets(
assets: any[],
root: string,
sourceRoot: string
): AssetGlobPattern[] {
return assets.map((asset) => {
if (typeof asset === 'string') {
const assetPath = normalizePath(asset);
const resolvedAssetPath = resolve(root, assetPath);
const resolvedSourceRoot = resolve(root, sourceRoot);
if (!resolvedAssetPath.startsWith(resolvedSourceRoot)) {
throw new Error(
`The ${resolvedAssetPath} asset path must start with the project source root: ${sourceRoot}`
);
}
const isDirectory = statSync(resolvedAssetPath).isDirectory();
const input = isDirectory
? resolvedAssetPath
: dirname(resolvedAssetPath);
const output = relative(resolvedSourceRoot, resolve(root, input));
const glob = isDirectory ? '**/*' : basename(resolvedAssetPath);
return {
input,
output,
glob,
};
} else {
if (asset.output.startsWith('..')) {
throw new Error(
'An asset cannot be written to a location outside of the output path.'
);
}
const assetPath = normalizePath(asset.input);
const resolvedAssetPath = resolve(root, assetPath);
return {
...asset,
input: resolvedAssetPath,
// Now we remove starting slash to make Webpack place it from the output root.
output: asset.output.replace(/^\//, ''),
};
}
});
}
function normalizeFileReplacements(
root: string,
fileReplacements: FileReplacement[]
): FileReplacement[] {
return fileReplacements
? fileReplacements.map((fileReplacement) => ({
replace: resolve(root, fileReplacement.replace),
with: resolve(root, fileReplacement.with),
}))
: [];
}

View File

@ -0,0 +1,142 @@
import * as path from 'path';
import autoprefixer = require('autoprefixer');
import postcssImports = require('postcss-import');
import MiniCssExtractPlugin = require('mini-css-extract-plugin');
import { getCSSModuleLocalIdent } from '../../../utils/get-css-module-local-ident';
import { getOutputHashFormat } from '../../../utils/hash-format';
import { NormalizedNxWebpackPluginOptions } from '../nx-webpack-plugin-options';
import { PostcssCliResources } from '../../../utils/webpack/plugins/postcss-cli-resources';
interface PostcssOptions {
(loader: any): any;
config?: string;
}
export function getCommonLoadersForCssModules(
options: NormalizedNxWebpackPluginOptions,
includePaths: string[]
) {
// load component css as raw strings
return [
{
loader: options.extractCss
? MiniCssExtractPlugin.loader
: require.resolve('style-loader'),
},
{
loader: require.resolve('css-loader'),
options: {
modules: {
mode: 'local',
getLocalIdent: getCSSModuleLocalIdent,
},
importLoaders: 1,
},
},
{
loader: require.resolve('postcss-loader'),
options: {
implementation: require('postcss'),
postcssOptions: postcssOptionsCreator(options, {
includePaths,
forCssModules: true,
}),
},
},
];
}
export function getCommonLoadersForGlobalCss(
options: NormalizedNxWebpackPluginOptions,
includePaths: string[]
) {
return [
{
loader: options.extractCss
? MiniCssExtractPlugin.loader
: require.resolve('style-loader'),
},
{ loader: require.resolve('css-loader'), options: { url: false } },
{
loader: require.resolve('postcss-loader'),
options: {
implementation: require('postcss'),
postcssOptions: postcssOptionsCreator(options, {
includePaths,
}),
},
},
];
}
export function getCommonLoadersForGlobalStyle(
options: NormalizedNxWebpackPluginOptions,
includePaths: string[]
) {
return [
{
loader: MiniCssExtractPlugin.loader,
options: { esModule: true },
},
{ loader: require.resolve('css-loader'), options: { url: false } },
{
loader: require.resolve('postcss-loader'),
options: {
implementation: require('postcss'),
postcssOptions: postcssOptionsCreator(options, {
includePaths,
}),
},
},
];
}
function postcssOptionsCreator(
options: NormalizedNxWebpackPluginOptions,
{
includePaths,
forCssModules = false,
}: {
includePaths: string[];
forCssModules?: boolean;
}
) {
const hashFormat = getOutputHashFormat(options.outputHashing as string);
// PostCSS options depend on the webpack loader, but we need to set the `config` path as a string due to this check:
// https://github.com/webpack-contrib/postcss-loader/blob/0d342b1/src/utils.js#L36
const postcssOptions: PostcssOptions = (loader) => ({
map: options.sourceMap &&
options.sourceMap !== 'hidden' && {
inline: true,
annotation: false,
},
plugins: [
postcssImports({
addModulesDirectories: includePaths,
resolve: (url: string) => (url.startsWith('~') ? url.slice(1) : url),
}),
...(forCssModules
? []
: [
PostcssCliResources({
baseHref: options.baseHref,
deployUrl: options.deployUrl,
loader,
filename: `[name]${hashFormat.file}.[ext]`,
}),
autoprefixer(),
]),
],
});
// If a path to postcssConfig is passed in, set it for app and all libs, otherwise
// use automatic detection.
if (typeof options.postcssConfig === 'string') {
postcssOptions.config = path.join(options.root, options.postcssConfig);
}
return postcssOptions;
}

View File

@ -0,0 +1,100 @@
import { ProjectGraph } from '@nx/devkit';
import { AssetGlob } from '@nx/js/src/utils/assets/assets';
export interface AssetGlobPattern {
glob: string;
input: string;
output: string;
ignore?: string[];
}
export interface ExtraEntryPointClass {
bundleName?: string;
inject?: boolean;
input: string;
lazy?: boolean;
}
export interface FileReplacement {
replace: string;
with: string;
}
export interface AdditionalEntryPoint {
entryName: string;
entryPath: string;
}
export interface TransformerPlugin {
name: string;
options: Record<string, unknown>;
}
export type TransformerEntry = string | TransformerPlugin;
export interface OptimizationOptions {
scripts: boolean;
styles: boolean;
}
export interface NxWebpackPluginOptions {
// Required options
main: string;
outputPath: string;
tsConfig: string;
// Optional options
additionalEntryPoints?: AdditionalEntryPoint[];
assets?: Array<AssetGlob | string>;
babelConfig?: string;
babelUpwardRootMode?: boolean;
baseHref?: string;
commonChunk?: boolean;
compiler?: 'babel' | 'swc' | 'tsc';
crossOrigin?: 'none' | 'anonymous' | 'use-credentials';
deleteOutputPath?: boolean;
deployUrl?: string;
externalDependencies?: 'all' | 'none' | string[];
extractCss?: boolean;
extractLicenses?: boolean;
fileReplacements?: FileReplacement[];
generateIndexHtml?: boolean;
generatePackageJson?: boolean;
index?: string;
memoryLimit?: number;
namedChunks?: boolean;
optimization?: boolean | OptimizationOptions;
outputFileName?: string;
outputHashing?: any;
poll?: number;
polyfills?: string;
postcssConfig?: string;
progress?: boolean;
runtimeChunk?: boolean;
scripts?: Array<ExtraEntryPointClass | string>;
skipTypeChecking?: boolean;
sourceMap?: boolean | 'hidden';
ssr?: boolean;
statsJson?: boolean;
stylePreprocessorOptions?: any;
styles?: Array<ExtraEntryPointClass | string>;
subresourceIntegrity?: boolean;
target?: string | string[];
transformers?: TransformerEntry[];
vendorChunk?: boolean;
verbose?: boolean;
watch?: boolean;
}
export interface NormalizedNxWebpackPluginOptions
extends NxWebpackPluginOptions {
projectName: string;
root: string;
projectRoot: string;
sourceRoot: string;
configurationName: string;
targetName: string;
projectGraph: ProjectGraph;
outputFileName: string;
assets: AssetGlobPattern[];
}

View File

@ -0,0 +1,61 @@
import { Compiler } from 'webpack';
import {
NormalizedNxWebpackPluginOptions,
NxWebpackPluginOptions,
} from './nx-webpack-plugin-options';
import { normalizeOptions } from './lib/normalize-options';
import { deleteOutputDir } from '../../utils/fs';
import { applyBaseConfig } from './lib/apply-base-config';
import { applyWebConfig } from './lib/apply-web-config';
/**
* This plugin provides features to build Node and Web applications.
* - TS support (including tsconfig paths)
* - Different compiler options
* - Assets handling
* - Stylesheets handling
* - index.html and package.json generation
*
* Web-only features, such as stylesheets and images, are only supported when `target` is 'web' or 'webworker'.
*/
export class NxWebpackPlugin {
private readonly options: NormalizedNxWebpackPluginOptions;
constructor(options: NxWebpackPluginOptions) {
this.options = normalizeOptions({
...options,
...this.readExecutorOptions(),
});
}
apply(compiler: Compiler): void {
const target = this.options.target ?? compiler.options.target;
this.options.outputPath ??= compiler.options.output?.path;
if (typeof target === 'string') {
this.options.target = target;
}
if (this.options.deleteOutputPath) {
deleteOutputDir(this.options.root, this.options.outputPath);
}
applyBaseConfig(this.options, compiler.options);
if (compiler.options.target) {
this.options.target = compiler.options.target;
}
if (this.options.target === 'web' || this.options.target === 'webworker') {
applyWebConfig(this.options, compiler.options);
}
}
private readExecutorOptions() {
const fromExecutor = process.env['NX_WEBPACK_EXECUTOR_RAW_OPTIONS'] ?? '{}';
try {
return JSON.parse(fromExecutor);
} catch {
return {};
}
}
}

View File

@ -1,63 +1,6 @@
import * as path from 'path';
import { join } from 'path';
import { Configuration, ProgressPlugin, WebpackPluginInstance } from 'webpack';
import { ExecutorContext } from 'nx/src/config/misc-interfaces';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
import { readTsConfig } from '@nx/js';
import { LicenseWebpackPlugin } from 'license-webpack-plugin';
import { NormalizedWebpackExecutorOptions } from '../executors/webpack/schema';
import { StatsJsonPlugin } from '../plugins/stats-json-plugin';
import { createCopyPlugin } from './create-copy-plugin';
import { GeneratePackageJsonPlugin } from '../plugins/generate-package-json-plugin';
import { getOutputHashFormat } from './hash-format';
import { NxWebpackPlugin } from './config';
import { existsSync } from 'fs';
import TerserPlugin = require('terser-webpack-plugin');
import nodeExternals = require('webpack-node-externals');
import ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
import browserslist = require('browserslist');
const VALID_BROWSERSLIST_FILES = ['.browserslistrc', 'browserslist'];
const ES5_BROWSERS = [
'ie 10',
'ie 11',
'safari 11',
'safari 11.1',
'safari 12',
'safari 12.1',
'safari 13',
'ios_saf 13.0',
'ios_saf 13.3',
];
function getTerserEcmaVersion(projectRoot: string) {
let pathToBrowserslistFile = '';
for (const browserslistFile of VALID_BROWSERSLIST_FILES) {
const fullPathToFile = join(projectRoot, browserslistFile);
if (existsSync(fullPathToFile)) {
pathToBrowserslistFile = fullPathToFile;
break;
}
}
if (!pathToBrowserslistFile) {
return 2020;
}
const env = browserslist.loadConfig({ path: pathToBrowserslistFile });
const browsers = browserslist(env);
return browsers.some((b) => ES5_BROWSERS.includes(b)) ? 5 : 2020;
}
const IGNORED_WEBPACK_WARNINGS = [
/The comment file/i,
/could not find any license/i,
];
const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx'];
const mainFields = ['module', 'main'];
import { Configuration } from 'webpack';
import { NxWebpackExecutionContext, NxWebpackPlugin } from './config';
import { applyBaseConfig } from '../plugins/nx-webpack-plugin/lib/apply-base-config';
const processed = new Set();
@ -72,362 +15,24 @@ export interface WithNxOptions {
export function withNx(pluginOptions?: WithNxOptions): NxWebpackPlugin {
return function configure(
config: Configuration,
{
options,
context,
}: {
options: NormalizedWebpackExecutorOptions;
context: ExecutorContext;
}
{ options, context }: NxWebpackExecutionContext
): Configuration {
if (processed.has(config)) return config;
const plugins: WebpackPluginInstance[] = [];
if (!pluginOptions?.skipTypeChecking) {
plugins.push(
new ForkTsCheckerWebpackPlugin({
typescript: {
configFile: options.tsConfig,
memoryLimit: options.memoryLimit || 2018,
},
})
);
}
const entry = {};
if (options.main) {
const mainEntry = options.outputFileName
? path.parse(options.outputFileName).name
: 'main';
entry[mainEntry] = [options.main];
}
if (options.additionalEntryPoints) {
for (const { entryName, entryPath } of options.additionalEntryPoints) {
entry[entryName] = entryPath;
}
}
if (options.polyfills) {
entry['polyfills'] = [
...(entry['polyfills'] || []),
path.resolve(options.root, options.polyfills),
];
}
if (options.progress) {
plugins.push(new ProgressPlugin({ profile: options.verbose }));
}
if (options.extractLicenses) {
plugins.push(
new LicenseWebpackPlugin({
stats: {
warnings: false,
errors: false,
},
perChunkOutput: false,
outputFilename: `3rdpartylicenses.txt`,
}) as unknown as WebpackPluginInstance
);
}
if (Array.isArray(options.assets) && options.assets.length > 0) {
plugins.push(createCopyPlugin(options.assets));
}
if (options.generatePackageJson && context) {
plugins.push(new GeneratePackageJsonPlugin(options, context));
}
if (options.statsJson) {
plugins.push(new StatsJsonPlugin());
}
let externals = [];
if (options.target === 'node' && options.externalDependencies === 'all') {
const modulesDir = `${options.root}/node_modules`;
externals.push(nodeExternals({ modulesDir }));
} else if (Array.isArray(options.externalDependencies)) {
externals.push(function (ctx, callback: Function) {
if (options.externalDependencies.includes(ctx.request)) {
// not bundled
return callback(null, `commonjs ${ctx.request}`);
}
// bundled
callback();
});
}
const hashFormat = getOutputHashFormat(options.outputHashing as string);
const filename = options.outputHashing
? `[name]${hashFormat.script}.js`
: '[name].js';
const chunkFilename = options.outputHashing
? `[name]${hashFormat.chunk}.js`
: '[name].js';
const updated = {
...config,
context: context
? path.join(context.root, options.projectRoot)
: undefined,
target: options.target,
node: false as const,
mode:
// When the target is Node avoid any optimizations, such as replacing `process.env.NODE_ENV` with build time value.
options.target === ('node' as const)
? 'none'
: // Otherwise, make sure it matches `process.env.NODE_ENV`.
// When mode is development or production, webpack will automatically
// configure DefinePlugin to replace `process.env.NODE_ENV` with the
// build-time value. Thus, we need to make sure it's the same value to
// avoid conflicts.
//
// When the NODE_ENV is something else (e.g. test), then set it to none
// to prevent extra behavior from webpack.
process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'production'
? (process.env.NODE_ENV as 'development' | 'production')
: ('none' as const),
// When target is Node, the Webpack mode will be set to 'none' which disables in memory caching and causes a full rebuild on every change.
// So to mitigate this we enable in memory caching when target is Node and in watch mode.
cache:
options.target === ('node' as const) && options.watch
? { type: 'memory' as const }
: undefined,
devtool:
options.sourceMap === 'hidden'
? 'hidden-source-map'
: options.sourceMap
? 'source-map'
: (false as const),
entry,
output: {
...config.output,
libraryTarget: options.target === 'node' ? 'commonjs' : undefined,
path: options.outputPath,
filename,
chunkFilename,
hashFunction: 'xxhash64',
// Disabled for performance
pathinfo: false,
// Use CJS for Node since it has the widest support.
scriptType: options.target === 'node' ? undefined : ('module' as const),
applyBaseConfig(
{
...options,
...pluginOptions,
root: context.root,
projectName: context.projectName,
targetName: context.targetName,
configurationName: context.configurationName,
projectGraph: context.projectGraph,
},
watch: options.watch,
watchOptions: {
poll: options.poll,
},
profile: options.statsJson,
resolve: {
...config.resolve,
extensions: [...extensions, ...(config?.resolve?.extensions ?? [])],
alias: options.fileReplacements.reduce(
(aliases, replacement) => ({
...aliases,
[replacement.replace]: replacement.with,
}),
{}
),
plugins: [
...(config.resolve?.plugins ?? []),
new TsconfigPathsPlugin({
configFile: options.tsConfig,
extensions: [...extensions, ...(config?.resolve?.extensions ?? [])],
mainFields,
}),
],
mainFields,
},
externals,
optimization: {
...config.optimization,
sideEffects: true,
minimize:
typeof options.optimization === 'object'
? !!options.optimization.scripts
: !!options.optimization,
minimizer: [
options.compiler !== 'swc'
? new TerserPlugin({
parallel: true,
terserOptions: {
keep_classnames: true,
ecma: getTerserEcmaVersion(
join(options.root, options.projectRoot)
),
safari10: true,
format: {
ascii_only: true,
comments: false,
webkit: true,
},
},
extractComments: false,
})
: new TerserPlugin({
minify: TerserPlugin.swcMinify,
// `terserOptions` options will be passed to `swc`
terserOptions: {
module: true,
mangle: false,
},
}),
],
runtimeChunk: false,
concatenateModules: true,
},
performance: {
...config.performance,
hints: false as const,
},
experiments: { ...config.experiments, cacheUnaffected: true },
ignoreWarnings: [
(x) =>
IGNORED_WEBPACK_WARNINGS.some((r) =>
typeof x === 'string' ? r.test(x) : r.test(x.message)
),
],
module: {
...config.module,
// Enabled for performance
unsafeCache: true,
rules: [
...(config?.module?.rules ?? []),
options.sourceMap && {
test: /\.js$/,
enforce: 'pre' as const,
loader: require.resolve('source-map-loader'),
},
{
// There's an issue resolving paths without fully specified extensions
// See: https://github.com/graphql/graphql-js/issues/2721
// TODO(jack): Add a flag to turn this option on like Next.js does via experimental flag.
// See: https://github.com/vercel/next.js/pull/29880
test: /\.m?jsx?$/,
resolve: {
fullySpecified: false,
},
},
// There's an issue when using buildable libs and .js files (instead of .ts files),
// where the wrong type is used (commonjs vs esm) resulting in export-imports throwing errors.
// See: https://github.com/nrwl/nx/issues/10990
{
test: /\.js$/,
type: 'javascript/auto',
},
createLoaderFromCompiler(options),
].filter((r) => !!r),
},
plugins: (config.plugins ?? []).concat(plugins),
stats: {
hash: true,
timings: false,
cached: false,
cachedAssets: false,
modules: false,
warnings: true,
errors: true,
colors: !options.verbose && !options.statsJson,
chunks: !options.verbose,
assets: !!options.verbose,
chunkOrigins: !!options.verbose,
chunkModules: !!options.verbose,
children: !!options.verbose,
reasons: !!options.verbose,
version: !!options.verbose,
errorDetails: !!options.verbose,
moduleTrace: !!options.verbose,
usedExports: !!options.verbose,
},
};
config
);
processed.add(updated);
return updated;
processed.add(config);
return config;
};
}
export function createLoaderFromCompiler(
options: NormalizedWebpackExecutorOptions
) {
switch (options.compiler) {
case 'swc':
return {
test: /\.([jt])sx?$/,
loader: require.resolve('swc-loader'),
exclude: /node_modules/,
options: {
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
tsx: true,
},
transform: {
react: {
runtime: 'automatic',
},
},
loose: true,
},
},
};
case 'tsc':
const { loadTsTransformers } = require('@nx/js');
const { compilerPluginHooks, hasPlugin } = loadTsTransformers(
options.transformers
);
return {
test: /\.([jt])sx?$/,
loader: require.resolve(`ts-loader`),
exclude: /node_modules/,
options: {
configFile: options.tsConfig,
transpileOnly: !hasPlugin,
// https://github.com/TypeStrong/ts-loader/pull/685
experimentalWatchApi: true,
getCustomTransformers: (program) => ({
before: compilerPluginHooks.beforeHooks.map((hook) =>
hook(program)
),
after: compilerPluginHooks.afterHooks.map((hook) => hook(program)),
afterDeclarations: compilerPluginHooks.afterDeclarationsHooks.map(
(hook) => hook(program)
),
}),
},
};
case 'babel':
const tsConfig = readTsConfig(options.tsConfig);
const babelConfig = {
test: /\.([jt])sx?$/,
loader: path.join(__dirname, './web-babel-loader'),
exclude: /node_modules/,
options: {
cwd: path.join(options.root, options.sourceRoot),
emitDecoratorMetadata: tsConfig.options.emitDecoratorMetadata,
isModern: true,
isTest: process.env.NX_CYPRESS_COMPONENT_TEST === 'true',
envName: process.env.BABEL_ENV ?? process.env.NODE_ENV,
cacheDirectory: true,
cacheCompression: false,
},
};
if (options.babelUpwardRootMode) {
babelConfig.options['rootMode'] = 'upward';
babelConfig.options['babelrc'] = true;
} else {
babelConfig.options['configFile'] = options.babelConfig
? path.join(options.root, options.babelConfig)
: path.join(options.root, options.projectRoot, '.babelrc');
}
return babelConfig;
default:
return null;
}
}

View File

@ -1,36 +1,11 @@
import * as webpack from 'webpack';
import {
Configuration,
ids,
RuleSetRule,
WebpackPluginInstance,
} from 'webpack';
import { SubresourceIntegrityPlugin } from 'webpack-subresource-integrity';
import * as path from 'path';
import { basename, join } from 'path';
import { getOutputHashFormat } from './hash-format';
import { PostcssCliResources } from './webpack/plugins/postcss-cli-resources';
import { normalizeExtraEntryPoints } from './webpack/normalize-entry';
import { Configuration } from 'webpack';
import { NxWebpackExecutionContext, NxWebpackPlugin } from './config';
import {
ExtraEntryPointClass,
NormalizedWebpackExecutorOptions,
} from '../executors/webpack/schema';
import { getClientEnvironment } from './get-client-environment';
import { ScriptsWebpackPlugin } from './webpack/plugins/scripts-webpack-plugin';
import { getCSSModuleLocalIdent } from './get-css-module-local-ident';
import { WriteIndexHtmlPlugin } from '../plugins/write-index-html-plugin';
import CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
import MiniCssExtractPlugin = require('mini-css-extract-plugin');
import autoprefixer = require('autoprefixer');
import postcssImports = require('postcss-import');
interface PostcssOptions {
(loader: any): any;
config?: string;
}
import { applyWebConfig } from '../plugins/nx-webpack-plugin/lib/apply-web-config';
const processed = new Set();
@ -63,559 +38,23 @@ export type MergedOptions = Omit<
export function withWeb(pluginOptions: WithWebOptions = {}): NxWebpackPlugin {
return function configure(
config: Configuration,
{ options: executorOptions, context }: NxWebpackExecutionContext
{ options, context }: NxWebpackExecutionContext
): Configuration {
if (processed.has(config)) return config;
const mergedOptions: MergedOptions = {
...executorOptions,
...pluginOptions,
};
const plugins = [];
const stylesOptimization =
typeof mergedOptions.optimization === 'object'
? mergedOptions.optimization.styles
: mergedOptions.optimization;
if (Array.isArray(mergedOptions.scripts)) {
plugins.push(...createScriptsPlugin(mergedOptions));
}
if (mergedOptions.index && mergedOptions.generateIndexHtml) {
plugins.push(
new WriteIndexHtmlPlugin({
crossOrigin: mergedOptions.crossOrigin,
sri: mergedOptions.subresourceIntegrity,
outputPath: basename(mergedOptions.index),
indexPath: join(context.root, mergedOptions.index),
baseHref: mergedOptions.baseHref,
deployUrl: mergedOptions.deployUrl,
scripts: mergedOptions.scripts,
styles: mergedOptions.styles,
})
);
}
if (mergedOptions.subresourceIntegrity) {
plugins.push(new SubresourceIntegrityPlugin());
}
const minimizer: WebpackPluginInstance[] = [
new ids.HashedModuleIdsPlugin(),
];
if (stylesOptimization) {
minimizer.push(
new CssMinimizerPlugin({
test: /\.(?:css|scss|sass|less|styl)$/,
})
);
}
if (!pluginOptions.ssr) {
plugins.push(
new webpack.DefinePlugin(
getClientEnvironment(process.env.NODE_ENV).stringified
)
);
}
const entry: { [key: string]: string[] } = {};
const globalStylePaths: string[] = [];
// Determine hashing format.
const hashFormat = getOutputHashFormat(
mergedOptions.outputHashing as string
applyWebConfig(
{
...options,
...pluginOptions,
projectName: context.projectName,
targetName: context.targetName,
configurationName: context.configurationName,
projectGraph: context.projectGraph,
},
config
);
const includePaths: string[] = [];
if (mergedOptions?.stylePreprocessorOptions?.includePaths?.length > 0) {
mergedOptions.stylePreprocessorOptions.includePaths.forEach(
(includePath: string) =>
includePaths.push(path.resolve(mergedOptions.root, includePath))
);
}
let lessPathOptions: { paths?: string[] } = {};
if (includePaths.length > 0) {
lessPathOptions = {
paths: includePaths,
};
}
// Process global styles.
if (mergedOptions.styles.length > 0) {
normalizeExtraEntryPoints(mergedOptions.styles, 'styles').forEach(
(style) => {
const resolvedPath = path.resolve(mergedOptions.root, style.input);
// Add style entry points.
if (entry[style.bundleName]) {
entry[style.bundleName].push(resolvedPath);
} else {
entry[style.bundleName] = [resolvedPath];
}
// Add global css paths.
globalStylePaths.push(resolvedPath);
}
);
}
const cssModuleRules: RuleSetRule[] = [
{
test: /\.module\.css$/,
exclude: globalStylePaths,
use: getCommonLoadersForCssModules(mergedOptions, includePaths),
},
{
test: /\.module\.(scss|sass)$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForCssModules(mergedOptions, includePaths),
{
loader: require.resolve('sass-loader'),
options: {
implementation: require('sass'),
sassOptions: {
fiber: false,
precision: 8,
includePaths,
},
},
},
],
},
{
test: /\.module\.less$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForCssModules(mergedOptions, includePaths),
{
loader: require.resolve('less-loader'),
options: {
lessOptions: {
paths: includePaths,
},
},
},
],
},
{
test: /\.module\.styl$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForCssModules(mergedOptions, includePaths),
{
loader: join(__dirname, 'webpack/deprecated-stylus-loader.js'),
options: {
stylusOptions: {
include: includePaths,
},
},
},
],
},
];
const globalCssRules: RuleSetRule[] = [
{
test: /\.css$/,
exclude: globalStylePaths,
use: getCommonLoadersForGlobalCss(mergedOptions, includePaths),
},
{
test: /\.scss$|\.sass$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForGlobalCss(mergedOptions, includePaths),
{
loader: require.resolve('sass-loader'),
options: {
implementation: require('sass'),
sourceMap: !!mergedOptions.sourceMap,
sassOptions: {
fiber: false,
// bootstrap-sass requires a minimum precision of 8
precision: 8,
includePaths,
},
},
},
],
},
{
test: /\.less$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForGlobalCss(mergedOptions, includePaths),
{
loader: require.resolve('less-loader'),
options: {
sourceMap: !!mergedOptions.sourceMap,
lessOptions: {
javascriptEnabled: true,
...lessPathOptions,
},
},
},
],
},
{
test: /\.styl$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForGlobalCss(mergedOptions, includePaths),
{
loader: join(__dirname, 'webpack/deprecated-stylus-loader.js'),
options: {
sourceMap: !!mergedOptions.sourceMap,
stylusOptions: {
include: includePaths,
},
},
},
],
},
];
const globalStyleRules: RuleSetRule[] = [
{
test: /\.css$/,
include: globalStylePaths,
use: getCommonLoadersForGlobalStyle(mergedOptions, includePaths),
},
{
test: /\.scss$|\.sass$/,
include: globalStylePaths,
use: [
...getCommonLoadersForGlobalStyle(mergedOptions, includePaths),
{
loader: require.resolve('sass-loader'),
options: {
implementation: require('sass'),
sourceMap: !!mergedOptions.sourceMap,
sassOptions: {
fiber: false,
// bootstrap-sass requires a minimum precision of 8
precision: 8,
includePaths,
},
},
},
],
},
{
test: /\.less$/,
include: globalStylePaths,
use: [
...getCommonLoadersForGlobalStyle(mergedOptions, includePaths),
{
loader: require.resolve('less-loader'),
options: {
sourceMap: !!mergedOptions.sourceMap,
lessOptions: {
javascriptEnabled: true,
...lessPathOptions,
},
},
},
],
},
{
test: /\.styl$/,
include: globalStylePaths,
use: [
...getCommonLoadersForGlobalStyle(mergedOptions, includePaths),
{
loader: require.resolve('stylus-loader'),
options: {
sourceMap: !!mergedOptions.sourceMap,
stylusOptions: {
include: includePaths,
},
},
},
],
},
];
const rules: RuleSetRule[] = [
{
test: /\.css$|\.scss$|\.sass$|\.less$|\.styl$/,
oneOf: [...cssModuleRules, ...globalCssRules, ...globalStyleRules],
},
];
plugins.push(
// extract global css from js files into own css file
new MiniCssExtractPlugin({
filename: `[name]${hashFormat.extract}.css`,
})
);
config.output = {
...config.output,
crossOriginLoading: mergedOptions.subresourceIntegrity
? ('anonymous' as const)
: (false as const),
};
// In case users customize their webpack config with unsupported entry.
if (typeof config.entry === 'function')
throw new Error('Entry function is not supported. Use an object.');
if (typeof config.entry === 'string')
throw new Error('Entry string is not supported. Use an object.');
if (Array.isArray(config.entry))
throw new Error('Entry array is not supported. Use an object.');
config.entry = { ...config.entry, ...entry };
config.optimization = {
...config.optimization,
minimizer: [...config.optimization.minimizer, ...minimizer],
emitOnErrors: false,
moduleIds: 'deterministic' as const,
runtimeChunk: mergedOptions.runtimeChunk ? ('single' as const) : false,
splitChunks: {
maxAsyncRequests: Infinity,
cacheGroups: {
default: !!mergedOptions.commonChunk && {
chunks: 'async' as const,
minChunks: 2,
priority: 10,
},
common: !!mergedOptions.commonChunk && {
name: 'common',
chunks: 'async' as const,
minChunks: 2,
enforce: true,
priority: 5,
},
vendors: false as const,
vendor: !!mergedOptions.vendorChunk && {
name: 'vendor',
chunks: (chunk) => chunk.name === 'main',
enforce: true,
test: /[\\/]node_modules[\\/]/,
},
},
},
};
config.plugins.push(...plugins);
config.resolve.mainFields = ['browser', 'module', 'main'];
config.module = {
...config.module,
rules: [
...(config.module.rules ?? []),
// Images: Inline small images, and emit a separate file otherwise.
{
test: /\.(avif|bmp|gif|ico|jpe?g|png|webp)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10_000, // 10 kB
},
},
generator: {
filename: `[name]${hashFormat.file}[ext]`,
},
},
// SVG: same as image but we need to separate it so it can be swapped for SVGR in the React plugin.
{
test: /\.svg$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10_000, // 10 kB
},
},
generator: {
filename: `[name]${hashFormat.file}[ext]`,
},
},
// Fonts: Emit separate file and export the URL.
{
test: /\.(eot|otf|ttf|woff|woff2)$/,
type: 'asset/resource',
generator: {
filename: `[name]${hashFormat.file}[ext]`,
},
},
...rules,
],
};
processed.add(config);
return config;
};
}
function createScriptsPlugin(options: MergedOptions): WebpackPluginInstance[] {
// process global scripts
const globalScriptsByBundleName = normalizeExtraEntryPoints(
options.scripts || [],
'scripts'
).reduce(
(
prev: { inject: boolean; bundleName: string; paths: string[] }[],
curr
) => {
const bundleName = curr.bundleName;
const resolvedPath = path.resolve(options.root, curr.input);
const existingEntry = prev.find((el) => el.bundleName === bundleName);
if (existingEntry) {
existingEntry.paths.push(resolvedPath);
} else {
prev.push({
inject: curr.inject,
bundleName,
paths: [resolvedPath],
});
}
return prev;
},
[]
);
const hashFormat = getOutputHashFormat(options.outputHashing as string);
const plugins = [];
// Add a new asset for each entry.
globalScriptsByBundleName.forEach((script) => {
const hash = script.inject ? hashFormat.script : '';
const bundleName = script.bundleName;
plugins.push(
new ScriptsWebpackPlugin({
name: bundleName,
sourceMap: !!options.sourceMap,
filename: `${basename(bundleName)}${hash}.js`,
scripts: script.paths,
basePath: options.sourceRoot,
})
);
});
return plugins;
}
function getCommonLoadersForCssModules(
options: MergedOptions,
includePaths: string[]
) {
// load component css as raw strings
return [
{
loader: options.extractCss
? MiniCssExtractPlugin.loader
: require.resolve('style-loader'),
},
{
loader: require.resolve('css-loader'),
options: {
modules: {
mode: 'local',
getLocalIdent: getCSSModuleLocalIdent,
},
importLoaders: 1,
},
},
{
loader: require.resolve('postcss-loader'),
options: {
implementation: require('postcss'),
postcssOptions: postcssOptionsCreator(options, {
includePaths,
forCssModules: true,
}),
},
},
];
}
function getCommonLoadersForGlobalCss(
options: MergedOptions,
includePaths: string[]
) {
return [
{
loader: options.extractCss
? MiniCssExtractPlugin.loader
: require.resolve('style-loader'),
},
{ loader: require.resolve('css-loader'), options: { url: false } },
{
loader: require.resolve('postcss-loader'),
options: {
implementation: require('postcss'),
postcssOptions: postcssOptionsCreator(options, { includePaths }),
},
},
];
}
function getCommonLoadersForGlobalStyle(
options: MergedOptions,
includePaths: string[]
) {
return [
{
loader: MiniCssExtractPlugin.loader,
options: { esModule: true },
},
{ loader: require.resolve('css-loader'), options: { url: false } },
{
loader: require.resolve('postcss-loader'),
options: {
implementation: require('postcss'),
postcssOptions: postcssOptionsCreator(options, { includePaths }),
},
},
];
}
function postcssOptionsCreator(
options: MergedOptions,
{
includePaths,
forCssModules = false,
}: { includePaths: string[]; forCssModules?: boolean }
) {
const hashFormat = getOutputHashFormat(options.outputHashing as string);
// PostCSS options depend on the webpack loader, but we need to set the `config` path as a string due to this check:
// https://github.com/webpack-contrib/postcss-loader/blob/0d342b1/src/utils.js#L36
const postcssOptions: PostcssOptions = (loader) => ({
map: options.sourceMap &&
options.sourceMap !== 'hidden' && {
inline: true,
annotation: false,
},
plugins: [
postcssImports({
addModulesDirectories: includePaths,
resolve: (url: string) => (url.startsWith('~') ? url.slice(1) : url),
}),
...(forCssModules
? []
: [
PostcssCliResources({
baseHref: options.baseHref,
deployUrl: options.deployUrl,
loader,
filename: `[name]${hashFormat.file}.[ext]`,
}),
autoprefixer(),
]),
],
});
// If a path to postcssConfig is passed in, set it for app and all libs, otherwise
// use automatic detection.
if (typeof options.postcssConfig === 'string') {
postcssOptions.config = path.join(options.root, options.postcssConfig);
}
return postcssOptions;
}