Skip to content

Commit 67324b3

Browse files
committed
feat(@angular-devkit/build-angular): add initial incremental code rebuilding to esbuild builder
The experimental esbuild-based browser application builder will now support incremental JavaScript bundling when run in watch mode via the `watch` option. This initial implementation integrates the esbuild incremental rebuild functionality. TypeScript source file caching has also been added to improve the rebuild initialization time for the TypeScript and Angular compilation steps. This initial support is not yet fully optimized and additional work is planned to further improve the rebuild performance.
1 parent 301b566 commit 67324b3

File tree

4 files changed

+117
-20
lines changed

4 files changed

+117
-20
lines changed

packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
PluginBuild,
1919
} from 'esbuild';
2020
import { promises as fs } from 'fs';
21+
import { platform } from 'os';
2122
import * as path from 'path';
2223
import ts from 'typescript';
2324
import angularApplicationPreset from '../../babel/presets/application';
@@ -120,12 +121,33 @@ function convertTypeScriptDiagnostic(
120121
return message;
121122
}
122123

124+
const USING_WINDOWS = platform() === 'win32';
125+
const WINDOWS_SEP_REGEXP = new RegExp(`\\${path.win32.sep}`, 'g');
126+
127+
export class SourceFileCache extends Map<string, ts.SourceFile> {
128+
readonly modifiedFiles = new Set<string>();
129+
130+
invalidate(files: Iterable<string>): void {
131+
this.modifiedFiles.clear();
132+
for (let file of files) {
133+
// Normalize separators to allow matching TypeScript Host paths
134+
if (USING_WINDOWS) {
135+
file = file.replace(WINDOWS_SEP_REGEXP, path.posix.sep);
136+
}
137+
138+
this.delete(file);
139+
this.modifiedFiles.add(file);
140+
}
141+
}
142+
}
143+
123144
export interface CompilerPluginOptions {
124145
sourcemap: boolean;
125146
tsconfig: string;
126147
advancedOptimizations?: boolean;
127148
thirdPartySourcemaps?: boolean;
128149
fileReplacements?: Record<string, string>;
150+
sourceFileCache?: SourceFileCache;
129151
}
130152

131153
// This is a non-watch version of the compiler code from `@ngtools/webpack` augmented for esbuild
@@ -262,6 +284,7 @@ export function createCompilerPlugin(
262284

263285
// Temporary deep import for host augmentation support
264286
const {
287+
augmentHostWithCaching,
265288
augmentHostWithReplacements,
266289
augmentProgramWithVersioning,
267290
} = require('@ngtools/webpack/src/ivy/host');
@@ -271,6 +294,15 @@ export function createCompilerPlugin(
271294
augmentHostWithReplacements(host, pluginOptions.fileReplacements);
272295
}
273296

297+
// Augment TypeScript Host with source file caching if provided
298+
if (pluginOptions.sourceFileCache) {
299+
augmentHostWithCaching(host, pluginOptions.sourceFileCache);
300+
// Allow the AOT compiler to request the set of changed templates and styles
301+
(host as CompilerHost).getModifiedResourceFiles = function () {
302+
return pluginOptions.sourceFileCache?.modifiedFiles;
303+
};
304+
}
305+
274306
// Create the Angular specific program that contains the Angular compiler
275307
const angularProgram = new compilerCli.NgtscProgram(
276308
rootNames,

packages/angular_devkit/build_angular/src/builders/browser-esbuild/esbuild.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { BuilderContext } from '@angular-devkit/architect';
1010
import {
1111
BuildFailure,
12+
BuildInvalidate,
1213
BuildOptions,
1314
BuildResult,
1415
Message,
@@ -32,20 +33,25 @@ export function isEsBuildFailure(value: unknown): value is BuildFailure {
3233
* All builds use the `write` option with a value of `false` to allow for the output files
3334
* build result array to be populated.
3435
*
35-
* @param options The esbuild options object to use when building.
36+
* @param optionsOrInvalidate The esbuild options object to use when building or the invalidate object
37+
* returned from an incremental build to perform an additional incremental build.
3638
* @returns If output files are generated, the full esbuild BuildResult; if not, the
3739
* warnings and errors for the attempted build.
3840
*/
3941
export async function bundle(
40-
options: BuildOptions,
42+
optionsOrInvalidate: BuildOptions | BuildInvalidate,
4143
): Promise<
4244
(BuildResult & { outputFiles: OutputFile[] }) | (BuildFailure & { outputFiles?: never })
4345
> {
4446
try {
45-
return await build({
46-
...options,
47-
write: false,
48-
});
47+
if (typeof optionsOrInvalidate === 'function') {
48+
return (await optionsOrInvalidate()) as BuildResult & { outputFiles: OutputFile[] };
49+
} else {
50+
return await build({
51+
...optionsOrInvalidate,
52+
write: false,
53+
});
54+
}
4955
} catch (failure) {
5056
// Build failures will throw an exception which contains errors/warnings
5157
if (isEsBuildFailure(failure)) {

packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
1010
import * as assert from 'assert';
11-
import type { Message, OutputFile } from 'esbuild';
11+
import type { BuildInvalidate, BuildOptions, Message, OutputFile } from 'esbuild';
1212
import * as fs from 'fs/promises';
1313
import * as path from 'path';
1414
import { deleteOutputDir } from '../../utils';
@@ -19,18 +19,56 @@ import { FileInfo } from '../../utils/index-file/augment-index-html';
1919
import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator';
2020
import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker';
2121
import { getSupportedBrowsers } from '../../utils/supported-browsers';
22-
import { createCompilerPlugin } from './compiler-plugin';
22+
import { SourceFileCache, createCompilerPlugin } from './compiler-plugin';
2323
import { bundle, logMessages } from './esbuild';
2424
import { logExperimentalWarnings } from './experimental-warnings';
2525
import { NormalizedBrowserOptions, normalizeOptions } from './options';
2626
import { Schema as BrowserBuilderOptions } from './schema';
2727
import { bundleStylesheetText } from './stylesheets';
28-
import { createWatcher } from './watcher';
28+
import { ChangedFiles, createWatcher } from './watcher';
29+
30+
interface RebuildState {
31+
codeRebuild?: BuildInvalidate;
32+
codeBundleCache?: SourceFileCache;
33+
fileChanges: ChangedFiles;
34+
}
35+
36+
/**
37+
* Represents the result of a single builder execute call.
38+
*/
39+
class ExecutionResult {
40+
constructor(
41+
private success: boolean,
42+
private codeRebuild?: BuildInvalidate,
43+
private codeBundleCache?: SourceFileCache,
44+
) {}
45+
46+
get output() {
47+
return {
48+
success: this.success,
49+
};
50+
}
51+
52+
createRebuildState(fileChanges: ChangedFiles): RebuildState {
53+
this.codeBundleCache?.invalidate([...fileChanges.modified, ...fileChanges.removed]);
54+
55+
return {
56+
codeRebuild: this.codeRebuild,
57+
codeBundleCache: this.codeBundleCache,
58+
fileChanges,
59+
};
60+
}
61+
62+
dispose(): void {
63+
this.codeRebuild?.dispose();
64+
}
65+
}
2966

3067
async function execute(
3168
options: NormalizedBrowserOptions,
3269
context: BuilderContext,
33-
): Promise<BuilderOutput> {
70+
rebuildState?: RebuildState,
71+
): Promise<ExecutionResult> {
3472
const startTime = Date.now();
3573

3674
const {
@@ -47,9 +85,13 @@ async function execute(
4785
getSupportedBrowsers(projectRoot, context.logger),
4886
);
4987

88+
const codeBundleCache = options.watch
89+
? rebuildState?.codeBundleCache ?? new SourceFileCache()
90+
: undefined;
91+
5092
const [codeResults, styleResults] = await Promise.all([
5193
// Execute esbuild to bundle the application code
52-
bundleCode(options, target),
94+
bundle(rebuildState?.codeRebuild ?? createCodeBundleOptions(options, target, codeBundleCache)),
5395
// Execute esbuild to bundle the global stylesheets
5496
bundleGlobalStylesheets(options, target),
5597
]);
@@ -62,7 +104,7 @@ async function execute(
62104

63105
// Return if the bundling failed to generate output files or there are errors
64106
if (!codeResults.outputFiles || codeResults.errors.length) {
65-
return { success: false };
107+
return new ExecutionResult(false, rebuildState?.codeRebuild, codeBundleCache);
66108
}
67109

68110
// Structure the code bundling output files
@@ -93,7 +135,7 @@ async function execute(
93135

94136
// Return if the global stylesheet bundling has errors
95137
if (styleResults.errors.length) {
96-
return { success: false };
138+
return new ExecutionResult(false, codeResults.rebuild, codeBundleCache);
97139
}
98140

99141
// Generate index HTML file
@@ -160,13 +202,13 @@ async function execute(
160202
} catch (error) {
161203
context.logger.error(error instanceof Error ? error.message : `${error}`);
162204

163-
return { success: false };
205+
return new ExecutionResult(false, codeResults.rebuild, codeBundleCache);
164206
}
165207
}
166208

167209
context.logger.info(`Complete. [${(Date.now() - startTime) / 1000} seconds]`);
168210

169-
return { success: true };
211+
return new ExecutionResult(true, codeResults.rebuild, codeBundleCache);
170212
}
171213

172214
function createOutputFileFromText(path: string, text: string): OutputFile {
@@ -179,7 +221,11 @@ function createOutputFileFromText(path: string, text: string): OutputFile {
179221
};
180222
}
181223

182-
async function bundleCode(options: NormalizedBrowserOptions, target: string[]) {
224+
function createCodeBundleOptions(
225+
options: NormalizedBrowserOptions,
226+
target: string[],
227+
sourceFileCache?: SourceFileCache,
228+
): BuildOptions {
183229
const {
184230
workspaceRoot,
185231
entryPoints,
@@ -194,9 +240,10 @@ async function bundleCode(options: NormalizedBrowserOptions, target: string[]) {
194240
advancedOptimizations,
195241
} = options;
196242

197-
return bundle({
243+
return {
198244
absWorkingDir: workspaceRoot,
199245
bundle: true,
246+
incremental: options.watch,
200247
format: 'esm',
201248
entryPoints,
202249
entryNames: outputNames.bundles,
@@ -234,6 +281,7 @@ async function bundleCode(options: NormalizedBrowserOptions, target: string[]) {
234281
tsconfig,
235282
advancedOptimizations,
236283
fileReplacements,
284+
sourceFileCache,
237285
},
238286
// Component stylesheet options
239287
{
@@ -255,7 +303,7 @@ async function bundleCode(options: NormalizedBrowserOptions, target: string[]) {
255303
...(optimizationOptions.scripts ? { 'ngDevMode': 'false' } : undefined),
256304
'ngJitMode': 'false',
257305
},
258-
});
306+
};
259307
}
260308

261309
async function bundleGlobalStylesheets(options: NormalizedBrowserOptions, target: string[]) {
@@ -383,13 +431,16 @@ export async function* buildEsbuildBrowser(
383431
}
384432

385433
// Initial build
386-
yield await execute(normalizedOptions, context);
434+
let result = await execute(normalizedOptions, context);
435+
yield result.output;
387436

388437
// Finish if watch mode is not enabled
389438
if (!initialOptions.watch) {
390439
return;
391440
}
392441

442+
context.logger.info('Watch mode enabled. Watching for file changes...');
443+
393444
// Setup a watcher
394445
const watcher = createWatcher({
395446
polling: typeof initialOptions.poll === 'number',
@@ -416,10 +467,14 @@ export async function* buildEsbuildBrowser(
416467
context.logger.info(changes.toDebugString());
417468
}
418469

419-
yield await execute(normalizedOptions, context);
470+
result = await execute(normalizedOptions, context, result.createRebuildState(changes));
471+
yield result.output;
420472
}
421473
} finally {
474+
// Stop the watcher
422475
await watcher.close();
476+
// Cleanup incremental rebuild state
477+
result.dispose();
423478
}
424479
}
425480

packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,12 @@ export async function normalizeOptions(
133133
buildOptimizer,
134134
crossOrigin,
135135
externalDependencies,
136+
poll,
136137
preserveSymlinks,
137138
stylePreprocessorOptions,
138139
subresourceIntegrity,
139140
verbose,
141+
watch,
140142
} = options;
141143

142144
// Return all the normalized options
@@ -145,10 +147,12 @@ export async function normalizeOptions(
145147
baseHref,
146148
crossOrigin,
147149
externalDependencies,
150+
poll,
148151
preserveSymlinks,
149152
stylePreprocessorOptions,
150153
subresourceIntegrity,
151154
verbose,
155+
watch,
152156
workspaceRoot,
153157
entryPoints,
154158
optimizationOptions,

0 commit comments

Comments
 (0)