Skip to content

feat(@angular-devkit/build-angular): export @angular/platform-server symbols in server bundle #23866

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions packages/angular_devkit/build_angular/src/builders/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,21 @@

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';
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';
Expand Down Expand Up @@ -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)];
},
);

Expand All @@ -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<Configuration> {
// 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)],
},
],
},
};
}
Original file line number Diff line number Diff line change
@@ -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<import('webpack').LoaderDefinitionFunction>[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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<import('webpack').LoaderDefinitionFunction>[1],
) {
const source = `${content}

// HMR Accept Code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,3 @@ if (environment.production) {
}

export { AppServerModule } from './app/app.server.module';
export { renderModule } from '@angular/platform-server';
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ts.SourceFile> {
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);
}
}
};
}
Original file line number Diff line number Diff line change
@@ -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'`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,3 @@ if (environment.production) {
}

export { <%= rootModuleClassName %> } from './app/<%= stripTsExtension(rootModuleFileName) %>';
export { renderModule } from '@angular/platform-server';
3 changes: 2 additions & 1 deletion tests/legacy-cli/e2e/tests/build/platform-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '/',
Expand Down