Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add initial watch support to esb…
Browse files Browse the repository at this point in the history
…uild-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.
  • Loading branch information
clydin committed Oct 3, 2022
1 parent 033e8ca commit 3d94ca2
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 57 deletions.
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -136,7 +136,7 @@
"bootstrap": "^4.0.0",
"browserslist": "^4.9.1",
"cacache": "16.1.3",
"chokidar": "^3.5.2",
"chokidar": "3.5.3",
"copy-webpack-plugin": "11.0.0",
"critters": "0.0.16",
"cross-env": "^7.0.3",
Expand Down
1 change: 1 addition & 0 deletions packages/angular_devkit/build_angular/BUILD.bazel
Expand Up @@ -138,6 +138,7 @@ ts_library(
"@npm//babel-plugin-istanbul",
"@npm//browserslist",
"@npm//cacache",
"@npm//chokidar",
"@npm//copy-webpack-plugin",
"@npm//critters",
"@npm//css-loader",
Expand Down
1 change: 1 addition & 0 deletions packages/angular_devkit/build_angular/package.json
Expand Up @@ -27,6 +27,7 @@
"babel-plugin-istanbul": "6.1.1",
"browserslist": "^4.9.1",
"cacache": "16.1.3",
"chokidar": "3.5.3",
"copy-webpack-plugin": "11.0.0",
"critters": "0.0.16",
"css-loader": "6.7.1",
Expand Down
Expand Up @@ -28,10 +28,6 @@ const UNSUPPORTED_OPTIONS: Array<keyof BrowserBuilderOptions> = [
// The following option has no effect until preprocessors are supported
// 'stylePreprocessorOptions',

// * Watch mode
'watch',
'poll',

// * Deprecated
'deployUrl',

Expand Down
Expand Up @@ -28,41 +28,15 @@ import { logExperimentalWarnings } from './experimental-warnings';
import { normalizeOptions } from './options';
import { Schema as BrowserBuilderOptions, SourceMapClass } from './schema';
import { bundleStylesheetText } from './stylesheets';
import { createWatcher } from './watcher';

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

// Only AOT is currently supported
if (options.aot !== true) {
context.logger.error(
'JIT mode is currently not supported by this experimental builder. AOT mode must be used.',
);

return { success: false };
}

// Inform user of experimental status of builder and options
logExperimentalWarnings(options, context);

// Determine project name from builder context target
const projectName = context.target?.project;
if (!projectName) {
context.logger.error(`The 'browser-esbuild' builder requires a target to be specified.`);

return { success: false };
}

const {
projectRoot,
workspaceRoot,
Expand All @@ -74,22 +48,7 @@ export async function buildEsbuildBrowser(
tsconfig,
assets,
outputNames,
} = await normalizeOptions(context, projectName, options);

// Clean output path if enabled
if (options.deleteOutputPath) {
deleteOutputDir(workspaceRoot, options.outputPath);
}

// Create output directory if needed
try {
await fs.mkdir(outputPath, { recursive: true });
} catch (e) {
assertIsError(e);
context.logger.error('Unable to create output directory: ' + e.message);

return { success: false };
}
} = normalizedOptions;

const target = transformSupportedBrowsersToTargets(
getSupportedBrowsers(projectRoot, context.logger),
Expand Down Expand Up @@ -410,4 +369,93 @@ async function bundleGlobalStylesheets(
return { outputFiles, initialFiles, errors, warnings };
}

/**
* Main execution function for the esbuild-based application builder.
* The options are compatible with the Webpack-based builder.
* @param initialOptions The browser builder options to use when setting up the application build
* @param context The Architect builder context object
* @returns An async iterable with the builder result output
*/
export async function* buildEsbuildBrowser(
initialOptions: BrowserBuilderOptions,
context: BuilderContext,
): AsyncIterable<BuilderOutput> {
// Only AOT is currently supported
if (initialOptions.aot !== true) {
context.logger.error(
'JIT mode is currently not supported by this experimental builder. AOT mode must be used.',
);

return { success: false };
}

// Inform user of experimental status of builder and options
logExperimentalWarnings(initialOptions, context);

// Determine project name from builder context target
const projectName = context.target?.project;
if (!projectName) {
context.logger.error(`The 'browser-esbuild' builder requires a target to be specified.`);

return { success: false };
}

const normalizedOptions = await normalizeOptions(context, projectName, initialOptions);

// Clean output path if enabled
if (initialOptions.deleteOutputPath) {
deleteOutputDir(normalizedOptions.workspaceRoot, initialOptions.outputPath);
}

// Create output directory if needed
try {
await fs.mkdir(normalizedOptions.outputPath, { recursive: true });
} catch (e) {
assertIsError(e);
context.logger.error('Unable to create output directory: ' + e.message);

return { success: false };
}

// Initial build
yield await execute(initialOptions, normalizedOptions, context);

// Finish if watch mode is not enabled
if (!initialOptions.watch) {
return;
}

// Setup a watcher
const watcher = createWatcher({
polling: typeof initialOptions.poll === 'number',
interval: initialOptions.poll,
// Ignore the output path to avoid infinite rebuild cycles
ignored: [normalizedOptions.outputPath],
});

// Temporarily watch the entire project
watcher.add(normalizedOptions.projectRoot);

// Watch workspace root node modules
// Includes Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/)
watcher.add(path.join(normalizedOptions.workspaceRoot, 'node_modules'));
watcher.add(path.join(normalizedOptions.workspaceRoot, '.pnp.cjs'));
watcher.add(path.join(normalizedOptions.workspaceRoot, '.pnp.data.json'));

// Wait for changes and rebuild as needed
try {
for await (const changes of watcher) {
context.logger.info('Changes detected. Rebuilding...');

if (initialOptions.verbose) {
context.logger.info(changes.toDebugString());
}

yield await execute(initialOptions, normalizedOptions, context);
}
} finally {
await watcher.close();
}
}

export default createBuilder(buildEsbuildBrowser);
@@ -0,0 +1,110 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import { FSWatcher } from 'chokidar';

export class ChangedFiles {
readonly added = new Set<string>();
readonly modified = new Set<string>();
readonly removed = new Set<string>();

toDebugString(): string {
const content = {
added: Array.from(this.added),
modified: Array.from(this.modified),
removed: Array.from(this.removed),
};

return JSON.stringify(content, null, 2);
}
}

export interface BuildWatcher extends AsyncIterableIterator<ChangedFiles> {
add(paths: string | string[]): void;
remove(paths: string | string[]): void;
close(): Promise<void>;
}

export function createWatcher(options?: {
polling?: boolean;
interval?: number;
ignored?: string[];
}): BuildWatcher {
const watcher = new FSWatcher({
...options,
disableGlobbing: true,
ignoreInitial: true,
});

const nextQueue: ((value?: ChangedFiles) => void)[] = [];
let currentChanges: ChangedFiles | undefined;

watcher.on('all', (event, path) => {
switch (event) {
case 'add':
currentChanges ??= new ChangedFiles();
currentChanges.added.add(path);
break;
case 'change':
currentChanges ??= new ChangedFiles();
currentChanges.modified.add(path);
break;
case 'unlink':
currentChanges ??= new ChangedFiles();
currentChanges.removed.add(path);
break;
default:
return;
}

const next = nextQueue.shift();
if (next) {
const value = currentChanges;
currentChanges = undefined;
next(value);
}
});

return {
[Symbol.asyncIterator]() {
return this;
},

async next() {
if (currentChanges && nextQueue.length === 0) {
const result = { value: currentChanges };
currentChanges = undefined;

return result;
}

return new Promise((resolve) => {
nextQueue.push((value) => resolve(value ? { value } : { done: true, value }));
});
},

add(paths) {
watcher.add(paths);
},

remove(paths) {
watcher.unwatch(paths);
},

async close() {
try {
await watcher.close();
} finally {
let next;
while ((next = nextQueue.shift()) !== undefined) {
next();
}
}
},
};
}
8 changes: 1 addition & 7 deletions yarn.lock
Expand Up @@ -129,7 +129,6 @@

"@angular/build-tooling@https://github.com/angular/dev-infra-private-build-tooling-builds.git#6df2d555d326fe6282de28db80c49dd439b42af3":
version "0.0.0-40aaf3831425d472965dd61e58cbd5854abd7214"
uid "6df2d555d326fe6282de28db80c49dd439b42af3"
resolved "https://github.com/angular/dev-infra-private-build-tooling-builds.git#6df2d555d326fe6282de28db80c49dd439b42af3"
dependencies:
"@angular-devkit/build-angular" "15.0.0-next.0"
Expand Down Expand Up @@ -243,7 +242,6 @@

"@angular/ng-dev@https://github.com/angular/dev-infra-private-ng-dev-builds.git#8c3a9ec4176a7315d24977cfefb6edee22b724d9":
version "0.0.0-40aaf3831425d472965dd61e58cbd5854abd7214"
uid "8c3a9ec4176a7315d24977cfefb6edee22b724d9"
resolved "https://github.com/angular/dev-infra-private-ng-dev-builds.git#8c3a9ec4176a7315d24977cfefb6edee22b724d9"
dependencies:
"@yarnpkg/lockfile" "^1.1.0"
Expand Down Expand Up @@ -3816,7 +3814,7 @@ chardet@^0.7.0:
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==

"chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.1, chokidar@^3.5.2, chokidar@^3.5.3:
chokidar@3.5.3, "chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.1, chokidar@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
Expand Down Expand Up @@ -8304,7 +8302,6 @@ npm@^8.11.0:
"@npmcli/fs" "^2.1.0"
"@npmcli/map-workspaces" "^2.0.3"
"@npmcli/package-json" "^2.0.0"
"@npmcli/promise-spawn" "^3.0.0"
"@npmcli/run-script" "^4.2.1"
abbrev "~1.1.1"
archy "~1.0.0"
Expand All @@ -8315,7 +8312,6 @@ npm@^8.11.0:
cli-table3 "^0.6.2"
columnify "^1.6.0"
fastest-levenshtein "^1.0.12"
fs-minipass "^2.1.0"
glob "^8.0.1"
graceful-fs "^4.2.10"
hosted-git-info "^5.1.0"
Expand All @@ -8335,7 +8331,6 @@ npm@^8.11.0:
libnpmteam "^4.0.4"
libnpmversion "^3.0.7"
make-fetch-happen "^10.2.0"
minimatch "^5.1.0"
minipass "^3.1.6"
minipass-pipeline "^1.2.4"
mkdirp "^1.0.4"
Expand Down Expand Up @@ -9959,7 +9954,6 @@ sass@1.55.0, sass@^1.55.0:

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

saucelabs@^1.5.0:
Expand Down

0 comments on commit 3d94ca2

Please sign in to comment.