From 3938863b9900fcfe574b3112d73a8f34672f38bd Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 11 Oct 2023 09:53:31 +0000 Subject: [PATCH] feat(@schematics/angular): add migration to migrate from `@nguniversal` to `@angular/ssr` This commit adds a migration to migrate usages of `@nguniversal` to `@angular/ssr`. --- .../migrations/migration-collection.json | 5 + .../update-17/replace-nguniversal-engines.ts | 167 ++++++++++++++++ .../replace-nguniversal-engines_spec.ts | 188 ++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 packages/schematics/angular/migrations/update-17/replace-nguniversal-engines.ts create mode 100644 packages/schematics/angular/migrations/update-17/replace-nguniversal-engines_spec.ts diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index b7bd5a1a2c84..bf6f2c77362b 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -5,6 +5,11 @@ "factory": "./update-17/replace-nguniversal-builders", "description": "Replace usages of '@nguniversal/builders' with '@angular-devkit/build-angular'." }, + "replace-nguniversal-engines": { + "version": "17.0.0", + "factory": "./update-17/replace-nguniversal-engines", + "description": "Replace usages of '@nguniversal/' packages with '@angular/ssr'." + }, "update-workspace-config": { "version": "17.0.0", "factory": "./update-17/update-workspace-config", diff --git a/packages/schematics/angular/migrations/update-17/replace-nguniversal-engines.ts b/packages/schematics/angular/migrations/update-17/replace-nguniversal-engines.ts new file mode 100644 index 000000000000..31d39c09f37f --- /dev/null +++ b/packages/schematics/angular/migrations/update-17/replace-nguniversal-engines.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { DirEntry, Rule, chain } from '@angular-devkit/schematics'; +import { addDependency } from '../../utility'; +import { removePackageJsonDependency } from '../../utility/dependencies'; +import { latestVersions } from '../../utility/latest-versions'; +import { allTargetOptions, getWorkspace } from '../../utility/workspace'; +import { Builders, ProjectType } from '../../utility/workspace-models'; + +function* visit(directory: DirEntry): IterableIterator<[fileName: string, contents: string]> { + for (const path of directory.subfiles) { + if (path.endsWith('.ts') && !path.endsWith('.d.ts')) { + const entry = directory.file(path); + if (entry) { + const content = entry.content; + if (content.includes('@nguniversal/')) { + // Only need to rename the import so we can just string replacements. + yield [entry.path, content.toString()]; + } + } + } + } + + for (const path of directory.subdirs) { + if (path === 'node_modules' || path.startsWith('.')) { + continue; + } + + yield* visit(directory.dir(path)); + } +} + +/** + * Regexp to match Universal packages. + * @nguniversal/common/engine + * @nguniversal/common + * @nguniversal/express-engine + **/ +const NGUNIVERSAL_PACKAGE_REGEXP = /@nguniversal\/(common(\/engine)?|express-engine)/g; + +export default function (): Rule { + return chain([ + async (tree) => { + // Replace server file. + const workspace = await getWorkspace(tree); + for (const [, project] of workspace.projects) { + if (project.extensions.projectType !== ProjectType.Application) { + continue; + } + + const serverMainFiles = new Map(); + for (const [, target] of project.targets) { + if (target.builder !== Builders.Server) { + continue; + } + + const outputPath = project.targets.get('build')?.options?.outputPath; + + for (const [, { main }] of allTargetOptions(target, false)) { + if ( + typeof main === 'string' && + typeof outputPath === 'string' && + tree.readText(main).includes('ngExpressEngine') + ) { + serverMainFiles.set(main, outputPath); + } + } + } + + // Replace server file + for (const [path, outputPath] of serverMainFiles.entries()) { + tree.rename(path, path + '.bak'); + tree.create(path, getServerFileContents(outputPath)); + } + } + + // Replace all import specifiers in all files. + for (const file of visit(tree.root)) { + const [path, content] = file; + tree.overwrite(path, content.replaceAll(NGUNIVERSAL_PACKAGE_REGEXP, '@angular/ssr')); + } + + // Remove universal packages from deps. + removePackageJsonDependency(tree, '@nguniversal/express-engine'); + removePackageJsonDependency(tree, '@nguniversal/common'); + }, + addDependency('@angular/ssr', latestVersions.Angular), + ]); +} + +function getServerFileContents(outputPath: string): string { + return ` +import 'zone.js/node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { CommonEngine } from '@angular/ssr'; +import * as express from 'express'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import bootstrap from './src/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(), '${outputPath}'); + 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) => { + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + url: req.originalUrl, + publicPath: distFolder, + providers: [{ provide: APP_BASE_HREF, useValue: req.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/schematics/angular/migrations/update-17/replace-nguniversal-engines_spec.ts b/packages/schematics/angular/migrations/update-17/replace-nguniversal-engines_spec.ts new file mode 100644 index 000000000000..47921c752044 --- /dev/null +++ b/packages/schematics/angular/migrations/update-17/replace-nguniversal-engines_spec.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { Builders, ProjectType, WorkspaceSchema } from '../../utility/workspace-models'; + +function createWorkSpaceConfig(tree: UnitTestTree) { + const angularConfig: WorkspaceSchema = { + version: 1, + projects: { + app: { + root: '', + sourceRoot: '/src', + projectType: ProjectType.Application, + prefix: 'app', + architect: { + build: { + builder: Builders.Browser, + options: { + tsConfig: 'tsconfig.json', + main: 'main.ts', + polyfills: '', + outputPath: 'dist/browser', + }, + }, + server: { + builder: Builders.Server, + options: { + tsConfig: 'tsconfig.json', + main: 'server.ts', + outputPath: 'dist/server', + }, + configurations: { + production: { + main: 'server.ts', + }, + }, + }, + }, + }, + }, + }; + + tree.create('/angular.json', JSON.stringify(angularConfig, undefined, 2)); +} + +describe(`Migration to replace usages of '@nguniversal/' packages with '@angular/ssr'.`, () => { + const schematicName = 'replace-nguniversal-engines'; + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + let tree: UnitTestTree; + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + + createWorkSpaceConfig(tree); + tree.create( + '/package.json', + JSON.stringify( + { + dependencies: { + '@nguniversal/common': '0.0.0', + '@nguniversal/express-engine': '0.0.0', + }, + }, + undefined, + 2, + ), + ); + + tree.create( + 'server.ts', + ` + import 'zone.js/node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { ngExpressEngine } from '@nguniversal/express-engine'; +import * as express from 'express'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +import { AppServerModule } from './src/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/browser'); + const indexHtml = existsSync(join(distFolder, '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, + inlineCriticalCss: true, + }), + ); + + 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 Universal engine + server.get('*', (req, res) => { + res.render(indexHtml, { req, providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] }); + }); + + return server; +} + +function run() { + const port = process.env.PORT || 4000; + + // Start up the Node server + const server = app(); + server.listen(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(); +} `, + ); + }); + + it(`should remove all '@nguniversal/' from dependencies`, async () => { + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const { dependencies } = JSON.parse(newTree.readContent('/package.json')); + expect(dependencies['@nguniversal/common']).toBeUndefined(); + expect(dependencies['@nguniversal/express-engine']).toBeUndefined(); + }); + + it(`should add '@angular/ssr' as a dependencies`, async () => { + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const { dependencies } = JSON.parse(newTree.readContent('/package.json')); + expect(dependencies['@angular/ssr']).toBeDefined(); + }); + + it(`should replace imports from '@nguniversal/common' to '@angular/ssr'`, async () => { + tree.create( + 'file.ts', + ` + import { CommonEngine } from '@nguniversal/common'; + import { Component } from '@angular/core'; + `, + ); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + expect(newTree.readContent('/file.ts')).toContain( + `import { CommonEngine } from '@angular/ssr';`, + ); + }); + + it(`should replace anf backup 'server.ts' file`, async () => { + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + expect(newTree.readContent('server.ts.bak')).toContain( + `import { ngExpressEngine } from '@nguniversal/express-engine';`, + ); + + const newServerFile = newTree.readContent('server.ts'); + expect(newServerFile).toContain(`import { CommonEngine } from '@angular/ssr';`); + expect(newServerFile).toContain(`const distFolder = join(process.cwd(), 'dist/browser');`); + }); +});