From 15d3fc6dc3f74462818b3745f6fb4995212a4d22 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Mon, 19 Sep 2022 17:51:40 +0000 Subject: [PATCH] feat(@angular-devkit/build-angular): export `@angular/platform-server` symbols in server bundle This commit adds an internal file to export needed symbols from `@angular/platform-server` when building a server bundle. This is needed. This is needed so that DI tokens can be referenced and set at runtime outside of the bundle. Also, it adds a migration to remove these exports from the users files as otherwise an export collision would occur due to the same symbol being exported multiple times. --- .../src/builders/server/index.ts | 38 ++++++- .../server/platform-server-exports-loader.ts | 28 ++++++ .../src/webpack/configs/dev-server.ts | 2 +- .../src/webpack/plugins/hmr/hmr-loader.ts | 11 +-- .../test/hello-world-app/src/main.server.ts | 1 - .../migrations/migration-collection.json | 5 + .../remove-platform-server-exports.ts | 99 +++++++++++++++++++ .../remove-platform-server-exports_spec.ts | 85 ++++++++++++++++ .../src/__main@stripTsExtension__.ts.template | 1 - .../e2e/tests/build/platform-server.ts | 3 +- 10 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts create mode 100644 packages/schematics/angular/migrations/update-15/remove-platform-server-exports.ts create mode 100644 packages/schematics/angular/migrations/update-15/remove-platform-server-exports_spec.ts diff --git a/packages/angular_devkit/build_angular/src/builders/server/index.ts b/packages/angular_devkit/build_angular/src/builders/server/index.ts index e9f424769f43..554d7d333e0a 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/server/index.ts @@ -8,11 +8,10 @@ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; import { runWebpack } from '@angular-devkit/build-webpack'; -import { tags } from '@angular-devkit/core'; import * as path from 'path'; import { Observable, from } from 'rxjs'; import { concatMap, map } from 'rxjs/operators'; -import webpack from 'webpack'; +import webpack, { Configuration } from 'webpack'; import { ExecutionTransformer } from '../../transforms'; import { NormalizedBrowserBuilderSchema, deleteOutputDir } from '../../utils'; import { i18nInlineEmittedFiles } from '../../utils/i18n-inlining'; @@ -20,7 +19,10 @@ import { I18nOptions } from '../../utils/i18n-options'; import { ensureOutputPaths } from '../../utils/output-paths'; import { purgeStaleBuildCache } from '../../utils/purge-cache'; import { assertCompatibleAngularVersion } from '../../utils/version'; -import { generateI18nBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config'; +import { + BrowserWebpackConfigOptions, + generateI18nBrowserWebpackConfigFromContext, +} from '../../utils/webpack-browser-config'; import { getCommonConfig, getStylesConfig } from '../../webpack/configs'; import { webpackStatsLogger } from '../../webpack/utils/stats'; import { Schema as ServerBuilderOptions } from './schema'; @@ -152,7 +154,7 @@ async function initialize( // We use the platform to determine the JavaScript syntax output. wco.buildOptions.supportedBrowsers.push(...browserslist('maintained node versions')); - return [getCommonConfig(wco), getStylesConfig(wco)]; + return [getPlatformServerExportsConfig(wco), getCommonConfig(wco), getStylesConfig(wco)]; }, ); @@ -164,3 +166,31 @@ async function initialize( return { config: transformedConfig, i18n }; } + +/** + * Add `@angular/platform-server` exports. + * This is needed so that DI tokens can be referenced and set at runtime outside of the bundle. + */ +function getPlatformServerExportsConfig(wco: BrowserWebpackConfigOptions): Partial { + // Add `@angular/platform-server` exports. + // This is needed so that DI tokens can be referenced and set at runtime outside of the bundle. + try { + // Only add `@angular/platform-server` exports when it is installed. + // In some cases this builder is used when `@angular/platform-server` is not installed. + // Example: when using `@nguniversal/common/clover` which does not need `@angular/platform-server`. + require.resolve('@angular/platform-server', { paths: [wco.root] }); + } catch { + return {}; + } + + return { + module: { + rules: [ + { + loader: require.resolve('./platform-server-exports-loader'), + include: [path.resolve(wco.root, wco.buildOptions.main)], + }, + ], + }, + }; +} diff --git a/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts b/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts new file mode 100644 index 000000000000..51b0c5741374 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/server/platform-server-exports-loader.ts @@ -0,0 +1,28 @@ +/** + * @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 + */ + +/** + * This loader is needed to add additional exports and is a workaround for a Webpack bug that doesn't + * allow exports from multiple files in the same entry. + * @see https://github.com/webpack/webpack/issues/15936. + */ +export default function ( + this: import('webpack').LoaderContext<{}>, + content: string, + map: Parameters[1], +) { + const source = `${content} + + // EXPORTS added by @angular-devkit/build-angular + export { renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; + `; + + this.callback(null, source, map); + + return; +} diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts b/packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts index 1f994be74b7f..6d542ca46c1a 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts @@ -33,7 +33,7 @@ export async function getDevServerConfig( if (hmr) { extraRules.push({ loader: HmrLoader, - include: [main].map((p) => resolve(wco.root, p)), + include: [resolve(wco.root, main)], }); } diff --git a/packages/angular_devkit/build_angular/src/webpack/plugins/hmr/hmr-loader.ts b/packages/angular_devkit/build_angular/src/webpack/plugins/hmr/hmr-loader.ts index 8526b447f5e7..45e80733f695 100644 --- a/packages/angular_devkit/build_angular/src/webpack/plugins/hmr/hmr-loader.ts +++ b/packages/angular_devkit/build_angular/src/webpack/plugins/hmr/hmr-loader.ts @@ -11,14 +11,11 @@ import { join } from 'path'; export const HmrLoader = __filename; const hmrAcceptPath = join(__dirname, './hmr-accept.js').replace(/\\/g, '/'); -export default function ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this: any, +export default function localizeExtractLoader( + this: import('webpack').LoaderContext<{}>, content: string, - // Source map types are broken in the webpack type definitions - // eslint-disable-next-line @typescript-eslint/no-explicit-any - map: any, -): void { + map: Parameters[1], +) { const source = `${content} // HMR Accept Code diff --git a/packages/angular_devkit/build_angular/test/hello-world-app/src/main.server.ts b/packages/angular_devkit/build_angular/test/hello-world-app/src/main.server.ts index 97e03b7b47fb..817a21636ff6 100644 --- a/packages/angular_devkit/build_angular/test/hello-world-app/src/main.server.ts +++ b/packages/angular_devkit/build_angular/test/hello-world-app/src/main.server.ts @@ -16,4 +16,3 @@ if (environment.production) { } export { AppServerModule } from './app/app.server.module'; -export { renderModule } from '@angular/platform-server'; diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index 0e5d79a4bf99..ac1dcc473d78 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -4,6 +4,11 @@ "version": "15.0.0", "factory": "./update-15/remove-browserslist-config", "description": "Remove Browserslist configuration files that matches the Angular CLI default configuration." + }, + "remove-platform-server-exports": { + "version": "15.0.0", + "factory": "./update-15/remove-platform-server-exports", + "description": "Remove exported `@angular/platform-server` `renderModule` method. The `renderModule` method is now exported by the Angular CLI." } } } diff --git a/packages/schematics/angular/migrations/update-15/remove-platform-server-exports.ts b/packages/schematics/angular/migrations/update-15/remove-platform-server-exports.ts new file mode 100644 index 000000000000..11ccd3fa7dfc --- /dev/null +++ b/packages/schematics/angular/migrations/update-15/remove-platform-server-exports.ts @@ -0,0 +1,99 @@ +/** + * @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, UpdateRecorder } from '@angular-devkit/schematics'; +import * as ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript'; + +function* visit(directory: DirEntry): IterableIterator { + 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('@angular/platform-server') && content.includes('renderModule')) { + const source = ts.createSourceFile( + entry.path, + content.toString().replace(/^\uFEFF/, ''), + ts.ScriptTarget.Latest, + true, + ); + + yield source; + } + } + } + } + + for (const path of directory.subdirs) { + if (path === 'node_modules' || path.startsWith('.')) { + continue; + } + + yield* visit(directory.dir(path)); + } +} + +export default function (): Rule { + return (tree) => { + for (const sourceFile of visit(tree.root)) { + let recorder: UpdateRecorder | undefined; + let printer: ts.Printer | undefined; + + ts.forEachChild(sourceFile, function analyze(node) { + if ( + !( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === '@angular/platform-server' && + node.exportClause && + ts.isNamedExports(node.exportClause) + ) + ) { + // Not a @angular/platform-server named export. + return; + } + + const exportClause = node.exportClause; + const newElements: ts.ExportSpecifier[] = []; + for (const element of exportClause.elements) { + if (element.name.text !== 'renderModule') { + newElements.push(element); + } + } + + if (newElements.length === exportClause.elements.length) { + // No changes + return; + } + + recorder ??= tree.beginUpdate(sourceFile.fileName); + + if (newElements.length) { + // Update named exports as there are leftovers. + const newExportClause = ts.factory.updateNamedExports(exportClause, newElements); + printer ??= ts.createPrinter(); + const fix = printer.printNode(ts.EmitHint.Unspecified, newExportClause, sourceFile); + + const index = exportClause.getStart(); + const length = exportClause.getWidth(); + recorder.remove(index, length).insertLeft(index, fix); + } else { + // Delete export as no exports remain. + recorder.remove(node.getStart(), node.getWidth()); + } + + ts.forEachChild(node, analyze); + }); + + if (recorder) { + tree.commitUpdate(recorder); + } + } + }; +} diff --git a/packages/schematics/angular/migrations/update-15/remove-platform-server-exports_spec.ts b/packages/schematics/angular/migrations/update-15/remove-platform-server-exports_spec.ts new file mode 100644 index 000000000000..12a57ee1f450 --- /dev/null +++ b/packages/schematics/angular/migrations/update-15/remove-platform-server-exports_spec.ts @@ -0,0 +1,85 @@ +/** + * @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 } from '@angular-devkit/schematics/testing'; + +describe('Migration to delete platform-server exports', () => { + const schematicName = 'remove-platform-server-exports'; + + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + let tree: EmptyTree; + + beforeEach(() => { + tree = new EmptyTree(); + }); + + const testTypeScriptFilePath = './test.ts'; + + describe(`Migration to remove '@angular/platform-server' exports`, () => { + it(`should delete '@angular/platform-server' export when 'renderModule' is the only exported symbol`, async () => { + tree.create( + testTypeScriptFilePath, + ` + import { Path, join } from '@angular-devkit/core'; + export { renderModule } from '@angular/platform-server'; + `, + ); + + const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise(); + const content = newTree.readText(testTypeScriptFilePath); + expect(content).not.toContain('@angular/platform-server'); + expect(content).toContain(`import { Path, join } from '@angular-devkit/core';`); + }); + + it(`should delete only 'renderModule' when there are additional exports`, async () => { + tree.create( + testTypeScriptFilePath, + ` + import { Path, join } from '@angular-devkit/core'; + export { renderModule, ServerModule } from '@angular/platform-server'; + `, + ); + + const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise(); + const content = newTree.readContent(testTypeScriptFilePath); + expect(content).toContain(`import { Path, join } from '@angular-devkit/core';`); + expect(content).toContain(`export { ServerModule } from '@angular/platform-server';`); + }); + + it(`should not delete 'renderModule' when it's exported from another module`, async () => { + tree.create( + testTypeScriptFilePath, + ` + export { renderModule } from '@angular/core'; + `, + ); + + const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise(); + const content = newTree.readText(testTypeScriptFilePath); + expect(content).toContain(`export { renderModule } from '@angular/core';`); + }); + + it(`should not delete 'renderModule' when it's imported from '@angular/platform-server'`, async () => { + tree.create( + testTypeScriptFilePath, + ` + import { renderModule } from '@angular/platform-server'; + `, + ); + + const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise(); + const content = newTree.readText(testTypeScriptFilePath); + expect(content).toContain(`import { renderModule } from '@angular/platform-server'`); + }); + }); +}); diff --git a/packages/schematics/angular/universal/files/src/__main@stripTsExtension__.ts.template b/packages/schematics/angular/universal/files/src/__main@stripTsExtension__.ts.template index 53e6ea3bb6e7..5f14d1441931 100644 --- a/packages/schematics/angular/universal/files/src/__main@stripTsExtension__.ts.template +++ b/packages/schematics/angular/universal/files/src/__main@stripTsExtension__.ts.template @@ -21,4 +21,3 @@ if (environment.production) { } export { <%= rootModuleClassName %> } from './app/<%= stripTsExtension(rootModuleFileName) %>'; -export { renderModule } from '@angular/platform-server'; diff --git a/tests/legacy-cli/e2e/tests/build/platform-server.ts b/tests/legacy-cli/e2e/tests/build/platform-server.ts index 8f5f1627c061..dd10b7bafd80 100644 --- a/tests/legacy-cli/e2e/tests/build/platform-server.ts +++ b/tests/legacy-cli/e2e/tests/build/platform-server.ts @@ -32,7 +32,8 @@ export default async function () { './server.ts', ` import 'zone.js/dist/zone-node'; import * as fs from 'fs'; - import { AppServerModule, renderModule } from './src/main.server'; + import { renderModule } from '@angular/platform-server'; + import { AppServerModule } from './src/main.server'; renderModule(AppServerModule, { url: '/',