Skip to content

Commit 0d76bf0

Browse files
clydinalan-agius4
authored andcommitted
fix(@angular-devkit/build-angular): support WASM-based esbuild optimizer fallback
In the event that the Angular CLI is executed on a platform that does not yet have native support for esbuild, the WASM-based variant of esbuild will now be used. If the first attempt to optimize a file fails to execute the native variant of esbuild, future executions will instead use the WASM-based variant instead which will execute regardless of the native platform. The WASM-based variant, unfortunately, can be significantly slower than the native version (some cases can be several times slower). For install time concerns regarding the esbuild post-install step, esbuild is now listed as an optional dependency which will allow the post-install step to fail but allow the full npm install to pass. This install scenario should only occur in the event that the esbuild native binary cannot be installed or is otherwise unavailable.
1 parent b99371e commit 0d76bf0

File tree

10 files changed

+413
-91
lines changed

10 files changed

+413
-91
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
"css-loader": "6.3.0",
146146
"debug": "^4.1.1",
147147
"esbuild": "0.12.29",
148+
"esbuild-wasm": "0.12.29",
148149
"eslint": "7.32.0",
149150
"eslint-config-prettier": "8.3.0",
150151
"eslint-plugin-header": "3.1.1",

packages/angular_devkit/build_angular/BUILD.bazel

+2
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ ts_library(
8686
include = [
8787
"package.json",
8888
"builders.json",
89+
"esbuild-check.js",
8990
"src/**/schema.json",
9091
"src/**/*.js",
9192
"src/**/*.html",
@@ -143,6 +144,7 @@ ts_library(
143144
"@npm//critters",
144145
"@npm//css-loader",
145146
"@npm//esbuild",
147+
"@npm//esbuild-wasm",
146148
"@npm//find-cache-dir",
147149
"@npm//glob",
148150
"@npm//https-proxy-agent",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// If the platform does not support the native variant of esbuild, this will crash.
10+
// This script can then be spawned by the CLI to determine if native usage is supported.
11+
require('esbuild')
12+
.formatMessages([], { kind: 'error ' })
13+
.then(
14+
() => {},
15+
() => {},
16+
);

packages/angular_devkit/build_angular/package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"core-js": "3.18.0",
3333
"critters": "0.0.10",
3434
"css-loader": "6.3.0",
35-
"esbuild": "0.12.29",
35+
"esbuild-wasm": "0.12.29",
3636
"find-cache-dir": "3.3.2",
3737
"glob": "7.1.7",
3838
"https-proxy-agent": "5.0.0",
@@ -72,6 +72,9 @@
7272
"webpack-merge": "5.8.0",
7373
"webpack-subresource-integrity": "5.0.0"
7474
},
75+
"optionalDependencies": {
76+
"esbuild": "0.12.28"
77+
},
7578
"peerDependencies": {
7679
"@angular/compiler-cli": "^13.0.0 || ^13.0.0-next",
7780
"@angular/localize": "^13.0.0 || ^13.0.0-next",

packages/angular_devkit/build_angular/src/webpack/plugins/css-optimizer-plugin.ts

+45-20
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import { Message, formatMessages, transform } from 'esbuild';
9+
import type { Message, TransformResult } from 'esbuild';
1010
import type { Compilation, Compiler, sources } from 'webpack';
1111
import { addWarning } from '../../utils/webpack-diagnostics';
12+
import { EsbuildExecutor } from './esbuild-executor';
13+
1214
/**
1315
* The name of the plugin provided to Webpack when tapping Webpack compiler hooks.
1416
*/
@@ -26,6 +28,7 @@ export interface CssOptimizerPluginOptions {
2628
*/
2729
export class CssOptimizerPlugin {
2830
private targets: string[] | undefined;
31+
private esbuild = new EsbuildExecutor();
2932

3033
constructor(options?: CssOptimizerPluginOptions) {
3134
if (options?.supportedBrowsers) {
@@ -76,25 +79,13 @@ export class CssOptimizerPlugin {
7679
}
7780

7881
const { source, map: inputMap } = styleAssetSource.sourceAndMap();
79-
let sourceMapLine;
80-
if (inputMap) {
81-
// esbuild will automatically remap the sourcemap if provided
82-
sourceMapLine = `\n/*# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(
83-
JSON.stringify(inputMap),
84-
).toString('base64')} */`;
85-
}
86-
8782
const input = typeof source === 'string' ? source : source.toString();
88-
const { code, warnings, map } = await transform(
89-
sourceMapLine ? input + sourceMapLine : input,
90-
{
91-
loader: 'css',
92-
legalComments: 'inline',
93-
minify: true,
94-
sourcemap: !!inputMap && 'external',
95-
sourcefile: asset.name,
96-
target: this.targets,
97-
},
83+
84+
const { code, warnings, map } = await this.optimize(
85+
input,
86+
asset.name,
87+
inputMap,
88+
this.targets,
9889
);
9990

10091
await this.addWarnings(compilation, warnings);
@@ -114,9 +105,43 @@ export class CssOptimizerPlugin {
114105
});
115106
}
116107

108+
/**
109+
* Optimizes a CSS asset using esbuild.
110+
*
111+
* @param input The CSS asset source content to optimize.
112+
* @param name The name of the CSS asset. Used to generate source maps.
113+
* @param inputMap Optionally specifies the CSS asset's original source map that will
114+
* be merged with the intermediate optimized source map.
115+
* @param target Optionally specifies the target browsers for the output code.
116+
* @returns A promise resolving to the optimized CSS, source map, and any warnings.
117+
*/
118+
private optimize(
119+
input: string,
120+
name: string,
121+
inputMap: object,
122+
target: string[] | undefined,
123+
): Promise<TransformResult> {
124+
let sourceMapLine;
125+
if (inputMap) {
126+
// esbuild will automatically remap the sourcemap if provided
127+
sourceMapLine = `\n/*# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(
128+
JSON.stringify(inputMap),
129+
).toString('base64')} */`;
130+
}
131+
132+
return this.esbuild.transform(sourceMapLine ? input + sourceMapLine : input, {
133+
loader: 'css',
134+
legalComments: 'inline',
135+
minify: true,
136+
sourcemap: !!inputMap && 'external',
137+
sourcefile: name,
138+
target,
139+
});
140+
}
141+
117142
private async addWarnings(compilation: Compilation, warnings: Message[]) {
118143
if (warnings.length > 0) {
119-
for (const warning of await formatMessages(warnings, { kind: 'warning' })) {
144+
for (const warning of await this.esbuild.formatMessages(warnings, { kind: 'warning' })) {
120145
addWarning(compilation, warning);
121146
}
122147
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { spawnSync } from 'child_process';
10+
import type {
11+
FormatMessagesOptions,
12+
PartialMessage,
13+
TransformOptions,
14+
TransformResult,
15+
} from 'esbuild';
16+
import * as path from 'path';
17+
18+
/**
19+
* Provides the ability to execute esbuild regardless of the current platform's support
20+
* for using the native variant of esbuild. The native variant will be preferred (assuming
21+
* the `alwaysUseWasm` constructor option is `false) due to its inherent performance advantages.
22+
* At first use of esbuild, a supportability test will be automatically performed and the
23+
* WASM-variant will be used if needed by the platform.
24+
*/
25+
export class EsbuildExecutor
26+
implements Pick<typeof import('esbuild'), 'transform' | 'formatMessages'>
27+
{
28+
private esbuildTransform: this['transform'];
29+
private esbuildFormatMessages: this['formatMessages'];
30+
private initialized = false;
31+
32+
/**
33+
* Constructs an instance of the `EsbuildExecutor` class.
34+
*
35+
* @param alwaysUseWasm If true, the WASM-variant will be preferred and no support test will be
36+
* performed; if false (default), the native variant will be preferred.
37+
*/
38+
constructor(private alwaysUseWasm = false) {
39+
this.esbuildTransform = this.esbuildFormatMessages = () => {
40+
throw new Error('esbuild implementation missing');
41+
};
42+
}
43+
44+
/**
45+
* Determines whether the native variant of esbuild can be used on the current platform.
46+
*
47+
* @returns True, if the native variant of esbuild is support; False, if the WASM variant is required.
48+
*/
49+
static hasNativeSupport(): boolean {
50+
// Try to use native variant to ensure it is functional for the platform.
51+
// Spawning a separate esbuild check process is used to determine if the native
52+
// variant is viable. If check fails, the WASM variant is initialized instead.
53+
// Attempting to call one of the native esbuild functions is not a viable test
54+
// currently since esbuild spawn errors are currently not propagated through the
55+
// call stack for the esbuild function. If this limitation is removed in the future
56+
// then the separate process spawn check can be removed in favor of a direct function
57+
// call check.
58+
try {
59+
const { status, error } = spawnSync(process.execPath, [
60+
path.join(__dirname, '../../../esbuild-check.js'),
61+
]);
62+
63+
return status === 0 && error === undefined;
64+
} catch {
65+
return false;
66+
}
67+
}
68+
69+
/**
70+
* Initializes the esbuild transform and format messages functions.
71+
*
72+
* @returns A promise that fulfills when esbuild has been loaded and available for use.
73+
*/
74+
private async ensureEsbuild(): Promise<void> {
75+
if (this.initialized) {
76+
return;
77+
}
78+
79+
// If the WASM variant was preferred at class construction or native is not supported, use WASM
80+
if (this.alwaysUseWasm || !EsbuildExecutor.hasNativeSupport()) {
81+
await this.useWasm();
82+
this.initialized = true;
83+
84+
return;
85+
}
86+
87+
try {
88+
// Use the faster native variant if available.
89+
const { transform, formatMessages } = await import('esbuild');
90+
91+
this.esbuildTransform = transform;
92+
this.esbuildFormatMessages = formatMessages;
93+
} catch {
94+
// If the native variant is not installed then use the WASM-based variant
95+
await this.useWasm();
96+
}
97+
98+
this.initialized = true;
99+
}
100+
101+
/**
102+
* Transitions an executor instance to use the WASM-variant of esbuild.
103+
*/
104+
private async useWasm(): Promise<void> {
105+
const { transform, formatMessages } = await import('esbuild-wasm');
106+
this.esbuildTransform = transform;
107+
this.esbuildFormatMessages = formatMessages;
108+
109+
// The ESBUILD_BINARY_PATH environment variable cannot exist when attempting to use the
110+
// WASM variant. If it is then the binary located at the specified path will be used instead
111+
// of the WASM variant.
112+
delete process.env.ESBUILD_BINARY_PATH;
113+
114+
this.alwaysUseWasm = true;
115+
}
116+
117+
async transform(input: string, options?: TransformOptions): Promise<TransformResult> {
118+
await this.ensureEsbuild();
119+
120+
return this.esbuildTransform(input, options);
121+
}
122+
123+
async formatMessages(
124+
messages: PartialMessage[],
125+
options: FormatMessagesOptions,
126+
): Promise<string[]> {
127+
await this.ensureEsbuild();
128+
129+
return this.esbuildFormatMessages(messages, options);
130+
}
131+
}

packages/angular_devkit/build_angular/src/webpack/plugins/javascript-optimizer-plugin.ts

+5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Piscina from 'piscina';
1010
import { ScriptTarget } from 'typescript';
1111
import type { Compiler, sources } from 'webpack';
1212
import { maxWorkers } from '../../utils/environment-options';
13+
import { EsbuildExecutor } from './esbuild-executor';
1314

1415
/**
1516
* The maximum number of Workers that will be created to execute optimize tasks.
@@ -160,6 +161,10 @@ export class JavaScriptOptimizerPlugin {
160161
target,
161162
removeLicenses: this.options.removeLicenses,
162163
advanced: this.options.advanced,
164+
// Perform a single native esbuild support check.
165+
// This removes the need for each worker to perform the check which would
166+
// otherwise require spawning a separate process per worker.
167+
alwaysUseWasm: !EsbuildExecutor.hasNativeSupport(),
163168
};
164169

165170
// Sort scripts so larger scripts start first - worker pool uses a FIFO queue

0 commit comments

Comments
 (0)