feat(webpack): add convertConfigToWebpackPlugin (#26516)

This PR introduces functionality for users who currently use the
`withNx` and `withReact` plugins in their webpack configuration to
migrate to the `NxAppWebpackPlugin`.

The `nxUseLegacyPlugin` wraps the legacy style function so that it
continues to work with the standardized generated webpack config.

By implementing this change, the aim is to provide a consistent method
for users opting to transition to inferred targets. This ensures a
smoother migration process, offering better integration and reducing
potential configuration complexities.
This commit is contained in:
Nicholas Cunningham 2024-06-21 06:55:23 -06:00 committed by GitHub
parent 1d1c699c81
commit b1dbf47aa2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1231 additions and 18 deletions

View File

@ -9867,6 +9867,14 @@
"children": [],
"isExternal": false,
"disableCollapsible": false
},
{
"id": "convert-config-to-webpack-plugin",
"path": "/nx-api/webpack/generators/convert-config-to-webpack-plugin",
"name": "convert-config-to-webpack-plugin",
"children": [],
"isExternal": false,
"disableCollapsible": false
}
],
"isExternal": false,

View File

@ -3215,6 +3215,15 @@
"originalFilePath": "/packages/webpack/src/generators/configuration/schema.json",
"path": "/nx-api/webpack/generators/configuration",
"type": "generator"
},
"/nx-api/webpack/generators/convert-config-to-webpack-plugin": {
"description": "Convert the project to use the `NxAppWebpackPlugin` and `NxReactWebpackPlugin`.",
"file": "generated/packages/webpack/generators/convert-config-to-webpack-plugin.json",
"hidden": false,
"name": "convert-config-to-webpack-plugin",
"originalFilePath": "/packages/webpack/src/generators/convert-config-to-webpack-plugin/schema.json",
"path": "/nx-api/webpack/generators/convert-config-to-webpack-plugin",
"type": "generator"
}
},
"path": "/nx-api/webpack"

View File

@ -3180,6 +3180,15 @@
"originalFilePath": "/packages/webpack/src/generators/configuration/schema.json",
"path": "webpack/generators/configuration",
"type": "generator"
},
{
"description": "Convert the project to use the `NxAppWebpackPlugin` and `NxReactWebpackPlugin`.",
"file": "generated/packages/webpack/generators/convert-config-to-webpack-plugin.json",
"hidden": false,
"name": "convert-config-to-webpack-plugin",
"originalFilePath": "/packages/webpack/src/generators/convert-config-to-webpack-plugin/schema.json",
"path": "webpack/generators/convert-config-to-webpack-plugin",
"type": "generator"
}
],
"githubRoot": "https://github.com/nrwl/nx/blob/master",

View File

@ -0,0 +1,30 @@
{
"name": "convert-config-to-webpack-plugin",
"factory": "./src/generators/convert-config-to-webpack-plugin/convert-config-to-webpack-plugin",
"schema": {
"$schema": "https://json-schema.org/schema",
"$id": "NxWebpackConvertConfigToWebpackPlugin",
"description": "Convert existing Webpack project(s) using `@nx/webpack:webpack` executor that uses `withNx` to use `NxAppWebpackPlugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.",
"title": "Convert Webpack project using withNx to NxAppWebpackPlugin",
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "The project to convert from using the `@nx/webpack:webpack` executor and `withNx` plugin to use `NxAppWebpackPlugin`.",
"x-priority": "important"
},
"skipFormat": {
"type": "boolean",
"description": "Whether to format files at the end of the migration.",
"default": false
}
},
"presets": []
},
"description": "Convert the project to use the `NxAppWebpackPlugin` and `NxReactWebpackPlugin`.",
"implementation": "/packages/webpack/src/generators/convert-config-to-webpack-plugin/convert-config-to-webpack-plugin.ts",
"aliases": [],
"hidden": false,
"path": "/packages/webpack/src/generators/convert-config-to-webpack-plugin/schema.json",
"type": "generator"
}

View File

@ -709,6 +709,7 @@
- [generators](/nx-api/webpack/generators)
- [init](/nx-api/webpack/generators/init)
- [configuration](/nx-api/webpack/generators/configuration)
- [convert-config-to-webpack-plugin](/nx-api/webpack/generators/convert-config-to-webpack-plugin)
- [workspace](/nx-api/workspace)
- [documents](/nx-api/workspace/documents)
- [Overview](/nx-api/workspace/documents/overview)

View File

@ -0,0 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Webpack Plugin (legacy) ConvertConfigToWebpackPlugin, should convert withNx webpack config to a standard config using NxWebpackPlugin 1`] = `
"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'
// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.
const options = {};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
plugins: [
new NxAppWebpackPlugin(),
new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old'), options),
],
});
"
`;
exports[`Webpack Plugin (legacy) ConvertConfigToWebpackPlugin, should convert withNx webpack config to a standard config using NxWebpackPlugin 2`] = `
"{
"name": "app3224373",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "app3224373/src",
"tags": [],
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"target": "web",
"outputPath": "dist/app3224373",
"compiler": "swc",
"main": "app3224373/src/main.ts",
"tsConfig": "app3224373/tsconfig.app.json",
"webpackConfig": "app3224373/webpack.config.js",
"assets": ["app3224373/src/favicon.ico", "app3224373/src/assets"],
"index": "app3224373/src/index.html",
"baseHref": "/",
"styles": ["app3224373/src/styles.css"],
"scripts": [],
"standardWebpackConfigFunction": true
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"fileReplacements": [
{
"replace": "app3224373/src/environments/environment.ts",
"with": "app3224373/src/environments/environment.prod.ts"
}
]
}
}
},
"serve": {
"executor": "@nx/webpack:dev-server",
"options": {
"buildTarget": "app3224373:build"
},
"configurations": {
"production": {
"buildTarget": "app3224373:build:production"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "app3224373/jest.config.ts"
}
}
}
}
"
`;
exports[`Webpack Plugin (legacy) ConvertConfigToWebpackPlugin, should convert withNx webpack config to a standard config using NxWebpackPlugin 3`] = `
"const { composePlugins } = require('@nx/webpack');
// Nx plugins for webpack.
module.exports = composePlugins((config) => {
// Update the webpack config as needed here.
// e.g. \`config.plugins.push(new MyPlugin())\`
return config;
});
"
`;

View File

@ -3,6 +3,7 @@ import {
cleanupProject,
killProcessAndPorts,
newProject,
readFile,
runCLI,
runCommandUntil,
runE2ETests,
@ -118,21 +119,21 @@ describe('Webpack Plugin (legacy)', () => {
updateFile(
`${appName}/webpack.config.js`,
`
const { join } = require('path');
const {NxWebpackPlugin} = require('@nx/webpack');
module.exports = {
output: {
path: join(__dirname, '../dist/app9524918'),
},
plugins: [
new NxAppWebpackPlugin({
main: './src/main.ts',
compiler: 'tsc',
index: './src/index.html',
tsConfig: './tsconfig.app.json',
})
]
};
const { join } = require('path');
const {NxWebpackPlugin} = require('@nx/webpack');
module.exports = {
output: {
path: join(__dirname, '../dist/app9524918'),
},
plugins: [
new NxAppWebpackPlugin({
main: './src/main.ts',
compiler: 'tsc',
index: './src/index.html',
tsConfig: './tsconfig.app.json',
})
]
};
`
);
@ -146,4 +147,45 @@ module.exports = {
}).not.toThrow();
}
});
describe('ConvertConfigToWebpackPlugin,', () => {
it('should convert withNx webpack config to a standard config using NxWebpackPlugin', () => {
const appName = 'app3224373'; // Needs to be reserved so that the snapshot projectName matches
runCLI(
`generate @nx/web:app ${appName} --bundler webpack --e2eTestRunner=playwright --projectNameAndRootFormat=as-provided`
);
updateFile(
`${appName}/src/main.ts`,
`
const root = document.querySelector('proj-root');
if(root) {
root.innerHTML = '<h1>Welcome</h1>'
}
`
);
runCLI(
`generate @nx/webpack:convert-config-to-webpack-plugin --project ${appName}`
);
const webpackConfig = readFile(`${appName}/webpack.config.js`);
const oldWebpackConfig = readFile(`${appName}/webpack.config.old.js`);
const projectJSON = readFile(`${appName}/project.json`);
expect(webpackConfig).toMatchSnapshot();
expect(projectJSON).toMatchSnapshot(); // This file should be updated adding standardWebpackConfigFunction: true
expect(oldWebpackConfig).toMatchSnapshot(); // This file should be renamed and updated to not include `withNx`, `withReact`, and `withWeb`.
expect(() => {
runCLI(`build ${appName}`);
}).not.toThrow();
if (runE2ETests()) {
expect(() => {
runCLI(`e2e ${appName}-e2e`);
}).not.toThrow();
}
}, 600_000);
});
});

View File

@ -15,6 +15,11 @@
"schema": "./src/generators/configuration/schema.json",
"description": "Add webpack configuration to a project.",
"hidden": true
},
"convert-config-to-webpack-plugin": {
"factory": "./src/generators/convert-config-to-webpack-plugin/convert-config-to-webpack-plugin",
"schema": "./src/generators/convert-config-to-webpack-plugin/schema.json",
"description": "Convert the project to use the `NxAppWebpackPlugin` and `NxReactWebpackPlugin`."
}
}
}

View File

@ -1,8 +1,14 @@
import { configurationGenerator } from './src/generators/configuration/configuration';
import { NxAppWebpackPlugin } from './src/plugins/nx-webpack-plugin/nx-app-webpack-plugin';
import { NxTsconfigPathsWebpackPlugin as _NxTsconfigPathsWebpackPlugin } from './src/plugins/nx-typescript-webpack-plugin/nx-tsconfig-paths-webpack-plugin';
import { convertConfigToWebpackPluginGenerator } from './src/generators/convert-config-to-webpack-plugin/convert-config-to-webpack-plugin';
import { useLegacyNxPlugin } from './src/plugins/use-legacy-nx-plugin/use-legacy-nx-plugin';
export { configurationGenerator };
export {
configurationGenerator,
convertConfigToWebpackPluginGenerator,
useLegacyNxPlugin,
};
// Exported for backwards compatibility in case a plugin is using the old name.
/** @deprecated Use `configurationGenerator` instead. */

View File

@ -31,6 +31,7 @@
},
"dependencies": {
"@babel/core": "^7.23.2",
"@phenomnomnominal/tsquery": "~5.0.1",
"ajv": "^8.12.0",
"autoprefixer": "^10.4.9",
"babel-loader": "^9.1.2",

View File

@ -102,7 +102,12 @@ export async function* devServerExecutor(
);
} else if (userDefinedWebpackConfig) {
// New behavior, we want the webpack config to export object
config = userDefinedWebpackConfig;
// If the config is a function, we assume it's a standard webpack config function and it's async
if (typeof userDefinedWebpackConfig === 'function') {
config = await userDefinedWebpackConfig(process.env.NODE_ENV, {});
} else {
config = userDefinedWebpackConfig;
}
config.devServer ??= devServer;
}
}

View File

@ -75,6 +75,11 @@ async function getWebpackConfigs(
configuration: context.configurationName, // backwards compat
});
} else if (userDefinedWebpackConfig) {
if (typeof userDefinedWebpackConfig === 'function') {
// assume it's an async standard webpack config function
// https://webpack.js.org/configuration/configuration-types/#exporting-a-promise
return await userDefinedWebpackConfig(process.env.NODE_ENV, {});
}
// New behavior, we want the webpack config to export object
return userDefinedWebpackConfig;
} else {

View File

@ -0,0 +1,429 @@
import {
ProjectConfiguration,
Tree,
addProjectConfiguration,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import convertConfigToWebpackPluginGenerator from './convert-config-to-webpack-plugin';
interface CreateProjectOptions {
name: string;
root: string;
targetName: string;
targetOptions: Record<string, unknown>;
additionalTargets?: Record<string, unknown>;
}
const defaultOptions: CreateProjectOptions = {
name: 'my-app',
root: 'my-app',
targetName: 'build',
targetOptions: {},
};
function createProject(tree: Tree, options: Partial<CreateProjectOptions>) {
const projectOpts = {
...defaultOptions,
...options,
targetOptions: {
...defaultOptions.targetOptions,
...options?.targetOptions,
},
};
const project: ProjectConfiguration = {
name: projectOpts.name,
root: projectOpts.root,
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
webpackConfig: `${projectOpts.root}/webpack.config.js`,
...projectOpts.targetOptions,
},
},
...options.additionalTargets,
},
};
addProjectConfiguration(tree, project.name, project);
return project;
}
describe('convertConfigToWebpackPluginGenerator', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should migrate the webpack config of the specified project', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
createProject(tree, {
name: 'another-app',
root: 'another-app',
});
tree.write(
'another-app/webpack.config.js',
`
const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx(),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
}
);
`
);
tree.write(
`${project.name}/webpack.config.js`,
`
const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx(),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
}
);
`
);
await convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
});
expect(tree.read(`${project.name}/webpack.config.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'
// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.
const options = {};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
plugins: [
new NxAppWebpackPlugin(),
new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old'), options),
],
});
"
`);
expect(tree.read(`${project.name}/webpack.config.old.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"const { composePlugins } = require('@nx/webpack');
// Nx plugins for webpack.
module.exports = composePlugins((config) => {
return config;
});
"
`);
expect(tree.read(`another-app/webpack.config.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx(),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
}
);
"
`);
expect(tree.exists(`${project.name}/webpack.config.old.js`)).toBe(true);
expect(tree.exists(`another-app/webpack.config.old.js`)).toBe(false);
});
it('should update project.json adding the standardWebpackConfigFunction option', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
tree.write(
`${project.name}/webpack.config.js`,
`
const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx(),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
}
);
`
);
await convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
});
expect(tree.read(`${project.name}/project.json`, 'utf-8'))
.toMatchInlineSnapshot(`
"{
"name": "my-app",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"options": {
"webpackConfig": "my-app/webpack.config.js",
"standardWebpackConfigFunction": true
}
}
}
}
"
`);
});
it('should throw an error if no projects are found', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
await expect(
convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
})
).rejects.toThrowError('Could not find any projects to migrate.');
});
it('should not migrate a webpack config that does not use withNx', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
tree.write(`${project.name}/webpack.config.js`, `module.exports = {};`);
await expect(
convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
})
).rejects.toThrowError('Could not find any projects to migrate.');
expect(
tree.read(`${project.name}/webpack.config.js`, 'utf-8')
).toMatchInlineSnapshot(`"module.exports = {};"`);
});
it('should throw an error if the project is using Module federation', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
additionalTargets: {
serve: {
executor: '@nx/react:module-federation-dev-server',
options: {
buildTarget: 'my-app:build',
},
},
},
});
await expect(
convertConfigToWebpackPluginGenerator(tree, { project: project.name })
).rejects.toThrowError(
`The project ${project.name} is using Module Federation. At the moment, we don't support migrating projects that use Module Federation.`
);
});
it('should throw an error if the project is a Nest project', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
additionalTargets: {
serve: {
executor: '@nx/js:node',
options: {
buildTarget: 'my-app:build',
},
},
},
});
await expect(
convertConfigToWebpackPluginGenerator(tree, { project: project.name })
).rejects.toThrowError(
`The project ${project.name} is using the '@nx/js:node' executor. At the moment, we do not support migrating such projects.`
);
});
it('should not migrate a webpack config that is already using NxAppWebpackPlugin', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
tree.write(
`${project.name}/webpack.config.js`,
`
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
module.exports = {
plugins: [
new NxAppWebpackPlugin(),
],
};
`
);
await expect(
convertConfigToWebpackPluginGenerator(tree, { project: project.name })
).rejects.toThrowError(`Could not find any projects to migrate.`);
expect(tree.read(`${project.name}/webpack.config.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
module.exports = {
plugins: [
new NxAppWebpackPlugin(),
],
};
"
`);
expect(tree.exists(`${project.name}/webpack.config.old.js`)).toBe(false);
});
it('should convert absolute options paths to relative paths during the conversion', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'apps/my-app',
});
tree.write(
`${project.root}/webpack.config.js`,
`
const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx({
assets: ["apps/${project.name}/src/favicon.ico","apps/${project.name}/src/assets"],
styles: ["apps/${project.name}/src/styles.scss"],
scripts: ["apps/${project.name}/src/scripts.js"],
tsConfig: "apps/${project.name}/tsconfig.app.json",
fileReplacements: [
{
replace: "apps/${project.name}/src/environments/environment.ts",
with: "apps/${project.name}/src/environments/environment.prod.ts"
}
],
additionalEntryPoints: [
{
entryPath: "apps/${project.name}/src/polyfills.ts",
}
]
}),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
}
);
`
);
await convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
});
expect(tree.read(`${project.root}/webpack.config.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'
// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.
const options = {
assets: ['./src/favicon.ico', './src/assets'],
styles: ['./src/styles.scss'],
scripts: ['./src/scripts.js'],
tsConfig: './tsconfig.app.json',
fileReplacements: [
{
replace: './src/environments/environment.ts',
with: './src/environments/environment.prod.ts',
},
],
additionalEntryPoints: [
{
entryPath: './src/polyfills.ts',
},
],
};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
plugins: [
new NxAppWebpackPlugin(options),
new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old'), options),
],
});
"
`);
});
});

View File

@ -0,0 +1,135 @@
import {
formatFiles,
getProjects,
stripIndents,
Tree,
joinPathFragments,
updateProjectConfiguration,
ProjectConfiguration,
} from '@nx/devkit';
import { forEachExecutorOptions } from '@nx/devkit/src/generators/executor-options-utils';
import { WebpackExecutorOptions } from '../../executors/webpack/schema';
import { extractWebpackOptions } from './lib/extract-webpack-options';
import { normalizePathOptions } from './lib/normalize-path-options';
import { parse } from 'path';
import { validateProject } from './lib/validate-project';
interface Schema {
project?: string;
skipFormat?: boolean;
}
// Make text JSON compatible
const preprocessText = (text: string) => {
return text
.replace(/(\w+):/g, '"$1":') // Quote property names
.replace(/'/g, '"') // Convert single quotes to double quotes
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas
.replace(/(\r\n|\n|\r|\t)/gm, ''); // Remove newlines and tabs
};
export async function convertConfigToWebpackPluginGenerator(
tree: Tree,
options: Schema
) {
let migrated = 0;
const projects = getProjects(tree);
forEachExecutorOptions<WebpackExecutorOptions>(
tree,
'@nx/webpack:webpack',
(currentTargetOptions, projectName, targetName, configurationName) => {
if (options.project && projectName !== options.project) {
return;
}
if (!configurationName) {
const project = projects.get(projectName);
const target = project.targets[targetName];
const hasError = validateProject(tree, project);
if (hasError) {
throw new Error(hasError);
}
const webpackConfigPath = currentTargetOptions?.webpackConfig || '';
if (webpackConfigPath && tree.exists(webpackConfigPath)) {
let { withNxConfig: webpackOptions, withReactConfig } =
extractWebpackOptions(tree, webpackConfigPath);
// if webpackOptions === undefined
// withNx was not found in the webpack.config.js file so we should skip this project
if (webpackOptions !== undefined) {
let parsedOptions = {};
if (webpackOptions) {
parsedOptions = JSON.parse(
preprocessText(webpackOptions.getText())
);
parsedOptions = normalizePathOptions(project.root, parsedOptions);
}
target.options.standardWebpackConfigFunction = true;
updateProjectConfiguration(tree, projectName, project);
const { dir, name, ext } = parse(webpackConfigPath);
tree.rename(
webpackConfigPath,
`${joinPathFragments(dir, `${name}.old${ext}`)}`
);
tree.write(
webpackConfigPath,
stripIndents`
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'
// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.
const options = ${
webpackOptions ? JSON.stringify(parsedOptions, null, 2) : '{}'
};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
plugins: [
${
webpackOptions
? 'new NxAppWebpackPlugin(options)'
: 'new NxAppWebpackPlugin()'
},
${
withReactConfig
? `new NxReactWebpackPlugin(${withReactConfig.getText()})`
: `new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
})`
},
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old'), options),
],
});
`
);
migrated++;
}
}
}
}
);
if (migrated === 0) {
throw new Error('Could not find any projects to migrate.');
}
if (!options.skipFormat) {
await formatFiles(tree);
}
}
export default convertConfigToWebpackPluginGenerator;

View File

@ -0,0 +1,176 @@
import { Tree } from '@nx/devkit';
import { tsquery } from '@phenomnomnominal/tsquery';
import * as ts from 'typescript';
export function extractWebpackOptions(tree: Tree, webpackConfigPath: string) {
const source = tree.read(webpackConfigPath).toString('utf-8');
const ast = tsquery.ast(source);
const withNxQuery = 'CallExpression:has(Identifier[name="withNx"])';
const withReactQuery = 'CallExpression:has(Identifier[name="withReact"])';
const withWebQuery = 'CallExpression:has(Identifier[name="withWeb"])';
const withNxCall = tsquery(ast, withNxQuery) as ts.CallExpression[];
const withReactCall = tsquery(ast, withReactQuery) as ts.CallExpression[];
const withWebCall = tsquery(ast, withWebQuery) as ts.CallExpression[];
// If the config is empty set to empty string to avoid undefined. Undefined is used to check if the withNx exists inside of the config file.
let withNxConfig: ts.Node | '' | undefined,
withReactConfig: ts.Node | '' | undefined;
withWebCall.forEach((node) => {
const argument = node.arguments[0] || '';
withNxConfig = argument; // Since withWeb and withNx use the same config object and both should not exist in the same file, we can reuse the withNxConfig variable.
});
withNxCall.forEach((node) => {
const argument = node.arguments[0] || ''; // The first argument is the config object
withNxConfig = argument;
});
withReactCall.forEach((node) => {
const argument = node.arguments[0] || '';
withReactConfig = argument;
});
if (withNxConfig !== undefined) {
// Only remove the withNx and withReact calls if they exist
let updatedSource = removeCallExpressions(source, [
'withNx',
'withReact',
'withWeb',
]);
updatedSource = removeImportDeclarations(
updatedSource,
'withNx',
'@nx/webpack'
);
updatedSource = removeImportDeclarations(
updatedSource,
'withWeb',
'@nx/webpack'
);
updatedSource = removeImportDeclarations(
updatedSource,
'withReact',
'@nx/react'
);
tree.write(webpackConfigPath, updatedSource);
}
return { withNxConfig, withReactConfig };
}
function removeCallExpressions(
source: string,
functionNames: string[]
): string {
let modifiedSource = source;
functionNames.forEach((functionName) => {
const callExpressionQuery = `CallExpression:has(Identifier[name="composePlugins"]) > CallExpression:has(Identifier[name="${functionName}"])`;
modifiedSource = tsquery.replace(
modifiedSource,
callExpressionQuery,
() => {
return ''; // Removes the entire CallExpression
}
);
});
return modifiedSource;
}
function removeImportDeclarations(
source: string,
importName: string,
moduleName: string
) {
const sourceFile = tsquery.ast(source);
const modifiedStatements = sourceFile.statements
.map((statement) => {
if (!ts.isVariableStatement(statement)) return statement;
const declarationList = statement.declarationList;
const newDeclarations = declarationList.declarations
.map((declaration) => {
if (
!ts.isVariableDeclaration(declaration) ||
!declaration.initializer
)
return declaration;
if (
ts.isCallExpression(declaration.initializer) &&
ts.isIdentifier(declaration.initializer.expression)
) {
const callExpr = declaration.initializer.expression;
if (
callExpr.text === 'require' &&
declaration.initializer.arguments[0]
?.getText()
.replace(/['"]/g, '') === moduleName
) {
if (ts.isObjectBindingPattern(declaration.name)) {
const bindingElements = declaration.name.elements.filter(
(element) => {
const elementName = element.name.getText();
return elementName !== importName;
}
);
if (bindingElements.length > 0) {
const newBindingPattern =
ts.factory.updateObjectBindingPattern(
declaration.name,
bindingElements
);
// Update the variable declaration with the new binding pattern without the specified import name
return ts.factory.updateVariableDeclaration(
declaration,
newBindingPattern,
declaration.exclamationToken,
declaration.type,
declaration.initializer
);
} else {
return null; // Remove this declaration entirely if no bindings remain
}
}
}
}
return declaration;
})
.filter(Boolean);
if (newDeclarations.length > 0) {
const newDeclarationList = ts.factory.updateVariableDeclarationList(
declarationList,
newDeclarations as ts.VariableDeclaration[]
);
return ts.factory.updateVariableStatement(
statement,
statement.modifiers,
newDeclarationList
);
} else {
return null; // Remove the entire statement
}
})
.filter(Boolean);
// Use printer to format the source code and rewrite the modified
const newSourceFile = ts.factory.updateSourceFile(
sourceFile,
modifiedStatements as ts.Statement[]
);
const printer = ts.createPrinter();
const formattedSource = printer.printFile(newSourceFile);
return formattedSource;
}

View File

@ -0,0 +1,92 @@
import { WebpackExecutorOptions } from '../../../executors/webpack/schema';
import { toProjectRelativePath } from './utils';
const executorFieldsToNormalize: Array<keyof WebpackExecutorOptions> = [
'outputPath',
'index',
'main',
'assets',
'tsConfig',
'styles',
'babelConfig',
'additionalEntryPoints',
'scripts',
'fileReplacements',
'postcssConfig',
'stylePreprocessorOptions',
'publicPath',
];
export function normalizePathOptions(
projectRoot: string,
options: Partial<WebpackExecutorOptions>
) {
for (const [key, value] of Object.entries(options)) {
if (
!executorFieldsToNormalize.includes(key as keyof WebpackExecutorOptions)
) {
continue;
}
options[key] = normalizePath(
projectRoot,
key as keyof WebpackExecutorOptions,
value
);
}
return options;
}
function normalizePath<K extends keyof WebpackExecutorOptions>(
projectRoot: string,
key: K,
value: WebpackExecutorOptions[K]
) {
if (!value) return value;
switch (key) {
case 'assets':
return value.map((asset) => {
if (typeof asset === 'string') {
return toProjectRelativePath(asset, projectRoot);
}
return {
...asset,
input: toProjectRelativePath(asset.input, projectRoot),
output: toProjectRelativePath(asset.output, projectRoot),
};
});
case 'styles':
case 'scripts':
return value.map((item) => {
if (typeof item === 'string') {
return toProjectRelativePath(item, projectRoot);
}
return {
...item,
input: toProjectRelativePath(item.input, projectRoot),
};
});
case 'additionalEntryPoints':
return value.map((entry) => {
return {
...entry,
entryPath: toProjectRelativePath(entry.entryPath, projectRoot),
};
});
case 'fileReplacements':
return value.map((replacement) => {
return {
replace: toProjectRelativePath(replacement.replace, projectRoot),
with: toProjectRelativePath(replacement.with, projectRoot),
};
});
default:
return Array.isArray(value)
? value.map((item) => toProjectRelativePath(item, projectRoot))
: toProjectRelativePath(value, projectRoot);
}
}

View File

@ -0,0 +1,19 @@
import { relative, resolve } from 'path/posix';
import { workspaceRoot } from '@nx/devkit';
export function toProjectRelativePath(
path: string,
projectRoot: string
): string {
if (projectRoot === '.') {
// workspace and project root are the same, we normalize it to ensure it
return path.startsWith('.') ? path : `./${path}`;
}
const relativePath = relative(
resolve(workspaceRoot, projectRoot),
resolve(workspaceRoot, path)
);
return relativePath.startsWith('.') ? relativePath : `./${relativePath}`;
}

View File

@ -0,0 +1,47 @@
import { ProjectConfiguration, Tree } from '@nx/devkit';
function hasAnotherWebpackConfig(tree: Tree, projectRoot: string) {
const files = tree.children(projectRoot);
const projectJsonString = tree.read(`${projectRoot}/project.json`, 'utf-8');
for (const file of files) {
if (
file !== 'webpack.config.js' &&
file.endsWith('.js') &&
file.includes('webpack.config') &&
projectJsonString.includes(file) &&
tree.exists(`${projectRoot}/webpack.config.js`)
) {
return 'Cannot convert a project with multiple webpack config files. Please consolidate them into a single webpack.config.js file.';
}
}
}
function isNestProject(project: ProjectConfiguration) {
for (const target in project.targets) {
if (project.targets[target].executor === '@nx/js:node') {
return `The project ${project.name} is using the '@nx/js:node' executor. At the moment, we do not support migrating such projects.`;
}
}
}
/**
* Validates the project to ensure it can be migrated
*
* @param tree The virtaul file system
* @param project the project configuration object for the project
* @returns A string if there is an error, otherwise undefined
*/
export function validateProject(tree: Tree, project: ProjectConfiguration) {
const containsMfeExecutor = Object.keys(project.targets).some((target) => {
return [
'@nx/react:module-federation-dev-server',
'@nx/angular:module-federation-dev-server',
].includes(project.targets[target].executor);
});
if (containsMfeExecutor) {
return `The project ${project.name} is using Module Federation. At the moment, we don't support migrating projects that use Module Federation.`;
}
const hasAnotherConfig = hasAnotherWebpackConfig(tree, project.root);
return hasAnotherConfig || isNestProject(project);
}

View File

@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/schema",
"$id": "NxWebpackConvertConfigToWebpackPlugin",
"description": "Convert existing Webpack project(s) using `@nx/webpack:webpack` executor that uses `withNx` to use `NxAppWebpackPlugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.",
"title": "Convert Webpack project using withNx to NxAppWebpackPlugin",
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "The project to convert from using the `@nx/webpack:webpack` executor and `withNx` plugin to use `NxAppWebpackPlugin`.",
"x-priority": "important"
},
"skipFormat": {
"type": "boolean",
"description": "Whether to format files at the end of the migration.",
"default": false
}
}
}

View File

@ -22,6 +22,8 @@ import {
import { instantiateScriptPlugins } from './instantiate-script-plugins';
import CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
import MiniCssExtractPlugin = require('mini-css-extract-plugin');
import { getDevServerOptions } from '../../../executors/dev-server/lib/get-dev-server-config';
import { NormalizedWebpackExecutorOptions } from '../../../executors/webpack/schema';
export function applyWebConfig(
options: NormalizedNxAppWebpackPluginOptions,

View File

@ -76,7 +76,7 @@ export function normalizeOptions(
const sourceRoot = projectNode.data.sourceRoot ?? projectNode.data.root;
if (!options.main) {
if (!combinedPluginAndMaybeExecutorOptions.main) {
throw new Error(
`Missing "main" option for the entry file. Set this option in your Nx webpack plugin.`
);

View File

@ -0,0 +1,65 @@
import { ExecutorContext, readCachedProjectGraph } from '@nx/devkit';
import { NxWebpackExecutionContext } from '../../utils/config';
import { NxAppWebpackPluginOptions } from '../nx-webpack-plugin/nx-app-webpack-plugin-options';
import { Configuration } from 'webpack';
import { normalizeOptions } from '../nx-webpack-plugin/lib/normalize-options';
/**
* This function is used to wrap the legacy plugin function to be used with the `composePlugins` function.
* Initially the webpack config would be passed to the legacy plugin function and the options would be passed as a second argument.
* example:
* module.exports = composePlugins(
withNx(),
(config) => {
return config;
}
);
Since composePlugins is async, this function is used to wrap the legacy plugin function to be async.
Using the nxUseLegacyPlugin function, the first argument is the legacy plugin function and the second argument is the options.
The context options are created and passed to the legacy plugin function.
module.exports = async () => ({
plugins: [
...otherPlugins,
await nxUseLegacyPlugin(require({path}), options),
],
});
* @param fn The legacy plugin function usually from `combinedPlugins`
* @param executorOptions The options passed usually inside the executor or the config file
* @returns Webpack configuration
*/
export async function useLegacyNxPlugin(
fn: (
config: Configuration,
ctx: NxWebpackExecutionContext
) => Promise<Configuration>,
executorOptions: NxAppWebpackPluginOptions
) {
const options = normalizeOptions(executorOptions);
const projectGraph = readCachedProjectGraph();
const projectName = process.env.NX_TASK_TARGET_PROJECT;
const project = projectGraph.nodes[projectName];
const targetName = process.env.NX_TASK_TARGET_TARGET;
const context: ExecutorContext = {
cwd: process.cwd(),
isVerbose: process.env.NX_VERBOSE_LOGGING === 'true',
root: project.data.root,
projectGraph: readCachedProjectGraph(),
target: project.data.targets[targetName],
targetName: targetName,
projectName: projectName,
};
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION;
return async (config: Configuration) => {
const ctx: NxWebpackExecutionContext = {
context,
options: options as NxWebpackExecutionContext['options'],
configuration,
};
return await fn(config, ctx);
};
}