Skip to content

Commit 44c2654

Browse files
committed
fix(@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 collition would occur due to the same symbol being exported multiple times.
1 parent 82649bc commit 44c2654

File tree

8 files changed

+236
-3
lines changed

8 files changed

+236
-3
lines changed

packages/angular_devkit/build_angular/src/builders/server/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export function execute(
6868

6969
return from(initialize(options, context, transforms.webpackConfiguration)).pipe(
7070
concatMap(({ config, i18n, target }) => {
71+
addPlatformServerExportsAsEntry(config, root);
72+
7173
return runWebpack(config, context, {
7274
webpackFactory: require('webpack') as typeof webpack,
7375
logging: (stats, config) => {
@@ -164,3 +166,27 @@ async function initialize(
164166

165167
return { config: transformedConfig || config, i18n, target };
166168
}
169+
/**
170+
* Add `@angular/platform-server` exports as an entry-point.
171+
* This is needed so that DI tokens can be referenced and set at runtime outside of the bundle.
172+
*/
173+
function addPlatformServerExportsAsEntry(config: webpack.Configuration, root: string): void {
174+
try {
175+
// Only add `@angular/platform-server` exports when it is installed.
176+
// In some cases this builder is used when `@angular/platform-server` is not installed.
177+
// Example: when using `@nguniversal/common/clover` which does not need `@angular/platform-server`.
178+
require.resolve('@angular/platform-server', { paths: [root] });
179+
} catch {
180+
return;
181+
}
182+
183+
const platformServerExportsFile = path.join(__dirname, 'platform-server-exports.js');
184+
185+
if (typeof config.entry === 'object' && !Array.isArray(config.entry) && config.entry['main']) {
186+
if (Array.isArray(config.entry['main'])) {
187+
config.entry['main'].push(platformServerExportsFile);
188+
} else {
189+
config.entry['main'] = [platformServerExportsFile, config.entry['main'] as string];
190+
}
191+
}
192+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export { renderModule, renderApplication } from '@angular/platform-server';

packages/angular_devkit/build_angular/test/hello-world-app/src/main.server.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,3 @@ if (environment.production) {
1616
}
1717

1818
export { AppServerModule } from './app/app.server.module';
19-
export { renderModule } from '@angular/platform-server';

packages/schematics/angular/migrations/migration-collection.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
"version": "15.0.0",
55
"factory": "./update-15/remove-browserslist-config",
66
"description": "Remove Browserslist configuration files that matches the Angular CLI default configuration."
7+
},
8+
"remove-platform-server-exports": {
9+
"version": "15.0.0",
10+
"factory": "./update-15/remove-platform-server-exports",
11+
"description": "Remove `@angular/platform-server` exported rendering methods. The `renderModule` and `renderApplication` methods are now exported by the Angular CLI."
712
}
813
}
914
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { DirEntry, Rule, UpdateRecorder } from '@angular-devkit/schematics';
10+
import * as ts from '../../third_party/github.com/Microsoft/TypeScript/lib/typescript';
11+
12+
const PLATFORM_SERVER_EXPORTS_TO_DELETE = new Set(['renderModule', 'renderApplication']);
13+
14+
function* visit(directory: DirEntry): IterableIterator<ts.SourceFile> {
15+
for (const path of directory.subfiles) {
16+
if (path.endsWith('.ts') && !path.endsWith('.d.ts')) {
17+
const entry = directory.file(path);
18+
if (entry) {
19+
const content = entry.content;
20+
if (
21+
content.includes('@angular/platform-server') &&
22+
[...PLATFORM_SERVER_EXPORTS_TO_DELETE].some((exportName) => content.includes(exportName))
23+
) {
24+
const source = ts.createSourceFile(
25+
entry.path,
26+
content.toString().replace(/^\uFEFF/, ''),
27+
ts.ScriptTarget.Latest,
28+
true,
29+
);
30+
31+
yield source;
32+
}
33+
}
34+
}
35+
}
36+
37+
for (const path of directory.subdirs) {
38+
if (path === 'node_modules' || path.startsWith('.')) {
39+
continue;
40+
}
41+
42+
yield* visit(directory.dir(path));
43+
}
44+
}
45+
46+
export default function (): Rule {
47+
return (tree) => {
48+
for (const sourceFile of visit(tree.root)) {
49+
let recorder: UpdateRecorder | undefined;
50+
let printer: ts.Printer | undefined;
51+
52+
ts.forEachChild(sourceFile, function analyze(node) {
53+
if (
54+
!(
55+
ts.isExportDeclaration(node) &&
56+
node.moduleSpecifier &&
57+
ts.isStringLiteral(node.moduleSpecifier) &&
58+
node.moduleSpecifier.text === '@angular/platform-server' &&
59+
node.exportClause &&
60+
ts.isNamedExports(node.exportClause)
61+
)
62+
) {
63+
// Not a @angular/platform-server named export.
64+
return;
65+
}
66+
67+
const exportClause = node.exportClause;
68+
const newElements: ts.ExportSpecifier[] = [];
69+
for (const element of exportClause.elements) {
70+
if (!PLATFORM_SERVER_EXPORTS_TO_DELETE.has(element.name.text)) {
71+
newElements.push(element);
72+
}
73+
}
74+
75+
if (newElements.length === exportClause.elements.length) {
76+
// No changes
77+
return;
78+
}
79+
80+
recorder ??= tree.beginUpdate(sourceFile.fileName);
81+
82+
if (newElements.length) {
83+
// Update named exports and there are leftovers.
84+
const newExportClause = ts.factory.updateNamedExports(exportClause, newElements);
85+
printer ??= ts.createPrinter();
86+
const fix = printer.printNode(ts.EmitHint.Unspecified, newExportClause, sourceFile);
87+
88+
const index = exportClause.getStart();
89+
const length = exportClause.getWidth();
90+
recorder.remove(index, length).insertLeft(index, fix);
91+
} else {
92+
// Delete export as no exports remain.
93+
recorder.remove(node.getStart(), node.getWidth());
94+
}
95+
96+
ts.forEachChild(node, analyze);
97+
});
98+
99+
if (recorder) {
100+
tree.commitUpdate(recorder);
101+
}
102+
}
103+
};
104+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { EmptyTree } from '@angular-devkit/schematics';
10+
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
11+
12+
describe('Migration to delete platform-server exports', () => {
13+
const schematicName = 'remove-platform-server-exports';
14+
15+
const schematicRunner = new SchematicTestRunner(
16+
'migrations',
17+
require.resolve('../migration-collection.json'),
18+
);
19+
20+
let tree: UnitTestTree;
21+
22+
beforeEach(() => {
23+
tree = new UnitTestTree(new EmptyTree());
24+
});
25+
26+
const testTypeScriptFilePath = './test.ts';
27+
28+
describe('Migration to import() style lazy routes', () => {
29+
beforeEach(async () => {
30+
tree = new UnitTestTree(new EmptyTree());
31+
tree.create('/package.json', JSON.stringify({}));
32+
});
33+
34+
it(`should delete '@angular/platform-server' export when 'renderModule' is the only exported symbol`, async () => {
35+
tree.create(
36+
testTypeScriptFilePath,
37+
`
38+
import { Path, join } from '@angular-devkit/core';
39+
export { renderModule } from '@angular/platform-server';
40+
`,
41+
);
42+
43+
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
44+
const content = newTree.readContent(testTypeScriptFilePath);
45+
expect(content).not.toContain('@angular/platform-server');
46+
expect(content).toContain(`import { Path, join } from '@angular-devkit/core';`);
47+
});
48+
49+
it(`should delete only 'renderModule' and 'renderApplication' when there are additional exports`, async () => {
50+
tree.create(
51+
testTypeScriptFilePath,
52+
`
53+
import { Path, join } from '@angular-devkit/core';
54+
export { renderModule, ServerModule, renderApplication } from '@angular/platform-server';
55+
`,
56+
);
57+
58+
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
59+
const content = newTree.readContent(testTypeScriptFilePath);
60+
expect(content).toContain(`import { Path, join } from '@angular-devkit/core';`);
61+
expect(content).toContain(`export { ServerModule } from '@angular/platform-server';`);
62+
});
63+
64+
it(`should not delete 'renderModule' when it's exported from another module`, async () => {
65+
tree.create(
66+
testTypeScriptFilePath,
67+
`
68+
export { renderModule } from '@angular/core';
69+
`,
70+
);
71+
72+
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
73+
const content = newTree.readContent(testTypeScriptFilePath);
74+
expect(content).toContain(`export { renderModule } from '@angular/core';`);
75+
});
76+
77+
it(`should not delete 'renderModule' when it's imported from '@angular/platform-server'`, async () => {
78+
tree.create(
79+
testTypeScriptFilePath,
80+
`
81+
import { renderModule } from '@angular/platform-server';
82+
`,
83+
);
84+
85+
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
86+
const content = newTree.readContent(testTypeScriptFilePath);
87+
expect(content).toContain(`import { renderModule } from '@angular/platform-server'`);
88+
});
89+
});
90+
});

packages/schematics/angular/universal/files/src/__main@stripTsExtension__.ts.template

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,3 @@ if (environment.production) {
2121
}
2222

2323
export { <%= rootModuleClassName %> } from './app/<%= stripTsExtension(rootModuleFileName) %>';
24-
export { renderModule } from '@angular/platform-server';

tests/legacy-cli/e2e/tests/build/platform-server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export default async function () {
3232
'./server.ts',
3333
` import 'zone.js/dist/zone-node';
3434
import * as fs from 'fs';
35-
import { AppServerModule, renderModule } from './src/main.server';
35+
import { renderModule } from '@angular/platform-server';
36+
import { AppServerModule } from './src/main.server';
3637
3738
renderModule(AppServerModule, {
3839
url: '/',

0 commit comments

Comments
 (0)