From 47dfdcfc6b36132701f2f8acda6d9e39e6d9e181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Tue, 25 Jun 2024 03:13:24 +0200 Subject: [PATCH] feat(webpack): add convert-to-inferred generator (#26621) ## Current Behavior ## Expected Behavior ## Related Issue(s) Fixes # --------- Co-authored-by: Jack Hsu --- docs/generated/manifests/menus.json | 8 + docs/generated/manifests/nx-api.json | 9 + docs/generated/packages-metadata.json | 9 + .../generators/convert-to-inferred.json | 30 + docs/shared/reference/sitemap.md | 1 + .../plugin-migrations/aggregate-log-util.ts | 4 + .../executor-to-plugin-migrator.ts | 62 +- packages/webpack/generators.json | 5 + .../convert-to-inferred.spec.ts.snap | 280 +++++ .../convert-to-inferred.spec.ts | 1096 +++++++++++++++++ .../convert-to-inferred.ts | 174 +++ .../convert-to-inferred/schema.json | 19 + .../convert-to-inferred/utils/ast.ts | 59 + .../utils/build-post-target-transformer.ts | 416 +++++++ .../convert-to-inferred/utils/index.ts | 3 + .../utils/serve-post-target-transformer.ts | 414 +++++++ .../convert-to-inferred/utils/types.ts | 13 + .../lib/apply-base-config.ts | 6 +- 18 files changed, 2594 insertions(+), 14 deletions(-) create mode 100644 docs/generated/packages/webpack/generators/convert-to-inferred.json create mode 100644 packages/webpack/src/generators/convert-to-inferred/__snapshots__/convert-to-inferred.spec.ts.snap create mode 100644 packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.spec.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/schema.json create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/ast.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/build-post-target-transformer.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/index.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/serve-post-target-transformer.ts create mode 100644 packages/webpack/src/generators/convert-to-inferred/utils/types.ts diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index f930fed36a0f7..feb57ce92a948 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -9891,6 +9891,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-inferred", + "path": "/nx-api/webpack/generators/convert-to-inferred", + "name": "convert-to-inferred", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index c17e269ef2f6b..80596938760ec 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -3242,6 +3242,15 @@ "originalFilePath": "/packages/webpack/src/generators/convert-config-to-webpack-plugin/schema.json", "path": "/nx-api/webpack/generators/convert-config-to-webpack-plugin", "type": "generator" + }, + "/nx-api/webpack/generators/convert-to-inferred": { + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "file": "generated/packages/webpack/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/webpack/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/webpack/generators/convert-to-inferred", + "type": "generator" } }, "path": "/nx-api/webpack" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 349f07543e3e1..8d5cb8974a399 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -3207,6 +3207,15 @@ "originalFilePath": "/packages/webpack/src/generators/convert-config-to-webpack-plugin/schema.json", "path": "webpack/generators/convert-config-to-webpack-plugin", "type": "generator" + }, + { + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "file": "generated/packages/webpack/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/webpack/src/generators/convert-to-inferred/schema.json", + "path": "webpack/generators/convert-to-inferred", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/webpack/generators/convert-to-inferred.json b/docs/generated/packages/webpack/generators/convert-to-inferred.json new file mode 100644 index 0000000000000..0d88dc6d75b22 --- /dev/null +++ b/docs/generated/packages/webpack/generators/convert-to-inferred.json @@ -0,0 +1,30 @@ +{ + "name": "convert-to-inferred", + "factory": "./src/generators/convert-to-inferred/convert-to-inferred#convertToInferred", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxWebpackConvertToInferred", + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "title": "Convert a Webpack project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/webpack:webpack` executor to use `@nx/webpack/plugin`. If not provided, all projects using the `@nx/webpack:webpack` executor will be converted.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "implementation": "/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred#convertToInferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/webpack/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index fa66bb18bdc8b..c0fe925927cdf 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -712,6 +712,7 @@ - [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) + - [convert-to-inferred](/nx-api/webpack/generators/convert-to-inferred) - [workspace](/nx-api/workspace) - [documents](/nx-api/workspace/documents) - [Overview](/nx-api/workspace/documents/overview) diff --git a/packages/devkit/src/generators/plugin-migrations/aggregate-log-util.ts b/packages/devkit/src/generators/plugin-migrations/aggregate-log-util.ts index 9d58bef4b88f3..9b6187ee5e97a 100644 --- a/packages/devkit/src/generators/plugin-migrations/aggregate-log-util.ts +++ b/packages/devkit/src/generators/plugin-migrations/aggregate-log-util.ts @@ -44,6 +44,10 @@ export class AggregatedLog { } flushLogs(): void { + if (this.logs.size === 0) { + return; + } + let fullLog = ''; for (const executorName of this.logs.keys()) { fullLog = `${fullLog}${output.bold( diff --git a/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts index ee1b647c2ea0e..0b663fb0ee6fb 100644 --- a/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts +++ b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts @@ -27,7 +27,10 @@ import { ProjectConfigurationsError, } from 'nx/src/devkit-internals'; import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-configuration-utils'; -import type { InputDefinition } from 'nx/src/config/workspace-json-project-json'; +import type { + InputDefinition, + ProjectConfiguration, +} from 'nx/src/config/workspace-json-project-json'; type PluginOptionsBuilder = (targetName: string) => T; type PostTargetTransformer = ( @@ -38,7 +41,10 @@ type PostTargetTransformer = ( ) => TargetConfiguration | Promise; type SkipTargetFilter = ( targetConfiguration: TargetConfiguration -) => [boolean, string]; +) => false | string; +type SkipProjectFilter = ( + projectConfiguration: ProjectConfiguration +) => false | string; class ExecutorToPluginMigrator { readonly tree: Tree; @@ -48,6 +54,7 @@ class ExecutorToPluginMigrator { readonly #pluginOptionsBuilder: PluginOptionsBuilder; readonly #postTargetTransformer: PostTargetTransformer; readonly #skipTargetFilter: SkipTargetFilter; + readonly #skipProjectFilter: SkipProjectFilter; readonly #specificProjectToMigrate: string; #nxJson: NxJsonConfiguration; #targetDefaultsForExecutor: Partial; @@ -56,6 +63,7 @@ class ExecutorToPluginMigrator { #createNodes?: CreateNodes; #createNodesV2?: CreateNodesV2; #createNodesResultsForTargets: Map; + #skippedProjects: Set; constructor( tree: Tree, @@ -67,7 +75,10 @@ class ExecutorToPluginMigrator { createNodes?: CreateNodes, createNodesV2?: CreateNodesV2, specificProjectToMigrate?: string, - skipTargetFilter?: SkipTargetFilter + filters?: { + skipProjectFilter?: SkipProjectFilter; + skipTargetFilter?: SkipTargetFilter; + } ) { this.tree = tree; this.#projectGraph = projectGraph; @@ -78,7 +89,9 @@ class ExecutorToPluginMigrator { this.#createNodes = createNodes; this.#createNodesV2 = createNodesV2; this.#specificProjectToMigrate = specificProjectToMigrate; - this.#skipTargetFilter = skipTargetFilter ?? ((...args) => [false, '']); + this.#skipProjectFilter = + filters?.skipProjectFilter ?? ((...args) => false); + this.#skipTargetFilter = filters?.skipTargetFilter ?? ((...args) => false); } async run(): Promise>> { @@ -99,6 +112,7 @@ class ExecutorToPluginMigrator { this.#targetAndProjectsToMigrate = new Map(); this.#pluginToAddForTarget = new Map(); this.#createNodesResultsForTargets = new Map(); + this.#skippedProjects = new Set(); this.#getTargetDefaultsForExecutor(); this.#getTargetAndProjectsToMigrate(); @@ -311,7 +325,7 @@ class ExecutorToPluginMigrator { this.tree, this.#executor, (targetConfiguration, projectName, targetName, configurationName) => { - if (configurationName) { + if (this.#skippedProjects.has(projectName) || configurationName) { return; } @@ -322,10 +336,23 @@ class ExecutorToPluginMigrator { return; } - const [skipTarget, reasonTargetWasSkipped] = - this.#skipTargetFilter(targetConfiguration); - if (skipTarget) { - const errorMsg = `${targetName} target on project "${projectName}" cannot be migrated. ${reasonTargetWasSkipped}`; + const skipProjectReason = this.#skipProjectFilter( + this.#projectGraph.nodes[projectName].data + ); + if (skipProjectReason) { + this.#skippedProjects.add(projectName); + const errorMsg = `The "${projectName}" project cannot be migrated. ${skipProjectReason}`; + if (this.#specificProjectToMigrate) { + throw new Error(errorMsg); + } + + console.warn(errorMsg); + return; + } + + const skipTargetReason = this.#skipTargetFilter(targetConfiguration); + if (skipTargetReason) { + const errorMsg = `${targetName} target on project "${projectName}" cannot be migrated. ${skipTargetReason}`; if (this.#specificProjectToMigrate) { throw new Error(errorMsg); } else { @@ -375,6 +402,7 @@ class ExecutorToPluginMigrator { return; } + global.NX_GRAPH_CREATION = true; for (const targetName of this.#targetAndProjectsToMigrate.keys()) { const loadedPlugin = new LoadedNxPlugin( { @@ -398,12 +426,14 @@ class ExecutorToPluginMigrator { if (e instanceof ProjectConfigurationsError) { projectConfigs = e.partialProjectConfigurationsResult; } else { + global.NX_GRAPH_CREATION = false; throw e; } } this.#createNodesResultsForTargets.set(targetName, projectConfigs); } + global.NX_GRAPH_CREATION = false; } } @@ -416,7 +446,10 @@ export async function migrateExecutorToPlugin( postTargetTransformer: PostTargetTransformer, createNodes: CreateNodesV2, specificProjectToMigrate?: string, - skipTargetFilter?: SkipTargetFilter + filters?: { + skipProjectFilter?: SkipProjectFilter; + skipTargetFilter?: SkipTargetFilter; + } ): Promise>> { const migrator = new ExecutorToPluginMigrator( tree, @@ -428,7 +461,7 @@ export async function migrateExecutorToPlugin( undefined, createNodes, specificProjectToMigrate, - skipTargetFilter + filters ); return await migrator.run(); } @@ -442,7 +475,10 @@ export async function migrateExecutorToPluginV1( postTargetTransformer: PostTargetTransformer, createNodes: CreateNodes, specificProjectToMigrate?: string, - skipTargetFilter?: SkipTargetFilter + filters?: { + skipProjectFilter?: SkipProjectFilter; + skipTargetFilter?: SkipTargetFilter; + } ): Promise>> { const migrator = new ExecutorToPluginMigrator( tree, @@ -454,7 +490,7 @@ export async function migrateExecutorToPluginV1( createNodes, undefined, specificProjectToMigrate, - skipTargetFilter + filters ); return await migrator.run(); } diff --git a/packages/webpack/generators.json b/packages/webpack/generators.json index 7bdc22df4ca56..a068087f35be8 100644 --- a/packages/webpack/generators.json +++ b/packages/webpack/generators.json @@ -20,6 +20,11 @@ "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`." + }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred#convertToInferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`." } } } diff --git a/packages/webpack/src/generators/convert-to-inferred/__snapshots__/convert-to-inferred.spec.ts.snap b/packages/webpack/src/generators/convert-to-inferred/__snapshots__/convert-to-inferred.spec.ts.snap new file mode 100644 index 0000000000000..9f67b2e19bd17 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/__snapshots__/convert-to-inferred.spec.ts.snap @@ -0,0 +1,280 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convert-to-inferred all projects should migrate all projects using the webpack executors 1`] = ` +"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); +const { useLegacyNxPlugin } = require('@nx/webpack'); + +// These options were migrated by @nx/webpack:convert-to-inferred from +// the project.json file and merged with the options in this file +const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app1', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, +}; + +// Determine the correct configValue to use based on the configuration +const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + +const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], +}; +const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], +}; + +/** + * @type{import('webpack').WebpackOptionsNormalized} + */ +module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + 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'), buildOptions), + ], +}); +" +`; + +exports[`convert-to-inferred all projects should migrate all projects using the webpack executors 2`] = ` +"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); +const { useLegacyNxPlugin } = require('@nx/webpack'); + +// These options were migrated by @nx/webpack:convert-to-inferred from +// the project.json file and merged with the options in this file +const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app2', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, +}; + +// Determine the correct configValue to use based on the configuration +const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + +const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], +}; +const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], +}; + +/** + * @type{import('webpack').WebpackOptionsNormalized} + */ +module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + 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'), buildOptions), + ], +}); +" +`; + +exports[`convert-to-inferred all projects should migrate all projects using the webpack executors 3`] = ` +"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin'); +const { useLegacyNxPlugin } = require('@nx/webpack'); + +// These options were migrated by @nx/webpack:convert-to-inferred from +// the project.json file and merged with the options in this file +const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app3', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, +}; + +// Determine the correct configValue to use based on the configuration +const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + +const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], +}; +const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], +}; + +/** + * @type{import('webpack').WebpackOptionsNormalized} + */ +module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + 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'), buildOptions), + ], +}); +" +`; diff --git a/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 0000000000000..266e28e52e2ad --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,1096 @@ +import { + addProjectConfiguration, + joinPathFragments, + readNxJson, + readProjectConfiguration, + updateProjectConfiguration, + writeJson, + type ExpandedPluginConfiguration, + type ProjectConfiguration, + type ProjectGraph, + type Tree, +} from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { join } from 'node:path'; +import { getRelativeProjectJsonSchemaPath } from 'nx/src/generators/utils/project-configuration'; +import { convertToInferred } from './convert-to-inferred'; + +let fs: TempFs; +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(() => Promise.resolve(projectGraph)), + updateProjectConfiguration: jest + .fn() + .mockImplementation((tree, projectName, projectConfiguration) => { + function handleEmptyTargets( + projectName: string, + projectConfiguration: ProjectConfiguration + ): void { + if ( + projectConfiguration.targets && + !Object.keys(projectConfiguration.targets).length + ) { + // Re-order `targets` to appear after the `// target` comment. + delete projectConfiguration.targets; + projectConfiguration[ + '// targets' + ] = `to see all targets run: nx show project ${projectName} --web`; + projectConfiguration.targets = {}; + } else { + delete projectConfiguration['// targets']; + } + } + + const projectConfigFile = joinPathFragments( + projectConfiguration.root, + 'project.json' + ); + + if (!tree.exists(projectConfigFile)) { + throw new Error( + `Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.` + ); + } + handleEmptyTargets(projectName, projectConfiguration); + writeJson(tree, projectConfigFile, { + name: projectConfiguration.name ?? projectName, + $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), + ...projectConfiguration, + root: undefined, + }); + projectGraph.nodes[projectName].data = projectConfiguration; + }), +})); +jest.mock('nx/src/devkit-internals', () => ({ + ...jest.requireActual('nx/src/devkit-internals'), + getExecutorInformation: jest + .fn() + .mockImplementation((pkg, ...args) => + jest + .requireActual('nx/src/devkit-internals') + .getExecutorInformation('@nx/webpack', ...args) + ), +})); + +function addProject(tree: Tree, name: string, project: ProjectConfiguration) { + addProjectConfiguration(tree, name, project); + projectGraph.nodes[name] = { + name: name, + type: project.projectType === 'application' ? 'app' : 'lib', + data: { + projectType: project.projectType, + root: project.root, + targets: project.targets, + }, + }; +} + +interface ProjectOptions { + appName: string; + appRoot: string; + buildTargetName: string; + buildExecutor: string; + serveTargetName: string; + serveExecutor: string; +} + +const defaultProjectOptions: ProjectOptions = { + appName: 'app1', + appRoot: 'apps/app1', + buildTargetName: 'build', + buildExecutor: '@nx/webpack:webpack', + serveTargetName: 'serve', + serveExecutor: '@nx/webpack:dev-server', +}; + +const defaultWebpackConfig = `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(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), + ], +}); +`; + +function writeWebpackConfig( + tree: Tree, + projectRoot: string, + webpackConfig = defaultWebpackConfig +) { + tree.write(`${projectRoot}/webpack.config.js`, webpackConfig); + fs.createFileSync(`${projectRoot}/webpack.config.js`, webpackConfig); + jest.doMock(join(fs.tempDir, projectRoot, 'webpack.config.js'), () => ({}), { + virtual: true, + }); +} + +function createProject( + tree: Tree, + opts: Partial = {}, + extraTargetOptions?: Record> +) { + let projectOpts = { ...defaultProjectOptions, ...opts }; + const project: ProjectConfiguration = { + name: projectOpts.appName, + root: projectOpts.appRoot, + projectType: 'application', + targets: { + [projectOpts.buildTargetName]: { + executor: projectOpts.buildExecutor, + options: { + webpackConfig: `${projectOpts.appRoot}/webpack.config.js`, + compiler: 'babel', + outputPath: `dist/${projectOpts.appRoot}`, + index: `${projectOpts.appRoot}/src/index.html`, + baseHref: '/', + main: `${projectOpts.appRoot}/src/main.tsx`, + tsConfig: `${projectOpts.appRoot}/tsconfig.app.json`, + assets: [ + `${projectOpts.appRoot}/src/favicon.ico`, + `${projectOpts.appRoot}/src/assets`, + ], + styles: [`${projectOpts.appRoot}/src/styles.scss`], + scripts: [], + ...extraTargetOptions?.[projectOpts.buildTargetName], + }, + configurations: { + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: `${projectOpts.appRoot}/src/environments/environment.ts`, + with: `${projectOpts.appRoot}/src/environments/environment.prod.ts`, + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + defaultConfiguration: 'production', + }, + [projectOpts.serveTargetName]: { + executor: projectOpts.serveExecutor, + options: { + buildTarget: `${projectOpts.appName}:${projectOpts.buildTargetName}`, + hmr: true, + ssl: true, + sslCert: `${projectOpts.appRoot}/server.crt`, + sslKey: `${projectOpts.appRoot}/server.key`, + proxyConfig: `${projectOpts.appRoot}/proxy.conf.json`, + ...extraTargetOptions?.[projectOpts.serveTargetName], + }, + configurations: { + development: { + buildTarget: `${projectOpts.appName}:${projectOpts.buildTargetName}:development`, + open: true, + }, + production: { + buildTarget: `${projectOpts.appName}:${projectOpts.buildTargetName}:production`, + hmr: false, + }, + }, + defaultConfiguration: 'development', + }, + }, + }; + fs.createFileSync( + `${projectOpts.appRoot}/proxy.conf.json`, + `{ + "/api": { + "target": "http://localhost:3333", + "secure": false + } + }` + ); + + writeWebpackConfig(tree, projectOpts.appRoot, `module.exports = {};`); + + addProject(tree, project.name, project); + fs.createFileSync( + `${projectOpts.appRoot}/project.json`, + JSON.stringify(project) + ); + return project; +} + +describe('convert-to-inferred', () => { + let tree: Tree; + + beforeEach(() => { + fs = new TempFs('webpack'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.cleanup(); + jest.resetModules(); + }); + + describe('--project', () => { + it('should not convert projects without the "webpackConfig" option set', async () => { + const project = createProject(tree); + delete project.targets.build.options.webpackConfig; + updateProjectConfiguration(tree, project.name, project); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await expect( + convertToInferred(tree, { project: project.name }) + ).rejects.toThrow(/missing in the project configuration/); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should not convert projects still using "composePlugins"', async () => { + const project = createProject(tree); + writeWebpackConfig( + tree, + project.root, + `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; + } + ); + ` + ); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await expect( + convertToInferred(tree, { project: project.name }) + ).rejects.toThrow(/@nx\/webpack:convert-config-to-webpack-plugin"/); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should not convert projects not using "NxAppWebpackPlugin"', async () => { + const project = createProject(tree); + writeWebpackConfig( + tree, + project.root, + `module.exports = { + entry: './src/main.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'main.bundle.js', + }, + }; + ` + ); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await expect( + convertToInferred(tree, { project: project.name }) + ).rejects.toThrow(/webpack config/); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should register plugin in nx.json', async () => { + const project = createProject(tree); + writeWebpackConfig(tree, project.root); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // assert plugin was added to nx.json + const nxJsonPlugins = readNxJson(tree).plugins; + const webpackPlugin = nxJsonPlugins.find( + (plugin): plugin is ExpandedPluginConfiguration => + typeof plugin !== 'string' && + plugin.plugin === '@nx/webpack/plugin' && + plugin.include?.length === 1 + ); + expect(webpackPlugin).toBeTruthy(); + expect(webpackPlugin.include).toEqual([`${project.root}/**/*`]); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should move options to the webpack config file', async () => { + const project = createProject(tree); + writeWebpackConfig(tree, project.root); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // check the updated webpack config + 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'); + + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app1', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, + }; + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + }; + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + }; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + 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'), buildOptions), + ], + }); + " + `); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should merge options into the options object in the webpack config file', async () => { + const project = createProject(tree, undefined, { + build: { + main: `${defaultProjectOptions.appRoot}/src/main.tsx`, + tsConfig: `${defaultProjectOptions.appRoot}/tsconfig.app.json`, + assets: [ + `${defaultProjectOptions.appRoot}/src/favicon.ico`, + `${defaultProjectOptions.appRoot}/src/public`, + ], + styles: [`${defaultProjectOptions.appRoot}/src/theme.scss`], + }, + }); + writeWebpackConfig( + tree, + project.root, + `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'], + memoryLimit: 4096, + }; + + /** + * @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), + ], + }); + ` + ); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // check the updated webpack config + 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'); + + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + const configValues = { + build: { + default: { + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + memoryLimit: 4096, + compiler: 'babel', + outputPath: '../../dist/apps/app1', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, + }; + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + }; + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + }; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + 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'), buildOptions), + ], + }); + " + `); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should not touch the existing "devServer" option', async () => { + const project = createProject(tree); + writeWebpackConfig( + tree, + project.root, + `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 () => ({ + devServer: { hot: true }, + 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), + ], + }); + ` + ); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // check the updated webpack config + expect(tree.read(`${project.root}/webpack.config.js`, 'utf-8')).toEqual( + expect.stringContaining(`// This is the untouched "devServer" option from the original webpack config. Please review it and make any necessary changes manually. + devServer: { hot: true },`) + ); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + + it('should keep the "port" value if set', async () => { + const project = createProject(tree, undefined, { + serve: { port: 1234 }, + }); + writeWebpackConfig(tree, project.root); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + }); + const project2BuildTarget = project2.targets.build; + + await convertToInferred(tree, { project: project.name }); + + // check the updated webpack config + expect(tree.read(`${project.root}/webpack.config.js`, 'utf-8')).toContain( + 'port: 1234,' + ); + expect( + tree.read(`${project.root}/webpack.config.js`, 'utf-8') + ).not.toContain('port: 4200,'); + // project configuration + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.build).toStrictEqual({ + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }); + // assert other projects were not modified + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets.build).toStrictEqual(project2BuildTarget); + }); + }); + + describe('all projects', () => { + it('should migrate all projects using the webpack executors', async () => { + const project1 = createProject(tree); + writeWebpackConfig(tree, project1.root); + const project2 = createProject(tree, { + appName: 'app2', + appRoot: 'apps/app2', + buildExecutor: '@nrwl/webpack:webpack', + serveExecutor: '@nrwl/webpack:dev-server', + }); + writeWebpackConfig(tree, project2.root); + const project3 = createProject(tree, { + appName: 'app3', + appRoot: 'apps/app3', + buildTargetName: 'build-webpack', + }); + writeWebpackConfig(tree, project3.root); + const projectWithComposePlugins = createProject(tree, { + appName: 'app4', + appRoot: 'apps/app4', + }); + const projectWithComposePluginsInitialTargets = + projectWithComposePlugins.targets; + const initialProjectWithComposePluginsWebpackConfig = `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; + } +); +`; + writeWebpackConfig( + tree, + projectWithComposePlugins.root, + initialProjectWithComposePluginsWebpackConfig + ); + const projectWithNoNxAppWebpackPlugin = createProject(tree, { + appName: 'app5', + appRoot: 'apps/app5', + }); + const projectWithNoNxAppWebpackPluginInitialTargets = + projectWithNoNxAppWebpackPlugin.targets; + const initialProjectWithNoNxAppWebpackPluginWebpackConfig = `module.exports = { + entry: './src/main.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'main.bundle.js', + }, +}; +`; + writeWebpackConfig( + tree, + projectWithNoNxAppWebpackPlugin.root, + initialProjectWithNoNxAppWebpackPluginWebpackConfig + ); + + await convertToInferred(tree, {}); + + // project configurations + const updatedProject1 = readProjectConfiguration(tree, project1.name); + expect(updatedProject1.targets).toStrictEqual({ + build: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }, + serve: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'development', + }, + }); + const updatedProject2 = readProjectConfiguration(tree, project2.name); + expect(updatedProject2.targets).toStrictEqual({ + build: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }, + serve: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'development', + }, + }); + const updatedProject3 = readProjectConfiguration(tree, project3.name); + expect(updatedProject3.targets).toStrictEqual({ + 'build-webpack': { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'production', + }, + serve: { + configurations: { development: {}, production: {} }, + defaultConfiguration: 'development', + }, + }); + const updatedProjectWithComposePlugins = readProjectConfiguration( + tree, + projectWithComposePlugins.name + ); + expect(updatedProjectWithComposePlugins.targets).toStrictEqual( + projectWithComposePluginsInitialTargets + ); + const updatedProjectWithNoNxAppWebpackPlugin = readProjectConfiguration( + tree, + projectWithNoNxAppWebpackPlugin.name + ); + expect(updatedProjectWithNoNxAppWebpackPlugin.targets).toStrictEqual( + projectWithNoNxAppWebpackPluginInitialTargets + ); + // webpack config files + const project1WebpackConfig = tree.read( + `${project1.root}/webpack.config.js`, + 'utf-8' + ); + expect(project1WebpackConfig).toMatchSnapshot(); + const project2WebpackConfig = tree.read( + `${project2.root}/webpack.config.js`, + 'utf-8' + ); + expect(project2WebpackConfig).toMatchSnapshot(); + const project3WebpackConfig = tree.read( + `${project3.root}/webpack.config.js`, + 'utf-8' + ); + expect(project3WebpackConfig).toMatchSnapshot(); + const updatedProjectWithComposePluginsWebpackConfig = tree.read( + `${projectWithComposePlugins.root}/webpack.config.js`, + 'utf-8' + ); + expect(updatedProjectWithComposePluginsWebpackConfig).toBe( + initialProjectWithComposePluginsWebpackConfig + ); + const updatedProjectWithNoNxAppWebpackPluginWebpackConfig = tree.read( + `${projectWithNoNxAppWebpackPlugin.root}/webpack.config.js`, + 'utf-8' + ); + expect(updatedProjectWithNoNxAppWebpackPluginWebpackConfig).toBe( + initialProjectWithNoNxAppWebpackPluginWebpackConfig + ); + }); + + it('should keep the higher "memoryLimit" value in the build configuration', async () => { + const project = createProject(tree, undefined, { + build: { memoryLimit: 4096 }, + serve: { memoryLimit: 8192 }, // higher value, should be set in the build configuration + }); + writeWebpackConfig(tree, project.root); + const project2 = createProject( + tree, + { appName: 'app2', appRoot: 'apps/app2' }, + { + build: { memoryLimit: 8192 }, // higher value, should be kept in the build configuration + serve: { memoryLimit: 4096 }, + } + ); + writeWebpackConfig(tree, project2.root); + + await convertToInferred(tree, {}); + + // check the updated webpack config + 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'); + + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app1', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + memoryLimit: 8192, + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, + }; + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + }; + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + }; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + 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'), buildOptions), + ], + }); + " + `); + expect(tree.read(`${project2.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'); + + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + const configValues = { + build: { + default: { + compiler: 'babel', + outputPath: '../../dist/apps/app2', + index: './src/index.html', + baseHref: '/', + main: './src/main.tsx', + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: ['./src/styles.scss'], + memoryLimit: 8192, + }, + development: { + extractLicenses: false, + optimization: false, + sourceMap: true, + vendorChunk: true, + }, + production: { + fileReplacements: [ + { + replace: './src/environments/environment.ts', + with: './src/environments/environment.prod.ts', + }, + ], + optimization: true, + outputHashing: 'all', + sourceMap: false, + namedChunks: false, + extractLicenses: true, + vendorChunk: false, + }, + }, + serve: { + default: { + hot: true, + liveReload: false, + server: { + type: 'https', + options: { cert: './server.crt', key: './server.key' }, + }, + proxy: { '/api': { target: 'http://localhost:3333', secure: false } }, + port: 4200, + headers: { 'Access-Control-Allow-Origin': '*' }, + historyApiFallback: { + index: '/index.html', + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }, + }, + development: { open: true }, + production: { hot: false }, + }, + }; + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + }; + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + }; + + /** + * @type{import('webpack').WebpackOptionsNormalized} + */ + module.exports = async () => ({ + devServer: devServerOptions, + plugins: [ + new NxAppWebpackPlugin(buildOptions), + 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'), buildOptions), + ], + }); + " + `); + }); + }); +}); diff --git a/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 0000000000000..f3afe012ceec2 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,174 @@ +import { + addDependenciesToPackageJson, + createProjectGraphAsync, + formatFiles, + type ProjectConfiguration, + runTasksInSerial, + type Tree, +} from '@nx/devkit'; +import { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; +import { migrateExecutorToPlugin } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import * as ts from 'typescript'; +import { createNodesV2, type WebpackPluginOptions } from '../../plugins/plugin'; +import { webpackCliVersion } from '../../utils/versions'; +import { + buildPostTargetTransformerFactory, + type MigrationContext, + servePostTargetTransformerFactory, +} from './utils'; + +interface Schema { + project?: string; + skipFormat?: boolean; +} + +export async function convertToInferred(tree: Tree, options: Schema) { + const projectGraph = await createProjectGraphAsync(); + const migrationContext: MigrationContext = { + logger: new AggregatedLog(), + projectGraph, + workspaceRoot: tree.root, + }; + + // build + const migratedBuildProjects = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nx/webpack:webpack', + '@nx/webpack/plugin', + (targetName) => ({ + buildTargetName: targetName, + previewTargetName: 'preview', + serveStaticTargetName: 'serve-static', + serveTargetName: 'serve', + }), + buildPostTargetTransformerFactory(migrationContext), + createNodesV2, + options.project, + { skipProjectFilter: skipProjectFilterFactory(tree) } + ); + const migratedBuildProjectsLegacy = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nrwl/webpack:webpack', + '@nx/webpack/plugin', + (targetName) => ({ + buildTargetName: targetName, + previewTargetName: 'preview', + serveStaticTargetName: 'serve-static', + serveTargetName: 'serve', + }), + buildPostTargetTransformerFactory(migrationContext), + createNodesV2, + options.project, + { skipProjectFilter: skipProjectFilterFactory(tree) } + ); + + // serve + const migratedServeProjects = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nx/webpack:dev-server', + '@nx/webpack/plugin', + (targetName) => ({ + buildTargetName: 'build', + previewTargetName: 'preview', + serveStaticTargetName: 'serve-static', + serveTargetName: targetName, + }), + servePostTargetTransformerFactory(migrationContext), + createNodesV2, + options.project, + { skipProjectFilter: skipProjectFilterFactory(tree) } + ); + const migratedServeProjectsLegacy = + await migrateExecutorToPlugin( + tree, + projectGraph, + '@nrwl/webpack:dev-server', + '@nx/webpack/plugin', + (targetName) => ({ + buildTargetName: 'build', + previewTargetName: 'preview', + serveStaticTargetName: 'serve-static', + serveTargetName: targetName, + }), + servePostTargetTransformerFactory(migrationContext), + createNodesV2, + options.project, + { skipProjectFilter: skipProjectFilterFactory(tree) } + ); + + const migratedProjects = + migratedBuildProjects.size + + migratedBuildProjectsLegacy.size + + migratedServeProjects.size + + migratedServeProjectsLegacy.size; + + if (migratedProjects === 0) { + throw new Error('Could not find any targets to migrate.'); + } + + const installCallback = addDependenciesToPackageJson( + tree, + {}, + { 'webpack-cli': webpackCliVersion }, + undefined, + true + ); + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return runTasksInSerial(installCallback, () => { + migrationContext.logger.flushLogs(); + }); +} + +function skipProjectFilterFactory(tree: Tree) { + return function skipProjectFilter( + projectConfiguration: ProjectConfiguration + ): false | string { + const buildTarget = Object.values(projectConfiguration.targets).find( + (target) => + target.executor === '@nx/webpack:webpack' || + target.executor === '@nrwl/webpack:webpack' + ); + // the projects for which this is called are guaranteed to have a build target + const webpackConfigPath = buildTarget.options.webpackConfig; + if (!webpackConfigPath) { + return `The webpack config path is missing in the project configuration (${projectConfiguration.root}).`; + } + + const sourceFile = tsquery.ast(tree.read(webpackConfigPath, 'utf-8')); + + const composePluginsSelector = + 'CallExpression:has(Identifier[name=composePlugins])'; + const composePlugins = tsquery( + sourceFile, + composePluginsSelector + )[0]; + + if (composePlugins) { + return `The webpack config (${webpackConfigPath}) can only work with the "@nx/webpack:webpack" executor. Run the "@nx/webpack:convert-config-to-webpack-plugin" generator first.`; + } + + const nxAppWebpackPluginSelector = + 'PropertyAssignment:has(Identifier[name=plugins]) NewExpression:has(Identifier[name=NxAppWebpackPlugin])'; + const nxAppWebpackPlugin = tsquery( + sourceFile, + nxAppWebpackPluginSelector + )[0]; + + if (!nxAppWebpackPlugin) { + return `No "NxAppWebpackPlugin" found in the webpack config (${webpackConfigPath}). Its usage is required for the migration to work.`; + } + + return false; + }; +} diff --git a/packages/webpack/src/generators/convert-to-inferred/schema.json b/packages/webpack/src/generators/convert-to-inferred/schema.json new file mode 100644 index 0000000000000..7de0321ae1a67 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxWebpackConvertToInferred", + "description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.", + "title": "Convert a Webpack project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/webpack:webpack` executor to use `@nx/webpack/plugin`. If not provided, all projects using the `@nx/webpack:webpack` executor will be converted.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files.", + "default": false + } + } +} diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/ast.ts b/packages/webpack/src/generators/convert-to-inferred/utils/ast.ts new file mode 100644 index 0000000000000..a6a3f9d9514e0 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/ast.ts @@ -0,0 +1,59 @@ +import * as ts from 'typescript'; + +export function toPropertyAssignment( + key: string, + value: unknown +): ts.PropertyAssignment { + if (typeof value === 'string') { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + ts.factory.createStringLiteral(value) + ); + } else if (typeof value === 'number') { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + ts.factory.createNumericLiteral(value) + ); + } else if (typeof value === 'boolean') { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + value ? ts.factory.createTrue() : ts.factory.createFalse() + ); + } else if (Array.isArray(value)) { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + ts.factory.createArrayLiteralExpression( + value.map((item) => toExpression(item)) + ) + ); + } else { + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(key), + ts.factory.createObjectLiteralExpression( + Object.entries(value).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ) + ); + } +} + +export function toExpression(value: unknown): ts.Expression { + if (typeof value === 'string') { + return ts.factory.createStringLiteral(value); + } else if (typeof value === 'number') { + return ts.factory.createNumericLiteral(value); + } else if (typeof value === 'boolean') { + return value ? ts.factory.createTrue() : ts.factory.createFalse(); + } else if (Array.isArray(value)) { + return ts.factory.createArrayLiteralExpression( + value.map((item) => toExpression(item)) + ); + } else { + return ts.factory.createObjectLiteralExpression( + Object.entries(value).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ); + } +} diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/build-post-target-transformer.ts b/packages/webpack/src/generators/convert-to-inferred/utils/build-post-target-transformer.ts new file mode 100644 index 0000000000000..342a3829e63c5 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/build-post-target-transformer.ts @@ -0,0 +1,416 @@ +import type { TargetConfiguration, Tree } from '@nx/devkit'; +import { + processTargetOutputs, + toProjectRelativePath, +} from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import * as ts from 'typescript'; +import type { WebpackExecutorOptions } from '../../../executors/webpack/schema'; +import type { NxAppWebpackPluginOptions } from '../../../plugins/nx-webpack-plugin/nx-app-webpack-plugin-options'; +import { toPropertyAssignment } from './ast'; +import type { MigrationContext, TransformerContext } from './types'; + +export function buildPostTargetTransformerFactory( + migrationContext: MigrationContext +) { + return function buildPostTargetTransformer( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTarget: TargetConfiguration + ): TargetConfiguration { + const context: TransformerContext = { + ...migrationContext, + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }; + + const { pluginOptions, webpackConfigPath } = processOptions( + target, + context + ); + + updateWebpackConfig(tree, webpackConfigPath, pluginOptions); + + if (target.outputs) { + processTargetOutputs(target, [], inferredTarget, { + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }); + } + + return target; + }; +} + +type ExtractedOptions = { + default: NxAppWebpackPluginOptions; + [configName: string]: NxAppWebpackPluginOptions; +}; +function processOptions( + target: TargetConfiguration, + context: TransformerContext +): { + pluginOptions: ExtractedOptions; + webpackConfigPath: string; +} { + const webpackConfigPath = target.options.webpackConfig; + delete target.options.webpackConfig; + + const pluginOptions: ExtractedOptions = { + default: extractPluginOptions(target.options, context), + }; + + if (target.configurations && Object.keys(target.configurations).length) { + for (const [configName, config] of Object.entries(target.configurations)) { + pluginOptions[configName] = extractPluginOptions( + config, + context, + configName + ); + } + } + + return { pluginOptions, webpackConfigPath }; +} + +const pathOptions = new Set([ + 'babelConfig', + 'index', + 'main', + 'outputPath', + 'polyfills', + 'postcssConfig', + 'tsConfig', +]); +const assetsOptions = new Set(['assets', 'scripts', 'styles']); +function extractPluginOptions( + options: WebpackExecutorOptions, + context: TransformerContext, + configName?: string +): NxAppWebpackPluginOptions { + const pluginOptions: NxAppWebpackPluginOptions = {}; + + for (const [key, value] of Object.entries(options)) { + if (pathOptions.has(key)) { + pluginOptions[key] = toProjectRelativePath(value, context.projectRoot); + delete options[key]; + } else if (assetsOptions.has(key)) { + pluginOptions[key] = value.map((asset: string | { input: string }) => { + if (typeof asset === 'string') { + return toProjectRelativePath(asset, context.projectRoot); + } + + asset.input = toProjectRelativePath(asset.input, context.projectRoot); + return asset; + }); + delete options[key]; + } else if (key === 'fileReplacements') { + pluginOptions.fileReplacements = value.map( + (replacement: { replace: string; with: string }) => ({ + replace: toProjectRelativePath( + replacement.replace, + context.projectRoot + ), + with: toProjectRelativePath(replacement.with, context.projectRoot), + }) + ); + delete options.fileReplacements; + } else if (key === 'additionalEntryPoints') { + pluginOptions.additionalEntryPoints = value.map((entryPoint) => { + entryPoint.entryPath = toProjectRelativePath( + entryPoint.entryPath, + context.projectRoot + ); + return entryPoint; + }); + delete options.additionalEntryPoints; + } else if (key === 'memoryLimit') { + pluginOptions.memoryLimit = value; + const serveMemoryLimit = getMemoryLimitFromServeTarget( + context, + configName + ); + if (serveMemoryLimit) { + pluginOptions.memoryLimit = Math.max(serveMemoryLimit, value); + context.logger.addLog({ + executorName: '@nx/webpack:webpack', + log: `The "memoryLimit" option was set in both the serve and build configurations. The migration set the higher value to the build configuration and removed the option from the serve configuration.`, + project: context.projectName, + }); + } + delete options.memoryLimit; + } else if (key === 'isolatedConfig') { + context.logger.addLog({ + executorName: '@nx/webpack:webpack', + log: `The 'isolatedConfig' option is deprecated and not supported by the NxAppWebpackPlugin. It was removed from your project configuration.`, + project: context.projectName, + }); + delete options.isolatedConfig; + } else if (key === 'standardWebpackConfigFunction') { + delete options.standardWebpackConfigFunction; + } else { + pluginOptions[key] = value; + delete options[key]; + } + } + + return pluginOptions; +} + +function updateWebpackConfig( + tree: Tree, + webpackConfig: string, + pluginOptions: ExtractedOptions +): void { + let sourceFile: ts.SourceFile; + let webpackConfigText: string; + + const updateSources = () => { + webpackConfigText = tree.read(webpackConfig, 'utf-8'); + sourceFile = tsquery.ast(webpackConfigText); + }; + updateSources(); + + setOptionsInWebpackConfig( + tree, + webpackConfigText, + sourceFile, + webpackConfig, + pluginOptions + ); + updateSources(); + + setOptionsInNxWebpackPlugin( + tree, + webpackConfigText, + sourceFile, + webpackConfig + ); + updateSources(); + + setOptionsInLegacyNxPlugin( + tree, + webpackConfigText, + sourceFile, + webpackConfig + ); +} + +function setOptionsInWebpackConfig( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfig: string, + pluginOptions: ExtractedOptions +): void { + const { default: defaultOptions, ...configurationOptions } = pluginOptions; + + const optionsSelector = + 'VariableStatement:has(VariableDeclaration:has(Identifier[name=options]))'; + const optionsVariable = tsquery( + sourceFile, + optionsSelector + )[0]; + + // This is assuming the `options` variable will be available since it's what the + // `convert-config-to-webpack-plugin` generates + + let defaultOptionsObject: ts.ObjectLiteralExpression; + const optionsObject = tsquery( + optionsVariable, + 'ObjectLiteralExpression' + )[0]; + if (optionsObject.properties.length === 0) { + defaultOptionsObject = ts.factory.createObjectLiteralExpression( + Object.entries(defaultOptions).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ); + } else { + // filter out the default options that are already in the options object + // the existing options override the options from the project.json file + const filteredDefaultOptions = Object.entries(defaultOptions).filter( + ([key]) => + !optionsObject.properties.some( + (property) => + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === key + ) + ); + defaultOptionsObject = ts.factory.createObjectLiteralExpression([ + ...optionsObject.properties, + ...filteredDefaultOptions.map(([key, value]) => + toPropertyAssignment(key, value) + ), + ]); + } + + /** + * const configValues = { + * build: { + * default: { ... }, + * configuration1: { ... }, + * configuration2: { ... }, + * } + */ + const configValuesVariable = ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + 'configValues', + undefined, + undefined, + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + 'build', + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment( + 'default', + defaultOptionsObject + ), + ...(configurationOptions + ? Object.entries(configurationOptions).map(([key, value]) => + ts.factory.createPropertyAssignment( + key, + ts.factory.createObjectLiteralExpression( + Object.entries(value).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ) + ) + ) + : []), + ]) + ), + ], + true + ) + ), + ], + ts.NodeFlags.Const + ) + ); + + text = `${text.slice(0, optionsVariable.getStart())} + // These options were migrated by @nx/webpack:convert-to-inferred from + // the project.json file and merged with the options in this file + ${ts + .createPrinter() + .printNode(ts.EmitHint.Unspecified, configValuesVariable, sourceFile)} + + // Determine the correct configValue to use based on the configuration + const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default'; + + const buildOptions = { + ...configValues.build.default, + ...configValues.build[configuration], + };${text.slice(optionsVariable.getEnd())}`; + + // These are comments written by the `convert-config-to-webpack-plugin` that are no longer needed + text = text + .replace( + `// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'`, + '' + ) + .replace( + '// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.', + '' + ); + + tree.write(webpackConfig, text); +} + +function setOptionsInNxWebpackPlugin( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfig: string +): void { + const nxAppWebpackPluginSelector = + 'PropertyAssignment:has(Identifier[name=plugins]) NewExpression:has(Identifier[name=NxAppWebpackPlugin])'; + const nxAppWebpackPlugin = tsquery( + sourceFile, + nxAppWebpackPluginSelector + )[0]; + + // the NxAppWebpackPlugin must exists, otherwise, the migration doesn't run and we wouldn't reach this point + const updatedNxAppWebpackPlugin = ts.factory.updateNewExpression( + nxAppWebpackPlugin, + nxAppWebpackPlugin.expression, + undefined, + [ts.factory.createIdentifier('buildOptions')] + ); + + text = `${text.slice(0, nxAppWebpackPlugin.getStart())}${ts + .createPrinter() + .printNode( + ts.EmitHint.Unspecified, + updatedNxAppWebpackPlugin, + sourceFile + )}${text.slice(nxAppWebpackPlugin.getEnd())}`; + + tree.write(webpackConfig, text); +} + +function setOptionsInLegacyNxPlugin( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfig: string +): void { + const legacyNxPluginSelector = + 'AwaitExpression CallExpression:has(Identifier[name=useLegacyNxPlugin])'; + const legacyNxPlugin = tsquery( + sourceFile, + legacyNxPluginSelector + )[0]; + + // we're assuming the `useLegacyNxPlugin` function is being called since it's what the `convert-config-to-webpack-plugin` generates + // we've already "ensured" that the `convert-config-to-webpack-plugin` was run by checking for the `NxAppWebpackPlugin` in the project validation + const updatedLegacyNxPlugin = ts.factory.updateCallExpression( + legacyNxPlugin, + legacyNxPlugin.expression, + undefined, + [legacyNxPlugin.arguments[0], ts.factory.createIdentifier('buildOptions')] + ); + + text = `${text.slice(0, legacyNxPlugin.getStart())}${ts + .createPrinter() + .printNode( + ts.EmitHint.Unspecified, + updatedLegacyNxPlugin, + sourceFile + )}${text.slice(legacyNxPlugin.getEnd())}`; + + tree.write(webpackConfig, text); +} + +function getMemoryLimitFromServeTarget( + context: TransformerContext, + configName: string | undefined +): number | undefined { + const { targets } = context.projectGraph.nodes[context.projectName].data; + + const serveTarget = Object.values(targets).find( + (target) => + target.executor === '@nx/webpack:dev-server' || + target.executor === '@nrwl/web:dev-server' + ); + + if (!serveTarget) { + return undefined; + } + + if (configName && serveTarget.configurations?.[configName]) { + return ( + serveTarget.configurations[configName].options?.memoryLimit ?? + serveTarget.options?.memoryLimit + ); + } + + return serveTarget.options?.memoryLimit; +} diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/index.ts b/packages/webpack/src/generators/convert-to-inferred/utils/index.ts new file mode 100644 index 0000000000000..a453ed810be55 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/index.ts @@ -0,0 +1,3 @@ +export * from './build-post-target-transformer'; +export * from './serve-post-target-transformer'; +export * from './types'; diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/serve-post-target-transformer.ts b/packages/webpack/src/generators/convert-to-inferred/utils/serve-post-target-transformer.ts new file mode 100644 index 0000000000000..9d8e3600f3de6 --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/serve-post-target-transformer.ts @@ -0,0 +1,414 @@ +import { + parseTargetString, + readJson, + readTargetOptions, + type ExecutorContext, + type ProjectsConfigurations, + type TargetConfiguration, + type Tree, +} from '@nx/devkit'; +import { + processTargetOutputs, + toProjectRelativePath, +} from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import { basename, resolve } from 'path'; +import * as ts from 'typescript'; +import type { WebpackOptionsNormalized } from 'webpack'; +import { buildServePath } from '../../../executors/dev-server/lib/serve-path'; +import type { WebDevServerOptions as DevServerExecutorOptions } from '../../../executors/dev-server/schema'; +import { toPropertyAssignment } from './ast'; +import type { MigrationContext, TransformerContext } from './types'; + +export function servePostTargetTransformerFactory( + migrationContext: MigrationContext +) { + return async function servePostTargetTransformer( + target: TargetConfiguration, + tree: Tree, + projectDetails: { projectName: string; root: string }, + inferredTarget: TargetConfiguration + ): Promise { + const context: TransformerContext = { + ...migrationContext, + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }; + + const { devServerOptions, webpackConfigPath } = await processOptions( + tree, + target, + context + ); + + updateWebpackConfig(tree, webpackConfigPath, devServerOptions, context); + + if (target.outputs) { + processTargetOutputs(target, [], inferredTarget, { + projectName: projectDetails.projectName, + projectRoot: projectDetails.root, + }); + } + + return target; + }; +} + +type WebpackConfigDevServerOptions = WebpackOptionsNormalized['devServer']; +type ExtractedOptions = { + default: WebpackConfigDevServerOptions; + [configName: string]: WebpackConfigDevServerOptions; +}; + +async function processOptions( + tree: Tree, + target: TargetConfiguration, + context: TransformerContext +): Promise<{ + devServerOptions: ExtractedOptions; + webpackConfigPath: string; +}> { + const executorContext = { + cwd: process.cwd(), + nxJsonConfiguration: readJson(tree, 'nx.json'), + projectGraph: context.projectGraph, + projectName: context.projectName, + projectsConfigurations: Object.entries(context.projectGraph.nodes).reduce( + (acc, [projectName, project]) => { + acc.projects[projectName] = project.data; + return acc; + }, + { version: 1, projects: {} } as ProjectsConfigurations + ), + root: context.workspaceRoot, + } as ExecutorContext; + const buildTarget = parseTargetString( + target.options.buildTarget, + executorContext + ); + const buildOptions = readTargetOptions(buildTarget, executorContext); + + // it must exist, we validated it in the project filter + const webpackConfigPath = buildOptions.webpackConfig; + + const defaultOptions = extractDevServerOptions(target.options, context); + applyDefaults(defaultOptions, buildOptions); + const devServerOptions: ExtractedOptions = { + default: defaultOptions, + }; + + if (target.configurations && Object.keys(target.configurations).length) { + for (const [configName, config] of Object.entries(target.configurations)) { + devServerOptions[configName] = extractDevServerOptions(config, context); + } + } + + return { devServerOptions, webpackConfigPath }; +} + +function extractDevServerOptions( + options: DevServerExecutorOptions, + context: TransformerContext +): WebpackConfigDevServerOptions { + const devServerOptions: WebpackConfigDevServerOptions = {}; + + for (const [key, value] of Object.entries(options)) { + if (key === 'hmr') { + devServerOptions.hot = value; + + if (value) { + // the executor disables liveReload when hmr is enabled + devServerOptions.liveReload = false; + delete options.liveReload; + } + + delete options.hmr; + } else if (key === 'allowedHosts') { + devServerOptions.allowedHosts = value.split(','); + delete options.allowedHosts; + } else if (key === 'publicHost') { + devServerOptions.client = { + webSocketURL: value, + }; + delete options.publicHost; + } else if (key === 'proxyConfig') { + devServerOptions.proxy = getProxyConfig(context.workspaceRoot, value); + delete options.proxyConfig; + } else if (key === 'ssl' || key === 'sslCert' || key === 'sslKey') { + if (key === 'ssl' || 'ssl' in options) { + if (options.ssl) { + devServerOptions.server = { type: 'https' }; + + if (options.sslCert && options.sslKey) { + devServerOptions.server.options = {}; + devServerOptions.server.options.cert = toProjectRelativePath( + options.sslCert, + context.projectRoot + ); + devServerOptions.server.options.key = toProjectRelativePath( + options.sslKey, + context.projectRoot + ); + } else if (options.sslCert) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "sslCert" option was set but "sslKey" was missing and "ssl" was set to "true". This means that "sslCert" was ignored by the executor. It has been removed from the options.', + project: context.projectName, + }); + } else if (options.sslKey) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "sslKey" option was set but "sslCert" was missing and "ssl" was set to "true". This means that "sslKey" was ignored by the executor. It has been removed from the options.', + project: context.projectName, + }); + } + } else if (options.sslCert || options.sslKey) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "sslCert" and/or "sslKey" were set with "ssl" set to "false". This means they were ignored by the executor. They have been removed from the options.', + project: context.projectName, + }); + } + delete options.ssl; + delete options.sslCert; + delete options.sslKey; + } else if (options.sslCert || options.sslKey) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "sslCert" and/or "sslKey" were set but the "ssl" was not set. This means they were ignored by the executor. They have been removed from the options.', + project: context.projectName, + }); + delete options.sslCert; + delete options.sslKey; + } + } else if (key === 'buildTarget') { + delete options.buildTarget; + } else if (key === 'watch') { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "watch" option was removed from the serve configuration since it is not needed. The dev server always watches the files.', + project: context.projectName, + }); + delete options.watch; + } else if (key === 'baseHref') { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: 'The "baseHref" option was removed from the serve configuration. If you need different base hrefs for the build and the dev server, please update the final webpack config manually to achieve that.', + project: context.projectName, + }); + delete options.baseHref; + } else if (key === 'memoryLimit') { + // we already log a message for this one when processing the build options + delete options.memoryLimit; + } else { + devServerOptions[key] = value; + delete options[key]; + } + } + + return devServerOptions; +} + +function applyDefaults( + options: WebpackConfigDevServerOptions, + buildOptions: any +) { + if (options.port === undefined) { + options.port = 4200; + } + + options.headers = { 'Access-Control-Allow-Origin': '*' }; + + const servePath = buildServePath(buildOptions); + options.historyApiFallback = { + index: buildOptions.index && `${servePath}${basename(buildOptions.index)}`, + disableDotRule: true, + htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], + }; +} + +function getProxyConfig(root: string, proxyConfig: string) { + const proxyPath = resolve(root, proxyConfig); + return require(proxyPath); +} + +function updateWebpackConfig( + tree: Tree, + webpackConfigPath: string, + devServerOptions: ExtractedOptions, + context: TransformerContext +): void { + let sourceFile: ts.SourceFile; + let webpackConfigText: string; + + const updateSources = () => { + webpackConfigText = tree.read(webpackConfigPath, 'utf-8'); + sourceFile = tsquery.ast(webpackConfigText); + }; + updateSources(); + + setOptionsInWebpackConfig( + tree, + webpackConfigText, + sourceFile, + webpackConfigPath, + devServerOptions + ); + updateSources(); + + setDevServerOptionsInWebpackConfig( + tree, + webpackConfigText, + sourceFile, + webpackConfigPath, + context + ); +} + +function setOptionsInWebpackConfig( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfigPath: string, + devServerOptions: ExtractedOptions +) { + const { default: defaultOptions, ...configurationOptions } = devServerOptions; + + const configValuesSelector = + 'VariableDeclaration:has(Identifier[name=configValues]) ObjectLiteralExpression'; + const configValuesObject = tsquery( + sourceFile, + configValuesSelector + )[0]; + + // configValues must exist at this point, we added it when processing the build target + + /** + * const configValues = { + * ... + * serve: { + * default: { ... }, + * configuration1: { ... }, + * ... + * }, + */ + const updatedConfigValuesObject = ts.factory.updateObjectLiteralExpression( + configValuesObject, + [ + ...configValuesObject.properties, + ts.factory.createPropertyAssignment( + 'serve', + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment( + 'default', + ts.factory.createObjectLiteralExpression( + Object.entries(defaultOptions).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ) + ), + ...(configurationOptions + ? Object.entries(configurationOptions).map(([key, value]) => + ts.factory.createPropertyAssignment( + key, + ts.factory.createObjectLiteralExpression( + Object.entries(value).map(([key, value]) => + toPropertyAssignment(key, value) + ) + ) + ) + ) + : []), + ]) + ), + ] + ); + + text = `${text.slice(0, configValuesObject.getStart())}${ts + .createPrinter() + .printNode( + ts.EmitHint.Unspecified, + updatedConfigValuesObject, + sourceFile + )}${text.slice(configValuesObject.getEnd())}`; + + tree.write(webpackConfigPath, text); + + sourceFile = tsquery.ast(text); + const buildOptionsSelector = + 'VariableStatement:has(VariableDeclaration:has(Identifier[name=buildOptions]))'; + const buildOptionsStatement = tsquery( + sourceFile, + buildOptionsSelector + )[0]; + + text = `${text.slice(0, buildOptionsStatement.getEnd())} + const devServerOptions = { + ...configValues.serve.default, + ...configValues.serve[configuration], + };${text.slice(buildOptionsStatement.getEnd())}`; + + tree.write(webpackConfigPath, text); +} + +function setDevServerOptionsInWebpackConfig( + tree: Tree, + text: string, + sourceFile: ts.SourceFile, + webpackConfigPath: string, + context: TransformerContext +) { + const webpackConfigDevServerSelector = + 'ObjectLiteralExpression > PropertyAssignment:has(Identifier[name=devServer])'; + const webpackConfigDevServer = tsquery( + sourceFile, + webpackConfigDevServerSelector + )[0]; + if (webpackConfigDevServer) { + context.logger.addLog({ + executorName: '@nx/webpack:dev-server', + log: `The "devServer" option is already set in the webpack config. The migration doesn't support updating it. Please review it and make any necessary changes manually.`, + project: context.projectName, + }); + + text = `${text.slice( + 0, + webpackConfigDevServer.getStart() + )}// This is the untouched "devServer" option from the original webpack config. Please review it and make any necessary changes manually. + ${text.slice(webpackConfigDevServer.getStart())}`; + + tree.write(webpackConfigPath, text); + + // If the devServer property already exists, we don't know how to merge the + // options, so we leave it as is. + return; + } + + const webpackConfigSelector = + 'ObjectLiteralExpression:has(PropertyAssignment:has(Identifier[name=plugins]))'; + const webpackConfig = tsquery( + sourceFile, + webpackConfigSelector + )[0]; + + const updatedWebpackConfig = ts.factory.updateObjectLiteralExpression( + webpackConfig, + [ + ts.factory.createPropertyAssignment( + 'devServer', + ts.factory.createIdentifier('devServerOptions') + ), + ...webpackConfig.properties, + ] + ); + + text = `${text.slice(0, webpackConfig.getStart())}${ts + .createPrinter() + .printNode( + ts.EmitHint.Unspecified, + updatedWebpackConfig, + sourceFile + )}${text.slice(webpackConfig.getEnd())}`; + + tree.write(webpackConfigPath, text); +} diff --git a/packages/webpack/src/generators/convert-to-inferred/utils/types.ts b/packages/webpack/src/generators/convert-to-inferred/utils/types.ts new file mode 100644 index 0000000000000..85b5c1646f04d --- /dev/null +++ b/packages/webpack/src/generators/convert-to-inferred/utils/types.ts @@ -0,0 +1,13 @@ +import type { ProjectGraph } from '@nx/devkit'; +import type { AggregatedLog } from '@nx/devkit/src/generators/plugin-migrations/aggregate-log-util'; + +export type MigrationContext = { + logger: AggregatedLog; + projectGraph: ProjectGraph; + workspaceRoot: string; +}; + +export type TransformerContext = MigrationContext & { + projectName: string; + projectRoot: string; +}; diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-base-config.ts b/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-base-config.ts index 2fb39b5022457..7d2ba347d89d6 100644 --- a/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-base-config.ts +++ b/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-base-config.ts @@ -100,7 +100,11 @@ function applyNxIndependentConfig( path: config.output?.path ?? (options.outputPath - ? path.join(options.root, options.outputPath) + ? // If path is relative, it is relative from project root (aka cwd). + // Otherwise, it is relative to workspace root (legacy behavior). + options.outputPath.startsWith('.') + ? path.join(options.root, options.projectRoot, options.outputPath) + : path.join(options.root, options.outputPath) : undefined), filename: config.output?.filename ??