diff --git a/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap b/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap index 52f76fbc54..98f8baa895 100644 --- a/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap +++ b/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap @@ -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'; diff --git a/packages/angular/src/generators/application/application.spec.ts b/packages/angular/src/generators/application/application.spec.ts index 2029bacb84..ebae1dfa1b 100644 --- a/packages/angular/src/generators/application/application.spec.ts +++ b/packages/angular/src/generators/application/application.spec.ts @@ -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', diff --git a/packages/angular/src/generators/application/application.ts b/packages/angular/src/generators/application/application.ts index 3838d3907c..a81d5e63a1 100644 --- a/packages/angular/src/generators/application/application.ts +++ b/packages/angular/src/generators/application/application.ts @@ -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) { diff --git a/packages/angular/src/generators/application/files/rspack-ssr/server.ts__tmpl__ b/packages/angular/src/generators/application/files/rspack-ssr/server.ts__tmpl__ new file mode 100644 index 0000000000..a50d81a424 --- /dev/null +++ b/packages/angular/src/generators/application/files/rspack-ssr/server.ts__tmpl__ @@ -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; diff --git a/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.spec.ts b/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.spec.ts index 2cd5b3d94b..d601190e34 100644 --- a/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.spec.ts +++ b/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.spec.ts @@ -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(); diff --git a/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.ts b/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.ts index 0e3c5d9eb7..8b0d1184aa 100644 --- a/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.ts +++ b/packages/angular/src/generators/convert-to-rspack/convert-to-rspack.ts @@ -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' ||