Skip to content

Commit 3d94ca2

Browse files
committed
feat(@angular-devkit/build-angular): add initial watch support to esbuild-based builder
The experimental esbuild-based browser application builder now contains initial support for watching input files and rebuilding the application via the `--watch` option. This initial implemention is not yet optimized for incremental rebuilds and will perform a full rebuild upon detection of a change. Incremental rebuild support will be added in followup changes and will significantly improve the rebuild speed. The `chokidar` npm package is used to perform the file watching which allows for native file- system event based watching. Polling is also support via the `--poll` option for environments that require it.
1 parent 033e8ca commit 3d94ca2

File tree

7 files changed

+207
-57
lines changed

7 files changed

+207
-57
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@
136136
"bootstrap": "^4.0.0",
137137
"browserslist": "^4.9.1",
138138
"cacache": "16.1.3",
139-
"chokidar": "^3.5.2",
139+
"chokidar": "3.5.3",
140140
"copy-webpack-plugin": "11.0.0",
141141
"critters": "0.0.16",
142142
"cross-env": "^7.0.3",

packages/angular_devkit/build_angular/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ ts_library(
138138
"@npm//babel-plugin-istanbul",
139139
"@npm//browserslist",
140140
"@npm//cacache",
141+
"@npm//chokidar",
141142
"@npm//copy-webpack-plugin",
142143
"@npm//critters",
143144
"@npm//css-loader",

packages/angular_devkit/build_angular/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"babel-plugin-istanbul": "6.1.1",
2828
"browserslist": "^4.9.1",
2929
"cacache": "16.1.3",
30+
"chokidar": "3.5.3",
3031
"copy-webpack-plugin": "11.0.0",
3132
"critters": "0.0.16",
3233
"css-loader": "6.7.1",

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,6 @@ const UNSUPPORTED_OPTIONS: Array<keyof BrowserBuilderOptions> = [
2828
// The following option has no effect until preprocessors are supported
2929
// 'stylePreprocessorOptions',
3030

31-
// * Watch mode
32-
'watch',
33-
'poll',
34-
3531
// * Deprecated
3632
'deployUrl',
3733

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

Lines changed: 93 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -28,41 +28,15 @@ import { logExperimentalWarnings } from './experimental-warnings';
2828
import { normalizeOptions } from './options';
2929
import { Schema as BrowserBuilderOptions, SourceMapClass } from './schema';
3030
import { bundleStylesheetText } from './stylesheets';
31+
import { createWatcher } from './watcher';
3132

32-
/**
33-
* Main execution function for the esbuild-based application builder.
34-
* The options are compatible with the Webpack-based builder.
35-
* @param options The browser builder options to use when setting up the application build
36-
* @param context The Architect builder context object
37-
* @returns A promise with the builder result output
38-
*/
39-
// eslint-disable-next-line max-lines-per-function
40-
export async function buildEsbuildBrowser(
33+
async function execute(
4134
options: BrowserBuilderOptions,
35+
normalizedOptions: Awaited<ReturnType<typeof normalizeOptions>>,
4236
context: BuilderContext,
4337
): Promise<BuilderOutput> {
4438
const startTime = Date.now();
4539

46-
// Only AOT is currently supported
47-
if (options.aot !== true) {
48-
context.logger.error(
49-
'JIT mode is currently not supported by this experimental builder. AOT mode must be used.',
50-
);
51-
52-
return { success: false };
53-
}
54-
55-
// Inform user of experimental status of builder and options
56-
logExperimentalWarnings(options, context);
57-
58-
// Determine project name from builder context target
59-
const projectName = context.target?.project;
60-
if (!projectName) {
61-
context.logger.error(`The 'browser-esbuild' builder requires a target to be specified.`);
62-
63-
return { success: false };
64-
}
65-
6640
const {
6741
projectRoot,
6842
workspaceRoot,
@@ -74,22 +48,7 @@ export async function buildEsbuildBrowser(
7448
tsconfig,
7549
assets,
7650
outputNames,
77-
} = await normalizeOptions(context, projectName, options);
78-
79-
// Clean output path if enabled
80-
if (options.deleteOutputPath) {
81-
deleteOutputDir(workspaceRoot, options.outputPath);
82-
}
83-
84-
// Create output directory if needed
85-
try {
86-
await fs.mkdir(outputPath, { recursive: true });
87-
} catch (e) {
88-
assertIsError(e);
89-
context.logger.error('Unable to create output directory: ' + e.message);
90-
91-
return { success: false };
92-
}
51+
} = normalizedOptions;
9352

9453
const target = transformSupportedBrowsersToTargets(
9554
getSupportedBrowsers(projectRoot, context.logger),
@@ -410,4 +369,93 @@ async function bundleGlobalStylesheets(
410369
return { outputFiles, initialFiles, errors, warnings };
411370
}
412371

372+
/**
373+
* Main execution function for the esbuild-based application builder.
374+
* The options are compatible with the Webpack-based builder.
375+
* @param initialOptions The browser builder options to use when setting up the application build
376+
* @param context The Architect builder context object
377+
* @returns An async iterable with the builder result output
378+
*/
379+
export async function* buildEsbuildBrowser(
380+
initialOptions: BrowserBuilderOptions,
381+
context: BuilderContext,
382+
): AsyncIterable<BuilderOutput> {
383+
// Only AOT is currently supported
384+
if (initialOptions.aot !== true) {
385+
context.logger.error(
386+
'JIT mode is currently not supported by this experimental builder. AOT mode must be used.',
387+
);
388+
389+
return { success: false };
390+
}
391+
392+
// Inform user of experimental status of builder and options
393+
logExperimentalWarnings(initialOptions, context);
394+
395+
// Determine project name from builder context target
396+
const projectName = context.target?.project;
397+
if (!projectName) {
398+
context.logger.error(`The 'browser-esbuild' builder requires a target to be specified.`);
399+
400+
return { success: false };
401+
}
402+
403+
const normalizedOptions = await normalizeOptions(context, projectName, initialOptions);
404+
405+
// Clean output path if enabled
406+
if (initialOptions.deleteOutputPath) {
407+
deleteOutputDir(normalizedOptions.workspaceRoot, initialOptions.outputPath);
408+
}
409+
410+
// Create output directory if needed
411+
try {
412+
await fs.mkdir(normalizedOptions.outputPath, { recursive: true });
413+
} catch (e) {
414+
assertIsError(e);
415+
context.logger.error('Unable to create output directory: ' + e.message);
416+
417+
return { success: false };
418+
}
419+
420+
// Initial build
421+
yield await execute(initialOptions, normalizedOptions, context);
422+
423+
// Finish if watch mode is not enabled
424+
if (!initialOptions.watch) {
425+
return;
426+
}
427+
428+
// Setup a watcher
429+
const watcher = createWatcher({
430+
polling: typeof initialOptions.poll === 'number',
431+
interval: initialOptions.poll,
432+
// Ignore the output path to avoid infinite rebuild cycles
433+
ignored: [normalizedOptions.outputPath],
434+
});
435+
436+
// Temporarily watch the entire project
437+
watcher.add(normalizedOptions.projectRoot);
438+
439+
// Watch workspace root node modules
440+
// Includes Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/)
441+
watcher.add(path.join(normalizedOptions.workspaceRoot, 'node_modules'));
442+
watcher.add(path.join(normalizedOptions.workspaceRoot, '.pnp.cjs'));
443+
watcher.add(path.join(normalizedOptions.workspaceRoot, '.pnp.data.json'));
444+
445+
// Wait for changes and rebuild as needed
446+
try {
447+
for await (const changes of watcher) {
448+
context.logger.info('Changes detected. Rebuilding...');
449+
450+
if (initialOptions.verbose) {
451+
context.logger.info(changes.toDebugString());
452+
}
453+
454+
yield await execute(initialOptions, normalizedOptions, context);
455+
}
456+
} finally {
457+
await watcher.close();
458+
}
459+
}
460+
413461
export default createBuilder(buildEsbuildBrowser);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 { FSWatcher } from 'chokidar';
10+
11+
export class ChangedFiles {
12+
readonly added = new Set<string>();
13+
readonly modified = new Set<string>();
14+
readonly removed = new Set<string>();
15+
16+
toDebugString(): string {
17+
const content = {
18+
added: Array.from(this.added),
19+
modified: Array.from(this.modified),
20+
removed: Array.from(this.removed),
21+
};
22+
23+
return JSON.stringify(content, null, 2);
24+
}
25+
}
26+
27+
export interface BuildWatcher extends AsyncIterableIterator<ChangedFiles> {
28+
add(paths: string | string[]): void;
29+
remove(paths: string | string[]): void;
30+
close(): Promise<void>;
31+
}
32+
33+
export function createWatcher(options?: {
34+
polling?: boolean;
35+
interval?: number;
36+
ignored?: string[];
37+
}): BuildWatcher {
38+
const watcher = new FSWatcher({
39+
...options,
40+
disableGlobbing: true,
41+
ignoreInitial: true,
42+
});
43+
44+
const nextQueue: ((value?: ChangedFiles) => void)[] = [];
45+
let currentChanges: ChangedFiles | undefined;
46+
47+
watcher.on('all', (event, path) => {
48+
switch (event) {
49+
case 'add':
50+
currentChanges ??= new ChangedFiles();
51+
currentChanges.added.add(path);
52+
break;
53+
case 'change':
54+
currentChanges ??= new ChangedFiles();
55+
currentChanges.modified.add(path);
56+
break;
57+
case 'unlink':
58+
currentChanges ??= new ChangedFiles();
59+
currentChanges.removed.add(path);
60+
break;
61+
default:
62+
return;
63+
}
64+
65+
const next = nextQueue.shift();
66+
if (next) {
67+
const value = currentChanges;
68+
currentChanges = undefined;
69+
next(value);
70+
}
71+
});
72+
73+
return {
74+
[Symbol.asyncIterator]() {
75+
return this;
76+
},
77+
78+
async next() {
79+
if (currentChanges && nextQueue.length === 0) {
80+
const result = { value: currentChanges };
81+
currentChanges = undefined;
82+
83+
return result;
84+
}
85+
86+
return new Promise((resolve) => {
87+
nextQueue.push((value) => resolve(value ? { value } : { done: true, value }));
88+
});
89+
},
90+
91+
add(paths) {
92+
watcher.add(paths);
93+
},
94+
95+
remove(paths) {
96+
watcher.unwatch(paths);
97+
},
98+
99+
async close() {
100+
try {
101+
await watcher.close();
102+
} finally {
103+
let next;
104+
while ((next = nextQueue.shift()) !== undefined) {
105+
next();
106+
}
107+
}
108+
},
109+
};
110+
}

yarn.lock

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@
129129

130130
"@angular/build-tooling@https://github.com/angular/dev-infra-private-build-tooling-builds.git#6df2d555d326fe6282de28db80c49dd439b42af3":
131131
version "0.0.0-40aaf3831425d472965dd61e58cbd5854abd7214"
132-
uid "6df2d555d326fe6282de28db80c49dd439b42af3"
133132
resolved "https://github.com/angular/dev-infra-private-build-tooling-builds.git#6df2d555d326fe6282de28db80c49dd439b42af3"
134133
dependencies:
135134
"@angular-devkit/build-angular" "15.0.0-next.0"
@@ -243,7 +242,6 @@
243242

244243
"@angular/ng-dev@https://github.com/angular/dev-infra-private-ng-dev-builds.git#8c3a9ec4176a7315d24977cfefb6edee22b724d9":
245244
version "0.0.0-40aaf3831425d472965dd61e58cbd5854abd7214"
246-
uid "8c3a9ec4176a7315d24977cfefb6edee22b724d9"
247245
resolved "https://github.com/angular/dev-infra-private-ng-dev-builds.git#8c3a9ec4176a7315d24977cfefb6edee22b724d9"
248246
dependencies:
249247
"@yarnpkg/lockfile" "^1.1.0"
@@ -3816,7 +3814,7 @@ chardet@^0.7.0:
38163814
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
38173815
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
38183816

3819-
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.1, chokidar@^3.5.2, chokidar@^3.5.3:
3817+
chokidar@3.5.3, "chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.1, chokidar@^3.5.3:
38203818
version "3.5.3"
38213819
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
38223820
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@@ -8304,7 +8302,6 @@ npm@^8.11.0:
83048302
"@npmcli/fs" "^2.1.0"
83058303
"@npmcli/map-workspaces" "^2.0.3"
83068304
"@npmcli/package-json" "^2.0.0"
8307-
"@npmcli/promise-spawn" "^3.0.0"
83088305
"@npmcli/run-script" "^4.2.1"
83098306
abbrev "~1.1.1"
83108307
archy "~1.0.0"
@@ -8315,7 +8312,6 @@ npm@^8.11.0:
83158312
cli-table3 "^0.6.2"
83168313
columnify "^1.6.0"
83178314
fastest-levenshtein "^1.0.12"
8318-
fs-minipass "^2.1.0"
83198315
glob "^8.0.1"
83208316
graceful-fs "^4.2.10"
83218317
hosted-git-info "^5.1.0"
@@ -8335,7 +8331,6 @@ npm@^8.11.0:
83358331
libnpmteam "^4.0.4"
83368332
libnpmversion "^3.0.7"
83378333
make-fetch-happen "^10.2.0"
8338-
minimatch "^5.1.0"
83398334
minipass "^3.1.6"
83408335
minipass-pipeline "^1.2.4"
83418336
mkdirp "^1.0.4"
@@ -9959,7 +9954,6 @@ sass@1.55.0, sass@^1.55.0:
99599954

99609955
"sauce-connect-proxy@https://saucelabs.com/downloads/sc-4.8.1-linux.tar.gz":
99619956
version "0.0.0"
9962-
uid "9c16682e4c9716734432789884f868212f95f563"
99639957
resolved "https://saucelabs.com/downloads/sc-4.8.1-linux.tar.gz#9c16682e4c9716734432789884f868212f95f563"
99649958

99659959
saucelabs@^1.5.0:

0 commit comments

Comments
 (0)