diff --git a/package.json b/package.json index d9f5d2ab5b75..abfa2cfc0253 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/angular_devkit/build_angular/BUILD.bazel b/packages/angular_devkit/build_angular/BUILD.bazel index 848c0072b16c..3bc212e68ecb 100644 --- a/packages/angular_devkit/build_angular/BUILD.bazel +++ b/packages/angular_devkit/build_angular/BUILD.bazel @@ -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", diff --git a/packages/angular_devkit/build_angular/package.json b/packages/angular_devkit/build_angular/package.json index b8dfafc887e8..fe697d629104 100644 --- a/packages/angular_devkit/build_angular/package.json +++ b/packages/angular_devkit/build_angular/package.json @@ -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", diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts index e7f200e9f2e6..dac13da1e40e 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts @@ -28,10 +28,6 @@ const UNSUPPORTED_OPTIONS: Array = [ // The following option has no effect until preprocessors are supported // 'stylePreprocessorOptions', - // * Watch mode - 'watch', - 'poll', - // * Deprecated 'deployUrl', diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts index e99446d5fee9..8437774c74c4 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -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>, context: BuilderContext, ): Promise { 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, @@ -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), @@ -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 { + // 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); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/watcher.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/watcher.ts new file mode 100644 index 000000000000..4d11fb5e7bf6 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/watcher.ts @@ -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(); + readonly modified = new Set(); + readonly removed = new Set(); + + 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 { + add(paths: string | string[]): void; + remove(paths: string | string[]): void; + close(): Promise; +} + +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(); + } + } + }, + }; +} diff --git a/yarn.lock b/yarn.lock index a4f9057cad38..cf7a5fde0852 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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== @@ -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" @@ -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" @@ -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" @@ -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: