Skip to content

Commit

Permalink
feat(@schematics/angular): add migration to migrate from `@nguniversa…
Browse files Browse the repository at this point in the history
…l` to `@angular/ssr`

This commit adds a migration to migrate usages of `@nguniversal` to `@angular/ssr`.
  • Loading branch information
alan-agius4 committed Oct 11, 2023
1 parent 1635fdc commit 3938863
Show file tree
Hide file tree
Showing 3 changed files with 360 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string /** Main Path */, string /** Output Path */>();
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;
`;
}
Original file line number Diff line number Diff line change
@@ -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');`);
});
});

0 comments on commit 3938863

Please sign in to comment.