diff --git a/packages/angular_devkit/build_angular/src/babel/presets/application.ts b/packages/angular_devkit/build_angular/src/babel/presets/application.ts index fcc6b3bdd79f..aa50101732a3 100644 --- a/packages/angular_devkit/build_angular/src/babel/presets/application.ts +++ b/packages/angular_devkit/build_angular/src/babel/presets/application.ts @@ -20,6 +20,7 @@ export interface ApplicationPresetOptions { angularLinker?: { shouldLink: boolean; jitMode: boolean; + linkerPluginCreator: typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin; }; forceES5?: boolean; @@ -28,6 +29,11 @@ export interface ApplicationPresetOptions { diagnosticReporter?: DiagnosticReporter; } +// Extract Logger type from the linker function to avoid deep importing to access the type +type NgtscLogger = Parameters< + typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin +>[0]['logger']; + type I18nDiagnostics = import('@angular/localize/src/tools/src/diagnostics').Diagnostics; function createI18nDiagnostics(reporter: DiagnosticReporter | undefined): I18nDiagnostics { // Babel currently is synchronous so import cannot be used @@ -106,9 +112,7 @@ function createI18nPlugins( return plugins; } -function createNgtscLogger( - reporter: DiagnosticReporter | undefined, -): import('@angular/compiler-cli/src/ngtsc/logging').Logger { +function createNgtscLogger(reporter: DiagnosticReporter | undefined): NgtscLogger { return { level: 1, // Info level debug(...args: string[]) {}, @@ -130,12 +134,8 @@ export default function (api: unknown, options: ApplicationPresetOptions) { let needRuntimeTransform = false; if (options.angularLinker?.shouldLink) { - // Babel currently is synchronous so import cannot be used - const { createEs2015LinkerPlugin } = - require('@angular/compiler-cli/linker/babel') as typeof import('@angular/compiler-cli/linker/babel'); - plugins.push( - createEs2015LinkerPlugin({ + options.angularLinker.linkerPluginCreator({ linkerJitMode: options.angularLinker.jitMode, // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. sourceMapping: false, diff --git a/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts b/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts index 0a4abd04bd9c..0ac9ffd6d6e5 100644 --- a/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts +++ b/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import { needsLinking } from '@angular/compiler-cli/linker'; import { custom } from 'babel-loader'; import { ScriptTarget } from 'typescript'; +import { loadEsmModule } from '../utils/load-esm'; import { ApplicationPresetOptions } from './presets/application'; interface AngularCustomOptions extends Pick { @@ -21,13 +21,35 @@ interface AngularCustomOptions extends Pick { // @angular/core and @angular/compiler will cause false positives // Also, TypeScript files do not require linking if (/[\\/]@angular[\\/](?:compiler|core)|\.tsx?$/.test(path)) { return false; } + if (!needsLinking) { + // Load ESM `@angular/compiler-cli/linker` using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + const linkerModule = await loadEsmModule( + '@angular/compiler-cli/linker', + ); + needsLinking = linkerModule.needsLinking; + } + return needsLinking(path, source); } @@ -55,9 +77,20 @@ export default custom(() => { // Analyze file for linking if (await requiresLinking(this.resourcePath, source)) { + if (!linkerPluginCreator) { + // Load ESM `@angular/compiler-cli/linker/babel` using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + const linkerBabelModule = await loadEsmModule< + typeof import('@angular/compiler-cli/linker/babel') + >('@angular/compiler-cli/linker/babel'); + linkerPluginCreator = linkerBabelModule.createEs2015LinkerPlugin; + } + customOptions.angularLinker = { shouldLink: true, jitMode: aot !== true, + linkerPluginCreator, }; shouldProcess = true; } diff --git a/packages/angular_devkit/build_angular/src/utils/load-esm.ts b/packages/angular_devkit/build_angular/src/utils/load-esm.ts new file mode 100644 index 000000000000..7afa96a3ad5b --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/load-esm.ts @@ -0,0 +1,36 @@ +/** + * @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 uses a dynamic import to load a module 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. + * + * @param modulePath The path of the module to load. + * @returns A Promise that resolves to the dynamically imported module. + */ +export async function loadEsmModule(modulePath: string): Promise { + try { + return (await new Function('modulePath', `return import(modulePath);`)(modulePath)) as T; + } catch (e) { + // Temporary workaround to handle directory imports for current packages. ESM does not support + // directory imports. + // TODO_ESM: Remove once FW packages are fully ESM with defined `exports` package.json fields + if (e.code !== 'ERR_UNSUPPORTED_DIR_IMPORT') { + throw e; + } + + return (await new Function('modulePath', `return import(modulePath);`)( + modulePath + '/index.js', + )) as T; + } +} diff --git a/packages/angular_devkit/build_angular/src/utils/read-tsconfig.ts b/packages/angular_devkit/build_angular/src/utils/read-tsconfig.ts index fd3a0bae068f..339af4c97016 100644 --- a/packages/angular_devkit/build_angular/src/utils/read-tsconfig.ts +++ b/packages/angular_devkit/build_angular/src/utils/read-tsconfig.ts @@ -8,6 +8,7 @@ import type { ParsedConfiguration } from '@angular/compiler-cli'; import * as path from 'path'; +import { loadEsmModule } from './load-esm'; /** * Reads and parses a given TsConfig file. @@ -22,15 +23,14 @@ export async function readTsconfig( ): Promise { const tsConfigFullPath = workspaceRoot ? path.resolve(workspaceRoot, tsconfigPath) : tsconfigPath; - // This 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. - const compilerCliModule = await new Function(`return import('@angular/compiler-cli');`)(); + // Load ESM `@angular/compiler-cli` using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + const compilerCliModule = await loadEsmModule<{ readConfiguration: unknown; default: unknown }>( + '@angular/compiler-cli', + ); // If it is not ESM then the functions needed will be stored in the `default` property. + // TODO_ESM: This can be removed once `@angular/compiler-cli` is ESM only. const { formatDiagnostics, readConfiguration } = ( compilerCliModule.readConfiguration ? compilerCliModule : compilerCliModule.default ) as typeof import('@angular/compiler-cli'); diff --git a/packages/angular_devkit/build_angular/src/utils/service-worker.ts b/packages/angular_devkit/build_angular/src/utils/service-worker.ts index 32ec4a5d0e1c..3f2536b2b2b2 100644 --- a/packages/angular_devkit/build_angular/src/utils/service-worker.ts +++ b/packages/angular_devkit/build_angular/src/utils/service-worker.ts @@ -7,11 +7,12 @@ */ import { Path, getSystemPath, normalize } from '@angular-devkit/core'; -import { Config, Filesystem, Generator } from '@angular/service-worker/config'; +import type { Config, Filesystem } from '@angular/service-worker/config'; import * as crypto from 'crypto'; import { createReadStream, promises as fs, constants as fsConstants } from 'fs'; import * as path from 'path'; import { pipeline } from 'stream'; +import { loadEsmModule } from './load-esm'; class CliFilesystem implements Filesystem { constructor(private base: string) {} @@ -103,8 +104,14 @@ export async function augmentAppWithServiceWorker( } } + // Load ESM `@angular/service-worker/config` using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + const GeneratorConstructor = ( + await loadEsmModule(swConfigPath) + ).Generator; + // Generate the manifest - const GeneratorConstructor = require(swConfigPath).Generator as typeof Generator; const generator = new GeneratorConstructor(new CliFilesystem(distPath), baseHref); const output = await generator.process(config); diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts index ef09d49f9da1..ba9d0e64a4fe 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts @@ -30,6 +30,7 @@ import { persistentBuildCacheEnabled, profilingEnabled, } from '../../utils/environment-options'; +import { loadEsmModule } from '../../utils/load-esm'; import { Spinner } from '../../utils/spinner'; import { addError } from '../../utils/webpack-diagnostics'; import { DedupeModuleResolvePlugin, ScriptsWebpackPlugin } from '../plugins'; @@ -49,15 +50,15 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise('@angular/compiler-cli'); // If it is not ESM then the values needed will be stored in the `default` property. + // TODO_ESM: This can be removed once `@angular/compiler-cli` is ESM only. const { GLOBAL_DEFS_FOR_TERSER, GLOBAL_DEFS_FOR_TERSER_WITH_AOT,