Skip to content

Commit 820ff2a

Browse files
committedJan 24, 2022
fix(@angular-devkit/build-webpack): correctly handle ESM webpack configurations
Previoiusly, we didn't correctly handle ESM configurations as the `import` was always downlevelled to `require` by TypeScript. Closes #22547 (cherry picked from commit cb73c0b)
1 parent 9b3b57a commit 820ff2a

File tree

9 files changed

+114
-18
lines changed

9 files changed

+114
-18
lines changed
 

‎goldens/public-api/angular_devkit/build_webpack/src/index.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export type DevServerBuildOutput = BuildResult & {
2424
address: string;
2525
};
2626

27-
// @public
27+
// @public (undocumented)
2828
export interface EmittedFiles {
2929
// (undocumented)
3030
asset?: boolean;

‎packages/angular_devkit/build_webpack/src/utils.ts

+52-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import { existsSync } from 'fs';
910
import * as path from 'path';
11+
import { URL, pathToFileURL } from 'url';
12+
import { Compilation, Configuration } from 'webpack';
1013

1114
export interface EmittedFiles {
1215
id?: string;
@@ -17,7 +20,7 @@ export interface EmittedFiles {
1720
extension: string;
1821
}
1922

20-
export function getEmittedFiles(compilation: import('webpack').Compilation): EmittedFiles[] {
23+
export function getEmittedFiles(compilation: Compilation): EmittedFiles[] {
2124
const files: EmittedFiles[] = [];
2225
const chunkFileNames = new Set<string>();
2326

@@ -51,3 +54,51 @@ export function getEmittedFiles(compilation: import('webpack').Compilation): Emi
5154

5255
return files;
5356
}
57+
58+
/**
59+
* This uses a dynamic import to load a module which may be ESM.
60+
* CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript
61+
* will currently, unconditionally downlevel dynamic import into a require call.
62+
* require calls cannot load ESM code and will result in a runtime error. To workaround
63+
* this, a Function constructor is used to prevent TypeScript from changing the dynamic import.
64+
* Once TypeScript provides support for keeping the dynamic import this workaround can
65+
* be dropped.
66+
*
67+
* @param modulePath The path of the module to load.
68+
* @returns A Promise that resolves to the dynamically imported module.
69+
*/
70+
function loadEsmModule<T>(modulePath: string | URL): Promise<T> {
71+
return new Function('modulePath', `return import(modulePath);`)(modulePath) as Promise<T>;
72+
}
73+
74+
export async function getWebpackConfig(configPath: string): Promise<Configuration> {
75+
if (!existsSync(configPath)) {
76+
throw new Error(`Webpack configuration file ${configPath} does not exist.`);
77+
}
78+
79+
switch (path.extname(configPath)) {
80+
case '.mjs':
81+
// Load the ESM configuration file using the TypeScript dynamic import workaround.
82+
// Once TypeScript provides support for keeping the dynamic import this workaround can be
83+
// changed to a direct dynamic import.
84+
return (await loadEsmModule<{ default: Configuration }>(pathToFileURL(configPath))).default;
85+
case '.cjs':
86+
return require(configPath);
87+
default:
88+
// The file could be either CommonJS or ESM.
89+
// CommonJS is tried first then ESM if loading fails.
90+
try {
91+
return require(configPath);
92+
} catch (e) {
93+
if (e.code === 'ERR_REQUIRE_ESM') {
94+
// Load the ESM configuration file using the TypeScript dynamic import workaround.
95+
// Once TypeScript provides support for keeping the dynamic import this workaround can be
96+
// changed to a direct dynamic import.
97+
return (await loadEsmModule<{ default: Configuration }>(pathToFileURL(configPath)))
98+
.default;
99+
}
100+
101+
throw e;
102+
}
103+
}
104+
}

‎packages/angular_devkit/build_webpack/src/webpack-dev-server/index.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Observable, from, isObservable, of } from 'rxjs';
1212
import { switchMap } from 'rxjs/operators';
1313
import webpack from 'webpack';
1414
import WebpackDevServer from 'webpack-dev-server';
15-
import { getEmittedFiles } from '../utils';
15+
import { getEmittedFiles, getWebpackConfig } from '../utils';
1616
import { BuildResult, WebpackFactory, WebpackLoggingCallback } from '../webpack';
1717
import { Schema as WebpackDevServerBuilderSchema } from './schema';
1818

@@ -112,10 +112,8 @@ export default createBuilder<WebpackDevServerBuilderSchema, DevServerBuildOutput
112112
(options, context) => {
113113
const configPath = pathResolve(context.workspaceRoot, options.webpackConfig);
114114

115-
return from(import(configPath)).pipe(
116-
switchMap(({ default: config }: { default: webpack.Configuration }) =>
117-
runWebpackDevServer(config, context),
118-
),
115+
return from(getWebpackConfig(configPath)).pipe(
116+
switchMap((config) => runWebpackDevServer(config, context)),
119117
);
120118
},
121119
);

‎packages/angular_devkit/build_webpack/src/webpack-dev-server/index_spec.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,23 @@ describe('Dev Server Builder', () => {
4747
await createArchitect(workspaceRoot);
4848
});
4949

50-
it('works', async () => {
51-
const run = await architect.scheduleTarget(webpackTargetSpec);
50+
it('works with CJS config', async () => {
51+
const run = await architect.scheduleTarget(webpackTargetSpec, {
52+
webpackConfig: 'webpack.config.cjs',
53+
});
54+
const output = (await run.result) as DevServerBuildOutput;
55+
expect(output.success).toBe(true);
56+
57+
const response = await fetch(`http://${output.address}:${output.port}/bundle.js`);
58+
expect(await response.text()).toContain(`console.log('hello world')`);
59+
60+
await run.stop();
61+
}, 30000);
62+
63+
it('works with ESM config', async () => {
64+
const run = await architect.scheduleTarget(webpackTargetSpec, {
65+
webpackConfig: 'webpack.config.mjs',
66+
});
5267
const output = (await run.result) as DevServerBuildOutput;
5368
expect(output.success).toBe(true);
5469

‎packages/angular_devkit/build_webpack/src/webpack/index.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { resolve as pathResolve } from 'path';
1111
import { Observable, from, isObservable, of } from 'rxjs';
1212
import { switchMap } from 'rxjs/operators';
1313
import webpack from 'webpack';
14-
import { EmittedFiles, getEmittedFiles } from '../utils';
14+
import { EmittedFiles, getEmittedFiles, getWebpackConfig } from '../utils';
1515
import { Schema as RealWebpackBuilderSchema } from './schema';
1616

1717
export type WebpackBuilderSchema = RealWebpackBuilderSchema;
@@ -118,9 +118,7 @@ export function runWebpack(
118118
export default createBuilder<WebpackBuilderSchema>((options, context) => {
119119
const configPath = pathResolve(context.workspaceRoot, options.webpackConfig);
120120

121-
return from(import(configPath)).pipe(
122-
switchMap(({ default: config }: { default: webpack.Configuration }) =>
123-
runWebpack(config, context),
124-
),
121+
return from(getWebpackConfig(configPath)).pipe(
122+
switchMap((config) => runWebpack(config, context)),
125123
);
126124
});

‎packages/angular_devkit/build_webpack/src/webpack/index_spec.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,23 @@ describe('Webpack Builder basic test', () => {
4747
await createArchitect(workspaceRoot);
4848
});
4949

50-
it('works', async () => {
51-
const run = await architect.scheduleTarget({ project: 'app', target: 'build' });
50+
it('works with CJS config', async () => {
51+
const run = await architect.scheduleTarget(
52+
{ project: 'app', target: 'build' },
53+
{ webpackConfig: 'webpack.config.cjs' },
54+
);
55+
const output = await run.result;
56+
57+
expect(output.success).toBe(true);
58+
expect(await vfHost.exists(join(outputPath, 'bundle.js')).toPromise()).toBe(true);
59+
await run.stop();
60+
});
61+
62+
it('works with ESM config', async () => {
63+
const run = await architect.scheduleTarget(
64+
{ project: 'app', target: 'build' },
65+
{ webpackConfig: 'webpack.config.mjs' },
66+
);
5267
const output = await run.result;
5368

5469
expect(output.success).toBe(true);

‎packages/angular_devkit/build_webpack/test/basic-app/angular.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
"build": {
1616
"builder": "../../:webpack",
1717
"options": {
18-
"webpackConfig": "webpack.config.js"
18+
"webpackConfig": "webpack.config.cjs"
1919
}
2020
},
2121
"serve": {
2222
"builder": "../../:webpack-dev-server",
2323
"options": {
24-
"webpackConfig": "webpack.config.js"
24+
"webpackConfig": "webpack.config.cjs"
2525
}
2626
}
2727
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { resolve } from 'path';
2+
import { fileURLToPath } from 'url';
3+
4+
export default {
5+
mode: 'development',
6+
entry: resolve(fileURLToPath(import.meta.url), '../src/main.js'),
7+
module: {
8+
rules: [
9+
// rxjs 6 requires directory imports which are not support in ES modules.
10+
// Disabling `fullySpecified` allows Webpack to ignore this but this is
11+
// not ideal because it currently disables ESM behavior import for all JS files.
12+
{ test: /\.[m]?js$/, resolve: { fullySpecified: false } },
13+
],
14+
},
15+
output: {
16+
path: resolve(fileURLToPath(import.meta.url), '../dist'),
17+
filename: 'bundle.js',
18+
},
19+
};

0 commit comments

Comments
 (0)
Please sign in to comment.