Skip to content

Commit e77baea

Browse files
committed
feat(@angular-devkit/build-angular): add initial experimental esbuild-based application browser builder
An experimental browser application builder (`browser-esbuild`) has been introduced that leverages esbuild as the bundler. This new builder is compatible with options of the current browser application builder (`browser`) and can be enabled for experimentation purposes by replacing the `builder` field of `@angular-devkit/build-angular:browser` from an existing project to `@angular-devkit/build-angular:browser-esbuild`. The builder will generate an ESM-based application output and is currently limited to an ES2020 compatible output. This builder is considered experimental and is not recommended for production applications. Currently not all `browser` builder options and capabilities are supported with this experimental builder. Those that are unsupported will be ignored with a warning. Additional support for these options may be added in the future. The following options and capabilities are not currently supported: * Stylesheet Preprocessors (only CSS styles are supported) * Angular JIT mode (only AOT is supported) * Localization [`localize`] * Watch and dev-server modes [`watch`, `poll`, etc.] * File replacements [`fileReplacements`] * License text extraction [`extractLicenses`] * Bundle budgets [`budgets`] * Global scripts [`scripts`] * Build stats JSON output [`statsJson`] * Deploy URL [`deployURL`] * CommonJS module warnings (no warnings will be generated for CommonJS package usage) * Web Workers * Service workers [`serviceWorker`, `ngswConfigPath`]
1 parent f74a79f commit e77baea

File tree

8 files changed

+977
-2
lines changed

8 files changed

+977
-2
lines changed

packages/angular_devkit/build_angular/builders.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
"schema": "./src/builders/browser/schema.json",
1212
"description": "Build a browser application."
1313
},
14+
"browser-esbuild": {
15+
"implementation": "./src/builders/browser-esbuild",
16+
"schema": "./src/builders/browser/schema.json",
17+
"description": "Build a browser application."
18+
},
1419
"dev-server": {
1520
"implementation": "./src/builders/dev-server",
1621
"schema": "./src/builders/dev-server/schema.json",

packages/angular_devkit/build_angular/src/babel/webpack-loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ let linkerPluginCreator:
3939
*/
4040
let i18nPluginCreators: I18nPluginCreators | undefined;
4141

42-
async function requiresLinking(path: string, source: string): Promise<boolean> {
42+
export async function requiresLinking(path: string, source: string): Promise<boolean> {
4343
// @angular/core and @angular/compiler will cause false positives
4444
// Also, TypeScript files do not require linking
4545
if (/[\\/]@angular[\\/](?:compiler|core)|\.tsx?$/.test(path)) {
Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
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 type { CompilerHost } from '@angular/compiler-cli';
10+
import { transformAsync } from '@babel/core';
11+
// Temporary deep import for transformer support
12+
import { createAotTransformers, mergeTransformers } from '@ngtools/webpack/src/ivy/transformation';
13+
import type { OnStartResult, PartialMessage, PartialNote, Plugin, PluginBuild } from 'esbuild';
14+
import { promises as fs } from 'fs';
15+
import * as path from 'path';
16+
import ts from 'typescript';
17+
import { requiresLinking } from '../../babel/webpack-loader';
18+
import { loadEsmModule } from '../../utils/load-esm';
19+
import { BundleStylesheetOptions, bundleStylesheetData, bundleStylesheetFile } from './stylesheets';
20+
21+
interface EmitFileResult {
22+
content?: string;
23+
map?: string;
24+
dependencies: readonly string[];
25+
hash?: Uint8Array;
26+
}
27+
type FileEmitter = (file: string) => Promise<EmitFileResult | undefined>;
28+
29+
/**
30+
* Converts TypeScript Diagnostic related information into an esbuild compatible note object.
31+
* Related information is a subset of a full TypeScript Diagnostic and also used for diagnostic
32+
* notes associated with the main Diagnostic.
33+
* @param diagnostic The TypeScript diagnostic relative information to convert.
34+
* @param host A TypeScript FormatDiagnosticsHost instance to use during conversion.
35+
* @returns An esbuild diagnostic message as a PartialMessage object
36+
*/
37+
function convertTypeScriptDiagnosticInfo(
38+
info: ts.DiagnosticRelatedInformation,
39+
host: ts.FormatDiagnosticsHost,
40+
textPrefix?: string,
41+
): PartialNote {
42+
let text = ts.flattenDiagnosticMessageText(info.messageText, host.getNewLine());
43+
if (textPrefix) {
44+
text = textPrefix + text;
45+
}
46+
47+
const note: PartialNote = { text };
48+
49+
if (info.file) {
50+
note.location = {
51+
file: info.file.fileName,
52+
length: info.length,
53+
};
54+
55+
if (info.start) {
56+
const { line, character } = ts.getLineAndCharacterOfPosition(info.file, info.start);
57+
note.location.line = line + 1;
58+
note.location.column = character;
59+
const { line: lastLine } = ts.getLineAndCharacterOfPosition(
60+
info.file,
61+
info.file.text.length - 1,
62+
);
63+
const lineStart = ts.getPositionOfLineAndCharacter(info.file, line, 0);
64+
const lineEnd =
65+
line < lastLine
66+
? ts.getPositionOfLineAndCharacter(info.file, line + 1, 0)
67+
: info.file.text.length;
68+
note.location.lineText = info.file.text.slice(lineStart, lineEnd).trimEnd();
69+
}
70+
}
71+
72+
return note;
73+
}
74+
75+
/**
76+
* Converts a TypeScript Diagnostic message into an esbuild compatible message object.
77+
* @param diagnostic The TypeScript diagnostic to convert.
78+
* @param host A TypeScript FormatDiagnosticsHost instance to use during conversion.
79+
* @returns An esbuild diagnostic message as a PartialMessage object
80+
*/
81+
function convertTypeScriptDiagnostic(
82+
diagnostic: ts.Diagnostic,
83+
host: ts.FormatDiagnosticsHost,
84+
): PartialMessage {
85+
let codePrefix = 'TS';
86+
let code = `${diagnostic.code}`;
87+
if (diagnostic.source === 'ngtsc') {
88+
codePrefix = 'NG';
89+
// Remove `-99` Angular prefix from diagnostic code
90+
code = code.slice(3);
91+
}
92+
93+
const message: PartialMessage = {
94+
...convertTypeScriptDiagnosticInfo(diagnostic, host, `${codePrefix}${code}: `),
95+
// Store original diagnostic for reference if needed downstream
96+
detail: diagnostic,
97+
};
98+
99+
if (diagnostic.relatedInformation?.length) {
100+
message.notes = diagnostic.relatedInformation.map((info) =>
101+
convertTypeScriptDiagnosticInfo(info, host),
102+
);
103+
}
104+
105+
return message;
106+
}
107+
108+
// This is a non-watch version of the compiler code from `@ngtools/webpack` augmented for esbuild
109+
// eslint-disable-next-line max-lines-per-function
110+
export function createCompilerPlugin(
111+
pluginOptions: { sourcemap: boolean; tsconfig: string; advancedOptimizations?: boolean },
112+
styleOptions: BundleStylesheetOptions,
113+
): Plugin {
114+
return {
115+
name: 'angular-compiler',
116+
// eslint-disable-next-line max-lines-per-function
117+
async setup(build: PluginBuild): Promise<void> {
118+
// This uses a wrapped dynamic import to load `@angular/compiler-cli` which is ESM.
119+
// Once TypeScript provides support for retaining dynamic imports this workaround can be dropped.
120+
const compilerCli = await loadEsmModule<typeof import('@angular/compiler-cli')>(
121+
'@angular/compiler-cli',
122+
);
123+
124+
// Setup defines based on the values provided by the Angular compiler-cli
125+
build.initialOptions.define ??= {};
126+
for (const [key, value] of Object.entries(compilerCli.GLOBAL_DEFS_FOR_TERSER_WITH_AOT)) {
127+
if (key in build.initialOptions.define) {
128+
// Skip keys that have been manually provided
129+
continue;
130+
}
131+
// esbuild requires values to be a string (actual strings need to be quoted).
132+
// In this case, all provided values are booleans.
133+
build.initialOptions.define[key] = `${value}`;
134+
}
135+
136+
// The file emitter created during `onStart` that will be used during the build in `onLoad` callbacks for TS files
137+
let fileEmitter: FileEmitter | undefined;
138+
139+
build.onStart(async () => {
140+
const result: OnStartResult = {};
141+
142+
const {
143+
options: compilerOptions,
144+
rootNames,
145+
errors,
146+
} = compilerCli.readConfiguration(
147+
pluginOptions.tsconfig,
148+
// this.pluginOptions.compilerOptions,
149+
);
150+
compilerOptions.enableIvy = true;
151+
compilerOptions.noEmitOnError = false;
152+
compilerOptions.suppressOutputPathCheck = true;
153+
compilerOptions.outDir = undefined;
154+
compilerOptions.inlineSources = pluginOptions.sourcemap;
155+
compilerOptions.inlineSourceMap = pluginOptions.sourcemap;
156+
compilerOptions.sourceMap = false;
157+
compilerOptions.mapRoot = undefined;
158+
compilerOptions.sourceRoot = undefined;
159+
compilerOptions.declaration = false;
160+
compilerOptions.declarationMap = false;
161+
compilerOptions.allowEmptyCodegenFiles = false;
162+
compilerOptions.annotationsAs = 'decorators';
163+
compilerOptions.enableResourceInlining = false;
164+
if (compilerOptions.scriptTarget < ts.ScriptTarget.ES2015) {
165+
compilerOptions.target = ts.ScriptTarget.ES2015;
166+
}
167+
168+
// Create TypeScript compiler host
169+
const host = ts.createIncrementalCompilerHost(compilerOptions);
170+
171+
const resourceHost = host as CompilerHost;
172+
173+
resourceHost.readResource = async function (fileName: string) {
174+
if (fileName.endsWith('.html')) {
175+
return this.readFile(fileName) ?? '';
176+
}
177+
178+
const { contents, errors, warnings } = await bundleStylesheetFile(fileName, styleOptions);
179+
(result.errors ??= []).push(...errors);
180+
(result.warnings ??= []).push(...warnings);
181+
182+
return contents;
183+
};
184+
185+
resourceHost.transformResource = async function (data, context) {
186+
// Only style resources are supported currently
187+
if (context.resourceFile || context.type !== 'style') {
188+
return null;
189+
}
190+
191+
const { contents, errors, warnings } = await bundleStylesheetData(
192+
data,
193+
{
194+
resolvePath: path.dirname(context.containingFile),
195+
virtualName: context.containingFile,
196+
},
197+
styleOptions,
198+
);
199+
(result.errors ??= []).push(...errors);
200+
(result.warnings ??= []).push(...warnings);
201+
202+
return { content: contents };
203+
};
204+
205+
// Create the Angular specific program that contains the Angular compiler
206+
const angularProgram = new compilerCli.NgtscProgram(
207+
rootNames,
208+
compilerOptions,
209+
host,
210+
// this.ngtscNextProgram,
211+
);
212+
const angularCompiler = angularProgram.compiler;
213+
const { ignoreForDiagnostics, ignoreForEmit } = angularCompiler;
214+
const typeScriptProgram = angularProgram.getTsProgram();
215+
216+
const builder = ts.createAbstractBuilder(typeScriptProgram, host);
217+
218+
// Collect program level diagnostics
219+
const diagnostics = [
220+
...angularCompiler.getOptionDiagnostics(),
221+
...builder.getOptionsDiagnostics(),
222+
...builder.getGlobalDiagnostics(),
223+
];
224+
225+
await angularCompiler.analyzeAsync();
226+
227+
// Collect source file specific diagnostics
228+
const OptimizeFor = compilerCli.OptimizeFor;
229+
for (const sourceFile of builder.getSourceFiles()) {
230+
if (!ignoreForDiagnostics.has(sourceFile)) {
231+
diagnostics.push(...builder.getSyntacticDiagnostics(sourceFile));
232+
diagnostics.push(...builder.getSemanticDiagnostics(sourceFile));
233+
234+
const angularDiagnostics = angularCompiler.getDiagnosticsForFile(
235+
sourceFile,
236+
OptimizeFor.WholeProgram,
237+
);
238+
diagnostics.push(...angularDiagnostics);
239+
}
240+
}
241+
242+
for (const diagnostic of diagnostics) {
243+
const message = convertTypeScriptDiagnostic(diagnostic, host);
244+
if (diagnostic.category === ts.DiagnosticCategory.Error) {
245+
(result.errors ??= []).push(message);
246+
} else {
247+
(result.warnings ??= []).push(message);
248+
}
249+
}
250+
251+
fileEmitter = createFileEmitter(
252+
builder,
253+
mergeTransformers(
254+
angularCompiler.prepareEmit().transformers,
255+
createAotTransformers(builder, {}),
256+
),
257+
() => [],
258+
);
259+
260+
return result;
261+
});
262+
263+
build.onLoad({ filter: /\.[cm]?tsx?$/ }, async (args) => {
264+
if (!fileEmitter) {
265+
throw new Error('Invalid execution order');
266+
}
267+
268+
const tsResult = await fileEmitter(args.path);
269+
const data = tsResult?.content || '';
270+
const babelResult = await transformAsync(data, {
271+
filename: args.path,
272+
inputSourceMap: (pluginOptions.sourcemap ? undefined : false) as undefined,
273+
sourceMaps: pluginOptions.sourcemap ? 'inline' : false,
274+
compact: false,
275+
configFile: false,
276+
babelrc: false,
277+
browserslistConfigFile: false,
278+
plugins: [],
279+
presets: [
280+
[
281+
require('../../babel/presets/application').default,
282+
{
283+
forceAsyncTransformation: data.includes('async'),
284+
optimize: pluginOptions.advancedOptimizations && {},
285+
},
286+
],
287+
],
288+
});
289+
290+
return {
291+
contents: babelResult?.code ?? '',
292+
loader: 'js',
293+
};
294+
});
295+
296+
build.onLoad({ filter: /\.[cm]?js$/ }, async (args) => {
297+
const angularPackage = /[\\/]node_modules[\\/]@angular[\\/]/.test(args.path);
298+
299+
const linkerPluginCreator = (
300+
await loadEsmModule<typeof import('@angular/compiler-cli/linker/babel')>(
301+
'@angular/compiler-cli/linker/babel',
302+
)
303+
).createEs2015LinkerPlugin;
304+
305+
const data = await fs.readFile(args.path, 'utf-8');
306+
const result = await transformAsync(data, {
307+
filename: args.path,
308+
inputSourceMap: (pluginOptions.sourcemap ? undefined : false) as undefined,
309+
sourceMaps: pluginOptions.sourcemap ? 'inline' : false,
310+
compact: false,
311+
configFile: false,
312+
babelrc: false,
313+
browserslistConfigFile: false,
314+
plugins: [],
315+
presets: [
316+
[
317+
require('../../babel/presets/application').default,
318+
{
319+
angularLinker: {
320+
shouldLink: await requiresLinking(args.path, data),
321+
jitMode: false,
322+
linkerPluginCreator,
323+
},
324+
forceAsyncTransformation:
325+
!/[\\/][_f]?esm2015[\\/]/.test(args.path) && data.includes('async'),
326+
optimize: pluginOptions.advancedOptimizations && {
327+
looseEnums: angularPackage,
328+
pureTopLevel: angularPackage,
329+
},
330+
},
331+
],
332+
],
333+
});
334+
335+
return {
336+
contents: result?.code ?? data,
337+
loader: 'js',
338+
};
339+
});
340+
},
341+
};
342+
}
343+
344+
function createFileEmitter(
345+
program: ts.BuilderProgram,
346+
transformers: ts.CustomTransformers = {},
347+
onAfterEmit?: (sourceFile: ts.SourceFile) => void,
348+
): FileEmitter {
349+
return async (file: string) => {
350+
const sourceFile = program.getSourceFile(file);
351+
if (!sourceFile) {
352+
return undefined;
353+
}
354+
355+
let content: string | undefined;
356+
program.emit(
357+
sourceFile,
358+
(filename, data) => {
359+
if (/\.[cm]?js$/.test(filename)) {
360+
content = data;
361+
}
362+
},
363+
undefined,
364+
undefined,
365+
transformers,
366+
);
367+
368+
onAfterEmit?.(sourceFile);
369+
370+
return { content, dependencies: [] };
371+
};
372+
}

0 commit comments

Comments
 (0)