diff --git a/docs/generated/packages/webpack/executors/webpack.json b/docs/generated/packages/webpack/executors/webpack.json index aa309e56c9fc8..9f5587e366fde 100644 --- a/docs/generated/packages/webpack/executors/webpack.json +++ b/docs/generated/packages/webpack/executors/webpack.json @@ -30,8 +30,7 @@ "compiler": { "type": "string", "description": "The compiler to use.", - "enum": ["babel", "swc", "tsc"], - "default": "babel" + "enum": ["babel", "swc", "tsc"] }, "outputPath": { "type": "string", @@ -43,8 +42,7 @@ "type": "string", "alias": "platform", "description": "Target platform for the build, same as the Webpack target option.", - "enum": ["node", "web", "webworker"], - "default": "web" + "enum": ["node", "web", "webworker"] }, "deleteOutputPath": { "type": "boolean", @@ -53,8 +51,7 @@ }, "watch": { "type": "boolean", - "description": "Enable re-building when files change.", - "default": false + "description": "Enable re-building when files change." }, "baseHref": { "type": "string", @@ -66,33 +63,27 @@ }, "vendorChunk": { "type": "boolean", - "description": "Use a separate bundle containing only vendor libraries.", - "default": true + "description": "Use a separate bundle containing only vendor libraries." }, "commonChunk": { "type": "boolean", - "description": "Use a separate bundle containing code used across multiple bundles.", - "default": true + "description": "Use a separate bundle containing code used across multiple bundles." }, "runtimeChunk": { "type": "boolean", - "description": "Use a separate bundle containing the runtime.", - "default": true + "description": "Use a separate bundle containing the runtime." }, "sourceMap": { "description": "Output sourcemaps. Use 'hidden' for use with error reporting tools without generating sourcemap comment.", - "default": true, "oneOf": [{ "type": "boolean" }, { "type": "string" }] }, "progress": { "type": "boolean", - "description": "Log progress to the console while building.", - "default": false + "description": "Log progress to the console while building." }, "assets": { "type": "array", "description": "List of static application assets.", - "default": [], "items": { "oneOf": [ { @@ -163,8 +154,7 @@ "x-completion-glob": "**/*@(.css|.scss|.less|.sass|.styl|.stylus)" } ] - }, - "default": [] + } }, "styles": { "type": "array", @@ -200,18 +190,15 @@ "x-completion-glob": "**/*@(.css|.scss|.less|.sass|.styl|.stylus)" } ] - }, - "default": [] + } }, "namedChunks": { "type": "boolean", - "description": "Names the produced bundles according to their entry file.", - "default": true + "description": "Names the produced bundles according to their entry file." }, "outputHashing": { "type": "string", "description": "Define the output filename cache-busting hashing mode.", - "default": "none", "enum": ["none", "all", "media", "bundles"] }, "stylePreprocessorOptions": { @@ -221,8 +208,7 @@ "includePaths": { "description": "Paths to include. Paths will be resolved to project root.", "type": "array", - "items": { "type": "string" }, - "default": [] + "items": { "type": "string" } } }, "additionalProperties": false @@ -251,13 +237,11 @@ }, "generatePackageJson": { "type": "boolean", - "description": "Generates a `package.json` and pruned lock file with the project's `node_module` dependencies populated for installing in a container. If a `package.json` exists in the project's directory, it will be reused with dependencies populated.", - "default": false + "description": "Generates a `package.json` and pruned lock file with the project's `node_module` dependencies populated for installing in a container. If a `package.json` exists in the project's directory, it will be reused with dependencies populated." }, "transformers": { "type": "array", "description": "List of TypeScript Compiler Transfomers Plugins.", - "default": [], "aliases": ["tsPlugins"], "items": { "oneOf": [ @@ -302,18 +286,15 @@ { "type": "string", "enum": ["none", "all"] }, { "type": "array", "items": { "type": "string" } } ], - "description": "Dependencies to keep external to the bundle. (`all` (default), `none`, or an array of module names)", - "default": "all" + "description": "Dependencies to keep external to the bundle. (`all` (default), `none`, or an array of module names)" }, "extractCss": { "type": "boolean", - "description": "Extract CSS into a `.css` file.", - "default": true + "description": "Extract CSS into a `.css` file." }, "subresourceIntegrity": { "type": "boolean", - "description": "Enables the use of subresource integrity validation.", - "default": false + "description": "Enables the use of subresource integrity validation." }, "polyfills": { "type": "string", @@ -321,30 +302,24 @@ "x-completion-type": "file", "x-completion-glob": "**/*@(.js|.ts|.tsx)" }, - "verbose": { - "type": "boolean", - "description": "Emits verbose output", - "default": false - }, + "verbose": { "type": "boolean", "description": "Emits verbose output" }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as: 'webpack-bundle-analyzer' or ``.", - "default": false + "description": "Generates a 'stats.json' file which can be analyzed using tools such as: 'webpack-bundle-analyzer' or ``." }, "isolatedConfig": { "type": "boolean", "description": "Do not apply Nx webpack plugins automatically. Plugins need to be applied in the project's webpack.config.js file (e.g. withNx, withReact, etc.).", - "default": true + "default": true, + "x-deprecated": "Automatic configuration of Webpack is deprecated in favor of an explicit 'webpack.config.js' file. This option will be removed in Nx 18. See https://nx.dev/recipes/webpack/webpack-config-setup." }, "extractLicenses": { "type": "boolean", - "description": "Extract all licenses in a separate file, in the case of production builds only.", - "default": false + "description": "Extract all licenses in a separate file, in the case of production builds only." }, "memoryLimit": { "type": "number", - "description": "Memory limit for type checking service process in `MB`.", - "default": 2048 + "description": "Memory limit for type checking service process in `MB`." }, "fileReplacements": { "description": "Replace files with other files in the build.", @@ -365,18 +340,16 @@ }, "additionalProperties": false, "required": ["replace", "with"] - }, - "default": [] + } }, "buildLibsFromSource": { "type": "boolean", - "description": "Read buildable libraries from source instead of building them separately.", + "description": "Read buildable libraries from source instead of building them separately. If set to `false`, the `tsConfig` option must also be set to remap paths.", "default": true }, "generateIndexHtml": { "type": "boolean", - "description": "Generates `index.html` file to the output path. This can be turned off if using a webpack plugin to generate HTML such as `html-webpack-plugin`.", - "default": true + "description": "Generates `index.html` file to the output path. This can be turned off if using a webpack plugin to generate HTML such as `html-webpack-plugin`." }, "postcssConfig": { "type": "string", @@ -391,8 +364,7 @@ }, "babelUpwardRootMode": { "type": "boolean", - "description": "Whether to set rootmode to upward. See https://babeljs.io/docs/en/options#rootmode", - "default": false + "description": "Whether to set rootmode to upward. See https://babeljs.io/docs/en/options#rootmode" }, "babelConfig": { "type": "string", @@ -400,7 +372,7 @@ "x-completion-type": "file" } }, - "required": ["tsConfig", "main"], + "required": [], "definitions": { "assetPattern": { "oneOf": [ diff --git a/e2e/webpack/src/webpack.pcv3.test.ts b/e2e/webpack/src/webpack.pcv3.test.ts new file mode 100644 index 0000000000000..6da2a29fea755 --- /dev/null +++ b/e2e/webpack/src/webpack.pcv3.test.ts @@ -0,0 +1,34 @@ +import { + cleanupProject, + newProject, + runCLI, + runE2ETests, + uniq, +} from '@nx/e2e/utils'; + +describe('Webpack Plugin (PCv3)', () => { + let originalPcv3: string | undefined; + beforeAll(() => { + originalPcv3 = process.env.NX_PCV3; + process.env.NX_PCV3 = 'true'; + newProject(); + }); + + afterAll(() => { + process.env.NX_PCV3 = originalPcv3; + cleanupProject(); + }); + + it('should generate, build, and serve React applications', () => { + const appName = uniq('app'); + runCLI( + `generate @nx/react:app ${appName} --bundler webpack --e2eTestRunner=cypress --no-interactive` + ); + + expect(() => runCLI(`build ${appName}`)).not.toThrow(); + + if (runE2ETests()) { + runCLI(`e2e ${appName}-e2e --watch=false --verbose`); + } + }, 500_000); +}); diff --git a/e2e/webpack/src/webpack.test.ts b/e2e/webpack/src/webpack.test.ts index 2af280763d16e..e976f13be37ac 100644 --- a/e2e/webpack/src/webpack.test.ts +++ b/e2e/webpack/src/webpack.test.ts @@ -125,12 +125,18 @@ module.exports = composePlugins(withNx(), (config) => { path: path.join(__dirname, '../../dist/${appName}') }, plugins: [ - new NxWebpackPlugin() + new NxWebpackPlugin({ + compiler: 'tsc', + main: 'apps/${appName}/src/main.ts', + tsConfig: 'apps/${appName}/tsconfig.app.json', + outputHashing: 'none', + optimization: false, + }) ] };` ); - runCLI(`build ${appName} --outputHashing none`); + runCLI(`build ${appName}`); let output = runCommand(`node dist/${appName}/main.js`); expect(output).toMatch(/Hello/); diff --git a/packages/next/plugins/component-testing.ts b/packages/next/plugins/component-testing.ts index c921eda5614c4..c940fb1af6c3e 100644 --- a/packages/next/plugins/component-testing.ts +++ b/packages/next/plugins/component-testing.ts @@ -112,12 +112,13 @@ Able to find CT project, ${!!ctProjectConfig}.`); ), }; const configure = composePluginsSync( - withNx(), - withReact({ + withNx({ + target: 'web', styles: [], scripts: [], postcssConfig: ctProjectConfig.root, - }) + }), + withReact({}) ); const webpackConfig = configure( {}, diff --git a/packages/node/.eslintrc.json b/packages/node/.eslintrc.json index 721d2bf67965c..438fe915ac44e 100644 --- a/packages/node/.eslintrc.json +++ b/packages/node/.eslintrc.json @@ -43,7 +43,7 @@ "error", { "buildTargets": ["build-base"], - "ignoredDependencies": ["nx", "typescript"] + "ignoredDependencies": ["nx", "typescript", "@nx/webpack"] } ] } diff --git a/packages/node/src/generators/application/application.pcv3.spec.ts b/packages/node/src/generators/application/application.pcv3.spec.ts new file mode 100644 index 0000000000000..5ff7a34176407 --- /dev/null +++ b/packages/node/src/generators/application/application.pcv3.spec.ts @@ -0,0 +1,40 @@ +import { + readNxJson, + readProjectConfiguration, + Tree, + updateNxJson, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +// nx-ignore-next-line +import { applicationGenerator } from './application'; + +describe('node app generator (PCv3)', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push('@nx/webpack/plugin'); + updateNxJson(tree, nxJson); + }); + + it('should skip the build target and setup webpack config', async () => { + await applicationGenerator(tree, { + name: 'my-node-app', + bundler: 'webpack', + projectNameAndRootFormat: 'as-provided', + }); + const project = readProjectConfiguration(tree, 'my-node-app'); + expect(project.root).toEqual('my-node-app'); + expect(project.targets.build).toBeUndefined(); + + const webpackConfig = tree.read('my-node-app/webpack.config.js', 'utf-8'); + expect(webpackConfig).toContain(`new NxWebpackPlugin`); + expect(webpackConfig).toContain(`target: 'node'`); + expect(webpackConfig).toContain(`'../dist/my-node-app'`); + expect(webpackConfig).toContain(`main: './src/main.ts'`); + expect(webpackConfig).toContain(`tsConfig: './tsconfig.app.json'`); + }); +}); diff --git a/packages/node/src/generators/application/application.spec.ts b/packages/node/src/generators/application/application.spec.ts index 8e8e779c056f9..28604b7a7ae1a 100644 --- a/packages/node/src/generators/application/application.spec.ts +++ b/packages/node/src/generators/application/application.spec.ts @@ -42,7 +42,6 @@ describe('app', () => { outputPath: 'dist/my-node-app', main: 'my-node-app/src/main.ts', tsConfig: 'my-node-app/tsconfig.app.json', - isolatedConfig: true, webpackConfig: 'my-node-app/webpack.config.js', assets: ['my-node-app/src/assets'], }, diff --git a/packages/node/src/generators/application/application.ts b/packages/node/src/generators/application/application.ts index 1abbdeb308b01..a4058531da167 100644 --- a/packages/node/src/generators/application/application.ts +++ b/packages/node/src/generators/application/application.ts @@ -40,6 +40,7 @@ import { e2eProjectGenerator } from '../e2e-project/e2e-project'; import { initGenerator } from '../init/init'; import { setupDockerGenerator } from '../setup-docker/setup-docker'; import { Schema } from './schema'; +import { hasWebpackPlugin } from '../../utils/has-webpack-plugin'; export interface NormalizedSchema extends Schema { appProjectRoot: string; @@ -67,7 +68,6 @@ function getWebpackBuildConfig( ), tsConfig: joinPathFragments(options.appProjectRoot, 'tsconfig.app.json'), assets: [joinPathFragments(project.sourceRoot, 'assets')], - isolatedConfig: true, webpackConfig: joinPathFragments( options.appProjectRoot, 'webpack.config.js' @@ -153,10 +153,13 @@ function addProject(tree: Tree, options: NormalizedSchema) { tags: options.parsedTags, }; - project.targets.build = - options.bundler === 'esbuild' - ? getEsBuildConfig(project, options) - : getWebpackBuildConfig(project, options); + if (options.bundler === 'esbuild') { + project.targets.build = getEsBuildConfig(project, options); + } else if (options.bundler === 'webpack') { + if (!hasWebpackPlugin(tree)) { + project.targets.build = getWebpackBuildConfig(project, options); + } + } project.targets.serve = getServeConfig(options); addProjectConfiguration( @@ -168,6 +171,7 @@ function addProject(tree: Tree, options: NormalizedSchema) { } function addAppFiles(tree: Tree, options: NormalizedSchema) { + const sourceRoot = joinPathFragments(options.appProjectRoot, 'src'); generateFiles( tree, join(__dirname, './files/common'), @@ -182,6 +186,17 @@ function addAppFiles(tree: Tree, options: NormalizedSchema) { tree, options.appProjectRoot ), + webpackPluginOptions: hasWebpackPlugin(tree) + ? { + outputPath: joinPathFragments( + 'dist', + options.rootProject ? options.name : options.appProjectRoot + ), + main: './src/main' + (options.js ? '.js' : '.ts'), + tsConfig: './tsconfig.app.json', + assets: ['./assets'], + } + : null, } ); @@ -374,6 +389,18 @@ export async function applicationGeneratorInternal(tree: Tree, schema: Schema) { const installTask = addProjectDependencies(tree, options); tasks.push(installTask); + + if (options.bundler === 'webpack') { + const { webpackInitGenerator } = ensurePackage< + typeof import('@nx/webpack') + >('@nx/webpack', nxVersion); + const webpackInitTask = await webpackInitGenerator(tree, { + uiFramework: 'react', + skipFormat: true, + }); + tasks.push(webpackInitTask); + } + addAppFiles(tree, options); addProject(tree, options); diff --git a/packages/node/src/generators/application/files/common/webpack.config.js__tmpl__ b/packages/node/src/generators/application/files/common/webpack.config.js__tmpl__ index e51dd73f9ca0a..85bf1670741ec 100644 --- a/packages/node/src/generators/application/files/common/webpack.config.js__tmpl__ +++ b/packages/node/src/generators/application/files/common/webpack.config.js__tmpl__ @@ -1,8 +1,35 @@ +<% if (webpackPluginOptions) { %> +const { NxWebpackPlugin } = require('@nx/webpack'); +const { join } = require('path'); + +module.exports = { + output: { + path: join(__dirname, '<%= offset %><%= webpackPluginOptions.outputPath %>'), + }, + plugins: [ + new NxWebpackPlugin({ + target: 'node', + compiler: 'tsc', + main: '<%= webpackPluginOptions.main %>', + tsConfig: '<%= webpackPluginOptions.tsConfig %>', + assets: <%- JSON.stringify(webpackPluginOptions.assets) %>, + optimization: false, + outputHashing: 'none', + }) + ], +}; +<% } else { %> const { composePlugins, withNx} = require('@nx/webpack'); // Nx plugins for webpack. -module.exports = composePlugins(withNx(), (config) => { - // Update the webpack config as needed here. - // e.g. `config.plugins.push(new MyPlugin())` - return config; -}); +module.exports = composePlugins( + withNx({ + target: 'node', + }), + (config) => { + // Update the webpack config as needed here. + // e.g. `config.plugins.push(new MyPlugin())` + return config; + } +); +<% } %> diff --git a/packages/node/src/utils/has-webpack-plugin.ts b/packages/node/src/utils/has-webpack-plugin.ts new file mode 100644 index 0000000000000..7519a9bc5c90e --- /dev/null +++ b/packages/node/src/utils/has-webpack-plugin.ts @@ -0,0 +1,10 @@ +import { readNxJson, Tree } from '@nx/devkit'; + +export function hasWebpackPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + return !!nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/webpack/plugin' + : p.plugin === '@nx/webpack/plugin' + ); +} diff --git a/packages/react/plugins/component-testing/index.ts b/packages/react/plugins/component-testing/index.ts index 7cde7d951cb50..8d1e96a99f60a 100644 --- a/packages/react/plugins/component-testing/index.ts +++ b/packages/react/plugins/component-testing/index.ts @@ -8,7 +8,6 @@ import { joinPathFragments, logger, parseTargetString, - ProjectGraph, readCachedProjectGraph, readTargetOptions, stripIndents, @@ -235,11 +234,9 @@ function buildTargetWebpack( normalizeOptions, } = require('@nx/webpack/src/executors/webpack/lib/normalize-options'); const { - resolveCustomWebpackConfig, - } = require('@nx/webpack/src/utils/webpack/custom-webpack'); - const { - getWebpackConfig, - } = require('@nx/webpack/src/executors/webpack/lib/get-webpack-config'); + resolveUserDefinedWebpackConfig, + } = require('@nx/webpack/src/utils/webpack/resolve-user-defined-webpack-config'); + const { withNx } = require('@nx/webpack/src/utils/with-nx'); const options = normalizeOptions( withSchemaDefaults(parsed, context), @@ -251,7 +248,7 @@ function buildTargetWebpack( let customWebpack: any; if (options.webpackConfig) { - customWebpack = resolveCustomWebpackConfig( + customWebpack = resolveUserDefinedWebpackConfig( options.webpackConfig, options.tsConfig.startsWith(context.root) ? options.tsConfig @@ -262,12 +259,8 @@ function buildTargetWebpack( return async () => { customWebpack = await customWebpack; // TODO(jack): Once webpackConfig is always set in @nx/webpack:webpack, we no longer need this default. - const defaultWebpack = getWebpackConfig(context, { + const defaultWebpack = withNx({ ...options, - // cypress will generate its own index.html from component-index.html - generateIndexHtml: false, - // causes issues with buildable libraries with ENOENT: no such file or directory, scandir error - extractLicenses: false, root: workspaceRoot, projectRoot: ctProjectConfig.root, sourceRoot: ctProjectConfig.sourceRoot, diff --git a/packages/react/plugins/nx-react-webpack-plugin/lib/apply-react-config.ts b/packages/react/plugins/nx-react-webpack-plugin/lib/apply-react-config.ts index 890fd143c95e1..a6bc7336979e1 100644 --- a/packages/react/plugins/nx-react-webpack-plugin/lib/apply-react-config.ts +++ b/packages/react/plugins/nx-react-webpack-plugin/lib/apply-react-config.ts @@ -1,9 +1,11 @@ -import { Compiler, Configuration, WebpackOptionsNormalized } from 'webpack'; +import { Configuration, WebpackOptionsNormalized } from 'webpack'; export function applyReactConfig( options: { svgr?: boolean }, config: Partial = {} ): void { + if (!process.env['NX_TASK_TARGET_PROJECT']) return; + addHotReload(config); if (options.svgr !== false) { diff --git a/packages/react/plugins/storybook/index.ts b/packages/react/plugins/storybook/index.ts index afa2c65a8cf1b..1835e812b3e4c 100644 --- a/packages/react/plugins/storybook/index.ts +++ b/packages/react/plugins/storybook/index.ts @@ -196,8 +196,7 @@ export const webpack = async ( // ESM build for modern browsers. let baseWebpackConfig: Configuration = {}; const configure = composePluginsSync( - withNx({ skipTypeChecking: true }), - withWeb(), + withNx({ target: 'web', skipTypeChecking: true }), withReact() ); const finalConfig = configure(baseWebpackConfig, { diff --git a/packages/react/plugins/with-react.ts b/packages/react/plugins/with-react.ts index a4ceac7e9bcf3..47ae082a23bd8 100644 --- a/packages/react/plugins/with-react.ts +++ b/packages/react/plugins/with-react.ts @@ -4,7 +4,7 @@ import { applyReactConfig } from './nx-react-webpack-plugin/lib/apply-react-conf const processed = new Set(); -interface WithReactOptions extends WithWebOptions { +export interface WithReactOptions extends WithWebOptions { svgr?: false; } diff --git a/packages/react/src/generators/application/application.pcv3.spec.ts b/packages/react/src/generators/application/application.pcv3.spec.ts new file mode 100644 index 0000000000000..38e1c44e3d8e6 --- /dev/null +++ b/packages/react/src/generators/application/application.pcv3.spec.ts @@ -0,0 +1,60 @@ +import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; +import { + readNxJson, + readProjectConfiguration, + Tree, + updateNxJson, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { Linter } from '@nx/eslint'; +import { applicationGenerator } from './application'; +import { Schema } from './schema'; +// need to mock cypress otherwise it'll use the nx installed version from package.json +// which is v9 while we are testing for the new v10 version +jest.mock('@nx/cypress/src/utils/cypress-version'); +describe('react app generator (PCv3)', () => { + let appTree: Tree; + let schema: Schema = { + compiler: 'babel', + e2eTestRunner: 'cypress', + skipFormat: false, + name: 'my-app', + linter: Linter.EsLint, + style: 'css', + strict: true, + projectNameAndRootFormat: 'as-provided', + }; + let mockedInstalledCypressVersion: jest.Mock< + ReturnType + > = installedCypressVersion as never; + beforeEach(() => { + mockedInstalledCypressVersion.mockReturnValue(10); + appTree = createTreeWithEmptyWorkspace(); + const nxJson = readNxJson(appTree); + nxJson.plugins ??= []; + nxJson.plugins.push('@nx/webpack/plugin'); + updateNxJson(appTree, nxJson); + }); + + it('should setup webpack config that is compatible without project targets', async () => { + await applicationGenerator(appTree, { + ...schema, + name: 'my-app', + bundler: 'webpack', + }); + + const targets = readProjectConfiguration(appTree, 'my-app').targets; + expect(targets.build).toBeUndefined(); + expect(targets.serve).toBeUndefined(); + + const webpackConfig = appTree.read('my-app/webpack.config.js', 'utf-8'); + expect(webpackConfig).toContain(`new NxWebpackPlugin`); + expect(webpackConfig).toContain(`'../dist/my-app'`); + expect(webpackConfig).toContain(`main: './src/main.tsx'`); + expect(webpackConfig).toContain(`tsConfig: './tsconfig.app.json'`); + expect(webpackConfig).toContain(`styles: ['./src/styles.css']`); + expect(webpackConfig).toContain( + `assets: ['./src/favicon.ico', './src/assets']` + ); + }); +}); diff --git a/packages/react/src/generators/application/application.ts b/packages/react/src/generators/application/application.ts index d8a3809ce8b90..f2a878b233ac4 100644 --- a/packages/react/src/generators/application/application.ts +++ b/packages/react/src/generators/application/application.ts @@ -95,9 +95,19 @@ export async function applicationGeneratorInternal( skipFormat: true, skipHelperLibs: options.bundler === 'vite', }); - tasks.push(initTask); + if (options.bundler === 'webpack') { + const { webpackInitGenerator } = ensurePackage< + typeof import('@nx/webpack') + >('@nx/webpack', nxVersion); + const webpackInitTask = await webpackInitGenerator(host, { + uiFramework: 'react', + skipFormat: true, + }); + tasks.push(webpackInitTask); + } + if (!options.rootProject) { extractTsConfigBase(host); } @@ -149,15 +159,6 @@ export async function applicationGeneratorInternal( }, false ); - } else if (options.bundler === 'webpack') { - const { webpackInitGenerator } = ensurePackage< - typeof import('@nx/webpack') - >('@nx/webpack', nxVersion); - const webpackInitTask = await webpackInitGenerator(host, { - uiFramework: 'react', - skipFormat: true, - }); - tasks.push(webpackInitTask); } else if (options.bundler === 'rspack') { const { configurationGenerator } = ensurePackage( '@nx/rspack', diff --git a/packages/react/src/generators/application/files/base-webpack/src/environments/environment.prod.ts__tmpl__ b/packages/react/src/generators/application/files/base-webpack/src/environments/environment.prod.ts__tmpl__ deleted file mode 100644 index 3612073bc31cd..0000000000000 --- a/packages/react/src/generators/application/files/base-webpack/src/environments/environment.prod.ts__tmpl__ +++ /dev/null @@ -1,3 +0,0 @@ -export const environment = { - production: true -}; diff --git a/packages/react/src/generators/application/files/base-webpack/src/environments/environment.ts__tmpl__ b/packages/react/src/generators/application/files/base-webpack/src/environments/environment.ts__tmpl__ deleted file mode 100644 index d9370e924b51b..0000000000000 --- a/packages/react/src/generators/application/files/base-webpack/src/environments/environment.ts__tmpl__ +++ /dev/null @@ -1,6 +0,0 @@ -// This file can be replaced during build by using the `fileReplacements` array. -// When building for production, this file is replaced with `environment.prod.ts`. - -export const environment = { - production: false -}; diff --git a/packages/react/src/generators/application/files/base-webpack/webpack.config.js__tmpl__ b/packages/react/src/generators/application/files/base-webpack/webpack.config.js__tmpl__ index f92d94803544c..890e1b0048991 100644 --- a/packages/react/src/generators/application/files/base-webpack/webpack.config.js__tmpl__ +++ b/packages/react/src/generators/application/files/base-webpack/webpack.config.js__tmpl__ @@ -1,9 +1,50 @@ +<% if (webpackPluginOptions) { %> +const { NxWebpackPlugin } = require('@nx/webpack'); +const { NxReactWebpackPlugin } = require('@nx/react'); +const { join } = require('path'); + +module.exports = { + output: { + path: join(__dirname, '<%= offsetFromRoot %><%= webpackPluginOptions.outputPath %>'), + }, + devServer: { + port: 4200 + }, + plugins: [ + new NxWebpackPlugin({ + tsConfig: '<%= webpackPluginOptions.tsConfig %>', + compiler: '<%= webpackPluginOptions.compiler %>', + main: '<%= webpackPluginOptions.main %>', + index: '<%= webpackPluginOptions.index %>', + baseHref: '<%= webpackPluginOptions.baseHref %>', + assets: <%- JSON.stringify(webpackPluginOptions.assets) %>, + styles: <%- JSON.stringify(webpackPluginOptions.styles) %>, + outputHashing: process.env['NODE_ENV'] === 'production' ? 'all' : 'none', + optimization: process.env['NODE_ENV'] === 'production', + }), + new NxReactWebpackPlugin({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + ], +}; +<% } else { %> const { composePlugins, withNx } = require('@nx/webpack'); const { withReact } = require('@nx/react'); // Nx plugins for webpack. -module.exports = composePlugins(withNx(), withReact(), (config) => { - // Update the webpack config as needed here. - // e.g. `config.plugins.push(new MyPlugin())` - return config; -}); +module.exports = composePlugins( + withNx(), + withReact({ + // Uncomment this line if you don't want to use SVGR + // See: https://react-svgr.com/ + // svgr: false + }), + (config) => { + // Update the webpack config as needed here. + // e.g. `config.plugins.push(new MyPlugin())` + return config; + } +); +<% } %> diff --git a/packages/react/src/generators/application/lib/add-e2e.ts b/packages/react/src/generators/application/lib/add-e2e.ts index 2084e2ba198d2..6b25bb945df76 100644 --- a/packages/react/src/generators/application/lib/add-e2e.ts +++ b/packages/react/src/generators/application/lib/add-e2e.ts @@ -8,6 +8,7 @@ import { import { webStaticServeGenerator } from '@nx/web'; import { nxVersion } from '../../../utils/versions'; +import { hasWebpackPlugin } from '../../../utils/has-webpack-plugin'; import { NormalizedSchema } from '../schema'; export async function addE2e( @@ -16,10 +17,12 @@ export async function addE2e( ): Promise { switch (options.e2eTestRunner) { case 'cypress': { - webStaticServeGenerator(tree, { - buildTarget: `${options.projectName}:build`, - targetName: 'serve-static', - }); + if (!hasWebpackPlugin(tree)) { + webStaticServeGenerator(tree, { + buildTarget: `${options.projectName}:build`, + targetName: 'serve-static', + }); + } const { configurationGenerator } = ensurePackage< typeof import('@nx/cypress') diff --git a/packages/react/src/generators/application/lib/add-project.ts b/packages/react/src/generators/application/lib/add-project.ts index 74adecc69a2d3..e3b9b8aec825f 100644 --- a/packages/react/src/generators/application/lib/add-project.ts +++ b/packages/react/src/generators/application/lib/add-project.ts @@ -4,9 +4,11 @@ import { joinPathFragments, ProjectConfiguration, TargetConfiguration, + Tree, } from '@nx/devkit'; +import { hasWebpackPlugin } from '../../../utils/has-webpack-plugin'; -export function addProject(host, options: NormalizedSchema) { +export function addProject(host: Tree, options: NormalizedSchema) { const project: ProjectConfiguration = { root: options.appProjectRoot, sourceRoot: `${options.appProjectRoot}/src`, @@ -16,10 +18,12 @@ export function addProject(host, options: NormalizedSchema) { }; if (options.bundler === 'webpack') { - project.targets = { - build: createBuildTarget(options), - serve: createServeTarget(options), - }; + if (!hasWebpackPlugin(host)) { + project.targets = { + build: createBuildTarget(options), + serve: createServeTarget(options), + }; + } } addProjectConfiguration(host, options.projectName, { diff --git a/packages/react/src/generators/application/lib/create-application-files.ts b/packages/react/src/generators/application/lib/create-application-files.ts index 1190f06a26ae1..6c1c17c4968f4 100644 --- a/packages/react/src/generators/application/lib/create-application-files.ts +++ b/packages/react/src/generators/application/lib/create-application-files.ts @@ -1,5 +1,6 @@ import { generateFiles, + joinPathFragments, names, offsetFromRoot, toJS, @@ -12,6 +13,10 @@ import { createTsConfig } from '../../../utils/create-ts-config'; import { getInSourceVitestTestsTemplate } from '../../../utils/get-in-source-vitest-tests-template'; import { NormalizedSchema } from '../schema'; import { getAppTests } from './get-app-tests'; +import { maybeJs } from './add-project'; +import { WithReactOptions } from '../../../../plugins/with-react'; +import { WithNxOptions } from '@nx/webpack'; +import { hasWebpackPlugin } from '../../../utils/has-webpack-plugin'; export function createApplicationFiles(host: Tree, options: NormalizedSchema) { let styleSolutionSpecificAppFiles: string; @@ -53,7 +58,12 @@ export function createApplicationFiles(host: Tree, options: NormalizedSchema) { host, join(__dirname, '../files/base-webpack'), options.appProjectRoot, - templateVariables + { + ...templateVariables, + webpackPluginOptions: hasWebpackPlugin(host) + ? createNxWebpackPluginOptions(options) + : null, + } ); if (options.compiler === 'babel') { writeJson(host, `${options.appProjectRoot}/.babelrc`, { @@ -154,3 +164,27 @@ export function createApplicationFiles(host: Tree, options: NormalizedSchema) { relativePathToRootTsConfig ); } + +function createNxWebpackPluginOptions( + options: NormalizedSchema +): WithNxOptions & WithReactOptions { + return { + target: 'web', + compiler: options.compiler ?? 'babel', + outputPath: joinPathFragments( + 'dist', + options.appProjectRoot != '.' + ? options.appProjectRoot + : options.projectName + ), + index: './src/index.html', + baseHref: '/', + main: maybeJs(options, `./src/main.tsx`), + tsConfig: './tsconfig.app.json', + assets: ['./src/favicon.ico', './src/assets'], + styles: + options.styledModule || !options.hasStyles + ? [] + : [`./src/styles.${options.style}`], + }; +} diff --git a/packages/react/src/generators/library/lib/normalize-options.ts b/packages/react/src/generators/library/lib/normalize-options.ts index 2c19088ef9735..deb91908d986e 100644 --- a/packages/react/src/generators/library/lib/normalize-options.ts +++ b/packages/react/src/generators/library/lib/normalize-options.ts @@ -1,4 +1,10 @@ -import { getProjects, logger, normalizePath, Tree } from '@nx/devkit'; +import { + getProjects, + joinPathFragments, + logger, + normalizePath, + Tree, +} from '@nx/devkit'; import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils'; import { assertValidStyle } from '../../../utils/assertion'; import { NormalizedSchema, Schema } from '../schema'; @@ -74,10 +80,13 @@ export async function normalizeOptions( ); } - try { - normalized.appMain = appProjectConfig.targets.build.options.main; - normalized.appSourceRoot = normalizePath(appProjectConfig.sourceRoot); - } catch (e) { + normalized.appMain = + appProjectConfig.targets.build.options.main ?? + findMainEntry(host, appProjectConfig.root); + normalized.appSourceRoot = normalizePath(appProjectConfig.sourceRoot); + + // TODO(jack): We should use appEntryFile instead of appProject so users can directly set it rather than us inferring it. + if (!normalized.appMain) { throw new Error( `Could not locate project main for ${options.appProject}` ); @@ -88,3 +97,30 @@ export async function normalizeOptions( return normalized; } + +function findMainEntry(tree: Tree, projectRoot: string): string | undefined { + const mainFiles = [ + // These are the main files we generate with. + 'src/main.ts', + 'src/main.tsx', + 'src/main.js', + 'src/main.jsx', + // Other options just in case + 'src/index.ts', + 'src/index.tsx', + 'src/index.js', + 'src/index.jsx', + 'main.ts', + 'main.tsx', + 'main.js', + 'main.jsx', + 'index.ts', + 'index.tsx', + 'index.js', + 'index.jsx', + ]; + const mainEntry = mainFiles.find((file) => + tree.exists(joinPathFragments(projectRoot, file)) + ); + return mainEntry ? joinPathFragments(projectRoot, mainEntry) : undefined; +} diff --git a/packages/react/src/generators/setup-ssr/setup-ssr.ts b/packages/react/src/generators/setup-ssr/setup-ssr.ts index 2fe028079b40a..6c5b535674cf7 100644 --- a/packages/react/src/generators/setup-ssr/setup-ssr.ts +++ b/packages/react/src/generators/setup-ssr/setup-ssr.ts @@ -120,7 +120,6 @@ export async function setupSsrGenerator(tree: Tree, options: Schema) { compiler: 'babel', externalDependencies: 'all', outputHashing: 'none', - isolatedConfig: true, webpackConfig: joinPathFragments(projectRoot, 'webpack.config.js'), }, configurations: { diff --git a/packages/react/src/module-federation/with-module-federation.ts b/packages/react/src/module-federation/with-module-federation.ts index 6c705cfe0a986..8cd4dceab4f50 100644 --- a/packages/react/src/module-federation/with-module-federation.ts +++ b/packages/react/src/module-federation/with-module-federation.ts @@ -1,15 +1,15 @@ import { ModuleFederationConfig } from '@nx/webpack/src/utils/module-federation'; import { getModuleFederationConfig } from './utils'; -import type { AsyncNxWebpackPlugin } from '@nx/webpack'; +import type { AsyncNxComposableWebpackPlugin } from '@nx/webpack'; import ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); /** * @param {ModuleFederationConfig} options - * @return {Promise} + * @return {Promise} */ export async function withModuleFederation( options: ModuleFederationConfig -): Promise { +): Promise { const { sharedDependencies, sharedLibraries, mappedRemotes } = await getModuleFederationConfig(options); diff --git a/packages/react/src/utils/has-webpack-plugin.ts b/packages/react/src/utils/has-webpack-plugin.ts new file mode 100644 index 0000000000000..7519a9bc5c90e --- /dev/null +++ b/packages/react/src/utils/has-webpack-plugin.ts @@ -0,0 +1,10 @@ +import { readNxJson, Tree } from '@nx/devkit'; + +export function hasWebpackPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + return !!nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/webpack/plugin' + : p.plugin === '@nx/webpack/plugin' + ); +} diff --git a/packages/web/src/executors/file-server/schema.d.ts b/packages/web/src/executors/file-server/schema.d.ts index 2d901fc7cd200..d095e23c08dcf 100644 --- a/packages/web/src/executors/file-server/schema.d.ts +++ b/packages/web/src/executors/file-server/schema.d.ts @@ -1,7 +1,7 @@ export interface Schema { - host: string; - port: number; - ssl: boolean; + host?: string; + port?: number; + ssl?: boolean; sslKey?: string; sslCert?: string; proxyUrl?: string; diff --git a/packages/web/src/generators/application/application.pcv3.spec.ts b/packages/web/src/generators/application/application.pcv3.spec.ts new file mode 100644 index 0000000000000..d3cb40cb17139 --- /dev/null +++ b/packages/web/src/generators/application/application.pcv3.spec.ts @@ -0,0 +1,53 @@ +import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; +import { + readNxJson, + readProjectConfiguration, + Tree, + updateNxJson, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import { applicationGenerator } from './application'; +// need to mock cypress otherwise it'll use the nx installed version from package.json +// which is v9 while we are testing for the new v10 version +jest.mock('@nx/cypress/src/utils/cypress-version'); +jest.mock('@nx/devkit', () => { + return { + ...jest.requireActual('@nx/devkit'), + ensurePackage: jest.fn((pkg) => jest.requireActual(pkg)), + }; +}); +describe('web app generator (PCv3)', () => { + let tree: Tree; + let mockedInstalledCypressVersion: jest.Mock< + ReturnType + > = installedCypressVersion as never; + beforeEach(() => { + mockedInstalledCypressVersion.mockReturnValue(10); + tree = createTreeWithEmptyWorkspace(); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push('@nx/webpack/plugin'); + updateNxJson(tree, nxJson); + }); + + it('should setup webpack configuration', async () => { + await applicationGenerator(tree, { + name: 'my-app', + projectNameAndRootFormat: 'as-provided', + }); + const targets = readProjectConfiguration(tree, 'my-app').targets; + expect(targets.build).toBeUndefined(); + expect(targets.serve).toBeUndefined(); + + const webpackConfig = tree.read('my-app/webpack.config.js', 'utf-8'); + expect(webpackConfig).toContain(`new NxWebpackPlugin`); + expect(webpackConfig).toContain(`'../dist/my-app'`); + expect(webpackConfig).toContain(`main: './src/main.ts'`); + expect(webpackConfig).toContain(`tsConfig: './tsconfig.app.json'`); + expect(webpackConfig).toContain(`styles: ['./src/styles.css']`); + expect(webpackConfig).toContain( + `assets: ['./src/favicon.ico', './src/assets']` + ); + }); +}); diff --git a/packages/web/src/generators/application/application.spec.ts b/packages/web/src/generators/application/application.spec.ts index 6e8a854190718..2cdc8b1891962 100644 --- a/packages/web/src/generators/application/application.spec.ts +++ b/packages/web/src/generators/application/application.spec.ts @@ -349,7 +349,7 @@ describe('app', () => { ); }); - it('should setup the nrwl web build builder', async () => { + it('should setup the web build builder', async () => { await applicationGenerator(tree, { name: 'my-app', projectNameAndRootFormat: 'as-provided', @@ -386,7 +386,7 @@ describe('app', () => { }); }); - it('should setup the nrwl web dev server builder', async () => { + it('should setup the web dev server builder', async () => { await applicationGenerator(tree, { name: 'my-app', projectNameAndRootFormat: 'as-provided', diff --git a/packages/web/src/generators/application/application.ts b/packages/web/src/generators/application/application.ts index 2562ebe9614cc..52e8ac67dcb68 100644 --- a/packages/web/src/generators/application/application.ts +++ b/packages/web/src/generators/application/application.ts @@ -27,6 +27,7 @@ import { nxVersion, swcLoaderVersion } from '../../utils/versions'; import { webInitGenerator } from '../init/init'; import { Schema } from './schema'; import { getNpmScope } from '@nx/js/src/utils/package-json/get-npm-scope'; +import { hasWebpackPlugin } from '../../utils/has-webpack-plugin'; interface NormalizedSchema extends Schema { projectName: string; @@ -37,26 +38,60 @@ interface NormalizedSchema extends Schema { } function createApplicationFiles(tree: Tree, options: NormalizedSchema) { - generateFiles( - tree, - join( - __dirname, - options.bundler === 'vite' ? './files/app-vite' : './files/app-webpack' - ), - options.appProjectRoot, - { - ...options, - ...names(options.name), - tmpl: '', - offsetFromRoot: offsetFromRoot(options.appProjectRoot), - rootTsConfigPath: getRelativePathToRootTsConfig( - tree, - options.appProjectRoot - ), + if (options.bundler === 'vite') { + generateFiles( + tree, + join(__dirname, './files/app-vite'), + options.appProjectRoot, + { + ...options, + ...names(options.name), + tmpl: '', + offsetFromRoot: offsetFromRoot(options.appProjectRoot), + rootTsConfigPath: getRelativePathToRootTsConfig( + tree, + options.appProjectRoot + ), + } + ); + } else { + generateFiles( + tree, + join(__dirname, './files/app-webpack'), + options.appProjectRoot, + { + ...options, + ...names(options.name), + tmpl: '', + offsetFromRoot: offsetFromRoot(options.appProjectRoot), + rootTsConfigPath: getRelativePathToRootTsConfig( + tree, + options.appProjectRoot + ), + webpackPluginOptions: hasWebpackPlugin(tree) + ? { + target: 'web', + outputPath: joinPathFragments( + 'dist', + options.appProjectRoot != '.' + ? options.appProjectRoot + : options.projectName + ), + tsConfig: './tsconfig.app.json', + main: './src/main.ts', + assets: ['./src/favicon.ico', './src/assets'], + index: './src/index.html', + baseHref: '/', + styles: [`./src/styles.${options.style}`], + } + : null, + } + ); + if (options.unitTestRunner === 'none') { + tree.delete( + join(options.appProjectRoot, './src/app/app.element.spec.ts') + ); } - ); - if (options.unitTestRunner === 'none') { - tree.delete(join(options.appProjectRoot, './src/app/app.element.spec.ts')); } } @@ -89,43 +124,48 @@ async function setupBundler(tree: Tree, options: NormalizedSchema) { skipFormat: true, }); const project = readProjectConfiguration(tree, options.projectName); - const prodConfig = project.targets.build.configurations.production; - const buildOptions = project.targets.build.options; - buildOptions.assets = assets; - buildOptions.index = joinPathFragments( - options.appProjectRoot, - 'src/index.html' - ); - buildOptions.baseHref = '/'; - buildOptions.styles = [ - joinPathFragments(options.appProjectRoot, `src/styles.${options.style}`), - ]; - // We can delete that, because this projest is an application - // and applications have a .babelrc file in their root dir. - // So Nx will find it and use it - delete buildOptions.babelUpwardRootMode; - buildOptions.scripts = []; - prodConfig.fileReplacements = [ - { - replace: joinPathFragments( - options.appProjectRoot, - `src/environments/environment.ts` - ), - with: joinPathFragments( + if (project.targets.build) { + const prodConfig = project.targets.build.configurations.production; + const buildOptions = project.targets.build.options; + buildOptions.assets = assets; + buildOptions.index = joinPathFragments( + options.appProjectRoot, + 'src/index.html' + ); + buildOptions.baseHref = '/'; + buildOptions.styles = [ + joinPathFragments( options.appProjectRoot, - `src/environments/environment.prod.ts` + `src/styles.${options.style}` ), - }, - ]; - prodConfig.optimization = true; - prodConfig.outputHashing = 'all'; - prodConfig.sourceMap = false; - prodConfig.namedChunks = false; - prodConfig.extractLicenses = true; - prodConfig.vendorChunk = false; - updateProjectConfiguration(tree, options.projectName, project); - } else if (options.bundler === 'none') { + ]; + // We can delete that, because this projest is an application + // and applications have a .babelrc file in their root dir. + // So Nx will find it and use it + delete buildOptions.babelUpwardRootMode; + buildOptions.scripts = []; + prodConfig.fileReplacements = [ + { + replace: joinPathFragments( + options.appProjectRoot, + `src/environments/environment.ts` + ), + with: joinPathFragments( + options.appProjectRoot, + `src/environments/environment.prod.ts` + ), + }, + ]; + prodConfig.optimization = true; + prodConfig.outputHashing = 'all'; + prodConfig.sourceMap = false; + prodConfig.namedChunks = false; + prodConfig.extractLicenses = true; + prodConfig.vendorChunk = false; + updateProjectConfiguration(tree, options.projectName, project); + } // TODO(jack): Flush this out... no bundler should be possible for web but the experience isn't holistic due to missing features (e.g. writing index.html). + } else if (options.bundler === 'none') { const project = readProjectConfiguration(tree, options.projectName); project.targets.build = { executor: `@nx/js:${options.compiler}`, @@ -134,7 +174,6 @@ async function setupBundler(tree: Tree, options: NormalizedSchema) { main, outputPath: joinPathFragments('dist', options.appProjectRoot), tsConfig, - assets, }, }; updateProjectConfiguration(tree, options.projectName, project); @@ -158,10 +197,6 @@ async function addProject(tree: Tree, options: NormalizedSchema) { }, options.standaloneConfig ); - - if (options.bundler !== 'vite') { - await setupBundler(tree, options); - } } function setDefaults(tree: Tree, options: NormalizedSchema) { @@ -195,9 +230,14 @@ export async function applicationGeneratorInternal(host: Tree, schema: Schema) { }); tasks.push(webTask); - createApplicationFiles(host, options); await addProject(host, options); + if (options.bundler !== 'vite') { + await setupBundler(host, options); + } + + createApplicationFiles(host, options); + if (options.bundler === 'vite') { const { viteConfigurationGenerator, createOrEditViteConfig } = ensurePackage('@nx/vite', nxVersion); diff --git a/packages/web/src/generators/application/files/app-webpack/src/environments/environment.prod.ts__tmpl__ b/packages/web/src/generators/application/files/app-webpack/src/environments/environment.prod.ts__tmpl__ deleted file mode 100644 index 3612073bc31cd..0000000000000 --- a/packages/web/src/generators/application/files/app-webpack/src/environments/environment.prod.ts__tmpl__ +++ /dev/null @@ -1,3 +0,0 @@ -export const environment = { - production: true -}; diff --git a/packages/web/src/generators/application/files/app-webpack/src/environments/environment.ts__tmpl__ b/packages/web/src/generators/application/files/app-webpack/src/environments/environment.ts__tmpl__ deleted file mode 100644 index d9370e924b51b..0000000000000 --- a/packages/web/src/generators/application/files/app-webpack/src/environments/environment.ts__tmpl__ +++ /dev/null @@ -1,6 +0,0 @@ -// This file can be replaced during build by using the `fileReplacements` array. -// When building for production, this file is replaced with `environment.prod.ts`. - -export const environment = { - production: false -}; diff --git a/packages/web/src/generators/application/files/app-webpack/webpack.config.js__tmpl__ b/packages/web/src/generators/application/files/app-webpack/webpack.config.js__tmpl__ index 793c7b31eb090..16538cf170ff2 100644 --- a/packages/web/src/generators/application/files/app-webpack/webpack.config.js__tmpl__ +++ b/packages/web/src/generators/application/files/app-webpack/webpack.config.js__tmpl__ @@ -1,8 +1,38 @@ +<% if (webpackPluginOptions) { %> +const { NxWebpackPlugin } = require('@nx/webpack'); +const { join } = require('path'); + +module.exports = { + output: { + path: join(__dirname, '<%= offsetFromRoot %><%= webpackPluginOptions.outputPath %>'), + }, + devServer: { + port: 4200 + }, + plugins: [ + new NxWebpackPlugin({ + tsConfig: '<%= webpackPluginOptions.tsConfig %>', + main: '<%= webpackPluginOptions.main %>', + index: '<%= webpackPluginOptions.index %>', + baseHref: '<%= webpackPluginOptions.baseHref %>', + assets: <%- JSON.stringify(webpackPluginOptions.assets) %>, + styles: <%- JSON.stringify(webpackPluginOptions.styles) %>, + outputHashing: process.env['NODE_ENV'] === 'production' ? 'all' : 'none', + optimization: process.env['NODE_ENV'] === 'production', + }) + ], +}; +<% } else { %> const { composePlugins, withNx, withWeb } = require('@nx/webpack'); // Nx plugins for webpack. -module.exports = composePlugins(withNx(), withWeb(), (config) => { - // Update the webpack config as needed here. - // e.g. `config.plugins.push(new MyPlugin())` - return config; -}); +module.exports = composePlugins( + withNx(), + withWeb(), + (config) => { + // Update the webpack config as needed here. + // e.g. `config.plugins.push(new MyPlugin())` + return config; + } +); +<% } %> diff --git a/packages/web/src/utils/has-webpack-plugin.ts b/packages/web/src/utils/has-webpack-plugin.ts new file mode 100644 index 0000000000000..7519a9bc5c90e --- /dev/null +++ b/packages/web/src/utils/has-webpack-plugin.ts @@ -0,0 +1,10 @@ +import { readNxJson, Tree } from '@nx/devkit'; + +export function hasWebpackPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + return !!nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/webpack/plugin' + : p.plugin === '@nx/webpack/plugin' + ); +} diff --git a/packages/webpack/plugin.ts b/packages/webpack/plugin.ts new file mode 100644 index 0000000000000..f8ca64185f7ae --- /dev/null +++ b/packages/webpack/plugin.ts @@ -0,0 +1 @@ +export { createNodes } from './src/plugins/plugin'; diff --git a/packages/webpack/src/executors/dev-server/dev-server.impl.ts b/packages/webpack/src/executors/dev-server/dev-server.impl.ts index 3774795688eea..0dea24c63e634 100644 --- a/packages/webpack/src/executors/dev-server/dev-server.impl.ts +++ b/packages/webpack/src/executors/dev-server/dev-server.impl.ts @@ -9,17 +9,18 @@ import { eachValueFrom } from '@nx/devkit/src/utils/rxjs-for-await'; import { map, tap } from 'rxjs/operators'; import * as WebpackDevServer from 'webpack-dev-server'; -import { getDevServerConfig } from './lib/get-dev-server-config'; +import { getDevServerOptions } from './lib/get-dev-server-config'; import { calculateProjectBuildableDependencies, createTmpTsConfig, } from '@nx/js/src/utils/buildable-libs-utils'; import { runWebpackDevServer } from '../../utils/run-webpack'; -import { resolveCustomWebpackConfig } from '../../utils/webpack/custom-webpack'; +import { resolveUserDefinedWebpackConfig } from '../../utils/webpack/resolve-user-defined-webpack-config'; import { normalizeOptions } from '../webpack/lib/normalize-options'; import { WebpackExecutorOptions } from '../webpack/schema'; import { WebDevServerOptions } from './schema'; -import { join } from 'path'; +import { isNxWebpackComposablePlugin } from '../../utils/config'; +import { getRootTsConfigPath } from '@nx/js'; export async function* devServerExecutor( serveOptions: WebDevServerOptions, @@ -37,13 +38,13 @@ export async function* devServerExecutor( sourceRoot ); - if (!buildOptions.index) { - throw new Error( - `Cannot run dev-server without "index" option. Check the build options for ${context.projectName}.` - ); - } - + // TODO(jack): Figure out a way to port this into NxWebpackPlugin if (!buildOptions.buildLibsFromSource) { + if (!buildOptions.tsConfig) { + throw new Error( + `Cannot find "tsConfig" to remap paths for. Set this option in project.json.` + ); + } const { target, dependencies } = calculateProjectBuildableDependencies( context.taskGraph, context.projectGraph, @@ -60,37 +61,35 @@ export async function* devServerExecutor( ); } - let config = getDevServerConfig(context, buildOptions, serveOptions); + let config; + + const devServer = getDevServerOptions( + context.root, + serveOptions, + buildOptions + ); if (buildOptions.webpackConfig) { - let tsconfigPath = buildOptions.tsConfig.startsWith(context.root) - ? buildOptions.tsConfig - : join(context.root, buildOptions.tsConfig); - let customWebpack = resolveCustomWebpackConfig( + let userDefinedWebpackConfig = resolveUserDefinedWebpackConfig( buildOptions.webpackConfig, - tsconfigPath + getRootTsConfigPath() ); - if (typeof customWebpack.then === 'function') { - customWebpack = await customWebpack; + if (typeof userDefinedWebpackConfig.then === 'function') { + userDefinedWebpackConfig = await userDefinedWebpackConfig; } - if (typeof customWebpack === 'function') { - // Old behavior, call the webpack function that is specific to Nx - config = await customWebpack(config, { - options: buildOptions, - context, - configuration: serveOptions.buildTarget.split(':')[2], - }); - } else if (customWebpack) { - // New behavior, use the config object as is with devServer defaults - config = { - devServer: { - ...customWebpack.devServer, - ...config.devServer, - }, - ...customWebpack, - }; + // Only add the dev server option if user is composable plugin. + // Otherwise, user should define `devServer` option directly in their webpack config. + if (isNxWebpackComposablePlugin(userDefinedWebpackConfig)) { + config = await userDefinedWebpackConfig( + { devServer }, + { + options: buildOptions, + context, + configuration: serveOptions.buildTarget.split(':')[2], + } + ); } } diff --git a/packages/webpack/src/executors/dev-server/lib/get-dev-server-config.ts b/packages/webpack/src/executors/dev-server/lib/get-dev-server-config.ts index c86b2fb135ae6..e4022ecd27583 100644 --- a/packages/webpack/src/executors/dev-server/lib/get-dev-server-config.ts +++ b/packages/webpack/src/executors/dev-server/lib/get-dev-server-config.ts @@ -1,36 +1,14 @@ -import { ExecutorContext, logger } from '@nx/devkit'; -import type { Configuration as WebpackConfiguration } from 'webpack'; +import { logger } from '@nx/devkit'; import type { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server'; import * as path from 'path'; import { readFileSync } from 'fs'; - -import { getWebpackConfig } from '../../webpack/lib/get-webpack-config'; import { WebDevServerOptions } from '../schema'; import { buildServePath } from './serve-path'; import { NormalizedWebpackExecutorOptions } from '../../webpack/schema'; -export function getDevServerConfig( - context: ExecutorContext, - buildOptions: NormalizedWebpackExecutorOptions, - serveOptions: WebDevServerOptions -): Partial { - const workspaceRoot = context.root; - const webpackConfig = buildOptions.isolatedConfig - ? {} - : getWebpackConfig(context, buildOptions); - - (webpackConfig as any).devServer = getDevServerPartial( - workspaceRoot, - serveOptions, - buildOptions - ); - - return webpackConfig as WebpackConfiguration; -} - -function getDevServerPartial( +export function getDevServerOptions( root: string, - options: WebDevServerOptions, + serveOptions: WebDevServerOptions, buildOptions: NormalizedWebpackExecutorOptions ): WebpackDevServerConfiguration { const servePath = buildServePath(buildOptions); @@ -47,11 +25,13 @@ function getDevServerPartial( } const config: WebpackDevServerConfiguration = { - host: options.host, - port: options.port, + host: serveOptions.host, + port: serveOptions.port, headers: { 'Access-Control-Allow-Origin': '*' }, historyApiFallback: { - index: `${servePath}${path.basename(buildOptions.index)}`, + index: + buildOptions.index && + `${servePath}${path.basename(buildOptions.index)}`, disableDotRule: true, htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'], }, @@ -66,7 +46,7 @@ function getDevServerPartial( )}` ); }, - open: options.open, + open: serveOptions.open, static: false, compress: scriptsOptimization || stylesOptimization, devMiddleware: { @@ -74,31 +54,31 @@ function getDevServerPartial( stats: false, }, client: { - webSocketURL: options.publicHost, + webSocketURL: serveOptions.publicHost, overlay: { errors: !(scriptsOptimization || stylesOptimization), warnings: false, }, }, - liveReload: options.hmr ? false : options.liveReload, // disable liveReload if hmr is enabled - hot: options.hmr, + liveReload: serveOptions.hmr ? false : serveOptions.liveReload, // disable liveReload if hmr is enabled + hot: serveOptions.hmr, }; - if (options.ssl) { + if (serveOptions.ssl) { config.server = { type: 'https', }; - if (options.sslKey && options.sslCert) { - config.server.options = getSslConfig(root, options); + if (serveOptions.sslKey && serveOptions.sslCert) { + config.server.options = getSslConfig(root, serveOptions); } } - if (options.proxyConfig) { - config.proxy = getProxyConfig(root, options); + if (serveOptions.proxyConfig) { + config.proxy = getProxyConfig(root, serveOptions); } - if (options.allowedHosts) { - config.allowedHosts = options.allowedHosts.split(','); + if (serveOptions.allowedHosts) { + config.allowedHosts = serveOptions.allowedHosts.split(','); } return config; diff --git a/packages/webpack/src/executors/dev-server/schema.d.ts b/packages/webpack/src/executors/dev-server/schema.d.ts index b7cac5d605c62..e556aa4dff229 100644 --- a/packages/webpack/src/executors/dev-server/schema.d.ts +++ b/packages/webpack/src/executors/dev-server/schema.d.ts @@ -1,17 +1,17 @@ export interface WebDevServerOptions { - host: string; - port: number; + host?: string; + port?: number; publicHost?: string; - ssl: boolean; + ssl?: boolean; sslKey?: string; sslCert?: string; proxyConfig?: string; buildTarget: string; - open: boolean; - liveReload: boolean; - hmr: boolean; - watch: boolean; - allowedHosts: string; + open?: boolean; + liveReload?: boolean; + hmr?: boolean; + watch?: boolean; + allowedHosts?: string; memoryLimit?: number; baseHref?: string; } diff --git a/packages/webpack/src/executors/webpack/lib/get-webpack-config.ts b/packages/webpack/src/executors/webpack/lib/get-webpack-config.ts deleted file mode 100644 index bf1e4cca41247..0000000000000 --- a/packages/webpack/src/executors/webpack/lib/get-webpack-config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Configuration } from 'webpack'; -import { ExecutorContext } from '@nx/devkit'; - -import { NormalizedWebpackExecutorOptions } from '../schema'; -import { withNx } from '../../../utils/with-nx'; -import { withWeb } from '../../../utils/with-web'; -import { composePluginsSync } from '../../../utils/config'; - -interface GetWebpackConfigOverrides { - root: string; - sourceRoot: string; - configuration?: string; -} - -/** @deprecated Use withNx, withWeb, or withReact */ -// TODO(jack): Remove in Nx 16 -export function getWebpackConfig( - context: ExecutorContext, - options: NormalizedWebpackExecutorOptions -): Configuration { - const config: Configuration = {}; - const configure = - options.target === 'web' - ? composePluginsSync(withNx(), withWeb()) - : withNx(); - return configure(config, { options, context }); -} diff --git a/packages/webpack/src/executors/webpack/lib/normalize-options.ts b/packages/webpack/src/executors/webpack/lib/normalize-options.ts index 824f5880d1fba..efd5506212729 100644 --- a/packages/webpack/src/executors/webpack/lib/normalize-options.ts +++ b/packages/webpack/src/executors/webpack/lib/normalize-options.ts @@ -15,14 +15,13 @@ export function normalizeOptions( projectRoot: string, sourceRoot: string ): NormalizedWebpackExecutorOptions { - return { + const normalizedOptions = { ...options, root, projectRoot, sourceRoot, target: options.target ?? 'web', outputFileName: options.outputFileName ?? 'main.js', - assets: normalizeAssets(options.assets, root, sourceRoot), webpackConfig: normalizePluginPath(options.webpackConfig, root), fileReplacements: normalizeFileReplacements(root, options.fileReplacements), optimization: @@ -33,6 +32,14 @@ export function normalizeOptions( } : options.optimization, }; + if (options.assets) { + normalizedOptions.assets = normalizeAssets( + options.assets, + root, + sourceRoot + ); + } + return normalizedOptions as NormalizedWebpackExecutorOptions; } export function normalizePluginPath(pluginPath: void | string, root: string) { diff --git a/packages/webpack/src/executors/webpack/schema.d.ts b/packages/webpack/src/executors/webpack/schema.d.ts index 22dd3c25bb13d..79a8257aac2ea 100644 --- a/packages/webpack/src/executors/webpack/schema.d.ts +++ b/packages/webpack/src/executors/webpack/schema.d.ts @@ -47,8 +47,10 @@ export interface WebpackExecutorOptions { extractLicenses?: boolean; fileReplacements?: FileReplacement[]; generatePackageJson?: boolean; + // TODO(v18): Remove this option + /** @deprecated set webpackConfig and provide an explicit webpack.config.js file (See: https://nx.dev/recipes/webpack/webpack-config-setup) */ isolatedConfig?: boolean; - main: string; + main?: string; memoryLimit?: number; namedChunks?: boolean; optimization?: boolean | OptimizationOptions; @@ -61,9 +63,9 @@ export interface WebpackExecutorOptions { runtimeChunk?: boolean; sourceMap?: boolean | 'hidden'; statsJson?: boolean; - target?: 'node' | 'web' | 'webworker'; + target?: string; transformers?: TransformerEntry[]; - tsConfig: string; + tsConfig?: string; vendorChunk?: boolean; verbose?: boolean; watch?: boolean; diff --git a/packages/webpack/src/executors/webpack/schema.json b/packages/webpack/src/executors/webpack/schema.json index 883a12f1f4b78..885df98c244ec 100644 --- a/packages/webpack/src/executors/webpack/schema.json +++ b/packages/webpack/src/executors/webpack/schema.json @@ -27,8 +27,7 @@ "compiler": { "type": "string", "description": "The compiler to use.", - "enum": ["babel", "swc", "tsc"], - "default": "babel" + "enum": ["babel", "swc", "tsc"] }, "outputPath": { "type": "string", @@ -40,8 +39,7 @@ "type": "string", "alias": "platform", "description": "Target platform for the build, same as the Webpack target option.", - "enum": ["node", "web", "webworker"], - "default": "web" + "enum": ["node", "web", "webworker"] }, "deleteOutputPath": { "type": "boolean", @@ -50,8 +48,7 @@ }, "watch": { "type": "boolean", - "description": "Enable re-building when files change.", - "default": false + "description": "Enable re-building when files change." }, "baseHref": { "type": "string", @@ -63,22 +60,18 @@ }, "vendorChunk": { "type": "boolean", - "description": "Use a separate bundle containing only vendor libraries.", - "default": true + "description": "Use a separate bundle containing only vendor libraries." }, "commonChunk": { "type": "boolean", - "description": "Use a separate bundle containing code used across multiple bundles.", - "default": true + "description": "Use a separate bundle containing code used across multiple bundles." }, "runtimeChunk": { "type": "boolean", - "description": "Use a separate bundle containing the runtime.", - "default": true + "description": "Use a separate bundle containing the runtime." }, "sourceMap": { "description": "Output sourcemaps. Use 'hidden' for use with error reporting tools without generating sourcemap comment.", - "default": true, "oneOf": [ { "type": "boolean" @@ -90,13 +83,11 @@ }, "progress": { "type": "boolean", - "description": "Log progress to the console while building.", - "default": false + "description": "Log progress to the console while building." }, "assets": { "type": "array", "description": "List of static application assets.", - "default": [], "items": { "$ref": "#/definitions/assetPattern" } @@ -112,26 +103,22 @@ "description": "External Scripts which will be included before the main application entry.", "items": { "$ref": "#/definitions/extraEntryPoint" - }, - "default": [] + } }, "styles": { "type": "array", "description": "External Styles which will be included with the application", "items": { "$ref": "#/definitions/extraEntryPoint" - }, - "default": [] + } }, "namedChunks": { "type": "boolean", - "description": "Names the produced bundles according to their entry file.", - "default": true + "description": "Names the produced bundles according to their entry file." }, "outputHashing": { "type": "string", "description": "Define the output filename cache-busting hashing mode.", - "default": "none", "enum": ["none", "all", "media", "bundles"] }, "stylePreprocessorOptions": { @@ -143,8 +130,7 @@ "type": "array", "items": { "type": "string" - }, - "default": [] + } } }, "additionalProperties": false @@ -175,13 +161,11 @@ }, "generatePackageJson": { "type": "boolean", - "description": "Generates a `package.json` and pruned lock file with the project's `node_module` dependencies populated for installing in a container. If a `package.json` exists in the project's directory, it will be reused with dependencies populated.", - "default": false + "description": "Generates a `package.json` and pruned lock file with the project's `node_module` dependencies populated for installing in a container. If a `package.json` exists in the project's directory, it will be reused with dependencies populated." }, "transformers": { "type": "array", "description": "List of TypeScript Compiler Transfomers Plugins.", - "default": [], "aliases": ["tsPlugins"], "items": { "$ref": "#/definitions/transformerPattern" @@ -223,18 +207,15 @@ } } ], - "description": "Dependencies to keep external to the bundle. (`all` (default), `none`, or an array of module names)", - "default": "all" + "description": "Dependencies to keep external to the bundle. (`all` (default), `none`, or an array of module names)" }, "extractCss": { "type": "boolean", - "description": "Extract CSS into a `.css` file.", - "default": true + "description": "Extract CSS into a `.css` file." }, "subresourceIntegrity": { "type": "boolean", - "description": "Enables the use of subresource integrity validation.", - "default": false + "description": "Enables the use of subresource integrity validation." }, "polyfills": { "type": "string", @@ -244,28 +225,25 @@ }, "verbose": { "type": "boolean", - "description": "Emits verbose output", - "default": false + "description": "Emits verbose output" }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as: 'webpack-bundle-analyzer' or ``.", - "default": false + "description": "Generates a 'stats.json' file which can be analyzed using tools such as: 'webpack-bundle-analyzer' or ``." }, "isolatedConfig": { "type": "boolean", "description": "Do not apply Nx webpack plugins automatically. Plugins need to be applied in the project's webpack.config.js file (e.g. withNx, withReact, etc.).", - "default": true + "default": true, + "x-deprecated": "Automatic configuration of Webpack is deprecated in favor of an explicit 'webpack.config.js' file. This option will be removed in Nx 18. See https://nx.dev/recipes/webpack/webpack-config-setup." }, "extractLicenses": { "type": "boolean", - "description": "Extract all licenses in a separate file, in the case of production builds only.", - "default": false + "description": "Extract all licenses in a separate file, in the case of production builds only." }, "memoryLimit": { "type": "number", - "description": "Memory limit for type checking service process in `MB`.", - "default": 2048 + "description": "Memory limit for type checking service process in `MB`." }, "fileReplacements": { "description": "Replace files with other files in the build.", @@ -286,18 +264,16 @@ }, "additionalProperties": false, "required": ["replace", "with"] - }, - "default": [] + } }, "buildLibsFromSource": { "type": "boolean", - "description": "Read buildable libraries from source instead of building them separately.", + "description": "Read buildable libraries from source instead of building them separately. If set to `false`, the `tsConfig` option must also be set to remap paths.", "default": true }, "generateIndexHtml": { "type": "boolean", - "description": "Generates `index.html` file to the output path. This can be turned off if using a webpack plugin to generate HTML such as `html-webpack-plugin`.", - "default": true + "description": "Generates `index.html` file to the output path. This can be turned off if using a webpack plugin to generate HTML such as `html-webpack-plugin`." }, "postcssConfig": { "type": "string", @@ -312,8 +288,7 @@ }, "babelUpwardRootMode": { "type": "boolean", - "description": "Whether to set rootmode to upward. See https://babeljs.io/docs/en/options#rootmode", - "default": false + "description": "Whether to set rootmode to upward. See https://babeljs.io/docs/en/options#rootmode" }, "babelConfig": { "type": "string", @@ -321,7 +296,7 @@ "x-completion-type": "file" } }, - "required": ["tsConfig", "main"], + "required": [], "definitions": { "assetPattern": { "oneOf": [ diff --git a/packages/webpack/src/executors/webpack/webpack.impl.ts b/packages/webpack/src/executors/webpack/webpack.impl.ts index 4bd99f334887d..59f61c7ff00e5 100644 --- a/packages/webpack/src/executors/webpack/webpack.impl.ts +++ b/packages/webpack/src/executors/webpack/webpack.impl.ts @@ -9,25 +9,29 @@ import { switchMap, tap, } from 'rxjs/operators'; -import { join, resolve } from 'path'; +import { resolve } from 'path'; import { calculateProjectBuildableDependencies, createTmpTsConfig, } from '@nx/js/src/utils/buildable-libs-utils'; - -import { getWebpackConfig } from './lib/get-webpack-config'; import { runWebpack } from './lib/run-webpack'; import { deleteOutputDir } from '../../utils/fs'; -import { resolveCustomWebpackConfig } from '../../utils/webpack/custom-webpack'; +import { resolveUserDefinedWebpackConfig } from '../../utils/webpack/resolve-user-defined-webpack-config'; import type { NormalizedWebpackExecutorOptions, WebpackExecutorOptions, } from './schema'; import { normalizeOptions } from './lib/normalize-options'; +import { + composePlugins, + isNxWebpackComposablePlugin, +} from '../../utils/config'; +import { withNx } from '../../utils/with-nx'; +import { getRootTsConfigPath } from '@nx/js'; +import { withWeb } from '../../utils/with-web'; async function getWebpackConfigs( options: NormalizedWebpackExecutorOptions, - projectRoot: string, context: ExecutorContext ): Promise { if (options.isolatedConfig && !options.webpackConfig) { @@ -36,34 +40,32 @@ async function getWebpackConfigs( ); } - let customWebpack = null; - if (options.webpackConfig && options.tsConfig) { - customWebpack = resolveCustomWebpackConfig( + let userDefinedWebpackConfig = null; + if (options.webpackConfig) { + userDefinedWebpackConfig = resolveUserDefinedWebpackConfig( options.webpackConfig, - options.tsConfig.startsWith(context.root) - ? options.tsConfig - : join(context.root, options.tsConfig) + getRootTsConfigPath() ); - if (typeof customWebpack.then === 'function') { - customWebpack = await customWebpack; + if (typeof userDefinedWebpackConfig.then === 'function') { + userDefinedWebpackConfig = await userDefinedWebpackConfig; } } const config = options.isolatedConfig ? {} - : getWebpackConfig(context, options); + : composePlugins(withNx(options), withWeb(options)); - if (typeof customWebpack === 'function') { + if (isNxWebpackComposablePlugin(userDefinedWebpackConfig)) { // Old behavior, call the Nx-specific webpack config function that user exports - return await customWebpack(config, { + return await userDefinedWebpackConfig(config, { options, context, configuration: context.configurationName, // backwards compat }); - } else if (customWebpack) { + } else if (userDefinedWebpackConfig) { // New behavior, we want the webpack config to export object - return customWebpack; + return userDefinedWebpackConfig; } else { // Fallback case, if we cannot find a webpack config path return config; @@ -86,8 +88,8 @@ export async function* webpackExecutor( _options: WebpackExecutorOptions, context: ExecutorContext ): AsyncGenerator { - // Pass to NxWebpackPlugin so we can get the CLI overrides. - process.env['NX_WEBPACK_EXECUTOR_RAW_OPTIONS'] = JSON.stringify(_options); + // Default to production build. + process.env['NODE_ENV'] ||= 'production'; const metadata = context.projectsConfigurations.projects[context.projectName]; const sourceRoot = metadata.sourceRoot; @@ -118,11 +120,6 @@ export async function* webpackExecutor( ); return { success: false, - outfile: resolve( - context.root, - options.outputPath, - options.outputFileName - ), options, }; } @@ -157,7 +154,7 @@ export async function* webpackExecutor( ); } - const configs = await getWebpackConfigs(options, metadata.root, context); + const configs = await getWebpackConfigs(options, context); return yield* eachValueFrom( of(configs).pipe( @@ -184,6 +181,8 @@ export async function* webpackExecutor( const success = results.every( (result) => Boolean(result) && !result.hasErrors() ); + // TODO(jack): This should read output from webpack config if provided. + // The outfile is only used by NestJS, where `@nx/js:node` executor requires it to run the file. return { success, outfile: resolve( diff --git a/packages/webpack/src/generators/configuration/configuration.pcv3.spec.ts b/packages/webpack/src/generators/configuration/configuration.pcv3.spec.ts new file mode 100644 index 0000000000000..736b0109080d6 --- /dev/null +++ b/packages/webpack/src/generators/configuration/configuration.pcv3.spec.ts @@ -0,0 +1,58 @@ +import { + addProjectConfiguration, + readNxJson, + readProjectConfiguration, + Tree, + updateNxJson, +} from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import configurationGenerator from './configuration'; + +describe('webpackProject', () => { + let tree: Tree; + + beforeEach(async () => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push('@nx/webpack/plugin'); + updateNxJson(tree, nxJson); + addProjectConfiguration(tree, 'mypkg', { + root: 'libs/mypkg', + sourceRoot: 'libs/mypkg/src', + targets: {}, + }); + }); + + it('should generate files', async () => { + await configurationGenerator(tree, { + project: 'mypkg', + }); + const project = readProjectConfiguration(tree, 'mypkg'); + expect(project.targets.build).toBeUndefined(); + expect(project.targets.serve).toBeUndefined(); + }); + + it('should support --main option', async () => { + await configurationGenerator(tree, { + project: 'mypkg', + main: 'libs/mypkg/index.ts', + }); + + expect(tree.read('libs/mypkg/webpack.config.js', 'utf-8')).toContain( + `main: 'libs/mypkg/index.ts'` + ); + }); + + it('should support --tsConfig option', async () => { + await configurationGenerator(tree, { + project: 'mypkg', + tsConfig: 'libs/mypkg/tsconfig.custom.json', + }); + + expect(tree.read('libs/mypkg/webpack.config.js', 'utf-8')).toContain( + `tsConfig: 'libs/mypkg/tsconfig.custom.json'` + ); + }); +}); diff --git a/packages/webpack/src/generators/configuration/configuration.ts b/packages/webpack/src/generators/configuration/configuration.ts index 4d2a953f95578..2eca11e782391 100644 --- a/packages/webpack/src/generators/configuration/configuration.ts +++ b/packages/webpack/src/generators/configuration/configuration.ts @@ -10,6 +10,7 @@ import { import { webpackInitGenerator } from '../init/init'; import { ConfigurationGeneratorSchema } from './schema'; import { WebpackExecutorOptions } from '../../executors/webpack/schema'; +import { hasPlugin } from '../../utils/has-plugin'; export async function configurationGenerator( tree: Tree, @@ -20,11 +21,16 @@ export async function configurationGenerator( skipFormat: true, }); checkForTargetConflicts(tree, options); - addBuildTarget(tree, options); - if (options.devServer) { - addServeTarget(tree, options); + + if (!hasPlugin(tree)) { + addBuildTarget(tree, options); + if (options.devServer) { + addServeTarget(tree, options); + } } + createWebpackConfig(tree, options); + if (!options.skipFormat) { await formatFiles(tree); } @@ -53,7 +59,10 @@ function checkForTargetConflicts( } } -function addBuildTarget(tree: Tree, options: ConfigurationGeneratorSchema) { +function createWebpackConfig( + tree: Tree, + options: ConfigurationGeneratorSchema +) { const project = readProjectConfiguration(tree, options.project); const buildOptions: WebpackExecutorOptions = { target: options.target, @@ -65,23 +74,28 @@ function addBuildTarget(tree: Tree, options: ConfigurationGeneratorSchema) { webpackConfig: joinPathFragments(project.root, 'webpack.config.js'), }; - if (options.webpackConfig) { - buildOptions.webpackConfig = options.webpackConfig; - } - - if (options.babelConfig) { - buildOptions.babelConfig = options.babelConfig; - } else if (options.compiler === 'babel') { - // If no babel config file is provided then write a default one, otherwise build will fail. - writeJson(tree, joinPathFragments(project.root, '.babelrc'), { - presets: ['@nx/js/babel'], - }); - } - if (options.target === 'web') { tree.write( joinPathFragments(project.root, 'webpack.config.js'), - ` + hasPlugin(tree) + ? ` +const { NxWebpackPlugin } = require('@nx/webpack'); + +module.exports = { + output: { + path: '${buildOptions.outputPath}', + }, + plugins: [ + new NxWebpackPlugin({ + target: '${buildOptions.target}', + tsConfig: '${buildOptions.tsConfig}', + compiler: '${buildOptions.compiler}', + main: '${buildOptions.main}', + }) + ], +} +` + : ` const { composePlugins, withNx, withWeb } = require('@nx/webpack'); // Nx plugins for webpack. @@ -95,7 +109,25 @@ module.exports = composePlugins(withNx(), withWeb(), (config) => { } else { tree.write( joinPathFragments(project.root, 'webpack.config.js'), - ` + hasPlugin(tree) + ? ` +const { NxWebpackPlugin } = require('@nx/webpack'); + +module.exports = { + output: { + path: '${buildOptions.outputPath}', + }, + plugins: [ + new NxWebpackPlugin({ + target: '${buildOptions.target}', + tsConfig: '${buildOptions.tsConfig}', + compiler: '${buildOptions.compiler}', + main: '${buildOptions.main}', + }) + ], +} +` + : ` const { composePlugins, withNx } = require('@nx/webpack'); // Nx plugins for webpack. @@ -107,6 +139,33 @@ module.exports = composePlugins(withNx(), (config) => { ` ); } +} + +function addBuildTarget(tree: Tree, options: ConfigurationGeneratorSchema) { + const project = readProjectConfiguration(tree, options.project); + const buildOptions: WebpackExecutorOptions = { + target: options.target, + outputPath: joinPathFragments('dist', project.root), + compiler: options.compiler ?? 'swc', + main: options.main ?? joinPathFragments(project.root, 'src/main.ts'), + tsConfig: + options.tsConfig ?? joinPathFragments(project.root, 'tsconfig.app.json'), + webpackConfig: joinPathFragments(project.root, 'webpack.config.js'), + }; + + if (options.webpackConfig) { + buildOptions.webpackConfig = options.webpackConfig; + } + + if (options.babelConfig) { + buildOptions.babelConfig = options.babelConfig; + } else if (options.compiler === 'babel') { + // If no babel config file is provided then write a default one, otherwise build will fail. + writeJson(tree, joinPathFragments(project.root, '.babelrc'), { + presets: ['@nx/js/babel'], + }); + } + updateProjectConfiguration(tree, options.project, { ...project, targets: { diff --git a/packages/webpack/src/generators/init/init.pcv3.spec.ts b/packages/webpack/src/generators/init/init.pcv3.spec.ts new file mode 100644 index 0000000000000..c383baf24315b --- /dev/null +++ b/packages/webpack/src/generators/init/init.pcv3.spec.ts @@ -0,0 +1,38 @@ +import { readJson, Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import { webpackInitGenerator } from './init'; + +describe('webpackInitGenerator (PCv3)', () => { + let tree: Tree; + let previousEnv: string | undefined; + + beforeEach(async () => { + previousEnv = process.env.NX_PCV3; + process.env.NX_PCV3 = 'true'; + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + }); + + afterEach(() => { + process.env.NX_PCV3 = previousEnv; + }); + + it('should install webpack-cli', async () => { + await webpackInitGenerator(tree, { compiler: 'swc' }); + + const packageJson = readJson(tree, 'package.json'); + expect(packageJson).toEqual({ + name: expect.any(String), + dependencies: { + '@swc/helpers': expect.any(String), + }, + devDependencies: { + '@nx/webpack': expect.any(String), + '@swc/cli': expect.any(String), + '@swc/core': expect.any(String), + 'swc-loader': expect.any(String), + 'webpack-cli': expect.any(String), + }, + }); + }); +}); diff --git a/packages/webpack/src/generators/init/init.spec.ts b/packages/webpack/src/generators/init/init.spec.ts index 8ac0257cb6d49..e3627c1f212b7 100644 --- a/packages/webpack/src/generators/init/init.spec.ts +++ b/packages/webpack/src/generators/init/init.spec.ts @@ -1,4 +1,4 @@ -import { Tree, readJson, NxJsonConfiguration, updateJson } from '@nx/devkit'; +import { readJson, Tree } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { webpackInitGenerator } from './init'; diff --git a/packages/webpack/src/generators/init/init.ts b/packages/webpack/src/generators/init/init.ts index ea94976c026b6..0c3601fa38837 100644 --- a/packages/webpack/src/generators/init/init.ts +++ b/packages/webpack/src/generators/init/init.ts @@ -2,8 +2,10 @@ import { addDependenciesToPackageJson, formatFiles, GeneratorCallback, + readNxJson, runTasksInSerial, Tree, + updateNxJson, } from '@nx/devkit'; import { addSwcDependencies } from '@nx/js/src/utils/swc/add-swc-dependencies'; @@ -16,15 +18,21 @@ import { swcLoaderVersion, tsLibVersion, urlLoaderVersion, + webpackCliVersion, } from '../../utils/versions'; -import { addBabelInputs } from '@nx/js/src/utils/add-babel-inputs'; +import { WebpackPluginOptions } from '../../plugins/plugin'; export async function webpackInitGenerator(tree: Tree, schema: Schema) { + const shouldAddPlugin = process.env.NX_PCV3 === 'true'; const tasks: GeneratorCallback[] = []; const devDependencies = { '@nx/webpack': nxVersion, }; + if (shouldAddPlugin) { + devDependencies['webpack-cli'] = webpackCliVersion; + } + if (schema.compiler === 'swc') { devDependencies['swc-loader'] = swcLoaderVersion; const addSwcTask = addSwcDependencies(tree); @@ -47,14 +55,41 @@ export async function webpackInitGenerator(tree: Tree, schema: Schema) { await formatFiles(tree); } - const baseInstalTask = addDependenciesToPackageJson( + const baseInstallTask = addDependenciesToPackageJson( tree, {}, devDependencies ); - tasks.push(baseInstalTask); + tasks.push(baseInstallTask); + + if (shouldAddPlugin) addPlugin(tree); return runTasksInSerial(...tasks); } +function addPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + + for (const plugin of nxJson.plugins) { + if ( + typeof plugin === 'string' + ? plugin === '@nx/webpack/plugin' + : plugin.plugin === '@nx/webpack/plugin' + ) { + return; + } + } + + nxJson.plugins.push({ + plugin: '@nx/webpack/plugin', + options: { + buildTargetName: 'build', + serveTargetName: 'serve', + previewTargetName: 'preview', + } as WebpackPluginOptions, + }); + updateNxJson(tree, nxJson); +} + export default webpackInitGenerator; 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 eb29ce34bf0eb..506d20ee6a61b 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 @@ -8,6 +8,7 @@ import { WebpackOptionsNormalized, WebpackPluginInstance, } from 'webpack'; +import { getRootTsConfigPath } from '@nx/js'; import { StatsJsonPlugin } from '../../stats-json-plugin'; import { GeneratePackageJsonPlugin } from '../../generate-package-json-plugin'; @@ -40,8 +41,173 @@ export function applyBaseConfig( useNormalizedEntry?: boolean; } = {} ): void { + // Defaults that was applied from executor schema previously. + options.compiler ??= 'babel'; + options.deleteOutputPath ??= true; + options.externalDependencies ??= 'all'; + options.fileReplacements ??= []; + options.memoryLimit ??= 2048; + options.transformers ??= []; + + applyNxIndependentConfig(options, config); + + // Some of the options only work during actual tasks, not when reading the webpack config during CreateNodes. + if (!process.env['NX_TASK_TARGET_PROJECT']) return; + + applyNxDependentConfig(options, config, { useNormalizedEntry }); +} + +function applyNxIndependentConfig( + options: NormalizedNxWebpackPluginOptions, + config: Partial +): void { + const hashFormat = getOutputHashFormat(options.outputHashing as string); + config.context = path.join(options.root, options.projectRoot); + config.target ??= options.target; + config.node = false; + config.mode = + // When the target is Node avoid any optimizations, such as replacing `process.env.NODE_ENV` with build time value. + config.target === 'node' + ? 'none' + : // Otherwise, make sure it matches `process.env.NODE_ENV`. + // When mode is development or production, webpack will automatically + // configure DefinePlugin to replace `process.env.NODE_ENV` with the + // build-time value. Thus, we need to make sure it's the same value to + // avoid conflicts. + // + // When the NODE_ENV is something else (e.g. test), then set it to none + // to prevent extra behavior from webpack. + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'production' + ? (process.env.NODE_ENV as 'development' | 'production') + : 'none'; + // When target is Node, the Webpack mode will be set to 'none' which disables in memory caching and causes a full rebuild on every change. + // So to mitigate this we enable in memory caching when target is Node and in watch mode. + config.cache = + options.target === 'node' && options.watch ? { type: 'memory' } : undefined; + + config.devtool = + options.sourceMap === 'hidden' + ? 'hidden-source-map' + : options.sourceMap + ? 'source-map' + : false; + + config.output = { + ...config.output, + path: + config.output?.path ?? + (options.outputPath + ? path.join(options.root, options.outputPath) + : undefined), + filename: + config.output?.filename ?? options.outputHashing + ? `[name]${hashFormat.script}.js` + : '[name].js', + chunkFilename: + config.output?.chunkFilename ?? options.outputHashing + ? `[name]${hashFormat.chunk}.js` + : '[name].js', + hashFunction: config.output?.hashFunction ?? 'xxhash64', + // Disabled for performance + pathinfo: config.output?.pathinfo ?? false, + // Use CJS for Node since it has the widest support. + scriptType: + config.output?.scriptType ?? options.target === 'node' + ? undefined + : 'module', + }; + + config.watch = options.watch; + + config.watchOptions = { + poll: options.poll, + }; + + config.profile = options.statsJson; + + config.performance = { + ...config.performance, + hints: false, + }; + + config.experiments = { ...config.experiments, cacheUnaffected: true }; + + config.ignoreWarnings = [ + (x) => + IGNORED_WEBPACK_WARNINGS.some((r) => + typeof x === 'string' ? r.test(x) : r.test(x.message) + ), + ]; + + config.optimization = { + ...config.optimization, + sideEffects: true, + minimize: + typeof options.optimization === 'object' + ? !!options.optimization.scripts + : !!options.optimization, + minimizer: [ + options.compiler !== 'swc' + ? new TerserPlugin({ + parallel: true, + terserOptions: { + keep_classnames: true, + ecma: getTerserEcmaVersion( + path.join(options.root, options.projectRoot) + ), + safari10: true, + format: { + ascii_only: true, + comments: false, + webkit: true, + }, + }, + extractComments: false, + }) + : new TerserPlugin({ + minify: TerserPlugin.swcMinify, + // `terserOptions` options will be passed to `swc` + terserOptions: { + module: true, + mangle: false, + }, + }), + ], + runtimeChunk: false, + concatenateModules: true, + }; + + config.stats = { + hash: true, + timings: false, + cached: false, + cachedAssets: false, + modules: false, + warnings: true, + errors: true, + colors: !options.verbose && !options.statsJson, + chunks: !options.verbose, + assets: !!options.verbose, + chunkOrigins: !!options.verbose, + chunkModules: !!options.verbose, + children: !!options.verbose, + reasons: !!options.verbose, + version: !!options.verbose, + errorDetails: !!options.verbose, + moduleTrace: !!options.verbose, + usedExports: !!options.verbose, + }; +} + +function applyNxDependentConfig( + options: NormalizedNxWebpackPluginOptions, + config: Partial, + { useNormalizedEntry }: { useNormalizedEntry?: boolean } = {} +): void { + const tsConfig = options.tsConfig ?? getRootTsConfigPath(); const plugins: WebpackPluginInstance[] = [ - new NxTsconfigPathsWebpackPlugin(options), + new NxTsconfigPathsWebpackPlugin({ tsConfig }), ]; const executorContext: Partial = { projectName: options.projectName, @@ -55,9 +221,9 @@ export function applyBaseConfig( plugins.push( new ForkTsCheckerWebpackPlugin({ typescript: { - configFile: path.isAbsolute(options.tsConfig) - ? options.tsConfig - : path.join(options.root, options.tsConfig), + configFile: path.isAbsolute(tsConfig) + ? tsConfig + : path.join(options.root, tsConfig), memoryLimit: options.memoryLimit || 2018, }, }) @@ -141,7 +307,7 @@ export function applyBaseConfig( ); } if (options.generatePackageJson && executorContext) { - plugins.push(new GeneratePackageJsonPlugin(options)); + plugins.push(new GeneratePackageJsonPlugin({ ...options, tsConfig })); } if (options.statsJson) { @@ -163,138 +329,23 @@ export function applyBaseConfig( }); } - const hashFormat = getOutputHashFormat(options.outputHashing as string); - config.context = path.join(options.root, options.projectRoot); - config.target ??= options.target; - config.node = false; - config.mode = - // When the target is Node avoid any optimizations, such as replacing `process.env.NODE_ENV` with build time value. - config.target === 'node' - ? 'none' - : // Otherwise, make sure it matches `process.env.NODE_ENV`. - // When mode is development or production, webpack will automatically - // configure DefinePlugin to replace `process.env.NODE_ENV` with the - // build-time value. Thus, we need to make sure it's the same value to - // avoid conflicts. - // - // When the NODE_ENV is something else (e.g. test), then set it to none - // to prevent extra behavior from webpack. - process.env.NODE_ENV === 'development' || - process.env.NODE_ENV === 'production' - ? (process.env.NODE_ENV as 'development' | 'production') - : 'none'; - // When target is Node, the Webpack mode will be set to 'none' which disables in memory caching and causes a full rebuild on every change. - // So to mitigate this we enable in memory caching when target is Node and in watch mode. - config.cache = - options.target === 'node' && options.watch ? { type: 'memory' } : undefined; - - config.devtool = - options.sourceMap === 'hidden' - ? 'hidden-source-map' - : options.sourceMap - ? 'source-map' - : false; - - config.output = { - ...config.output, - path: - config.output?.path ?? - (options.outputPath - ? path.join(options.root, options.outputPath) - : undefined), - filename: - config.output?.filename ?? options.outputHashing - ? `[name]${hashFormat.script}.js` - : '[name].js', - chunkFilename: - config.output?.chunkFilename ?? options.outputHashing - ? `[name]${hashFormat.chunk}.js` - : '[name].js', - hashFunction: config.output?.hashFunction ?? 'xxhash64', - // Disabled for performance - pathinfo: config.output?.pathinfo ?? false, - // Use CJS for Node since it has the widest support. - scriptType: - config.output?.scriptType ?? options.target === 'node' - ? undefined - : 'module', - }; - - config.watch = options.watch; - - config.watchOptions = { - poll: options.poll, - }; - - config.profile = options.statsJson; - config.resolve = { ...config.resolve, extensions: [...extensions, ...(config?.resolve?.extensions ?? [])], - alias: options.fileReplacements.reduce( - (aliases, replacement) => ({ - ...aliases, - [replacement.replace]: replacement.with, - }), - {} - ), + alias: + options.fileReplacements && + options.fileReplacements.reduce( + (aliases, replacement) => ({ + ...aliases, + [replacement.replace]: replacement.with, + }), + {} + ), mainFields, }; config.externals = externals; - config.optimization = { - ...config.optimization, - sideEffects: true, - minimize: - typeof options.optimization === 'object' - ? !!options.optimization.scripts - : !!options.optimization, - minimizer: [ - options.compiler !== 'swc' - ? new TerserPlugin({ - parallel: true, - terserOptions: { - keep_classnames: true, - ecma: getTerserEcmaVersion( - path.join(options.root, options.projectRoot) - ), - safari10: true, - format: { - ascii_only: true, - comments: false, - webkit: true, - }, - }, - extractComments: false, - }) - : new TerserPlugin({ - minify: TerserPlugin.swcMinify, - // `terserOptions` options will be passed to `swc` - terserOptions: { - module: true, - mangle: false, - }, - }), - ], - runtimeChunk: false, - concatenateModules: true, - }; - - config.performance = { - ...config.performance, - hints: false, - }; - - config.experiments = { ...config.experiments, cacheUnaffected: true }; - - config.ignoreWarnings = [ - (x) => - IGNORED_WEBPACK_WARNINGS.some((r) => - typeof x === 'string' ? r.test(x) : r.test(x.message) - ), - ]; - config.module = { ...config.module, // Enabled for performance @@ -327,27 +378,6 @@ export function applyBaseConfig( ].filter((r) => !!r), }; - config.stats = { - hash: true, - timings: false, - cached: false, - cachedAssets: false, - modules: false, - warnings: true, - errors: true, - colors: !options.verbose && !options.statsJson, - chunks: !options.verbose, - assets: !!options.verbose, - chunkOrigins: !!options.verbose, - chunkModules: !!options.verbose, - children: !!options.verbose, - reasons: !!options.verbose, - version: !!options.verbose, - errorDetails: !!options.verbose, - moduleTrace: !!options.verbose, - usedExports: !!options.verbose, - }; - config.plugins ??= []; config.plugins.push(...plugins); } diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-web-config.ts b/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-web-config.ts index 6d46a412c654b..b879bc71aaa77 100644 --- a/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-web-config.ts +++ b/packages/webpack/src/plugins/nx-webpack-plugin/lib/apply-web-config.ts @@ -35,6 +35,15 @@ export function applyWebConfig( useNormalizedEntry?: boolean; } = {} ): void { + if (!process.env['NX_TASK_TARGET_PROJECT']) return; + + // Defaults that was applied from executor schema previously. + options.runtimeChunk ??= true; // need this for HMR and other things to work + options.extractCss ??= true; + options.generateIndexHtml ??= true; + options.styles ??= []; + options.scripts ??= []; + const plugins: WebpackPluginInstance[] = []; const stylesOptimization = diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/lib/normalize-options.ts b/packages/webpack/src/plugins/nx-webpack-plugin/lib/normalize-options.ts index dfdb72677054d..c4311aadad7e3 100644 --- a/packages/webpack/src/plugins/nx-webpack-plugin/lib/normalize-options.ts +++ b/packages/webpack/src/plugins/nx-webpack-plugin/lib/normalize-options.ts @@ -1,4 +1,4 @@ -import { basename, dirname, relative, resolve } from 'path'; +import { basename, dirname, join, relative, resolve } from 'path'; import { statSync } from 'fs'; import { normalizePath, @@ -51,12 +51,15 @@ export function normalizeOptions( Object.assign(combinedOptions, originalTargetOptions, options); } + normalizeRelativePaths(projectNode.data.root, options); + const sourceRoot = projectNode.data.sourceRoot ?? projectNode.data.root; - if (!options.main) + if (!options.main) { throw new Error( `Missing "main" option for the entry file. Set this option in your Nx webpack plugin.` ); + } return { ...options, @@ -153,3 +156,29 @@ export function normalizeFileReplacements( })) : []; } + +function normalizeRelativePaths( + projectRoot: string, + options: NxWebpackPluginOptions +): void { + for (const [fieldName, fieldValue] of Object.entries(options)) { + if (isRelativePath(fieldValue)) { + options[fieldName] = join(projectRoot, fieldValue); + } else if (Array.isArray(fieldValue)) { + for (let i = 0; i < fieldValue.length; i++) { + if (isRelativePath(fieldValue[i])) { + fieldValue[i] = join(projectRoot, fieldValue[i]); + } + } + } + } +} + +function isRelativePath(val: unknown): boolean { + return ( + typeof val === 'string' && + (val.startsWith('./') || + // Windows + val.startsWith('.\\')) + ); +} diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/nx-webpack-plugin-options.ts b/packages/webpack/src/plugins/nx-webpack-plugin/nx-webpack-plugin-options.ts index 38b304b57992a..c806be3fa80a5 100644 --- a/packages/webpack/src/plugins/nx-webpack-plugin/nx-webpack-plugin-options.ts +++ b/packages/webpack/src/plugins/nx-webpack-plugin/nx-webpack-plugin-options.ts @@ -38,51 +38,177 @@ export interface OptimizationOptions { } export interface NxWebpackPluginOptions { - // Required options - main: string; - outputPath: string; - tsConfig: string; - - // Optional options + /** + * The tsconfig file for the project. e.g. `tsconfig.json` + */ + tsConfig?: string; + /** + * The entry point for the bundle. e.g. `src/main.ts` + */ + main?: string; + /** + * Secondary entry points for the bundle. + */ additionalEntryPoints?: AdditionalEntryPoint[]; + /** + * Assets to be copied over to the output path. + */ assets?: Array; + /** + * Babel configuration file if compiler is babel. + */ babelConfig?: string; + /** + * If true, Babel will look for a babel.config.json up the directory tree. + */ babelUpwardRootMode?: boolean; + /** + * Set for the resulting index.html. + */ baseHref?: string; commonChunk?: boolean; + /** + * The compiler to use. Default is `babel` and requires a `.babelrc` file. + */ compiler?: 'babel' | 'swc' | 'tsc'; + /** + * Set `crossorigin` attribute on the `script` and `link` tags. + */ crossOrigin?: 'none' | 'anonymous' | 'use-credentials'; + /** + * Delete the output path before building. + */ deleteOutputPath?: boolean; + /** + * The deploy path for the application. e.g. `/my-app/` + */ deployUrl?: string; + /** + * Define external packages that will not be bundled. + * Use `all` to exclude all 3rd party packages, and `none` to bundle all packages. + * Use an array to exclude specific packages from the bundle. + * Default is `none`. + */ externalDependencies?: 'all' | 'none' | string[]; + /** + * Extract CSS as an external file. Default is `true`. + */ extractCss?: boolean; + /** + * Extract licenses from 3rd party modules and add them to the output. + */ extractLicenses?: boolean; + /** + * Replace files at build time. e.g. `[{ "replace": "src/a.dev.ts", "with": "src/a.prod.ts" }]` + */ fileReplacements?: FileReplacement[]; + /** + * Generate an `index.html` file if `index.html` is passed. Default is `true` + */ generateIndexHtml?: boolean; + /** + * Generate a `package.json` file for the bundle. Useful for Node applications. + */ generatePackageJson?: boolean; + /** + * Path to the `index.html`. + */ index?: string; + /** + * Set the memory limit for the type-checking process. Default is `2048`. + */ memoryLimit?: number; + /** + * Use the source file name in output chunks. Useful for development or for Node. + */ namedChunks?: boolean; + /** + * Optimize the bundle using Terser. + */ optimization?: boolean | OptimizationOptions; + /** + * Specify the output filename for the bundle. Useful for Node applications that use `@nx/js:node` to serve. + */ outputFileName?: string; + /** + * Use file hashes in the output filenames. Recommended for production web applications. + */ outputHashing?: any; + /** + * Override `output.path` in webpack configuration. This setting is not recommended and exists for backwards compatibility. + */ + outputPath?: string; + /** + * Override `watchOptions.poll` in webpack configuration. This setting is not recommended and exists for backwards compatibility. + */ poll?: number; + /** + * The polyfill file to use. Useful for supporting legacy browsers. e.g. `src/polyfills.ts` + */ polyfills?: string; + /** + * Manually set the PostCSS configuration file. By default, PostCSS will look for `postcss.config.js` in the directory. + */ postcssConfig?: string; + /** + * Display build progress in the terminal. + */ progress?: boolean; + /** + * Add an additional chunk for the Webpack runtime. Defaults to `true` when `target === 'web'`. + */ runtimeChunk?: boolean; + /** + * External scripts that will be included before the main application entry. + */ scripts?: Array; + /** + * Skip type checking. Default is `false`. + */ skipTypeChecking?: boolean; + /** + * Generate source maps. + */ sourceMap?: boolean | 'hidden'; + /** + * When `true`, `process.env.NODE_ENV` will be excluded from the bundle. Useful for building a web application to run in a Node environment. + */ ssr?: boolean; + /** + * Generate a `stats.json` file which can be analyzed using tools such as `webpack-bundle-analyzer`. + */ statsJson?: boolean; + /** + * Options for the style preprocessor. e.g. `{ "includePaths": [] }` for SASS. + */ stylePreprocessorOptions?: any; + /** + * External stylesheets that will be included with the application. + */ styles?: Array; + /** + * Enables the use of subresource integrity validation. + */ subresourceIntegrity?: boolean; + /** + * Override the `target` option in webpack configuration. This setting is not recommended and exists for backwards compatibility. + */ target?: string | string[]; + /** + * List of TypeScript Compiler Transformers Plugins. + */ transformers?: TransformerEntry[]; + /** + * Generate a separate vendor chunk for 3rd party packages. + */ vendorChunk?: boolean; + /** + * Log additional information for debugging purposes. + */ verbose?: boolean; + /** + * Watch for file changes. + */ watch?: boolean; } diff --git a/packages/webpack/src/plugins/nx-webpack-plugin/nx-webpack-plugin.ts b/packages/webpack/src/plugins/nx-webpack-plugin/nx-webpack-plugin.ts index e8c5abbdeff52..3b393c82317ab 100644 --- a/packages/webpack/src/plugins/nx-webpack-plugin/nx-webpack-plugin.ts +++ b/packages/webpack/src/plugins/nx-webpack-plugin/nx-webpack-plugin.ts @@ -21,24 +21,21 @@ import { applyWebConfig } from './lib/apply-web-config'; export class NxWebpackPlugin { private readonly options: NormalizedNxWebpackPluginOptions; - constructor(options: NxWebpackPluginOptions) { - this.options = normalizeOptions({ - ...options, - ...this.readExecutorOptions(), - }); + constructor(options: NxWebpackPluginOptions = {}) { + // If we're not in an Nx task, we're building inferred targets, so skip normalizing build options. + if (process.env['NX_TASK_TARGET_PROJECT']) { + this.options = normalizeOptions(options); + } } apply(compiler: Compiler): void { - const target = this.options.target ?? compiler.options.target; + // Defaults to 'web' if not specified to match Webpack's default. + const target = this.options.target ?? compiler.options.target ?? 'web'; this.options.outputPath ??= compiler.options.output?.path; if (typeof target === 'string') { this.options.target = target; } - if (this.options.deleteOutputPath) { - deleteOutputDir(this.options.root, this.options.outputPath); - } - applyBaseConfig(this.options, compiler.options, { useNormalizedEntry: true, }); @@ -52,14 +49,9 @@ export class NxWebpackPlugin { useNormalizedEntry: true, }); } - } - private readExecutorOptions() { - const fromExecutor = process.env['NX_WEBPACK_EXECUTOR_RAW_OPTIONS'] ?? '{}'; - try { - return JSON.parse(fromExecutor); - } catch { - return {}; + if (this.options.deleteOutputPath) { + deleteOutputDir(this.options.root, this.options.outputPath); } } } diff --git a/packages/webpack/src/plugins/plugin.ts b/packages/webpack/src/plugins/plugin.ts new file mode 100644 index 0000000000000..906f826d6f398 --- /dev/null +++ b/packages/webpack/src/plugins/plugin.ts @@ -0,0 +1,197 @@ +import { + CreateDependencies, + CreateNodes, + CreateNodesContext, + detectPackageManager, + readJsonFile, + TargetConfiguration, + workspaceRoot, + writeJsonFile, +} from '@nx/devkit'; +import { basename, dirname, isAbsolute, join, relative } from 'path'; +import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs'; +import { readTargetDefaultsForTarget } from 'nx/src/project-graph/utils/project-configuration-utils'; +import { WebpackExecutorOptions } from '../executors/webpack/schema'; +import { WebDevServerOptions } from '../executors/dev-server/schema'; +import { existsSync, readdirSync } from 'fs'; +import { readWebpackOptions } from '../utils/webpack/read-webpack-options'; +import { resolveUserDefinedWebpackConfig } from '../utils/webpack/resolve-user-defined-webpack-config'; +import { getLockFileName, getRootTsConfigPath } from '@nx/js'; +import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory'; +import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes'; + +export interface WebpackPluginOptions { + buildTargetName?: string; + serveTargetName?: string; + staticServeTargetName?: string; + previewTargetName?: string; +} + +const cachePath = join(projectGraphCacheDirectory, 'webpack.hash'); +const targetsCache = existsSync(cachePath) ? readTargetsCache() : {}; + +const calculatedTargets: Record< + string, + Record +> = {}; + +function readTargetsCache(): Record< + string, + Record +> { + return readJsonFile(cachePath); +} + +function writeTargetsToCache( + targets: Record> +) { + writeJsonFile(cachePath, targets); +} + +export const createDependencies: CreateDependencies = () => { + writeTargetsToCache(calculatedTargets); + return []; +}; + +export const createNodes: CreateNodes = [ + '**/webpack.config.{js,ts,mjs,mts,cjs,cts}', + async (configFilePath, options, context) => { + options ??= {}; + options.buildTargetName ??= 'build'; + options.serveTargetName ??= 'serve'; + options.staticServeTargetName ??= 'static-serve'; + options.previewTargetName ??= 'preview'; + + const projectRoot = dirname(configFilePath); + + // Do not create a project if package.json and project.json isn't there. + const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot)); + if ( + !siblingFiles.includes('package.json') && + !siblingFiles.includes('project.json') + ) { + return {}; + } + + const hash = calculateHashForCreateNodes(projectRoot, options, context, [ + getLockFileName(detectPackageManager(context.workspaceRoot)), + ]); + const targets = targetsCache[hash] + ? targetsCache[hash] + : await createWebpackTargets( + configFilePath, + projectRoot, + options, + context + ); + + return { + projects: { + [projectRoot]: { + projectType: 'application', + targets, + }, + }, + }; + }, +]; + +async function createWebpackTargets( + configFilePath: string, + projectRoot: string, + options: WebpackPluginOptions, + context: CreateNodesContext +): Promise< + Record< + string, + TargetConfiguration + > +> { + const namedInputs = getNamedInputs(projectRoot, context); + const webpackConfig = resolveUserDefinedWebpackConfig( + join(context.workspaceRoot, configFilePath), + getRootTsConfigPath() + ); + const webpackOptions = await readWebpackOptions(webpackConfig); + + const outputPath = + normalizeOutputPath(webpackOptions.output?.path) ?? + '{workspaceRoot}/dist/{projectRoot}'; + + const targets = {}; + + const configBasename = basename(configFilePath); + + targets[options.buildTargetName] = { + command: `webpack -c ${configBasename} --node-env=production`, + options: { + cwd: projectRoot, + }, + }; + + const buildTargetDefaults = readTargetDefaultsForTarget( + options.buildTargetName, + context.nxJsonConfiguration.targetDefaults + ); + + if (buildTargetDefaults?.cache === undefined) { + targets[options.buildTargetName].cache = true; + } + + if (buildTargetDefaults?.inputs === undefined) { + targets[options.buildTargetName].inputs = + 'production' in namedInputs + ? [ + 'default', + '^production', + { + externalDependencies: ['webpack-cli'], + }, + ] + : [ + 'default', + '^default', + { + externalDependencies: ['webpack-cli'], + }, + ]; + } + + if (buildTargetDefaults?.outputs === undefined) { + targets[options.buildTargetName].outputs = [outputPath]; + } + + targets[options.serveTargetName] = { + command: `webpack serve -c ${configBasename} --node-env=development`, + options: { + cwd: projectRoot, + }, + }; + + targets[options.previewTargetName] = { + command: `webpack serve -c ${configBasename} --node-env=production`, + options: { + cwd: projectRoot, + }, + }; + + targets[options.staticServeTargetName] = { + executor: '@nx/web:file-server', + options: { + buildTarget: `${projectRoot}:${options.buildTargetName}`, + }, + }; + + return targets; +} + +function normalizeOutputPath( + outputPath: string | undefined +): string | undefined { + if (!outputPath) return undefined; + if (isAbsolute(outputPath)) { + return `{workspaceRoot}/${relative(workspaceRoot, outputPath)}`; + } else { + return outputPath; + } +} diff --git a/packages/webpack/src/utils/config.spec.ts b/packages/webpack/src/utils/config.spec.ts index 7eaf6f7959060..9c16ef8457f93 100644 --- a/packages/webpack/src/utils/config.spec.ts +++ b/packages/webpack/src/utils/config.spec.ts @@ -2,6 +2,7 @@ import { composePluginsSync, composePlugins, NxWebpackExecutionContext, + isNxWebpackComposablePlugin, } from './config'; describe('composePlugins', () => { @@ -29,6 +30,7 @@ describe('composePlugins', () => { }; const combined = composePlugins(a(), b(), c(), d()); + expect(isNxWebpackComposablePlugin(combined)).toBeTruthy(); const config = await combined( { plugins: [] }, {} as NxWebpackExecutionContext @@ -59,6 +61,7 @@ describe('composePluginsSync', () => { }; const combined = composePluginsSync(a(), b()); + expect(isNxWebpackComposablePlugin(combined)).toBeTruthy(); const config = await combined( { plugins: [] }, {} as NxWebpackExecutionContext diff --git a/packages/webpack/src/utils/config.ts b/packages/webpack/src/utils/config.ts index ffadd834b67bb..a589f8bcd0b6d 100644 --- a/packages/webpack/src/utils/config.ts +++ b/packages/webpack/src/utils/config.ts @@ -1,29 +1,31 @@ -import { ExecutorContext } from '@nx/devkit'; +import { + ExecutorContext, + readCachedProjectGraph, + workspaceRoot, +} from '@nx/devkit'; import { Configuration } from 'webpack'; import { NormalizedWebpackExecutorOptions } from '../executors/webpack/schema'; -import { withNx } from './with-nx'; -import { withWeb } from './with-web'; - -/** @deprecated use withNx and withWeb plugins directly */ -export function getBaseWebpackPartial( - options: NormalizedWebpackExecutorOptions, - context?: ExecutorContext -): Configuration { - const config: Configuration = {}; - const configure = composePluginsSync(withNx(), withWeb()); - return configure(config, { options, context }); + +export const nxWebpackComposablePlugin = 'nxWebpackComposablePlugin'; + +export function isNxWebpackComposablePlugin( + a: unknown +): a is AsyncNxComposableWebpackPlugin { + return a?.[nxWebpackComposablePlugin] === true; } export interface NxWebpackExecutionContext { options: NormalizedWebpackExecutorOptions; context: ExecutorContext; + configuration?: string; } -export interface NxWebpackPlugin { +export interface NxComposableWebpackPlugin { (config: Configuration, ctx: NxWebpackExecutionContext): Configuration; } -export interface AsyncNxWebpackPlugin { + +export interface AsyncNxComposableWebpackPlugin { (config: Configuration, ctx: NxWebpackExecutionContext): | Configuration | Promise; @@ -31,31 +33,77 @@ export interface AsyncNxWebpackPlugin { export function composePlugins( ...plugins: ( - | NxWebpackPlugin - | AsyncNxWebpackPlugin - | Promise + | NxComposableWebpackPlugin + | AsyncNxComposableWebpackPlugin + | Promise )[] ) { - return async function combined( - config: Configuration, - ctx: NxWebpackExecutionContext - ): Promise { - for (const plugin of plugins) { - const fn = await plugin; - config = await fn(config, ctx); + return Object.assign( + async function combined( + config: Configuration, + ctx: NxWebpackExecutionContext + ): Promise { + // Webpack may be calling us as a standard config function. + // Build up Nx context from environment variables. + // This is to enable `@nx/webpack/plugin` to work with existing projects. + if (ctx['env']) { + ensureNxWebpackExecutionContext(ctx); + // Build this from scratch since what webpack passes us is the env, not config, + // and `withNX()` creates a new config object anyway. + config = {}; + } + + for (const plugin of plugins) { + const fn = await plugin; + config = await fn(config, ctx); + } + return config; + }, + { + [nxWebpackComposablePlugin]: true, } - return config; - }; + ); } -export function composePluginsSync(...plugins: NxWebpackPlugin[]) { - return function combined( - config: Configuration, - ctx: NxWebpackExecutionContext - ): Configuration { - for (const plugin of plugins) { - config = plugin(config, ctx); +export function composePluginsSync(...plugins: NxComposableWebpackPlugin[]) { + return Object.assign( + function combined( + config: Configuration, + ctx: NxWebpackExecutionContext + ): Configuration { + for (const plugin of plugins) { + config = plugin(config, ctx); + } + return config; + }, + { + [nxWebpackComposablePlugin]: true, } - return config; + ); +} + +function ensureNxWebpackExecutionContext(ctx: NxWebpackExecutionContext): void { + const projectName = process.env.NX_TASK_TARGET_PROJECT; + const targetName = process.env.NX_TASK_TARGET_TARGET; + const configurationName = process.env.NX_TASK_TARGET_CONFIGURATION; + const projectGraph = readCachedProjectGraph(); + const projectNode = projectGraph.nodes[projectName]; + ctx.options ??= { + root: workspaceRoot, + projectRoot: projectNode.data.root, + sourceRoot: projectNode.data.sourceRoot ?? projectNode.data.root, + // These aren't actually needed since NxWebpackPlugin and withNx both support them being undefined. + assets: undefined, + outputPath: undefined, + tsConfig: undefined, + outputFileName: undefined, + }; + ctx.context ??= { + projectName, + targetName, + configurationName, + cwd: process.cwd(), + root: workspaceRoot, + isVerbose: process.env['NX_VERBOSE_LOGGING'] === 'true', }; } diff --git a/packages/webpack/src/utils/has-plugin.ts b/packages/webpack/src/utils/has-plugin.ts new file mode 100644 index 0000000000000..298d5481fbccd --- /dev/null +++ b/packages/webpack/src/utils/has-plugin.ts @@ -0,0 +1,10 @@ +import { readNxJson, Tree } from '@nx/devkit'; + +export function hasPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + return !!nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/webpack/plugin' + : p.plugin === '@nx/webpack/plugin' + ); +} diff --git a/packages/webpack/src/utils/versions.ts b/packages/webpack/src/utils/versions.ts index ba4e371c5f41d..722485f1b0c24 100644 --- a/packages/webpack/src/utils/versions.ts +++ b/packages/webpack/src/utils/versions.ts @@ -3,6 +3,8 @@ export const nxVersion = require('../../package.json').version; export const swcLoaderVersion = '0.1.15'; export const tsLibVersion = '^2.3.0'; +export const webpackCliVersion = '^5.1.4'; + // React apps export const reactRefreshWebpackPluginVersion = '^0.5.7'; export const svgrWebpackVersion = '^8.0.1'; diff --git a/packages/webpack/src/utils/webpack/read-webpack-options.ts b/packages/webpack/src/utils/webpack/read-webpack-options.ts new file mode 100644 index 0000000000000..a898363beca65 --- /dev/null +++ b/packages/webpack/src/utils/webpack/read-webpack-options.ts @@ -0,0 +1,45 @@ +import { workspaceRoot } from '@nx/devkit'; +import { isNxWebpackComposablePlugin } from '../config'; +import { Configuration } from 'webpack'; + +/** + * Reads the webpack options from a give webpack configuration. The configuration can be: + * 1. A standard config object + * 2. A standard function that returns a config object (webpack.js.org/configuration/configuration-types/#exporting-a-function) + * 3. A Nx-specific composable function that takes Nx context, webpack config, and returns the config object. + * + * @param webpackConfig + */ +export async function readWebpackOptions( + webpackConfig: unknown +): Promise { + let config: Configuration; + if (isNxWebpackComposablePlugin(webpackConfig)) { + config = await webpackConfig( + {}, + { + // These values are only used during build-time, so passing stubs here just to read out + // the returned config object. + options: { + root: workspaceRoot, + projectRoot: '', + sourceRoot: '', + outputFileName: undefined, + outputPath: undefined, + assets: undefined, + }, + context: { root: workspaceRoot, cwd: undefined, isVerbose: false }, + } + ); + } else if (typeof webpackConfig === 'function') { + config = await webpackConfig( + { + production: true, // we want the production build options + }, + {} + ); + } else { + config = webpackConfig; + } + return config; +} diff --git a/packages/webpack/src/utils/webpack/custom-webpack.ts b/packages/webpack/src/utils/webpack/resolve-user-defined-webpack-config.ts similarity index 83% rename from packages/webpack/src/utils/webpack/custom-webpack.ts rename to packages/webpack/src/utils/webpack/resolve-user-defined-webpack-config.ts index 82ed5a55506c5..f93a5d6839d09 100644 --- a/packages/webpack/src/utils/webpack/custom-webpack.ts +++ b/packages/webpack/src/utils/webpack/resolve-user-defined-webpack-config.ts @@ -1,6 +1,9 @@ import { registerTsProject } from '@nx/js/src/internal'; -export function resolveCustomWebpackConfig(path: string, tsConfig: string) { +export function resolveUserDefinedWebpackConfig( + path: string, + tsConfig: string +) { // Don't transpile non-TS files. This prevents workspaces libs from being registered via tsconfig-paths. // There's an issue here with Nx workspace where loading plugins from source (via tsconfig-paths) can lead to errors. if (!/\.(ts|mts|cts)$/.test(path)) { @@ -27,10 +30,3 @@ export function resolveCustomWebpackConfig(path: string, tsConfig: string) { return customWebpackConfig; } - -export function isRegistered() { - return ( - require.extensions['.ts'] != undefined || - require.extensions['.tsx'] != undefined - ); -} diff --git a/packages/webpack/src/utils/with-nx.ts b/packages/webpack/src/utils/with-nx.ts index 7daf5377da33d..2766a2e696878 100644 --- a/packages/webpack/src/utils/with-nx.ts +++ b/packages/webpack/src/utils/with-nx.ts @@ -1,18 +1,20 @@ import { Configuration } from 'webpack'; -import { NxWebpackExecutionContext, NxWebpackPlugin } from './config'; +import { NxComposableWebpackPlugin, NxWebpackExecutionContext } from './config'; import { applyBaseConfig } from '../plugins/nx-webpack-plugin/lib/apply-base-config'; +import { NxWebpackPluginOptions } from '../plugins/nx-webpack-plugin/nx-webpack-plugin-options'; +import { normalizeAssets } from '../plugins/nx-webpack-plugin/lib/normalize-options'; const processed = new Set(); -export interface WithNxOptions { - skipTypeChecking?: boolean; -} +export type WithNxOptions = Partial; /** * @param {WithNxOptions} pluginOptions * @returns {NxWebpackPlugin} */ -export function withNx(pluginOptions?: WithNxOptions): NxWebpackPlugin { +export function withNx( + pluginOptions: WithNxOptions = {} +): NxComposableWebpackPlugin { return function configure( config: Configuration, { options, context }: NxWebpackExecutionContext @@ -23,6 +25,15 @@ export function withNx(pluginOptions?: WithNxOptions): NxWebpackPlugin { { ...options, ...pluginOptions, + assets: options.assets + ? options.assets + : pluginOptions.assets + ? normalizeAssets( + pluginOptions.assets, + options.root, + options.sourceRoot + ) + : [], root: context.root, projectName: context.projectName, targetName: context.targetName, diff --git a/packages/webpack/src/utils/with-web.ts b/packages/webpack/src/utils/with-web.ts index a3d3f30c00f08..88f7d5197572b 100644 --- a/packages/webpack/src/utils/with-web.ts +++ b/packages/webpack/src/utils/with-web.ts @@ -1,6 +1,6 @@ import { Configuration } from 'webpack'; -import { NxWebpackExecutionContext, NxWebpackPlugin } from './config'; +import { NxComposableWebpackPlugin, NxWebpackExecutionContext } from './config'; import { ExtraEntryPointClass, NormalizedWebpackExecutorOptions, @@ -35,7 +35,9 @@ export type MergedOptions = Omit< * @param {WithWebOptions} pluginOptions * @returns {NxWebpackPlugin} */ -export function withWeb(pluginOptions: WithWebOptions = {}): NxWebpackPlugin { +export function withWeb( + pluginOptions: WithWebOptions = {} +): NxComposableWebpackPlugin { return function configure( config: Configuration, { options, context }: NxWebpackExecutionContext