Skip to content

Commit 7d98ab3

Browse files
clydinalan-agius4
authored andcommitted
refactor(@ngtools/webpack): support an ESM-only @angular/compiler-cli package
With the Angular CLI currently being a CommonJS package, this change uses a dynamic import to load @angular/compiler-cli which may be ESM. CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript will currently, unconditionally downlevel dynamic import into a require call. require calls cannot load ESM code and will result in a runtime error. To workaround this, a Function constructor is used to prevent TypeScript from changing the dynamic import. Once TypeScript provides support for keeping the dynamic import this workaround can be dropped and replaced with a standard dynamic import. Type only static import statements for `@angular/compiler-cli` are also now used since the `@angular/compiler-cli` is used as a peer dependency and has the potential to not be present. As a result static imports should only be used for types and value imports should be dynamic so that they can be guarded in the event of package absence. BREAKING CHANGE: Applications directly using the `webpack-cli` and not the Angular CLI to build must set the environment variable `DISABLE_V8_COMPILE_CACHE=1`. The `@ngtools/webpack` package now uses dynamic imports to provide support for the ESM `@angular/compiler-cli` package. The `v8-compile-cache` package used by the `webpack-cli` does not currently support dynamic import expressions and will cause builds to fail if the environment variable is not specified. Applications using the Angular CLI are not affected by this limitation.
1 parent 5bb6897 commit 7d98ab3

File tree

7 files changed

+112
-28
lines changed

7 files changed

+112
-28
lines changed

goldens/public-api/ngtools/webpack/src/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
```ts
66

77
import type { Compiler } from 'webpack';
8-
import { CompilerOptions } from '@angular/compiler-cli';
8+
import type { CompilerOptions } from '@angular/compiler-cli';
99
import type { LoaderContext } from 'webpack';
1010

1111
// @public (undocumented)

packages/ngtools/webpack/src/ivy/diagnostics.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { Diagnostics, formatDiagnostics } from '@angular/compiler-cli';
9+
import type { Diagnostics } from '@angular/compiler-cli';
1010
import { DiagnosticCategory } from 'typescript';
1111
import type { Compilation } from 'webpack';
1212

1313
export type DiagnosticsReporter = (diagnostics: Diagnostics) => void;
1414

15-
export function createDiagnosticsReporter(compilation: Compilation): DiagnosticsReporter {
15+
export function createDiagnosticsReporter(
16+
compilation: Compilation,
17+
formatter: (diagnostic: Diagnostics[number]) => string,
18+
): DiagnosticsReporter {
1619
return (diagnostics) => {
1720
for (const diagnostic of diagnostics) {
18-
const text = formatDiagnostics([diagnostic]);
21+
const text = formatter(diagnostic);
1922
if (diagnostic.category === DiagnosticCategory.Error) {
2023
addError(compilation, text);
2124
} else {

packages/ngtools/webpack/src/ivy/host.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
/* eslint-disable @typescript-eslint/unbound-method */
10-
import { CompilerHost } from '@angular/compiler-cli';
10+
import type { CompilerHost } from '@angular/compiler-cli';
1111
import { createHash } from 'crypto';
1212
import * as path from 'path';
1313
import * as ts from 'typescript';

packages/ngtools/webpack/src/ivy/plugin.ts

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,8 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {
10-
CompilerHost,
11-
CompilerOptions,
12-
NgtscProgram,
13-
readConfiguration,
14-
} from '@angular/compiler-cli';
9+
import type { CompilerHost, CompilerOptions, NgtscProgram } from '@angular/compiler-cli';
10+
import { strict as assert } from 'assert';
1511
import { createHash } from 'crypto';
1612
import * as ts from 'typescript';
1713
import type { Compilation, Compiler, Module, NormalModule } from 'webpack';
@@ -61,6 +57,7 @@ export interface AngularWebpackPluginOptions {
6157
function initializeNgccProcessor(
6258
compiler: Compiler,
6359
tsconfig: string,
60+
compilerNgccModule: typeof import('@angular/compiler-cli/ngcc') | undefined,
6461
): { processor: NgccProcessor; errors: string[]; warnings: string[] } {
6562
const { inputFileSystem, options: webpackOptions } = compiler;
6663
const mainFields = webpackOptions.resolve?.mainFields?.flat() ?? [];
@@ -73,7 +70,14 @@ function initializeNgccProcessor(
7370
extensions: ['.json'],
7471
useSyncFileSystemCalls: true,
7572
});
73+
74+
// The compilerNgccModule field is guaranteed to be defined during a compilation
75+
// due to the `beforeCompile` hook. Usage of this property accessor prior to the
76+
// hook execution is an implementation error.
77+
assert.ok(compilerNgccModule, `'@angular/compiler-cli/ngcc' used prior to Webpack compilation.`);
78+
7679
const processor = new NgccProcessor(
80+
compilerNgccModule,
7781
mainFields,
7882
warnings,
7983
errors,
@@ -95,6 +99,8 @@ const compilationFileEmitters = new WeakMap<Compilation, FileEmitterCollection>(
9599

96100
export class AngularWebpackPlugin {
97101
private readonly pluginOptions: AngularWebpackPluginOptions;
102+
private compilerCliModule?: typeof import('@angular/compiler-cli');
103+
private compilerNgccModule?: typeof import('@angular/compiler-cli/ngcc');
98104
private watchMode?: boolean;
99105
private ngtscNextProgram?: NgtscProgram;
100106
private builder?: ts.EmitAndSemanticDiagnosticsBuilderProgram;
@@ -117,6 +123,15 @@ export class AngularWebpackPlugin {
117123
};
118124
}
119125

126+
private get compilerCli(): typeof import('@angular/compiler-cli') {
127+
// The compilerCliModule field is guaranteed to be defined during a compilation
128+
// due to the `beforeCompile` hook. Usage of this property accessor prior to the
129+
// hook execution is an implementation error.
130+
assert.ok(this.compilerCliModule, `'@angular/compiler-cli' used prior to Webpack compilation.`);
131+
132+
return this.compilerCliModule;
133+
}
134+
120135
get options(): AngularWebpackPluginOptions {
121136
return this.pluginOptions;
122137
}
@@ -153,6 +168,9 @@ export class AngularWebpackPlugin {
153168
});
154169
});
155170

171+
// Load the compiler-cli if not already available
172+
compiler.hooks.beforeCompile.tapPromise(PLUGIN_NAME, () => this.initializeCompilerCli());
173+
156174
let ngccProcessor: NgccProcessor | undefined;
157175
let resourceLoader: WebpackResourceLoader | undefined;
158176
let previousUnused: Set<string> | undefined;
@@ -172,6 +190,7 @@ export class AngularWebpackPlugin {
172190
const { processor, errors, warnings } = initializeNgccProcessor(
173191
compiler,
174192
this.pluginOptions.tsconfig,
193+
this.compilerNgccModule,
175194
);
176195

177196
processor.process();
@@ -185,7 +204,9 @@ export class AngularWebpackPlugin {
185204
const { compilerOptions, rootNames, errors } = this.loadConfiguration();
186205

187206
// Create diagnostics reporter and report configuration file errors
188-
const diagnosticsReporter = createDiagnosticsReporter(compilation);
207+
const diagnosticsReporter = createDiagnosticsReporter(compilation, (diagnostic) =>
208+
this.compilerCli.formatDiagnostics([diagnostic]),
209+
);
189210
diagnosticsReporter(errors);
190211

191212
// Update TypeScript path mapping plugin with new configuration
@@ -398,7 +419,10 @@ export class AngularWebpackPlugin {
398419
options: compilerOptions,
399420
rootNames,
400421
errors,
401-
} = readConfiguration(this.pluginOptions.tsconfig, this.pluginOptions.compilerOptions);
422+
} = this.compilerCli.readConfiguration(
423+
this.pluginOptions.tsconfig,
424+
this.pluginOptions.compilerOptions,
425+
);
402426
compilerOptions.enableIvy = true;
403427
compilerOptions.noEmitOnError = false;
404428
compilerOptions.suppressOutputPathCheck = true;
@@ -422,7 +446,7 @@ export class AngularWebpackPlugin {
422446
resourceLoader: WebpackResourceLoader,
423447
) {
424448
// Create the Angular specific program that contains the Angular compiler
425-
const angularProgram = new NgtscProgram(
449+
const angularProgram = new this.compilerCli.NgtscProgram(
426450
rootNames,
427451
compilerOptions,
428452
host,
@@ -561,8 +585,15 @@ export class AngularWebpackPlugin {
561585
}
562586
}
563587

588+
// Temporary workaround during transition to ESM-only @angular/compiler-cli
589+
// TODO_ESM: This workaround should be removed prior to the final release of v13
590+
// and replaced with only `this.compilerCli.OptimizeFor`.
591+
const OptimizeFor =
592+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
593+
(this.compilerCli as any).OptimizeFor ??
594+
require('@angular/compiler-cli/src/ngtsc/typecheck/api').OptimizeFor;
595+
564596
// Collect new Angular diagnostics for files affected by changes
565-
const { OptimizeFor } = require('@angular/compiler-cli/src/ngtsc/typecheck/api');
566597
const optimizeDiagnosticsFor =
567598
affectedFiles.size <= DIAGNOSTICS_AFFECTED_THRESHOLD
568599
? OptimizeFor.SingleFile
@@ -628,7 +659,7 @@ export class AngularWebpackPlugin {
628659
];
629660
diagnosticsReporter(diagnostics);
630661

631-
const transformers = createJitTransformers(builder, this.pluginOptions);
662+
const transformers = createJitTransformers(builder, this.compilerCli, this.pluginOptions);
632663

633664
return {
634665
fileEmitter: this.createFileEmitter(builder, transformers, () => []),
@@ -687,4 +718,37 @@ export class AngularWebpackPlugin {
687718
return { content, map, dependencies, hash };
688719
};
689720
}
721+
722+
private async initializeCompilerCli(): Promise<void> {
723+
if (this.compilerCliModule) {
724+
return;
725+
}
726+
727+
// This uses a dynamic import to load `@angular/compiler-cli` which may be ESM.
728+
// CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript
729+
// will currently, unconditionally downlevel dynamic import into a require call.
730+
// require calls cannot load ESM code and will result in a runtime error. To workaround
731+
// this, a Function constructor is used to prevent TypeScript from changing the dynamic import.
732+
// Once TypeScript provides support for keeping the dynamic import this workaround can
733+
// be dropped.
734+
const compilerCliModule = await new Function(`return import('@angular/compiler-cli');`)();
735+
let compilerNgccModule;
736+
try {
737+
compilerNgccModule = await new Function(`return import('@angular/compiler-cli/ngcc');`)();
738+
} catch {
739+
// If the `exports` field entry is not present then try the file directly.
740+
// TODO_ESM: This try/catch can be removed once the `exports` field is present in `@angular/compiler-cli`
741+
compilerNgccModule = await new Function(
742+
`return import('@angular/compiler-cli/ngcc/index.js');`,
743+
)();
744+
}
745+
// If it is not ESM then the functions needed will be stored in the `default` property.
746+
// TODO_ESM: This conditional can be removed when `@angular/compiler-cli` is ESM only.
747+
this.compilerCliModule = compilerCliModule.readConfiguration
748+
? compilerCliModule
749+
: compilerCliModule.default;
750+
this.compilerNgccModule = compilerNgccModule.process
751+
? compilerNgccModule
752+
: compilerNgccModule.default;
753+
}
690754
}

packages/ngtools/webpack/src/ivy/transformation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { constructorParametersDownlevelTransform } from '@angular/compiler-cli';
109
import * as ts from 'typescript';
1110
import { elideImports } from '../transformers/elide_imports';
1211
import { removeIvyJitSupportCalls } from '../transformers/remove-ivy-jit-support-calls';
@@ -36,6 +35,7 @@ export function createAotTransformers(
3635

3736
export function createJitTransformers(
3837
builder: ts.BuilderProgram,
38+
compilerCli: typeof import('@angular/compiler-cli'),
3939
options: {
4040
directTemplateLoading?: boolean;
4141
inlineStyleFileExtension?: string;
@@ -51,7 +51,7 @@ export function createJitTransformers(
5151
options.directTemplateLoading,
5252
options.inlineStyleFileExtension,
5353
),
54-
constructorParametersDownlevelTransform(builder.getProgram()),
54+
compilerCli.constructorParametersDownlevelTransform(builder.getProgram()),
5555
],
5656
};
5757
}

packages/ngtools/webpack/src/ngcc_processor.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { LogLevel, Logger, process as mainNgcc } from '@angular/compiler-cli/ngcc';
9+
import type { LogLevel, Logger } from '@angular/compiler-cli/ngcc';
1010
import { spawnSync } from 'child_process';
1111
import { createHash } from 'crypto';
1212
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
@@ -35,6 +35,7 @@ export class NgccProcessor {
3535
private _nodeModulesDirectory: string;
3636

3737
constructor(
38+
private readonly compilerNgcc: typeof import('@angular/compiler-cli/ngcc'),
3839
private readonly propertiesToConsider: string[],
3940
private readonly compilationWarnings: (Error | string)[],
4041
private readonly compilationErrors: (Error | string)[],
@@ -43,7 +44,11 @@ export class NgccProcessor {
4344
private readonly inputFileSystem: InputFileSystem,
4445
private readonly resolver: ResolverWithOptions,
4546
) {
46-
this._logger = new NgccLogger(this.compilationWarnings, this.compilationErrors);
47+
this._logger = new NgccLogger(
48+
this.compilationWarnings,
49+
this.compilationErrors,
50+
compilerNgcc.LogLevel.info,
51+
);
4752
this._nodeModulesDirectory = this.findNodeModulesDirectory(this.basePath);
4853
}
4954

@@ -115,6 +120,14 @@ export class NgccProcessor {
115120
const timeLabel = 'NgccProcessor.process';
116121
time(timeLabel);
117122

123+
// Temporary workaround during transition to ESM-only @angular/compiler-cli
124+
// TODO_ESM: This workaround should be removed prior to the final release of v13
125+
// and replaced with only `this.compilerNgcc.ngccMainFilePath`.
126+
const ngccExecutablePath =
127+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
128+
(this.compilerNgcc as any).ngccMainFilePath ??
129+
require.resolve('@angular/compiler-cli/ngcc/main-ngcc.js');
130+
118131
// We spawn instead of using the API because:
119132
// - NGCC Async uses clustering which is problematic when used via the API which means
120133
// that we cannot setup multiple cluster masters with different options.
@@ -123,7 +136,7 @@ export class NgccProcessor {
123136
const { status, error } = spawnSync(
124137
process.execPath,
125138
[
126-
require.resolve('@angular/compiler-cli/ngcc/main-ngcc.js'),
139+
ngccExecutablePath,
127140
'--source' /** basePath */,
128141
this._nodeModulesDirectory,
129142
'--properties' /** propertiesToConsider */,
@@ -187,7 +200,7 @@ export class NgccProcessor {
187200

188201
const timeLabel = `NgccProcessor.processModule.ngcc.process+${moduleName}`;
189202
time(timeLabel);
190-
mainNgcc({
203+
this.compilerNgcc.process({
191204
basePath: this._nodeModulesDirectory,
192205
targetEntryPointPath: path.dirname(packageJsonPath),
193206
propertiesToConsider: this.propertiesToConsider,
@@ -246,11 +259,10 @@ export class NgccProcessor {
246259
}
247260

248261
class NgccLogger implements Logger {
249-
level = LogLevel.info;
250-
251262
constructor(
252263
private readonly compilationWarnings: (Error | string)[],
253264
private readonly compilationErrors: (Error | string)[],
265+
public level: LogLevel,
254266
) {}
255267

256268
// eslint-disable-next-line @typescript-eslint/no-empty-function

tests/legacy-cli/e2e/tests/packages/webpack/test-app.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
import { normalize } from 'path';
22
import { createProjectFromAsset } from '../../../utils/assets';
33
import { expectFileSizeToBeUnder, expectFileToMatch, replaceInFile } from '../../../utils/fs';
4-
import { exec } from '../../../utils/process';
4+
import { execWithEnv } from '../../../utils/process';
55

66
export default async function (skipCleaning: () => void) {
77
const webpackCLIBin = normalize('node_modules/.bin/webpack-cli');
88

99
await createProjectFromAsset('webpack/test-app');
1010

11-
await exec(webpackCLIBin);
11+
// DISABLE_V8_COMPILE_CACHE=1 is required to disable the `v8-compile-cache` package.
12+
// It currently does not support dynamic import expressions which are now required by the
13+
// CLI to support ESM. ref: https://github.com/zertosh/v8-compile-cache/issues/30
14+
await execWithEnv(webpackCLIBin, [], { ...process.env, 'DISABLE_V8_COMPILE_CACHE': '1' });
1215

1316
// Note: these sizes are without Build Optimizer or any advanced optimizations in the CLI.
1417
await expectFileSizeToBeUnder('dist/app.main.js', 656 * 1024);
1518
await expectFileSizeToBeUnder('dist/501.app.main.js', 1 * 1024);
1619
await expectFileSizeToBeUnder('dist/888.app.main.js', 2 * 1024);
1720
await expectFileSizeToBeUnder('dist/972.app.main.js', 2 * 1024);
1821

19-
2022
// test resource urls without ./
2123
await replaceInFile('app/app.component.ts', './app.component.html', 'app.component.html');
2224
await replaceInFile('app/app.component.ts', './app.component.scss', 'app.component.scss');
2325

2426
// test the inclusion of metadata
2527
// This build also test resource URLs without ./
26-
await exec(webpackCLIBin, '--mode=development');
28+
await execWithEnv(webpackCLIBin, ['--mode=development'], {
29+
...process.env,
30+
'DISABLE_V8_COMPILE_CACHE': '1',
31+
});
2732
await expectFileToMatch('dist/app.main.js', 'AppModule');
2833

2934
skipCleaning();

0 commit comments

Comments
 (0)