nx/packages/rspack/src/plugins/utils/apply-base-config.ts
Nicholas Cunningham 939cc732de
fix(module-federation): remove disabling runtimeChunk (#30637)
<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
When using Module Federation with an app that defines styles as a
separate entry point, HMR fails to update in the browser. Instead of
updating automatically, a warning is shown in the console related to a
missing chunk. A full-page reload is typically required, which is not
the intended behaviour.


## Expected Behavior
<!-- This is the behaviour we should expect with the changes in this PR
-->
HMR should work with styles as an entrypoint.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->
The default is for `runtimeChunk` is `config.optimization.runtimeChunk =
{name: 'runtime'}` it is _not_ the same as `single` but the naming will
be the same.

Fixes #9582
2025-04-24 12:08:35 -06:00

482 lines
15 KiB
TypeScript

import * as path from 'path';
import { type ExecutorContext } from '@nx/devkit';
import { LicenseWebpackPlugin } from 'license-webpack-plugin';
import {
Configuration,
ProgressPlugin,
RspackPluginInstance,
SwcJsMinimizerRspackPlugin,
CopyRspackPlugin,
RspackOptionsNormalized,
} from '@rspack/core';
import { getRootTsConfigPath } from '@nx/js';
import { StatsJsonPlugin } from './plugins/stats-json-plugin';
import { GeneratePackageJsonPlugin } from './plugins/generate-package-json-plugin';
import { getOutputHashFormat } from './hash-format';
import { NxTsconfigPathsRspackPlugin } from './plugins/nx-tsconfig-paths-rspack-plugin';
import { getTerserEcmaVersion } from './get-terser-ecma-version';
import nodeExternals = require('webpack-node-externals');
import { NormalizedNxAppRspackPluginOptions } from './models';
import { isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup';
import { isBuildableLibrary } from './is-lib-buildable';
const IGNORED_RSPACK_WARNINGS = [
/The comment file/i,
/could not find any license/i,
];
const extensions = ['...', '.ts', '.tsx', '.mjs', '.js', '.jsx'];
const mainFields = ['module', 'main'];
export function applyBaseConfig(
options: NormalizedNxAppRspackPluginOptions,
config: Partial<RspackOptionsNormalized | Configuration> = {},
{
useNormalizedEntry,
}: {
// rspack.Configuration allows arrays to be set on a single entry
// rspack 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.deleteOutputPath ??= true;
options.externalDependencies ??= 'all';
options.fileReplacements ??= [];
options.memoryLimit ??= 2048;
options.transformers ??= [];
options.progress ??= true;
options.outputHashing ??= 'all';
applyNxIndependentConfig(options, config);
// Some of the options only work during actual tasks, not when reading the rspack config during CreateNodes.
if (global.NX_GRAPH_CREATION) return;
applyNxDependentConfig(options, config, { useNormalizedEntry });
}
function applyNxIndependentConfig(
options: NormalizedNxAppRspackPluginOptions,
config: Partial<RspackOptionsNormalized | Configuration>
): void {
const isProd =
process.env.NODE_ENV === 'production' || options.mode === 'production';
const hashFormat = getOutputHashFormat(options.outputHashing as string);
config.context = path.join(options.root, options.projectRoot);
config.target ??= options.target as 'async-node' | 'node' | 'web';
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' || config.target === 'async-node'
? 'none'
: // Otherwise, make sure it matches `process.env.NODE_ENV`.
// When mode is development or production, rspack 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 rspack.
options.mode ??
(process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'production'
? (process.env.NODE_ENV as 'development' | 'production')
: 'none');
// When target is Node, the Webpack mode will be set to 'none' which disables in memory caching and causes a full rebuild on every change.
// So to mitigate this we enable in memory caching when target is Node and in watch mode.
config.cache =
(options.target === 'node' || options.target === 'async-node') &&
options.watch
? true
: undefined;
config.devtool =
options.sourceMap === true ? 'source-map' : options.sourceMap;
config.output = {
...(config.output ?? {}),
libraryTarget:
options.target === 'node'
? 'commonjs'
: options.target === 'async-node'
? 'commonjs-module'
: 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,
clean: config.output?.clean ?? options.deleteOutputPath,
};
config.watch = options.watch;
config.watchOptions = {
poll: options.poll,
};
config.profile = options.statsJson;
config.performance = {
...config.performance,
hints: false,
};
config.ignoreWarnings = [
(x) =>
IGNORED_RSPACK_WARNINGS.some((r) =>
typeof x === 'string' ? r.test(x) : r.test(x.message)
),
...(config.ignoreWarnings ?? []),
];
config.optimization = !isProd
? undefined
: {
...(config.optimization ?? {}),
sideEffects: true,
minimize:
typeof options.optimization === 'object'
? !!options.optimization.scripts
: !!options.optimization,
minimizer: [
new SwcJsMinimizerRspackPlugin({
extractComments: false,
minimizerOptions: {
// this needs to be false to allow toplevel variables to be used in the global scope
// important especially for module-federation which operates as such
module: false,
mangle: {
keep_classnames: true,
},
format: {
ecma: getTerserEcmaVersion(
path.join(options.root, options.projectRoot)
),
ascii_only: true,
comments: false,
webkit: true,
safari10: true,
},
},
}),
],
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 rspack is used during task execution.
* These properties may be used by consumers who expect them to not be undefined.
*
* When @nx/rspack/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 rspack options from the resolved file.
*/
config.entry ??= {};
config.resolve ??= {};
config.module ??= {};
config.plugins ??= [];
config.externals ??= [];
}
function applyNxDependentConfig(
options: NormalizedNxAppRspackPluginOptions,
config: Partial<RspackOptionsNormalized | Configuration>,
{ useNormalizedEntry }: { useNormalizedEntry?: boolean } = {}
): void {
const tsConfig = options.tsConfig ?? getRootTsConfigPath();
const plugins: RspackPluginInstance[] = [];
const isUsingTsSolution = isUsingTsSolutionSetup();
const executorContext: Partial<ExecutorContext> = {
projectName: options.projectName,
targetName: options.targetName,
projectGraph: options.projectGraph,
configurationName: options.configurationName,
root: options.root,
};
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 NxTsconfigPathsRspackPlugin({ ...options, tsConfig }));
}
// New TS Solution already has a typecheck target but allow it to run during serve
if (
(!options?.skipTypeChecking && !isUsingTsSolution) ||
(isUsingTsSolution &&
options?.skipTypeChecking === false &&
process.env['WEBPACK_SERVE'])
) {
const { TsCheckerRspackPlugin } = require('ts-checker-rspack-plugin');
plugins.push(
new TsCheckerRspackPlugin({
typescript: {
configFile: path.isAbsolute(tsConfig)
? tsConfig
: path.join(options.root, tsConfig),
memoryLimit: options.memoryLimit || 8192, // default memory limit is 8192
},
})
);
}
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 RspackPluginInstance
);
}
if (Array.isArray(options.assets) && options.assets.length > 0) {
plugins.push(
new CopyRspackPlugin({
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,
},
};
}),
})
);
}
if (options.generatePackageJson && executorContext) {
plugins.push(new GeneratePackageJsonPlugin({ ...options, tsConfig }));
}
if (options.statsJson) {
plugins.push(new StatsJsonPlugin());
}
const externals = [];
if (
(options.target === 'node' || options.target === 'async-node') &&
options.externalDependencies === 'all'
) {
const modulesDir = `${options.root}/node_modules`;
const graph = options.projectGraph;
const projectName = options.projectName;
const deps = graph?.dependencies?.[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. This is needed for the new TS
// solution setup.
const nonBuildableWorkspaceLibs = isUsingTsSolution
? 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;
}
// If there is no build target we check the package exports to see if they reference
// source files
return !isBuildableLibrary(node);
})
.map(
(dep) => graph.nodes?.[dep.target]?.data?.metadata?.js?.packageName
)
.filter((name): name is string => !!name)
: [];
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],
alias: {
...(config.resolve?.alias ?? {}),
...(options.fileReplacements?.reduce(
(aliases, replacement) => ({
...aliases,
[replacement.replace]: replacement.with,
}),
{}
) ?? {}),
},
mainFields: config.resolve?.mainFields ?? mainFields,
};
config.externals = externals;
// Enabled for performance
config.cache = true;
config.module = {
...config.module,
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',
},
// Rspack's docs only suggest swc for TS compilation
//https://rspack.dev/guide/tech/typescript
{
test: /\.([jt])sx?$/,
loader: 'builtin:swc-loader',
exclude: /node_modules/,
type: 'javascript/auto',
options: {
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
tsx: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: true,
react: {
runtime: 'automatic',
pragma: 'React.createElement',
pragmaFrag: 'React.Fragment',
throwIfNamespace: true,
// Config.mode is already set based on options.mode and `process.env.NODE_ENV`
development: config.mode === 'development',
refresh: config.mode === 'development',
useBuiltins: false,
},
},
},
},
},
].filter((r) => !!r),
};
config.plugins ??= [];
config.plugins.push(...plugins);
}