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.
This commit is contained in:
Colum Ferry 2025-04-16 16:31:51 +01:00 committed by GitHub
parent 4f8b407a75
commit c37007ec6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 359 additions and 16 deletions

View File

@ -1,5 +1,154 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app --minimal should generate a correct setup when --bundler=rspack and ssr 1`] = `
"
import { createConfig }from '@nx/angular-rspack';
export default createConfig({
options: {
root: __dirname,
"outputPath": {
"base": "../dist/app2"
},
"index": "./src/index.html",
"browser": "./src/main.ts",
"polyfills": [
"./zone.js"
],
"tsConfig": "./tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "./public"
}
],
"styles": [
"./src/styles.css"
],
"scripts": [],
"devServer": {},
"ssr": {
"entry": "./src/server.ts"
},
"server": "./src/main.server.ts"
}
}, {
production: {
options: {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all",
"devServer": {}
}
},
development: {
options: {
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true,
"devServer": {}
}
}});
"
`;
exports[`app --minimal should generate a correct setup when --bundler=rspack and ssr 2`] = `
"import 'zone.js/node';
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node';
import * as express from 'express';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import bootstrap from './main.server';
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const distFolder = join(process.cwd(), "../dist/app2/browser");
const indexHtml = existsSync(join(distFolder, 'index.original.html'))
? join(distFolder, 'index.original.html')
: join(distFolder, 'index.html');
const commonEngine = new CommonEngine();
server.set('view engine', 'html');
server.set('views', distFolder);
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get(
'*.*',
express.static(distFolder, {
maxAge: '1y',
})
);
// All regular routes use the Angular engine
server.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: \`\${protocol}://\${headers.host}\${originalUrl}\`,
publicPath: distFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});
return server;
}
function run(): void {
const port = process.env['PORT'] || 4000;
// Start up the Node server
const server = app();
server.listen(port, () => {
console.log(\`Node Express server listening on http://localhost:\${port}\`);
});
}
// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = (mainModule && mainModule.filename) || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
run();
}
export default bootstrap;
"
`;
exports[`app --minimal should generate a correct setup when --bundler=rspack including a correct config file and no build target 1`] = `
"
import { createConfig }from '@nx/angular-rspack';

View File

@ -1258,6 +1258,18 @@ describe('app', () => {
expect(appTree.read('app1/rspack.config.ts', 'utf-8')).toMatchSnapshot();
});
it('should generate a correct setup when --bundler=rspack and ssr', async () => {
await generateApp(appTree, 'app2', {
bundler: 'rspack',
ssr: true,
});
const project = readProjectConfiguration(appTree, 'app2');
expect(appTree.exists('app2/rspack.config.ts')).toBeTruthy();
expect(appTree.read('app2/rspack.config.ts', 'utf-8')).toMatchSnapshot();
expect(appTree.read('app2/src/server.ts', 'utf-8')).toMatchSnapshot();
});
it('should generate use crystal jest when --bundler=rspack', async () => {
await generateApp(appTree, 'app1', {
bundler: 'rspack',

View File

@ -1,8 +1,9 @@
import {
addDependenciesToPackageJson,
formatFiles,
generateFiles,
GeneratorCallback,
installPackagesTask,
joinPathFragments,
offsetFromRoot,
readNxJson,
Tree,
@ -115,6 +116,22 @@ export async function applicationGenerator(
skipInstall: options.skipPackageJson,
skipFormat: true,
});
if (options.ssr) {
generateFiles(
tree,
joinPathFragments(__dirname, './files/rspack-ssr'),
options.appProjectSourceRoot,
{
pathToDistFolder: joinPathFragments(
offsetFromRoot(options.appProjectRoot),
options.outputPath,
'browser'
),
tmpl: '',
}
);
}
}
if (!options.skipFormat) {

View File

@ -0,0 +1,72 @@
import 'zone.js/node';
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node';
import * as express from 'express';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import bootstrap from './main.server';
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const distFolder = join(process.cwd(), "<%= pathToDistFolder %>");
const indexHtml = existsSync(join(distFolder, 'index.original.html'))
? join(distFolder, 'index.original.html')
: join(distFolder, 'index.html');
const commonEngine = new CommonEngine();
server.set('view engine', 'html');
server.set('views', distFolder);
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get(
'*.*',
express.static(distFolder, {
maxAge: '1y',
})
);
// All regular routes use the Angular engine
server.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: distFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});
return server;
}
function run(): void {
const port = process.env['PORT'] || 4000;
// Start up the Node server
const server = app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = (mainModule && mainModule.filename) || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
run();
}
export default bootstrap;

View File

@ -102,6 +102,99 @@ describe('convert-to-rspack', () => {
expect(updatedProject.targets.serve).not.toBeDefined();
});
it('should convert a ssr angular webpack application to rspack', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();
addProjectConfiguration(tree, 'app', {
root: 'apps/app',
sourceRoot: 'apps/app/src',
projectType: 'application',
targets: {
build: {
executor: '@angular-devkit/build-angular:browser',
options: {
outputPath: 'dist/apps/app',
index: 'apps/app/src/index.html',
main: 'apps/app/src/main.ts',
polyfills: ['tslib'], // zone.js is not in nx repo's node_modules so simulating it with a package that is
tsConfig: 'apps/app/tsconfig.app.json',
assets: [
'apps/app/src/favicon.ico',
'apps/app/src/assets',
{ input: 'apps/app/public', glob: '**/*' },
],
styles: ['apps/app/src/styles.scss'],
scripts: [],
},
},
server: {
executor: '@angular-devkit/build-angular:server',
options: {
main: 'apps/app/src/server.ts',
},
},
},
});
writeJson(tree, 'apps/app/tsconfig.json', {});
updateJson(tree, 'package.json', (json) => {
json.scripts ??= {};
json.scripts.build = 'nx build';
return json;
});
// ACT
await convertToRspack(tree, { project: 'app' });
// ASSERT
const updatedProject = readProjectConfiguration(tree, 'app');
const pkgJson = readJson(tree, 'package.json');
const nxJson = readNxJson(tree);
expect(tree.read('apps/app/rspack.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { createConfig } from '@nx/angular-rspack';
export default createConfig({
options: {
root: __dirname,
outputPath: {
base: '../../dist/apps/app',
},
index: './src/index.html',
browser: './src/main.ts',
polyfills: ['tslib'],
tsConfig: './tsconfig.app.json',
assets: [
'./src/favicon.ico',
'./src/assets',
{
input: './public',
glob: '**/*',
},
],
styles: ['./src/styles.scss'],
scripts: [],
ssr: {
entry: './src/server.ts',
},
server: './src/main.server.ts',
},
});
"
`);
expect(pkgJson.devDependencies['@nx/angular-rspack']).toBeDefined();
expect(
nxJson.plugins.find((p) =>
typeof p === 'string' ? false : p.plugin === '@nx/rspack/plugin'
)
).toBeDefined();
expect(pkgJson.scripts?.build).toBeUndefined();
expect(updatedProject.targets.build).not.toBeDefined();
expect(updatedProject.targets.serve).not.toBeDefined();
});
it('should normalize paths to libs in workspace correctly', async () => {
// ARRANGE
const tree = createTreeWithEmptyWorkspace();

View File

@ -31,7 +31,9 @@ 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',
];
@ -41,21 +43,7 @@ const RENAMED_OPTIONS = {
ngswConfigPath: 'serviceWorker',
};
const REMOVED_OPTIONS = [
'publicHost',
'disableHostCheck',
'resourcesOutputPath',
'routesFile',
'routes',
'discoverRoutes',
'appModuleBundle',
'inputIndexPath',
'outputIndexPath',
'buildOptimizer',
'deployUrl',
'buildTarget',
'browserTarget',
];
const REMOVED_OPTIONS = ['buildOptimizer', 'buildTarget', 'browserTarget'];
function normalizeFromProjectRoot(
tree: Tree,
@ -382,6 +370,18 @@ export async function convertToRspack(
}
}
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' ||