From 2471768ce173f055e769a440f1f5da5d0bf008da Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 24 Nov 2022 11:06:40 +0000 Subject: [PATCH] feat(angular): add --ssr flag to remote generator (#13370) --- docs/generated/packages/angular.json | 5 + e2e/angular-core/src/projects.test.ts | 34 ++++ .../remote/__snapshots__/remote.spec.ts.snap | 182 ++++++++++++++++++ .../remote/files/src/main.server.ts__tmpl__ | 66 +++++++ .../files/webpack.server.config.js__tmpl__ | 3 + .../src/generators/remote/lib/add-ssr.ts | 58 ++++++ .../remote/lib/find-next-available-port.ts | 18 ++ .../src/generators/remote/lib/index.ts | 2 + .../src/generators/remote/remote.spec.ts | 48 +++++ .../angular/src/generators/remote/remote.ts | 35 ++-- .../angular/src/generators/remote/schema.d.ts | 1 + .../angular/src/generators/remote/schema.json | 5 + .../setup-ssr/lib/update-app-module.ts | 2 +- .../generators/setup-ssr/setup-ssr.spec.ts | 3 +- packages/angular/src/utils/versions.ts | 2 + 15 files changed, 438 insertions(+), 26 deletions(-) create mode 100644 packages/angular/src/generators/remote/files/src/main.server.ts__tmpl__ create mode 100644 packages/angular/src/generators/remote/files/webpack.server.config.js__tmpl__ create mode 100644 packages/angular/src/generators/remote/lib/add-ssr.ts create mode 100644 packages/angular/src/generators/remote/lib/find-next-available-port.ts create mode 100644 packages/angular/src/generators/remote/lib/index.ts diff --git a/docs/generated/packages/angular.json b/docs/generated/packages/angular.json index a7a9802d3cd5a..81b9ee63a523c 100644 --- a/docs/generated/packages/angular.json +++ b/docs/generated/packages/angular.json @@ -1195,6 +1195,11 @@ "description": "Whether to generate a remote application with standalone components.", "type": "boolean", "default": false + }, + "ssr": { + "description": "Whether to configure SSR for the remote application to be consumed by a host application using SSR.", + "type": "boolean", + "default": false } }, "additionalProperties": false, diff --git a/e2e/angular-core/src/projects.test.ts b/e2e/angular-core/src/projects.test.ts index ce90019b6fcbf..52747988ddcb2 100644 --- a/e2e/angular-core/src/projects.test.ts +++ b/e2e/angular-core/src/projects.test.ts @@ -363,6 +363,40 @@ describe('Angular Projects', () => { expect(buildOutput).toContain('Successfully ran target build'); }, 300000); + it('MF - should serve a ssr remote app successfully', async () => { + // ARRANGE + const remoteApp1 = uniq('remote'); + // generate remote apps + runCLI( + `generate @nrwl/angular:remote ${remoteApp1} --ssr --no-interactive` + ); + + let process: ChildProcess; + + try { + process = await runCommandUntil(`serve-ssr ${remoteApp1}`, (output) => { + return ( + output.includes(`Browser application bundle generation complete.`) && + output.includes(`Server application bundle generation complete.`) && + output.includes( + `Angular Universal Live Development Server is listening` + ) + ); + }); + } catch (err) { + console.error(err); + } + + // port and process cleanup + try { + if (process && process.pid) { + await promisifiedTreeKill(process.pid, 'SIGKILL'); + } + } catch (err) { + expect(err).toBeFalsy(); + } + }, 300000); + it('Custom Webpack Config for SSR - should serve the app correctly', async () => { // ARRANGE const ssrApp = uniq('app'); diff --git a/packages/angular/src/generators/remote/__snapshots__/remote.spec.ts.snap b/packages/angular/src/generators/remote/__snapshots__/remote.spec.ts.snap index 02633d0173c21..4effca8f98bce 100644 --- a/packages/angular/src/generators/remote/__snapshots__/remote.spec.ts.snap +++ b/packages/angular/src/generators/remote/__snapshots__/remote.spec.ts.snap @@ -1,5 +1,187 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`MF Remote App Generator --ssr should generate the correct files 1`] = ` +"import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app.component'; + +@NgModule({ + declarations: [AppComponent], + imports: [ + BrowserModule.withServerTransition({ appId: 'serverApp' }), + RouterModule.forRoot([{ + path: '', + loadChildren: () => import('./remote-entry/entry.module').then(m => m.RemoteEntryModule) + }], { initialNavigation: 'enabledBlocking' }), + ], + providers: [], + bootstrap: [AppComponent], +}) +export class AppModule {}" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files 2`] = ` +"import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; + + +platformBrowserDynamic().bootstrapModule(AppModule) + .catch(err => console.error(err)); +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files 3`] = ` +"/*************************************************************************************************** + * Initialize the server environment - for example, adding DOM built-in types to the global scope. + * + * NOTE: + * This import must come before any imports (direct or transitive) that rely on DOM built-ins being + * available, such as \`@angular/elements\`. + */ +import '@angular/platform-server/init'; + +export { AppServerModule } from './app/app.server.module'; +export { renderModule } from '@angular/platform-server';" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files 4`] = ` +"import 'zone.js/dist/zone-node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { ngExpressEngine } from '@nguniversal/express-engine'; +import * as express from 'express'; +import * as cors from 'cors'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +import { AppServerModule } from './bootstrap.server'; + +// The Express app is exported so that it can be used by serverless Functions. +export function app(): express.Express { + const server = express(); + const browserBundles = join(process.cwd(), 'dist/apps/test/browser'); + const serverBundles = join(process.cwd(), 'dist/apps/test/server'); + + server.use(cors()); + const indexHtml = existsSync(join(browserBundles, 'index.original.html')) + ? 'index.original.html' + : 'index'; + + // Our Universal express-engine (found @ https://github.com/angular/universal/tree/main/modules/express-engine) + server.engine( + 'html', + ngExpressEngine({ + bootstrap: AppServerModule, + }) + ); + + server.set('view engine', 'html'); + server.set('views', browserBundles); + + + // Example Express Rest API endpoints + // server.get('/api/**', (req, res) => { }); + // Serve static files from /browser + // serve static files + server.use('/', express.static(browserBundles, { maxAge: '1y' })); + server.use('/server', express.static(serverBundles, { maxAge: '1y' })); + + // All regular routes use the Universal engine + server.get('*', (req, res) => { + + res.render(indexHtml, { + req, + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + }); + }); + + 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}\`); + }); +} + +run(); + +export * from './bootstrap.server';" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files 5`] = `"import('./src/main.server');"`; + +exports[`MF Remote App Generator --ssr should generate the correct files 6`] = ` +"module.exports = { + name: 'test', + exposes: { + './Module': 'apps/test/src/app/remote-entry/entry.module.ts', + }, +}" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files 7`] = ` +"const { withModuleFederationForSSR } = require('@nrwl/angular/module-federation'); +const config = require('./module-federation.config'); +module.exports = withModuleFederationForSSR(config)" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files 8`] = ` +"import { Component } from '@angular/core'; + +@Component({ + selector: 'proj-test-entry', + template: \`\` +}) +export class RemoteEntryComponent {} +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files 9`] = ` +"import { Route } from '@angular/router'; + +export const appRoutes: Route[] = [ + {path: '', loadChildren: () => import('./remote-entry/entry.module').then(m => m.RemoteEntryModule)},]" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files 10`] = ` +"import { Route } from '@angular/router'; +import { RemoteEntryComponent } from './entry.component'; + +export const remoteRoutes: Route[] = [{ path: '', component: RemoteEntryComponent }];" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files 11`] = ` +Object { + "configurations": Object { + "development": Object { + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + }, + "production": Object { + "outputHashing": "media", + }, + }, + "defaultConfiguration": "production", + "executor": "@nrwl/angular:webpack-server", + "options": Object { + "customWebpackConfig": Object { + "path": "apps/test/webpack.server.config.js", + }, + "main": "apps/test/server.ts", + "outputPath": "dist/apps/test/server", + "tsConfig": "apps/test/tsconfig.server.json", + }, +} +`; + exports[`MF Remote App Generator should generate a remote mf app with a host 1`] = ` "const { withModuleFederation } = require('@nrwl/angular/module-federation'); const config = require('./module-federation.config'); diff --git a/packages/angular/src/generators/remote/files/src/main.server.ts__tmpl__ b/packages/angular/src/generators/remote/files/src/main.server.ts__tmpl__ new file mode 100644 index 0000000000000..5c5b49f19b517 --- /dev/null +++ b/packages/angular/src/generators/remote/files/src/main.server.ts__tmpl__ @@ -0,0 +1,66 @@ +import 'zone.js/dist/zone-node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { ngExpressEngine } from '@nguniversal/express-engine'; +import * as express from 'express'; +import * as cors from 'cors'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +import { AppServerModule } from './bootstrap.server'; + +// The Express app is exported so that it can be used by serverless Functions. +export function app(): express.Express { + const server = express(); + const browserBundles = join(process.cwd(), 'dist/apps/<%= appName %>/browser'); + const serverBundles = join(process.cwd(), 'dist/apps/<%= appName %>/server'); + + server.use(cors()); + const indexHtml = existsSync(join(browserBundles, 'index.original.html')) + ? 'index.original.html' + : 'index'; + + // Our Universal express-engine (found @ https://github.com/angular/universal/tree/main/modules/express-engine) + server.engine( + 'html', + ngExpressEngine({ + bootstrap: AppServerModule, + }) + ); + + server.set('view engine', 'html'); + server.set('views', browserBundles); + + + // Example Express Rest API endpoints + // server.get('/api/**', (req, res) => { }); + // Serve static files from /browser + // serve static files + server.use('/', express.static(browserBundles, { maxAge: '1y' })); + server.use('/server', express.static(serverBundles, { maxAge: '1y' })); + + // All regular routes use the Universal engine + server.get('*', (req, res) => { + + res.render(indexHtml, { + req, + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + }); + }); + + 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}`); + }); +} + +run(); + +export * from './bootstrap.server'; \ No newline at end of file diff --git a/packages/angular/src/generators/remote/files/webpack.server.config.js__tmpl__ b/packages/angular/src/generators/remote/files/webpack.server.config.js__tmpl__ new file mode 100644 index 0000000000000..a859109e6d32d --- /dev/null +++ b/packages/angular/src/generators/remote/files/webpack.server.config.js__tmpl__ @@ -0,0 +1,3 @@ +const { withModuleFederationForSSR } = require('@nrwl/angular/module-federation'); +const config = require('./module-federation.config'); +module.exports = withModuleFederationForSSR(config) \ No newline at end of file diff --git a/packages/angular/src/generators/remote/lib/add-ssr.ts b/packages/angular/src/generators/remote/lib/add-ssr.ts new file mode 100644 index 0000000000000..599a4d0aef32f --- /dev/null +++ b/packages/angular/src/generators/remote/lib/add-ssr.ts @@ -0,0 +1,58 @@ +import type { Tree } from '@nrwl/devkit'; +import { + addDependenciesToPackageJson, + generateFiles, + joinPathFragments, + readProjectConfiguration, + updateProjectConfiguration, +} from '@nrwl/devkit'; +import type { Schema } from '../schema'; + +import setupSsr from '../../setup-ssr/setup-ssr'; +import { + corsVersion, + moduleFederationNodeVersion, +} from '../../../utils/versions'; + +export async function addSsr(tree: Tree, options: Schema, appName: string) { + let project = readProjectConfiguration(tree, appName); + + await setupSsr(tree, { + project: appName, + }); + + tree.rename( + joinPathFragments(project.sourceRoot, 'main.server.ts'), + joinPathFragments(project.sourceRoot, 'bootstrap.server.ts') + ); + tree.write( + joinPathFragments(project.root, 'server.ts'), + "import('./src/main.server');" + ); + + generateFiles(tree, joinPathFragments(__dirname, '../files'), project.root, { + appName, + tmpl: '', + }); + + // update project.json + project = readProjectConfiguration(tree, appName); + + project.targets.server.executor = '@nrwl/angular:webpack-server'; + project.targets.server.options.customWebpackConfig = { + path: joinPathFragments(project.root, 'webpack.server.config.js'), + }; + + updateProjectConfiguration(tree, appName, project); + + const installTask = addDependenciesToPackageJson( + tree, + { + cors: corsVersion, + '@module-federation/node': moduleFederationNodeVersion, + }, + {} + ); + + return installTask; +} diff --git a/packages/angular/src/generators/remote/lib/find-next-available-port.ts b/packages/angular/src/generators/remote/lib/find-next-available-port.ts new file mode 100644 index 0000000000000..ebee855f00cbd --- /dev/null +++ b/packages/angular/src/generators/remote/lib/find-next-available-port.ts @@ -0,0 +1,18 @@ +import type { Tree } from '@nrwl/devkit'; +import { readProjectConfiguration } from '@nrwl/devkit'; +import { getMFProjects } from '../../../utils/get-mf-projects'; + +export function findNextAvailablePort(tree: Tree) { + const mfProjects = getMFProjects(tree); + + const ports = new Set([4200]); + for (const mfProject of mfProjects) { + const { targets } = readProjectConfiguration(tree, mfProject); + const port = targets?.serve?.options?.port ?? 4200; + ports.add(port); + } + + const nextAvailablePort = Math.max(...ports) + 1; + + return nextAvailablePort; +} diff --git a/packages/angular/src/generators/remote/lib/index.ts b/packages/angular/src/generators/remote/lib/index.ts new file mode 100644 index 0000000000000..ec1360ed68935 --- /dev/null +++ b/packages/angular/src/generators/remote/lib/index.ts @@ -0,0 +1,2 @@ +export * from './find-next-available-port'; +export * from './add-ssr'; diff --git a/packages/angular/src/generators/remote/remote.spec.ts b/packages/angular/src/generators/remote/remote.spec.ts index d075a34329d35..c638d8fc0bc26 100644 --- a/packages/angular/src/generators/remote/remote.spec.ts +++ b/packages/angular/src/generators/remote/remote.spec.ts @@ -195,4 +195,52 @@ describe('MF Remote App Generator', () => { 'proj-test-entry' ); }); + + describe('--ssr', () => { + it('should generate the correct files', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + // ACT + await remote(tree, { + name: 'test', + ssr: true, + }); + + // ASSERT + const project = readProjectConfiguration(tree, 'test'); + expect( + tree.exists(`apps/test/src/app/remote-entry/entry.module.ts`) + ).toBeTruthy(); + expect( + tree.read(`apps/test/src/app/app.module.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`apps/test/src/bootstrap.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`apps/test/src/bootstrap.server.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`apps/test/src/main.server.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(tree.read(`apps/test/server.ts`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`apps/test/module-federation.config.js`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`apps/test/webpack.server.config.js`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`apps/test/src/app/remote-entry/entry.component.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`apps/test/src/app/app.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`apps/test/src/app/remote-entry/entry.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(project.targets.server).toMatchSnapshot(); + }); + }); }); diff --git a/packages/angular/src/generators/remote/remote.ts b/packages/angular/src/generators/remote/remote.ts index 7ded7fd742065..ca583e11db0a1 100644 --- a/packages/angular/src/generators/remote/remote.ts +++ b/packages/angular/src/generators/remote/remote.ts @@ -1,30 +1,11 @@ -import { - formatFiles, - getProjects, - readProjectConfiguration, - Tree, -} from '@nrwl/devkit'; +import { formatFiles, getProjects, Tree } from '@nrwl/devkit'; import type { Schema } from './schema'; import applicationGenerator from '../application/application'; -import { getMFProjects } from '../../utils/get-mf-projects'; import { normalizeProjectName } from '../utils/project'; import { setupMf } from '../setup-mf/setup-mf'; import { E2eTestRunner } from '../../utils/test-runners'; - -function findNextAvailablePort(tree: Tree) { - const mfProjects = getMFProjects(tree); - - const ports = new Set([4200]); - for (const mfProject of mfProjects) { - const { targets } = readProjectConfiguration(tree, mfProject); - const port = targets?.serve?.options?.port ?? 4200; - ports.add(port); - } - - const nextAvailablePort = Math.max(...ports) + 1; - - return nextAvailablePort; -} +import { addSsr, findNextAvailablePort } from './lib'; +import { runTasksInSerial } from '@nrwl/workspace/src/utilities/run-tasks-in-serial'; export async function remote(tree: Tree, options: Schema) { const projects = getProjects(tree); @@ -37,7 +18,7 @@ export async function remote(tree: Tree, options: Schema) { const appName = normalizeProjectName(options.name, options.directory); const port = options.port ?? findNextAvailablePort(tree); - const installTask = await applicationGenerator(tree, { + const appInstallTask = await applicationGenerator(tree, { ...options, routing: true, skipDefaultProject: true, @@ -60,11 +41,17 @@ export async function remote(tree: Tree, options: Schema) { standalone: options.standalone, }); + let installTasks = [appInstallTask]; + if (options.ssr) { + let ssrInstallTask = await addSsr(tree, options, appName); + installTasks.push(ssrInstallTask); + } + if (!options.skipFormat) { await formatFiles(tree); } - return installTask; + return runTasksInSerial(...installTasks); } export default remote; diff --git a/packages/angular/src/generators/remote/schema.d.ts b/packages/angular/src/generators/remote/schema.d.ts index 51a2aae188c97..de11d136c1d30 100644 --- a/packages/angular/src/generators/remote/schema.d.ts +++ b/packages/angular/src/generators/remote/schema.d.ts @@ -25,4 +25,5 @@ export interface Schema { viewEncapsulation?: 'Emulated' | 'Native' | 'None'; skipFormat?: boolean; standalone?: boolean; + ssr?: boolean; } diff --git a/packages/angular/src/generators/remote/schema.json b/packages/angular/src/generators/remote/schema.json index cd9f32ea75c1d..059612aa2baa9 100644 --- a/packages/angular/src/generators/remote/schema.json +++ b/packages/angular/src/generators/remote/schema.json @@ -149,6 +149,11 @@ "description": "Whether to generate a remote application with standalone components.", "type": "boolean", "default": false + }, + "ssr": { + "description": "Whether to configure SSR for the remote application to be consumed by a host application using SSR.", + "type": "boolean", + "default": false } }, "additionalProperties": false, diff --git a/packages/angular/src/generators/setup-ssr/lib/update-app-module.ts b/packages/angular/src/generators/setup-ssr/lib/update-app-module.ts index 1a9c55c25e723..da9183868381f 100644 --- a/packages/angular/src/generators/setup-ssr/lib/update-app-module.ts +++ b/packages/angular/src/generators/setup-ssr/lib/update-app-module.ts @@ -32,7 +32,7 @@ export function updateAppModule(tree: Tree, schema: Schema) { browserModuleNode.getEnd() )}.withServerTransition({ appId: '${schema.appId}' })${fileContents.slice( browserModuleNode.getEnd(), - -1 + fileContents.length )}`; tree.write(pathToAppModule, newFileContents); diff --git a/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts b/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts index ca54bc3fc2af9..c26522d08ce62 100644 --- a/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts +++ b/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts @@ -113,7 +113,8 @@ describe('setupSSR', () => { providers: [], bootstrap: [AppComponent] }) - export class AppModule { }" + export class AppModule { } + " `); const packageJson = readJson(tree, 'package.json'); const dependencies = { diff --git a/packages/angular/src/utils/versions.ts b/packages/angular/src/utils/versions.ts index 5d7b7f57cd24c..cdfa452367889 100644 --- a/packages/angular/src/utils/versions.ts +++ b/packages/angular/src/utils/versions.ts @@ -10,6 +10,8 @@ export const angularJsVersion = '1.7.9'; export const tsLibVersion = '^2.3.0'; export const ngUniversalVersion = '~15.0.0'; +export const corsVersion = '~2.8.5'; +export const moduleFederationNodeVersion = '~0.9.6'; export const angularEslintVersion = '~15.0.0'; export const tailwindVersion = '^3.0.2';