Skip to content

Commit 48c25f6

Browse files
committed
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.
1 parent 82649bc commit 48c25f6

File tree

8 files changed

+231
-3
lines changed

8 files changed

+231
-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, ɵSERVER_CONTEXT } 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 exported `@angular/platform-server``renderModule` method. The `renderModule` method is now exported by the Angular CLI."
712
}
813
}
914
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
function* visit(directory: DirEntry): IterableIterator<ts.SourceFile> {
13+
for (const path of directory.subfiles) {
14+
if (path.endsWith('.ts') && !path.endsWith('.d.ts')) {
15+
const entry = directory.file(path);
16+
if (entry) {
17+
const content = entry.content;
18+
if (content.includes('@angular/platform-server') && content.includes('renderModule')) {
19+
const source = ts.createSourceFile(
20+
entry.path,
21+
content.toString().replace(/^\uFEFF/, ''),
22+
ts.ScriptTarget.Latest,
23+
true,
24+
);
25+
26+
yield source;
27+
}
28+
}
29+
}
30+
}
31+
32+
for (const path of directory.subdirs) {
33+
if (path === 'node_modules' || path.startsWith('.')) {
34+
continue;
35+
}
36+
37+
yield* visit(directory.dir(path));
38+
}
39+
}
40+
41+
export default function (): Rule {
42+
return (tree) => {
43+
for (const sourceFile of visit(tree.root)) {
44+
let recorder: UpdateRecorder | undefined;
45+
let printer: ts.Printer | undefined;
46+
47+
ts.forEachChild(sourceFile, function analyze(node) {
48+
if (
49+
!(
50+
ts.isExportDeclaration(node) &&
51+
node.moduleSpecifier &&
52+
ts.isStringLiteral(node.moduleSpecifier) &&
53+
node.moduleSpecifier.text === '@angular/platform-server' &&
54+
node.exportClause &&
55+
ts.isNamedExports(node.exportClause)
56+
)
57+
) {
58+
// Not a @angular/platform-server named export.
59+
return;
60+
}
61+
62+
const exportClause = node.exportClause;
63+
const newElements: ts.ExportSpecifier[] = [];
64+
for (const element of exportClause.elements) {
65+
if (element.name.text !== 'renderModule') {
66+
newElements.push(element);
67+
}
68+
}
69+
70+
if (newElements.length === exportClause.elements.length) {
71+
// No changes
72+
return;
73+
}
74+
75+
recorder ??= tree.beginUpdate(sourceFile.fileName);
76+
77+
if (newElements.length) {
78+
// Update named exports and there are leftovers.
79+
const newExportClause = ts.factory.updateNamedExports(exportClause, newElements);
80+
printer ??= ts.createPrinter();
81+
const fix = printer.printNode(ts.EmitHint.Unspecified, newExportClause, sourceFile);
82+
83+
const index = exportClause.getStart();
84+
const length = exportClause.getWidth();
85+
recorder.remove(index, length).insertLeft(index, fix);
86+
} else {
87+
// Delete export as no exports remain.
88+
recorder.remove(node.getStart(), node.getWidth());
89+
}
90+
91+
ts.forEachChild(node, analyze);
92+
});
93+
94+
if (recorder) {
95+
tree.commitUpdate(recorder);
96+
}
97+
}
98+
};
99+
}
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' when there are additional exports`, async () => {
50+
tree.create(
51+
testTypeScriptFilePath,
52+
`
53+
import { Path, join } from '@angular-devkit/core';
54+
export { renderModule, ServerModule } 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)