Skip to content

Commit

Permalink
fix(@angular-devkit/build-webpack): correctly handle ESM webpack conf…
Browse files Browse the repository at this point in the history
…igurations

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)
  • Loading branch information
alan-agius4 committed Jan 24, 2022
1 parent 9b3b57a commit 820ff2a
Show file tree
Hide file tree
Showing 9 changed files with 114 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type DevServerBuildOutput = BuildResult & {
address: string;
};

// @public
// @public (undocumented)
export interface EmittedFiles {
// (undocumented)
asset?: boolean;
Expand Down
53 changes: 52 additions & 1 deletion packages/angular_devkit/build_webpack/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/

import { existsSync } from 'fs';
import * as path from 'path';
import { URL, pathToFileURL } from 'url';
import { Compilation, Configuration } from 'webpack';

export interface EmittedFiles {
id?: string;
Expand All @@ -17,7 +20,7 @@ export interface EmittedFiles {
extension: string;
}

export function getEmittedFiles(compilation: import('webpack').Compilation): EmittedFiles[] {
export function getEmittedFiles(compilation: Compilation): EmittedFiles[] {
const files: EmittedFiles[] = [];
const chunkFileNames = new Set<string>();

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

return files;
}

/**
* 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.
*/
function loadEsmModule<T>(modulePath: string | URL): Promise<T> {
return new Function('modulePath', `return import(modulePath);`)(modulePath) as Promise<T>;
}

export async function getWebpackConfig(configPath: string): Promise<Configuration> {
if (!existsSync(configPath)) {
throw new Error(`Webpack configuration file ${configPath} does not exist.`);
}

switch (path.extname(configPath)) {
case '.mjs':
// Load the ESM configuration file 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.
return (await loadEsmModule<{ default: Configuration }>(pathToFileURL(configPath))).default;
case '.cjs':
return require(configPath);
default:
// The file could be either CommonJS or ESM.
// CommonJS is tried first then ESM if loading fails.
try {
return require(configPath);
} catch (e) {
if (e.code === 'ERR_REQUIRE_ESM') {
// Load the ESM configuration file 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.
return (await loadEsmModule<{ default: Configuration }>(pathToFileURL(configPath)))
.default;
}

throw e;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Observable, from, isObservable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import { getEmittedFiles } from '../utils';
import { getEmittedFiles, getWebpackConfig } from '../utils';
import { BuildResult, WebpackFactory, WebpackLoggingCallback } from '../webpack';
import { Schema as WebpackDevServerBuilderSchema } from './schema';

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

return from(import(configPath)).pipe(
switchMap(({ default: config }: { default: webpack.Configuration }) =>
runWebpackDevServer(config, context),
),
return from(getWebpackConfig(configPath)).pipe(
switchMap((config) => runWebpackDevServer(config, context)),
);
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,23 @@ describe('Dev Server Builder', () => {
await createArchitect(workspaceRoot);
});

it('works', async () => {
const run = await architect.scheduleTarget(webpackTargetSpec);
it('works with CJS config', async () => {
const run = await architect.scheduleTarget(webpackTargetSpec, {
webpackConfig: 'webpack.config.cjs',
});
const output = (await run.result) as DevServerBuildOutput;
expect(output.success).toBe(true);

const response = await fetch(`http://${output.address}:${output.port}/bundle.js`);
expect(await response.text()).toContain(`console.log('hello world')`);

await run.stop();
}, 30000);

it('works with ESM config', async () => {
const run = await architect.scheduleTarget(webpackTargetSpec, {
webpackConfig: 'webpack.config.mjs',
});
const output = (await run.result) as DevServerBuildOutput;
expect(output.success).toBe(true);

Expand Down
8 changes: 3 additions & 5 deletions packages/angular_devkit/build_webpack/src/webpack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { resolve as pathResolve } from 'path';
import { Observable, from, isObservable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import webpack from 'webpack';
import { EmittedFiles, getEmittedFiles } from '../utils';
import { EmittedFiles, getEmittedFiles, getWebpackConfig } from '../utils';
import { Schema as RealWebpackBuilderSchema } from './schema';

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

return from(import(configPath)).pipe(
switchMap(({ default: config }: { default: webpack.Configuration }) =>
runWebpack(config, context),
),
return from(getWebpackConfig(configPath)).pipe(
switchMap((config) => runWebpack(config, context)),
);
});
19 changes: 17 additions & 2 deletions packages/angular_devkit/build_webpack/src/webpack/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,23 @@ describe('Webpack Builder basic test', () => {
await createArchitect(workspaceRoot);
});

it('works', async () => {
const run = await architect.scheduleTarget({ project: 'app', target: 'build' });
it('works with CJS config', async () => {
const run = await architect.scheduleTarget(
{ project: 'app', target: 'build' },
{ webpackConfig: 'webpack.config.cjs' },
);
const output = await run.result;

expect(output.success).toBe(true);
expect(await vfHost.exists(join(outputPath, 'bundle.js')).toPromise()).toBe(true);
await run.stop();
});

it('works with ESM config', async () => {
const run = await architect.scheduleTarget(
{ project: 'app', target: 'build' },
{ webpackConfig: 'webpack.config.mjs' },
);
const output = await run.result;

expect(output.success).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@
"build": {
"builder": "../../:webpack",
"options": {
"webpackConfig": "webpack.config.js"
"webpackConfig": "webpack.config.cjs"
}
},
"serve": {
"builder": "../../:webpack-dev-server",
"options": {
"webpackConfig": "webpack.config.js"
"webpackConfig": "webpack.config.cjs"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { resolve } from 'path';
import { fileURLToPath } from 'url';

export default {
mode: 'development',
entry: resolve(fileURLToPath(import.meta.url), '../src/main.js'),
module: {
rules: [
// rxjs 6 requires directory imports which are not support in ES modules.
// Disabling `fullySpecified` allows Webpack to ignore this but this is
// not ideal because it currently disables ESM behavior import for all JS files.
{ test: /\.[m]?js$/, resolve: { fullySpecified: false } },
],
},
output: {
path: resolve(fileURLToPath(import.meta.url), '../dist'),
filename: 'bundle.js',
},
};

0 comments on commit 820ff2a

Please sign in to comment.