From d908521f15692617dae53a5bed608e007c2fbcd8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 25 Nov 2022 12:13:13 +0000 Subject: [PATCH] feat(angular): add ssr flag to host generator (#13398) --- docs/generated/packages/angular.json | 5 + .../host/__snapshots__/host.spec.ts.snap | 178 ++++++++++++++++++ .../host/files/src/main.server.ts__tmpl__ | 66 +++++++ .../files/webpack.server.config.js__tmpl__ | 3 + .../angular/src/generators/host/host.spec.ts | 44 ++++- packages/angular/src/generators/host/host.ts | 12 +- .../src/generators/host/lib/add-ssr.ts | 69 +++++++ .../angular/src/generators/host/lib/index.ts | 1 + .../angular/src/generators/host/schema.d.ts | 1 + .../angular/src/generators/host/schema.json | 5 + 10 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 packages/angular/src/generators/host/files/src/main.server.ts__tmpl__ create mode 100644 packages/angular/src/generators/host/files/webpack.server.config.js__tmpl__ create mode 100644 packages/angular/src/generators/host/lib/add-ssr.ts create mode 100644 packages/angular/src/generators/host/lib/index.ts diff --git a/docs/generated/packages/angular.json b/docs/generated/packages/angular.json index 81b9ee63a5..5862f8ecfb 100644 --- a/docs/generated/packages/angular.json +++ b/docs/generated/packages/angular.json @@ -1452,6 +1452,11 @@ "type": "boolean", "description": "Whether to generate a host application that uses standalone components.", "default": false + }, + "ssr": { + "description": "Whether to configure SSR for the host application", + "type": "boolean", + "default": false } }, "additionalProperties": false, diff --git a/packages/angular/src/generators/host/__snapshots__/host.spec.ts.snap b/packages/angular/src/generators/host/__snapshots__/host.spec.ts.snap index 97aacef90a..c58a15ae8d 100644 --- a/packages/angular/src/generators/host/__snapshots__/host.spec.ts.snap +++ b/packages/angular/src/generators/host/__snapshots__/host.spec.ts.snap @@ -1,5 +1,183 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Host App Generator --ssr should generate the correct files 1`] = ` +"import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; + +import { AppComponent } from './app.component'; +import { NxWelcomeComponent } from './nx-welcome.component'; +import { RouterModule } from '@angular/router'; +import { appRoutes } from './app.routes'; + +@NgModule({ + declarations: [ + AppComponent, + NxWelcomeComponent + ], + imports: [ + BrowserModule.withServerTransition({ appId: 'serverApp' }), + RouterModule.forRoot(appRoutes, {initialNavigation: 'enabledBlocking'}) + ], + providers: [], + bootstrap: [AppComponent] +}) +export class AppModule { } +" +`; + +exports[`Host App Generator --ssr should generate the correct files 2`] = ` +"import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; + +function bootstrap() { + platformBrowserDynamic() + .bootstrapModule(AppModule) + .catch((err) => console.error(err)); +}; + + + if (document.readyState === 'complete') { + bootstrap(); + } else { + document.addEventListener('DOMContentLoaded', bootstrap); + }" +`; + +exports[`Host 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[`Host 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'); + + 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); + + // Serve static files from /browser + server.get( + '*.*', + express.static(browserBundles, { + maxAge: '1y', + }) + ); + + // All regular routes use the Universal engine + server.get('*', (req, res) => { + // keep it async to avoid blocking the server thread + + res.render(indexHtml, { + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + req, + }); + }); + + 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[`Host App Generator --ssr should generate the correct files 5`] = `"import('./src/main.server');"`; + +exports[`Host App Generator --ssr should generate the correct files 6`] = ` +"module.exports = { + name: 'test', + remotes: [] +}" +`; + +exports[`Host 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[`Host App Generator --ssr should generate the correct files 8`] = ` +"import { NxWelcomeComponent } from './nx-welcome.component'; + import { Route } from '@angular/router'; + +export const appRoutes: Route[] = [ + { + path: '', + component: NxWelcomeComponent + },]" +`; + +exports[`Host App Generator --ssr should generate the correct files 9`] = ` +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[`Host App Generator should generate a host app with a remote 1`] = ` "const { withModuleFederation } = require('@nrwl/angular/module-federation'); const config = require('./module-federation.config'); diff --git a/packages/angular/src/generators/host/files/src/main.server.ts__tmpl__ b/packages/angular/src/generators/host/files/src/main.server.ts__tmpl__ new file mode 100644 index 0000000000..909e5eee4a --- /dev/null +++ b/packages/angular/src/generators/host/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'); + + 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); + + // Serve static files from /browser + server.get( + '*.*', + express.static(browserBundles, { + maxAge: '1y', + }) + ); + + // All regular routes use the Universal engine + server.get('*', (req, res) => { + // keep it async to avoid blocking the server thread + + res.render(indexHtml, { + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + req, + }); + }); + + 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/host/files/webpack.server.config.js__tmpl__ b/packages/angular/src/generators/host/files/webpack.server.config.js__tmpl__ new file mode 100644 index 0000000000..a859109e6d --- /dev/null +++ b/packages/angular/src/generators/host/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/host/host.spec.ts b/packages/angular/src/generators/host/host.spec.ts index 1c8b217244..dcc065ba72 100644 --- a/packages/angular/src/generators/host/host.spec.ts +++ b/packages/angular/src/generators/host/host.spec.ts @@ -2,7 +2,10 @@ import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; import host from './host'; import remote from '../remote/remote'; import { E2eTestRunner } from '../../utils/test-runners'; -import { getProjects } from 'nx/src/generators/utils/project-configuration'; +import { + getProjects, + readProjectConfiguration, +} from 'nx/src/generators/utils/project-configuration'; describe('Host App Generator', () => { it('should generate a host app with no remotes', async () => { @@ -185,4 +188,43 @@ describe('Host App Generator', () => { expect(projects.has('dashboard-e2e')).toBeFalsy(); expect(projects.has('remote1-e2e')).toBeFalsy(); }); + + describe('--ssr', () => { + it('should generate the correct files', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await host(tree, { + name: 'test', + ssr: true, + }); + + // ASSERT + const project = readProjectConfiguration(tree, 'test'); + 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/app.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(project.targets.server).toMatchSnapshot(); + }); + }); }); diff --git a/packages/angular/src/generators/host/host.ts b/packages/angular/src/generators/host/host.ts index c5930be155..7caf4346e1 100644 --- a/packages/angular/src/generators/host/host.ts +++ b/packages/angular/src/generators/host/host.ts @@ -5,6 +5,8 @@ import remoteGenerator from '../remote/remote'; import { normalizeProjectName } from '../utils/project'; import { setupMf } from '../setup-mf/setup-mf'; import { E2eTestRunner } from '../../utils/test-runners'; +import { addSsr } from './lib'; +import { runTasksInSerial } from '@nrwl/workspace/src/utilities/run-tasks-in-serial'; export async function host(tree: Tree, options: Schema) { const projects = getProjects(tree); @@ -24,7 +26,7 @@ export async function host(tree: Tree, options: Schema) { const appName = normalizeProjectName(options.name, options.directory); - const installTask = await applicationGenerator(tree, { + const appInstallTask = await applicationGenerator(tree, { ...options, routing: true, port: 4200, @@ -46,6 +48,12 @@ export async function host(tree: Tree, options: Schema) { e2eProjectName: skipE2E ? undefined : `${appName}-e2e`, }); + let installTasks = [appInstallTask]; + if (options.ssr) { + let ssrInstallTask = await addSsr(tree, options, appName); + installTasks.push(ssrInstallTask); + } + for (const remote of remotesToGenerate) { await remoteGenerator(tree, { ...options, @@ -60,7 +68,7 @@ export async function host(tree: Tree, options: Schema) { await formatFiles(tree); } - return installTask; + return runTasksInSerial(...installTasks); } export default host; diff --git a/packages/angular/src/generators/host/lib/add-ssr.ts b/packages/angular/src/generators/host/lib/add-ssr.ts new file mode 100644 index 0000000000..2b2f0e0cc6 --- /dev/null +++ b/packages/angular/src/generators/host/lib/add-ssr.ts @@ -0,0 +1,69 @@ +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, + expressVersion, + 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');" + ); + + tree.rename( + joinPathFragments(project.sourceRoot, 'main.ts'), + joinPathFragments(project.sourceRoot, 'bootstrap.ts') + ); + tree.write( + joinPathFragments(project.sourceRoot, 'main.ts'), + `import("./bootstrap")` + ); + + 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, + express: expressVersion, + '@module-federation/node': moduleFederationNodeVersion, + }, + {} + ); + + return installTask; +} diff --git a/packages/angular/src/generators/host/lib/index.ts b/packages/angular/src/generators/host/lib/index.ts new file mode 100644 index 0000000000..b0b15a6bb4 --- /dev/null +++ b/packages/angular/src/generators/host/lib/index.ts @@ -0,0 +1 @@ +export * from './add-ssr'; diff --git a/packages/angular/src/generators/host/schema.d.ts b/packages/angular/src/generators/host/schema.d.ts index 1fe10f32e6..613e97fef1 100644 --- a/packages/angular/src/generators/host/schema.d.ts +++ b/packages/angular/src/generators/host/schema.d.ts @@ -26,4 +26,5 @@ export interface Schema { viewEncapsulation?: 'Emulated' | 'Native' | 'None'; skipFormat?: boolean; standalone?: boolean; + ssr?: boolean; } diff --git a/packages/angular/src/generators/host/schema.json b/packages/angular/src/generators/host/schema.json index 37373b3595..059e3cf7cc 100644 --- a/packages/angular/src/generators/host/schema.json +++ b/packages/angular/src/generators/host/schema.json @@ -154,6 +154,11 @@ "type": "boolean", "description": "Whether to generate a host application that uses standalone components.", "default": false + }, + "ssr": { + "description": "Whether to configure SSR for the host application", + "type": "boolean", + "default": false } }, "additionalProperties": false,