Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Nov 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions e2e/webpack/src/webpack.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
cleanupProject,
newProject,
packageInstall,
rmDist,
runCLI,
runCommand,
Expand All @@ -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');
Expand Down Expand Up @@ -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`,
Expand All @@ -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);
});
1 change: 1 addition & 0 deletions packages/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
76 changes: 3 additions & 73 deletions packages/react/plugins/with-react.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,13 @@
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();

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}
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/webpack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
22 changes: 17 additions & 5 deletions packages/webpack/src/executors/dev-server/dev-server.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
69 changes: 2 additions & 67 deletions packages/webpack/src/executors/webpack/lib/normalize-options.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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:
Expand All @@ -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 '';
Expand All @@ -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(/^\//, ''),
};
}
});
}
8 changes: 4 additions & 4 deletions packages/webpack/src/executors/webpack/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading