Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(@angular-devkit/build-webpack): correctly handle ESM webpack configurations #22553

Merged
merged 2 commits into from
Jan 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { json, workspaces } from '@angular-devkit/core';
import * as path from 'path';
import { URL, pathToFileURL } from 'url';
import { deserialize, serialize } from 'v8';
import { BuilderInfo } from '../src';
import { Schema as BuilderSchema } from '../src/builders-schema';
Expand Down Expand Up @@ -197,7 +198,8 @@ export class WorkspaceNodeModulesArchitectHost implements ArchitectHost<NodeModu
}

async loadBuilder(info: NodeModulesBuilderInfo): Promise<Builder> {
const builder = (await import(info.import)).default;
const builder = await getBuilder(info.import);

if (builder[BuilderSymbol]) {
return builder;
}
Expand All @@ -210,3 +212,47 @@ export class WorkspaceNodeModulesArchitectHost implements ArchitectHost<NodeModu
throw new Error('Builder is not a builder');
}
}

/**
* 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>;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function getBuilder(builderPath: string): Promise<any> {
switch (path.extname(builderPath)) {
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: unknown }>(pathToFileURL(builderPath))).default;
case '.cjs':
return require(builderPath);
default:
// The file could be either CommonJS or ESM.
// CommonJS is tried first then ESM if loading fails.
try {
return require(builderPath);
} 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: unknown }>(pathToFileURL(builderPath))).default;
}

throw e;
}
}
}
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',
},
};