Colum Ferry c37007ec6c
fix(angular): handle ssr with convert-to-rspack (#30752)
## Current Behavior
The `convert-to-rspack` generator for `@nx/angular` does not currently
handle SSR Webpack applications correctly.

## Expected Behavior
Ensure that the `convert-to-rspack` generator handles SSR correctly.
2025-04-16 16:31:51 +01:00

472 lines
13 KiB
TypeScript

import {
type Tree,
readProjectConfiguration,
addDependenciesToPackageJson,
formatFiles,
GeneratorCallback,
runTasksInSerial,
ensurePackage,
updateProjectConfiguration,
workspaceRoot,
joinPathFragments,
readJson,
writeJson,
} from '@nx/devkit';
import type { ConvertToRspackSchema } from './schema';
import {
angularRspackVersion,
nxVersion,
tsNodeVersion,
} from '../../utils/versions';
import { createConfig } from './lib/create-config';
import { getCustomWebpackConfig } from './lib/get-custom-webpack-config';
import { updateTsconfig } from './lib/update-tsconfig';
import { validateSupportedBuildExecutor } from './lib/validate-supported-executor';
import { join } from 'path/posix';
import { relative } from 'path';
import { existsSync } from 'fs';
import { forEachExecutorOptions } from '@nx/devkit/src/generators/executor-options-utils';
import { prompt } from 'enquirer';
const SUPPORTED_EXECUTORS = [
'@angular-devkit/build-angular:browser',
'@angular-devkit/build-angular:dev-server',
'@angular-devkit/build-angular:server',
'@nx/angular:webpack-browser',
'@nx/angular:webpack-server',
'@nx/angular:dev-server',
'@nx/angular:module-federation-dev-server',
];
const RENAMED_OPTIONS = {
main: 'browser',
ngswConfigPath: 'serviceWorker',
};
const REMOVED_OPTIONS = ['buildOptimizer', 'buildTarget', 'browserTarget'];
function normalizeFromProjectRoot(
tree: Tree,
path: string,
projectRoot: string
) {
if (projectRoot === '.') {
if (!path.startsWith('./')) {
return `./${path}`;
} else {
return path;
}
} else if (path.startsWith(projectRoot)) {
return path.replace(projectRoot, '.');
} else if (!path.startsWith('./')) {
if (tree.exists(path)) {
const pathWithWorkspaceRoot = joinPathFragments(workspaceRoot, path);
const projectRootWithWorkspaceRoot = joinPathFragments(
workspaceRoot,
projectRoot
);
return relative(projectRootWithWorkspaceRoot, pathWithWorkspaceRoot);
}
return `./${path}`;
}
return path;
}
const defaultNormalizer = (tree: Tree, path: string, root: string) =>
normalizeFromProjectRoot(tree, path, root);
const PATH_NORMALIZER = {
index: (
tree: Tree,
path: string | { input: string; output: string },
root: string
) => {
if (typeof path === 'string') {
return normalizeFromProjectRoot(tree, path, root);
}
return {
input: normalizeFromProjectRoot(tree, path.input, root),
output: path.output ?? 'index.html',
};
},
indexHtmlTransformer: defaultNormalizer,
main: defaultNormalizer,
server: defaultNormalizer,
tsConfig: defaultNormalizer,
outputPath: (tree: Tree, path: string, root: string) => {
const relativePathFromWorkspaceRoot = relative(
joinPathFragments(workspaceRoot, root),
workspaceRoot
);
return joinPathFragments(relativePathFromWorkspaceRoot, path);
},
proxyConfig: defaultNormalizer,
polyfills: (tree: Tree, paths: string | string[], root: string) => {
const normalizedPaths: string[] = [];
const normalizeFn = (path: string) => {
try {
const resolvedPath = require.resolve(path, {
paths: [join(workspaceRoot, 'node_modules')],
});
normalizedPaths.push(path);
} catch {
normalizedPaths.push(normalizeFromProjectRoot(tree, path, root));
}
};
if (typeof paths === 'string') {
normalizeFn(paths);
} else {
for (const path of paths) {
normalizeFn(path);
}
}
return normalizedPaths;
},
styles: (
tree: Tree,
paths: Array<
string | { input: string; bundleName: string; inject: boolean }
>,
root: string
) => {
const normalizedPaths: Array<
string | { input: string; bundleName: string; inject: boolean }
> = [];
for (const path of paths) {
if (typeof path === 'string') {
normalizedPaths.push(normalizeFromProjectRoot(tree, path, root));
} else {
normalizedPaths.push({
input: normalizeFromProjectRoot(tree, path.input, root),
bundleName: path.bundleName,
inject: path.inject ?? true,
});
}
}
return normalizedPaths;
},
scripts: (
tree: Tree,
paths: Array<
string | { input: string; bundleName: string; inject: boolean }
>,
root: string
) => {
const normalizedPaths: Array<
string | { input: string; bundleName: string; inject: boolean }
> = [];
for (const path of paths) {
if (typeof path === 'string') {
normalizedPaths.push(normalizeFromProjectRoot(tree, path, root));
} else {
normalizedPaths.push({
input: normalizeFromProjectRoot(tree, path.input, root),
bundleName: path.bundleName,
inject: path.inject ?? true,
});
}
}
return normalizedPaths;
},
assets: (
tree: Tree,
paths: Array<string | { input: string; [key: string]: any }>,
root: string
) => {
const normalizedPaths: Array<
string | { input: string; [key: string]: any }
> = [];
for (const path of paths) {
if (typeof path === 'string') {
normalizedPaths.push(normalizeFromProjectRoot(tree, path, root));
} else {
normalizedPaths.push({
...path,
input: normalizeFromProjectRoot(tree, path.input, root),
});
}
}
return normalizedPaths;
},
fileReplacements: (
tree: Tree,
paths: Array<
{ replace: string; with: string } | { src: string; replaceWith: string }
>,
root: string
) => {
const normalizedPaths: Array<
{ replace: string; with: string } | { src: string; replaceWith: string }
> = [];
for (const path of paths) {
normalizedPaths.push({
replace: normalizeFromProjectRoot(
tree,
'src' in path ? path.src : path.replace,
root
),
with: normalizeFromProjectRoot(
tree,
'replaceWith' in path ? path.replaceWith : path.with,
root
),
});
}
return normalizedPaths;
},
};
function handleBuildTargetOptions(
tree: Tree,
options: Record<string, any>,
newConfigurationOptions: Record<string, any>,
root: string
) {
let customWebpackConfigPath: string | undefined;
if (!options || Object.keys(options).length === 0) {
return customWebpackConfigPath;
}
if (options.customWebpackConfig) {
customWebpackConfigPath = options.customWebpackConfig.path;
delete options.customWebpackConfig;
}
if (options.outputs) {
// handled by the Rspack inference plugin
delete options.outputs;
}
for (const [key, value] of Object.entries(options)) {
let optionName = key;
let optionValue =
key in PATH_NORMALIZER ? PATH_NORMALIZER[key](tree, value, root) : value;
if (REMOVED_OPTIONS.includes(key)) {
continue;
}
if (key in RENAMED_OPTIONS) {
optionName = RENAMED_OPTIONS[key];
}
newConfigurationOptions[optionName] = optionValue;
}
if (typeof newConfigurationOptions.polyfills === 'string') {
newConfigurationOptions.polyfills = [newConfigurationOptions.polyfills];
}
let outputPath = newConfigurationOptions.outputPath;
if (typeof outputPath === 'string') {
if (!/\/browser\/?$/.test(outputPath)) {
console.warn(
`The output location of the browser build has been updated from "${outputPath}" to ` +
`"${join(outputPath, 'browser')}". ` +
'You might need to adjust your deployment pipeline or, as an alternative, ' +
'set outputPath.browser to "" in order to maintain the previous functionality.'
);
} else {
outputPath = outputPath.replace(/\/browser\/?$/, '');
}
newConfigurationOptions['outputPath'] = {
base: outputPath,
};
if (typeof newConfigurationOptions.resourcesOutputPath === 'string') {
const media = newConfigurationOptions.resourcesOutputPath.replaceAll(
'/',
''
);
if (media && media !== 'media') {
newConfigurationOptions['outputPath'] = {
base: outputPath,
media,
};
}
}
}
return customWebpackConfigPath;
}
function handleDevServerTargetOptions(
tree: Tree,
options: Record<string, any>,
newConfigurationOptions: Record<string, any>,
root: string
) {
for (const [key, value] of Object.entries(options)) {
let optionName = key;
let optionValue =
key in PATH_NORMALIZER ? PATH_NORMALIZER[key](tree, value, root) : value;
if (REMOVED_OPTIONS.includes(key)) {
continue;
}
if (key in RENAMED_OPTIONS) {
optionName = RENAMED_OPTIONS[key];
}
newConfigurationOptions[optionName] = optionValue;
}
}
async function getProjectToConvert(tree: Tree) {
const projects = new Set<string>();
for (const executor of SUPPORTED_EXECUTORS) {
forEachExecutorOptions(tree, executor, (_, project) => {
projects.add(project);
});
}
const { project } = await prompt<{ project: string }>({
type: 'select',
name: 'project',
message: 'Which project would you like to convert to rspack?',
choices: Array.from(projects),
});
return project;
}
export async function convertToRspack(
tree: Tree,
schema: ConvertToRspackSchema
) {
let { project: projectName } = schema;
if (!projectName) {
projectName = await getProjectToConvert(tree);
}
const project = readProjectConfiguration(tree, projectName);
const tasks: GeneratorCallback[] = [];
const createConfigOptions: Record<string, any> = {
root: project.root,
};
const configurationOptions: Record<string, Record<string, any>> = {};
const buildTargetNames: string[] = [];
const serveTargetNames: string[] = [];
let customWebpackConfigPath: string | undefined;
validateSupportedBuildExecutor(Object.values(project.targets));
for (const [targetName, target] of Object.entries(project.targets)) {
if (
target.executor === '@angular-devkit/build-angular:browser' ||
target.executor === '@nx/angular:webpack-browser'
) {
customWebpackConfigPath = handleBuildTargetOptions(
tree,
target.options,
createConfigOptions,
project.root
);
if (target.configurations) {
for (const [configurationName, configuration] of Object.entries(
target.configurations
)) {
configurationOptions[configurationName] = {};
handleBuildTargetOptions(
tree,
configuration,
configurationOptions[configurationName],
project.root
);
}
}
buildTargetNames.push(targetName);
} else if (
target.executor === '@angular-devkit/build-angular:server' ||
target.executor === '@nx/angular:webpack-server'
) {
createConfigOptions.ssr ??= {};
createConfigOptions.ssr.entry ??= normalizeFromProjectRoot(
tree,
target.options.main,
project.root
);
createConfigOptions.server = './src/main.server.ts';
buildTargetNames.push(targetName);
} else if (
target.executor === '@angular-devkit/build-angular:dev-server' ||
target.executor === '@nx/angular:dev-server' ||
target.executor === '@nx/angular:module-federation-dev-server'
) {
createConfigOptions.devServer = {};
if (target.options) {
handleDevServerTargetOptions(
tree,
target.options,
createConfigOptions.devServer,
project.root
);
}
if (target.configurations) {
for (const [configurationName, configuration] of Object.entries(
target.configurations
)) {
configurationOptions[configurationName] ??= {};
configurationOptions[configurationName].devServer ??= {};
handleDevServerTargetOptions(
tree,
configuration,
configurationOptions[configurationName].devServer,
project.root
);
}
}
serveTargetNames.push(targetName);
}
}
const customWebpackConfigInfo = customWebpackConfigPath
? await getCustomWebpackConfig(tree, project.root, customWebpackConfigPath)
: undefined;
createConfig(
tree,
createConfigOptions,
configurationOptions,
customWebpackConfigInfo?.normalizedPathToCustomWebpackConfig,
customWebpackConfigInfo?.isWebpackConfigFunction
);
updateTsconfig(tree, project.root);
for (const targetName of [...buildTargetNames, ...serveTargetNames]) {
delete project.targets[targetName];
}
updateProjectConfiguration(tree, projectName, project);
const { rspackInitGenerator } = ensurePackage<typeof import('@nx/rspack')>(
'@nx/rspack',
nxVersion
);
await rspackInitGenerator(tree, {
addPlugin: true,
});
// This is needed to prevent a circular execution of the build target
const rootPkgJson = readJson(tree, 'package.json');
if (rootPkgJson.scripts?.build === 'nx build') {
delete rootPkgJson.scripts.build;
writeJson(tree, 'package.json', rootPkgJson);
}
if (!schema.skipInstall) {
const installTask = addDependenciesToPackageJson(
tree,
{},
{
'@nx/angular-rspack': angularRspackVersion,
'ts-node': tsNodeVersion,
}
);
tasks.push(installTask);
}
if (!schema.skipFormat) {
await formatFiles(tree);
}
return runTasksInSerial(...tasks);
}
export default convertToRspack;