Skip to content

Commit 15d3fc6

Browse files
alan-agius4clydin
authored andcommitted
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 326e923 commit 15d3fc6

File tree

10 files changed

+258
-15
lines changed

10 files changed

+258
-15
lines changed

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,21 @@
88

99
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
1010
import { runWebpack } from '@angular-devkit/build-webpack';
11-
import { tags } from '@angular-devkit/core';
1211
import * as path from 'path';
1312
import { Observable, from } from 'rxjs';
1413
import { concatMap, map } from 'rxjs/operators';
15-
import webpack from 'webpack';
14+
import webpack, { Configuration } from 'webpack';
1615
import { ExecutionTransformer } from '../../transforms';
1716
import { NormalizedBrowserBuilderSchema, deleteOutputDir } from '../../utils';
1817
import { i18nInlineEmittedFiles } from '../../utils/i18n-inlining';
1918
import { I18nOptions } from '../../utils/i18n-options';
2019
import { ensureOutputPaths } from '../../utils/output-paths';
2120
import { purgeStaleBuildCache } from '../../utils/purge-cache';
2221
import { assertCompatibleAngularVersion } from '../../utils/version';
23-
import { generateI18nBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config';
22+
import {
23+
BrowserWebpackConfigOptions,
24+
generateI18nBrowserWebpackConfigFromContext,
25+
} from '../../utils/webpack-browser-config';
2426
import { getCommonConfig, getStylesConfig } from '../../webpack/configs';
2527
import { webpackStatsLogger } from '../../webpack/utils/stats';
2628
import { Schema as ServerBuilderOptions } from './schema';
@@ -152,7 +154,7 @@ async function initialize(
152154
// We use the platform to determine the JavaScript syntax output.
153155
wco.buildOptions.supportedBrowsers.push(...browserslist('maintained node versions'));
154156

155-
return [getCommonConfig(wco), getStylesConfig(wco)];
157+
return [getPlatformServerExportsConfig(wco), getCommonConfig(wco), getStylesConfig(wco)];
156158
},
157159
);
158160

@@ -164,3 +166,31 @@ async function initialize(
164166

165167
return { config: transformedConfig, i18n };
166168
}
169+
170+
/**
171+
* Add `@angular/platform-server` exports.
172+
* This is needed so that DI tokens can be referenced and set at runtime outside of the bundle.
173+
*/
174+
function getPlatformServerExportsConfig(wco: BrowserWebpackConfigOptions): Partial<Configuration> {
175+
// Add `@angular/platform-server` exports.
176+
// This is needed so that DI tokens can be referenced and set at runtime outside of the bundle.
177+
try {
178+
// Only add `@angular/platform-server` exports when it is installed.
179+
// In some cases this builder is used when `@angular/platform-server` is not installed.
180+
// Example: when using `@nguniversal/common/clover` which does not need `@angular/platform-server`.
181+
require.resolve('@angular/platform-server', { paths: [wco.root] });
182+
} catch {
183+
return {};
184+
}
185+
186+
return {
187+
module: {
188+
rules: [
189+
{
190+
loader: require.resolve('./platform-server-exports-loader'),
191+
include: [path.resolve(wco.root, wco.buildOptions.main)],
192+
},
193+
],
194+
},
195+
};
196+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
/**
10+
* This loader is needed to add additional exports and is a workaround for a Webpack bug that doesn't
11+
* allow exports from multiple files in the same entry.
12+
* @see https://github.com/webpack/webpack/issues/15936.
13+
*/
14+
export default function (
15+
this: import('webpack').LoaderContext<{}>,
16+
content: string,
17+
map: Parameters<import('webpack').LoaderDefinitionFunction>[1],
18+
) {
19+
const source = `${content}
20+
21+
// EXPORTS added by @angular-devkit/build-angular
22+
export { renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
23+
`;
24+
25+
this.callback(null, source, map);
26+
27+
return;
28+
}

packages/angular_devkit/build_angular/src/webpack/configs/dev-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export async function getDevServerConfig(
3333
if (hmr) {
3434
extraRules.push({
3535
loader: HmrLoader,
36-
include: [main].map((p) => resolve(wco.root, p)),
36+
include: [resolve(wco.root, main)],
3737
});
3838
}
3939

packages/angular_devkit/build_angular/src/webpack/plugins/hmr/hmr-loader.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,11 @@ import { join } from 'path';
1111
export const HmrLoader = __filename;
1212
const hmrAcceptPath = join(__dirname, './hmr-accept.js').replace(/\\/g, '/');
1313

14-
export default function (
15-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16-
this: any,
14+
export default function localizeExtractLoader(
15+
this: import('webpack').LoaderContext<{}>,
1716
content: string,
18-
// Source map types are broken in the webpack type definitions
19-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20-
map: any,
21-
): void {
17+
map: Parameters<import('webpack').LoaderDefinitionFunction>[1],
18+
) {
2219
const source = `${content}
2320
2421
// HMR Accept Code

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 as 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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 } 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: EmptyTree;
21+
22+
beforeEach(() => {
23+
tree = new EmptyTree();
24+
});
25+
26+
const testTypeScriptFilePath = './test.ts';
27+
28+
describe(`Migration to remove '@angular/platform-server' exports`, () => {
29+
it(`should delete '@angular/platform-server' export when 'renderModule' is the only exported symbol`, async () => {
30+
tree.create(
31+
testTypeScriptFilePath,
32+
`
33+
import { Path, join } from '@angular-devkit/core';
34+
export { renderModule } from '@angular/platform-server';
35+
`,
36+
);
37+
38+
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
39+
const content = newTree.readText(testTypeScriptFilePath);
40+
expect(content).not.toContain('@angular/platform-server');
41+
expect(content).toContain(`import { Path, join } from '@angular-devkit/core';`);
42+
});
43+
44+
it(`should delete only 'renderModule' when there are additional exports`, async () => {
45+
tree.create(
46+
testTypeScriptFilePath,
47+
`
48+
import { Path, join } from '@angular-devkit/core';
49+
export { renderModule, ServerModule } from '@angular/platform-server';
50+
`,
51+
);
52+
53+
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
54+
const content = newTree.readContent(testTypeScriptFilePath);
55+
expect(content).toContain(`import { Path, join } from '@angular-devkit/core';`);
56+
expect(content).toContain(`export { ServerModule } from '@angular/platform-server';`);
57+
});
58+
59+
it(`should not delete 'renderModule' when it's exported from another module`, async () => {
60+
tree.create(
61+
testTypeScriptFilePath,
62+
`
63+
export { renderModule } from '@angular/core';
64+
`,
65+
);
66+
67+
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
68+
const content = newTree.readText(testTypeScriptFilePath);
69+
expect(content).toContain(`export { renderModule } from '@angular/core';`);
70+
});
71+
72+
it(`should not delete 'renderModule' when it's imported from '@angular/platform-server'`, async () => {
73+
tree.create(
74+
testTypeScriptFilePath,
75+
`
76+
import { renderModule } from '@angular/platform-server';
77+
`,
78+
);
79+
80+
const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise();
81+
const content = newTree.readText(testTypeScriptFilePath);
82+
expect(content).toContain(`import { renderModule } from '@angular/platform-server'`);
83+
});
84+
});
85+
});

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)