Skip to content

Commit 8ace0e4

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. 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 8ace0e4

File tree

8 files changed

+981
-2
lines changed

8 files changed

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

0 commit comments

Comments
 (0)