diff --git a/packages/angular/build/src/builders/application/index.ts b/packages/angular/build/src/builders/application/index.ts index f1b16a27e394..db2cee237896 100644 --- a/packages/angular/build/src/builders/application/index.ts +++ b/packages/angular/build/src/builders/application/index.ts @@ -8,13 +8,12 @@ import { Builder, BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; import assert from 'node:assert'; -import fs from 'node:fs/promises'; import path from 'node:path'; import { BuildOutputFileType } from '../../tools/esbuild/bundler-files'; import { createJsonBuildManifest } from '../../tools/esbuild/utils'; import { colors as ansiColors } from '../../utils/color'; import { deleteOutputDir } from '../../utils/delete-output-dir'; -import { emitFilesToDisk } from '../../utils/emit-files-to-disk'; +import { deleteFilesFromDisk, emitFilesToDisk } from '../../utils/disk-files'; import { bazelEsbuildPluginPath, useJSONBuildLogs } from '../../utils/environment-options'; import { isSubDirectory } from '../../utils/path'; import { purgeStaleBuildCache } from '../../utils/purge-cache'; @@ -215,13 +214,10 @@ export async function* buildApplication( // Delete any removed files if incremental if (result.kind === ResultKind.Incremental && result.removed?.length) { try { - await Promise.all( - result.removed.map((file) => { - const fullFilePath = generateFullPath(file.path, file.type, outputOptions); - - return fs.rm(fullFilePath, { force: true, maxRetries: 3 }); - }), + const filesToDelete = result.removed.map((file) => + generateFullPath(file.path, file.type, outputOptions), ); + await deleteFilesFromDisk(filesToDelete); } catch (error) { context.logger.error(error instanceof Error ? error.message : String(error)); yield { success: false }; diff --git a/packages/angular/build/src/builders/library/pipeline/build-action.ts b/packages/angular/build/src/builders/library/pipeline/build-action.ts index 6d40f5fe4383..acdc69c61bff 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -9,7 +9,7 @@ import type { BuilderContext } from '@angular-devkit/architect'; import path from 'node:path'; import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; -import { emitFilesToDisk } from '../../../utils/emit-files-to-disk'; +import { emitFilesToDisk } from '../../../utils/disk-files'; import { assertIsError } from '../../../utils/error'; import { toPosixPath } from '../../../utils/path'; import type { NormalizedLibraryOptions, SingleBuildState } from '../types'; diff --git a/packages/angular/build/src/private.ts b/packages/angular/build/src/private.ts index 6f98e4f8f0e9..e95ad26af3fc 100644 --- a/packages/angular/build/src/private.ts +++ b/packages/angular/build/src/private.ts @@ -72,7 +72,7 @@ export { ComponentStylesheetBundler } from './tools/esbuild/angular/component-st export * from './utils/bundle-calculator'; export { checkPort } from './utils/check-port'; export { deleteOutputDir } from './utils/delete-output-dir'; -export { emitFilesToDisk } from './utils/emit-files-to-disk'; +export { emitFilesToDisk } from './utils/disk-files'; export { type I18nOptions, createI18nOptions, loadTranslations } from './utils/i18n-options'; export { IndexHtmlGenerator, diff --git a/packages/angular/build/src/utils/emit-files-to-disk.ts b/packages/angular/build/src/utils/disk-files.ts similarity index 79% rename from packages/angular/build/src/utils/emit-files-to-disk.ts rename to packages/angular/build/src/utils/disk-files.ts index b6df74088451..dbbe34ee919e 100644 --- a/packages/angular/build/src/utils/emit-files-to-disk.ts +++ b/packages/angular/build/src/utils/disk-files.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ -import { constants, cp, mkdir, writeFile } from 'node:fs/promises'; +import { constants, cp, mkdir, rm, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { runConcurrent } from './concurrency'; /** * Maximum number of concurrent file system write/copy operations to avoid open file descriptor exhaustion. */ -const MAX_CONCURRENT_WRITES = 64; +const MAX_CONCURRENT_OPS = 64; /** * Represents a file that can be emitted to disk from memory or copied from an existing path on disk. @@ -38,7 +38,7 @@ export async function emitFilesToDisk( const directoryCreationPromises = new Map>(); - await runConcurrent(files, MAX_CONCURRENT_WRITES, async (file) => { + await runConcurrent(files, MAX_CONCURRENT_OPS, async (file) => { const fullFilePath = resolveDestinationPath(file); if (!fullFilePath) { return; @@ -69,3 +69,15 @@ export async function emitFilesToDisk( }); }); } + +/** + * Deletes a collection of files from disk with bounded concurrency. + * @param files The collection of files to delete. + */ +export async function deleteFilesFromDisk(files: readonly string[]): Promise { + if (files.length === 0) { + return; + } + + await runConcurrent(files, MAX_CONCURRENT_OPS, (file) => rm(file, { force: true })); +} diff --git a/packages/angular/build/src/utils/disk-files_spec.ts b/packages/angular/build/src/utils/disk-files_spec.ts new file mode 100644 index 000000000000..4a9422bd4373 --- /dev/null +++ b/packages/angular/build/src/utils/disk-files_spec.ts @@ -0,0 +1,75 @@ +/** + * @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.dev/license + */ + +import assert from 'node:assert'; +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { type EmittableFile, deleteFilesFromDisk, emitFilesToDisk } from './disk-files'; + +describe('disk-files', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-disk-files-test-')); + }); + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + describe('emitFilesToDisk', () => { + it('should write memory files and copy disk files while creating nested directories', async () => { + const sourceFile = join(tempRoot, 'source-asset.txt'); + await writeFile(sourceFile, 'from-disk'); + + const files: (EmittableFile & { path: string })[] = [ + { + origin: 'memory', + path: 'nested/a/mem1.txt', + contents: new TextEncoder().encode('mem-1'), + }, + { origin: 'memory', path: 'nested/a/mem2.txt', contents: 'mem-2' }, + { origin: 'disk', path: 'nested/b/disk1.txt', source: sourceFile }, + { origin: 'disk', path: 'nested/c/disk2.txt', source: sourceFile }, + { origin: 'memory', path: 'skipped/file.txt', contents: 'should-not-exist' }, + ]; + + const outDir = join(tempRoot, 'out'); + await emitFilesToDisk(files, (file) => + file.path.startsWith('skipped/') ? undefined : join(outDir, file.path), + ); + + expect(await readFile(join(outDir, 'nested/a/mem1.txt'), 'utf-8')).toBe('mem-1'); + expect(await readFile(join(outDir, 'nested/a/mem2.txt'), 'utf-8')).toBe('mem-2'); + expect(await readFile(join(outDir, 'nested/b/disk1.txt'), 'utf-8')).toBe('from-disk'); + expect(await readFile(join(outDir, 'nested/c/disk2.txt'), 'utf-8')).toBe('from-disk'); + expect(existsSync(join(outDir, 'skipped/file.txt'))).toBeFalse(); + }); + }); + + describe('deleteFilesFromDisk', () => { + it('should delete existing files and ignore non-existent files', async () => { + const fileA = join(tempRoot, 'a.txt'); + const fileB = join(tempRoot, 'b.txt'); + const nonExistent = join(tempRoot, 'missing.txt'); + + await writeFile(fileA, 'a'); + await writeFile(fileB, 'b'); + + await deleteFilesFromDisk([fileA, nonExistent]); + + expect(existsSync(fileA)).toBeFalse(); + expect(existsSync(fileB)).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/utils/emit-files-to-disk_spec.ts b/packages/angular/build/src/utils/emit-files-to-disk_spec.ts deleted file mode 100644 index 028653028b43..000000000000 --- a/packages/angular/build/src/utils/emit-files-to-disk_spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @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.dev/license - */ - -import assert from 'node:assert'; -import { existsSync } from 'node:fs'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { type EmittableFile, emitFilesToDisk } from './emit-files-to-disk'; - -describe('emitFilesToDisk', () => { - let tempRoot: string; - - beforeEach(async () => { - const baseTmpDir = process.env['TEST_TMPDIR']; - assert(baseTmpDir, 'TEST_TMPDIR is not set'); - tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-emit-files-test-')); - }); - - afterEach(async () => { - if (tempRoot) { - await rm(tempRoot, { recursive: true, force: true }); - } - }); - - it('should write memory files and copy disk files while creating nested directories', async () => { - const sourceFile = join(tempRoot, 'source-asset.txt'); - await writeFile(sourceFile, 'from-disk'); - - const files: (EmittableFile & { path: string })[] = [ - { origin: 'memory', path: 'nested/a/mem1.txt', contents: new TextEncoder().encode('mem-1') }, - { origin: 'memory', path: 'nested/a/mem2.txt', contents: 'mem-2' }, - { origin: 'disk', path: 'nested/b/disk1.txt', source: sourceFile }, - { origin: 'disk', path: 'nested/c/disk2.txt', source: sourceFile }, - { origin: 'memory', path: 'skipped/file.txt', contents: 'should-not-exist' }, - ]; - - const outDir = join(tempRoot, 'out'); - await emitFilesToDisk(files, (file) => - file.path.startsWith('skipped/') ? undefined : join(outDir, file.path), - ); - - expect(await readFile(join(outDir, 'nested/a/mem1.txt'), 'utf-8')).toBe('mem-1'); - expect(await readFile(join(outDir, 'nested/a/mem2.txt'), 'utf-8')).toBe('mem-2'); - expect(await readFile(join(outDir, 'nested/b/disk1.txt'), 'utf-8')).toBe('from-disk'); - expect(await readFile(join(outDir, 'nested/c/disk2.txt'), 'utf-8')).toBe('from-disk'); - expect(existsSync(join(outDir, 'skipped/file.txt'))).toBeFalse(); - }); -}); diff --git a/packages/angular/build/src/utils/test-files.ts b/packages/angular/build/src/utils/test-files.ts index db480686b66a..763c762ac19f 100644 --- a/packages/angular/build/src/utils/test-files.ts +++ b/packages/angular/build/src/utils/test-files.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import { ResultFile } from '../builders/application/results'; import { BuildOutputFileType } from '../tools/esbuild/bundler-files'; -import { emitFilesToDisk } from './emit-files-to-disk'; +import { emitFilesToDisk } from './disk-files'; /** * Writes a collection of build result files to a specified directory.