Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): export `@angular/platform-server…
Browse files Browse the repository at this point in the history
…` 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.
  • Loading branch information
alan-agius4 authored and clydin committed Sep 20, 2022
1 parent 326e923 commit 15d3fc6
Show file tree
Hide file tree
Showing 10 changed files with 258 additions and 15 deletions.
38 changes: 34 additions & 4 deletions packages/angular_devkit/build_angular/src/builders/server/index.ts
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)],
},
],
},
};
}
@@ -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;
}
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
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
Expand Up @@ -16,4 +16,3 @@ if (environment.production) {
}

export { AppServerModule } from './app/app.server.module';
export { renderModule } from '@angular/platform-server';
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."
}
}
}
@@ -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);
}
}
};
}
@@ -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'`);
});
});
});
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
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

0 comments on commit 15d3fc6

Please sign in to comment.