Skip to content

Commit d0eb6b6

Browse files
committed
refactor(@angular/build): extract shared concurrency, watcher, and styling utilities
1 parent 758192d commit d0eb6b6

12 files changed

Lines changed: 513 additions & 130 deletions

File tree

modules/testing/builder/src/builder-harness.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export class BuilderHarness<T> {
111111
}
112112
}
113113

114-
private resolvePath(path: string): string {
114+
resolvePath(path: string): string {
115115
return join(getSystemPath(this.host.root()), path);
116116
}
117117

packages/angular/build/src/builders/application/build-action.ts

Lines changed: 10 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
*/
88

99
import { BuilderContext } from '@angular-devkit/architect';
10-
import { existsSync } from 'node:fs';
11-
import path from 'node:path';
1210
import {
1311
BuildOutputAsset,
1412
ExecutionResult,
@@ -21,10 +19,8 @@ import {
2119
} from '../../tools/esbuild/stylesheets/sass-language';
2220
import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/utils';
2321
import { ChangedFiles } from '../../tools/esbuild/watcher';
24-
import { shouldWatchRoot } from '../../utils/environment-options';
2522
import { initializeHash } from '../../utils/hash';
2623
import { NormalizedCachedOptions } from '../../utils/normalize-cache';
27-
import { toPosixPath } from '../../utils/path';
2824
import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options';
2925
import {
3026
ComponentUpdateResult,
@@ -35,20 +31,6 @@ import {
3531
ResultMessage,
3632
} from './results';
3733

38-
// Watch workspace for package manager changes
39-
const packageWatchFiles = [
40-
// manifest can affect module resolution
41-
'package.json',
42-
// npm lock file
43-
'package-lock.json',
44-
// pnpm lock file
45-
'pnpm-lock.yaml',
46-
// yarn lock file including Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/)
47-
'yarn.lock',
48-
'.pnp.cjs',
49-
'.pnp.data.json',
50-
];
51-
5234
// eslint-disable-next-line max-lines-per-function
5335
export async function* runEsBuildBuildAction(
5436
action: (rebuildState?: RebuildState) => Promise<ExecutionResult>,
@@ -115,55 +97,18 @@ export async function* runEsBuildBuildAction(
11597
logger.info('Watch mode enabled. Watching for file changes...');
11698
}
11799

118-
const normalizedOutputBase = toPosixPath(outputOptions.base);
119-
const normalizedCacheBase = toPosixPath(cacheOptions.basePath);
120-
const ignored: string[] = [
121-
// Ignore the output and cache paths to avoid infinite rebuild cycles
122-
normalizedOutputBase,
123-
`${normalizedOutputBase}/**`,
124-
normalizedCacheBase,
125-
`${normalizedCacheBase}/**`,
126-
`${toPosixPath(workspaceRoot)}/**/.*/**`,
127-
];
128-
129-
if (cacheOptions.localBasePath && cacheOptions.localBasePath !== cacheOptions.basePath) {
130-
const normalizedLocalCacheBase = toPosixPath(cacheOptions.localBasePath);
131-
ignored.push(normalizedLocalCacheBase, `${normalizedLocalCacheBase}/**`);
132-
}
133-
134100
// Setup a watcher
135-
const { createWatcher } = await import('../../tools/esbuild/watcher');
136-
watcher = await createWatcher({
137-
polling: typeof poll === 'number',
138-
interval: poll,
139-
followSymlinks: preserveSymlinks,
140-
ignored,
141-
cwd: workspaceRoot,
101+
const { setupWatcher } = await import('../../tools/esbuild/watcher');
102+
watcher = await setupWatcher({
103+
workspaceRoot,
104+
projectRoot,
105+
outputPath: outputOptions.base,
106+
cacheOptions,
107+
poll,
108+
preserveSymlinks,
109+
signal: options.signal,
110+
watchFiles: result.watchFiles,
142111
});
143-
144-
// Setup abort support
145-
options.signal?.addEventListener('abort', () => void watcher?.close());
146-
147-
// Watch the entire project root if 'NG_BUILD_WATCH_ROOT' environment variable is set
148-
if (shouldWatchRoot) {
149-
if (!preserveSymlinks) {
150-
// Ignore all node modules directories to avoid excessive file watchers.
151-
// Package changes are handled below by watching manifest and lock files.
152-
// NOTE: this is not enable when preserveSymlinks is true as this would break `npm link` usages.
153-
ignored.push('**/node_modules/**');
154-
155-
watcher.add(
156-
packageWatchFiles
157-
.map((file) => path.join(workspaceRoot, file))
158-
.filter((file) => existsSync(file)),
159-
);
160-
}
161-
162-
watcher.add(projectRoot);
163-
}
164-
165-
// Watch locations provided by the initial build result
166-
watcher.add(result.watchFiles);
167112
}
168113

169114
// Output the first build results after setting up the watcher to ensure that any code executed

packages/angular/build/src/builders/application/options.ts

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import type { BuilderContext } from '@angular-devkit/architect';
1010
import type { Plugin } from 'esbuild';
1111
import { access, constants, readFile } from 'node:fs/promises';
12-
import { createRequire } from 'node:module';
1312
import path from 'node:path';
1413
import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } from '../../utils';
1514
import { supportColor } from '../../utils/color';
@@ -19,9 +18,8 @@ import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator'
1918
import { normalizeCacheOptions } from '../../utils/normalize-cache';
2019
import { canonicalizePath } from '../../utils/path';
2120
import {
22-
SearchDirectory,
23-
findTailwindConfiguration,
2421
generateSearchDirectories,
22+
getTailwindConfig,
2523
loadPostcssConfiguration,
2624
} from '../../utils/postcss-configuration';
2725
import { getProjectRootPaths, normalizeDirectoryPath } from '../../utils/project-metadata';
@@ -280,7 +278,7 @@ export async function normalizeOptions(
280278
// Skip tailwind configuration if postcss is customized
281279
const tailwindConfiguration = postcssConfiguration
282280
? undefined
283-
: await getTailwindConfig(searchDirectories, workspaceRoot, context);
281+
: await getTailwindConfig(searchDirectories, workspaceRoot, context.logger);
284282

285283
let serverEntryPoint: string | undefined;
286284
if (typeof options.server === 'string') {
@@ -538,36 +536,6 @@ export async function normalizeOptions(
538536
};
539537
}
540538

541-
async function getTailwindConfig(
542-
searchDirectories: SearchDirectory[],
543-
workspaceRoot: string,
544-
context: BuilderContext,
545-
): Promise<{ file: string; package: string } | undefined> {
546-
const tailwindConfigurationPath = findTailwindConfiguration(searchDirectories);
547-
548-
if (!tailwindConfigurationPath) {
549-
return undefined;
550-
}
551-
552-
// Create a node resolver from the configuration file
553-
const resolver = createRequire(tailwindConfigurationPath);
554-
try {
555-
return {
556-
file: tailwindConfigurationPath,
557-
package: resolver.resolve('tailwindcss'),
558-
};
559-
} catch {
560-
const relativeTailwindConfigPath = path.relative(workspaceRoot, tailwindConfigurationPath);
561-
context.logger.warn(
562-
`Tailwind CSS configuration file found (${relativeTailwindConfigPath})` +
563-
` but the 'tailwindcss' package is not installed.` +
564-
` To enable Tailwind CSS, please install the 'tailwindcss' package.`,
565-
);
566-
}
567-
568-
return undefined;
569-
}
570-
571539
/**
572540
* Normalize entry point options. To maintain compatibility with the legacy browser builder, we need a single `browser`
573541
* option which defines a single entry point. However, we also want to support multiple entry points as an internal option.

packages/angular/build/src/builders/dev-server/tests/behavior/build-errors_spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
3838
expectNoLog(logs, 'Unexpected character "EOF"');
3939
},
4040
],
41-
{ outputLogsOnFailure: false, timeout: 60_000 },
41+
{ outputLogsOnFailure: false, timeout: 90_000 },
4242
);
43-
}, 90_000);
43+
}, 120_000);
4444
});
4545
});

packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type { Loader, OnLoadResult, PartialMessage } from 'esbuild';
3131
import { readFile, stat } from 'node:fs/promises';
3232
import { isAbsolute } from 'node:path';
3333
import { fileURLToPath } from 'node:url';
34+
import { mapConcurrent, runConcurrent } from '../../utils/concurrency';
3435
import { calculateHash, createContentHash } from '../../utils/hash';
3536
import type { Cache as PersistentCacheStore } from './cache';
3637
import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache';
@@ -114,29 +115,6 @@ export function extractDiskFilePath(path: string): string | undefined {
114115
/** Maximum number of concurrent file system read/stat operations to prevent OS file descriptor exhaustion. */
115116
const MAX_CONCURRENT_READS = 16;
116117

117-
/**
118-
* Maps an array asynchronously with a sliding worker pool to maintain full concurrency saturation.
119-
*/
120-
async function mapConcurrent<T, R>(
121-
items: T[],
122-
limit: number,
123-
fn: (item: T) => Promise<R>,
124-
): Promise<R[]> {
125-
const results: R[] = new Array(items.length);
126-
let index = 0;
127-
128-
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
129-
while (index < items.length) {
130-
const i = index++;
131-
results[i] = await fn(items[i]);
132-
}
133-
});
134-
135-
await Promise.all(workers);
136-
137-
return results;
138-
}
139-
140118
/**
141119
* Validates that all imported watch files exist on disk and their contents match.
142120
* Performs a fast-path metadata check (mtime + size) first, falling back to content hashing.
@@ -214,7 +192,7 @@ async function computeMetadataForWatchFiles(
214192
): Promise<Record<string, CachedDependencyMetadata>> {
215193
const watchFilesMetadata: Record<string, CachedDependencyMetadata> = {};
216194

217-
await mapConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => {
195+
await runConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => {
218196
try {
219197
const knownContent = knownContents?.get(filePath);
220198
const [content, stats] = await Promise.all([

packages/angular/build/src/tools/esbuild/stylesheets/bundle-options.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface BundleStylesheetOptions {
2222
workspaceRoot: string;
2323
optimization: boolean;
2424
inlineFonts: boolean;
25+
dataurl?: boolean;
2526
preserveSymlinks?: boolean;
2627
sourcemap: boolean | 'external' | 'inline' | 'linked';
2728
sourcesContent?: boolean;
@@ -62,7 +63,7 @@ export function createStylesheetBundleOptions(
6263
pluginFactory.create(SassStylesheetLanguage),
6364
pluginFactory.create(LessStylesheetLanguage),
6465
pluginFactory.create(CssStylesheetLanguage),
65-
createCssResourcePlugin(cache),
66+
createCssResourcePlugin(cache, options.dataurl),
6667
];
6768

6869
if (options.inlineFonts) {

packages/angular/build/src/tools/esbuild/stylesheets/css-resource-plugin.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ const CSS_RESOURCE_RESOLUTION = Symbol('CSS_RESOURCE_RESOLUTION');
2525
* and types to be supported without needing to manually specify all extensions
2626
* within the build configuration.
2727
*
28+
* @param cache An optional load result cache.
29+
* @param dataurl If true, resources will be loaded with the 'dataurl' loader to inline them as base64 data URIs.
2830
* @returns An esbuild {@link Plugin} instance.
2931
*/
30-
export function createCssResourcePlugin(cache?: LoadResultCache): Plugin {
32+
export function createCssResourcePlugin(cache?: LoadResultCache, dataurl?: boolean): Plugin {
3133
return {
3234
name: 'angular-css-resource',
3335
setup(build: PluginBuild): void {
@@ -119,7 +121,7 @@ export function createCssResourcePlugin(cache?: LoadResultCache): Plugin {
119121

120122
return {
121123
contents: await readFile(resourcePath),
122-
loader: 'file',
124+
loader: dataurl ? 'dataurl' : 'file',
123125
watchFiles: [resourcePath],
124126
};
125127
}),

packages/angular/build/src/tools/esbuild/watcher.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type * as Chokidar from 'chokidar';
1111
import * as fs from 'node:fs';
1212
import * as path from 'node:path';
1313
import picomatch from 'picomatch';
14+
import { shouldWatchRoot } from '../../utils/environment-options';
1415
import { toPosixPath } from '../../utils/path';
1516

1617
export class ChangedFiles {
@@ -47,6 +48,108 @@ export interface WatcherOptions {
4748
cwd?: string;
4849
}
4950

51+
// Watch workspace for package manager changes
52+
const packageWatchFiles = [
53+
// manifest can affect module resolution
54+
'package.json',
55+
// npm lock file
56+
'package-lock.json',
57+
// pnpm lock file
58+
'pnpm-lock.yaml',
59+
// yarn lock file including Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/)
60+
'yarn.lock',
61+
'.pnp.cjs',
62+
'.pnp.data.json',
63+
];
64+
65+
export interface SetupWatcherOptions {
66+
workspaceRoot: string;
67+
projectRoot: string;
68+
outputPath: string;
69+
cacheOptions: { basePath: string; localBasePath?: string };
70+
poll?: number;
71+
preserveSymlinks?: boolean;
72+
signal?: AbortSignal;
73+
watchFiles?: Iterable<string>;
74+
}
75+
76+
/**
77+
* Sets up and initializes a file watcher with proper ignore patterns for build outputs and caches.
78+
*/
79+
export async function setupWatcher(options: SetupWatcherOptions): Promise<BuildWatcher> {
80+
const {
81+
workspaceRoot,
82+
projectRoot,
83+
outputPath,
84+
cacheOptions,
85+
poll,
86+
preserveSymlinks,
87+
signal,
88+
watchFiles,
89+
} = options;
90+
91+
const normalizedOutputBase = toPosixPath(outputPath);
92+
const normalizedCacheBase = toPosixPath(cacheOptions.basePath);
93+
const ignored: string[] = [
94+
// Ignore the output and cache paths to avoid infinite rebuild cycles
95+
normalizedOutputBase,
96+
`${normalizedOutputBase}/**`,
97+
normalizedCacheBase,
98+
`${normalizedCacheBase}/**`,
99+
`${toPosixPath(workspaceRoot)}/**/.*/**`,
100+
];
101+
102+
if (cacheOptions.localBasePath && cacheOptions.localBasePath !== cacheOptions.basePath) {
103+
const normalizedLocalCacheBase = toPosixPath(cacheOptions.localBasePath);
104+
ignored.push(normalizedLocalCacheBase, `${normalizedLocalCacheBase}/**`);
105+
}
106+
107+
if (shouldWatchRoot && !preserveSymlinks) {
108+
// Ignore all node modules directories to avoid excessive file watchers.
109+
// Package changes are handled below by watching manifest and lock files.
110+
// NOTE: this is not enabled when preserveSymlinks is true as this would break `npm link` usages.
111+
ignored.push('**/node_modules/**');
112+
}
113+
114+
const watcher = await createWatcher({
115+
polling: typeof poll === 'number',
116+
interval: poll,
117+
followSymlinks: preserveSymlinks,
118+
ignored,
119+
cwd: workspaceRoot,
120+
});
121+
122+
// Setup abort support
123+
if (signal) {
124+
const onAbort = () => void watcher.close();
125+
signal.addEventListener('abort', onAbort, { once: true });
126+
const originalClose = watcher.close.bind(watcher);
127+
watcher.close = async () => {
128+
signal.removeEventListener('abort', onAbort);
129+
await originalClose();
130+
};
131+
}
132+
133+
// Watch the entire project root if 'NG_BUILD_WATCH_ROOT' environment variable is set
134+
if (shouldWatchRoot) {
135+
if (!preserveSymlinks) {
136+
watcher.add(
137+
packageWatchFiles
138+
.map((file) => path.join(workspaceRoot, file))
139+
.filter((file) => fs.existsSync(file)),
140+
);
141+
}
142+
143+
watcher.add(projectRoot);
144+
}
145+
146+
if (watchFiles) {
147+
watcher.add(Array.isArray(watchFiles) ? watchFiles : Array.from(watchFiles));
148+
}
149+
150+
return watcher;
151+
}
152+
50153
/**
51154
* Probes the filesystem at the specified target directory to determine whether it is case-sensitive.
52155
*/

0 commit comments

Comments
 (0)