chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
@@ -0,0 +1,159 @@
import { eachValueFrom } from '@nx/devkit/internal';
import {
ExecutorContext,
parseTargetString,
readTargetOptions,
} from '@nx/devkit';
import { map, tap } from 'rxjs/operators';
import { getDevServerOptions } from './lib/get-dev-server-config';
import {
calculateProjectBuildableDependencies,
createTmpTsConfig,
} from '@nx/js/internal';
import { runWebpackDevServer } from '../../utils/run-webpack';
import { resolveUserDefinedWebpackConfig } from '../../utils/webpack/resolve-user-defined-webpack-config';
import { warnWebpackDevServerExecutorDeprecation } from '../../utils/deprecation';
import { normalizeOptions } from '../webpack/lib/normalize-options';
import { WebpackExecutorOptions } from '../webpack/schema';
import { WebDevServerOptions } from './schema';
import { isNxWebpackComposablePlugin } from '../../utils/config';
import { getRootTsConfigPath } from '@nx/js';
export async function* devServerExecutor(
serveOptions: WebDevServerOptions,
context: ExecutorContext
) {
warnWebpackDevServerExecutorDeprecation();
// Default to dev mode so builds are faster and HMR mode works better.
(process.env as any).NODE_ENV ??= 'development';
const { root: projectRoot, sourceRoot } =
context.projectsConfigurations.projects[context.projectName];
const buildOptions = normalizeOptions(
getBuildOptions(serveOptions, context),
context.root,
projectRoot,
sourceRoot
);
process.env.NX_BUILD_LIBS_FROM_SOURCE = `${buildOptions.buildLibsFromSource}`;
process.env.NX_BUILD_TARGET = serveOptions.buildTarget;
// 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,
context.root,
context.projectName,
'build', // should be generalized
context.configurationName
);
buildOptions.tsConfig = createTmpTsConfig(
buildOptions.tsConfig,
context.root,
target.data.root,
dependencies
);
process.env.NX_TSCONFIG_PATH = buildOptions.tsConfig;
}
let config;
const devServer = getDevServerOptions(
context.root,
serveOptions,
buildOptions
);
if (buildOptions.webpackConfig) {
let userDefinedWebpackConfig = resolveUserDefinedWebpackConfig(
buildOptions.webpackConfig,
getRootTsConfigPath()
);
if (typeof userDefinedWebpackConfig.then === 'function') {
userDefinedWebpackConfig = await userDefinedWebpackConfig;
}
// Only add the dev server option if user is composable plugin.
// Otherwise, user should define `devServer` option directly in their webpack config.
if (
typeof userDefinedWebpackConfig === 'function' &&
(isNxWebpackComposablePlugin(userDefinedWebpackConfig) ||
!buildOptions.standardWebpackConfigFunction)
) {
config = await userDefinedWebpackConfig(
{ devServer },
{
options: buildOptions,
context,
configuration: serveOptions.buildTarget.split(':')[2],
}
);
} else if (userDefinedWebpackConfig) {
// New behavior, we want the webpack config to export object
// If the config is a function, we assume it's a standard webpack config function and it's async
if (typeof userDefinedWebpackConfig === 'function') {
config = await userDefinedWebpackConfig(process.env.NODE_ENV, {});
} else {
config = userDefinedWebpackConfig;
}
config.devServer ??= devServer;
}
}
// Lazy-loaded: optional peers absent during project-graph discovery.
const webpack = require('webpack') as typeof import('webpack');
const WebpackDevServer =
require('webpack-dev-server') as typeof import('webpack-dev-server');
return yield* eachValueFrom(
runWebpackDevServer(config, webpack, WebpackDevServer).pipe(
tap(({ stats }) => {
console.info(stats.toString((config as any).stats));
}),
map(({ baseUrl, stats }) => {
return {
baseUrl,
success: !stats.hasErrors(),
};
})
)
);
}
function getBuildOptions(
options: WebDevServerOptions,
context: ExecutorContext
): WebpackExecutorOptions {
const target = parseTargetString(options.buildTarget, context);
const overrides: Partial<WebpackExecutorOptions> = {
watch: false,
};
if (options.memoryLimit) {
overrides.memoryLimit = options.memoryLimit;
}
if (options.baseHref) {
overrides.baseHref = options.baseHref;
}
const buildOptions = readTargetOptions(target, context);
return {
...buildOptions,
...overrides,
};
}
export default devServerExecutor;
@@ -0,0 +1,97 @@
import { logger } from '@nx/devkit';
import type { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server';
import * as path from 'path';
import { readFileSync } from 'fs';
import { WebDevServerOptions } from '../schema';
import { buildServePath } from './serve-path';
import { NormalizedWebpackExecutorOptions } from '../../webpack/schema';
export function getDevServerOptions(
root: string,
serveOptions: WebDevServerOptions,
buildOptions: NormalizedWebpackExecutorOptions
): WebpackDevServerConfiguration {
const servePath = buildServePath(buildOptions);
let scriptsOptimization: boolean;
let stylesOptimization: boolean;
if (typeof buildOptions.optimization === 'boolean') {
scriptsOptimization = stylesOptimization = buildOptions.optimization;
} else if (buildOptions.optimization) {
scriptsOptimization = buildOptions.optimization.scripts;
stylesOptimization = buildOptions.optimization.styles;
} else {
scriptsOptimization = stylesOptimization = false;
}
const config: WebpackDevServerConfiguration = {
host: serveOptions.host,
port: serveOptions.port,
headers: { 'Access-Control-Allow-Origin': '*' },
historyApiFallback: {
index:
buildOptions.index &&
`${servePath}${path.basename(buildOptions.index)}`,
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
},
onListening(server: any) {
const isHttps =
server.options.https || server.options.server?.type === 'https';
logger.info(
`NX Web Development Server is listening at ${
isHttps ? 'https' : 'http'
}://${server.options.host}:${server.options.port}${buildServePath(
buildOptions
)}`
);
},
open: serveOptions.open,
static: false,
compress: scriptsOptimization || stylesOptimization,
devMiddleware: {
publicPath: servePath,
stats: false,
},
client: {
webSocketURL: serveOptions.publicHost,
overlay: {
errors: !(scriptsOptimization || stylesOptimization),
warnings: false,
},
},
liveReload: serveOptions.hmr ? false : serveOptions.liveReload, // disable liveReload if hmr is enabled
hot: serveOptions.hmr,
};
if (serveOptions.ssl) {
config.server = {
type: 'https',
};
if (serveOptions.sslKey && serveOptions.sslCert) {
config.server.options = getSslConfig(root, serveOptions);
}
}
if (serveOptions.proxyConfig) {
config.proxy = getProxyConfig(root, serveOptions);
}
if (serveOptions.allowedHosts) {
config.allowedHosts = serveOptions.allowedHosts.split(',');
}
return config;
}
function getSslConfig(root: string, options: WebDevServerOptions) {
return {
key: readFileSync(path.resolve(root, options.sslKey), 'utf-8'),
cert: readFileSync(path.resolve(root, options.sslCert), 'utf-8'),
};
}
function getProxyConfig(root: string, options: WebDevServerOptions) {
const proxyPath = path.resolve(root, options.proxyConfig as string);
return require(proxyPath);
}
@@ -0,0 +1,58 @@
import type { NormalizedWebpackExecutorOptions } from '../../webpack/schema';
export function buildServePath(
browserOptions: NormalizedWebpackExecutorOptions
) {
let servePath =
_findDefaultServePath(browserOptions.baseHref, browserOptions.deployUrl) ||
'/';
if (servePath.endsWith('/')) {
servePath = servePath.slice(0, -1);
}
if (!servePath.startsWith('/')) {
servePath = `/${servePath}`;
}
return servePath;
}
export function _findDefaultServePath(
baseHref?: string,
deployUrl?: string
): string | null {
if (!baseHref && !deployUrl) {
return '';
}
if (
/^(\w+:)?\/\//.test(baseHref || '') ||
/^(\w+:)?\/\//.test(deployUrl || '')
) {
// If baseHref or deployUrl is absolute, unsupported by nx serve
return null;
}
// normalize baseHref
// for nx serve the starting base is always `/` so a relative
// and root relative value are identical
const baseHrefParts = (baseHref || '')
.split('/')
.filter((part) => part !== '');
if (baseHref && !baseHref.endsWith('/')) {
baseHrefParts.pop();
}
const normalizedBaseHref =
baseHrefParts.length === 0 ? '/' : `/${baseHrefParts.join('/')}/`;
if (deployUrl && deployUrl[0] === '/') {
if (baseHref && baseHref[0] === '/' && normalizedBaseHref !== deployUrl) {
// If baseHref and deployUrl are root relative and not equivalent, unsupported by nx serve
return null;
}
return deployUrl;
}
// Join together baseHref and deployUrl
return `${normalizedBaseHref}${deployUrl || ''}`;
}
+17
View File
@@ -0,0 +1,17 @@
export interface WebDevServerOptions {
host?: string;
port?: number;
publicHost?: string;
ssl?: boolean;
sslKey?: string;
sslCert?: string;
proxyConfig?: string;
buildTarget: string;
open?: boolean;
liveReload?: boolean;
hmr?: boolean;
watch?: boolean;
allowedHosts?: string;
memoryLimit?: number;
baseHref?: string;
}
@@ -0,0 +1,78 @@
{
"version": 2,
"continuous": true,
"outputCapture": "direct-nodejs",
"title": "Webpack dev server",
"description": "Serve an application using webpack.",
"cli": "nx",
"type": "object",
"x-deprecated": "The `@nx/webpack:dev-server` executor is deprecated and will be removed in Nx v24. Run `nx g @nx/webpack:convert-to-inferred` to migrate to the `@nx/webpack/plugin` inferred plugin. See https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.",
"properties": {
"buildTarget": {
"type": "string",
"description": "Target which builds the application.",
"x-priority": "important"
},
"port": {
"type": "number",
"description": "Port to listen on.",
"default": 4200,
"x-priority": "important"
},
"host": {
"type": "string",
"description": "Host to listen on.",
"default": "localhost"
},
"ssl": {
"type": "boolean",
"description": "Serve using `HTTPS`.",
"default": false
},
"sslKey": {
"type": "string",
"description": "SSL key to use for serving `HTTPS`."
},
"sslCert": {
"type": "string",
"description": "SSL certificate to use for serving `HTTPS`."
},
"watch": {
"type": "boolean",
"description": "Watches for changes and rebuilds application.",
"default": true
},
"liveReload": {
"type": "boolean",
"description": "Whether to reload the page on change, using live-reload.",
"default": true
},
"hmr": {
"type": "boolean",
"description": "Enable hot module replacement.",
"default": false
},
"publicHost": {
"type": "string",
"description": "Public URL where the application will be served."
},
"open": {
"type": "boolean",
"description": "Open the application in the browser.",
"default": false,
"x-priority": "important"
},
"allowedHosts": {
"type": "string",
"description": "This option allows you to whitelist services that are allowed to access the dev server."
},
"memoryLimit": {
"type": "number",
"description": "Memory limit for type checking service process in `MB`."
},
"baseHref": {
"type": "string",
"description": "Base url for the application being built."
}
}
}
@@ -0,0 +1,38 @@
import * as net from 'net';
export function waitUntilServerIsListening(port: number): Promise<void> {
const allowedErrorCodes = ['ECONNREFUSED', 'ECONNRESET'];
const maxAttempts = 25;
let attempts = 0;
const client = new net.Socket();
const cleanup = () => {
client.removeAllListeners('connect');
client.removeAllListeners('error');
client.end();
client.destroy();
client.unref();
};
return new Promise<void>((resolve, reject) => {
const listen = () => {
client.once('connect', () => {
cleanup();
resolve();
});
client.on('error', (err) => {
if (
attempts > maxAttempts ||
!allowedErrorCodes.includes(err['code'])
) {
cleanup();
reject(err);
} else {
attempts++;
setTimeout(listen, 100 * attempts);
}
});
client.connect({ port, host: 'localhost' });
};
listen();
});
}
@@ -0,0 +1,11 @@
interface TargetOptions {
[key: string]: string | boolean | number | TargetOptions;
}
export interface WebSsrDevServerOptions {
browserTarget: string;
serverTarget: string;
port: number;
browserTargetOptions: TargetOptions;
serverTargetOptions: TargetOptions;
}
@@ -0,0 +1,38 @@
{
"version": 2,
"continuous": true,
"outputCapture": "direct-nodejs",
"title": "Webpack SSR Dev Server",
"description": "Serve a SSR application using webpack.",
"cli": "nx",
"type": "object",
"properties": {
"browserTarget": {
"type": "string",
"description": "Target which builds the browser application.",
"x-priority": "important"
},
"serverTarget": {
"type": "string",
"description": "Target which builds the server application.",
"x-priority": "important"
},
"port": {
"type": "number",
"description": "The port to be set on `process.env.PORT` for use in the server.",
"default": 4200,
"x-priority": "important"
},
"browserTargetOptions": {
"type": "object",
"description": "Additional options to pass into the browser build target.",
"default": {}
},
"serverTargetOptions": {
"type": "object",
"description": "Additional options to pass into the server build target.",
"default": {}
}
},
"required": ["browserTarget", "serverTarget"]
}
@@ -0,0 +1,75 @@
import { combineAsyncIterables } from '@nx/devkit/internal';
import {
ExecutorContext,
parseTargetString,
readTargetOptions,
runExecutor,
} from '@nx/devkit';
import * as pc from 'picocolors';
import { WebpackExecutorOptions } from '../webpack/schema';
import { TargetOptions, WebSsrDevServerOptions } from './schema';
import { waitUntilServerIsListening } from './lib/wait-until-server-is-listening';
export async function* ssrDevServerExecutor(
options: WebSsrDevServerOptions,
context: ExecutorContext
) {
const browserTarget = parseTargetString(
options.browserTarget,
context.projectGraph
);
const serverTarget = parseTargetString(options.serverTarget, context);
const browserOptions = readTargetOptions<WebpackExecutorOptions>(
browserTarget,
context
);
const serverOptions = readTargetOptions<WebpackExecutorOptions>(
serverTarget,
context
);
const runBrowser = await runExecutor<{
success: boolean;
baseUrl?: string;
options: TargetOptions;
}>(
browserTarget,
{ ...browserOptions, ...options.browserTargetOptions },
context
);
const runServer = await runExecutor<{
success: boolean;
baseUrl?: string;
options: TargetOptions;
}>(
serverTarget,
{ ...serverOptions, ...options.serverTargetOptions },
context
);
let browserBuilt = false;
let nodeStarted = false;
const combined = combineAsyncIterables(runBrowser, runServer);
for await (const output of combined) {
if (!output.success) throw new Error('Could not build application');
if (output.options?.target === 'node') {
nodeStarted = true;
} else if (output.options?.target === 'web') {
browserBuilt = true;
}
if (nodeStarted && browserBuilt) {
await waitUntilServerIsListening(options.port);
console.log(
`[ ${pc.green('ready')} ] on http://localhost:${options.port}`
);
yield {
...output,
baseUrl: `http://localhost:${options.port}`,
};
}
}
}
export default ssrDevServerExecutor;
@@ -0,0 +1,61 @@
import { resolve } from 'path';
import {
normalizeAssets,
normalizeFileReplacements,
} from '../../../plugins/nx-webpack-plugin/lib/normalize-options';
import type {
NormalizedWebpackExecutorOptions,
WebpackExecutorOptions,
} from '../schema';
import { isUsingTsSolutionSetup } from '@nx/js/internal';
export function normalizeOptions(
options: WebpackExecutorOptions,
root: string,
projectRoot: string,
sourceRoot: string
): NormalizedWebpackExecutorOptions {
const normalizedOptions = {
...options,
useTsconfigPaths: !isUsingTsSolutionSetup(),
root,
projectRoot,
sourceRoot,
target: options.target ?? 'web',
outputFileName: options.outputFileName ?? 'main.js',
webpackConfig: normalizePluginPath(options.webpackConfig, root),
fileReplacements: normalizeFileReplacements(
root,
options.fileReplacements ?? []
),
optimization:
typeof options.optimization !== 'object'
? {
scripts: options.optimization,
styles: options.optimization,
}
: options.optimization,
};
if (options.assets) {
normalizedOptions.assets = normalizeAssets(
options.assets,
root,
sourceRoot,
projectRoot,
false // executor assets are relative to workspace root for consistency
);
}
return normalizedOptions as NormalizedWebpackExecutorOptions;
}
export function normalizePluginPath(pluginPath: void | string, root: string) {
if (!pluginPath) {
return '';
}
try {
return require.resolve(pluginPath);
} catch {
return resolve(root, pluginPath);
}
}
@@ -0,0 +1,38 @@
import type webpack from 'webpack';
import { Observable } from 'rxjs';
// TODO(jack): move to dev-server executor
export function runWebpack(
config: webpack.Configuration
): Observable<webpack.Stats> {
// Lazy-loaded: `webpack` is an optional peer, absent during project-graph discovery.
const webpackImpl = require('webpack') as typeof import('webpack');
return new Observable((subscriber) => {
// Passing `watch` option here will result in a warning due to missing callback.
// We manually call `.watch` or `.run` later so this option isn't needed here.
const { watch, ...normalizedConfig } = config;
const webpackCompiler = webpackImpl(normalizedConfig);
const callback = (err: Error, stats: webpack.Stats) => {
if (err) {
subscriber.error(err);
}
subscriber.next(stats);
};
if (config.watch) {
const watchOptions = config.watchOptions || {};
const watching = webpackCompiler.watch(watchOptions, callback);
return () => watching.close(() => subscriber.complete());
} else {
webpackCompiler.run((err, stats) => {
callback(err, stats);
webpackCompiler.close((closeErr) => {
if (closeErr) subscriber.error(closeErr);
subscriber.complete();
});
});
}
});
}
+104
View File
@@ -0,0 +1,104 @@
import { AssetGlob } from '@nx/js/internal';
export interface AssetGlobPattern {
glob: string;
input: string;
output: string;
ignore?: string[];
}
export interface ExtraEntryPointClass {
bundleName?: string;
inject?: boolean;
input: string;
lazy?: boolean;
}
export interface FileReplacement {
replace: string;
with: string;
}
export interface AdditionalEntryPoint {
entryName: string;
entryPath: string;
}
export interface TransformerPlugin {
name: string;
options: Record<string, unknown>;
}
export type TransformerEntry = string | TransformerPlugin;
export interface OptimizationOptions {
scripts: boolean;
styles: boolean;
}
export interface TypeCheckOptions {
async: boolean;
}
export interface WebpackExecutorOptions {
additionalEntryPoints?: AdditionalEntryPoint[];
assets?: Array<AssetGlob | string>;
buildLibsFromSource?: boolean;
commonChunk?: boolean;
compiler?: 'babel' | 'swc' | 'tsc';
externalDependencies?: 'all' | 'none' | string[];
extractLicenses?: boolean;
fileReplacements?: FileReplacement[];
generatePackageJson?: boolean;
runtimeDependencies?: string[];
standardWebpackConfigFunction?: boolean;
main?: string;
memoryLimit?: number;
namedChunks?: boolean;
optimization?: boolean | OptimizationOptions;
outputFileName?: string;
outputHashing?: any;
outputPath: string;
poll?: number;
polyfills?: string;
progress?: boolean;
runtimeChunk?: boolean;
sourceMap?: boolean | string;
statsJson?: boolean;
target?: string;
/** @deprecated Use `typeCheckOptions` option instead. */
skipTypeChecking?: boolean;
typeCheckOptions?: boolean | TypeCheckOptions;
transformers?: TransformerEntry[];
tsConfig?: string;
vendorChunk?: boolean;
verbose?: boolean;
watch?: boolean;
cache?: boolean | { type: 'memory' | 'filesystem'; [key: string]: any };
webpackConfig?: string;
babelConfig?: string;
babelUpwardRootMode?: boolean;
baseHref?: string;
crossOrigin?: 'none' | 'anonymous' | 'use-credentials';
deployUrl?: string;
extractCss?: boolean;
generateIndexHtml?: boolean;
index?: string;
postcssConfig?: string;
scripts?: Array<ExtraEntryPointClass | string>;
stylePreprocessorOptions?: any;
styles?: Array<ExtraEntryPointClass | string>;
subresourceIntegrity?: boolean;
publicPath?: string;
rebaseRootRelative?: boolean;
}
export interface NormalizedWebpackExecutorOptions
extends WebpackExecutorOptions {
outputFileName: string;
assets: AssetGlobPattern[];
root: string;
projectRoot: string;
sourceRoot: string;
useTsconfigPaths: boolean;
}
@@ -0,0 +1,455 @@
{
"version": 2,
"outputCapture": "direct-nodejs",
"title": "Webpack builder",
"description": "Build a project using webpack.",
"cli": "nx",
"type": "object",
"x-deprecated": "The `@nx/webpack:webpack` executor is deprecated and will be removed in Nx v24. Run `nx g @nx/webpack:convert-to-inferred` to migrate to the `@nx/webpack/plugin` inferred plugin. See https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.",
"properties": {
"crossOrigin": {
"type": "string",
"description": "The `crossorigin` attribute to use for generated javascript script tags. One of 'none' | 'anonymous' | 'use-credentials'."
},
"main": {
"type": "string",
"description": "The name of the main entry-point file.",
"x-completion-type": "file",
"x-completion-glob": "**/*@(.js|.ts|.tsx)",
"x-priority": "important"
},
"tsConfig": {
"type": "string",
"description": "The name of the Typescript configuration file.",
"x-completion-type": "file",
"x-completion-glob": "tsconfig.*.json",
"x-priority": "important"
},
"compiler": {
"type": "string",
"description": "The compiler to use.",
"enum": ["babel", "swc", "tsc"]
},
"outputPath": {
"type": "string",
"description": "The output path of the generated files.",
"x-completion-type": "directory",
"x-priority": "important"
},
"target": {
"type": "string",
"alias": "platform",
"description": "Target platform for the build, same as the Webpack target option.",
"enum": ["node", "web", "webworker"]
},
"watch": {
"type": "boolean",
"description": "Enable re-building when files change."
},
"cache": {
"description": "Configure webpack caching behavior. When not specified, defaults to `{ type: 'memory' }` for Node targets in watch mode, and `undefined` otherwise.",
"oneOf": [
{
"type": "boolean"
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of cache to use.",
"enum": ["memory", "filesystem"]
}
},
"additionalProperties": true
}
]
},
"baseHref": {
"type": "string",
"description": "Base url for the application being built."
},
"deployUrl": {
"type": "string",
"description": "URL where the application will be deployed."
},
"vendorChunk": {
"type": "boolean",
"description": "Use a separate bundle containing only vendor libraries."
},
"commonChunk": {
"type": "boolean",
"description": "Use a separate bundle containing code used across multiple bundles."
},
"runtimeChunk": {
"type": "boolean",
"description": "Use a separate bundle containing the runtime."
},
"skipTypeChecking": {
"alias": "typeCheck",
"type": "boolean",
"description": "Skip the type checking. Default is `false`.",
"x-deprecated": "Use `typeCheckOptions` instead. This option will be removed in Nx 24."
},
"typeCheckOptions": {
"description": "Configure type checking during the build. Set to `true` to enable with defaults (async: true). Set to `false` to disable type checking entirely. Use `{ async: true }` to run type checking in a separate process without blocking the build. Default is `{ async: true }`.",
"oneOf": [
{
"type": "boolean"
},
{
"type": "object",
"properties": {
"async": {
"type": "boolean",
"description": "Run type checking in a separate process without blocking the build.",
"default": true
}
},
"additionalProperties": false
}
]
},
"sourceMap": {
"description": "Output sourcemaps. Use 'hidden' for use with error reporting tools without generating sourcemap comment.",
"oneOf": [
{
"type": "boolean"
},
{
"type": "string"
}
]
},
"progress": {
"type": "boolean",
"description": "Log progress to the console while building."
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period."
},
"assets": {
"type": "array",
"description": "List of static application assets.",
"items": {
"$ref": "#/definitions/assetPattern"
}
},
"index": {
"type": "string",
"description": "HTML File which will be contain the application.",
"x-completion-type": "file",
"x-completion-glob": "**/*@(.html|.htm)"
},
"scripts": {
"type": "array",
"description": "External Scripts which will be included before the main application entry.",
"items": {
"$ref": "#/definitions/extraEntryPoint"
}
},
"styles": {
"type": "array",
"description": "External Styles which will be included with the application",
"items": {
"$ref": "#/definitions/extraEntryPoint"
}
},
"namedChunks": {
"type": "boolean",
"description": "Names the produced bundles according to their entry file."
},
"outputHashing": {
"type": "string",
"description": "Define the output filename cache-busting hashing mode.",
"enum": ["none", "all", "media", "bundles"]
},
"stylePreprocessorOptions": {
"description": "Options to pass to style preprocessors.",
"type": "object",
"properties": {
"includePaths": {
"description": "Paths to include. Paths will be resolved to project root.",
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
},
"optimization": {
"description": "Enables optimization of the build output.",
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "Enables optimization of the scripts output.",
"default": true
},
"styles": {
"type": "boolean",
"description": "Enables optimization of the styles output.",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"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."
},
"skipOverrides": {
"type": "boolean",
"description": "Do not add a `overrides` and `resolutions` entries to the generated package.json file. Only works in conjunction with `generatePackageJson` option."
},
"skipPackageManager": {
"type": "boolean",
"description": "Do not add a `packageManager` entry to the generated package.json file. Only works in conjunction with `generatePackageJson` option."
},
"transformers": {
"type": "array",
"description": "List of TypeScript Compiler Transfomers Plugins.",
"aliases": ["tsPlugins"],
"items": {
"$ref": "#/definitions/transformerPattern"
}
},
"additionalEntryPoints": {
"type": "array",
"items": {
"type": "object",
"properties": {
"entryName": {
"type": "string",
"description": "Name of the additional entry file."
},
"entryPath": {
"type": "string",
"description": "Path to the additional entry file.",
"x-completion-type": "file",
"x-completion-glob": "**/*@(.js|.ts)"
}
}
}
},
"outputFileName": {
"type": "string",
"description": "Name of the main output file.",
"default": "main.js"
},
"externalDependencies": {
"oneOf": [
{
"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)"
},
"extractCss": {
"type": "boolean",
"description": "Extract CSS into a `.css` file."
},
"subresourceIntegrity": {
"type": "boolean",
"description": "Enables the use of subresource integrity validation."
},
"polyfills": {
"type": "string",
"description": "Polyfills to load before application",
"x-completion-type": "file",
"x-completion-glob": "**/*@(.js|.ts|.tsx)"
},
"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 `<https://webpack.github.io/analyse>`."
},
"runtimeDependencies": {
"description": "Add runtime dependencies to the generated `package.json` file. Useful for Docker install.",
"type": "array",
"items": {
"type": "string"
}
},
"standardWebpackConfigFunction": {
"type": "boolean",
"description": "Set to true if the webpack config exports a standard webpack function, not an Nx-specific one. See: https://webpack.js.org/configuration/configuration-types/#exporting-a-function",
"default": false
},
"extractLicenses": {
"type": "boolean",
"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`."
},
"fileReplacements": {
"description": "Replace files with other files in the build.",
"type": "array",
"items": {
"type": "object",
"properties": {
"replace": {
"type": "string",
"description": "The file to be replaced.",
"x-completion-type": "file"
},
"with": {
"type": "string",
"description": "The file to replace with.",
"x-completion-type": "file"
}
},
"additionalProperties": false,
"required": ["replace", "with"]
}
},
"buildLibsFromSource": {
"type": "boolean",
"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`."
},
"postcssConfig": {
"type": "string",
"description": "Set a path to PostCSS config that applies to the app and all libs. Defaults to `undefined`, which auto-detects postcss.config.js files in each `app`/`lib` directory."
},
"webpackConfig": {
"type": "string",
"description": "Path to a function which takes a webpack config, some context and returns the resulting webpack config. See https://nx.dev/guides/customize-webpack",
"x-completion-type": "file",
"x-completion-glob": "webpack?(*)@(.js|.ts)",
"x-priority": "important"
},
"babelUpwardRootMode": {
"type": "boolean",
"description": "Whether to set rootmode to upward. See https://babeljs.io/docs/en/options#rootmode"
},
"babelConfig": {
"type": "string",
"description": "Path to the babel configuration file of your project. If not provided, Nx will default to the .babelrc file at the root of your project. See https://babeljs.io/docs/en/config-files",
"x-completion-type": "file"
},
"publicPath": {
"type": "string",
"description": "Set a public path for assets resources with absolute paths."
},
"rebaseRootRelative": {
"type": "boolean",
"description": "Whether to rebase absolute path for assets in postcss cli resources."
}
},
"required": [],
"definitions": {
"assetPattern": {
"oneOf": [
{
"type": "object",
"properties": {
"glob": {
"type": "string",
"description": "The pattern to match."
},
"input": {
"type": "string",
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
},
"ignore": {
"description": "An array of globs to ignore.",
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "string",
"description": "Absolute path within the output."
}
},
"additionalProperties": false,
"required": ["glob", "input", "output"]
},
{
"type": "string"
}
]
},
"extraEntryPoint": {
"oneOf": [
{
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The file to include.",
"x-completion-type": "file",
"x-completion-glob": "**/*@(.css|.scss|.less|.sass)"
},
"bundleName": {
"type": "string",
"description": "The bundle name for this extra entry point."
},
"inject": {
"type": "boolean",
"description": "If the bundle will be referenced in the HTML file.",
"default": true
}
},
"additionalProperties": false,
"required": ["input"]
},
{
"type": "string",
"description": "The file to include.",
"x-completion-type": "file",
"x-completion-glob": "**/*@(.css|.scss|.less|.sass)"
}
]
},
"transformerPattern": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"options": {
"type": "object",
"additionalProperties": true
}
},
"additionalProperties": false,
"required": ["name"]
}
]
}
},
"examplesFile": "../../../docs/webpack-build-executor-examples.md"
}
@@ -0,0 +1,185 @@
import { eachValueFrom } from '@nx/devkit/internal';
import {
ExecutorContext,
logger,
stripIndents,
targetToTargetString,
} from '@nx/devkit';
import { getRootTsConfigPath } from '@nx/js';
import { getProjectSourceRoot } from '@nx/js/internal';
import { resolve } from 'path';
import { from, of } from 'rxjs';
import {
bufferCount,
mergeMap,
mergeScan,
switchMap,
tap,
} from 'rxjs/operators';
import type { Configuration, Stats } from 'webpack';
import { isNxWebpackComposablePlugin } from '../../utils/config';
import { resolveUserDefinedWebpackConfig } from '../../utils/webpack/resolve-user-defined-webpack-config';
import { warnWebpackExecutorDeprecation } from '../../utils/deprecation';
import { normalizeOptions } from './lib/normalize-options';
import { runWebpack } from './lib/run-webpack';
import type {
NormalizedWebpackExecutorOptions,
WebpackExecutorOptions,
} from './schema';
async function getWebpackConfigs(
options: NormalizedWebpackExecutorOptions,
context: ExecutorContext
): Promise<Configuration | Configuration[]> {
let userDefinedWebpackConfig = null;
if (options.webpackConfig) {
userDefinedWebpackConfig = resolveUserDefinedWebpackConfig(
options.webpackConfig,
getRootTsConfigPath()
);
if (typeof userDefinedWebpackConfig.then === 'function') {
userDefinedWebpackConfig = await userDefinedWebpackConfig;
}
}
const config = {};
if (
typeof userDefinedWebpackConfig === 'function' &&
(isNxWebpackComposablePlugin(userDefinedWebpackConfig) ||
!options.standardWebpackConfigFunction)
) {
// Old behavior, call the Nx-specific webpack config function that user exports
return await userDefinedWebpackConfig(config, {
options,
context,
configuration: context.configurationName, // backwards compat
});
} else if (userDefinedWebpackConfig) {
if (typeof userDefinedWebpackConfig === 'function') {
// assume it's an async standard webpack config function
// https://webpack.js.org/configuration/configuration-types/#exporting-a-promise
return await userDefinedWebpackConfig(process.env.NODE_ENV, {});
}
// New behavior, we want the webpack config to export object
return userDefinedWebpackConfig;
} else {
// Fallback case, if we cannot find a webpack config path
return config;
}
}
export type WebpackExecutorEvent =
| {
success: false;
outfile?: string;
options?: WebpackExecutorOptions;
}
| {
success: true;
outfile: string;
options?: WebpackExecutorOptions;
};
export async function* webpackExecutor(
_options: WebpackExecutorOptions,
context: ExecutorContext
): AsyncGenerator<WebpackExecutorEvent, WebpackExecutorEvent, undefined> {
warnWebpackExecutorDeprecation();
// Default to production build.
process.env['NODE_ENV'] ||= 'production';
const metadata = context.projectsConfigurations.projects[context.projectName];
const sourceRoot = getProjectSourceRoot(metadata);
const options = normalizeOptions(
_options,
context.root,
metadata.root,
sourceRoot
);
const isScriptOptimizeOn =
typeof options.optimization === 'boolean'
? options.optimization
: options.optimization && options.optimization.scripts
? options.optimization.scripts
: false;
(process.env as any).NODE_ENV ||= isScriptOptimizeOn
? 'production'
: 'development';
process.env.NX_BUILD_LIBS_FROM_SOURCE = `${options.buildLibsFromSource}`;
process.env.NX_BUILD_TARGET = targetToTargetString({
project: context.projectName,
target: context.targetName,
configuration: context.configurationName,
});
if (options.compiler === 'swc') {
try {
require.resolve('swc-loader');
require.resolve('@swc/core');
} catch {
logger.error(
`Missing SWC dependencies: @swc/core, swc-loader. Make sure you install them first.`
);
return {
success: false,
options,
};
}
}
if (options.generatePackageJson && metadata.projectType !== 'application') {
logger.warn(
stripIndents`The project ${context.projectName} is using the 'generatePackageJson' option which is deprecated for library projects. It should only be used for applications.
For libraries, configure the project to use the '@nx/dependency-checks' ESLint rule instead (https://nx.dev/nx-api/eslint-plugin/documents/dependency-checks).`
);
}
const configs = await getWebpackConfigs(options, context);
return yield* eachValueFrom(
of(configs).pipe(
mergeMap((config) => (Array.isArray(config) ? from(config) : of(config))),
// Run build sequentially and bail when first one fails.
mergeScan(
(acc, config) => {
if (!acc.hasErrors()) {
return runWebpack(config).pipe(
tap((stats) => {
console.info(stats.toString(config.stats));
})
);
} else {
return of();
}
},
{ hasErrors: () => false } as Stats,
1
),
// Collect build results as an array.
bufferCount(Array.isArray(configs) ? configs.length : 1),
switchMap(async (results) => {
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(
context.root,
options.outputPath,
options.outputFileName
),
options,
};
})
)
);
}
export default webpackExecutor;
@@ -0,0 +1,52 @@
import 'nx/src/internal-testing-utils/mock-project-graph';
import { addProjectConfiguration, Tree } 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' });
addProjectConfiguration(tree, 'mypkg', {
root: 'libs/mypkg',
sourceRoot: 'libs/mypkg/src',
targets: {},
});
});
it('should generate files', async () => {
await configurationGenerator(tree, {
project: 'mypkg',
addPlugin: true,
});
expect(tree.exists('libs/mypkg/webpack.config.js')).toBeTruthy();
});
it('should support --main option', async () => {
await configurationGenerator(tree, {
project: 'mypkg',
addPlugin: true,
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',
addPlugin: true,
tsConfig: 'libs/mypkg/tsconfig.custom.json',
});
expect(tree.read('libs/mypkg/webpack.config.js', 'utf-8')).toContain(
`tsConfig: 'libs/mypkg/tsconfig.custom.json'`
);
});
});
@@ -0,0 +1,261 @@
import { addBuildTargetDefaults } from '@nx/devkit/internal';
import {
formatFiles,
GeneratorCallback,
joinPathFragments,
offsetFromRoot,
readNxJson,
readProjectConfiguration,
runTasksInSerial,
Tree,
updateProjectConfiguration,
writeJson,
} from '@nx/devkit';
import { webpackInitGenerator } from '../init/init';
import { ConfigurationGeneratorSchema } from './schema';
import { WebpackExecutorOptions } from '../../executors/webpack/schema';
import { hasPlugin } from '../../utils/has-plugin';
import { warnWebpackExecutorGenerating } from '../../utils/deprecation';
import { TS_SOLUTION_SETUP_TSCONFIG_INPUT } from '@nx/js/internal';
import { ensureDependencies } from '../../utils/ensure-dependencies';
import { assertSupportedWebpackVersion } from '../../utils/versions';
export function configurationGenerator(
tree: Tree,
options: ConfigurationGeneratorSchema
) {
return configurationGeneratorInternal(tree, { addPlugin: false, ...options });
}
export async function configurationGeneratorInternal(
tree: Tree,
options: ConfigurationGeneratorSchema
) {
assertSupportedWebpackVersion(tree);
const tasks: GeneratorCallback[] = [];
const nxJson = readNxJson(tree);
const addPluginDefault =
process.env.NX_ADD_PLUGINS !== 'false' &&
nxJson.useInferencePlugins !== false;
options.addPlugin ??= addPluginDefault;
const initTask = await webpackInitGenerator(tree, {
...options,
skipFormat: true,
});
tasks.push(initTask);
const depsTask = ensureDependencies(tree, {
compiler: options.compiler === 'babel' ? undefined : options.compiler,
});
tasks.push(depsTask);
checkForTargetConflicts(tree, options);
if (!hasPlugin(tree)) {
warnWebpackExecutorGenerating();
addBuildTarget(tree, options);
if (options.devServer) {
addServeTarget(tree, options);
}
}
createWebpackConfig(tree, options);
if (!options.skipFormat) {
await formatFiles(tree);
}
return runTasksInSerial(...tasks);
}
function checkForTargetConflicts(
tree: Tree,
options: ConfigurationGeneratorSchema
) {
if (options.skipValidation) return;
const project = readProjectConfiguration(tree, options.project);
if (project.targets?.build) {
throw new Error(
`Project "${project.name}" already has a build target. Pass --skipValidation to ignore this error.`
);
}
if (options.devServer && project.targets?.serve) {
throw new Error(
`Project "${project.name}" already has a serve target. Pass --skipValidation to ignore this error.`
);
}
}
function createWebpackConfig(
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.target === 'web') {
tree.write(
joinPathFragments(project.root, 'webpack.config.js'),
hasPlugin(tree)
? `
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { join } = require('path');
module.exports = {
output: {
path: join(__dirname, '${offsetFromRoot(project.root)}${
buildOptions.outputPath
}'),
},
plugins: [
new NxAppWebpackPlugin({
target: '${buildOptions.target}',
tsConfig: '${buildOptions.tsConfig}',
compiler: '${buildOptions.compiler}',
main: '${buildOptions.main}',
outputHashing: '${buildOptions.target !== 'web' ? 'none' : 'all'}',
})
],
}
`
: `
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())\`
config.output.clean = true;
return config;
});
`
);
} else {
tree.write(
joinPathFragments(project.root, 'webpack.config.js'),
hasPlugin(tree)
? `
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { join } = require('path');
module.exports = {
output: {
path: join(__dirname, '${offsetFromRoot(project.root)}${
buildOptions.outputPath
}'),
},
plugins: [
new NxAppWebpackPlugin({
target: '${buildOptions.target}',
tsConfig: '${buildOptions.tsConfig}',
compiler: '${buildOptions.compiler}',
main: '${buildOptions.main}',
outputHashing: '${buildOptions.target !== 'web' ? 'none' : 'all'}',
})
],
}
`
: `
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())\`
config.output.clean = true;
return config;
});
`
);
}
}
function addBuildTarget(tree: Tree, options: ConfigurationGeneratorSchema) {
addBuildTargetDefaults(tree, '@nx/webpack:webpack', 'build', [
TS_SOLUTION_SETUP_TSCONFIG_INPUT,
]);
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: {
...project.targets,
build: {
executor: '@nx/webpack:webpack',
outputs: ['{options.outputPath}'],
defaultConfiguration: 'production',
options: buildOptions,
configurations: {
production: {
optimization: true,
outputHashing: options.target === 'web' ? 'all' : 'none',
sourceMap: false,
namedChunks: false,
extractLicenses: true,
vendorChunk: false,
},
},
},
},
});
}
function addServeTarget(tree: Tree, options: ConfigurationGeneratorSchema) {
const project = readProjectConfiguration(tree, options.project);
updateProjectConfiguration(tree, options.project, {
...project,
targets: {
...project.targets,
serve: {
executor: '@nx/webpack:dev-server',
options: {
buildTarget: `${options.project}:build`,
},
configurations: {
production: {
buildTarget: `${options.project}:build:production`,
},
},
},
},
});
}
export default configurationGenerator;
@@ -0,0 +1,14 @@
export interface ConfigurationGeneratorSchema {
project: string;
main?: string;
tsConfig?: string;
compiler?: 'babel' | 'swc' | 'tsc';
devServer?: boolean;
skipFormat?: boolean;
skipPackageJson?: boolean;
skipValidation?: boolean;
target?: 'node' | 'web' | 'webworker';
webpackConfig?: string;
babelConfig?: string;
addPlugin?: boolean;
}
@@ -0,0 +1,77 @@
{
"$schema": "https://json-schema.org/schema",
"$id": "NxWebpackProject",
"cli": "nx",
"title": "Add Webpack Configuration to a project",
"description": "Add Webpack Configuration to a project.",
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "The name of the project.",
"$default": {
"$source": "argv",
"index": 0
},
"x-dropdown": "project",
"x-prompt": "What is the name of the project to set up a webpack for?",
"x-priority": "important"
},
"compiler": {
"type": "string",
"enum": ["babel", "swc", "tsc"],
"description": "The compiler to use to build source.",
"default": "swc"
},
"main": {
"type": "string",
"description": "Path relative to the workspace root for the main entry file. Defaults to '<projectRoot>/src/main.ts'.",
"x-priority": "important"
},
"tsConfig": {
"type": "string",
"description": "Path relative to the workspace root for the tsconfig file to build with. Defaults to '<projectRoot>/tsconfig.app.json'.",
"x-priority": "important"
},
"target": {
"type": "string",
"description": "Target platform for the build, same as the Webpack config option.",
"enum": ["node", "web", "webworker"],
"default": "web"
},
"skipFormat": {
"description": "Skip formatting files.",
"type": "boolean",
"default": false,
"x-priority": "internal"
},
"skipPackageJson": {
"type": "boolean",
"default": false,
"description": "Do not add dependencies to `package.json`.",
"x-priority": "internal"
},
"skipValidation": {
"type": "boolean",
"default": false,
"description": "Do not perform any validation on existing project.",
"x-priority": "internal"
},
"devServer": {
"type": "boolean",
"description": "Add a serve target to run a local webpack dev-server",
"default": false
},
"webpackConfig": {
"type": "string",
"description": "Path relative to workspace root to a custom webpack file that takes a config object and returns an updated config.",
"x-priority": "internal"
},
"babelConfig": {
"type": "string",
"description": "Optionally specify a path relative to workspace root to the babel configuration file of your project.",
"x-completion-type": "file"
}
},
"required": []
}
@@ -0,0 +1,437 @@
import {
ProjectConfiguration,
Tree,
addProjectConfiguration,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import convertConfigToWebpackPluginGenerator from './convert-config-to-webpack-plugin';
interface CreateProjectOptions {
name: string;
root: string;
targetName: string;
targetOptions: Record<string, unknown>;
additionalTargets?: Record<string, unknown>;
}
const defaultOptions: CreateProjectOptions = {
name: 'my-app',
root: 'my-app',
targetName: 'build',
targetOptions: {},
};
function createProject(tree: Tree, options: Partial<CreateProjectOptions>) {
const projectOpts = {
...defaultOptions,
...options,
targetOptions: {
...defaultOptions.targetOptions,
...options?.targetOptions,
},
};
const project: ProjectConfiguration = {
name: projectOpts.name,
root: projectOpts.root,
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
webpackConfig: `${projectOpts.root}/webpack.config.js`,
...projectOpts.targetOptions,
},
},
...options.additionalTargets,
},
};
addProjectConfiguration(tree, project.name, project);
return project;
}
describe('convertConfigToWebpackPluginGenerator', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should migrate the webpack config of the specified project', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
createProject(tree, {
name: 'another-app',
root: 'another-app',
});
tree.write(
'another-app/webpack.config.js',
`
const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx(),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
}
);
`
);
tree.write(
`${project.name}/webpack.config.js`,
`
const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx(),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
}
);
`
);
await convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
});
expect(tree.read(`${project.name}/webpack.config.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'
// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.
const options = {};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
plugins: [
new NxAppWebpackPlugin(),
new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
// NOTE: useLegacyNxPlugin ensures that the non-standard Webpack configuration file previously used still works.
// To remove its usage, move options such as "plugins" into this file as standard Webpack configuration options.
// To enhance configurations after Nx plugins have applied, you can add a new plugin with the \\\`apply\\\` method.
// e.g. \\\`{ apply: (compiler) => { /* modify compiler.options */ }\\\`
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old.js'), options),
],
});
"
`);
expect(tree.read(`${project.name}/webpack.config.old.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"const { composePlugins } = require('@nx/webpack');
// Nx plugins for webpack.
module.exports = composePlugins((config) => {
return config;
});
"
`);
expect(tree.read(`another-app/webpack.config.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx(),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
},
);
"
`);
expect(tree.exists(`${project.name}/webpack.config.old.js`)).toBe(true);
expect(tree.exists(`another-app/webpack.config.old.js`)).toBe(false);
});
it('should update project.json adding the standardWebpackConfigFunction option', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
tree.write(
`${project.name}/webpack.config.js`,
`
const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx(),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
}
);
`
);
await convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
});
expect(tree.read(`${project.name}/project.json`, 'utf-8'))
.toMatchInlineSnapshot(`
"{
"name": "my-app",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"options": {
"webpackConfig": "my-app/webpack.config.js",
"standardWebpackConfigFunction": true
}
}
}
}
"
`);
});
it('should throw an error if no projects are found', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
await expect(
convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
})
).rejects.toThrow('Could not find any projects to migrate.');
});
it('should not migrate a webpack config that does not use withNx', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
tree.write(`${project.name}/webpack.config.js`, `module.exports = {};`);
await expect(
convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
})
).rejects.toThrow('Could not find any projects to migrate.');
expect(
tree.read(`${project.name}/webpack.config.js`, 'utf-8')
).toMatchInlineSnapshot(`"module.exports = {};"`);
});
it('should throw an error if the project is using Module federation', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
additionalTargets: {
serve: {
executor: '@nx/react:module-federation-dev-server',
options: {
buildTarget: 'my-app:build',
},
},
},
});
await expect(
convertConfigToWebpackPluginGenerator(tree, { project: project.name })
).rejects.toThrow(
`The project ${project.name} is using Module Federation. At the moment, we don't support migrating projects that use Module Federation.`
);
});
it('should throw an error if the project is a Nest project', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
additionalTargets: {
serve: {
executor: '@nx/js:node',
options: {
buildTarget: 'my-app:build',
},
},
},
});
await expect(
convertConfigToWebpackPluginGenerator(tree, { project: project.name })
).rejects.toThrow(
`The project ${project.name} is using the '@nx/js:node' executor. At the moment, we do not support migrating such projects.`
);
});
it('should not migrate a webpack config that is already using NxAppWebpackPlugin', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'my-app',
});
tree.write(
`${project.name}/webpack.config.js`,
`
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
module.exports = {
plugins: [
new NxAppWebpackPlugin(),
],
};
`
);
await expect(
convertConfigToWebpackPluginGenerator(tree, { project: project.name })
).rejects.toThrow(`Could not find any projects to migrate.`);
expect(tree.read(`${project.name}/webpack.config.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
module.exports = {
plugins: [
new NxAppWebpackPlugin(),
],
};
"
`);
expect(tree.exists(`${project.name}/webpack.config.old.js`)).toBe(false);
});
it('should convert absolute options paths to relative paths during the conversion', async () => {
const project = createProject(tree, {
name: 'my-app',
root: 'apps/my-app',
});
tree.write(
`${project.root}/webpack.config.js`,
`
const { composePlugins, withNx } = require('@nx/webpack');
const { withReact } = require('@nx/react');
// Nx plugins for webpack.
module.exports = composePlugins(
withNx({
assets: ["apps/${project.name}/src/favicon.ico","apps/${project.name}/src/assets"],
styles: ["apps/${project.name}/src/styles.scss"],
scripts: ["apps/${project.name}/src/scripts.js"],
tsConfig: "apps/${project.name}/tsconfig.app.json",
fileReplacements: [
{
replace: "apps/${project.name}/src/environments/environment.ts",
with: "apps/${project.name}/src/environments/environment.prod.ts"
}
],
additionalEntryPoints: [
{
entryPath: "apps/${project.name}/src/polyfills.ts",
}
]
}),
withReact({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
(config) => {
return config;
}
);
`
);
await convertConfigToWebpackPluginGenerator(tree, {
project: project.name,
});
expect(tree.read(`${project.root}/webpack.config.js`, 'utf-8'))
.toMatchInlineSnapshot(`
"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'
// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.
const options = {
assets: ['./src/favicon.ico', './src/assets'],
styles: ['./src/styles.scss'],
scripts: ['./src/scripts.js'],
tsConfig: './tsconfig.app.json',
fileReplacements: [
{
replace: './src/environments/environment.ts',
with: './src/environments/environment.prod.ts',
},
],
additionalEntryPoints: [
{
entryPath: './src/polyfills.ts',
},
],
};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
plugins: [
new NxAppWebpackPlugin(options),
new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
// NOTE: useLegacyNxPlugin ensures that the non-standard Webpack configuration file previously used still works.
// To remove its usage, move options such as "plugins" into this file as standard Webpack configuration options.
// To enhance configurations after Nx plugins have applied, you can add a new plugin with the \\\`apply\\\` method.
// e.g. \\\`{ apply: (compiler) => { /* modify compiler.options */ }\\\`
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old.js'), options),
],
});
"
`);
});
});
@@ -0,0 +1,142 @@
import { forEachExecutorOptions } from '@nx/devkit/internal';
import {
formatFiles,
getProjects,
stripIndents,
Tree,
joinPathFragments,
updateProjectConfiguration,
ProjectConfiguration,
} from '@nx/devkit';
import { WebpackExecutorOptions } from '../../executors/webpack/schema';
import { extractWebpackOptions } from './lib/extract-webpack-options';
import { normalizePathOptions } from './lib/normalize-path-options';
import { parse } from 'path';
import { validateProject } from './lib/validate-project';
import { assertSupportedWebpackVersion } from '../../utils/versions';
interface Schema {
project?: string;
skipFormat?: boolean;
}
// Make text JSON compatible
const preprocessText = (text: string) => {
return text
.replace(/(\w+):/g, '"$1":') // Quote property names
.replace(/'/g, '"') // Convert single quotes to double quotes
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas
.replace(/(\r\n|\n|\r|\t)/gm, ''); // Remove newlines and tabs
};
export async function convertConfigToWebpackPluginGenerator(
tree: Tree,
options: Schema
) {
assertSupportedWebpackVersion(tree);
let migrated = 0;
const projects = getProjects(tree);
forEachExecutorOptions<WebpackExecutorOptions>(
tree,
'@nx/webpack:webpack',
(currentTargetOptions, projectName, targetName, configurationName) => {
if (options.project && projectName !== options.project) {
return;
}
if (!configurationName) {
const project = projects.get(projectName);
const target = project.targets[targetName];
const hasError = validateProject(tree, project);
if (hasError) {
throw new Error(hasError);
}
const webpackConfigPath = currentTargetOptions?.webpackConfig || '';
if (webpackConfigPath && tree.exists(webpackConfigPath)) {
let { withNxConfig: webpackOptions, withReactConfig } =
extractWebpackOptions(tree, webpackConfigPath);
// if webpackOptions === undefined
// withNx was not found in the webpack.config.js file so we should skip this project
if (webpackOptions !== undefined) {
let parsedOptions = {};
if (webpackOptions) {
parsedOptions = JSON.parse(
preprocessText(webpackOptions.getText())
);
parsedOptions = normalizePathOptions(project.root, parsedOptions);
}
target.options.standardWebpackConfigFunction = true;
updateProjectConfiguration(tree, projectName, project);
const { dir, name, ext } = parse(webpackConfigPath);
tree.rename(
webpackConfigPath,
`${joinPathFragments(dir, `${name}.old${ext}`)}`
);
tree.write(
webpackConfigPath,
stripIndents`
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'
// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.
const options = ${
webpackOptions ? JSON.stringify(parsedOptions, null, 2) : '{}'
};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
plugins: [
${
webpackOptions
? 'new NxAppWebpackPlugin(options)'
: 'new NxAppWebpackPlugin()'
},
${
withReactConfig
? `new NxReactWebpackPlugin(${withReactConfig.getText()})`
: `new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
})`
},
// NOTE: useLegacyNxPlugin ensures that the non-standard Webpack configuration file previously used still works.
// To remove its usage, move options such as "plugins" into this file as standard Webpack configuration options.
// To enhance configurations after Nx plugins have applied, you can add a new plugin with the \`apply\` method.
// e.g. \`{ apply: (compiler) => { /* modify compiler.options */ }\`
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old${ext}'), options),
],
});
`
);
migrated++;
}
}
}
}
);
if (migrated === 0) {
throw new Error('Could not find any projects to migrate.');
}
if (!options.skipFormat) {
await formatFiles(tree);
}
}
export default convertConfigToWebpackPluginGenerator;
@@ -0,0 +1,175 @@
import { Tree } from '@nx/devkit';
import { ast, query, replace } from '@phenomnomnominal/tsquery';
import * as ts from 'typescript';
export function extractWebpackOptions(tree: Tree, webpackConfigPath: string) {
const source = tree.read(webpackConfigPath).toString('utf-8');
const sourceFile = ast(source);
const withNxQuery = 'CallExpression:has(Identifier[name="withNx"])';
const withReactQuery = 'CallExpression:has(Identifier[name="withReact"])';
const withWebQuery = 'CallExpression:has(Identifier[name="withWeb"])';
const withNxCall = query(sourceFile, withNxQuery) as ts.CallExpression[];
const withReactCall = query(
sourceFile,
withReactQuery
) as ts.CallExpression[];
const withWebCall = query(sourceFile, withWebQuery) as ts.CallExpression[];
// If the config is empty set to empty string to avoid undefined. Undefined is used to check if the withNx exists inside of the config file.
let withNxConfig: ts.Node | '' | undefined,
withReactConfig: ts.Node | '' | undefined;
withWebCall.forEach((node) => {
const argument = node.arguments[0] || '';
withNxConfig = argument; // Since withWeb and withNx use the same config object and both should not exist in the same file, we can reuse the withNxConfig variable.
});
withNxCall.forEach((node) => {
const argument = node.arguments[0] || ''; // The first argument is the config object
withNxConfig = argument;
});
withReactCall.forEach((node) => {
const argument = node.arguments[0] || '';
withReactConfig = argument;
});
if (withNxConfig !== undefined) {
// Only remove the withNx and withReact calls if they exist
let updatedSource = removeCallExpressions(source, [
'withNx',
'withReact',
'withWeb',
]);
updatedSource = removeImportDeclarations(
updatedSource,
'withNx',
'@nx/webpack'
);
updatedSource = removeImportDeclarations(
updatedSource,
'withWeb',
'@nx/webpack'
);
updatedSource = removeImportDeclarations(
updatedSource,
'withReact',
'@nx/react'
);
tree.write(webpackConfigPath, updatedSource);
}
return { withNxConfig, withReactConfig };
}
function removeCallExpressions(
source: string,
functionNames: string[]
): string {
let modifiedSource = source;
functionNames.forEach((functionName) => {
const callExpressionQuery = `CallExpression:has(Identifier[name="composePlugins"]) > CallExpression:has(Identifier[name="${functionName}"])`;
modifiedSource = replace(modifiedSource, callExpressionQuery, () => {
return ''; // Removes the entire CallExpression
});
});
return modifiedSource;
}
function removeImportDeclarations(
source: string,
importName: string,
moduleName: string
) {
const sourceFile = ast(source);
const modifiedStatements = sourceFile.statements
.map((statement) => {
if (!ts.isVariableStatement(statement)) return statement;
const declarationList = statement.declarationList;
const newDeclarations = declarationList.declarations
.map((declaration) => {
if (
!ts.isVariableDeclaration(declaration) ||
!declaration.initializer
)
return declaration;
if (
ts.isCallExpression(declaration.initializer) &&
ts.isIdentifier(declaration.initializer.expression)
) {
const callExpr = declaration.initializer.expression;
if (
callExpr.text === 'require' &&
declaration.initializer.arguments[0]
?.getText()
.replace(/['"]/g, '') === moduleName
) {
if (ts.isObjectBindingPattern(declaration.name)) {
const bindingElements = declaration.name.elements.filter(
(element) => {
const elementName = element.name.getText();
return elementName !== importName;
}
);
if (bindingElements.length > 0) {
const newBindingPattern =
ts.factory.updateObjectBindingPattern(
declaration.name,
bindingElements
);
// Update the variable declaration with the new binding pattern without the specified import name
return ts.factory.updateVariableDeclaration(
declaration,
newBindingPattern,
declaration.exclamationToken,
declaration.type,
declaration.initializer
);
} else {
return null; // Remove this declaration entirely if no bindings remain
}
}
}
}
return declaration;
})
.filter(Boolean);
if (newDeclarations.length > 0) {
const newDeclarationList = ts.factory.updateVariableDeclarationList(
declarationList,
newDeclarations as ts.VariableDeclaration[]
);
return ts.factory.updateVariableStatement(
statement,
statement.modifiers,
newDeclarationList
);
} else {
return null; // Remove the entire statement
}
})
.filter(Boolean);
// Use printer to format the source code and rewrite the modified
const newSourceFile = ts.factory.updateSourceFile(
sourceFile,
modifiedStatements as ts.Statement[]
);
const printer = ts.createPrinter();
const formattedSource = printer.printFile(newSourceFile);
return formattedSource;
}
@@ -0,0 +1,92 @@
import { WebpackExecutorOptions } from '../../../executors/webpack/schema';
import { toProjectRelativePath } from './utils';
const executorFieldsToNormalize: Array<keyof WebpackExecutorOptions> = [
'outputPath',
'index',
'main',
'assets',
'tsConfig',
'styles',
'babelConfig',
'additionalEntryPoints',
'scripts',
'fileReplacements',
'postcssConfig',
'stylePreprocessorOptions',
'publicPath',
];
export function normalizePathOptions(
projectRoot: string,
options: Partial<WebpackExecutorOptions>
) {
for (const [key, value] of Object.entries(options)) {
if (
!executorFieldsToNormalize.includes(key as keyof WebpackExecutorOptions)
) {
continue;
}
options[key] = normalizePath(
projectRoot,
key as keyof WebpackExecutorOptions,
value
);
}
return options;
}
function normalizePath<K extends keyof WebpackExecutorOptions>(
projectRoot: string,
key: K,
value: WebpackExecutorOptions[K]
) {
if (!value) return value;
switch (key) {
case 'assets':
return value.map((asset) => {
if (typeof asset === 'string') {
return toProjectRelativePath(asset, projectRoot);
}
return {
...asset,
input: toProjectRelativePath(asset.input, projectRoot),
output: toProjectRelativePath(asset.output, projectRoot),
};
});
case 'styles':
case 'scripts':
return value.map((item) => {
if (typeof item === 'string') {
return toProjectRelativePath(item, projectRoot);
}
return {
...item,
input: toProjectRelativePath(item.input, projectRoot),
};
});
case 'additionalEntryPoints':
return value.map((entry) => {
return {
...entry,
entryPath: toProjectRelativePath(entry.entryPath, projectRoot),
};
});
case 'fileReplacements':
return value.map((replacement) => {
return {
replace: toProjectRelativePath(replacement.replace, projectRoot),
with: toProjectRelativePath(replacement.with, projectRoot),
};
});
default:
return Array.isArray(value)
? value.map((item) => toProjectRelativePath(item, projectRoot))
: toProjectRelativePath(value, projectRoot);
}
}
@@ -0,0 +1,19 @@
import { relative, resolve } from 'path/posix';
import { workspaceRoot } from '@nx/devkit';
export function toProjectRelativePath(
path: string,
projectRoot: string
): string {
if (projectRoot === '.') {
// workspace and project root are the same, we normalize it to ensure it
return path.startsWith('.') ? path : `./${path}`;
}
const relativePath = relative(
resolve(workspaceRoot, projectRoot),
resolve(workspaceRoot, path)
);
return relativePath.startsWith('.') ? relativePath : `./${relativePath}`;
}
@@ -0,0 +1,47 @@
import { ProjectConfiguration, Tree } from '@nx/devkit';
function hasAnotherWebpackConfig(tree: Tree, projectRoot: string) {
const files = tree.children(projectRoot);
const projectJsonString = tree.read(`${projectRoot}/project.json`, 'utf-8');
for (const file of files) {
if (
file !== 'webpack.config.js' &&
file.endsWith('.js') &&
file.includes('webpack.config') &&
projectJsonString.includes(file) &&
tree.exists(`${projectRoot}/webpack.config.js`)
) {
return 'Cannot convert a project with multiple webpack config files. Please consolidate them into a single webpack.config.js file.';
}
}
}
function isNestProject(project: ProjectConfiguration) {
for (const target in project.targets) {
if (project.targets[target].executor === '@nx/js:node') {
return `The project ${project.name} is using the '@nx/js:node' executor. At the moment, we do not support migrating such projects.`;
}
}
}
/**
* Validates the project to ensure it can be migrated
*
* @param tree The virtaul file system
* @param project the project configuration object for the project
* @returns A string if there is an error, otherwise undefined
*/
export function validateProject(tree: Tree, project: ProjectConfiguration) {
const containsMfeExecutor = Object.keys(project.targets).some((target) => {
return [
'@nx/react:module-federation-dev-server',
'@nx/angular:module-federation-dev-server',
].includes(project.targets[target].executor);
});
if (containsMfeExecutor) {
return `The project ${project.name} is using Module Federation. At the moment, we don't support migrating projects that use Module Federation.`;
}
const hasAnotherConfig = hasAnotherWebpackConfig(tree, project.root);
return hasAnotherConfig || isNestProject(project);
}
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/schema",
"$id": "NxWebpackConvertConfigToWebpackPlugin",
"description": "Convert existing Webpack project(s) using `@nx/webpack:webpack` executor that uses `withNx` to use `NxAppWebpackPlugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.",
"title": "Convert Webpack project using withNx to NxAppWebpackPlugin",
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "The project to convert from using the `@nx/webpack:webpack` executor and `withNx` plugin to use `NxAppWebpackPlugin`.",
"x-priority": "important"
},
"skipFormat": {
"type": "boolean",
"description": "Whether to format files at the end of the migration.",
"default": false
}
}
}
@@ -0,0 +1,280 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`convert-to-inferred all projects should migrate all projects using the webpack executors 1`] = `
"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// These options were migrated by @nx/webpack:convert-to-inferred from
// the project.json file and merged with the options in this file
const configValues = {
build: {
default: {
compiler: 'babel',
outputPath: '../../dist/apps/app1',
index: './src/index.html',
baseHref: '/',
main: './src/main.tsx',
tsConfig: './tsconfig.app.json',
assets: ['./src/favicon.ico', './src/assets'],
styles: ['./src/styles.scss'],
},
development: {
extractLicenses: false,
optimization: false,
sourceMap: true,
vendorChunk: true,
},
production: {
fileReplacements: [
{
replace: './src/environments/environment.ts',
with: './src/environments/environment.prod.ts',
},
],
optimization: true,
outputHashing: 'all',
sourceMap: false,
namedChunks: false,
extractLicenses: true,
vendorChunk: false,
},
},
serve: {
default: {
hot: true,
liveReload: false,
server: {
type: 'https',
options: { cert: './server.crt', key: './server.key' },
},
proxy: { '/api': { target: 'http://localhost:3333', secure: false } },
port: 4200,
headers: { 'Access-Control-Allow-Origin': '*' },
historyApiFallback: {
index: '/index.html',
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
},
},
development: { open: true },
production: { hot: false },
},
};
// Determine the correct configValue to use based on the configuration
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
const buildOptions = {
...configValues.build.default,
...configValues.build[configuration],
};
const devServerOptions = {
...configValues.serve.default,
...configValues.serve[configuration],
};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
devServer: devServerOptions,
plugins: [
new NxAppWebpackPlugin(buildOptions),
new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions),
],
});
"
`;
exports[`convert-to-inferred all projects should migrate all projects using the webpack executors 2`] = `
"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// These options were migrated by @nx/webpack:convert-to-inferred from
// the project.json file and merged with the options in this file
const configValues = {
build: {
default: {
compiler: 'babel',
outputPath: '../../dist/apps/app2',
index: './src/index.html',
baseHref: '/',
main: './src/main.tsx',
tsConfig: './tsconfig.app.json',
assets: ['./src/favicon.ico', './src/assets'],
styles: ['./src/styles.scss'],
},
development: {
extractLicenses: false,
optimization: false,
sourceMap: true,
vendorChunk: true,
},
production: {
fileReplacements: [
{
replace: './src/environments/environment.ts',
with: './src/environments/environment.prod.ts',
},
],
optimization: true,
outputHashing: 'all',
sourceMap: false,
namedChunks: false,
extractLicenses: true,
vendorChunk: false,
},
},
serve: {
default: {
hot: true,
liveReload: false,
server: {
type: 'https',
options: { cert: './server.crt', key: './server.key' },
},
proxy: { '/api': { target: 'http://localhost:3333', secure: false } },
port: 4200,
headers: { 'Access-Control-Allow-Origin': '*' },
historyApiFallback: {
index: '/index.html',
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
},
},
development: { open: true },
production: { hot: false },
},
};
// Determine the correct configValue to use based on the configuration
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
const buildOptions = {
...configValues.build.default,
...configValues.build[configuration],
};
const devServerOptions = {
...configValues.serve.default,
...configValues.serve[configuration],
};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
devServer: devServerOptions,
plugins: [
new NxAppWebpackPlugin(buildOptions),
new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions),
],
});
"
`;
exports[`convert-to-inferred all projects should migrate all projects using the webpack executors 3`] = `
"const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { NxReactWebpackPlugin } = require('@nx/react/webpack-plugin');
const { useLegacyNxPlugin } = require('@nx/webpack');
// These options were migrated by @nx/webpack:convert-to-inferred from
// the project.json file and merged with the options in this file
const configValues = {
build: {
default: {
compiler: 'babel',
outputPath: '../../dist/apps/app3',
index: './src/index.html',
baseHref: '/',
main: './src/main.tsx',
tsConfig: './tsconfig.app.json',
assets: ['./src/favicon.ico', './src/assets'],
styles: ['./src/styles.scss'],
},
development: {
extractLicenses: false,
optimization: false,
sourceMap: true,
vendorChunk: true,
},
production: {
fileReplacements: [
{
replace: './src/environments/environment.ts',
with: './src/environments/environment.prod.ts',
},
],
optimization: true,
outputHashing: 'all',
sourceMap: false,
namedChunks: false,
extractLicenses: true,
vendorChunk: false,
},
},
serve: {
default: {
hot: true,
liveReload: false,
server: {
type: 'https',
options: { cert: './server.crt', key: './server.key' },
},
proxy: { '/api': { target: 'http://localhost:3333', secure: false } },
port: 4200,
headers: { 'Access-Control-Allow-Origin': '*' },
historyApiFallback: {
index: '/index.html',
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
},
},
development: { open: true },
production: { hot: false },
},
};
// Determine the correct configValue to use based on the configuration
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
const buildOptions = {
...configValues.build.default,
...configValues.build[configuration],
};
const devServerOptions = {
...configValues.serve.default,
...configValues.serve[configuration],
};
/**
* @type{import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
devServer: devServerOptions,
plugins: [
new NxAppWebpackPlugin(buildOptions),
new NxReactWebpackPlugin({
// Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/
// svgr: false
}),
// eslint-disable-next-line react-hooks/rules-of-hooks
await useLegacyNxPlugin(require('./webpack.config.old'), buildOptions),
],
});
"
`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,190 @@
import {
AggregatedLog,
migrateProjectExecutorsToPlugin,
NoTargetsToMigrateError,
} from '@nx/devkit/internal';
import {
addDependenciesToPackageJson,
createProjectGraphAsync,
formatFiles,
runTasksInSerial,
type ProjectConfiguration,
type Tree,
} from '@nx/devkit';
import { ast, query } from '@phenomnomnominal/tsquery';
import * as ts from 'typescript';
import { createNodesV2, type WebpackPluginOptions } from '../../plugins/plugin';
import {
webpackCliVersion,
assertSupportedWebpackVersion,
} from '../../utils/versions';
import {
buildPostTargetTransformerFactory,
servePostTargetTransformerFactory,
type MigrationContext,
} from './utils';
import { logger as devkitLogger } from 'nx/src/devkit-exports';
interface Schema {
project?: string;
skipFormat?: boolean;
}
export async function convertToInferred(tree: Tree, options: Schema) {
assertSupportedWebpackVersion(tree);
const projectGraph = await createProjectGraphAsync();
const migrationContext: MigrationContext = {
logger: new AggregatedLog(),
projectGraph,
workspaceRoot: tree.root,
};
const logger = createCollectingLogger();
const migratedProjects =
await migrateProjectExecutorsToPlugin<WebpackPluginOptions>(
tree,
projectGraph,
'@nx/webpack/plugin',
createNodesV2,
{
buildTargetName: 'build',
previewTargetName: 'preview',
serveStaticTargetName: 'serve-static',
serveTargetName: 'serve',
},
[
{
executors: ['@nx/webpack:webpack', '@nrwl/webpack:webpack'],
postTargetTransformer:
buildPostTargetTransformerFactory(migrationContext),
targetPluginOptionMapper: (target) => ({ buildTargetName: target }),
skipProjectFilter: skipProjectFilterFactory(tree),
},
{
executors: ['@nx/webpack:dev-server', '@nrwl/webpack:dev-server'],
postTargetTransformer:
servePostTargetTransformerFactory(migrationContext),
targetPluginOptionMapper: (target) => ({ serveTargetName: target }),
skipProjectFilter: skipProjectFilterFactory(tree),
},
],
options.project,
logger
);
if (migratedProjects.size === 0) {
const convertMessage = [...logger.loggedMessages.values()]
.flat()
.find((v) => v.includes('@nx/webpack:convert-config-to-webpack-plugin'));
if (convertMessage.length > 0) {
logger.flushLogs((message) => !convertMessage.includes(message));
throw new Error(convertMessage);
} else {
logger.flushLogs();
throw new NoTargetsToMigrateError();
}
} else {
logger.flushLogs();
}
const installCallback = addDependenciesToPackageJson(
tree,
{},
{ 'webpack-cli': webpackCliVersion },
undefined,
true
);
if (!options.skipFormat) {
await formatFiles(tree);
}
return runTasksInSerial(installCallback, () => {
migrationContext.logger.flushLogs();
});
}
function skipProjectFilterFactory(tree: Tree) {
return function skipProjectFilter(
projectConfiguration: ProjectConfiguration
): false | string {
const buildTarget = Object.values(projectConfiguration.targets).find(
(target) =>
target.executor === '@nx/webpack:webpack' ||
target.executor === '@nrwl/webpack:webpack'
);
// the projects for which this is called are guaranteed to have a build target
const webpackConfigPath = buildTarget.options.webpackConfig;
if (!webpackConfigPath) {
return `The webpack config path is missing in the project configuration (${projectConfiguration.root}).`;
}
const sourceFile = ast(tree.read(webpackConfigPath, 'utf-8'));
const composePluginsSelector =
'CallExpression:has(Identifier[name=composePlugins])';
const composePlugins = query<ts.CallExpression>(
sourceFile,
composePluginsSelector
)[0];
if (composePlugins) {
return `The webpack config (${webpackConfigPath}) can only work with the "@nx/webpack:webpack" executor. Run the "@nx/webpack:convert-config-to-webpack-plugin" generator first.`;
}
const nxAppWebpackPluginSelector =
'PropertyAssignment:has(Identifier[name=plugins]) NewExpression:has(Identifier[name=NxAppWebpackPlugin])';
const nxAppWebpackPlugin = query<ts.NewExpression>(
sourceFile,
nxAppWebpackPluginSelector
)[0];
if (!nxAppWebpackPlugin) {
return `No "NxAppWebpackPlugin" found in the webpack config (${webpackConfigPath}). Its usage is required for the migration to work.`;
}
return false;
};
}
export function createCollectingLogger(): typeof devkitLogger & {
loggedMessages: Map<string, string[]>;
flushLogs: (filter?: (message: string) => boolean) => void;
} {
const loggedMessages = new Map<string, string[]>();
const flushLogs = (filter?: (message: string) => boolean) => {
loggedMessages.forEach((messages, method) => {
messages.forEach((message) => {
if (!filter || filter(message)) {
devkitLogger[method](message);
}
});
});
};
return new Proxy(
{ ...devkitLogger, loggedMessages, flushLogs },
{
get(target, property) {
const originalMethod = target[property];
if (typeof originalMethod === 'function') {
return (...args) => {
const message = args.join(' ');
const propertyString = String(property);
if (!loggedMessages.has(message)) {
loggedMessages.set(propertyString, []);
}
loggedMessages.get(propertyString).push(message);
};
}
return originalMethod;
},
}
);
}
@@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/schema",
"$id": "NxWebpackConvertToInferred",
"description": "Convert existing Webpack project(s) using `@nx/webpack:wepack` executor to use `@nx/webpack/plugin`.",
"title": "Convert a Webpack project from executor to plugin",
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "The project to convert from using the `@nx/webpack:webpack` executor to use `@nx/webpack/plugin`. If not provided, all projects using the `@nx/webpack:webpack` executor will be converted.",
"x-priority": "important"
},
"skipFormat": {
"type": "boolean",
"description": "Whether to format files.",
"default": false
}
}
}
@@ -0,0 +1,59 @@
import * as ts from 'typescript';
export function toPropertyAssignment(
key: string,
value: unknown
): ts.PropertyAssignment {
if (typeof value === 'string') {
return ts.factory.createPropertyAssignment(
ts.factory.createStringLiteral(key),
ts.factory.createStringLiteral(value)
);
} else if (typeof value === 'number') {
return ts.factory.createPropertyAssignment(
ts.factory.createStringLiteral(key),
ts.factory.createNumericLiteral(value)
);
} else if (typeof value === 'boolean') {
return ts.factory.createPropertyAssignment(
ts.factory.createStringLiteral(key),
value ? ts.factory.createTrue() : ts.factory.createFalse()
);
} else if (Array.isArray(value)) {
return ts.factory.createPropertyAssignment(
ts.factory.createStringLiteral(key),
ts.factory.createArrayLiteralExpression(
value.map((item) => toExpression(item))
)
);
} else {
return ts.factory.createPropertyAssignment(
ts.factory.createStringLiteral(key),
ts.factory.createObjectLiteralExpression(
Object.entries(value).map(([key, value]) =>
toPropertyAssignment(key, value)
)
)
);
}
}
export function toExpression(value: unknown): ts.Expression {
if (typeof value === 'string') {
return ts.factory.createStringLiteral(value);
} else if (typeof value === 'number') {
return ts.factory.createNumericLiteral(value);
} else if (typeof value === 'boolean') {
return value ? ts.factory.createTrue() : ts.factory.createFalse();
} else if (Array.isArray(value)) {
return ts.factory.createArrayLiteralExpression(
value.map((item) => toExpression(item))
);
} else {
return ts.factory.createObjectLiteralExpression(
Object.entries(value).map(([key, value]) =>
toPropertyAssignment(key, value)
)
);
}
}
@@ -0,0 +1,409 @@
import type { TargetConfiguration, Tree } from '@nx/devkit';
import {
processTargetOutputs,
toProjectRelativePath,
} from '@nx/devkit/internal';
import { ast, query } from '@phenomnomnominal/tsquery';
import * as ts from 'typescript';
import type { WebpackExecutorOptions } from '../../../executors/webpack/schema';
import type { NxAppWebpackPluginOptions } from '../../../plugins/nx-webpack-plugin/nx-app-webpack-plugin-options';
import { toPropertyAssignment } from './ast';
import type { MigrationContext, TransformerContext } from './types';
export function buildPostTargetTransformerFactory(
migrationContext: MigrationContext
) {
return function buildPostTargetTransformer(
target: TargetConfiguration,
tree: Tree,
projectDetails: { projectName: string; root: string },
inferredTarget: TargetConfiguration
): TargetConfiguration {
const context: TransformerContext = {
...migrationContext,
projectName: projectDetails.projectName,
projectRoot: projectDetails.root,
};
const { pluginOptions, webpackConfigPath } = processOptions(
target,
context
);
updateWebpackConfig(tree, webpackConfigPath, pluginOptions);
if (target.outputs) {
processTargetOutputs(target, [], inferredTarget, {
projectName: projectDetails.projectName,
projectRoot: projectDetails.root,
});
}
return target;
};
}
type ExtractedOptions = {
default: NxAppWebpackPluginOptions;
[configName: string]: NxAppWebpackPluginOptions;
};
function processOptions(
target: TargetConfiguration<WebpackExecutorOptions>,
context: TransformerContext
): {
pluginOptions: ExtractedOptions;
webpackConfigPath: string;
} {
const webpackConfigPath = target.options.webpackConfig;
delete target.options.webpackConfig;
const pluginOptions: ExtractedOptions = {
default: extractPluginOptions(target.options, context),
};
if (target.configurations && Object.keys(target.configurations).length) {
for (const [configName, config] of Object.entries(target.configurations)) {
pluginOptions[configName] = extractPluginOptions(
config,
context,
configName
);
}
}
return { pluginOptions, webpackConfigPath };
}
const pathOptions = new Set([
'babelConfig',
'index',
'main',
'outputPath',
'polyfills',
'postcssConfig',
'tsConfig',
]);
const assetsOptions = new Set(['assets', 'scripts', 'styles']);
function extractPluginOptions(
options: WebpackExecutorOptions,
context: TransformerContext,
configName?: string
): NxAppWebpackPluginOptions {
const pluginOptions: NxAppWebpackPluginOptions = {};
for (const [key, value] of Object.entries(options)) {
if (pathOptions.has(key)) {
pluginOptions[key] = toProjectRelativePath(value, context.projectRoot);
delete options[key];
} else if (assetsOptions.has(key)) {
pluginOptions[key] = value.map((asset: string | { input: string }) => {
if (typeof asset === 'string') {
return toProjectRelativePath(asset, context.projectRoot);
}
asset.input = toProjectRelativePath(asset.input, context.projectRoot);
return asset;
});
delete options[key];
} else if (key === 'fileReplacements') {
pluginOptions.fileReplacements = value.map(
(replacement: { replace: string; with: string }) => ({
replace: toProjectRelativePath(
replacement.replace,
context.projectRoot
),
with: toProjectRelativePath(replacement.with, context.projectRoot),
})
);
delete options.fileReplacements;
} else if (key === 'additionalEntryPoints') {
pluginOptions.additionalEntryPoints = value.map((entryPoint) => {
entryPoint.entryPath = toProjectRelativePath(
entryPoint.entryPath,
context.projectRoot
);
return entryPoint;
});
delete options.additionalEntryPoints;
} else if (key === 'memoryLimit') {
pluginOptions.memoryLimit = value;
const serveMemoryLimit = getMemoryLimitFromServeTarget(
context,
configName
);
if (serveMemoryLimit) {
pluginOptions.memoryLimit = Math.max(serveMemoryLimit, value);
context.logger.addLog({
executorName: '@nx/webpack:webpack',
log: `The "memoryLimit" option was set in both the serve and build configurations. The migration set the higher value to the build configuration and removed the option from the serve configuration.`,
project: context.projectName,
});
}
delete options.memoryLimit;
} else if (key === 'standardWebpackConfigFunction') {
delete options.standardWebpackConfigFunction;
} else {
pluginOptions[key] = value;
delete options[key];
}
}
return pluginOptions;
}
function updateWebpackConfig(
tree: Tree,
webpackConfig: string,
pluginOptions: ExtractedOptions
): void {
let sourceFile: ts.SourceFile;
let webpackConfigText: string;
const updateSources = () => {
webpackConfigText = tree.read(webpackConfig, 'utf-8');
sourceFile = ast(webpackConfigText);
};
updateSources();
setOptionsInWebpackConfig(
tree,
webpackConfigText,
sourceFile,
webpackConfig,
pluginOptions
);
updateSources();
setOptionsInNxWebpackPlugin(
tree,
webpackConfigText,
sourceFile,
webpackConfig
);
updateSources();
setOptionsInLegacyNxPlugin(
tree,
webpackConfigText,
sourceFile,
webpackConfig
);
}
function setOptionsInWebpackConfig(
tree: Tree,
text: string,
sourceFile: ts.SourceFile,
webpackConfig: string,
pluginOptions: ExtractedOptions
): void {
const { default: defaultOptions, ...configurationOptions } = pluginOptions;
const optionsSelector =
'VariableStatement:has(VariableDeclaration:has(Identifier[name=options]))';
const optionsVariable = query<ts.VariableStatement>(
sourceFile,
optionsSelector
)[0];
// This is assuming the `options` variable will be available since it's what the
// `convert-config-to-webpack-plugin` generates
let defaultOptionsObject: ts.ObjectLiteralExpression;
const optionsObject = query<ts.ObjectLiteralExpression>(
optionsVariable,
'ObjectLiteralExpression'
)[0];
if (optionsObject.properties.length === 0) {
defaultOptionsObject = ts.factory.createObjectLiteralExpression(
Object.entries(defaultOptions).map(([key, value]) =>
toPropertyAssignment(key, value)
)
);
} else {
// filter out the default options that are already in the options object
// the existing options override the options from the project.json file
const filteredDefaultOptions = Object.entries(defaultOptions).filter(
([key]) =>
!optionsObject.properties.some(
(property) =>
ts.isPropertyAssignment(property) &&
ts.isIdentifier(property.name) &&
property.name.text === key
)
);
defaultOptionsObject = ts.factory.createObjectLiteralExpression([
...optionsObject.properties,
...filteredDefaultOptions.map(([key, value]) =>
toPropertyAssignment(key, value)
),
]);
}
/**
* const configValues = {
* build: {
* default: { ... },
* configuration1: { ... },
* configuration2: { ... },
* }
*/
const configValuesVariable = ts.factory.createVariableStatement(
undefined,
ts.factory.createVariableDeclarationList(
[
ts.factory.createVariableDeclaration(
'configValues',
undefined,
undefined,
ts.factory.createObjectLiteralExpression(
[
ts.factory.createPropertyAssignment(
'build',
ts.factory.createObjectLiteralExpression([
ts.factory.createPropertyAssignment(
'default',
defaultOptionsObject
),
...(configurationOptions
? Object.entries(configurationOptions).map(([key, value]) =>
ts.factory.createPropertyAssignment(
key,
ts.factory.createObjectLiteralExpression(
Object.entries(value).map(([key, value]) =>
toPropertyAssignment(key, value)
)
)
)
)
: []),
])
),
],
true
)
),
],
ts.NodeFlags.Const
)
);
text = `${text.slice(0, optionsVariable.getStart())}
// These options were migrated by @nx/webpack:convert-to-inferred from
// the project.json file and merged with the options in this file
${ts
.createPrinter()
.printNode(ts.EmitHint.Unspecified, configValuesVariable, sourceFile)}
// Determine the correct configValue to use based on the configuration
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
const buildOptions = {
...configValues.build.default,
...configValues.build[configuration],
};${text.slice(optionsVariable.getEnd())}`;
// These are comments written by the `convert-config-to-webpack-plugin` that are no longer needed
text = text
.replace(
`// This file was migrated using @nx/webpack:convert-config-to-webpack-plugin from your './webpack.config.old.js'`,
''
)
.replace(
'// Please check that the options here are correct as they were moved from the old webpack.config.js to this file.',
''
);
tree.write(webpackConfig, text);
}
function setOptionsInNxWebpackPlugin(
tree: Tree,
text: string,
sourceFile: ts.SourceFile,
webpackConfig: string
): void {
const nxAppWebpackPluginSelector =
'PropertyAssignment:has(Identifier[name=plugins]) NewExpression:has(Identifier[name=NxAppWebpackPlugin])';
const nxAppWebpackPlugin = query<ts.NewExpression>(
sourceFile,
nxAppWebpackPluginSelector
)[0];
// the NxAppWebpackPlugin must exists, otherwise, the migration doesn't run and we wouldn't reach this point
const updatedNxAppWebpackPlugin = ts.factory.updateNewExpression(
nxAppWebpackPlugin,
nxAppWebpackPlugin.expression,
undefined,
[ts.factory.createIdentifier('buildOptions')]
);
text = `${text.slice(0, nxAppWebpackPlugin.getStart())}${ts
.createPrinter()
.printNode(
ts.EmitHint.Unspecified,
updatedNxAppWebpackPlugin,
sourceFile
)}${text.slice(nxAppWebpackPlugin.getEnd())}`;
tree.write(webpackConfig, text);
}
function setOptionsInLegacyNxPlugin(
tree: Tree,
text: string,
sourceFile: ts.SourceFile,
webpackConfig: string
): void {
const legacyNxPluginSelector =
'AwaitExpression CallExpression:has(Identifier[name=useLegacyNxPlugin])';
const legacyNxPlugin = query<ts.CallExpression>(
sourceFile,
legacyNxPluginSelector
)[0];
// we're assuming the `useLegacyNxPlugin` function is being called since it's what the `convert-config-to-webpack-plugin` generates
// we've already "ensured" that the `convert-config-to-webpack-plugin` was run by checking for the `NxAppWebpackPlugin` in the project validation
const updatedLegacyNxPlugin = ts.factory.updateCallExpression(
legacyNxPlugin,
legacyNxPlugin.expression,
undefined,
[legacyNxPlugin.arguments[0], ts.factory.createIdentifier('buildOptions')]
);
text = `${text.slice(0, legacyNxPlugin.getStart())}${ts
.createPrinter()
.printNode(
ts.EmitHint.Unspecified,
updatedLegacyNxPlugin,
sourceFile
)}${text.slice(legacyNxPlugin.getEnd())}`;
tree.write(webpackConfig, text);
}
function getMemoryLimitFromServeTarget(
context: TransformerContext,
configName: string | undefined
): number | undefined {
const { targets } = context.projectGraph.nodes[context.projectName].data;
const serveTarget = Object.values(targets).find(
(target) =>
target.executor === '@nx/webpack:dev-server' ||
target.executor === '@nrwl/web:dev-server'
);
if (!serveTarget) {
return undefined;
}
if (configName && serveTarget.configurations?.[configName]) {
return (
serveTarget.configurations[configName].options?.memoryLimit ??
serveTarget.options?.memoryLimit
);
}
return serveTarget.options?.memoryLimit;
}
@@ -0,0 +1,3 @@
export * from './build-post-target-transformer';
export * from './serve-post-target-transformer';
export * from './types';
@@ -0,0 +1,417 @@
import {
processTargetOutputs,
toProjectRelativePath,
} from '@nx/devkit/internal';
import {
parseTargetString,
readJson,
readTargetOptions,
type ExecutorContext,
type ProjectsConfigurations,
type TargetConfiguration,
type Tree,
} from '@nx/devkit';
import { ast, query } from '@phenomnomnominal/tsquery';
import { basename, resolve } from 'path';
import * as ts from 'typescript';
import type { WebpackOptionsNormalized } from 'webpack';
import { buildServePath } from '../../../executors/dev-server/lib/serve-path';
import type { WebDevServerOptions as DevServerExecutorOptions } from '../../../executors/dev-server/schema';
import { toPropertyAssignment } from './ast';
import type { MigrationContext, TransformerContext } from './types';
export function servePostTargetTransformerFactory(
migrationContext: MigrationContext
) {
return async function servePostTargetTransformer(
target: TargetConfiguration,
tree: Tree,
projectDetails: { projectName: string; root: string },
inferredTarget: TargetConfiguration
): Promise<TargetConfiguration> {
const context: TransformerContext = {
...migrationContext,
projectName: projectDetails.projectName,
projectRoot: projectDetails.root,
};
const { devServerOptions, webpackConfigPath } = await processOptions(
tree,
target,
context
);
updateWebpackConfig(tree, webpackConfigPath, devServerOptions, context);
if (target.outputs) {
processTargetOutputs(target, [], inferredTarget, {
projectName: projectDetails.projectName,
projectRoot: projectDetails.root,
});
}
return target;
};
}
type WebpackConfigDevServerOptions = WebpackOptionsNormalized['devServer'];
type ExtractedOptions = {
default: WebpackConfigDevServerOptions;
[configName: string]: WebpackConfigDevServerOptions;
};
async function processOptions(
tree: Tree,
target: TargetConfiguration<DevServerExecutorOptions>,
context: TransformerContext
): Promise<{
devServerOptions: ExtractedOptions;
webpackConfigPath: string;
}> {
const executorContext = {
cwd: process.cwd(),
nxJsonConfiguration: readJson(tree, 'nx.json'),
projectGraph: context.projectGraph,
projectName: context.projectName,
projectsConfigurations: Object.entries(context.projectGraph.nodes).reduce(
(acc, [projectName, project]) => {
acc.projects[projectName] = project.data;
return acc;
},
{ version: 1, projects: {} } as ProjectsConfigurations
),
root: context.workspaceRoot,
} as ExecutorContext;
const buildTarget = parseTargetString(
target.options.buildTarget,
executorContext
);
const buildOptions = readTargetOptions(buildTarget, executorContext);
// it must exist, we validated it in the project filter
const webpackConfigPath = buildOptions.webpackConfig;
const defaultOptions = extractDevServerOptions(target.options, context);
applyDefaults(defaultOptions, buildOptions);
const devServerOptions: ExtractedOptions = {
default: defaultOptions,
};
if (target.configurations && Object.keys(target.configurations).length) {
for (const [configName, config] of Object.entries(target.configurations)) {
devServerOptions[configName] = extractDevServerOptions(config, context);
}
}
return { devServerOptions, webpackConfigPath };
}
function extractDevServerOptions(
options: DevServerExecutorOptions,
context: TransformerContext
): WebpackConfigDevServerOptions {
const devServerOptions: WebpackConfigDevServerOptions = {};
for (const [key, value] of Object.entries(options)) {
if (key === 'hmr') {
devServerOptions.hot = value;
if (value) {
// the executor disables liveReload when hmr is enabled
devServerOptions.liveReload = false;
delete options.liveReload;
}
delete options.hmr;
} else if (key === 'allowedHosts') {
devServerOptions.allowedHosts = value.split(',');
delete options.allowedHosts;
} else if (key === 'publicHost') {
devServerOptions.client = {
webSocketURL: value,
};
delete options.publicHost;
} else if (key === 'proxyConfig') {
devServerOptions.proxy = getProxyConfig(context.workspaceRoot, value);
delete options.proxyConfig;
} else if (key === 'ssl' || key === 'sslCert' || key === 'sslKey') {
if (key === 'ssl' || 'ssl' in options) {
if (options.ssl) {
devServerOptions.server = { type: 'https' };
if (options.sslCert && options.sslKey) {
devServerOptions.server.options = {};
devServerOptions.server.options.cert = toProjectRelativePath(
options.sslCert,
context.projectRoot
);
devServerOptions.server.options.key = toProjectRelativePath(
options.sslKey,
context.projectRoot
);
} else if (options.sslCert) {
context.logger.addLog({
executorName: '@nx/webpack:dev-server',
log: 'The "sslCert" option was set but "sslKey" was missing and "ssl" was set to "true". This means that "sslCert" was ignored by the executor. It has been removed from the options.',
project: context.projectName,
});
} else if (options.sslKey) {
context.logger.addLog({
executorName: '@nx/webpack:dev-server',
log: 'The "sslKey" option was set but "sslCert" was missing and "ssl" was set to "true". This means that "sslKey" was ignored by the executor. It has been removed from the options.',
project: context.projectName,
});
}
} else if (options.sslCert || options.sslKey) {
context.logger.addLog({
executorName: '@nx/webpack:dev-server',
log: 'The "sslCert" and/or "sslKey" were set with "ssl" set to "false". This means they were ignored by the executor. They have been removed from the options.',
project: context.projectName,
});
}
delete options.ssl;
delete options.sslCert;
delete options.sslKey;
} else if (options.sslCert || options.sslKey) {
context.logger.addLog({
executorName: '@nx/webpack:dev-server',
log: 'The "sslCert" and/or "sslKey" were set but the "ssl" was not set. This means they were ignored by the executor. They have been removed from the options.',
project: context.projectName,
});
delete options.sslCert;
delete options.sslKey;
}
} else if (key === 'buildTarget') {
delete options.buildTarget;
} else if (key === 'watch') {
context.logger.addLog({
executorName: '@nx/webpack:dev-server',
log: 'The "watch" option was removed from the serve configuration since it is not needed. The dev server always watches the files.',
project: context.projectName,
});
delete options.watch;
} else if (key === 'baseHref') {
context.logger.addLog({
executorName: '@nx/webpack:dev-server',
log: 'The "baseHref" option was removed from the serve configuration. If you need different base hrefs for the build and the dev server, please update the final webpack config manually to achieve that.',
project: context.projectName,
});
delete options.baseHref;
} else if (key === 'memoryLimit') {
// we already log a message for this one when processing the build options
delete options.memoryLimit;
} else {
devServerOptions[key] = value;
delete options[key];
}
}
return devServerOptions;
}
function applyDefaults(
options: WebpackConfigDevServerOptions,
buildOptions: any
) {
if (!options) {
options = {};
}
if (options?.port === undefined) {
options.port = 4200;
}
options.headers = { 'Access-Control-Allow-Origin': '*' };
const servePath = buildServePath(buildOptions);
options.historyApiFallback = {
index: buildOptions.index && `${servePath}${basename(buildOptions.index)}`,
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
};
}
function getProxyConfig(root: string, proxyConfig: string) {
const proxyPath = resolve(root, proxyConfig);
return require(proxyPath);
}
function updateWebpackConfig(
tree: Tree,
webpackConfigPath: string,
devServerOptions: ExtractedOptions,
context: TransformerContext
): void {
let sourceFile: ts.SourceFile;
let webpackConfigText: string;
const updateSources = () => {
webpackConfigText = tree.read(webpackConfigPath, 'utf-8');
sourceFile = ast(webpackConfigText);
};
updateSources();
setOptionsInWebpackConfig(
tree,
webpackConfigText,
sourceFile,
webpackConfigPath,
devServerOptions
);
updateSources();
setDevServerOptionsInWebpackConfig(
tree,
webpackConfigText,
sourceFile,
webpackConfigPath,
context
);
}
function setOptionsInWebpackConfig(
tree: Tree,
text: string,
sourceFile: ts.SourceFile,
webpackConfigPath: string,
devServerOptions: ExtractedOptions
) {
const { default: defaultOptions, ...configurationOptions } = devServerOptions;
const configValuesSelector =
'VariableDeclaration:has(Identifier[name=configValues]) ObjectLiteralExpression';
const configValuesObject = query<ts.ObjectLiteralExpression>(
sourceFile,
configValuesSelector
)[0];
// configValues must exist at this point, we added it when processing the build target
/**
* const configValues = {
* ...
* serve: {
* default: { ... },
* configuration1: { ... },
* ...
* },
*/
const updatedConfigValuesObject = ts.factory.updateObjectLiteralExpression(
configValuesObject,
[
...configValuesObject.properties,
ts.factory.createPropertyAssignment(
'serve',
ts.factory.createObjectLiteralExpression([
ts.factory.createPropertyAssignment(
'default',
ts.factory.createObjectLiteralExpression(
Object.entries(defaultOptions).map(([key, value]) =>
toPropertyAssignment(key, value)
)
)
),
...(configurationOptions
? Object.entries(configurationOptions).map(([key, value]) =>
ts.factory.createPropertyAssignment(
key,
ts.factory.createObjectLiteralExpression(
Object.entries(value).map(([key, value]) =>
toPropertyAssignment(key, value)
)
)
)
)
: []),
])
),
]
);
text = `${text.slice(0, configValuesObject.getStart())}${ts
.createPrinter()
.printNode(
ts.EmitHint.Unspecified,
updatedConfigValuesObject,
sourceFile
)}${text.slice(configValuesObject.getEnd())}`;
tree.write(webpackConfigPath, text);
sourceFile = ast(text);
const buildOptionsSelector =
'VariableStatement:has(VariableDeclaration:has(Identifier[name=buildOptions]))';
const buildOptionsStatement = query<ts.VariableStatement>(
sourceFile,
buildOptionsSelector
)[0];
text = `${text.slice(0, buildOptionsStatement.getEnd())}
const devServerOptions = {
...configValues.serve.default,
...configValues.serve[configuration],
};${text.slice(buildOptionsStatement.getEnd())}`;
tree.write(webpackConfigPath, text);
}
function setDevServerOptionsInWebpackConfig(
tree: Tree,
text: string,
sourceFile: ts.SourceFile,
webpackConfigPath: string,
context: TransformerContext
) {
const webpackConfigDevServerSelector =
'ObjectLiteralExpression > PropertyAssignment:has(Identifier[name=devServer])';
const webpackConfigDevServer = query<ts.PropertyAssignment>(
sourceFile,
webpackConfigDevServerSelector
)[0];
if (webpackConfigDevServer) {
context.logger.addLog({
executorName: '@nx/webpack:dev-server',
log: `The "devServer" option is already set in the webpack config. The migration doesn't support updating it. Please review it and make any necessary changes manually.`,
project: context.projectName,
});
text = `${text.slice(
0,
webpackConfigDevServer.getStart()
)}// This is the untouched "devServer" option from the original webpack config. Please review it and make any necessary changes manually.
${text.slice(webpackConfigDevServer.getStart())}`;
tree.write(webpackConfigPath, text);
// If the devServer property already exists, we don't know how to merge the
// options, so we leave it as is.
return;
}
const webpackConfigSelector =
'ObjectLiteralExpression:has(PropertyAssignment:has(Identifier[name=plugins]))';
const webpackConfig = query<ts.ObjectLiteralExpression>(
sourceFile,
webpackConfigSelector
)[0];
const updatedWebpackConfig = ts.factory.updateObjectLiteralExpression(
webpackConfig,
[
ts.factory.createPropertyAssignment(
'devServer',
ts.factory.createIdentifier('devServerOptions')
),
...webpackConfig.properties,
]
);
text = `${text.slice(0, webpackConfig.getStart())}${ts
.createPrinter()
.printNode(
ts.EmitHint.Unspecified,
updatedWebpackConfig,
sourceFile
)}${text.slice(webpackConfig.getEnd())}`;
tree.write(webpackConfigPath, text);
}
@@ -0,0 +1,13 @@
import type { ProjectGraph } from '@nx/devkit';
import type { AggregatedLog } from '@nx/devkit/internal';
export type MigrationContext = {
logger: AggregatedLog;
projectGraph: ProjectGraph;
workspaceRoot: string;
};
export type TransformerContext = MigrationContext & {
projectName: string;
projectRoot: string;
};
@@ -0,0 +1,30 @@
import { readJson, Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { webpackInitGenerator } from './init';
describe('webpackInitGenerator (legacy)', () => {
let tree: Tree;
beforeEach(async () => {
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
});
it('should install webpack and webpack-dev-server (they are peer dependencies)', async () => {
await webpackInitGenerator(tree, {
addPlugin: false,
});
const packageJson = readJson(tree, 'package.json');
expect(packageJson).toEqual({
name: expect.any(String),
dependencies: {},
devDependencies: {
'@nx/web': expect.any(String),
'@nx/webpack': expect.any(String),
webpack: expect.any(String),
'webpack-dev-server': expect.any(String),
},
});
});
});
@@ -0,0 +1,50 @@
import 'nx/src/internal-testing-utils/mock-project-graph';
import { readJson, Tree, updateJson } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { webpackInitGenerator } from './init';
describe('webpackInitGenerator', () => {
let tree: Tree;
beforeEach(async () => {
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
});
it('should install plugin, webpack, webpack-dev-server, and webpack-cli', async () => {
await webpackInitGenerator(tree, {
addPlugin: true,
});
const packageJson = readJson(tree, 'package.json');
expect(packageJson).toEqual({
name: expect.any(String),
dependencies: {},
devDependencies: {
'@nx/webpack': expect.any(String),
'@nx/web': expect.any(String),
webpack: expect.any(String),
'webpack-dev-server': expect.any(String),
'webpack-cli': expect.any(String),
},
});
});
it('should not overwrite an already-installed webpack version', async () => {
updateJson(tree, 'package.json', (json) => {
json.devDependencies = {
...(json.devDependencies ?? {}),
webpack: '5.50.0',
};
return json;
});
await webpackInitGenerator(tree, {
addPlugin: true,
});
const packageJson = readJson(tree, 'package.json');
expect(packageJson.devDependencies['webpack']).toBe('5.50.0');
});
});
@@ -0,0 +1,116 @@
import { addPlugin } from '@nx/devkit/internal';
import {
addDependenciesToPackageJson,
createProjectGraphAsync,
formatFiles,
GeneratorCallback,
readNxJson,
Tree,
} from '@nx/devkit';
import { createNodesV2 } from '../../plugins/plugin';
import {
nxVersion,
webpackCliVersion,
webpackDevServerVersion,
webpackVersion,
assertSupportedWebpackVersion,
} from '../../utils/versions';
import { Schema } from './schema';
export function webpackInitGenerator(tree: Tree, schema: Schema) {
return webpackInitGeneratorInternal(tree, { addPlugin: false, ...schema });
}
export async function webpackInitGeneratorInternal(tree: Tree, schema: Schema) {
assertSupportedWebpackVersion(tree);
const nxJson = readNxJson(tree);
const addPluginDefault =
process.env.NX_ADD_PLUGINS !== 'false' &&
nxJson.useInferencePlugins !== false;
schema.addPlugin ??= addPluginDefault;
if (schema.addPlugin) {
await addPlugin(
tree,
await createProjectGraphAsync(),
'@nx/webpack/plugin',
createNodesV2,
{
buildTargetName: [
'build',
'webpack:build',
'build:webpack',
'webpack-build',
'build-webpack',
],
serveTargetName: [
'serve',
'webpack:serve',
'serve:webpack',
'webpack-serve',
'serve-webpack',
],
previewTargetName: [
'preview',
'webpack:preview',
'preview:webpack',
'webpack-preview',
'preview-webpack',
],
buildDepsTargetName: [
'build-deps',
'webpack:build-deps',
'webpack-build-deps',
],
watchDepsTargetName: [
'watch-deps',
'webpack:watch-deps',
'webpack-watch-deps',
],
serveStaticTargetName: [
'serve-static',
'webpack:serve-static',
'serve-static:webpack',
'webpack-serve-static',
'serve-static-webpack',
],
},
schema.updatePackageScripts
);
}
let installTask: GeneratorCallback = () => {};
if (!schema.skipPackageJson) {
// webpack and webpack-dev-server are peer dependencies, so ensure they are
// installed (both the executor and inferred-plugin paths need them at the
// user's runtime).
const devDependencies = {
'@nx/webpack': nxVersion,
'@nx/web': nxVersion,
webpack: webpackVersion,
'webpack-dev-server': webpackDevServerVersion,
};
if (schema.addPlugin) {
// The inferred plugin runs the `webpack-cli` binary.
devDependencies['webpack-cli'] = webpackCliVersion;
}
installTask = addDependenciesToPackageJson(
tree,
{},
devDependencies,
undefined,
schema.keepExistingVersions ?? true
);
}
if (!schema.skipFormat) {
await formatFiles(tree);
}
return installTask;
}
export default webpackInitGenerator;
+7
View File
@@ -0,0 +1,7 @@
export interface Schema {
skipFormat?: boolean;
skipPackageJson?: boolean;
keepExistingVersions?: boolean;
updatePackageScripts?: boolean;
addPlugin?: boolean;
}
@@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/schema",
"$id": "NxWebpackInit",
"cli": "nx",
"title": "Init Webpack Plugin",
"description": "Initialize the Webpack Plugin.",
"type": "object",
"properties": {
"skipFormat": {
"description": "Skip formatting files.",
"type": "boolean",
"default": false
},
"skipPackageJson": {
"description": "Do not add dependencies to `package.json`.",
"type": "boolean",
"default": false
},
"keepExistingVersions": {
"type": "boolean",
"x-priority": "internal",
"description": "Keep existing dependencies versions",
"default": true
},
"updatePackageScripts": {
"type": "boolean",
"x-priority": "internal",
"description": "Update `package.json` scripts with inferred targets",
"default": false
}
},
"required": []
}
@@ -0,0 +1,37 @@
#### Remove `isolatedConfig` option
The `isolatedConfig` option is no longer supported by the `@nx/webpack:webpack` executor. Previously, setting `isolatedConfig: false` allowed you to use the executor's built-in Webpack configuration.
If this option is set in `project.json`, then it will be removed in favor of an explicit `webpackConfig` file. The Webpack configuration file matches the previous built-in configuration of the `@nx/webpack:webpack` executor.
#### Sample Code Changes
##### Before
```json title="project.json"
{
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"options": {
"isolatedConfig": false
}
}
}
}
```
##### After
```json title="project.json" {6}
{
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"options": {
"webpackConfig": "apps/myapp/webpack.config.js"
}
}
}
}
```
@@ -0,0 +1,111 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import {
addProjectConfiguration,
readProjectConfiguration,
Tree,
} from '@nx/devkit';
import update from './remove-isolated-config';
describe('21.0.0 - remove isolatedConfig option', () => {
let tree: Tree;
beforeEach(async () => {
tree = createTreeWithEmptyWorkspace();
});
it('should create webpack.config.js for projects that did not set webpackConfig', async () => {
addProjectConfiguration(tree, 'myapp', {
root: 'apps/myapp',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
// This option will be removed
isolatedConfig: false,
},
},
},
});
await update(tree);
const project = readProjectConfiguration(tree, 'myapp');
expect(tree.read('apps/myapp/webpack.config.js', 'utf-8'))
.toMatchInlineSnapshot(`
"const { composePlugins, withNx } = require('@nx/webpack');
// Nx plugins for webpack.
module.exports = composePlugins(withNx(), (config) => {
// Note: This was added by an Nx migration. Webpack builds are required to have a corresponding Webpack config file.
// See: https://nx.dev/recipes/webpack/webpack-config-setup
return config;
});
"
`);
expect(project.targets.build.options).toEqual({
webpackConfig: 'apps/myapp/webpack.config.js',
});
});
it('should create webpack.config.js for projects using `target: web`', async () => {
addProjectConfiguration(tree, 'myapp', {
root: 'apps/myapp',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
target: 'web',
// This option will be removed
isolatedConfig: false,
},
},
},
});
await update(tree);
const project = readProjectConfiguration(tree, 'myapp');
expect(tree.read('apps/myapp/webpack.config.js', 'utf-8'))
.toMatchInlineSnapshot(`
"const { composePlugins, withNx, withWeb } = require('@nx/webpack');
// Nx plugins for webpack.
module.exports = composePlugins(withNx(), withWeb(), (config) => {
// Note: This was added by an Nx migration. Webpack builds are required to have a corresponding Webpack config file.
// See: https://nx.dev/recipes/webpack/webpack-config-setup
return config;
});
"
`);
expect(project.targets.build.options).toEqual({
target: 'web',
webpackConfig: 'apps/myapp/webpack.config.js',
});
});
it('should not create webpack.config.js when webpackConfig is already set', async () => {
tree.write(
`apps/myapp/webpack.config.js`,
`
module.exports = { /* CUSTOM */ };
`
);
addProjectConfiguration(tree, 'myapp', {
root: 'apps/myapp',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
webpackConfig: 'apps/myapp/webpack.config.js',
},
},
},
});
await update(tree);
expect(tree.read('apps/myapp/webpack.config.js', 'utf-8')).toContain(
'/* CUSTOM */'
);
});
});
@@ -0,0 +1,60 @@
import { forEachExecutorOptions } from '@nx/devkit/internal';
import {
formatFiles,
readProjectConfiguration,
Tree,
updateProjectConfiguration,
} from '@nx/devkit';
import { WebpackExecutorOptions } from '../../executors/webpack/schema';
export default async function (tree: Tree) {
forEachExecutorOptions<WebpackExecutorOptions>(
tree,
'@nx/webpack:webpack',
(
options: WebpackExecutorOptions,
projectName: string,
targetName: string,
configurationName: string
) => {
// Only handle webpack config for default configuration
if (configurationName) return;
const projectConfiguration = readProjectConfiguration(tree, projectName);
if (!options.webpackConfig) {
delete options['isolatedConfig'];
options.webpackConfig = `${projectConfiguration.root}/webpack.config.js`;
tree.write(
options.webpackConfig,
options.target === 'web'
? `
const { composePlugins, withNx, withWeb } = require('@nx/webpack');
// Nx plugins for webpack.
module.exports = composePlugins(withNx(), withWeb(), (config) => {
// Note: This was added by an Nx migration. Webpack builds are required to have a corresponding Webpack config file.
// See: https://nx.dev/recipes/webpack/webpack-config-setup
return config;
});
`
: `
const { composePlugins, withNx } = require('@nx/webpack');
// Nx plugins for webpack.
module.exports = composePlugins(withNx(), (config) => {
// Note: This was added by an Nx migration. Webpack builds are required to have a corresponding Webpack config file.
// See: https://nx.dev/recipes/webpack/webpack-config-setup
return config;
});
`
);
projectConfiguration.targets[targetName].options = options;
updateProjectConfiguration(tree, projectName, projectConfiguration);
}
}
);
await formatFiles(tree);
}
@@ -0,0 +1,322 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import {
Tree,
readProjectConfiguration,
addProjectConfiguration,
} from '@nx/devkit';
import migration from './remove-deprecated-options';
describe('remove-deprecated-options migration', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should remove deleteOutputPath from options', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
outputPath: 'dist/apps/app1',
deleteOutputPath: true,
main: 'apps/app1/src/main.ts',
tsConfig: 'apps/app1/tsconfig.app.json',
},
},
},
});
await migration(tree);
const projectConfig = readProjectConfiguration(tree, 'app1');
expect(projectConfig.targets.build.options).not.toHaveProperty(
'deleteOutputPath'
);
expect(projectConfig.targets.build.options.outputPath).toBe(
'dist/apps/app1'
);
expect(projectConfig.targets.build.options.main).toBe(
'apps/app1/src/main.ts'
);
});
it('should remove sassImplementation from options', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
outputPath: 'dist/apps/app1',
sassImplementation: 'sass',
main: 'apps/app1/src/main.ts',
tsConfig: 'apps/app1/tsconfig.app.json',
},
},
},
});
await migration(tree);
const projectConfig = readProjectConfiguration(tree, 'app1');
expect(projectConfig.targets.build.options).not.toHaveProperty(
'sassImplementation'
);
expect(projectConfig.targets.build.options.outputPath).toBe(
'dist/apps/app1'
);
expect(projectConfig.targets.build.options.main).toBe(
'apps/app1/src/main.ts'
);
});
it('should remove both deleteOutputPath and sassImplementation from options', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
outputPath: 'dist/apps/app1',
deleteOutputPath: false,
sassImplementation: 'sass-embedded',
main: 'apps/app1/src/main.ts',
tsConfig: 'apps/app1/tsconfig.app.json',
},
},
},
});
await migration(tree);
const projectConfig = readProjectConfiguration(tree, 'app1');
expect(projectConfig.targets.build.options).not.toHaveProperty(
'deleteOutputPath'
);
expect(projectConfig.targets.build.options).not.toHaveProperty(
'sassImplementation'
);
expect(projectConfig.targets.build.options.outputPath).toBe(
'dist/apps/app1'
);
expect(projectConfig.targets.build.options.main).toBe(
'apps/app1/src/main.ts'
);
});
it('should remove deleteOutputPath from configurations', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
outputPath: 'dist/apps/app1',
main: 'apps/app1/src/main.ts',
tsConfig: 'apps/app1/tsconfig.app.json',
},
configurations: {
production: {
deleteOutputPath: true,
optimization: true,
},
development: {
deleteOutputPath: false,
optimization: false,
},
},
},
},
});
await migration(tree);
const projectConfig = readProjectConfiguration(tree, 'app1');
expect(
projectConfig.targets.build.configurations.production
).not.toHaveProperty('deleteOutputPath');
expect(
projectConfig.targets.build.configurations.development
).not.toHaveProperty('deleteOutputPath');
expect(
projectConfig.targets.build.configurations.production.optimization
).toBe(true);
expect(
projectConfig.targets.build.configurations.development.optimization
).toBe(false);
});
it('should remove sassImplementation from configurations', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
outputPath: 'dist/apps/app1',
main: 'apps/app1/src/main.ts',
tsConfig: 'apps/app1/tsconfig.app.json',
},
configurations: {
production: {
sassImplementation: 'sass',
optimization: true,
},
development: {
sassImplementation: 'sass-embedded',
optimization: false,
},
},
},
},
});
await migration(tree);
const projectConfig = readProjectConfiguration(tree, 'app1');
expect(
projectConfig.targets.build.configurations.production
).not.toHaveProperty('sassImplementation');
expect(
projectConfig.targets.build.configurations.development
).not.toHaveProperty('sassImplementation');
expect(
projectConfig.targets.build.configurations.production.optimization
).toBe(true);
expect(
projectConfig.targets.build.configurations.development.optimization
).toBe(false);
});
it('should handle both @nx/webpack and @nrwl/webpack executors', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
targets: {
build: {
executor: '@nrwl/webpack:webpack',
options: {
outputPath: 'dist/apps/app1',
deleteOutputPath: true,
sassImplementation: 'sass',
main: 'apps/app1/src/main.ts',
tsConfig: 'apps/app1/tsconfig.app.json',
},
},
},
});
await migration(tree);
const projectConfig = readProjectConfiguration(tree, 'app1');
expect(projectConfig.targets.build.options).not.toHaveProperty(
'deleteOutputPath'
);
expect(projectConfig.targets.build.options).not.toHaveProperty(
'sassImplementation'
);
});
it('should not modify projects with other executors', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
targets: {
build: {
executor: '@nx/vite:build',
options: {
outputPath: 'dist/apps/app1',
deleteOutputPath: true,
sassImplementation: 'sass',
main: 'apps/app1/src/main.ts',
tsConfig: 'apps/app1/tsconfig.app.json',
},
},
},
});
await migration(tree);
const projectConfig = readProjectConfiguration(tree, 'app1');
expect(projectConfig.targets.build.options.deleteOutputPath).toBe(true);
expect(projectConfig.targets.build.options.sassImplementation).toBe('sass');
});
it('should handle projects with no targets', async () => {
addProjectConfiguration(tree, 'lib1', {
root: 'libs/lib1',
});
await expect(migration(tree)).resolves.not.toThrow();
});
it('should handle targets with no options', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
targets: {
build: {
executor: '@nx/webpack:webpack',
},
},
});
await expect(migration(tree)).resolves.not.toThrow();
});
it('should handle multiple projects', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
outputPath: 'dist/apps/app1',
deleteOutputPath: true,
main: 'apps/app1/src/main.ts',
tsConfig: 'apps/app1/tsconfig.app.json',
},
},
},
});
addProjectConfiguration(tree, 'app2', {
root: 'apps/app2',
targets: {
build: {
executor: '@nx/webpack:webpack',
options: {
outputPath: 'dist/apps/app2',
sassImplementation: 'sass',
main: 'apps/app2/src/main.ts',
tsConfig: 'apps/app2/tsconfig.app.json',
},
configurations: {
production: {
deleteOutputPath: false,
sassImplementation: 'sass-embedded',
},
},
},
},
});
await migration(tree);
const app1Config = readProjectConfiguration(tree, 'app1');
const app2Config = readProjectConfiguration(tree, 'app2');
expect(app1Config.targets.build.options).not.toHaveProperty(
'deleteOutputPath'
);
expect(app2Config.targets.build.options).not.toHaveProperty(
'sassImplementation'
);
expect(
app2Config.targets.build.configurations.production
).not.toHaveProperty('deleteOutputPath');
expect(
app2Config.targets.build.configurations.production
).not.toHaveProperty('sassImplementation');
});
});
@@ -0,0 +1,72 @@
import {
formatFiles,
getProjects,
readProjectConfiguration,
Tree,
updateProjectConfiguration,
} from '@nx/devkit';
export default async function (tree: Tree) {
const projects = getProjects(tree);
for (const [projectName] of projects) {
const projectConfig = readProjectConfiguration(tree, projectName);
let hasChanges = false;
if (projectConfig.targets) {
for (const [targetName, targetConfig] of Object.entries(
projectConfig.targets
)) {
if (
targetConfig.executor === '@nx/webpack:webpack' ||
targetConfig.executor === '@nrwl/webpack:webpack'
) {
if (targetConfig.options) {
if ('deleteOutputPath' in targetConfig.options) {
delete targetConfig.options.deleteOutputPath;
hasChanges = true;
console.log(
`Removed deprecated 'deleteOutputPath' option from ${projectName}:${targetName}. Use the 'output.clean' option in Webpack configuration instead.`
);
}
if ('sassImplementation' in targetConfig.options) {
delete targetConfig.options.sassImplementation;
hasChanges = true;
console.log(
`Removed deprecated 'sassImplementation' option from ${projectName}:${targetName}. This option is no longer needed.`
);
}
}
if (targetConfig.configurations) {
for (const [configName, config] of Object.entries(
targetConfig.configurations
)) {
if ('deleteOutputPath' in config) {
delete config.deleteOutputPath;
hasChanges = true;
console.log(
`Removed deprecated 'deleteOutputPath' option from ${projectName}:${targetName}:${configName}. Use the 'output.clean' option in Webpack configuration instead.`
);
}
if ('sassImplementation' in config) {
delete config.sassImplementation;
hasChanges = true;
console.log(
`Removed deprecated 'sassImplementation' option from ${projectName}:${targetName}:${configName}. This option is no longer needed.`
);
}
}
}
}
}
}
if (hasChanges) {
updateProjectConfiguration(tree, projectName, projectConfig);
}
}
await formatFiles(tree);
}
@@ -0,0 +1,25 @@
#### Rename `createNodesV2` imports to `createNodes`
`@nx/webpack` renamed its primary inferred-plugin export from `createNodesV2` to `createNodes`. The `createNodesV2` name is preserved as a deprecated alias for now, but new code should use `createNodes`.
This migration scans every `.ts`, `.tsx`, `.cts`, and `.mts` file in your workspace and rewrites named imports and re-exports of `createNodesV2` from `@nx/webpack/plugin` to `createNodes`.
#### Sample Code Changes
##### Before
```ts
import { createNodesV2 } from '@nx/webpack/plugin';
```
##### After
```ts
import { createNodes } from '@nx/webpack/plugin';
```
Aliases are preserved (`createNodesV2 as cn` becomes `createNodes as cn`), and if a file already imports both names (`{ createNodes, createNodesV2 }`) the redundant binding is dropped.
#### What is not rewritten
Only static `import`/`export` named bindings from `@nx/webpack/plugin` are rewritten. Namespace imports, dynamic `import(...)`, `require(...)` destructuring, and property access such as `plugin.createNodesV2` are left untouched — they keep working through the `createNodesV2` runtime alias. Update those by hand if you want to drop the deprecated name everywhere.
@@ -0,0 +1,255 @@
import { type Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import migration, {
rewriteCreateNodesV2Imports,
} from './migrate-create-nodes-v2-to-create-nodes';
const SPECIFIERS = new Set(['@nx/webpack/plugin']);
const rewrite = (source: string) =>
rewriteCreateNodesV2Imports(source, SPECIFIERS);
describe('migrate-create-nodes-v2-to-create-nodes migration', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
describe('rewriteCreateNodesV2Imports', () => {
it('renames a lone createNodesV2 named import', () => {
const input = `import { createNodesV2 } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import { createNodes } from '@nx/webpack/plugin';\n`
);
});
it('preserves other named imports while renaming createNodesV2', () => {
const input = `import { createNodesV2, SomeOtherExport } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import { createNodes, SomeOtherExport } from '@nx/webpack/plugin';\n`
);
});
it('rewrites the original name in `as` aliases', () => {
const input = `import { createNodesV2 as cn } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import { createNodes as cn } from '@nx/webpack/plugin';\n`
);
});
it('collapses a redundant `createNodesV2 as createNodes` alias', () => {
const input = `import { createNodesV2 as createNodes } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import { createNodes } from '@nx/webpack/plugin';\n`
);
});
it('dedupes when both createNodes and createNodesV2 are imported', () => {
const input = `import { createNodes, createNodesV2 } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import { createNodes } from '@nx/webpack/plugin';\n`
);
});
it('dedupes when createNodesV2 precedes createNodes', () => {
const input = `import { createNodesV2, createNodes } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import { createNodes } from '@nx/webpack/plugin';\n`
);
});
it('handles multi-line named imports', () => {
const input = `import {\n createNodes,\n createNodesV2,\n SomeOtherExport,\n} from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import { createNodes, SomeOtherExport } from '@nx/webpack/plugin';\n`
);
});
it('preserves a default import alongside the renamed binding', () => {
const input = `import def, { createNodesV2 } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import def, { createNodes } from '@nx/webpack/plugin';\n`
);
});
it('handles `import type` declarations', () => {
const input = `import type { createNodesV2 } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import type { createNodes } from '@nx/webpack/plugin';\n`
);
});
it('preserves inline `type` modifiers', () => {
const input = `import { type createNodesV2 } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`import { type createNodes } from '@nx/webpack/plugin';\n`
);
});
it('rewrites named re-exports', () => {
const input = `export { createNodesV2 } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`export { createNodes } from '@nx/webpack/plugin';\n`
);
});
it('rewrites aliased named re-exports', () => {
const input = `export { createNodesV2 as cn } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(
`export { createNodes as cn } from '@nx/webpack/plugin';\n`
);
});
it('does not touch createNodesV2 from a different specifier', () => {
const input = `import { createNodesV2 } from '@nx/other-plugin/plugin';\n`;
expect(rewrite(input)).toBe(input);
});
it('does not touch createNodesV2 from a relative specifier', () => {
const input = `import { createNodesV2 } from './plugin';\n`;
expect(rewrite(input)).toBe(input);
});
it('does not touch unrelated imports from the target specifier', () => {
const input = `import { createNodes } from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(input);
});
it('does not rewrite `export * from` declarations', () => {
const input = `export * from '@nx/webpack/plugin';\n`;
expect(rewrite(input)).toBe(input);
});
it('leaves createNodesV2 in strings and comments alone', () => {
const input =
`// import { createNodesV2 } from '@nx/webpack/plugin';\n` +
`const s = "createNodesV2";\n`;
expect(rewrite(input)).toBe(input);
});
it('does not rewrite require() destructuring (still valid via alias)', () => {
const input = `const { createNodesV2 } = require('@nx/webpack/plugin');\n`;
expect(rewrite(input)).toBe(input);
});
it('renames value usages of a lone createNodesV2 import', () => {
const input =
`import { createNodesV2 } from '@nx/webpack/plugin';\n` +
`export const plugin = createNodesV2;\n`;
expect(rewrite(input)).toBe(
`import { createNodes } from '@nx/webpack/plugin';\n` +
`export const plugin = createNodes;\n`
);
});
it('renames a value usage passed as a call argument', () => {
const input =
`import { createNodesV2 } from '@nx/webpack/plugin';\n` +
`addPlugin(graph, '@nx/webpack/plugin', createNodesV2, {});\n`;
expect(rewrite(input)).toBe(
`import { createNodes } from '@nx/webpack/plugin';\n` +
`addPlugin(graph, '@nx/webpack/plugin', createNodes, {});\n`
);
});
it('renames value usages when deduped against an existing createNodes', () => {
const input =
`import { createNodes, createNodesV2 } from '@nx/webpack/plugin';\n` +
`use(createNodesV2);\n`;
expect(rewrite(input)).toBe(
`import { createNodes } from '@nx/webpack/plugin';\n` +
`use(createNodes);\n`
);
});
it('expands a shorthand property usage to preserve the key', () => {
const input =
`import { createNodesV2 } from '@nx/webpack/plugin';\n` +
`export const plugins = { createNodesV2 };\n`;
expect(rewrite(input)).toBe(
`import { createNodes } from '@nx/webpack/plugin';\n` +
`export const plugins = { createNodesV2: createNodes };\n`
);
});
it('leaves property accesses and strings while renaming the binding', () => {
const input =
`import { createNodesV2 } from '@nx/webpack/plugin';\n` +
`const v = createNodesV2;\n` +
`const w = config.createNodesV2;\n` +
`const s = 'createNodesV2';\n`;
expect(rewrite(input)).toBe(
`import { createNodes } from '@nx/webpack/plugin';\n` +
`const v = createNodes;\n` +
`const w = config.createNodesV2;\n` +
`const s = 'createNodesV2';\n`
);
});
it('does not rename usages for an aliased import (local name preserved)', () => {
const input =
`import { createNodesV2 as cn } from '@nx/webpack/plugin';\n` +
`export const plugin = cn;\n`;
expect(rewrite(input)).toBe(
`import { createNodes as cn } from '@nx/webpack/plugin';\n` +
`export const plugin = cn;\n`
);
});
});
describe('migration runner', () => {
it('rewrites imports across .ts/.tsx/.cts/.mts files', async () => {
tree.write(
'libs/foo/src/a.ts',
`import { createNodesV2 } from '@nx/webpack/plugin';\n`
);
tree.write(
'libs/foo/src/b.tsx',
`import { createNodesV2 as cn } from '@nx/webpack/plugin';\n`
);
tree.write(
'libs/foo/src/c.cts',
`export { createNodesV2 } from '@nx/webpack/plugin';\n`
);
tree.write(
'libs/foo/src/d.mts',
`import { createNodesV2, SomeOtherExport } from '@nx/webpack/plugin';\n`
);
await migration(tree);
expect(tree.read('libs/foo/src/a.ts', 'utf-8')).toContain(
`import { createNodes } from '@nx/webpack/plugin';`
);
expect(tree.read('libs/foo/src/b.tsx', 'utf-8')).toContain(
`import { createNodes as cn } from '@nx/webpack/plugin';`
);
expect(tree.read('libs/foo/src/c.cts', 'utf-8')).toContain(
`export { createNodes } from '@nx/webpack/plugin';`
);
expect(tree.read('libs/foo/src/d.mts', 'utf-8')).toContain(
`import { createNodes, SomeOtherExport } from '@nx/webpack/plugin';`
);
});
it('does not touch files without the deprecated name', async () => {
const content = `import { createNodes } from '@nx/webpack/plugin';\n`;
tree.write('libs/foo/src/index.ts', content);
await migration(tree);
expect(tree.read('libs/foo/src/index.ts', 'utf-8')).toBe(content);
});
it('does not rewrite non-TS files', async () => {
const md = `Example: \`import { createNodesV2 } from '@nx/webpack/plugin';\`\n`;
tree.write('docs/example.md', md);
await migration(tree);
expect(tree.read('docs/example.md', 'utf-8')).toContain(
`import { createNodesV2 } from '@nx/webpack/plugin';`
);
});
});
});
@@ -0,0 +1,317 @@
import {
applyChangesToString,
ChangeType,
ensurePackage,
formatFiles,
logger,
type StringChange,
type Tree,
visitNotIgnoredFiles,
} from '@nx/devkit';
import type {
ExportDeclaration,
ExportSpecifier,
Identifier,
ImportDeclaration,
ImportSpecifier,
NamedExports,
NamedImports,
Node,
SourceFile,
} from 'typescript';
const TS_EXTENSIONS = ['.ts', '.tsx', '.cts', '.mts'] as const;
const DEPRECATED_NAME = 'createNodesV2';
const CANONICAL_NAME = 'createNodes';
// Module specifiers from which `@nx/webpack` publicly exposes `createNodesV2`.
// A named import or re-export of `createNodesV2` from one of these is rewritten
// to the canonical `createNodes` export.
const TARGET_SPECIFIERS: ReadonlySet<string> = new Set(['@nx/webpack/plugin']);
let ts: typeof import('typescript') | undefined;
export default async function migrateCreateNodesV2ToCreateNodes(
tree: Tree
): Promise<void> {
let touchedCount = 0;
visitNotIgnoredFiles(tree, '.', (filePath) => {
if (!TS_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
return;
}
const original = tree.read(filePath, 'utf-8');
if (!original || !original.includes(DEPRECATED_NAME)) {
return;
}
const updated = rewriteCreateNodesV2Imports(original, TARGET_SPECIFIERS);
if (updated !== original) {
tree.write(filePath, updated);
touchedCount += 1;
}
});
if (touchedCount > 0) {
logger.info(
`Renamed \`${DEPRECATED_NAME}\` imports to \`${CANONICAL_NAME}\` in ${touchedCount} file(s).`
);
}
await formatFiles(tree);
}
/**
* Rewrites named imports and re-exports of `createNodesV2` to `createNodes`
* when they come from one of the given module specifiers. Only the named
* bindings are touched — the module specifier, the `import`/`export` keyword,
* any `type` modifier, and any default import are left untouched.
*/
export function rewriteCreateNodesV2Imports(
source: string,
specifiers: ReadonlySet<string>
): string {
ts ??= ensurePackage<typeof import('typescript')>('typescript', '*');
const sourceFile = ts.createSourceFile(
'tmp.ts',
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
ts.ScriptKind.TSX
);
const changes: StringChange[] = [];
let renameLocalUsages = false;
for (const stmt of sourceFile.statements) {
if (ts.isImportDeclaration(stmt)) {
renameLocalUsages =
collectImportRewrite(sourceFile, stmt, specifiers, changes) ||
renameLocalUsages;
} else if (ts.isExportDeclaration(stmt)) {
collectExportRewrite(sourceFile, stmt, specifiers, changes);
}
}
// Renaming a local `createNodesV2` import binding to `createNodes` (a lone
// `{ createNodesV2 }`, or one deduped against an existing `createNodes`)
// changes the name in scope, so value references to `createNodesV2` in the
// file body must be renamed too — otherwise they dangle. Aliased imports and
// re-exports keep their local name, so they never trigger this.
if (renameLocalUsages) {
collectValueUsageRewrites(sourceFile, changes);
}
return changes.length > 0 ? applyChangesToString(source, changes) : source;
}
function isTargetSpecifier(
node: ImportDeclaration['moduleSpecifier'],
specifiers: ReadonlySet<string>
): boolean {
return ts!.isStringLiteral(node) && specifiers.has(node.text);
}
function collectImportRewrite(
sourceFile: SourceFile,
stmt: ImportDeclaration,
specifiers: ReadonlySet<string>,
changes: StringChange[]
): boolean {
if (!isTargetSpecifier(stmt.moduleSpecifier, specifiers)) {
return false;
}
const namedBindings = stmt.importClause?.namedBindings;
// Only `import { ... }` carries renameable named bindings. `import x`,
// `import * as ns`, and side-effect imports reference the module wholesale
// and keep working through the `createNodesV2` runtime alias, so we leave
// them be. A mixed `import def, { createNodesV2 }` still has its named
// bindings rewritten below — the default binding is untouched.
if (!namedBindings || !ts!.isNamedImports(namedBindings)) {
return false;
}
// The local `createNodesV2` binding only disappears when it is imported
// without an alias — a lone `{ createNodesV2 }` or one deduped against an
// existing `createNodes`. `{ createNodesV2 as x }` keeps the local `x`, so
// its in-file usages are unaffected and must not be rewritten.
const localBindingRenamed = (
namedBindings.elements as readonly ImportSpecifier[]
).some(
(el) =>
el.name.text === DEPRECATED_NAME &&
(el.propertyName ?? el.name).text === DEPRECATED_NAME
);
rewriteNamedBindings(sourceFile, namedBindings, changes);
return localBindingRenamed;
}
function collectExportRewrite(
sourceFile: SourceFile,
stmt: ExportDeclaration,
specifiers: ReadonlySet<string>,
changes: StringChange[]
): void {
if (
!stmt.moduleSpecifier ||
!isTargetSpecifier(stmt.moduleSpecifier, specifiers)
) {
return;
}
// `export { ... } from '...'` can be rewritten; `export * from '...'` has no
// named bindings to rename.
if (!stmt.exportClause || !ts!.isNamedExports(stmt.exportClause)) {
return;
}
rewriteNamedBindings(sourceFile, stmt.exportClause, changes);
}
/**
* Re-renders the `{ ... }` of a named import/export, renaming any
* `createNodesV2` specifier to `createNodes`. If renaming would collide with a
* `createNodes` that is already present (e.g. `{ createNodes, createNodesV2 }`),
* the duplicate is dropped. Returns without recording a change when the binding
* list contains no `createNodesV2`.
*/
function rewriteNamedBindings(
sourceFile: SourceFile,
namedBindings: NamedImports | NamedExports,
changes: StringChange[]
): void {
const elements = namedBindings.elements as readonly (
| ImportSpecifier
| ExportSpecifier
)[];
const hasDeprecated = elements.some(
(el) => (el.propertyName ?? el.name).text === DEPRECATED_NAME
);
if (!hasDeprecated) {
return;
}
const seen = new Set<string>();
const rendered: string[] = [];
for (const el of elements) {
const text = renderSpecifier(el);
if (!seen.has(text)) {
seen.add(text);
rendered.push(text);
}
}
const start = namedBindings.getStart(sourceFile);
changes.push(
{
type: ChangeType.Delete,
start,
length: namedBindings.getEnd() - start,
},
{
type: ChangeType.Insert,
index: start,
text: `{ ${rendered.join(', ')} }`,
}
);
}
function renderSpecifier(el: ImportSpecifier | ExportSpecifier): string {
const typePrefix = el.isTypeOnly ? 'type ' : '';
const rename = (name: string) =>
name === DEPRECATED_NAME ? CANONICAL_NAME : name;
// `{ name }` — no alias, so the local binding follows the rename.
if (!el.propertyName) {
return `${typePrefix}${rename(el.name.text)}`;
}
// `{ propertyName as name }` — only the imported (left) side is renamed; the
// local alias is preserved. A now-redundant alias such as
// `createNodesV2 as createNodes` collapses to `createNodes`.
const canonicalImported = rename(el.propertyName.text);
const localName = el.name.text;
return canonicalImported === localName
? `${typePrefix}${localName}`
: `${typePrefix}${canonicalImported} as ${localName}`;
}
/**
* Renames value references of `createNodesV2` to `createNodes` in the file
* body. Only called once a local `createNodesV2` import binding has actually
* been renamed, so these references would otherwise dangle. Occurrences that
* are not references to that binding are skipped: the import/export
* declarations themselves, property accesses (`x.createNodesV2`), qualified
* type names, object-literal keys, and declaration names that shadow the
* import. A shorthand property (`{ createNodesV2 }`) is expanded to
* `{ createNodesV2: createNodes }` so the property key is preserved. Strings
* and comments are never `Identifier` nodes, so they are left alone.
*/
function collectValueUsageRewrites(
sourceFile: SourceFile,
changes: StringChange[]
): void {
const visit = (node: Node): void => {
if (
ts!.isIdentifier(node) &&
node.text === DEPRECATED_NAME &&
isRenamableValueUsage(node)
) {
const start = node.getStart(sourceFile);
changes.push(
{
type: ChangeType.Delete,
start,
length: node.getEnd() - start,
},
{
type: ChangeType.Insert,
index: start,
text: ts!.isShorthandPropertyAssignment(node.parent)
? `${DEPRECATED_NAME}: ${CANONICAL_NAME}`
: CANONICAL_NAME,
}
);
}
node.forEachChild(visit);
};
sourceFile.forEachChild(visit);
}
/**
* Whether a `createNodesV2` identifier is a value reference to the renamed
* import binding, as opposed to a position that must be left untouched.
*/
function isRenamableValueUsage(node: Identifier): boolean {
const parent = node.parent;
if (!parent) {
return false;
}
// Import/export bindings — already handled by the declaration rewrite.
if (
ts!.isImportSpecifier(parent) ||
ts!.isExportSpecifier(parent) ||
ts!.isImportClause(parent) ||
ts!.isNamespaceImport(parent)
) {
return false;
}
// `x.createNodesV2` / `X.createNodesV2` — a member name, not the binding.
if (ts!.isPropertyAccessExpression(parent) && parent.name === node) {
return false;
}
if (ts!.isQualifiedName(parent) && parent.right === node) {
return false;
}
// `{ createNodesV2: ... }` — an object-literal key, not the binding.
if (ts!.isPropertyAssignment(parent) && parent.name === node) {
return false;
}
// A declaration whose name shadows the import (variable, param, etc.).
if (
(ts!.isVariableDeclaration(parent) ||
ts!.isParameter(parent) ||
ts!.isBindingElement(parent) ||
ts!.isFunctionDeclaration(parent) ||
ts!.isClassDeclaration(parent)) &&
parent.name === node
) {
return false;
}
return true;
}
@@ -0,0 +1,54 @@
#### Rewrite `NxTsconfigPathsWebpackPlugin` Imports to the `@nx/webpack/tsconfig-paths-plugin` Sub-path
The deprecated re-export of `NxTsconfigPathsWebpackPlugin` from `@nx/webpack` is removed in v23. The migration rewrites both ES module imports and CJS `require()` calls to use the `@nx/webpack/tsconfig-paths-plugin` sub-path. Imports that combine the deprecated symbol with other named imports are split into two declarations so the rest of the original import still resolves from `@nx/webpack`. Aliases (`as Plugin`, `: Plugin`) are preserved.
#### Sample Code Changes
ES module import.
##### Before
```ts title="apps/my-app/webpack.config.ts" {1}
import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack';
export default { plugins: [new NxTsconfigPathsWebpackPlugin()] };
```
##### After
```ts title="apps/my-app/webpack.config.ts"
import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack/tsconfig-paths-plugin';
export default { plugins: [new NxTsconfigPathsWebpackPlugin()] };
```
ES module import combined with other named imports.
##### Before
```ts title="apps/my-app/webpack.config.ts" {1}
import { NxTsconfigPathsWebpackPlugin, NxAppWebpackPlugin } from '@nx/webpack';
```
##### After
```ts title="apps/my-app/webpack.config.ts"
import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack/tsconfig-paths-plugin';
import { NxAppWebpackPlugin } from '@nx/webpack';
```
CJS `require()`.
##### Before
```js title="apps/my-app/webpack.config.js" {1}
const { NxTsconfigPathsWebpackPlugin } = require('@nx/webpack');
```
##### After
```js title="apps/my-app/webpack.config.js"
const {
NxTsconfigPathsWebpackPlugin,
} = require('@nx/webpack/tsconfig-paths-plugin');
```
@@ -0,0 +1,236 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import removeNxTsconfigPathsWebpackPluginImport from './remove-nx-tsconfig-paths-webpack-plugin-import';
describe('remove-nx-tsconfig-paths-webpack-plugin-import migration', () => {
it('rewrites single ES import to sub-path', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.ts',
`import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack';`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack/tsconfig-paths-plugin';
"
`);
});
it('moves only the deprecated symbol when import has multiple specifiers', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.ts',
`import { NxTsconfigPathsWebpackPlugin, NxAppWebpackPlugin } from '@nx/webpack';`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack/tsconfig-paths-plugin';
import { NxAppWebpackPlugin } from '@nx/webpack';
"
`);
});
it('moves only the deprecated symbol when it is last in a multi-specifier import', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.ts',
`import { NxAppWebpackPlugin, NxTsconfigPathsWebpackPlugin } from '@nx/webpack';`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack/tsconfig-paths-plugin';
import { NxAppWebpackPlugin } from '@nx/webpack';
"
`);
});
it('rewrites single require() to sub-path', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.js',
`const { NxTsconfigPathsWebpackPlugin } = require('@nx/webpack');`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
// prettier wraps the long symbol name to multi-line
expect(tree.read('apps/my-app/webpack.config.js', 'utf-8'))
.toMatchInlineSnapshot(`
"const {
NxTsconfigPathsWebpackPlugin,
} = require('@nx/webpack/tsconfig-paths-plugin');
"
`);
});
it('moves only the deprecated symbol when require() has multiple bindings', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.js',
`const { NxTsconfigPathsWebpackPlugin, NxAppWebpackPlugin } = require('@nx/webpack');`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
// prettier wraps the long symbol name to multi-line
expect(tree.read('apps/my-app/webpack.config.js', 'utf-8'))
.toMatchInlineSnapshot(`
"const {
NxTsconfigPathsWebpackPlugin,
} = require('@nx/webpack/tsconfig-paths-plugin');
const { NxAppWebpackPlugin } = require('@nx/webpack');
"
`);
});
it('does not touch files with no usage of the deprecated symbol', async () => {
const tree = createTreeWithEmptyWorkspace();
const original = `const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
module.exports = { plugins: [new NxAppWebpackPlugin()] };
`;
tree.write('apps/my-app/webpack.config.js', original);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.js', 'utf-8')).toEqual(
original
);
});
it('is idempotent - running twice yields the same result', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.js',
`const { NxTsconfigPathsWebpackPlugin } = require('@nx/webpack');`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
const afterFirst = tree.read('apps/my-app/webpack.config.js', 'utf-8');
await removeNxTsconfigPathsWebpackPluginImport(tree);
const afterSecond = tree.read('apps/my-app/webpack.config.js', 'utf-8');
expect(afterFirst).toEqual(afterSecond);
});
it('preserves alias when ES import has multiple specifiers', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.ts',
`import { NxTsconfigPathsWebpackPlugin as Plugin, NxAppWebpackPlugin } from '@nx/webpack';`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { NxTsconfigPathsWebpackPlugin as Plugin } from '@nx/webpack/tsconfig-paths-plugin';
import { NxAppWebpackPlugin } from '@nx/webpack';
"
`);
});
it('preserves alias when require() has multiple bindings', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.js',
`const { NxTsconfigPathsWebpackPlugin: Plugin, NxAppWebpackPlugin } = require('@nx/webpack');`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.js', 'utf-8'))
.toMatchInlineSnapshot(`
"const {
NxTsconfigPathsWebpackPlugin: Plugin,
} = require('@nx/webpack/tsconfig-paths-plugin');
const { NxAppWebpackPlugin } = require('@nx/webpack');
"
`);
});
it('rewrites multiple matching imports/requires in the same file', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.js',
`import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack';
const { NxTsconfigPathsWebpackPlugin: P2 } = require('@nx/webpack');
import { NxTsconfigPathsWebpackPlugin as P3, NxAppWebpackPlugin } from '@nx/webpack';
`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.js', 'utf-8'))
.toMatchInlineSnapshot(`
"import { NxTsconfigPathsWebpackPlugin as P3 } from '@nx/webpack/tsconfig-paths-plugin';
import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack/tsconfig-paths-plugin';
const {
NxTsconfigPathsWebpackPlugin: P2,
} = require('@nx/webpack/tsconfig-paths-plugin');
import { NxAppWebpackPlugin } from '@nx/webpack';
"
`);
});
it('consumes whitespace before the trailing comma in ES imports', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.ts',
`import { NxTsconfigPathsWebpackPlugin , NxAppWebpackPlugin } from '@nx/webpack';`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { NxTsconfigPathsWebpackPlugin } from '@nx/webpack/tsconfig-paths-plugin';
import { NxAppWebpackPlugin } from '@nx/webpack';
"
`);
});
it('consumes whitespace before the trailing comma in CJS requires', async () => {
const tree = createTreeWithEmptyWorkspace();
tree.write(
'apps/my-app/webpack.config.js',
`const { NxTsconfigPathsWebpackPlugin , NxAppWebpackPlugin } = require('@nx/webpack');`
);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.js', 'utf-8'))
.toMatchInlineSnapshot(`
"const {
NxTsconfigPathsWebpackPlugin,
} = require('@nx/webpack/tsconfig-paths-plugin');
const { NxAppWebpackPlugin } = require('@nx/webpack');
"
`);
});
it('does not modify files already using the correct sub-path import', async () => {
const tree = createTreeWithEmptyWorkspace();
// Use the already-prettier-formatted form so formatFiles does not change it
const original = `const {
NxTsconfigPathsWebpackPlugin,
} = require('@nx/webpack/tsconfig-paths-plugin');
module.exports = { plugins: [new NxTsconfigPathsWebpackPlugin()] };
`;
tree.write('apps/my-app/webpack.config.js', original);
await removeNxTsconfigPathsWebpackPluginImport(tree);
expect(tree.read('apps/my-app/webpack.config.js', 'utf-8')).toEqual(
original
);
});
});
@@ -0,0 +1,152 @@
import { formatFiles, Tree, visitNotIgnoredFiles } from '@nx/devkit';
import { ast, query } from '@phenomnomnominal/tsquery';
const DEPRECATED_SYMBOL = 'NxTsconfigPathsWebpackPlugin';
const DEPRECATED_PACKAGE = '@nx/webpack';
const NEW_PACKAGE = '@nx/webpack/tsconfig-paths-plugin';
// ES module import: import { NxTsconfigPathsWebpackPlugin[, ...] } from '@nx/webpack'
const ES_IMPORT_SELECTOR = `ImportDeclaration:has(StringLiteral[value=${DEPRECATED_PACKAGE}]):has(ImportClause ImportSpecifier > Identifier[name=${DEPRECATED_SYMBOL}])`;
const IMPORT_SPECIFIERS_SELECTOR = `ImportClause ImportSpecifier`;
const TARGET_IMPORT_SPECIFIER_SELECTOR = `ImportClause ImportSpecifier > Identifier[name=${DEPRECATED_SYMBOL}]`;
const ES_MODULE_PATH_SELECTOR = `StringLiteral[value=${DEPRECATED_PACKAGE}]`;
// CJS require: const { NxTsconfigPathsWebpackPlugin[, ...] } = require('@nx/webpack')
const CJS_REQUIRE_STMT_SELECTOR = `VariableStatement:has(ObjectBindingPattern > BindingElement > Identifier[name=${DEPRECATED_SYMBOL}]):has(CallExpression:has(Identifier[name=require]) > StringLiteral[value=${DEPRECATED_PACKAGE}])`;
const CJS_BINDING_ELEMENTS_SELECTOR = `ObjectBindingPattern > BindingElement`;
const CJS_TARGET_BINDING_SELECTOR = `ObjectBindingPattern > BindingElement > Identifier[name=${DEPRECATED_SYMBOL}]`;
const CJS_REQUIRE_PATH_SELECTOR = `CallExpression:has(Identifier[name=require]) > StringLiteral[value=${DEPRECATED_PACKAGE}]`;
// Walk past whitespace from `fromIndex` to find a trailing comma. Returns the
// position AFTER the comma if found, otherwise the original index. Handles
// the `Foo , withReact` shape where the user wrote whitespace before the
// comma — `node.getEnd()` stops at the identifier and the comma sits one
// character past the whitespace.
function endAfterTrailingComma(text: string, fromIndex: number): number {
let i = fromIndex;
while (i < text.length && /\s/.test(text.charAt(i))) i++;
return text.charAt(i) === ',' ? i + 1 : fromIndex;
}
export default async function removeNxTsconfigPathsWebpackPluginImport(
tree: Tree
) {
visitNotIgnoredFiles(tree, '', (filePath) => {
if (
!filePath.endsWith('.ts') &&
!filePath.endsWith('.tsx') &&
!filePath.endsWith('.js') &&
!filePath.endsWith('.jsx') &&
!filePath.endsWith('.cjs') &&
!filePath.endsWith('.mjs')
) {
return;
}
let contents = tree.read(filePath, 'utf-8');
if (!contents) return;
// Quick check: must contain the deprecated symbol and the deprecated package path
if (
!contents.includes(DEPRECATED_SYMBOL) ||
(!contents.includes(`'${DEPRECATED_PACKAGE}'`) &&
!contents.includes(`"${DEPRECATED_PACKAGE}"`))
) {
return;
}
let changed = false;
// Re-parse on every iteration so multiple matches in the same file work
// correctly. The multi-specifier branch prepends a new declaration which
// shifts every offset; collected AST positions go stale after one rewrite,
// so process one match at a time.
let didRewrite = true;
while (didRewrite) {
didRewrite = false;
const sourceFile = ast(contents);
const importNode = query(sourceFile, ES_IMPORT_SELECTOR)[0];
if (importNode) {
const specifiers = query(importNode, IMPORT_SPECIFIERS_SELECTOR);
const targetIdentifier = query(
importNode,
TARGET_IMPORT_SPECIFIER_SELECTOR
)[0];
if (targetIdentifier) {
// Walk to the ImportSpecifier to cover `Foo` and `Foo as Bar`.
const targetSpec = targetIdentifier.parent;
if (specifiers.length === 1) {
const modulePathNode = query(
importNode,
ES_MODULE_PATH_SELECTOR
)[0];
if (modulePathNode) {
contents =
contents.slice(0, modulePathNode.getStart()) +
`'${NEW_PACKAGE}'` +
contents.slice(modulePathNode.getEnd());
changed = true;
didRewrite = true;
continue;
}
} else {
// Extract target spec verbatim (preserves alias)
const end = endAfterTrailingComma(contents, targetSpec.getEnd());
contents =
`import { ${targetSpec.getText()} } from '${NEW_PACKAGE}';\n` +
contents.slice(0, targetSpec.getStart()) +
contents.slice(end);
changed = true;
didRewrite = true;
continue;
}
}
}
const stmtNode = query(sourceFile, CJS_REQUIRE_STMT_SELECTOR)[0];
if (stmtNode) {
const bindingElements = query(stmtNode, CJS_BINDING_ELEMENTS_SELECTOR);
const targetIdentifier = query(
stmtNode,
CJS_TARGET_BINDING_SELECTOR
)[0];
if (targetIdentifier) {
// Walk to the BindingElement to cover `Foo` and `Foo: Bar`.
const targetBinding = targetIdentifier.parent;
if (bindingElements.length === 1) {
const requirePathNode = query(
stmtNode,
CJS_REQUIRE_PATH_SELECTOR
)[0];
if (requirePathNode) {
contents =
contents.slice(0, requirePathNode.getStart()) +
`'${NEW_PACKAGE}'` +
contents.slice(requirePathNode.getEnd());
changed = true;
didRewrite = true;
continue;
}
} else {
// Extract target binding verbatim (preserves alias)
const end = endAfterTrailingComma(contents, targetBinding.getEnd());
contents =
`const { ${targetBinding.getText()} } = require('${NEW_PACKAGE}');\n` +
contents.slice(0, targetBinding.getStart()) +
contents.slice(end);
changed = true;
didRewrite = true;
continue;
}
}
}
}
if (changed) {
tree.write(filePath, contents);
}
});
await formatFiles(tree);
}
@@ -0,0 +1,129 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import type { Tree } from '@nx/devkit';
import update, {
rewriteSubpathImports,
} from './rewrite-internal-subpath-imports';
describe('rewrite-@nx/webpack-internal-subpath-imports migration', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
describe('rewriteSubpathImports (pure)', () => {
it('routes an internal-symbol import to @nx/webpack/internal', () => {
const src = `import { ensureDependencies } from '@nx/webpack/src/some/internal';\n`;
expect(rewriteSubpathImports(src)).toBe(
`import { ensureDependencies } from '@nx/webpack/internal';\n`
);
});
it('routes a public-symbol import to @nx/webpack', () => {
const src = `import { composePluginsSync } from '@nx/webpack/src/some/path';\n`;
expect(rewriteSubpathImports(src)).toBe(
`import { composePluginsSync } from '@nx/webpack';\n`
);
});
it('splits a mixed public/internal import into two declarations', () => {
const src = `import { composePluginsSync, ensureDependencies } from '@nx/webpack/src/some/path';\n`;
expect(rewriteSubpathImports(src)).toBe(
`import { composePluginsSync } from '@nx/webpack';\nimport { ensureDependencies } from '@nx/webpack/internal';\n`
);
});
it('classifies aliased bindings by their original name', () => {
const src = `import { composePluginsSync as aliased } from '@nx/webpack/src/x';\n`;
expect(rewriteSubpathImports(src)).toBe(
`import { composePluginsSync as aliased } from '@nx/webpack';\n`
);
});
it('preserves the type modifier when splitting', () => {
const src = `import type { composePluginsSync, ensureDependencies } from '@nx/webpack/src/x';\n`;
expect(rewriteSubpathImports(src)).toBe(
`import type { composePluginsSync } from '@nx/webpack';\nimport type { ensureDependencies } from '@nx/webpack/internal';\n`
);
});
it('routes export { ... } from by symbol', () => {
const src = `export { composePluginsSync, ensureDependencies } from '@nx/webpack/src/x';\n`;
expect(rewriteSubpathImports(src)).toBe(
`export { composePluginsSync } from '@nx/webpack';\nexport { ensureDependencies } from '@nx/webpack/internal';\n`
);
});
it('routes a namespace import to @nx/webpack/internal', () => {
const src = `import * as ns from '@nx/webpack/src/x';\n`;
expect(rewriteSubpathImports(src)).toBe(
`import * as ns from '@nx/webpack/internal';\n`
);
});
it('routes a default import to @nx/webpack/internal', () => {
const src = `import thing from '@nx/webpack/src/x';\n`;
expect(rewriteSubpathImports(src)).toBe(
`import thing from '@nx/webpack/internal';\n`
);
});
it('routes export * to @nx/webpack/internal', () => {
const src = `export * from '@nx/webpack/src/x';\n`;
expect(rewriteSubpathImports(src)).toBe(
`export * from '@nx/webpack/internal';\n`
);
});
it('routes require() to @nx/webpack/internal', () => {
const src = `const m = require('@nx/webpack/src/x');\n`;
expect(rewriteSubpathImports(src)).toBe(
`const m = require('@nx/webpack/internal');\n`
);
});
it('routes dynamic import() to @nx/webpack/internal', () => {
const src = `const m = await import('@nx/webpack/src/x');\n`;
expect(rewriteSubpathImports(src)).toBe(
`const m = await import('@nx/webpack/internal');\n`
);
});
it('routes typeof import() type queries to @nx/webpack/internal', () => {
const src = `let m: typeof import('@nx/webpack/src/x');\n`;
expect(rewriteSubpathImports(src)).toBe(
`let m: typeof import('@nx/webpack/internal');\n`
);
});
it('rewrites both runtime and type args of a require()-with-cast', () => {
const src = `const m = require('@nx/webpack/src/x') as typeof import('@nx/webpack/src/x');\n`;
expect(rewriteSubpathImports(src)).toBe(
`const m = require('@nx/webpack/internal') as typeof import('@nx/webpack/internal');\n`
);
});
it('routes jest.mock() to @nx/webpack/internal', () => {
const src = `jest.mock('@nx/webpack/src/x');\n`;
expect(rewriteSubpathImports(src)).toBe(
`jest.mock('@nx/webpack/internal');\n`
);
});
it('leaves non-@nx/webpack imports untouched', () => {
const src = `import { foo } from '@nx/devkit';\nimport { bar } from '@nx/webpack';\n`;
expect(rewriteSubpathImports(src)).toBe(src);
});
});
it('rewrites files across the tree and formats', async () => {
tree.write(
'libs/app/src/x.ts',
`import { ensureDependencies } from '@nx/webpack/src/some/internal';\n`
);
await update(tree);
expect(tree.read('libs/app/src/x.ts', 'utf-8')).toContain(
`from '@nx/webpack/internal'`
);
});
});
@@ -0,0 +1,334 @@
import {
applyChangesToString,
ChangeType,
ensurePackage,
formatFiles,
logger,
type StringChange,
type Tree,
visitNotIgnoredFiles,
} from '@nx/devkit';
import type {
CallExpression,
ExportDeclaration,
ExportSpecifier,
ImportDeclaration,
ImportSpecifier,
ImportTypeNode,
Node,
SourceFile,
StringLiteral,
} from 'typescript';
const TS_EXTENSIONS = ['.ts', '.tsx', '.cts', '.mts'] as const;
const FROM_PREFIX = '@nx/webpack/src/';
const TO_PUBLIC = '@nx/webpack';
const TO_INTERNAL = '@nx/webpack/internal';
// Symbols exported from `@nx/webpack`'s public entry (packages/webpack/index.ts).
// A named import/export of one of these from `@nx/webpack/src/*` is routed to
// the public `@nx/webpack` entry; everything else goes to `@nx/webpack/internal`.
const PUBLIC_SYMBOLS: ReadonlySet<string> = new Set([
'AssetGlobPattern',
'AsyncNxComposableWebpackPlugin',
'FileReplacement',
'MergedOptions',
'NormalizedWebpackExecutorOptions',
'NxComposableWebpackPlugin',
'NxWebpackExecutionContext',
'NxWebpackPlugin',
'WebDevServerOptions',
'WebpackExecutorEvent',
'WebpackExecutorOptions',
'WithNxOptions',
'WithWebOptions',
'composePlugins',
'composePluginsSync',
'configurationGenerator',
'convertConfigToWebpackPluginGenerator',
'createCopyPlugin',
'devServerExecutor',
'getCSSModuleLocalIdent',
'getWebpackE2EWebServerInfo',
'isNxWebpackComposablePlugin',
'normalizeOptions',
'normalizePluginPath',
'nxWebpackComposablePlugin',
'useLegacyNxPlugin',
'webpackExecutor',
'webpackInitGenerator',
'webpackProjectGenerator',
'withNx',
'withWeb',
]);
// Methods on `jest` and `vi` that take a module specifier as their first arg.
const MOCK_HELPER_METHODS: ReadonlySet<string> = new Set([
'mock',
'unmock',
'doMock',
'dontMock',
'requireActual',
'requireMock',
'importActual',
'importMock',
]);
let ts: typeof import('typescript') | undefined;
export default async function rewriteInternalSubpathImports(
tree: Tree
): Promise<void> {
let touchedCount = 0;
visitNotIgnoredFiles(tree, '.', (filePath) => {
if (!TS_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
return;
}
const original = tree.read(filePath, 'utf-8');
if (!original || !original.includes(FROM_PREFIX)) {
return;
}
const updated = rewriteSubpathImports(original);
if (updated !== original) {
tree.write(filePath, updated);
touchedCount += 1;
}
});
if (touchedCount > 0) {
logger.info(
`Rewrote @nx/webpack/src/* imports in ${touchedCount} file(s) ` +
`(public symbols to @nx/webpack, internals to @nx/webpack/internal).`
);
}
await formatFiles(tree);
}
export function rewriteSubpathImports(source: string): string {
ts ??= ensurePackage<typeof import('typescript')>('typescript', '*');
const sourceFile = ts.createSourceFile(
'tmp.ts',
source,
ts.ScriptTarget.Latest,
/* setParentNodes */ true,
ts.ScriptKind.TSX
);
const changes: StringChange[] = [];
for (const stmt of sourceFile.statements) {
if (ts.isImportDeclaration(stmt)) {
collectImportRewrite(sourceFile, stmt, changes);
} else if (ts.isExportDeclaration(stmt)) {
collectExportRewrite(sourceFile, stmt, changes);
}
}
collectCallExpressionRewrites(sourceFile, changes);
return changes.length > 0 ? applyChangesToString(source, changes) : source;
}
function isSubpathSpecifier(node: Node): node is StringLiteral {
return ts!.isStringLiteral(node) && node.text.startsWith(FROM_PREFIX);
}
function collectImportRewrite(
sourceFile: SourceFile,
stmt: ImportDeclaration,
changes: StringChange[]
): void {
if (!isSubpathSpecifier(stmt.moduleSpecifier)) {
return;
}
const clause = stmt.importClause;
// Pure named imports (`import { a, b } from '...'`) can be split by symbol.
// A default or namespace import grabs the whole module, so it can't be
// split — route it wholesale to the internal entry.
if (
clause &&
!clause.name &&
clause.namedBindings &&
ts!.isNamedImports(clause.namedBindings)
) {
rewriteNamedDeclaration(
sourceFile,
stmt,
stmt.moduleSpecifier,
clause.isTypeOnly,
clause.namedBindings.elements,
'import',
changes
);
return;
}
replaceSpecifier(sourceFile, stmt.moduleSpecifier, TO_INTERNAL, changes);
}
function collectExportRewrite(
sourceFile: SourceFile,
stmt: ExportDeclaration,
changes: StringChange[]
): void {
if (!stmt.moduleSpecifier || !isSubpathSpecifier(stmt.moduleSpecifier)) {
return;
}
// `export { a, b } from '...'` can be split; `export * from '...'` cannot.
if (stmt.exportClause && ts!.isNamedExports(stmt.exportClause)) {
rewriteNamedDeclaration(
sourceFile,
stmt,
stmt.moduleSpecifier,
stmt.isTypeOnly,
stmt.exportClause.elements,
'export',
changes
);
return;
}
replaceSpecifier(sourceFile, stmt.moduleSpecifier, TO_INTERNAL, changes);
}
/**
* Partition the named bindings of an import/export declaration into the ones
* that resolve to `@nx/webpack`'s public entry and the ones that don't. If both
* groups are non-empty, the single declaration is split into two.
*/
function rewriteNamedDeclaration(
sourceFile: SourceFile,
decl: ImportDeclaration | ExportDeclaration,
specifier: StringLiteral,
isTypeOnly: boolean,
elements: readonly (ImportSpecifier | ExportSpecifier)[],
keyword: 'import' | 'export',
changes: StringChange[]
): void {
const publicEls: (ImportSpecifier | ExportSpecifier)[] = [];
const internalEls: (ImportSpecifier | ExportSpecifier)[] = [];
for (const el of elements) {
// `propertyName` is the original name in `orig as alias`; fall back to
// `name` for the plain `orig` form.
const importedName = (el.propertyName ?? el.name).text;
(PUBLIC_SYMBOLS.has(importedName) ? publicEls : internalEls).push(el);
}
if (publicEls.length === 0) {
replaceSpecifier(sourceFile, specifier, TO_INTERNAL, changes);
return;
}
if (internalEls.length === 0) {
replaceSpecifier(sourceFile, specifier, TO_PUBLIC, changes);
return;
}
// Mixed — replace the whole declaration with one statement per target.
const quote = sourceFile.text.charAt(specifier.getStart(sourceFile));
const start = decl.getStart(sourceFile);
const end = decl.getEnd();
const semicolon = sourceFile.text.charAt(end - 1) === ';' ? ';' : '';
const prefix = isTypeOnly ? `${keyword} type` : keyword;
const render = (
els: (ImportSpecifier | ExportSpecifier)[],
target: string
): string =>
`${prefix} { ${els
.map((el) => el.getText(sourceFile))
.join(', ')} } from ${quote}${target}${quote}${semicolon}`;
changes.push(
{ type: ChangeType.Delete, start, length: end - start },
{
type: ChangeType.Insert,
index: start,
text: `${render(publicEls, TO_PUBLIC)}\n${render(
internalEls,
TO_INTERNAL
)}`,
}
);
}
function collectCallExpressionRewrites(
sourceFile: SourceFile,
changes: StringChange[]
): void {
const visit = (node: Node): void => {
if (
ts!.isCallExpression(node) &&
shouldRewriteCallExpression(node) &&
node.arguments.length >= 1 &&
isSubpathSpecifier(node.arguments[0])
) {
// `require(...)`, dynamic `import(...)` and `jest.mock(...)` reference
// the module as a whole and can't be symbol-split, so they go to the
// internal entry.
replaceSpecifier(
sourceFile,
node.arguments[0] as StringLiteral,
TO_INTERNAL,
changes
);
} else if (ts!.isImportTypeNode(node)) {
// `typeof import('...')` parses as an `ImportTypeNode`, not a
// CallExpression — its argument is `LiteralTypeNode<StringLiteral>`.
// The whole module is referenced, so it can't be symbol-split.
const literal = getImportTypeStringLiteral(node);
if (literal && literal.text.startsWith(FROM_PREFIX)) {
replaceSpecifier(sourceFile, literal, TO_INTERNAL, changes);
}
}
ts!.forEachChild(node, visit);
};
visit(sourceFile);
}
function getImportTypeStringLiteral(
node: ImportTypeNode
): StringLiteral | undefined {
const arg = node.argument;
if (arg && ts!.isLiteralTypeNode(arg) && ts!.isStringLiteral(arg.literal)) {
return arg.literal;
}
return undefined;
}
function shouldRewriteCallExpression(call: CallExpression): boolean {
const callee = call.expression;
// `require('...')`
if (ts!.isIdentifier(callee) && callee.text === 'require') return true;
// dynamic `import('...')` (runtime form parses as a CallExpression whose
// callee is the `import` keyword). The `typeof import('...')` type-position
// form is an `ImportTypeNode` (handled in `collectCallExpressionRewrites`).
if (callee.kind === ts!.SyntaxKind.ImportKeyword) return true;
// `jest.mock(...)` / `vi.mock(...)` and friends.
if (ts!.isPropertyAccessExpression(callee)) {
const obj = callee.expression;
if (
ts!.isIdentifier(obj) &&
(obj.text === 'jest' || obj.text === 'vi') &&
MOCK_HELPER_METHODS.has(callee.name.text)
) {
return true;
}
}
return false;
}
function replaceSpecifier(
sourceFile: SourceFile,
literal: StringLiteral,
target: string,
changes: StringChange[]
): void {
const start = literal.getStart(sourceFile);
const end = literal.getEnd();
const quote = sourceFile.text.charAt(start);
changes.push(
{ type: ChangeType.Delete, start, length: end - start },
{
type: ChangeType.Insert,
index: start,
text: `${quote}${target}${quote}`,
}
);
}
@@ -0,0 +1,126 @@
import * as fs from 'fs';
import type { Compiler, WebpackPluginInstance } from 'webpack';
import {
createLockFile,
createPackageJson,
getHelperDependenciesFromProjectGraph,
getLockFileName,
HelperDependency,
readTsConfig,
} from '@nx/js';
import {
detectPackageManager,
type ProjectGraph,
readJsonFile,
serializeJson,
} from '@nx/devkit';
const pluginName = 'GeneratePackageJsonPlugin';
export class GeneratePackageJsonPlugin implements WebpackPluginInstance {
constructor(
private readonly options: {
skipPackageManager?: boolean;
tsConfig: string;
outputFileName: string;
root: string;
projectName: string;
targetName: string;
projectGraph: ProjectGraph;
runtimeDependencies?: string[];
}
) {}
private resolveRuntimeDependencies(): Record<string, string> {
const runtimeDependencies: Record<string, string> = {};
if (this.options.runtimeDependencies) {
for (const dep of this.options.runtimeDependencies) {
const depPkgJson = require.resolve(`${dep}/package.json`);
if (!fs.existsSync(depPkgJson)) continue;
const { name, version } = readJsonFile(depPkgJson);
runtimeDependencies[name] = version;
}
}
return runtimeDependencies;
}
apply(compiler: Compiler): void {
const { sources } = require('webpack') as typeof import('webpack');
compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: pluginName,
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
() => {
const helperDependencies = getHelperDependenciesFromProjectGraph(
this.options.root,
this.options.projectName,
this.options.projectGraph
);
const importHelpers = !!readTsConfig(this.options.tsConfig).options
.importHelpers;
const shouldAddHelperDependency =
importHelpers &&
helperDependencies.every(
(dep) => dep.target !== HelperDependency.tsc
);
if (shouldAddHelperDependency) {
helperDependencies.push({
type: 'static',
source: this.options.projectName,
target: HelperDependency.tsc,
});
}
const runtimeDependencies = this.resolveRuntimeDependencies();
const packageJson = createPackageJson(
this.options.projectName,
this.options.projectGraph,
{
target: this.options.targetName,
root: this.options.root,
isProduction: true,
helperDependencies: helperDependencies.map((dep) => dep.target),
skipPackageManager: this.options.skipPackageManager,
}
);
packageJson.main = packageJson.main ?? this.options.outputFileName;
packageJson.dependencies = {
...packageJson.dependencies,
...runtimeDependencies,
};
compilation.emitAsset(
'package.json',
new sources.RawSource(serializeJson(packageJson))
);
const packageManager = detectPackageManager(this.options.root);
if (packageManager === 'bun') {
compilation
.getLogger('GeneratePackageJsonPlugin')
.warn(
'Bun lockfile generation is not supported. Only package.json will be generated.'
);
} else {
compilation.emitAsset(
getLockFileName(packageManager),
new sources.RawSource(
createLockFile(
packageJson,
this.options.projectGraph,
packageManager
)
)
);
}
}
);
});
}
}
@@ -0,0 +1,89 @@
import * as path from 'path';
import type {
Compiler,
Configuration,
WebpackOptionsNormalized,
} from 'webpack';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
import { workspaceRoot } from '@nx/devkit';
import {
calculateProjectBuildableDependencies,
createTmpTsConfig,
} from '@nx/js/internal';
import { resolvePathsBaseUrl } from '@nx/js';
import { NormalizedNxAppWebpackPluginOptions } from '../nx-webpack-plugin/nx-app-webpack-plugin-options';
import { WebpackNxBuildCoordinationPlugin } from '../webpack-nx-build-coordination-plugin';
export class NxTsconfigPathsWebpackPlugin {
constructor(private options: NormalizedNxAppWebpackPluginOptions) {
if (!this.options.tsConfig)
throw new Error(
`Missing "tsConfig" option. Set this option in your Nx webpack plugin.`
);
}
apply(compiler: Compiler): void {
// If we are not building libs from source, we need to remap paths so tsconfig may be updated.
this.handleBuildLibsFromSource(compiler.options, this.options);
const extensions = new Set([
...['.ts', '.tsx', '.mjs', '.js', '.jsx'],
...(compiler.options?.resolve?.extensions ?? []),
]);
compiler.options.resolve = {
...compiler.options.resolve,
plugins: compiler.options.resolve?.plugins ?? [],
};
const configFile = !path.isAbsolute(this.options.tsConfig)
? path.join(workspaceRoot, this.options.tsConfig)
: this.options.tsConfig;
compiler.options.resolve.plugins.push(
new TsconfigPathsPlugin({
configFile,
baseUrl: resolvePathsBaseUrl(configFile),
extensions: Array.from(extensions),
mainFields: ['module', 'main'],
})
);
}
handleBuildLibsFromSource(
config: Partial<WebpackOptionsNormalized | Configuration>,
options: NormalizedNxAppWebpackPluginOptions
): void {
if (!options.buildLibsFromSource && options.targetName) {
const remappedTarget =
options.targetName === 'serve' ? 'build' : options.targetName;
const { target, dependencies } = calculateProjectBuildableDependencies(
undefined,
options.projectGraph,
options.root,
options.projectName,
remappedTarget,
options.configurationName
);
options.tsConfig = createTmpTsConfig(
options.tsConfig,
options.root,
target.data.root,
dependencies
);
if (options.targetName === 'serve') {
const buildableDependencies = dependencies
.filter((dependency) => dependency.node.type === 'lib')
.map((dependency) => dependency.node.name)
.join(',');
const buildCommand = `nx run-many --target=build --projects=${buildableDependencies}`;
config.plugins.push(
new WebpackNxBuildCoordinationPlugin(buildCommand, {
skipWatchingDeps: options.watchDependencies === false,
})
);
}
}
}
}
@@ -0,0 +1,125 @@
import { applyBaseConfig } from './apply-base-config';
import { NormalizedNxAppWebpackPluginOptions } from '../nx-app-webpack-plugin-options';
import { Configuration } from 'webpack';
describe('apply-base-config libraryTarget handling', () => {
let options: NormalizedNxAppWebpackPluginOptions;
let config: Partial<Configuration>;
beforeEach(() => {
options = {
root: '/test',
projectRoot: 'apps/test',
sourceRoot: 'apps/test/src',
target: 'node',
} as NormalizedNxAppWebpackPluginOptions;
config = {};
global.NX_GRAPH_CREATION = false;
});
afterEach(() => {
delete global.NX_GRAPH_CREATION;
});
it('should not set libraryTarget when user configures library.type', () => {
config.output = {
library: { type: 'module' },
};
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBeUndefined();
});
it('should respect user libraryTarget when set explicitly', () => {
config.output = {
libraryTarget: 'umd',
};
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBe('umd');
});
it('should default to commonjs for node targets when nothing configured', () => {
config.output = {};
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBe('commonjs');
});
it('should not set libraryTarget for non-node targets when nothing configured', () => {
options.target = 'web';
config.output = {};
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBeUndefined();
});
it('should prioritize library.type over libraryTarget when both are present', () => {
config.output = {
libraryTarget: 'umd',
library: { type: 'module' },
};
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBeUndefined();
});
it('should handle empty output config gracefully', () => {
config.output = undefined;
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBe('commonjs');
});
it('should handle undefined library type values', () => {
config.output = {
library: { type: undefined as any },
};
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBe('commonjs');
});
it('should handle explicit undefined libraryTarget', () => {
config.output = {
libraryTarget: undefined,
};
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBe('commonjs');
});
it('should respect empty string libraryTarget', () => {
config.output = {
libraryTarget: '' as any,
};
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBe('');
});
it('should handle complex library configuration', () => {
config.output = {
library: {
type: 'module',
name: 'MyLib',
},
};
applyBaseConfig(options, config);
expect(config.output.libraryTarget).toBeUndefined();
expect((config.output.library as any).type).toBe('module');
expect((config.output.library as any).name).toBe('MyLib');
});
});
@@ -0,0 +1,490 @@
import * as path from 'path';
import { ExecutorContext } from 'nx/src/config/misc-interfaces';
import type {
Configuration,
WebpackOptionsNormalized,
WebpackPluginInstance,
} from 'webpack';
import { getRootTsConfigPath } from '@nx/js';
import { StatsJsonPlugin } from '../../stats-json-plugin';
import { GeneratePackageJsonPlugin } from '../../generate-package-json-plugin';
import { getOutputHashFormat } from '../../../utils/hash-format';
import { NxTsconfigPathsWebpackPlugin } from '../../nx-typescript-webpack-plugin/nx-tsconfig-paths-webpack-plugin';
import { getTerserEcmaVersion } from './get-terser-ecma-version';
import { createLoaderFromCompiler } from './compiler-loaders';
import { NormalizedNxAppWebpackPluginOptions } from '../nx-app-webpack-plugin-options';
import { isUsingTsSolutionSetup } from '@nx/js/internal';
import { getNonBuildableLibs } from './utils';
const IGNORED_WEBPACK_WARNINGS = [
/The comment file/i,
/could not find any license/i,
];
const extensionAlias = {
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
'.cjs': ['.cts', '.cjs'],
'.jsx': ['.tsx', '.jsx'],
};
const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx'];
const mainFields = ['module', 'main'];
export function applyBaseConfig(
options: NormalizedNxAppWebpackPluginOptions,
config: Partial<WebpackOptionsNormalized | Configuration> = {},
{
useNormalizedEntry,
}: {
// webpack.Configuration allows arrays to be set on a single entry
// webpack then normalizes them to { import: "..." } objects
// This option allows use to preserve existing composePlugins behavior where entry.main is an array.
useNormalizedEntry?: boolean;
} = {}
): void {
// Defaults that was applied from executor schema previously.
options.compiler ??= 'babel';
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 (global.NX_GRAPH_CREATION) return;
applyNxDependentConfig(options, config, { useNormalizedEntry });
}
function applyNxIndependentConfig(
options: NormalizedNxAppWebpackPluginOptions,
config: Partial<WebpackOptionsNormalized | Configuration>
): void {
const TerserPlugin =
require('terser-webpack-plugin') as typeof import('terser-webpack-plugin');
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 =
'cache' in options
? options.cache
: options.target === 'node' && options.watch
? { type: 'memory' }
: undefined;
config.devtool =
options.sourceMap === true ? 'source-map' : options.sourceMap;
config.output = {
...config.output,
libraryTarget: (() => {
const existingOutputConfig = config.output as Configuration['output'];
const existingLibraryTarget = existingOutputConfig?.libraryTarget;
const existingLibraryType =
typeof existingOutputConfig?.library === 'object' &&
'type' in existingOutputConfig?.library
? existingOutputConfig?.library?.type
: undefined;
// If user is using modern library.type, don't set the deprecated libraryTarget
if (existingLibraryType !== undefined) {
return undefined;
}
// If user has set libraryTarget explicitly, use it
if (existingLibraryTarget !== undefined) {
return existingLibraryTarget;
}
// Set defaults based on target when user hasn't configured anything
if (options.target === 'node') return 'commonjs';
if (options.target === 'async-node') return 'commonjs-module';
return undefined;
})(),
path:
config.output?.path ??
(options.outputPath
? // If path is relative, it is relative from project root (aka cwd).
// Otherwise, it is relative to workspace root (legacy behavior).
options.outputPath.startsWith('.')
? path.join(options.root, options.projectRoot, options.outputPath)
: path.join(options.root, options.outputPath)
: undefined),
filename:
config.output?.filename ??
(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.ignoreWarnings ?? []),
];
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,
};
/**
* Initialize properties that get set when webpack is used during task execution.
* These properties may be used by consumers who expect them to not be undefined.
*
* When @nx/webpack/plugin resolves the config, it is not during a task, and therefore
* these values are not set, which can lead to errors being thrown when reading
* the webpack options from the resolved file.
*/
config.entry ??= {};
config.resolve ??= {};
config.module ??= {};
config.plugins ??= [];
config.externals ??= [];
}
function applyNxDependentConfig(
options: NormalizedNxAppWebpackPluginOptions,
config: Partial<WebpackOptionsNormalized | Configuration>,
{ useNormalizedEntry }: { useNormalizedEntry?: boolean } = {}
): void {
const { ProgressPlugin } = require('webpack') as typeof import('webpack');
const { LicenseWebpackPlugin } =
require('license-webpack-plugin') as typeof import('license-webpack-plugin');
const CopyWebpackPlugin =
require('copy-webpack-plugin') as typeof import('copy-webpack-plugin');
const nodeExternals =
require('webpack-node-externals') as typeof import('webpack-node-externals');
const tsConfig = options.tsConfig ?? getRootTsConfigPath();
const plugins: WebpackPluginInstance[] = [];
const executorContext: Partial<ExecutorContext> = {
projectName: options.projectName,
targetName: options.targetName,
projectGraph: options.projectGraph,
configurationName: options.configurationName,
root: options.root,
};
const isUsingTsSolution = isUsingTsSolutionSetup();
options.useTsconfigPaths ??= !isUsingTsSolution;
// If the project is using ts solutions setup, the paths are not in tsconfig and we should not use the plugin's paths.
if (options.useTsconfigPaths) {
plugins.push(new NxTsconfigPathsWebpackPlugin({ ...options, tsConfig }));
}
// Normalize typeCheckOptions from deprecated skipTypeChecking for backward compatibility
const defaultTypeCheckOptions = { async: true };
let typeCheckOptions: boolean | { async: boolean };
if (options.typeCheckOptions !== undefined) {
if (options.typeCheckOptions === true) {
typeCheckOptions = defaultTypeCheckOptions;
} else if (options.typeCheckOptions === false) {
typeCheckOptions = false;
} else {
typeCheckOptions = options.typeCheckOptions;
}
} else if (options.skipTypeChecking) {
typeCheckOptions = false;
} else {
typeCheckOptions = defaultTypeCheckOptions;
}
// New TS Solution already has a typecheck target but allow it to run during serve
const shouldTypeCheck =
typeCheckOptions !== false &&
(!isUsingTsSolution || process.env['WEBPACK_SERVE']);
if (shouldTypeCheck) {
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
plugins.push(
new ForkTsCheckerWebpackPlugin({
...typeCheckOptions,
typescript: {
configFile: path.isAbsolute(tsConfig)
? tsConfig
: path.join(options.root, tsConfig),
memoryLimit: options.memoryLimit || 2018,
},
})
);
}
const entries: Array<{ name: string; import: string[] }> = [];
if (options.main) {
const mainEntry = options.outputFileName
? path.parse(options.outputFileName).name
: 'main';
entries.push({
name: mainEntry,
import: [path.resolve(options.root, options.main)],
});
}
if (options.additionalEntryPoints) {
for (const { entryName, entryPath } of options.additionalEntryPoints) {
entries.push({
name: entryName,
import: [path.resolve(options.root, entryPath)],
});
}
}
if (options.polyfills) {
entries.push({
name: 'polyfills',
import: [path.resolve(options.root, options.polyfills)],
});
}
config.entry ??= {};
entries.forEach((entry) => {
if (useNormalizedEntry) {
config.entry[entry.name] = { import: entry.import };
} else {
config.entry[entry.name] = entry.import;
}
});
if (options.progress) {
plugins.push(new ProgressPlugin({ profile: options.verbose }));
}
if (options.extractLicenses) {
plugins.push(
new LicenseWebpackPlugin({
stats: {
warnings: false,
errors: false,
},
perChunkOutput: false,
outputFilename: `3rdpartylicenses.txt`,
}) as unknown as WebpackPluginInstance
);
}
if (Array.isArray(options.assets) && options.assets.length > 0) {
plugins.push(
new CopyWebpackPlugin({
patterns: options.assets.map((asset) => {
return {
context: asset.input,
// Now we remove starting slash to make Webpack place it from the output root.
to: asset.output,
from: asset.glob,
globOptions: {
ignore: [
'.gitkeep',
'**/.DS_Store',
'**/Thumbs.db',
...(asset.ignore ?? []),
],
dot: true,
},
noErrorOnMissing: true,
};
}),
})
);
}
if (options.generatePackageJson && executorContext) {
plugins.push(new GeneratePackageJsonPlugin({ ...options, tsConfig }));
}
if (options.statsJson) {
plugins.push(new StatsJsonPlugin());
}
const externals =
options.mergeExternals && Array.isArray(config.externals)
? [...config.externals]
: [];
if (options.target === 'node' && options.externalDependencies === 'all') {
const modulesDir = `${options.root}/node_modules`;
const graph = options.projectGraph;
const projectName = options.projectName;
// Collect non-buildable TS project references so that they are bundled
// in the final output. This is needed for projects that are not buildable
// but are referenced by buildable projects.
const nonBuildableWorkspaceLibs = isUsingTsSolution
? getNonBuildableLibs(graph, projectName)
: [];
externals.push(
nodeExternals({ modulesDir, allowlist: nonBuildableWorkspaceLibs })
);
} else if (Array.isArray(options.externalDependencies)) {
externals.push(function (ctx, callback: Function) {
if (options.externalDependencies.includes(ctx.request)) {
// not bundled
return callback(null, `commonjs ${ctx.request}`);
}
// bundled
callback();
});
}
config.resolve = {
...config.resolve,
extensions: [...(config?.resolve?.extensions ?? []), ...extensions],
extensionAlias: {
...(config.resolve?.extensionAlias ?? {}),
...extensionAlias,
},
alias: {
...(config.resolve?.alias ?? {}),
...(options.fileReplacements?.reduce(
(aliases, replacement) => ({
...aliases,
[replacement.replace]: replacement.with,
}),
{}
) ?? {}),
},
mainFields: config.resolve?.mainFields ?? mainFields,
};
config.externals = externals;
config.module = {
...config.module,
// Enabled for performance
unsafeCache: true,
rules: [
...(config?.module?.rules ?? []),
options.sourceMap && {
test: /\.js$/,
enforce: 'pre' as const,
loader: require.resolve('source-map-loader'),
},
{
// There's an issue resolving paths without fully specified extensions
// See: https://github.com/graphql/graphql-js/issues/2721
// TODO(jack): Add a flag to turn this option on like Next.js does via experimental flag.
// See: https://github.com/vercel/next.js/pull/29880
test: /\.m?jsx?$/,
resolve: {
fullySpecified: false,
},
},
// There's an issue when using buildable libs and .js files (instead of .ts files),
// where the wrong type is used (commonjs vs esm) resulting in export-imports throwing errors.
// See: https://github.com/nrwl/nx/issues/10990
{
test: /\.js$/,
type: 'javascript/auto',
},
createLoaderFromCompiler(options),
].filter((r) => !!r),
};
config.plugins ??= [];
config.plugins.push(...plugins);
}
@@ -0,0 +1,387 @@
import * as path from 'path';
import type {
Configuration,
RuleSetRule,
WebpackOptionsNormalized,
WebpackPluginInstance,
} from 'webpack';
import { WriteIndexHtmlPlugin } from '../../write-index-html-plugin';
import { NormalizedNxAppWebpackPluginOptions } from '../nx-app-webpack-plugin-options';
import { getOutputHashFormat } from '../../../utils/hash-format';
import { getClientEnvironment } from '../../../utils/get-client-environment';
import { normalizeExtraEntryPoints } from '../../../utils/webpack/normalize-entry';
import {
getCommonLoadersForCssModules,
getCommonLoadersForGlobalCss,
getCommonLoadersForGlobalStyle,
} from './stylesheet-loaders';
import { instantiateScriptPlugins } from './instantiate-script-plugins';
export function applyWebConfig(
options: NormalizedNxAppWebpackPluginOptions,
config: Partial<WebpackOptionsNormalized | Configuration> = {},
{
useNormalizedEntry,
}: {
// webpack.Configuration allows arrays to be set on a single entry
// webpack then normalizes them to { import: "..." } objects
// This option allows use to preserve existing composePlugins behavior where entry.main is an array.
useNormalizedEntry?: boolean;
} = {}
): void {
if (global.NX_GRAPH_CREATION) return;
const { DefinePlugin, ids } = require('webpack') as typeof import('webpack');
const { SubresourceIntegrityPlugin } =
require('webpack-subresource-integrity') as typeof import('webpack-subresource-integrity');
const CssMinimizerPlugin =
require('css-minimizer-webpack-plugin') as typeof import('css-minimizer-webpack-plugin');
const MiniCssExtractPlugin =
require('mini-css-extract-plugin') as typeof import('mini-css-extract-plugin');
// 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 =
typeof options.optimization === 'object'
? options.optimization.styles
: options.optimization;
if (Array.isArray(options.scripts)) {
plugins.push(...instantiateScriptPlugins(options));
}
if (options.index && options.generateIndexHtml) {
plugins.push(
new WriteIndexHtmlPlugin({
crossOrigin: options.crossOrigin,
sri: options.subresourceIntegrity,
outputPath: path.basename(options.index),
indexPath: path.join(options.root, options.index),
baseHref: options.baseHref !== false ? options.baseHref : undefined,
deployUrl: options.deployUrl,
scripts: options.scripts,
styles: options.styles,
})
);
}
if (options.subresourceIntegrity) {
plugins.push(new SubresourceIntegrityPlugin());
}
const minimizer: WebpackPluginInstance[] = [new ids.HashedModuleIdsPlugin()];
if (stylesOptimization) {
minimizer.push(
new CssMinimizerPlugin({
test: /\.(?:css|scss|sass|less)$/,
})
);
}
if (!options.ssr) {
plugins.push(
new DefinePlugin(getClientEnvironment(process.env.NODE_ENV).stringified)
);
}
const entries: { [key: string]: { import: string[] } } = {};
const globalStylePaths: string[] = [];
// Determine hashing format.
const hashFormat = getOutputHashFormat(options.outputHashing as string);
const sassOptions = options.stylePreprocessorOptions?.sassOptions;
const lessOptions = options.stylePreprocessorOptions?.lessOptions;
const includePaths: string[] = [];
if (options?.stylePreprocessorOptions?.includePaths?.length > 0) {
options.stylePreprocessorOptions.includePaths.forEach(
(includePath: string) =>
includePaths.push(path.resolve(options.root, includePath))
);
}
let lessPathOptions: { paths?: string[] } = {};
if (includePaths.length > 0) {
lessPathOptions = {
paths: includePaths,
};
}
// Process global styles.
if (options.styles.length > 0) {
normalizeExtraEntryPoints(options.styles, 'styles').forEach((style) => {
const resolvedPath = style.input.startsWith('.')
? style.input
: path.resolve(options.root, style.input);
// Add style entry points.
if (entries[style.bundleName]) {
entries[style.bundleName].import.push(resolvedPath);
} else {
entries[style.bundleName] = { import: [resolvedPath] };
}
// Add global css paths.
globalStylePaths.push(resolvedPath);
});
}
const cssModuleRules: RuleSetRule[] = [
{
test: /\.module\.css$/,
exclude: globalStylePaths,
use: getCommonLoadersForCssModules(options, includePaths),
},
{
test: /\.module\.(scss|sass)$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForCssModules(options, includePaths),
{
loader: require.resolve('sass-loader'),
options: {
api: 'modern-compiler',
implementation: require.resolve('sass-embedded'),
sassOptions: {
fiber: false,
precision: 8,
loadPaths: includePaths,
...(sassOptions ?? {}),
},
},
},
],
},
{
test: /\.module\.less$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForCssModules(options, includePaths),
{
loader: path.join(
__dirname,
'../../../utils/webpack/deprecated-less-loader.js'
),
options: {
lessOptions: {
paths: includePaths,
...(lessOptions ?? {}),
},
},
},
],
},
];
const globalCssRules: RuleSetRule[] = [
{
test: /\.css$/,
exclude: globalStylePaths,
use: getCommonLoadersForGlobalCss(options, includePaths),
},
{
test: /\.scss$|\.sass$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForGlobalCss(options, includePaths),
{
loader: require.resolve('sass-loader'),
options: {
api: 'modern-compiler',
implementation: require.resolve('sass-embedded'),
sourceMap: !!options.sourceMap,
sassOptions: {
fiber: false,
// bootstrap-sass requires a minimum precision of 8
precision: 8,
loadPaths: includePaths,
...(sassOptions ?? {}),
},
},
},
],
},
{
test: /\.less$/,
exclude: globalStylePaths,
use: [
...getCommonLoadersForGlobalCss(options, includePaths),
{
loader: path.join(
__dirname,
'../../../utils/webpack/deprecated-less-loader.js'
),
options: {
sourceMap: !!options.sourceMap,
lessOptions: {
javascriptEnabled: true,
...lessPathOptions,
...(lessOptions ?? {}),
},
},
},
],
},
];
const globalStyleRules: RuleSetRule[] = [
{
test: /\.css$/,
include: globalStylePaths,
use: getCommonLoadersForGlobalStyle(options, includePaths),
},
{
test: /\.scss$|\.sass$/,
include: globalStylePaths,
use: [
...getCommonLoadersForGlobalStyle(options, includePaths),
{
loader: require.resolve('sass-loader'),
options: {
api: 'modern-compiler',
implementation: require.resolve('sass-embedded'),
sourceMap: !!options.sourceMap,
sassOptions: {
fiber: false,
// bootstrap-sass requires a minimum precision of 8
precision: 8,
loadPaths: includePaths,
...(sassOptions ?? {}),
},
},
},
],
},
{
test: /\.less$/,
include: globalStylePaths,
use: [
...getCommonLoadersForGlobalStyle(options, includePaths),
{
loader: path.join(
__dirname,
'../../../utils/webpack/deprecated-less-loader.js'
),
options: {
sourceMap: !!options.sourceMap,
lessOptions: {
javascriptEnabled: true,
...lessPathOptions,
...(lessOptions ?? {}),
},
},
},
],
},
];
const rules: RuleSetRule[] = [
{
test: /\.css$|\.scss$|\.sass$|\.less$/,
oneOf: [...cssModuleRules, ...globalCssRules, ...globalStyleRules],
},
];
if (options.extractCss) {
plugins.push(
// extract global css from js files into own css file
new MiniCssExtractPlugin({
filename: `[name]${hashFormat.extract}.css`,
})
);
}
config.output = {
...config.output,
assetModuleFilename: '[name].[contenthash:20][ext]',
crossOriginLoading: options.subresourceIntegrity
? ('anonymous' as const)
: (false as const),
};
// In case users customize their webpack config with unsupported entry.
if (typeof config.entry === 'function')
throw new Error('Entry function is not supported. Use an object.');
if (typeof config.entry === 'string')
throw new Error('Entry string is not supported. Use an object.');
if (Array.isArray(config.entry))
throw new Error('Entry array is not supported. Use an object.');
Object.entries(entries).forEach(([entryName, entryData]) => {
if (useNormalizedEntry) {
config.entry[entryName] = { import: entryData.import };
} else {
config.entry[entryName] = entryData.import;
}
});
config.optimization = {
...config.optimization,
minimizer: [...config.optimization.minimizer, ...minimizer],
emitOnErrors: false,
moduleIds: 'deterministic' as const,
runtimeChunk: options.runtimeChunk ? { name: 'runtime' } : false,
splitChunks: {
defaultSizeTypes:
config.optimization.splitChunks !== false
? config.optimization.splitChunks?.defaultSizeTypes
: ['...'],
maxAsyncRequests: Infinity,
cacheGroups: {
default: !!options.commonChunk && {
chunks: 'async' as const,
minChunks: 2,
priority: 10,
},
common: !!options.commonChunk && {
name: 'common',
chunks: 'async' as const,
minChunks: 2,
enforce: true,
priority: 5,
},
vendors: false as const,
vendor: !!options.vendorChunk && {
name: 'vendor',
chunks: (chunk) => chunk.name === 'main',
enforce: true,
test: /[\\/]node_modules[\\/]/,
},
},
},
};
config.resolve.mainFields = ['browser', 'module', 'main'];
config.module = {
...config.module,
rules: [
...(config.module.rules ?? []),
// Images: Inline small images, and emit a separate file otherwise (including SVGs).
{
test: /\.(avif|bmp|gif|ico|jpe?g|png|svg|webp)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10_000, // 10 kB
},
},
},
// Fonts: Emit separate file and export the URL.
{
test: /\.(eot|otf|ttf|woff|woff2)$/,
type: 'asset/resource',
},
...rules,
],
};
config.plugins ??= [];
config.plugins.push(...plugins);
}
@@ -0,0 +1,22 @@
import { createLoaderFromCompiler } from './compiler-loaders';
import { NormalizedNxAppWebpackPluginOptions } from '../nx-app-webpack-plugin-options';
describe('createLoaderFromCompiler tsc', () => {
function tscOptions(): NormalizedNxAppWebpackPluginOptions {
return {
root: '/test',
tsConfig: 'apps/test/tsconfig.app.json',
compiler: 'tsc',
transformers: [],
} as unknown as NormalizedNxAppWebpackPluginOptions;
}
// ts-loader 9.5.7+ forwards the tsconfig rootDir to transpileModule, so
// workspace lib sources resolved outside the app rootDir fail with TS6059.
// The loader must widen rootDir to the workspace root to keep those builds green.
it('should widen ts-loader rootDir to the workspace root', () => {
const rule = createLoaderFromCompiler(tscOptions());
expect(rule.options.compilerOptions).toEqual({ rootDir: '/test' });
});
});
@@ -0,0 +1,99 @@
import * as path from 'path';
import { readTsConfig } from '@nx/js';
import { NormalizedNxAppWebpackPluginOptions } from '../nx-app-webpack-plugin-options';
export function createLoaderFromCompiler(
options: NormalizedNxAppWebpackPluginOptions
) {
switch (options.compiler) {
case 'swc':
return {
test: /\.([jt])sx?$/,
loader: require.resolve('swc-loader'),
exclude: /node_modules/,
options: {
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
tsx: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: true,
react: {
runtime: 'automatic',
},
},
loose: true,
},
},
};
case 'tsc':
const { loadTsTransformers } = require('@nx/js');
const { compilerPluginHooks, hasPlugin } = loadTsTransformers(
options.transformers
);
return {
test: /\.([jt])sx?$/,
loader: require.resolve(`ts-loader`),
exclude: /node_modules/,
options: {
configFile: options.tsConfig,
transpileOnly: !hasPlugin,
// https://github.com/TypeStrong/ts-loader/pull/685
experimentalWatchApi: true,
// ts-loader 9.5.7+ forwards the tsconfig rootDir to transpileModule, so
// workspace lib sources bundled from source (outside the app rootDir)
// fail with TS6059. rootDir only governs emit layout, which webpack owns
// here (the default transpile path emits JS, not declarations), so widen
// it to the workspace root to clear the error without altering output.
compilerOptions: { rootDir: options.root },
getCustomTransformers: (program) => ({
before: compilerPluginHooks.beforeHooks.map((hook) =>
hook(program)
),
after: compilerPluginHooks.afterHooks.map((hook) => hook(program)),
afterDeclarations: compilerPluginHooks.afterDeclarationsHooks.map(
(hook) => hook(program)
),
}),
},
};
case 'babel':
const tsConfig = options.tsConfig
? readTsConfig(path.join(options.root, options.tsConfig))
: undefined;
const babelConfig = {
test: /\.([jt])sx?$/,
loader: path.join(__dirname, '../../../utils/web-babel-loader'),
exclude: /node_modules/,
options: {
cwd: path.join(options.root, options.sourceRoot),
emitDecoratorMetadata: tsConfig
? tsConfig.options.emitDecoratorMetadata
: false,
isModern: true,
isTest: process.env.NX_CYPRESS_COMPONENT_TEST === 'true',
envName: process.env.BABEL_ENV ?? process.env.NODE_ENV,
cacheDirectory: true,
cacheCompression: false,
},
};
if (options.babelUpwardRootMode) {
babelConfig.options['rootMode'] = 'upward';
babelConfig.options['babelrc'] = true;
} else {
babelConfig.options['configFile'] = options.babelConfig
? path.join(options.root, options.babelConfig)
: path.join(options.root, options.projectRoot, '.babelrc');
}
return babelConfig;
default:
return null;
}
}
@@ -0,0 +1,36 @@
import * as path from 'path';
import * as fs from 'fs';
import browserslist = require('browserslist');
const VALID_BROWSERSLIST_FILES = ['.browserslistrc', 'browserslist'];
const ES5_BROWSERS = [
'ie 10',
'ie 11',
'safari 11',
'safari 11.1',
'safari 12',
'safari 12.1',
'safari 13',
'ios_saf 13.0',
'ios_saf 13.3',
];
export function getTerserEcmaVersion(projectRoot: string): 2020 | 5 {
let pathToBrowserslistFile = '';
for (const browserslistFile of VALID_BROWSERSLIST_FILES) {
const fullPathToFile = path.join(projectRoot, browserslistFile);
if (fs.existsSync(fullPathToFile)) {
pathToBrowserslistFile = fullPathToFile;
break;
}
}
if (!pathToBrowserslistFile) {
return 2020;
}
const env = browserslist.loadConfig({ path: pathToBrowserslistFile });
const browsers = browserslist(env);
return browsers.some((b) => ES5_BROWSERS.includes(b)) ? 5 : 2020;
}
@@ -0,0 +1,58 @@
import * as path from 'path';
import type { WebpackPluginInstance } from 'webpack';
import { getOutputHashFormat } from '../../../utils/hash-format';
import { ScriptsWebpackPlugin } from '../../../utils/webpack/plugins/scripts-webpack-plugin';
import { normalizeExtraEntryPoints } from '../../../utils/webpack/normalize-entry';
import { NormalizedNxAppWebpackPluginOptions } from '../nx-app-webpack-plugin-options';
export function instantiateScriptPlugins(
options: NormalizedNxAppWebpackPluginOptions
): WebpackPluginInstance[] {
// process global scripts
const globalScriptsByBundleName = normalizeExtraEntryPoints(
options.scripts || [],
'scripts'
).reduce(
(
prev: { inject: boolean; bundleName: string; paths: string[] }[],
curr
) => {
const bundleName = curr.bundleName;
const resolvedPath = path.resolve(options.root, curr.input);
const existingEntry = prev.find((el) => el.bundleName === bundleName);
if (existingEntry) {
existingEntry.paths.push(resolvedPath);
} else {
prev.push({
inject: curr.inject,
bundleName,
paths: [resolvedPath],
});
}
return prev;
},
[]
);
const hashFormat = getOutputHashFormat(options.outputHashing as string);
const plugins = [];
// Add a new asset for each entry.
globalScriptsByBundleName.forEach((script) => {
const hash = script.inject ? hashFormat.script : '';
const bundleName = script.bundleName;
plugins.push(
new ScriptsWebpackPlugin({
name: bundleName,
sourceMap: !!options.sourceMap,
filename: `${path.basename(bundleName)}${hash}.js`,
scripts: script.paths,
basePath: options.sourceRoot,
})
);
});
return plugins;
}
@@ -0,0 +1,235 @@
import {
normalizePath,
parseTargetString,
readCachedProjectGraph,
workspaceRoot,
} from '@nx/devkit';
import { getProjectSourceRoot } from '@nx/js/internal';
import { statSync } from 'fs';
import { basename, dirname, join, parse, relative, resolve } from 'path';
import {
AssetGlobPattern,
FileReplacement,
NormalizedNxAppWebpackPluginOptions,
NxAppWebpackPluginOptions,
} from '../nx-app-webpack-plugin-options';
export function normalizeOptions(
options: NxAppWebpackPluginOptions
): NormalizedNxAppWebpackPluginOptions {
const combinedPluginAndMaybeExecutorOptions: Partial<NormalizedNxAppWebpackPluginOptions> =
{};
const isProd = process.env.NODE_ENV === 'production';
// Since this is invoked by the executor, the graph has already been created and cached.
const projectGraph = readCachedProjectGraph();
const taskDetailsFromBuildTarget = process.env.NX_BUILD_TARGET
? parseTargetString(process.env.NX_BUILD_TARGET, projectGraph)
: undefined;
const projectName = taskDetailsFromBuildTarget
? taskDetailsFromBuildTarget.project
: process.env.NX_TASK_TARGET_PROJECT;
const targetName = taskDetailsFromBuildTarget
? taskDetailsFromBuildTarget.target
: process.env.NX_TASK_TARGET_TARGET;
const configurationName = taskDetailsFromBuildTarget
? taskDetailsFromBuildTarget.configuration
: process.env.NX_TASK_TARGET_CONFIGURATION;
const projectNode = projectGraph.nodes[projectName];
const targetConfig = projectNode.data.targets[targetName];
normalizeRelativePaths(projectNode.data.root, options);
// Merge options from `@nx/webpack:webpack` into plugin options.
// Options from `@nx/webpack:webpack` take precedence.
const originalTargetOptions = targetConfig.options;
if (configurationName) {
Object.assign(
originalTargetOptions,
targetConfig.configurations?.[configurationName]
);
}
// This could be called from dev-server which means we need to read `buildTarget` to get actual build options.
// Otherwise, the options are passed from the `@nx/webpack:webpack` executor.
if (originalTargetOptions.buildTarget) {
const buildTargetOptions = targetConfig.options;
if (configurationName) {
Object.assign(
buildTargetOptions,
targetConfig.configurations?.[configurationName]
);
}
Object.assign(
combinedPluginAndMaybeExecutorOptions,
options,
// executor options take precedence (especially for overriding with CLI args)
buildTargetOptions
);
} else {
Object.assign(
combinedPluginAndMaybeExecutorOptions,
options,
// executor options take precedence (especially for overriding with CLI args)
originalTargetOptions
);
}
const sourceRoot = getProjectSourceRoot(projectNode.data);
if (!combinedPluginAndMaybeExecutorOptions.main) {
throw new Error(
`Missing "main" option for the entry file. Set this option in your Nx webpack plugin.`
);
}
return {
...combinedPluginAndMaybeExecutorOptions,
assets: combinedPluginAndMaybeExecutorOptions.assets
? normalizeAssets(
combinedPluginAndMaybeExecutorOptions.assets,
workspaceRoot,
sourceRoot,
projectNode.data.root
)
: [],
baseHref: combinedPluginAndMaybeExecutorOptions.baseHref ?? '/',
buildLibsFromSource:
combinedPluginAndMaybeExecutorOptions.buildLibsFromSource ?? true,
commonChunk: combinedPluginAndMaybeExecutorOptions.commonChunk ?? true,
compiler: combinedPluginAndMaybeExecutorOptions.compiler ?? 'babel',
configurationName,
extractCss: combinedPluginAndMaybeExecutorOptions.extractCss ?? true,
fileReplacements: normalizeFileReplacements(
workspaceRoot,
combinedPluginAndMaybeExecutorOptions.fileReplacements
),
generateIndexHtml:
combinedPluginAndMaybeExecutorOptions.generateIndexHtml ?? true,
main: combinedPluginAndMaybeExecutorOptions.main,
namedChunks: combinedPluginAndMaybeExecutorOptions.namedChunks ?? !isProd,
optimization: combinedPluginAndMaybeExecutorOptions.optimization ?? isProd,
outputFileName:
combinedPluginAndMaybeExecutorOptions.outputFileName ?? 'main.js',
outputHashing:
combinedPluginAndMaybeExecutorOptions.outputHashing ??
(isProd ? 'all' : 'none'),
outputPath: combinedPluginAndMaybeExecutorOptions.outputPath,
projectGraph,
projectName,
projectRoot: projectNode.data.root,
root: workspaceRoot,
runtimeChunk: combinedPluginAndMaybeExecutorOptions.runtimeChunk ?? true,
scripts: combinedPluginAndMaybeExecutorOptions.scripts ?? [],
sourceMap: combinedPluginAndMaybeExecutorOptions.sourceMap ?? !isProd,
sourceRoot,
styles: combinedPluginAndMaybeExecutorOptions.styles ?? [],
target: combinedPluginAndMaybeExecutorOptions.target,
targetName,
vendorChunk: combinedPluginAndMaybeExecutorOptions.vendorChunk ?? !isProd,
};
}
export function normalizeAssets(
assets: any[],
root: string,
sourceRoot: string,
projectRoot: string,
resolveRelativePathsToProjectRoot = true
): AssetGlobPattern[] {
return assets.map((asset) => {
if (typeof asset === 'string') {
const assetPath = normalizePath(asset);
const resolvedAssetPath = resolve(root, assetPath);
const resolvedSourceRoot = resolve(root, sourceRoot);
if (!resolvedAssetPath.startsWith(resolvedSourceRoot)) {
throw new Error(
`The ${resolvedAssetPath} asset path must start with the project source root: ${sourceRoot}`
);
}
const isDirectory = statSync(resolvedAssetPath).isDirectory();
const input = isDirectory
? resolvedAssetPath
: dirname(resolvedAssetPath);
const output = relative(resolvedSourceRoot, resolve(root, input));
const glob = isDirectory ? '**/*' : basename(resolvedAssetPath);
return {
input,
output,
glob,
};
} else {
if (asset.output.startsWith('..')) {
throw new Error(
'An asset cannot be written to a location outside of the output path.'
);
}
const assetPath = normalizePath(asset.input);
let resolvedAssetPath = resolve(root, assetPath);
if (resolveRelativePathsToProjectRoot && asset.input.startsWith('.')) {
const resolvedProjectRoot = resolve(root, projectRoot);
resolvedAssetPath = resolve(resolvedProjectRoot, assetPath);
}
return {
...asset,
input: resolvedAssetPath,
// Now we remove starting slash to make Webpack place it from the output root.
output: asset.output.replace(/^\//, ''),
};
}
});
}
export function normalizeFileReplacements(
root: string,
fileReplacements: FileReplacement[]
): FileReplacement[] {
return fileReplacements
? fileReplacements.map((fileReplacement) => ({
replace: resolve(root, fileReplacement.replace),
with: resolve(root, fileReplacement.with),
}))
: [];
}
function normalizeRelativePaths(
projectRoot: string,
options: NxAppWebpackPluginOptions
): void {
for (const [fieldName, fieldValue] of Object.entries(options)) {
if (isRelativePath(fieldValue)) {
options[fieldName] = join(projectRoot, fieldValue);
} else if (fieldName === 'additionalEntryPoints') {
for (let i = 0; i < fieldValue.length; i++) {
const v = fieldValue[i];
if (isRelativePath(v)) {
fieldValue[i] = {
entryName: parse(v).name,
entryPath: join(projectRoot, v),
};
} else if (isRelativePath(v.entryPath)) {
v.entryPath = join(projectRoot, v.entryPath);
}
}
} 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('.\\'))
);
}
@@ -0,0 +1,154 @@
import * as path from 'path';
import autoprefixer = require('autoprefixer');
import postcssImports = require('postcss-import');
import { getCSSModuleLocalIdent } from '../../../utils/get-css-module-local-ident';
import { getOutputHashFormat } from '../../../utils/hash-format';
import { NormalizedNxAppWebpackPluginOptions } from '../nx-app-webpack-plugin-options';
import { PostcssCliResources } from '../../../utils/webpack/plugins/postcss-cli-resources';
interface PostcssOptions {
(loader: any): any;
config?: string;
}
export function getCommonLoadersForCssModules(
options: NormalizedNxAppWebpackPluginOptions,
includePaths: string[]
) {
const MiniCssExtractPlugin =
require('mini-css-extract-plugin') as typeof import('mini-css-extract-plugin');
// load component css as raw strings
return [
{
loader: options.extractCss
? MiniCssExtractPlugin.loader
: require.resolve('style-loader'),
},
{
loader: require.resolve('css-loader'),
options: {
modules: {
mode: 'local',
getLocalIdent: getCSSModuleLocalIdent,
},
importLoaders: 1,
},
},
{
loader: require.resolve('postcss-loader'),
options: {
implementation: require('postcss'),
postcssOptions: postcssOptionsCreator(options, {
includePaths,
forCssModules: true,
}),
},
},
];
}
export function getCommonLoadersForGlobalCss(
options: NormalizedNxAppWebpackPluginOptions,
includePaths: string[]
) {
const MiniCssExtractPlugin =
require('mini-css-extract-plugin') as typeof import('mini-css-extract-plugin');
return [
{
loader: options.extractCss
? MiniCssExtractPlugin.loader
: require.resolve('style-loader'),
},
{ loader: require.resolve('css-loader'), options: { url: false } },
{
loader: require.resolve('postcss-loader'),
options: {
implementation: require('postcss'),
postcssOptions: postcssOptionsCreator(options, {
includePaths,
}),
},
},
];
}
export function getCommonLoadersForGlobalStyle(
options: NormalizedNxAppWebpackPluginOptions,
includePaths: string[]
) {
const MiniCssExtractPlugin =
require('mini-css-extract-plugin') as typeof import('mini-css-extract-plugin');
return [
{
loader: options.extractCss
? MiniCssExtractPlugin.loader
: require.resolve('style-loader'),
options: { esModule: true },
},
{ loader: require.resolve('css-loader'), options: { url: false } },
{
loader: require.resolve('postcss-loader'),
options: {
implementation: require('postcss'),
postcssOptions: postcssOptionsCreator(options, {
includePaths,
}),
},
},
];
}
function postcssOptionsCreator(
options: NormalizedNxAppWebpackPluginOptions,
{
includePaths,
forCssModules = false,
}: {
includePaths: string[];
forCssModules?: boolean;
}
) {
const hashFormat = getOutputHashFormat(options.outputHashing as string);
// PostCSS options depend on the webpack loader, but we need to set the `config` path as a string due to this check:
// https://github.com/webpack-contrib/postcss-loader/blob/0d342b1/src/utils.js#L36
const postcssOptions: PostcssOptions = (loader) => ({
map: options.sourceMap &&
options.sourceMap !== 'hidden' && {
inline: true,
annotation: false,
},
plugins: [
postcssImports({
addModulesDirectories: includePaths,
resolve: (url: string) => (url.startsWith('~') ? url.slice(1) : url),
}),
...(forCssModules
? []
: [
PostcssCliResources({
baseHref: options.baseHref ? options.baseHref : undefined,
deployUrl: options.deployUrl,
loader,
filename: `[name]${hashFormat.file}.[ext]`,
publicPath: options.publicPath,
rebaseRootRelative: options.rebaseRootRelative,
}),
autoprefixer(),
]),
],
});
// If a path to postcssConfig is passed in, set it for app and all libs, otherwise
// use automatic detection.
if (typeof options.postcssConfig === 'string') {
postcssOptions.config = path.join(options.root, options.postcssConfig);
}
return postcssOptions;
}
@@ -0,0 +1,154 @@
import { logger } from '@nx/devkit';
import { createAllowlistFromExports } from './utils';
describe('createAllowlistFromExports', () => {
beforeEach(() => {
jest.spyOn(logger, 'warn').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should handle undefined exports', () => {
const result = createAllowlistFromExports('@test/lib', undefined);
expect(result).toEqual(['@test/lib']);
});
it('should handle string exports', () => {
const result = createAllowlistFromExports('@test/lib', './index.js');
expect(result).toEqual(['@test/lib']);
});
it('should handle wildcard exports', () => {
const result = createAllowlistFromExports('@test/lib', {
'./*': './src/*.ts',
});
expect(result).toHaveLength(2);
expect(result[0]).toBe('@test/lib');
expect(result[1]).toBeInstanceOf(RegExp);
const regex = result[1] as RegExp;
expect(regex.test('@test/lib/utils')).toBe(true);
expect(regex.test('@test/lib/nested/path')).toBe(true);
expect(regex.test('@other/lib/utils')).toBe(false);
expect(regex.test('@test/lib')).toBe(false);
});
it('should handle exact subpath exports', () => {
const result = createAllowlistFromExports('@test/lib', {
'./utils': './src/utils.ts',
'./types': './src/types.ts',
});
expect(result).toEqual(['@test/lib', '@test/lib/utils', '@test/lib/types']);
});
it('should handle conditional exports', () => {
const result = createAllowlistFromExports('@test/lib', {
'./utils': {
import: './src/utils.mjs',
require: './src/utils.cjs',
default: './src/utils.js',
},
});
expect(result).toEqual(['@test/lib', '@test/lib/utils']);
});
it('should handle conditional exports with development priority', () => {
const result = createAllowlistFromExports('@test/lib', {
'./utils': {
development: './src/utils.ts',
import: './src/utils.mjs',
require: './src/utils.cjs',
default: './src/utils.js',
},
});
expect(result).toEqual(['@test/lib', '@test/lib/utils']);
});
it('should handle mixed patterns', () => {
const result = createAllowlistFromExports('@test/lib', {
'./utils': './src/utils.ts',
'./*': './src/*.ts',
});
expect(result).toHaveLength(3);
expect(result[0]).toBe('@test/lib');
expect(result[1]).toBe('@test/lib/utils');
expect(result[2]).toBeInstanceOf(RegExp);
const regex = result[2] as RegExp;
expect(regex.test('@test/lib/helpers')).toBe(true);
expect(regex.test('@test/lib/utils')).toBe(true); // Also matches regex
});
it('should escape special characters in package names', () => {
const result = createAllowlistFromExports('@test/lib.name', {
'./*': './src/*.ts',
});
expect(result).toHaveLength(2);
expect(result[1]).toBeInstanceOf(RegExp);
const regex = result[1] as RegExp;
expect(regex.test('@test/lib.name/utils')).toBe(true);
expect(regex.test('@test/lib-name/utils')).toBe(false);
});
it('should handle scoped package names with special characters', () => {
const result = createAllowlistFromExports('@my-org/my-lib.pkg', {
'./*': './src/*.ts',
});
expect(result).toHaveLength(2);
expect(result[1]).toBeInstanceOf(RegExp);
const regex = result[1] as RegExp;
expect(regex.test('@my-org/my-lib.pkg/utils')).toBe(true);
expect(regex.test('@my-org/my-lib-pkg/utils')).toBe(false);
});
it('should handle complex wildcard patterns', () => {
const result = createAllowlistFromExports('@test/lib', {
'./utils/*': './src/utils/*.ts',
'./types/*': './src/types/*.ts',
});
expect(result).toHaveLength(3);
expect(result[0]).toBe('@test/lib');
expect(result[1]).toBeInstanceOf(RegExp);
expect(result[2]).toBeInstanceOf(RegExp);
const utilsRegex = result[1] as RegExp;
const typesRegex = result[2] as RegExp;
expect(utilsRegex.test('@test/lib/utils/helpers')).toBe(true);
expect(utilsRegex.test('@test/lib/types/common')).toBe(false);
expect(typesRegex.test('@test/lib/types/common')).toBe(true);
expect(typesRegex.test('@test/lib/utils/helpers')).toBe(false);
});
it('should ignore main export (.)', () => {
const result = createAllowlistFromExports('@test/lib', {
'.': './src/index.ts',
'./utils': './src/utils.ts',
});
expect(result).toEqual(['@test/lib', '@test/lib/utils']);
});
it('should handle invalid conditional exports gracefully', () => {
const result = createAllowlistFromExports('@test/lib', {
'./utils': {
import: null,
require: undefined,
types: './src/types.d.ts', // Should be ignored
},
'./valid': './src/valid.ts',
});
expect(result).toEqual(['@test/lib', '@test/lib/valid']);
});
it('should handle non-string export paths', () => {
const result = createAllowlistFromExports('@test/lib', {
123: './src/invalid.ts',
'./valid': './src/valid.ts',
} as any);
expect(result).toEqual(['@test/lib', '@test/lib/valid']);
});
});
@@ -0,0 +1,249 @@
import type { ProjectGraph, ProjectGraphProjectNode } from '@nx/devkit';
import { readJsonFile } from '@nx/devkit';
import { join } from 'path';
function escapePackageName(packageName: string): string {
return packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeRegexAndConvertWildcard(pattern: string): string {
return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*');
}
function resolveConditionalExport(target: any): string | null {
if (typeof target === 'string') {
return target;
}
if (typeof target === 'object' && target !== null) {
// Priority order for conditions
const conditions = ['development', 'import', 'require', 'default'];
for (const condition of conditions) {
if (target[condition] && typeof target[condition] === 'string') {
return target[condition];
}
}
}
return null;
}
export function createAllowlistFromExports(
packageName: string,
exports: Record<string, any> | string | undefined
): (string | RegExp)[] {
if (!exports) {
return [packageName];
}
const allowlist: (string | RegExp)[] = [];
allowlist.push(packageName);
if (typeof exports === 'string') {
return allowlist;
}
if (typeof exports === 'object') {
for (const [exportPath, target] of Object.entries(exports)) {
if (typeof exportPath !== 'string') continue;
const resolvedTarget = resolveConditionalExport(target);
if (!resolvedTarget) continue;
if (exportPath === '.') {
continue;
} else if (exportPath.startsWith('./')) {
const subpath = exportPath.slice(2);
if (subpath.includes('*')) {
const regexPattern = escapeRegexAndConvertWildcard(subpath);
allowlist.push(
new RegExp(`^${escapePackageName(packageName)}/${regexPattern}$`)
);
} else {
allowlist.push(`${packageName}/${subpath}`);
}
}
}
}
return allowlist;
}
function isSourceFile(path: string): boolean {
return ['.ts', '.tsx', '.mts', '.cts'].some((ext) => path.endsWith(ext));
}
function isBuildableExportMap(packageExports: any): boolean {
if (!packageExports || Object.keys(packageExports).length === 0) {
return false; // exports = {} → not buildable
}
const isCompiledExport = (value: unknown): boolean => {
if (typeof value === 'string') {
return !isSourceFile(value);
}
if (typeof value === 'object' && value !== null) {
return Object.entries(value).some(([key, subValue]) => {
if (
key === 'types' ||
key === 'development' ||
key === './package.json'
)
return false;
return typeof subValue === 'string' && !isSourceFile(subValue);
});
}
return false;
};
if (packageExports['.']) {
return isCompiledExport(packageExports['.']);
}
return Object.entries(packageExports).some(
([key, value]) => key !== '.' && isCompiledExport(value)
);
}
/**
* Check if the library is buildable.
* @param node from the project graph
* @returns boolean
*/
export function isBuildableLibrary(node: ProjectGraphProjectNode): boolean {
if (!node.data.metadata?.js) {
return false;
}
const { packageExports, packageMain } = node.data.metadata.js;
// if we have exports only check this else fallback to packageMain
if (packageExports) {
return isBuildableExportMap(packageExports);
}
return (
typeof packageMain === 'string' &&
packageMain !== '' &&
!isSourceFile(packageMain)
);
}
/**
* Get all transitive dependencies of a target that are non-buildable libraries.
* This function traverses the project graph to find all dependencies of a given target,
* @param graph Graph of the project
* @param targetName The project name to get dependencies for
* @param visited Set to keep track of visited nodes to prevent infinite loops in circular dependencies
* @returns string[] - List of all transitive dependencies that are non-buildable libraries
*/
export function getAllTransitiveDeps(
graph: ProjectGraph,
targetName: string,
visited = new Set<string>()
): string[] {
if (visited.has(targetName)) {
return [];
}
visited.add(targetName);
const node = graph.nodes?.[targetName];
if (!node) {
return [];
}
// Get direct dependencies of this target
const directDeps = graph.dependencies?.[targetName] || [];
const transitiveDeps: string[] = [];
for (const dep of directDeps) {
const depNode = graph.nodes?.[dep.target];
// Only consider library dependencies
if (!depNode || depNode.type !== 'lib') {
continue;
}
// Check if this dependency is non-buildable
const hasBuildTarget = 'build' in (depNode.data?.targets ?? {});
const isBuildable = hasBuildTarget || isBuildableLibrary(depNode);
if (!isBuildable) {
const packageName = depNode.data?.metadata?.js?.packageName;
if (packageName) {
transitiveDeps.push(packageName);
}
const nestedDeps = getAllTransitiveDeps(graph, dep.target, visited);
transitiveDeps.push(...nestedDeps);
}
}
return transitiveDeps;
}
/**
* Get all non-buildable libraries in the project graph for a given project.
* This function retrieves all direct and transitive dependencies of a project,
* filtering out only those that are libraries and not buildable.
* @param graph Project graph
* @param projectName The project name to get dependencies for
* @returns A list of all non-buildable libraries that the project depends on, including transitive dependencies.
*/
export function getNonBuildableLibs(
graph: ProjectGraph,
projectName: string
): (string | RegExp)[] {
const deps = graph?.dependencies?.[projectName] ?? [];
const allNonBuildable = new Set<string | RegExp>();
// First, find all direct non-buildable deps and add them App -> library
const directNonBuildable = deps.filter((dep) => {
const node = graph.nodes?.[dep.target];
if (!node || node.type !== 'lib') return false;
const hasBuildTarget = 'build' in (node.data?.targets ?? {});
if (hasBuildTarget) return false;
return !isBuildableLibrary(node);
});
// Add direct non-buildable dependencies with expanded export patterns
for (const dep of directNonBuildable) {
const node = graph.nodes?.[dep.target];
const packageName = node?.data?.metadata?.js?.packageName;
if (packageName) {
// Get exports from project metadata first (most reliable)
const packageExports = node?.data?.metadata?.js?.packageExports;
if (packageExports) {
// Use metadata exports if available
const allowlistPatterns = createAllowlistFromExports(
packageName,
packageExports
);
allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern));
} else {
// Fallback: try to read package.json directly
try {
const projectRoot = node.data.root;
const packageJsonPath = join(projectRoot, 'package.json');
const packageJson = readJsonFile(packageJsonPath);
const allowlistPatterns = createAllowlistFromExports(
packageName,
packageJson.exports
);
allowlistPatterns.forEach((pattern) => allNonBuildable.add(pattern));
} catch (error) {
// Final fallback: just add base package name
allNonBuildable.add(packageName);
}
}
}
// Get all transitive non-buildable dependencies App -> library1 -> library2
const transitiveDeps = getAllTransitiveDeps(graph, dep.target);
transitiveDeps.forEach((pkg) => allNonBuildable.add(pkg));
}
return Array.from(allNonBuildable);
}
@@ -0,0 +1,282 @@
import { ProjectGraph } from '@nx/devkit';
import { AssetGlob } from '@nx/js/internal';
export interface AssetGlobPattern {
glob: string;
input: string;
output: string;
ignore?: string[];
}
export interface ExtraEntryPointClass {
bundleName?: string;
inject?: boolean;
input: string;
lazy?: boolean;
}
export interface FileReplacement {
replace: string;
with: string;
}
export interface AdditionalEntryPoint {
entryName: string;
entryPath: string;
}
export interface TransformerPlugin {
name: string;
options: Record<string, unknown>;
}
export type TransformerEntry = string | TransformerPlugin;
export interface OptimizationOptions {
scripts: boolean;
styles: boolean;
}
export interface TypeCheckOptions {
async: boolean;
}
export interface NxAppWebpackPluginOptions {
/**
* 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<AssetGlob | string>;
/**
* 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 <base href> for the resulting index.html.
*/
baseHref?: string | false;
/**
* Build the libraries from source. Default is `true`.
*/
buildLibsFromSource?: boolean;
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';
/**
* 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 `all`.
*/
externalDependencies?: 'all' | 'none' | string[];
/**
* Whether to combine plugin externals config with the existing webpack external config.
* Default is `false`.
*/
mergeExternals?: boolean;
/**
* 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;
/**
* Add runtime dependencies to the generated `package.json` file. Useful for Docker install.
*/
runtimeDependencies?: string[];
/**
* 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<ExtraEntryPointClass | string>;
/**
* Do not add a `overrides` and `resolutions` entries to the generated package.json file. Only works in conjunction with `generatePackageJson` option.
*/
skipOverrides?: boolean;
/**
* Do not add a `packageManager` entry to the generated package.json file. Only works in conjunction with `generatePackageJson` option.
*/
skipPackageManager?: boolean;
/**
* Skip type checking. Default is `false`.
* @deprecated Use `typeCheckOptions` option instead. This option will be removed in Nx 24.
*/
skipTypeChecking?: boolean;
/**
* Configure type checking during the build.
* - Set to `true` to enable type checking with default options (async: true).
* - Set to `false` to disable type checking entirely.
* - Use `{ async: true }` to run type checking in a separate process without blocking the build.
* - Use `{ async: false }` to run type checking synchronously.
* Default is `{ async: true }`.
*/
typeCheckOptions?: boolean | TypeCheckOptions;
/**
* Generate source maps.
*/
sourceMap?: boolean | string;
/**
* 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?: {
includePaths?: string[];
sassOptions?: Record<string, any>;
lessOptions?: Record<string, any>;
};
/**
* External stylesheets that will be included with the application.
*/
styles?: Array<ExtraEntryPointClass | string>;
/**
* 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[];
/**
* Use tsconfig-paths-webpack-plugin to resolve modules using paths in the tsconfig file.
*/
useTsconfigPaths?: boolean;
/**
* 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;
/**
* Configure webpack caching behavior. When not specified, defaults to `{ type: 'memory' }` for Node targets in watch mode, and `undefined` otherwise.
*/
cache?: boolean | { type: 'memory' | 'filesystem'; [key: string]: any };
/**
* Set a public path for assets resources with absolute paths.
*/
publicPath?: string;
/**
* Whether to rebase absolute path for assets in postcss cli resources.
*/
rebaseRootRelative?: boolean;
/**
* Watch buildable dependencies and rebuild when they change.
*/
watchDependencies?: boolean;
}
export interface NormalizedNxAppWebpackPluginOptions
extends NxAppWebpackPluginOptions {
projectName: string;
root: string;
projectRoot: string;
sourceRoot: string;
configurationName: string;
targetName: string;
projectGraph: ProjectGraph;
outputFileName: string;
assets: AssetGlobPattern[];
}
@@ -0,0 +1,56 @@
import type { Compiler } from 'webpack';
import {
NormalizedNxAppWebpackPluginOptions,
NxAppWebpackPluginOptions,
} from './nx-app-webpack-plugin-options';
import { normalizeOptions } from './lib/normalize-options';
/**
* This plugin provides features to build Node and Web applications.
* - TS support (including tsconfig paths)
* - Different compiler options
* - Assets handling
* - Stylesheets handling
* - index.html and package.json generation
*
* Web-only features, such as stylesheets and images, are only supported when `target` is 'web' or 'webworker'.
*/
export class NxAppWebpackPlugin {
private readonly options: NormalizedNxAppWebpackPluginOptions;
constructor(options: NxAppWebpackPluginOptions = {}) {
// If we're building inferred targets, skip normalizing build options.
if (!global.NX_GRAPH_CREATION) {
this.options = normalizeOptions(options);
}
}
apply(compiler: Compiler): void {
// Lazy-required so a generator can load this without the webpack bundler.
const { applyBaseConfig } =
require('./lib/apply-base-config') as typeof import('./lib/apply-base-config');
const { applyWebConfig } =
require('./lib/apply-web-config') as typeof import('./lib/apply-web-config');
// 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;
}
applyBaseConfig(this.options, compiler.options, {
useNormalizedEntry: true,
});
if (compiler.options.target) {
this.options.target = compiler.options.target;
}
if (this.options.target === 'web' || this.options.target === 'webworker') {
applyWebConfig(this.options, compiler.options, {
useNormalizedEntry: true,
});
}
}
}
+229
View File
@@ -0,0 +1,229 @@
// Needed so the current environment is not used
jest.mock('@nx/devkit', () => ({
...jest.requireActual('@nx/devkit'),
getPackageManagerCommand: jest.fn(() => ({
exec: 'npx',
})),
}));
// Needed so the current environment is not used
jest.mock('@nx/js/internal', () => ({
...jest.requireActual('@nx/js/internal'),
isUsingTsSolutionSetup: jest.fn(() => false),
}));
import { CreateNodesContext } from '@nx/devkit';
import { createNodesV2 } from './plugin';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { join } from 'path';
describe('@nx/webpack/plugin', () => {
let createNodesFunction = createNodesV2[1];
let context: CreateNodesContext;
let tempFs: TempFs;
let originalCacheProjectGraph = process.env.NX_CACHE_PROJECT_GRAPH;
beforeEach(() => {
process.env.NX_CACHE_PROJECT_GRAPH = 'false';
tempFs = new TempFs('webpack-plugin');
context = {
nxJsonConfiguration: {
namedInputs: {
default: ['{projectRoot}/**/*'],
production: ['!{projectRoot}/**/*.spec.ts'],
},
},
workspaceRoot: tempFs.tempDir,
};
tempFs.createFileSync(
'my-app/project.json',
JSON.stringify({ name: 'my-app' })
);
tempFs.createFileSync('my-app/webpack.config.js', '');
tempFs.createFileSync('package-lock.json', '{}');
});
afterEach(() => {
jest.resetModules();
if (originalCacheProjectGraph !== undefined) {
process.env.NX_CACHE_PROJECT_GRAPH = originalCacheProjectGraph;
} else {
delete process.env.NX_CACHE_PROJECT_GRAPH;
}
});
it('should create nodes', async () => {
mockWebpackConfig({
output: {
path: 'dist/foo',
},
devServer: {
port: 9000,
},
});
const nodes = await createNodesFunction(
['my-app/webpack.config.js'],
{
buildTargetName: 'build-something',
serveTargetName: 'my-serve',
previewTargetName: 'preview-site',
serveStaticTargetName: 'serve-static',
},
context
);
expect(nodes).toMatchInlineSnapshot(`
[
[
"my-app/webpack.config.js",
{
"projects": {
"my-app": {
"metadata": {},
"projectType": "application",
"targets": {
"build-deps": {
"dependsOn": [
"^build",
],
},
"build-something": {
"cache": true,
"command": "webpack-cli build",
"dependsOn": [
"^build-something",
],
"inputs": [
"production",
"^production",
{
"externalDependencies": [
"webpack-cli",
],
},
{
"fields": [
"extends",
"files",
"include",
],
"json": "{workspaceRoot}/tsconfig.json",
},
],
"metadata": {
"description": "Runs Webpack build",
"help": {
"command": "npx webpack-cli build --help",
"example": {
"args": [
"--profile",
],
"options": {
"json": "stats.json",
},
},
},
"technologies": [
"webpack",
],
},
"options": {
"cwd": "my-app",
"env": {
"NODE_ENV": "production",
},
},
"outputs": [
"{projectRoot}/dist/foo",
],
},
"my-serve": {
"command": "webpack-cli serve",
"continuous": true,
"metadata": {
"description": "Starts Webpack dev server",
"help": {
"command": "npx webpack-cli serve --help",
"example": {
"options": {
"args": [
"--client-progress",
"--history-api-fallback ",
],
},
},
},
"technologies": [
"webpack",
],
},
"options": {
"cwd": "my-app",
"env": {
"NODE_ENV": "development",
},
},
},
"preview-site": {
"command": "webpack-cli serve",
"continuous": true,
"metadata": {
"description": "Starts Webpack dev server in production mode",
"help": {
"command": "npx webpack-cli serve --help",
"example": {
"options": {
"args": [
"--client-progress",
"--history-api-fallback ",
],
},
},
},
"technologies": [
"webpack",
],
},
"options": {
"cwd": "my-app",
"env": {
"NODE_ENV": "production",
},
},
},
"serve-static": {
"continuous": true,
"dependsOn": [
"build-something",
],
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "build-something",
"port": 9000,
"spa": true,
},
},
"watch-deps": {
"command": "npx nx watch --projects my-app --includeDependencies -- npx nx build-deps my-app",
"continuous": true,
"dependsOn": [
"build-deps",
],
},
},
},
},
},
],
]
`);
});
function mockWebpackConfig(config: any) {
jest.mock(join(tempFs.tempDir, 'my-app/webpack.config.js'), () => config, {
virtual: true,
});
}
});
+390
View File
@@ -0,0 +1,390 @@
import {
calculateHashesForCreateNodes,
getNamedInputs,
PluginCache,
} from '@nx/devkit/internal';
import {
AggregateCreateNodesError,
CreateDependencies,
CreateNodesContext,
createNodesFromFiles,
CreateNodesResult,
CreateNodesResultArray,
CreateNodes,
detectPackageManager,
getPackageManagerCommand,
joinPathFragments,
ProjectConfiguration,
TargetConfiguration,
workspaceRoot,
} from '@nx/devkit';
import { getLockFileName, getRootTsConfigPath } from '@nx/js';
import {
isUsingTsSolutionSetup,
TS_SOLUTION_SETUP_TSCONFIG_INPUT,
addBuildAndWatchDepsTargets,
} from '@nx/js/internal';
import { readdirSync } from 'fs';
import { hashObject } from 'nx/src/hasher/file-hasher';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import { dirname, isAbsolute, join, relative, resolve } from 'path';
import { readWebpackOptions } from '../utils/webpack/read-webpack-options';
import { resolveUserDefinedWebpackConfig } from '../utils/webpack/resolve-user-defined-webpack-config';
export interface WebpackPluginOptions {
buildTargetName?: string;
serveTargetName?: string;
serveStaticTargetName?: string;
previewTargetName?: string;
buildDepsTargetName?: string;
watchDepsTargetName?: string;
}
type WebpackTargets = Pick<ProjectConfiguration, 'targets' | 'metadata'>;
/**
* @deprecated The 'createDependencies' function is now a no-op. This functionality is included in 'createNodesV2'.
*/
export const createDependencies: CreateDependencies = () => {
return [];
};
const webpackConfigGlob = '**/webpack.config.{js,ts,mjs,cjs}';
export const createNodes: CreateNodes<WebpackPluginOptions> = [
webpackConfigGlob,
async (configFilePaths, options, context) => {
const optionsHash = hashObject(options);
const cachePath = join(
workspaceDataDirectory,
`webpack-${optionsHash}.hash`
);
const targetsCache = new PluginCache<WebpackTargets>(cachePath);
const normalizedOptions = normalizeOptions(options);
const isTsSolutionSetup = isUsingTsSolutionSetup();
const packageManager = detectPackageManager(context.workspaceRoot);
const pmc = getPackageManagerCommand(packageManager);
const lockFileName = getLockFileName(packageManager);
try {
const { entries, preErrors } = await filterWebpackConfigs(
configFilePaths,
context
);
const projectHashes = await calculateHashesForCreateNodes(
entries.map((e) => e.projectRoot),
normalizedOptions,
context,
entries.map(() => [lockFileName])
);
let results: CreateNodesResultArray = [];
let nodeErrors: Array<[string | null, Error]> = [];
try {
results = await createNodesFromFiles(
(configFile, opts, ctx, idx) =>
createNodesInternal(
configFile,
opts,
ctx,
targetsCache,
isTsSolutionSetup,
pmc,
projectHashes[idx]
),
entries.map((e) => e.configFile),
normalizedOptions,
context
);
} catch (e) {
if (e instanceof AggregateCreateNodesError) {
results = e.partialResults ?? [];
nodeErrors = e.errors;
} else {
throw e;
}
}
const allErrors = [...preErrors, ...nodeErrors];
if (allErrors.length > 0) {
throw new AggregateCreateNodesError(allErrors, results);
}
return results;
} finally {
targetsCache.writeToDisk();
}
},
];
export const createNodesV2 = createNodes;
async function createNodesInternal(
configFilePath: string,
options: Required<WebpackPluginOptions>,
context: CreateNodesContext,
targetsCache: PluginCache<WebpackTargets>,
isTsSolutionSetup: boolean,
pmc: ReturnType<typeof getPackageManagerCommand>,
hash: string
): Promise<CreateNodesResult> {
const projectRoot = dirname(configFilePath);
if (!targetsCache.has(hash)) {
targetsCache.set(
hash,
await createWebpackTargets(
configFilePath,
projectRoot,
options,
context,
isTsSolutionSetup,
pmc
)
);
}
const { targets, metadata } = targetsCache.get(hash);
return {
projects: {
[projectRoot]: {
projectType: 'application',
targets,
metadata,
},
},
};
}
async function createWebpackTargets(
configFilePath: string,
projectRoot: string,
options: Required<WebpackPluginOptions>,
context: CreateNodesContext,
isTsSolutionSetup: boolean,
pmc: ReturnType<typeof getPackageManagerCommand>
): Promise<WebpackTargets> {
const namedInputs = getNamedInputs(projectRoot, context);
const webpackConfig = resolveUserDefinedWebpackConfig(
join(context.workspaceRoot, configFilePath),
getRootTsConfigPath(),
true
);
const webpackOptions = await readWebpackOptions(webpackConfig);
const outputs = [];
for (const config of webpackOptions) {
if (config.output?.path) {
outputs.push(normalizeOutputPath(config.output.path, projectRoot));
}
}
const targets: Record<string, TargetConfiguration> = {};
targets[options.buildTargetName] = {
command: `webpack-cli build`,
options: { cwd: projectRoot, env: { NODE_ENV: 'production' } },
cache: true,
dependsOn: [`^${options.buildTargetName}`],
inputs:
'production' in namedInputs
? [
'production',
'^production',
{
externalDependencies: ['webpack-cli'],
},
TS_SOLUTION_SETUP_TSCONFIG_INPUT,
]
: [
'default',
'^default',
{
externalDependencies: ['webpack-cli'],
},
TS_SOLUTION_SETUP_TSCONFIG_INPUT,
],
outputs,
metadata: {
technologies: ['webpack'],
description: 'Runs Webpack build',
help: {
command: `${pmc.exec} webpack-cli build --help`,
example: {
options: {
json: 'stats.json',
},
args: ['--profile'],
},
},
},
};
targets[options.serveTargetName] = {
continuous: true,
command: `webpack-cli serve`,
options: {
cwd: projectRoot,
env: { NODE_ENV: 'development' },
},
metadata: {
technologies: ['webpack'],
description: 'Starts Webpack dev server',
help: {
command: `${pmc.exec} webpack-cli serve --help`,
example: {
options: {
args: ['--client-progress', '--history-api-fallback '],
},
},
},
},
};
targets[options.previewTargetName] = {
continuous: true,
command: `webpack-cli serve`,
options: {
cwd: projectRoot,
env: { NODE_ENV: 'production' },
},
metadata: {
technologies: ['webpack'],
description: 'Starts Webpack dev server in production mode',
help: {
command: `${pmc.exec} webpack-cli serve --help`,
example: {
options: {
args: ['--client-progress', '--history-api-fallback '],
},
},
},
},
};
targets[options.serveStaticTargetName] = {
continuous: true,
dependsOn: [options.buildTargetName],
executor: '@nx/web:file-server',
options: {
buildTarget: options.buildTargetName,
spa: true,
},
};
// for `convert-to-inferred` we need to leave the port undefined or the options will not match
if (webpackConfig.devServer?.port && webpackConfig.devServer?.port !== 4200) {
targets[options.serveStaticTargetName].options.port =
webpackConfig.devServer.port;
}
if (isTsSolutionSetup) {
targets[options.buildTargetName].syncGenerators = [
'@nx/js:typescript-sync',
];
targets[options.serveTargetName].syncGenerators = [
'@nx/js:typescript-sync',
];
targets[options.previewTargetName].syncGenerators = [
'@nx/js:typescript-sync',
];
targets[options.serveStaticTargetName].syncGenerators = [
'@nx/js:typescript-sync',
];
}
addBuildAndWatchDepsTargets(
context.workspaceRoot,
projectRoot,
targets,
options,
pmc
);
return { targets, metadata: {} };
}
function normalizeOutputPath(
outputPath: string | undefined,
projectRoot: string
): string | undefined {
if (!outputPath) {
// If outputPath is undefined, use webpack's default `dist` directory.
if (projectRoot === '.') {
return `{projectRoot}/dist`;
} else {
return `{workspaceRoot}/dist/{projectRoot}`;
}
} else {
if (isAbsolute(outputPath)) {
/**
* If outputPath is absolute, we need to resolve it relative to the workspaceRoot first.
* After that, we can use the relative path to the workspaceRoot token {workspaceRoot} to generate the output path.
*/
return `{workspaceRoot}/${relative(
workspaceRoot,
resolve(workspaceRoot, outputPath)
)}`;
} else {
if (outputPath.startsWith('..')) {
return joinPathFragments('{workspaceRoot}', projectRoot, outputPath);
} else {
return joinPathFragments('{projectRoot}', outputPath);
}
}
}
}
interface WebpackEntry {
configFile: string;
projectRoot: string;
}
async function filterWebpackConfigs(
configFiles: readonly string[],
context: CreateNodesContext
): Promise<{
entries: WebpackEntry[];
preErrors: Array<[string, Error]>;
}> {
const preErrors: Array<[string, Error]> = [];
const candidates = await Promise.all(
configFiles.map(async (configFile): Promise<WebpackEntry | null> => {
try {
const projectRoot = dirname(configFile);
const siblingFiles = readdirSync(
join(context.workspaceRoot, projectRoot)
);
if (
!siblingFiles.includes('package.json') &&
!siblingFiles.includes('project.json')
) {
return null;
}
return { configFile, projectRoot };
} catch (e) {
preErrors.push([configFile, e as Error]);
return null;
}
})
);
return {
entries: candidates.filter((c): c is WebpackEntry => c !== null),
preErrors,
};
}
function normalizeOptions(
options: WebpackPluginOptions
): Required<WebpackPluginOptions> {
return {
buildTargetName: options?.buildTargetName ?? 'build',
serveTargetName: options?.serveTargetName ?? 'serve',
serveStaticTargetName: options?.serveStaticTargetName ?? 'serve-static',
previewTargetName: options?.previewTargetName ?? 'preview',
buildDepsTargetName: 'build-deps',
watchDepsTargetName: 'watch-deps',
};
}
@@ -0,0 +1,12 @@
import type { Compiler } from 'webpack';
export class StatsJsonPlugin {
apply(compiler: Compiler) {
const { sources } = require('webpack') as typeof import('webpack');
compiler.hooks.emit.tap('StatsJsonPlugin', (compilation) => {
const data = JSON.stringify(compilation.getStats().toJson('verbose'));
compilation.assets[`stats.json`] = new sources.RawSource(data);
});
}
}
@@ -0,0 +1,89 @@
import {
type ExecutorContext,
readCachedProjectGraph,
readProjectsConfigurationFromProjectGraph,
workspaceRoot,
} from '@nx/devkit';
import type { NxWebpackExecutionContext } from '../../utils/config';
import type { NxAppWebpackPluginOptions } from '../nx-webpack-plugin/nx-app-webpack-plugin-options';
import type { Compiler, Configuration } from 'webpack';
import { normalizeOptions } from '../nx-webpack-plugin/lib/normalize-options';
import { readNxJson } from 'nx/src/config/configuration';
/**
* This function is used to wrap the legacy plugin function to be used with the `composePlugins` function.
* Initially the webpack config would be passed to the legacy plugin function and the options would be passed as a second argument.
* example:
* module.exports = composePlugins(
withNx(),
(config) => {
return config;
}
);
Since composePlugins is async, this function is used to wrap the legacy plugin function to be async.
Using the nxUseLegacyPlugin function, the first argument is the legacy plugin function and the second argument is the options.
The context options are created and passed to the legacy plugin function.
module.exports = async () => ({
plugins: [
...otherPlugins,
await nxUseLegacyPlugin(require({path}), options),
],
});
* @param fn The legacy plugin function usually from `combinedPlugins`
* @param executorOptions The options passed usually inside the executor or the config file
* @returns Webpack configuration
*/
export async function useLegacyNxPlugin(
fn: (
config: Configuration,
ctx: NxWebpackExecutionContext
) => Promise<Configuration>,
executorOptions: NxAppWebpackPluginOptions
) {
if (global.NX_GRAPH_CREATION) {
return;
}
const options = normalizeOptions(executorOptions);
const projectGraph = readCachedProjectGraph();
const projectName = process.env.NX_TASK_TARGET_PROJECT;
const project = projectGraph.nodes[projectName];
const targetName = process.env.NX_TASK_TARGET_TARGET;
const context: ExecutorContext = {
cwd: process.cwd(),
isVerbose: process.env.NX_VERBOSE_LOGGING === 'true',
root: workspaceRoot,
projectGraph,
projectsConfigurations:
readProjectsConfigurationFromProjectGraph(projectGraph),
nxJsonConfiguration: readNxJson(workspaceRoot),
target: project.data.targets[targetName],
targetName: targetName,
projectName: projectName,
};
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION;
const ctx: NxWebpackExecutionContext = {
context,
options: options as NxWebpackExecutionContext['options'],
configuration,
};
return {
apply(compiler: Compiler) {
compiler.hooks.beforeCompile.tapPromise('NxLegacyAsyncPlugin', () => {
return new Promise<void>((resolve) => {
fn(compiler.options as Configuration, ctx).then((updated) => {
// Merge options back shallowly since it's a fully functional configuration.
// Most likely, the user modified the config in place, but this guarantees that updates are applied if users did something like:
// `return { ...config, plugins: [...config.plugins, new MyPlugin()] }`
Object.assign(compiler.options, updated);
resolve();
});
});
});
},
};
}
@@ -0,0 +1,132 @@
import { exec } from 'child_process';
import type { Compiler } from 'webpack';
import { daemonClient, isDaemonEnabled } from 'nx/src/daemon/client/client';
import { BatchFunctionRunner } from 'nx/src/command-line/watch/watch';
import { output } from 'nx/src/utils/output';
type PluginOptions = {
skipInitialBuild?: boolean;
skipWatchingDeps?: boolean;
};
export class WebpackNxBuildCoordinationPlugin {
private currentlyRunning: 'none' | 'nx-build' | 'webpack-build' = 'none';
private buildCmdProcess: ReturnType<typeof exec> | null = null;
constructor(buildCmd: string);
/**
* @deprecated Use the constructor with the `options` parameter instead.
*/
constructor(buildCmd: string, skipInitialBuild?: boolean);
constructor(buildCmd: string, options?: PluginOptions);
constructor(
private readonly buildCmd: string,
skipInitialBuildOrOptions?: boolean | PluginOptions
) {
const options =
typeof skipInitialBuildOrOptions === 'boolean'
? { skipInitialBuild: skipInitialBuildOrOptions }
: skipInitialBuildOrOptions;
if (!options?.skipInitialBuild) {
this.buildChangedProjects();
}
if (!options?.skipWatchingDeps) {
if (isDaemonEnabled()) {
this.startWatchingBuildableLibs();
} else {
output.warn({
title:
'Nx Daemon is not enabled. Buildable libs will not be rebuilt on file changes.',
});
}
}
}
apply(compiler: Compiler) {
compiler.hooks.beforeCompile.tapPromise(
'IncrementalDevServerPlugin',
async () => {
while (this.currentlyRunning === 'nx-build') {
await sleep(50);
}
this.currentlyRunning = 'webpack-build';
}
);
compiler.hooks.done.tapPromise('IncrementalDevServerPlugin', async () => {
this.currentlyRunning = 'none';
});
}
async startWatchingBuildableLibs() {
const unregisterFileWatcher = await this.createFileWatcher();
process.on('exit', () => {
unregisterFileWatcher();
});
}
async buildChangedProjects() {
while (this.currentlyRunning === 'webpack-build') {
await sleep(50);
}
this.currentlyRunning = 'nx-build';
try {
return await new Promise<void>((res) => {
this.buildCmdProcess = exec(this.buildCmd, {
windowsHide: true,
});
this.buildCmdProcess.stdout.pipe(process.stdout);
this.buildCmdProcess.stderr.pipe(process.stderr);
this.buildCmdProcess.on('exit', () => {
res();
});
this.buildCmdProcess.on('error', () => {
res();
});
});
} finally {
this.currentlyRunning = 'none';
this.buildCmdProcess = null;
}
}
private createFileWatcher() {
const runner = new BatchFunctionRunner(() => this.buildChangedProjects());
return daemonClient.registerFileWatcher(
{
watchProjects: 'all',
},
(err, { changedProjects, changedFiles }) => {
if (err === 'reconnecting') {
// Silent - daemon restarts automatically on lockfile changes
return;
} else if (err === 'reconnected') {
// Silent - reconnection succeeded
return;
} else if (err === 'closed') {
output.error({
title: 'Failed to reconnect to daemon after multiple attempts',
});
process.exit(1);
} else if (err) {
output.error({
title: `Watch error: ${err?.message ?? 'Unknown'}`,
});
}
if (this.buildCmdProcess) {
this.buildCmdProcess.kill(2);
this.buildCmdProcess = null;
}
// Queue a build
runner.enqueue(changedProjects, changedFiles);
}
);
}
}
function sleep(time: number) {
return new Promise((resolve) => setTimeout(resolve, time));
}
@@ -0,0 +1,381 @@
import type { Compilation, Compiler, sources } from 'webpack';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { EmittedFile, ExtraEntryPoint } from '../utils/models';
import { interpolateEnvironmentVariablesToIndex } from '../utils/webpack/interpolate-env-variables-to-index';
import { generateEntryPoints } from '../utils/webpack/package-chunk-sort';
import { extname } from 'path';
const parse5 = require('parse5');
export interface WriteIndexHtmlOptions {
indexPath: string;
outputPath: string;
baseHref?: string;
deployUrl?: string;
sri?: boolean;
scripts?: ExtraEntryPoint[];
styles?: ExtraEntryPoint[];
crossOrigin?: 'none' | 'anonymous' | 'use-credentials';
}
export class WriteIndexHtmlPlugin {
constructor(private readonly options: WriteIndexHtmlOptions) {}
apply(compiler: Compiler) {
const webpack = require('webpack') as typeof import('webpack');
const {
outputPath,
indexPath,
baseHref,
deployUrl,
sri = false,
scripts = [],
styles = [],
crossOrigin,
} = this.options;
compiler.hooks.thisCompilation.tap(
'WriteIndexHtmlPlugin',
(compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'WriteIndexHtmlPlugin',
// After minification and sourcemaps are done
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE,
},
() => {
const moduleFiles = this.getEmittedFiles(compilation);
const files = moduleFiles.filter((x) => x.extension === '.css');
let content = readFileSync(indexPath).toString();
content = this.stripBom(content);
compilation.assets[outputPath] = this.augmentIndexHtml({
input: outputPath,
inputContent: interpolateEnvironmentVariablesToIndex(
content,
deployUrl
),
baseHref,
deployUrl,
crossOrigin,
sri,
entrypoints: generateEntryPoints({ scripts, styles }),
files: this.filterAndMapBuildFiles(files, ['.js', '.css']),
moduleFiles: this.filterAndMapBuildFiles(moduleFiles, ['.js']),
loadOutputFile: (filePath) =>
compilation.assets[filePath].source().toString(),
});
}
);
}
);
}
private getEmittedFiles(compilation: Compilation): EmittedFile[] {
const files: EmittedFile[] = [];
// adds all chunks to the list of emitted files such as lazy loaded modules
for (const chunk of compilation.chunks) {
for (const file of chunk.files) {
files.push({
// The id is guaranteed to exist at this point in the compilation process
id: chunk.id.toString(),
name: chunk.name,
file,
extension: extname(file),
initial: chunk.isOnlyInitial(),
});
}
}
// other all files
for (const file of Object.keys(compilation.assets)) {
files.push({
file,
extension: extname(file),
initial: false,
asset: true,
});
}
// dedupe
return files.filter(
({ file, name }, index) =>
files.findIndex(
(f) => f.file === file && (!name || name === f.name)
) === index
);
}
private stripBom(data: string) {
return data.replace(/^\uFEFF/, '');
}
private augmentIndexHtml(params: {
/* Input file name (e. g. index.html) */
input: string;
/* Input contents */
inputContent: string;
baseHref?: string;
deployUrl?: string;
sri: boolean;
/** crossorigin attribute setting of elements that provide CORS support */
crossOrigin?: 'none' | 'anonymous' | 'use-credentials';
/*
* Files emitted by the build.
*/
files: {
file: string;
name: string;
extension: string;
}[];
/** Files that should be added using 'module'. */
moduleFiles?: {
file: string;
name: string;
extension: string;
}[];
/*
* Function that loads a file used.
* This allows us to use different routines within the IndexHtmlWebpackPlugin and
* when used without this plugin.
*/
loadOutputFile: (file: string) => string;
/** Used to sort the inseration of files in the HTML file */
entrypoints: string[];
}): sources.Source {
const webpack = require('webpack') as typeof import('webpack');
const { loadOutputFile, files, moduleFiles = [], entrypoints } = params;
let { crossOrigin = 'none' } = params;
if (params.sri && crossOrigin === 'none') {
crossOrigin = 'anonymous';
}
const stylesheets = new Set<string>();
const scripts = new Set<string>();
// Sort files in the order we want to insert them by entrypoint and dedupes duplicates
const mergedFiles = [...moduleFiles, ...files];
for (const entrypoint of entrypoints) {
for (const { extension, file, name } of mergedFiles) {
if (name !== entrypoint) {
continue;
}
switch (extension) {
case '.js':
scripts.add(file);
break;
case '.css':
stylesheets.add(file);
break;
}
}
}
// Find the head and body elements
const treeAdapter = parse5.treeAdapters.default;
const document = parse5.parse(params.inputContent, {
treeAdapter,
locationInfo: true,
});
let headElement;
let bodyElement;
for (const docChild of document.childNodes) {
if (docChild.tagName === 'html') {
for (const htmlChild of docChild.childNodes) {
if (htmlChild.tagName === 'head') {
headElement = htmlChild;
} else if (htmlChild.tagName === 'body') {
bodyElement = htmlChild;
}
}
}
}
if (!headElement || !bodyElement) {
throw new Error('Missing head and/or body elements');
}
// Determine script insertion point
let scriptInsertionPoint;
if (bodyElement.__location && bodyElement.__location.endTag) {
scriptInsertionPoint = bodyElement.__location.endTag.startOffset;
} else {
// Less accurate fallback
// parse5 4.x does not provide locations if malformed html is present
scriptInsertionPoint = params.inputContent.indexOf('</body>');
}
let styleInsertionPoint;
if (headElement.__location && headElement.__location.endTag) {
styleInsertionPoint = headElement.__location.endTag.startOffset;
} else {
// Less accurate fallback
// parse5 4.x does not provide locations if malformed html is present
styleInsertionPoint = params.inputContent.indexOf('</head>');
}
// Inject into the html
const indexSource = new webpack.sources.ReplaceSource(
new webpack.sources.RawSource(params.inputContent, false),
params.input
);
let scriptElements = '';
for (const script of scripts) {
const attrs: { name: string; value: string | null }[] = [
{ name: 'src', value: (params.deployUrl || '') + script },
];
if (crossOrigin !== 'none') {
attrs.push({ name: 'crossorigin', value: crossOrigin });
}
// We want to include nomodule or module when a file is not common amongs all
// such as runtime.js
const scriptPredictor = ({
file,
}: {
file: string;
name: string;
extension: string;
}): boolean => file === script;
if (!files.some(scriptPredictor)) {
// in some cases for differential loading file with the same name is avialable in both
// nomodule and module such as scripts.js
// we shall not add these attributes if that's the case
const isModuleType = moduleFiles.some(scriptPredictor);
if (isModuleType) {
attrs.push({ name: 'type', value: 'module' });
} else {
attrs.push({ name: 'defer', value: null });
}
} else {
attrs.push({ name: 'type', value: 'module' });
}
if (params.sri) {
const content = loadOutputFile(script);
attrs.push(...this.generateSriAttributes(content));
}
const attributes = attrs
.map((attr) =>
attr.value === null ? attr.name : `${attr.name}="${attr.value}"`
)
.join(' ');
scriptElements += `<script ${attributes}></script>`;
}
indexSource.insert(scriptInsertionPoint, scriptElements);
// Adjust base href if specified
if (typeof params.baseHref == 'string') {
let baseElement;
for (const headChild of headElement.childNodes) {
if (headChild.tagName === 'base') {
baseElement = headChild;
}
}
const baseFragment = treeAdapter.createDocumentFragment();
if (!baseElement) {
baseElement = treeAdapter.createElement('base', undefined, [
{ name: 'href', value: params.baseHref },
]);
treeAdapter.appendChild(baseFragment, baseElement);
indexSource.insert(
headElement.__location.startTag.endOffset,
parse5.serialize(baseFragment, { treeAdapter })
);
} else {
let hrefAttribute;
for (const attribute of baseElement.attrs) {
if (attribute.name === 'href') {
hrefAttribute = attribute;
}
}
if (hrefAttribute) {
hrefAttribute.value = params.baseHref;
} else {
baseElement.attrs.push({ name: 'href', value: params.baseHref });
}
treeAdapter.appendChild(baseFragment, baseElement);
indexSource.replace(
baseElement.__location.startOffset,
baseElement.__location.endOffset,
parse5.serialize(baseFragment, { treeAdapter })
);
}
}
const styleElements = treeAdapter.createDocumentFragment();
for (const stylesheet of stylesheets) {
const attrs = [
{ name: 'rel', value: 'stylesheet' },
{ name: 'href', value: (params.deployUrl || '') + stylesheet },
];
if (crossOrigin !== 'none') {
attrs.push({ name: 'crossorigin', value: crossOrigin });
}
if (params.sri) {
const content = loadOutputFile(stylesheet);
attrs.push(...this.generateSriAttributes(content));
}
const element = treeAdapter.createElement('link', undefined, attrs);
treeAdapter.appendChild(styleElements, element);
}
indexSource.insert(
styleInsertionPoint,
parse5.serialize(styleElements, { treeAdapter })
);
return indexSource;
}
private generateSriAttributes(content: string) {
const algo = 'sha384';
const hash = createHash(algo).update(content, 'utf8').digest('base64');
return [{ name: 'integrity', value: `${algo}-${hash}` }];
}
private filterAndMapBuildFiles(
files: EmittedFile[],
extensionFilter: string[]
): {
file: string;
name: string;
extension: string;
}[] {
const filteredFiles: {
file: string;
name: string;
extension: string;
}[] = [];
// This test excludes files generated by HMR (e.g. main.hot-update.js).
const hotUpdateAsset = /hot-update\.[cm]?js$/;
for (const { file, name, extension, initial } of files) {
if (
name &&
initial &&
extensionFilter.includes(extension) &&
!hotUpdateAsset.test(file)
) {
filteredFiles.push({ file, extension, name });
}
}
return filteredFiles;
}
}
+73
View File
@@ -0,0 +1,73 @@
import {
composePluginsSync,
composePlugins,
NxWebpackExecutionContext,
isNxWebpackComposablePlugin,
} from './config';
describe('composePlugins', () => {
it('should support sync and async plugin functions', async () => {
const callOrder = [];
const a = () => (config) => {
callOrder.push('a');
config.plugins.push(new (class A {})());
return config;
};
const b = async () => (config) => {
callOrder.push('b');
config.plugins.push(new (class B {})());
return config;
};
const c = () => async (config) => {
callOrder.push('c');
config.plugins.push(new (class C {})());
return config;
};
const d = async () => async (config) => {
callOrder.push('d');
config.plugins.push(new (class D {})());
return config;
};
const combined = composePlugins(a(), b(), c(), d());
expect(isNxWebpackComposablePlugin(combined)).toBeTruthy();
const config = await combined(
{ plugins: [] },
{} as NxWebpackExecutionContext
);
expect(config.plugins.map((p) => p.constructor.name)).toEqual([
'A',
'B',
'C',
'D',
]);
expect(callOrder).toEqual(['a', 'b', 'c', 'd']);
});
});
describe('composePluginsSync', () => {
it('should support sync plugin functions', async () => {
const callOrder = [];
const a = () => (config) => {
callOrder.push('a');
config.plugins.push(new (class A {})());
return config;
};
const b = () => (config) => {
callOrder.push('b');
config.plugins.push(new (class B {})());
return config;
};
const combined = composePluginsSync(a(), b());
expect(isNxWebpackComposablePlugin(combined)).toBeTruthy();
const config = await combined(
{ plugins: [] },
{} as NxWebpackExecutionContext
);
expect(config.plugins.map((p) => p.constructor.name)).toEqual(['A', 'B']);
expect(callOrder).toEqual(['a', 'b']);
});
});
+132
View File
@@ -0,0 +1,132 @@
import {
ExecutorContext,
readCachedProjectGraph,
readProjectsConfigurationFromProjectGraph,
workspaceRoot,
} from '@nx/devkit';
import { getProjectSourceRoot } from '@nx/js/internal';
import { readNxJson } from 'nx/src/config/configuration';
import type { Configuration } from 'webpack';
import { NormalizedWebpackExecutorOptions } from '../executors/webpack/schema';
import { warnWebpackComposeHelpersDeprecation } from './deprecation';
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 NxComposableWebpackPlugin {
(config: Configuration, ctx: NxWebpackExecutionContext): Configuration;
}
export interface AsyncNxComposableWebpackPlugin {
(
config: Configuration,
ctx: NxWebpackExecutionContext
): Configuration | Promise<Configuration>;
}
/**
* @deprecated Will be removed in Nx v24. Use `NxAppWebpackPlugin` from
* `@nx/webpack/app-plugin` in a standard webpack config and run
* `nx g @nx/webpack:convert-to-inferred`. See
* https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.
*/
export function composePlugins(
...plugins: (
| NxComposableWebpackPlugin
| AsyncNxComposableWebpackPlugin
| Promise<NxComposableWebpackPlugin | AsyncNxComposableWebpackPlugin>
)[]
) {
warnWebpackComposeHelpersDeprecation();
return Object.assign(
async function combined(
config: Configuration,
ctx: NxWebpackExecutionContext
): Promise<Configuration> {
// 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,
}
);
}
/**
* @deprecated Will be removed in Nx v24. Use `NxAppWebpackPlugin` from
* `@nx/webpack/app-plugin` in a standard webpack config and run
* `nx g @nx/webpack:convert-to-inferred`. See
* https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.
*/
export function composePluginsSync(...plugins: NxComposableWebpackPlugin[]) {
warnWebpackComposeHelpersDeprecation();
return Object.assign(
function combined(
config: Configuration,
ctx: NxWebpackExecutionContext
): Configuration {
for (const plugin of plugins) {
config = plugin(config, ctx);
}
return config;
},
{
[nxWebpackComposablePlugin]: true,
}
);
}
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: getProjectSourceRoot(projectNode.data),
// These aren't actually needed since NxWebpackPlugin and withNx both support them being undefined.
assets: undefined,
outputPath: undefined,
tsConfig: undefined,
outputFileName: undefined,
useTsconfigPaths: undefined,
};
ctx.context ??= {
projectName,
targetName,
configurationName,
projectsConfigurations:
readProjectsConfigurationFromProjectGraph(projectGraph),
nxJsonConfiguration: readNxJson(workspaceRoot),
cwd: process.cwd(),
root: workspaceRoot,
isVerbose: process.env['NX_VERBOSE_LOGGING'] === 'true',
projectGraph,
};
}
@@ -0,0 +1,27 @@
import { AssetGlobPattern } from '../executors/webpack/schema';
export function createCopyPlugin(assets: AssetGlobPattern[]) {
const CopyWebpackPlugin =
require('copy-webpack-plugin') as typeof import('copy-webpack-plugin');
return new CopyWebpackPlugin({
patterns: assets.map((asset) => {
return {
context: asset.input,
// Now we remove starting slash to make Webpack place it from the output root.
to: asset.output,
from: asset.glob,
globOptions: {
ignore: [
'.gitkeep',
'**/.DS_Store',
'**/Thumbs.db',
...(asset.ignore ?? []),
],
dot: true,
},
noErrorOnMissing: true,
};
}),
});
}
@@ -0,0 +1,60 @@
describe('@nx/webpack compose helpers deprecation', () => {
// Each test runs in an isolated module registry so the warn-once flag and the
// logger spy resolve to the same fresh instances.
function setup() {
let warn!: jest.SpyInstance;
let mod!: typeof import('./deprecation');
jest.isolateModules(() => {
const { logger } = require('@nx/devkit');
warn = jest.spyOn(logger, 'warn').mockImplementation(() => {});
mod = require('./deprecation');
});
return { warn, mod };
}
it('warns once per process even when several helpers are composed', () => {
const { warn, mod } = setup();
mod.warnWebpackComposeHelpersDeprecation();
mod.warnWebpackComposeHelpersDeprecation();
mod.warnWebpackComposeHelpersDeprecation();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('@nx/webpack');
expect(warn.mock.calls[0][0]).toContain('convert-to-inferred');
});
it('does not warn when called inside a suppression scope', () => {
const { warn, mod } = setup();
mod.suppressWebpackComposeHelperWarnings(() =>
mod.warnWebpackComposeHelpersDeprecation()
);
expect(warn).not.toHaveBeenCalled();
});
it('restores warning after the suppression scope exits', () => {
const { warn, mod } = setup();
mod.suppressWebpackComposeHelperWarnings(() =>
mod.warnWebpackComposeHelpersDeprecation()
);
mod.warnWebpackComposeHelpersDeprecation();
expect(warn).toHaveBeenCalledTimes(1);
});
it('warns when a user constructs withNx', () => {
let warn!: jest.SpyInstance;
jest.isolateModules(() => {
const { logger } = require('@nx/devkit');
warn = jest.spyOn(logger, 'warn').mockImplementation(() => {});
const { withNx } = require('./with-nx');
withNx();
});
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('@nx/webpack');
});
});
+59
View File
@@ -0,0 +1,59 @@
import { logger } from '@nx/devkit';
// TODO(v24): Remove the @nx/webpack:webpack and @nx/webpack:dev-server
// executors. The inferred plugin (@nx/webpack/plugin) and the
// convert-to-inferred generator stay supported.
export const WEBPACK_EXECUTOR_DEPRECATION_MESSAGE =
'The `@nx/webpack:webpack` executor is deprecated and will be removed in Nx v24. Run `nx g @nx/webpack:convert-to-inferred` to migrate to the `@nx/webpack/plugin` inferred targets. See https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.';
export const WEBPACK_DEV_SERVER_EXECUTOR_DEPRECATION_MESSAGE =
'The `@nx/webpack:dev-server` executor is deprecated and will be removed in Nx v24. Run `nx g @nx/webpack:convert-to-inferred` to migrate to the `@nx/webpack/plugin` inferred targets. See https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.';
export function warnWebpackExecutorDeprecation(): void {
logger.warn(WEBPACK_EXECUTOR_DEPRECATION_MESSAGE);
}
export function warnWebpackDevServerExecutorDeprecation(): void {
logger.warn(WEBPACK_DEV_SERVER_EXECUTOR_DEPRECATION_MESSAGE);
}
// Fired when the @nx/webpack:configuration generator is about to generate
// targets that use the deprecated executors — i.e. when @nx/webpack/plugin
// isn't registered in nx.json. Surfaces the deprecation at generation time
// rather than only when the user later runs the generated targets.
export function warnWebpackExecutorGenerating(): void {
logger.warn(
'Generating targets that use the deprecated `@nx/webpack:webpack` and `@nx/webpack:dev-server` executors. These executors will be removed in Nx v24. Run `nx g @nx/webpack:convert-to-inferred` next to migrate these targets to the `@nx/webpack/plugin` inferred plugin and prevent future generators from emitting executor targets. See https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.'
);
}
// TODO(v24): Remove the composePlugins/withNx/withWeb config helpers. They emit
// an Nx-specific config function that only runs under the @nx/webpack:webpack
// executor; the inferred @nx/webpack/plugin works with standard webpack configs
// built around NxAppWebpackPlugin instead.
export const WEBPACK_COMPOSE_HELPERS_DEPRECATION_MESSAGE =
'The `composePlugins`, `withNx`, and `withWeb` config helpers from `@nx/webpack` are deprecated and will be removed in Nx v24. They produce an Nx-specific config function that only runs under the `@nx/webpack:webpack` executor. Migrate to a standard webpack config that uses `NxAppWebpackPlugin` (from `@nx/webpack/app-plugin`) under the inferred `@nx/webpack/plugin` by running `nx g @nx/webpack:convert-to-inferred`. See https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.';
let composeHelpersWarned = false;
let suppressDepth = 0;
// Nx-internal entry points compose these helpers themselves (e.g. the rspack
// executor, the storybook/component-testing presets). They wrap their
// synchronous composition in this so the warning fires only for user-authored
// configs, not for users who never touched the compose helpers.
export function suppressWebpackComposeHelperWarnings<T>(fn: () => T): T {
suppressDepth++;
try {
return fn();
} finally {
suppressDepth--;
}
}
// Warn once per process so a `composePlugins(withNx(), withWeb())` chain logs a
// single line, not one per helper, and HMR reloads don't repeat it.
export function warnWebpackComposeHelpersDeprecation(): void {
if (suppressDepth > 0 || composeHelpersWarned) return;
composeHelpersWarned = true;
logger.warn(WEBPACK_COMPOSE_HELPERS_DEPRECATION_MESSAGE);
}
@@ -0,0 +1,216 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { type Tree, readNxJson, updateNxJson } from 'nx/src/devkit-exports';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { getWebpackE2EWebServerInfo } from './e2e-web-server-info-utils';
describe('getWebpackE2EWebServerInfo', () => {
let tree: Tree;
let tempFs: TempFs;
beforeEach(() => {
tempFs = new TempFs('e2e-webserver-info');
tree = createTreeWithEmptyWorkspace();
tree.root = tempFs.tempDir;
tree.write(`app/webpack.config.ts`, ``);
tempFs.createFileSync(`app/webpack.config.ts`, ``);
tempFs.createFileSync('package-lock.json', '{}');
});
afterEach(() => {
tempFs.cleanup();
jest.resetModules();
});
it('should use the default values when no plugin is registered and plugins are not being used', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getWebpackE2EWebServerInfo(
tree,
'app',
'app/webpack.config.ts',
false
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4200",
"e2eCiWebServerCommand": "npx nx run app:serve-static",
"e2eDevServerTarget": "app:serve",
"e2eWebServerAddress": "http://localhost:4200",
"e2eWebServerCommand": "npx nx run app:serve",
}
`);
});
it('should use map-shaped targetDefaults when no plugin is registered and plugins are not being used', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.targetDefaults = {
serve: {
options: {
port: 4400,
},
},
};
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getWebpackE2EWebServerInfo(
tree,
'app',
'app/webpack.config.ts',
false
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4400",
"e2eCiWebServerCommand": "npx nx run app:serve-static",
"e2eDevServerTarget": "app:serve",
"e2eWebServerAddress": "http://localhost:4400",
"e2eWebServerCommand": "npx nx run app:serve",
}
`);
});
it('should use the default values of the plugin when the plugin is just a string', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins = ['@nx/webpack/plugin'];
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getWebpackE2EWebServerInfo(
tree,
'app',
'app/webpack.config.ts',
true
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4200",
"e2eCiWebServerCommand": "npx nx run app:serve-static",
"e2eDevServerTarget": "app:serve",
"e2eWebServerAddress": "http://localhost:4200",
"e2eWebServerCommand": "npx nx run app:serve",
}
`);
});
it('should use map-shaped targetDefaults when the plugin is just a string', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins = ['@nx/webpack/plugin'];
nxJson.targetDefaults = {
serve: {
options: {
port: 4500,
},
},
};
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getWebpackE2EWebServerInfo(
tree,
'app',
'app/webpack.config.ts',
true
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4500",
"e2eCiWebServerCommand": "npx nx run app:serve-static",
"e2eDevServerTarget": "app:serve",
"e2eWebServerAddress": "http://localhost:4500",
"e2eWebServerCommand": "npx nx run app:serve",
}
`);
});
it('should use the values of the registered plugin when there is no includes or excludes defined', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/webpack/plugin',
options: {
serveTargetName: 'webpack:serve',
serveStaticTargetName: 'webpack:preview',
},
});
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getWebpackE2EWebServerInfo(
tree,
'app',
'app/webpack.config.ts',
true
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4200",
"e2eCiWebServerCommand": "npx nx run app:webpack:preview",
"e2eDevServerTarget": "app:webpack:serve",
"e2eWebServerAddress": "http://localhost:4200",
"e2eWebServerCommand": "npx nx run app:webpack:serve",
}
`);
});
it('should use the values of the correct registered plugin when there are includes or excludes defined', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/webpack/plugin',
options: {
serveTargetName: 'webpack:serve',
serveStaticTargetName: 'webpack:preview',
},
include: ['libs/**'],
});
nxJson.plugins.push({
plugin: '@nx/webpack/plugin',
options: {
serveTargetName: 'webpack-serve',
serveStaticTargetName: 'webpack-preview',
},
include: ['app/**'],
});
updateNxJson(tree, nxJson);
// ACT
const e2eWebServerInfo = await getWebpackE2EWebServerInfo(
tree,
'app',
'app/webpack.config.ts',
true
);
// ASSERT
expect(e2eWebServerInfo).toMatchInlineSnapshot(`
{
"e2eCiBaseUrl": "http://localhost:4200",
"e2eCiWebServerCommand": "npx nx run app:webpack-preview",
"e2eDevServerTarget": "app:webpack-serve",
"e2eWebServerAddress": "http://localhost:4200",
"e2eWebServerCommand": "npx nx run app:webpack-serve",
}
`);
});
});
@@ -0,0 +1,41 @@
import { type Tree, readNxJson } from '@nx/devkit';
import {
getE2EWebServerInfo,
readTargetDefaultsForTarget,
} from '@nx/devkit/internal';
export async function getWebpackE2EWebServerInfo(
tree: Tree,
projectName: string,
configFilePath: string,
isPluginBeingAdded: boolean,
e2ePortOverride?: number
) {
const nxJson = readNxJson(tree);
let e2ePort = e2ePortOverride ?? 4200;
const servePort = readTargetDefaultsForTarget('serve', nxJson.targetDefaults)
?.options?.port;
if (servePort) {
e2ePort = servePort;
}
return getE2EWebServerInfo(
tree,
projectName,
{
plugin: '@nx/webpack/plugin',
serveTargetName: 'serveTargetName',
serveStaticTargetName: 'serveStaticTargetName',
configFilePath,
},
{
defaultServeTargetName: 'serve',
defaultServeStaticTargetName: 'serve-static',
defaultE2EWebServerAddress: `http://localhost:${e2ePort}`,
defaultE2ECiBaseUrl: `http://localhost:${e2ePort}`,
defaultE2EPort: e2ePort,
},
isPluginBeingAdded
);
}
@@ -0,0 +1,50 @@
import {
addDependenciesToPackageJson,
runTasksInSerial,
type GeneratorCallback,
type Tree,
} from '@nx/devkit';
import { addSwcDependencies } from '@nx/js/internal';
import {
reactRefreshVersion,
reactRefreshWebpackPluginVersion,
svgrWebpackVersion,
swcLoaderVersion,
tsLibVersion,
} from './versions';
export type EnsureDependenciesOptions = {
compiler?: 'swc' | 'tsc';
uiFramework?: 'none' | 'react';
};
export function ensureDependencies(
tree: Tree,
options: EnsureDependenciesOptions
) {
const tasks: GeneratorCallback[] = [];
const devDependencies: Record<string, string> = {};
if (options.compiler === 'swc') {
devDependencies['swc-loader'] = swcLoaderVersion;
const addSwcTask = addSwcDependencies(tree);
tasks.push(addSwcTask);
}
if (options.compiler === 'tsc') {
devDependencies['tslib'] = tsLibVersion;
}
if (options.uiFramework === 'react') {
devDependencies['@pmmmwh/react-refresh-webpack-plugin'] =
reactRefreshWebpackPluginVersion;
devDependencies['@svgr/webpack'] = svgrWebpackVersion;
devDependencies['react-refresh'] = reactRefreshVersion;
}
tasks.push(
addDependenciesToPackageJson(tree, {}, devDependencies, undefined, true)
);
return runTasksInSerial(...tasks);
}
+67
View File
@@ -0,0 +1,67 @@
import * as path from 'path';
import { existsSync, rmSync } from 'fs';
import { directoryExists } from 'nx/src/utils/fileutils';
export function findUp(
names: string | string[],
from: string,
stopOnNodeModules = false
) {
if (!Array.isArray(names)) {
names = [names];
}
const root = path.parse(from).root;
let currentDir = from;
while (currentDir && currentDir !== root) {
for (const name of names) {
const p = path.join(currentDir, name);
if (existsSync(p)) {
return p;
}
}
if (stopOnNodeModules) {
const nodeModuleP = path.join(currentDir, 'node_modules');
if (existsSync(nodeModuleP)) {
return null;
}
}
currentDir = path.dirname(currentDir);
}
return null;
}
export function findAllNodeModules(from: string, root?: string) {
const nodeModules: string[] = [];
let current = from;
while (current && current !== root) {
const potential = path.join(current, 'node_modules');
if (directoryExists(potential)) {
nodeModules.push(potential);
}
const next = path.dirname(current);
if (next === current) {
break;
}
current = next;
}
return nodeModules;
}
/**
* Delete an output directory, but error out if it's the root of the project.
*/
export function deleteOutputDir(root: string, outputPath: string) {
const resolvedOutputPath = path.resolve(root, outputPath);
if (resolvedOutputPath === root) {
throw new Error('Output path MUST not be project root directory!');
}
rmSync(resolvedOutputPath, { recursive: true, force: true });
}
@@ -0,0 +1,38 @@
export function getClientEnvironment(mode?: string) {
// Grab NODE_ENV and NX_PUBLIC_* environment variables and prepare them to be
// injected into the application via DefinePlugin in webpack configuration.
const nxPublicKeyRegex = /^NX_PUBLIC_/i;
const raw = Object.keys(process.env)
.filter((key) => nxPublicKeyRegex.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
// If mode is undefined or is dev or prod then webpack already defines this variable for us.
!mode || mode === 'development' || mode === 'production'
? {}
: {
NODE_ENV: process.env.NODE_ENV,
}
);
// Stringify all values so we can feed into webpack DefinePlugin
const stringified = Object.keys(raw).reduce(
(env, key) => {
env[`process.env.${key}`] = JSON.stringify(raw[key]);
return env;
},
// Provide a fallback for process.env itself to handle cases where code
// accesses process.env directly (e.g., in Cypress component testing)
{
'process.env': Object.keys(raw).reduce((env, key) => {
env[key] = JSON.stringify(raw[key]);
return env;
}, {}),
}
);
return { stringified };
}
@@ -0,0 +1,31 @@
import { posix } from 'path';
import { getHashDigest, interpolateName } from 'loader-utils';
export function getCSSModuleLocalIdent(
ctx,
localIdentName,
localName,
options
) {
// Use the filename or folder name, based on some uses the index.js / index.module.(css|scss|sass) project style
const fileNameOrFolder = ctx.resourcePath.match(
/index\.module\.(css|scss|sass)$/
)
? '[folder]'
: '[name]';
// Create a hash based on a the file location and class name. Will be unique across a project, and close to globally unique.
const hash = getHashDigest(
posix.relative(ctx.rootContext, ctx.resourcePath) + localName,
'md5',
'base64',
5
);
// Use loaderUtils to find the file or folder name
const className = interpolateName(
ctx,
`${fileNameOrFolder}_${localName}__${hash}`,
options
);
// Remove the .module that appears in every classname when based on the file and replace all "." with "_".
return className.replace('.module_', '_').replace(/\./g, '_');
}
+10
View File
@@ -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'
);
}
+26
View File
@@ -0,0 +1,26 @@
export interface HashFormat {
chunk: string;
extract: string;
file: string;
script: string;
}
export function getOutputHashFormat(option: string, length = 20): HashFormat {
const hashFormats: { [option: string]: HashFormat } = {
none: { chunk: '', extract: '', file: '', script: '' },
media: { chunk: '', extract: '', file: `.[hash:${length}]`, script: '' },
bundles: {
chunk: `.[chunkhash:${length}]`,
extract: `.[contenthash:${length}]`,
file: '',
script: `.[contenthash:${length}]`,
},
all: {
chunk: `.[chunkhash:${length}]`,
extract: `.[contenthash:${length}]`,
file: `.[contenthash:${length}]`,
script: `.[contenthash:${length}]`,
},
};
return hashFormats[option] || hashFormats['none'];
}
+27
View File
@@ -0,0 +1,27 @@
export type ExtraEntryPoint = ExtraEntryPointClass | string;
export interface ExtraEntryPointClass {
bundleName?: string;
inject?: boolean;
input: string;
}
export type NormalizedEntryPoint = Required<ExtraEntryPointClass>;
export interface EmittedFile {
id?: string;
name?: string;
file: string;
extension: string;
initial: boolean;
asset?: boolean;
}
export interface CreateWebpackConfigOptions<T = any> {
root: string;
projectRoot: string;
sourceRoot?: string;
buildOptions: T;
tsConfig: any;
tsConfigPath: string;
}
+46
View File
@@ -0,0 +1,46 @@
import type { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server';
import { Observable } from 'rxjs';
export function runWebpackDevServer(
config: any,
webpack: typeof import('webpack'),
WebpackDevServer: typeof import('webpack-dev-server')
): Observable<{ stats: any; baseUrl: string }> {
return new Observable((subscriber) => {
const webpackCompiler: any = webpack(config);
let baseUrl: string;
webpackCompiler.hooks.done.tap('build-webpack', (stats) => {
subscriber.next({ stats, baseUrl });
});
const devServerConfig = (config as any).devServer || {};
const originalOnListen = devServerConfig.onListening;
devServerConfig.onListening = function (server: any) {
if (typeof originalOnListen === 'function') {
originalOnListen(server);
}
const devServerOptions: WebpackDevServerConfiguration = server.options;
baseUrl = `${server.options.https ? 'https' : 'http'}://${
server.options.host
}:${server.options.port}${devServerOptions.devMiddleware.publicPath}`;
};
const webpackServer = new WebpackDevServer(
devServerConfig,
webpackCompiler as any
);
try {
webpackServer.start().catch((err) => subscriber.error(err));
return () => webpackServer.stop();
} catch (e) {
throw new Error('Could not start start dev server');
}
});
}
+27
View File
@@ -0,0 +1,27 @@
import { join } from 'path';
import { type Tree } from '@nx/devkit';
import { assertSupportedPackageVersion } from '@nx/devkit/internal';
export const nxVersion = require(join('@nx/webpack', 'package.json')).version;
export const swcLoaderVersion = '0.1.15';
export const tsLibVersion = '^2.3.0';
// Floor for the generator-level support check. The plugin's runtime code uses
// webpack 5-only APIs (e.g. `ids.HashedModuleIdsPlugin`, `webpack.sources`,
// `experiments.cacheUnaffected`), so webpack 5 is the supported floor.
export const minSupportedWebpackVersion = '5.0.0';
// Fresh-install versions written when the package is not already present.
export const webpackVersion = '^5.101.3';
export const webpackDevServerVersion = '^5.2.1';
export const webpackCliVersion = '^7.0.0';
// React apps
export const reactRefreshWebpackPluginVersion = '^0.5.7';
export const svgrWebpackVersion = '^8.0.1';
export const reactRefreshVersion = '^0.10.0';
export function assertSupportedWebpackVersion(tree: Tree): void {
assertSupportedPackageVersion(tree, 'webpack', minSupportedWebpackVersion);
}
@@ -0,0 +1,20 @@
module.exports = require('babel-loader').custom(() => {
return {
customOptions({ isTest, isModern, emitDecoratorMetadata, ...loader }) {
return {
custom: { isTest, isModern, emitDecoratorMetadata },
loader,
};
},
config(
cfg,
{ customOptions: { isTest, isModern, emitDecoratorMetadata } }
) {
// Add hint to our babel preset so it can handle modern vs legacy bundles.
cfg.options.caller.isModern = isModern;
cfg.options.caller.isTest = isTest;
cfg.options.caller.emitDecoratorMetadata = emitDecoratorMetadata;
return cfg.options;
},
};
});
@@ -0,0 +1,19 @@
const lessLoader = require('less-loader');
module.exports = function (...args) {
if (!process.env.__NX_LESS_DEPRECATION_WARNED) {
process.env.__NX_LESS_DEPRECATION_WARNED = '1';
console.warn(
'\n⚠️ Less support in Nx is deprecated and will be removed in a future version.\n' +
' Please migrate to CSS or SCSS.\n' +
' Existing .less imports continue to compile, but Nx generators no longer\n' +
' accept --style=less. To add new Less files, install less-loader manually\n' +
' and generate with --style=css, then rename the file to .less.\n'
);
}
return lessLoader.apply(this, args);
};
if (lessLoader.raw) {
module.exports.raw = lessLoader.raw;
}
@@ -0,0 +1,76 @@
import { interpolateEnvironmentVariablesToIndex } from './interpolate-env-variables-to-index';
describe('interpolateEnvironmentVariablesToIndex()', () => {
const envDefaults = {
NX_PUBLIC_VARIABLE: 'foo',
SOME_OTHER_VARIABLE: 'bar',
DEPLOY_URL: 'baz',
};
beforeEach(() => {
jest.resetModules();
process.env = { ...envDefaults };
});
test('default env variables', () => {
const content = `
<div>Nx Variable: %NX_PUBLIC_VARIABLE%</div>
<div>Some other variable: %SOME_OTHER_VARIABLE%</div>
<div>Deploy Url: %DEPLOY_URL%</div>
`;
const expected = `
<div>Nx Variable: foo</div>
<div>Some other variable: %SOME_OTHER_VARIABLE%</div>
<div>Deploy Url: baz</div>
`;
expect(interpolateEnvironmentVariablesToIndex(content)).toBe(expected);
});
test('Deploy url set as option overrides DEPLOY_URL env variable', () => {
const content = `
<div>Nx Variable: %NX_PUBLIC_VARIABLE%</div>
<div>Some other variable: %SOME_OTHER_VARIABLE%</div>
<div>Deploy Url: %DEPLOY_URL%</div>
`;
const expected = `
<div>Nx Variable: foo</div>
<div>Some other variable: %SOME_OTHER_VARIABLE%</div>
<div>Deploy Url: some-other-url.com</div>
`;
expect(
interpolateEnvironmentVariablesToIndex(content, 'some-other-url.com')
).toBe(expected);
});
test('No deploy url provided via either option', () => {
delete process.env.DEPLOY_URL;
const content = `
<div>Nx Variable: %NX_PUBLIC_VARIABLE%</div>
<div>Some other variable: %SOME_OTHER_VARIABLE%</div>
<div>Deploy Url: %DEPLOY_URL%</div>
`;
const expected = `
<div>Nx Variable: foo</div>
<div>Some other variable: %SOME_OTHER_VARIABLE%</div>
<div>Deploy Url: </div>
`;
expect(interpolateEnvironmentVariablesToIndex(content)).toBe(expected);
});
test('NX_ prefixed option present in index.html, but not present in process.env', () => {
delete process.env.DEPLOY_URL;
const content = `
<div>Nx Variable: %NX_PUBLIC_VARIABLE%</div>
<div>Some other variable: %SOME_OTHER_VARIABLE%</div>
<div>Some other nx_variable: %NX_SOME_OTHER_VARIABLE%</div>
<div>Deploy Url: %DEPLOY_URL%</div>
`;
const expected = `
<div>Nx Variable: foo</div>
<div>Some other variable: %SOME_OTHER_VARIABLE%</div>
<div>Some other nx_variable: %NX_SOME_OTHER_VARIABLE%</div>
<div>Deploy Url: </div>
`;
expect(interpolateEnvironmentVariablesToIndex(content)).toBe(expected);
});
});
@@ -0,0 +1,39 @@
export function interpolateEnvironmentVariablesToIndex(
contents: string,
deployUrl?: string
): string {
const environmentVariables = getClientEnvironment(deployUrl || '');
return interpolateEnvironmentVariables(contents, environmentVariables as any);
}
const NX_PREFIX = /^NX_PUBLIC_/i;
function isNxEnvironmentKey(x: string): boolean {
return NX_PREFIX.test(x);
}
function getClientEnvironment(deployUrl: string) {
return Object.keys(process.env)
.filter(isNxEnvironmentKey)
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
NODE_ENV: process.env.NODE_ENV || 'development',
DEPLOY_URL: deployUrl || process.env.DEPLOY_URL || '',
}
);
}
function interpolateEnvironmentVariables(
documentContents: string,
environmentVariables: Record<string, string>
): string {
let temp = documentContents;
for (const [key, value] of Object.entries(environmentVariables)) {
temp = temp.replace(new RegExp(`%${key}%`, 'g'), value);
}
return temp;
}
@@ -0,0 +1,30 @@
import { ExtraEntryPoint, NormalizedEntryPoint } from '../models';
export function normalizeExtraEntryPoints(
extraEntryPoints: ExtraEntryPoint[],
defaultBundleName: string
): NormalizedEntryPoint[] {
return extraEntryPoints.map((entry) => {
let normalizedEntry;
if (typeof entry === 'string') {
normalizedEntry = {
input: entry,
inject: true,
bundleName: defaultBundleName,
};
} else {
const { inject = true, ...newEntry } = entry;
let bundleName;
if (entry.bundleName) {
bundleName = entry.bundleName;
} else {
bundleName = defaultBundleName;
}
normalizedEntry = { ...newEntry, bundleName };
}
return normalizedEntry;
});
}
@@ -0,0 +1,53 @@
import { ExtraEntryPoint } from '../models';
import { normalizeExtraEntryPoints } from './normalize-entry';
export function generateEntryPoints(appConfig: {
styles: ExtraEntryPoint[];
scripts: ExtraEntryPoint[];
}) {
// Add all styles/scripts, except lazy-loaded ones.
const extraEntryPoints = (
extraEntryPoints: ExtraEntryPoint[],
defaultBundleName: string
): string[] => {
const entryPoints = normalizeExtraEntryPoints(
extraEntryPoints,
defaultBundleName
).map((entry) => entry.bundleName);
// remove duplicates
return [...new Set(entryPoints)];
};
const styleEntryPoints = appConfig.styles.filter(
(style) => !(typeof style !== 'string' && !style.inject)
);
const scriptEntryPoints = appConfig.scripts.filter(
(script) => !(typeof script !== 'string' && !script.inject)
);
const entryPoints = [
'runtime',
'polyfills',
'sw-register',
...extraEntryPoints(styleEntryPoints, 'styles'),
...extraEntryPoints(scriptEntryPoints, 'scripts'),
'vendor',
'main',
];
const duplicates = [
...new Set(
entryPoints.filter(
(x) => entryPoints.indexOf(x) !== entryPoints.lastIndexOf(x)
)
),
];
if (duplicates.length > 0) {
throw new Error(
`Multiple bundles have been named the same: '${duplicates.join(`', '`)}'.`
);
}
return entryPoints;
}
@@ -0,0 +1,216 @@
import { interpolateName } from 'loader-utils';
import * as path from 'path';
import { pathToFileURL } from 'url';
import type { Declaration } from 'postcss';
import type { LoaderContext } from 'webpack';
function wrapUrl(url: string): string {
let wrappedUrl;
const hasSingleQuotes = url.indexOf("'") >= 0;
if (hasSingleQuotes) {
wrappedUrl = `"${url}"`;
} else {
wrappedUrl = `'${url}'`;
}
return `url(${wrappedUrl})`;
}
function resolveUrl(from: string, to: string) {
const resolvedUrl = new URL(to, new URL(from, 'resolve://'));
if (resolvedUrl.protocol === 'resolve:') {
// `from` is a relative URL.
const { pathname, search, hash } = resolvedUrl;
return pathname + search + hash;
}
return resolvedUrl.toString();
}
export interface PostcssCliResourcesOptions {
baseHref?: string;
deployUrl?: string;
resourcesOutputPath?: string;
rebaseRootRelative?: boolean;
filename: string;
loader: LoaderContext<unknown>;
publicPath: string;
}
async function resolve(
file: string,
base: string,
resolver: (file: string, base: string) => Promise<boolean | string>
): Promise<boolean | string> {
try {
return await resolver(`./${file}`, base);
} catch {
return resolver(file, base);
}
}
module.exports.postcss = true;
export function PostcssCliResources(options: PostcssCliResourcesOptions) {
const {
deployUrl = '',
baseHref = '',
resourcesOutputPath = '',
rebaseRootRelative = false,
filename,
loader,
publicPath = '',
} = options;
const dedupeSlashes = (url: string) => url.replace(/\/\/+/g, '/');
const process = async (
inputUrl: string,
context: string,
resourceCache: Map<string, string>
) => {
// If root-relative, absolute or protocol relative url, leave as is
if (/^((?:\w+:)?\/\/|data:|chrome:|#)/.test(inputUrl)) {
return inputUrl;
}
if (!rebaseRootRelative && /^\//.test(inputUrl)) {
return inputUrl;
}
// If starts with a caret, remove and return remainder
// this supports bypassing asset processing
if (inputUrl.startsWith('^')) {
return inputUrl.slice(1);
}
const cacheKey = path.resolve(context, inputUrl);
const cachedUrl = resourceCache.get(cacheKey);
if (cachedUrl) {
return cachedUrl;
}
if (inputUrl.startsWith('~')) {
inputUrl = inputUrl.slice(1);
}
if (inputUrl.startsWith('/')) {
let outputUrl = '';
if (deployUrl.match(/:\/\//) || deployUrl.startsWith('/')) {
// If deployUrl is absolute or root relative, ignore baseHref & use deployUrl as is.
outputUrl = `${deployUrl.replace(/\/$/, '')}${inputUrl}`;
} else if (baseHref.match(/:\/\//)) {
// If baseHref contains a scheme, include it as is.
outputUrl =
baseHref.replace(/\/$/, '') +
dedupeSlashes(`/${deployUrl}/${inputUrl}`);
} else {
// Join together base-href, deploy-url and the original URL.
outputUrl = dedupeSlashes(
`/${baseHref}/${deployUrl}/${publicPath}/${inputUrl}`
);
}
resourceCache.set(cacheKey, outputUrl);
return outputUrl;
}
// Separate URL query/hash from the file path before resolving
const [, filePath, urlSuffix] = inputUrl.match(/^([^?#]*)(.*)$/)!;
const resolvedPath = path.resolve(context, filePath.replace(/\\/g, '/'));
const { pathname } = pathToFileURL(resolvedPath);
let hash = '';
let search = '';
if (urlSuffix) {
({ hash, search } = new URL(`file:///dummy${urlSuffix}`));
}
const resolver = (file: string, base: string) =>
new Promise<boolean | string>((resolve, reject) => {
loader.resolve(base, decodeURI(file), (err, result) => {
if (err) {
reject(err);
return;
}
resolve(result);
});
});
const result = await resolve(pathname as string, context, resolver);
return new Promise<boolean | string>((resolve, reject) => {
loader.fs.readFile(result as string, (err: Error, content: Buffer) => {
if (err) {
reject(err);
return;
}
let outputPath = interpolateName(
{ resourcePath: result } as LoaderContext<unknown>,
filename,
{ content }
);
if (resourcesOutputPath) {
outputPath = path.posix.join(resourcesOutputPath, outputPath);
}
loader.addDependency(result as string);
loader.emitFile(outputPath, content, undefined);
let outputUrl = outputPath.replace(/\\/g, '/');
if (hash || search) {
outputUrl = outputUrl + (search || '') + (hash || '');
}
const loaderOptions: any = loader.loaders[loader.loaderIndex].options;
if (deployUrl && loaderOptions.ident !== 'extracted') {
outputUrl = resolveUrl(deployUrl, outputUrl);
}
resourceCache.set(cacheKey, outputUrl);
resolve(outputUrl);
});
});
};
return {
postcssPlugin: 'postcss-cli-resources',
Once(root) {
const urlDeclarations: Array<Declaration> = [];
/**
* TODO: Explore if this can be rewritten using the new `Declaration()`
* listener added in postcss v8
*/
root.walkDecls((decl) => {
if (decl.value && decl.value.includes('url')) {
urlDeclarations.push(decl);
}
});
if (urlDeclarations.length === 0) {
return;
}
const resourceCache = new Map<string, string>();
return Promise.all(
urlDeclarations.map(async (decl) => {
const value = decl.value;
const urlRegex = /url(?:\(\s*(['"]?))(.*?)(?:\1\s*\))/g;
const segments: string[] = [];
let match;
let lastIndex = 0;
let modified = false;
// We want to load it relative to the file that imports
const inputFile = decl.source && decl.source.input.file;
const context =
(inputFile && path.dirname(inputFile)) || loader.context;
while ((match = urlRegex.exec(value))) {
const originalUrl = match[2];
let processedUrl;
try {
processedUrl = await process(originalUrl, context, resourceCache);
} catch (err) {
loader.emitError(decl.error(err.message, { word: originalUrl }));
continue;
}
if (lastIndex < match.index) {
segments.push(value.slice(lastIndex, match.index));
}
if (!processedUrl || originalUrl === processedUrl) {
segments.push(match[0]);
} else {
segments.push(wrapUrl(processedUrl));
modified = true;
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < value.length) {
segments.push(value.slice(lastIndex));
}
if (modified) {
decl.value = segments.join('');
}
})
);
},
};
}
@@ -0,0 +1,177 @@
import { interpolateName } from 'loader-utils';
import * as path from 'path';
import type * as Webpack from 'webpack';
export interface ScriptsWebpackPluginOptions {
name: string;
sourceMap: boolean;
scripts: string[];
filename: string;
basePath: string;
}
interface ScriptOutput {
filename: string;
source: Webpack.sources.Source;
}
function addDependencies(compilation: any, scripts: string[]): void {
for (const script of scripts) {
compilation.fileDependencies.add(script);
}
}
function hook(
compiler: any,
action: (compilation: any, callback: (err?: Error) => void) => void
) {
compiler.hooks.thisCompilation.tap(
'scripts-webpack-plugin',
(compilation: any) => {
compilation.hooks.additionalAssets.tapAsync(
'scripts-webpack-plugin',
(callback: (err?: Error) => void) => action(compilation, callback)
);
}
);
}
export class ScriptsWebpackPlugin {
private _lastBuildTime?: number;
private _cachedOutput?: ScriptOutput;
constructor(private options: Partial<ScriptsWebpackPluginOptions> = {}) {}
shouldSkip(compilation: any, scripts: string[]): boolean {
if (this._lastBuildTime == undefined) {
this._lastBuildTime = Date.now();
return false;
}
for (let i = 0; i < scripts.length; i++) {
const scriptTime = compilation.fileTimestamps?.get(scripts[i]);
if (!scriptTime || scriptTime > this._lastBuildTime) {
this._lastBuildTime = Date.now();
return false;
}
}
return true;
}
private _insertOutput(
compilation: any,
{ filename, source }: ScriptOutput,
cached = false
) {
const Chunk = require('webpack/lib/Chunk');
const EntryPoint = require('webpack/lib/Entrypoint');
const chunk = new Chunk(this.options.name);
chunk.rendered = !cached;
chunk.id = this.options.name;
chunk.ids = [chunk.id];
if (chunk.files instanceof Set) {
chunk.files.add(filename);
} else if (chunk.files instanceof Array) {
chunk.files.push(filename);
}
const entrypoint = new EntryPoint(this.options.name);
entrypoint.pushChunk(chunk);
chunk.addGroup(entrypoint);
compilation.entrypoints.set(this.options.name, entrypoint);
if (compilation.chunks instanceof Set) {
compilation.chunks.add(chunk);
} else if (compilation.chunks instanceof Array) {
compilation.chunks.push(chunk);
}
compilation.assets[filename] = source;
}
apply(compiler: Webpack.Compiler): void {
if (!this.options.scripts || this.options.scripts.length === 0) {
return;
}
const webpack = require('webpack') as typeof import('webpack');
const scripts = this.options.scripts
.filter((script) => !!script)
.map((script) => path.resolve(this.options.basePath || '', script));
hook(compiler, (compilation, callback) => {
if (this.shouldSkip(compilation, scripts)) {
if (this._cachedOutput) {
this._insertOutput(compilation, this._cachedOutput, true);
}
addDependencies(compilation, scripts);
callback();
return;
}
const sourceGetters = scripts.map((fullPath) => {
return new Promise<Webpack.sources.Source>((resolve, reject) => {
compilation.inputFileSystem.readFile(
fullPath,
(err: Error, data: Buffer) => {
if (err) {
reject(err);
return;
}
const content = data.toString();
let source;
if (this.options.sourceMap) {
// TODO: Look for source map file (for '.min' scripts, etc.)
let adjustedPath = fullPath;
if (this.options.basePath) {
adjustedPath = path.relative(this.options.basePath, fullPath);
}
source = new webpack.sources.OriginalSource(
content,
adjustedPath
);
} else {
source = new webpack.sources.RawSource(content);
}
resolve(source);
}
);
});
});
Promise.all(sourceGetters)
.then((sources) => {
const concatSource = new webpack.sources.ConcatSource();
sources.forEach((source) => {
concatSource.add(source);
concatSource.add('\n;');
});
const combinedSource = new webpack.sources.CachedSource(concatSource);
const filename = interpolateName(
{ resourcePath: 'scripts.js' },
this.options.filename as string,
{ content: combinedSource.source() }
);
const output = { filename, source: combinedSource };
this._insertOutput(compilation, output);
this._cachedOutput = output;
addDependencies(compilation, scripts);
callback();
})
.catch((err: Error) => callback(err));
});
}
}
@@ -0,0 +1,89 @@
import { workspaceRoot } from '@nx/devkit';
import { isNxWebpackComposablePlugin } from '../config';
import type { Configuration } from 'webpack';
import { readNxJsonFromDisk } from 'nx/src/devkit-internals';
/**
* 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. An array of standard config objects (multi-configuration mode)
* 4. 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<Configuration[]> {
const configs: Configuration[] = [];
const resolveConfig = async (
config: unknown
): Promise<Configuration | Configuration[]> => {
if (isNxWebpackComposablePlugin(config)) {
return await config(
{},
{
// These values are only used during build-time, so passing stubs here just to read out
options: {
root: workspaceRoot,
projectRoot: '',
sourceRoot: '',
outputFileName: undefined,
outputPath: undefined,
assets: undefined,
useTsconfigPaths: undefined,
},
context: {
root: workspaceRoot,
cwd: undefined,
isVerbose: false,
projectsConfigurations: null,
projectGraph: null,
nxJsonConfiguration: readNxJsonFromDisk(workspaceRoot),
},
}
);
} else if (typeof config === 'function') {
const resolved = await config(
{
production: true, // we want the production build options
},
{}
);
// If the resolved configuration is an array, resolve each configuration
return Array.isArray(resolved)
? (await Promise.all(resolved.map(resolveConfig))).flat()
: resolved;
} else if (Array.isArray(config)) {
// If the config passed is an array, resolve each configuration
const resolvedConfigs = await Promise.all(config.map(resolveConfig));
return resolvedConfigs.flat();
} else {
// Return plain configuration
return config as Configuration;
}
};
// Since configs can have nested arrays, we need to flatten them
const flattenConfigs = (
resolvedConfigs: Configuration | Configuration[]
): Configuration[] => {
return Array.isArray(resolvedConfigs)
? resolvedConfigs.flatMap((cfg) => flattenConfigs(cfg))
: [resolvedConfigs];
};
if (Array.isArray(webpackConfig)) {
for (const config of webpackConfig) {
const resolved = await resolveConfig(config);
configs.push(...flattenConfigs(resolved));
}
} else {
const resolved = await resolveConfig(webpackConfig);
configs.push(...flattenConfigs(resolved));
}
return configs;
}
@@ -0,0 +1,41 @@
import { clearRequireCache } from '@nx/devkit/internal';
import { loadTsFile } from '@nx/js/internal';
export function resolveUserDefinedWebpackConfig(
path: string,
tsConfig: string,
/** Skip require cache and return latest content */
reload = false
) {
if (reload) {
// Clear cache if the path is in the cache
if (require.cache[path]) {
// Clear all entries because config may import other modules
clearRequireCache();
}
}
// 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)) {
return require(path);
}
const maybeCustomWebpackConfig = loadTsFile(path, tsConfig);
// If the user provides a configuration in TS file
// then there are 3 cases for exporing an object. The first one is:
// `module.exports = { ... }`. And the second one is:
// `export default { ... }`. The ESM format is compiled into:
// `{ default: { ... } }`
// There is also a case of
// `{ default: { default: { ... } }`
const customWebpackConfig =
'default' in maybeCustomWebpackConfig
? 'default' in maybeCustomWebpackConfig.default
? maybeCustomWebpackConfig.default.default
: maybeCustomWebpackConfig.default
: maybeCustomWebpackConfig;
return customWebpackConfig;
}
+60
View File
@@ -0,0 +1,60 @@
import type { Configuration } from 'webpack';
import { NxComposableWebpackPlugin, NxWebpackExecutionContext } from './config';
import { NxAppWebpackPluginOptions } from '../plugins/nx-webpack-plugin/nx-app-webpack-plugin-options';
import { normalizeAssets } from '../plugins/nx-webpack-plugin/lib/normalize-options';
import { warnWebpackComposeHelpersDeprecation } from './deprecation';
const processed = new Set();
export type WithNxOptions = Partial<NxAppWebpackPluginOptions>;
/**
* @deprecated Will be removed in Nx v24. Use `NxAppWebpackPlugin` from
* `@nx/webpack/app-plugin` in a standard webpack config and run
* `nx g @nx/webpack:convert-to-inferred`. See
* https://nx.dev/docs/guides/tasks--caching/convert-to-inferred for details.
* @param {WithNxOptions} pluginOptions
* @returns {NxWebpackPlugin}
*/
export function withNx(
pluginOptions: WithNxOptions = {}
): NxComposableWebpackPlugin {
warnWebpackComposeHelpersDeprecation();
return function configure(
config: Configuration,
{ options, context }: NxWebpackExecutionContext
): Configuration {
if (processed.has(config)) return config;
// Lazy-required so importing this module does not load the webpack bundler.
const { applyBaseConfig } =
require('../plugins/nx-webpack-plugin/lib/apply-base-config') as typeof import('../plugins/nx-webpack-plugin/lib/apply-base-config');
applyBaseConfig(
{
...options,
...pluginOptions,
target: options.target ?? 'web',
assets: options.assets
? options.assets
: pluginOptions.assets
? normalizeAssets(
pluginOptions.assets,
options.root,
options.sourceRoot,
options.projectRoot
)
: [],
root: context.root,
projectName: context.projectName,
targetName: context.targetName,
configurationName: context.configurationName,
projectGraph: context.projectGraph,
},
config
);
processed.add(config);
return config;
};
}

Some files were not shown because too many files have changed in this diff Show More