Skip to content

Commit 4243830

Browse files
committed
refactor(@angular/build): avoid write file logic for internal application build action
The internal "buildApplicationInternal" function is only used by several consumers that require writing the output files to disk. One, `browser-esbuild`, directly writes to the disk. The two experimental unit test builders also have unique requirements and also directly write. This leaves only the main `application` builder that relies on the internal file writing functionality of `buildApplicationInternal`. To avoid unneeded logic for the other usages (`dev-server`, `extract-i18n`, unit testing, etc.), the disk writing logic is now elevated to the `application` build itself. The internal function will now always provide the output files within the result objects generated from a successful build. This also removes the need for the other usages to specify that files should not be written to disk.
1 parent 2253957 commit 4243830

File tree

10 files changed

+155
-185
lines changed

10 files changed

+155
-185
lines changed

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

Lines changed: 38 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,10 @@
99
import { BuilderContext } from '@angular-devkit/architect';
1010
import { existsSync } from 'node:fs';
1111
import path from 'node:path';
12-
import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
12+
import { BuildOutputFileType } from '../../tools/esbuild/bundler-context';
1313
import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result';
1414
import { shutdownSassWorkerPool } from '../../tools/esbuild/stylesheets/sass-language';
15-
import {
16-
logMessages,
17-
withNoProgress,
18-
withSpinner,
19-
writeResultFiles,
20-
} from '../../tools/esbuild/utils';
21-
import { deleteOutputDir } from '../../utils/delete-output-dir';
15+
import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/utils';
2216
import { shouldWatchRoot } from '../../utils/environment-options';
2317
import { NormalizedCachedOptions } from '../../utils/normalize-cache';
2418
import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options';
@@ -46,12 +40,9 @@ export async function* runEsBuildBuildAction(
4640
outputOptions: NormalizedOutputOptions;
4741
logger: BuilderContext['logger'];
4842
cacheOptions: NormalizedCachedOptions;
49-
writeToFileSystem: boolean;
50-
writeToFileSystemFilter: ((file: BuildOutputFile) => boolean) | undefined;
5143
watch?: boolean;
5244
verbose?: boolean;
5345
progress?: boolean;
54-
deleteOutputPath?: boolean;
5546
poll?: number;
5647
signal?: AbortSignal;
5748
preserveSymlinks?: boolean;
@@ -61,13 +52,10 @@ export async function* runEsBuildBuildAction(
6152
},
6253
): AsyncIterable<Result> {
6354
const {
64-
writeToFileSystemFilter,
65-
writeToFileSystem,
6655
watch,
6756
poll,
6857
clearScreen,
6958
logger,
70-
deleteOutputPath,
7159
cacheOptions,
7260
outputOptions,
7361
verbose,
@@ -79,13 +67,6 @@ export async function* runEsBuildBuildAction(
7967
jsonLogs,
8068
} = options;
8169

82-
if (deleteOutputPath && writeToFileSystem) {
83-
await deleteOutputDir(workspaceRoot, outputOptions.base, [
84-
outputOptions.browser,
85-
outputOptions.server,
86-
]);
87-
}
88-
8970
const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress;
9071

9172
// Initial build
@@ -154,7 +135,7 @@ export async function* runEsBuildBuildAction(
154135
// Output the first build results after setting up the watcher to ensure that any code executed
155136
// higher in the iterator call stack will trigger the watcher. This is particularly relevant for
156137
// unit tests which execute the builder and modify the file system programmatically.
157-
yield await writeAndEmitOutput(writeToFileSystem, result, outputOptions, writeToFileSystemFilter);
138+
yield await emitOutputResult(result, outputOptions);
158139

159140
// Finish if watch mode is not enabled
160141
if (!watcher) {
@@ -207,12 +188,7 @@ export async function* runEsBuildBuildAction(
207188
watcher.remove([...staleWatchFiles]);
208189
}
209190

210-
yield await writeAndEmitOutput(
211-
writeToFileSystem,
212-
result,
213-
outputOptions,
214-
writeToFileSystemFilter,
215-
);
191+
yield await emitOutputResult(result, outputOptions);
216192
}
217193
} finally {
218194
// Stop the watcher and cleanup incremental rebuild state
@@ -222,65 +198,55 @@ export async function* runEsBuildBuildAction(
222198
}
223199
}
224200

225-
async function writeAndEmitOutput(
226-
writeToFileSystem: boolean,
201+
async function emitOutputResult(
227202
{
228203
outputFiles,
229-
outputWithFiles,
230204
assetFiles,
205+
errors,
206+
warnings,
231207
externalMetadata,
232208
htmlIndexPath,
233209
htmlBaseHref,
234210
}: ExecutionResult,
235211
outputOptions: NormalizedApplicationBuildOptions['outputOptions'],
236-
writeToFileSystemFilter: ((file: BuildOutputFile) => boolean) | undefined,
237212
): Promise<Result> {
238-
if (!outputWithFiles.success) {
213+
if (errors.length > 0) {
239214
return {
240215
kind: ResultKind.Failure,
241-
errors: outputWithFiles.errors as ResultMessage[],
216+
errors: errors as ResultMessage[],
217+
warnings: warnings as ResultMessage[],
218+
detail: {
219+
outputOptions,
220+
},
242221
};
243222
}
244223

245-
if (writeToFileSystem) {
246-
// Write output files
247-
const outputFilesToWrite = writeToFileSystemFilter
248-
? outputFiles.filter(writeToFileSystemFilter)
249-
: outputFiles;
250-
251-
await writeResultFiles(outputFilesToWrite, assetFiles, outputOptions);
252-
253-
// Currently unused other than indicating success if writing to disk.
254-
return {
255-
kind: ResultKind.Full,
256-
files: {},
224+
const result: FullResult = {
225+
kind: ResultKind.Full,
226+
warnings: warnings as ResultMessage[],
227+
files: {},
228+
detail: {
229+
externalMetadata,
230+
htmlIndexPath,
231+
htmlBaseHref,
232+
outputOptions,
233+
},
234+
};
235+
for (const file of assetFiles) {
236+
result.files[file.destination] = {
237+
type: BuildOutputFileType.Browser,
238+
inputPath: file.source,
239+
origin: 'disk',
257240
};
258-
} else {
259-
const result: FullResult = {
260-
kind: ResultKind.Full,
261-
files: {},
262-
detail: {
263-
externalMetadata,
264-
htmlIndexPath,
265-
htmlBaseHref,
266-
},
241+
}
242+
for (const file of outputFiles) {
243+
result.files[file.path] = {
244+
type: file.type,
245+
contents: file.contents,
246+
origin: 'memory',
247+
hash: file.hash,
267248
};
268-
for (const file of outputWithFiles.assetFiles) {
269-
result.files[file.destination] = {
270-
type: BuildOutputFileType.Browser,
271-
inputPath: file.source,
272-
origin: 'disk',
273-
};
274-
}
275-
for (const file of outputWithFiles.outputFiles) {
276-
result.files[file.path] = {
277-
type: file.type,
278-
contents: file.contents,
279-
origin: 'memory',
280-
hash: file.hash,
281-
};
282-
}
283-
284-
return result;
285249
}
250+
251+
return result;
286252
}

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

Lines changed: 83 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,22 @@
88

99
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
1010
import type { Plugin } from 'esbuild';
11+
import assert from 'node:assert';
12+
import fs from 'node:fs/promises';
13+
import path from 'node:path';
1114
import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
12-
import { createJsonBuildManifest } from '../../tools/esbuild/utils';
15+
import { createJsonBuildManifest, emitFilesToDisk } from '../../tools/esbuild/utils';
1316
import { colors as ansiColors } from '../../utils/color';
17+
import { deleteOutputDir } from '../../utils/delete-output-dir';
18+
import { useJSONBuildLogs } from '../../utils/environment-options';
1419
import { purgeStaleBuildCache } from '../../utils/purge-cache';
1520
import { assertCompatibleAngularVersion } from '../../utils/version';
1621
import { runEsBuildBuildAction } from './build-action';
1722
import { executeBuild } from './execute-build';
1823
import {
1924
ApplicationBuilderExtensions,
2025
ApplicationBuilderInternalOptions,
26+
NormalizedOutputOptions,
2127
normalizeOptions,
2228
} from './options';
2329
import { Result, ResultKind } from './results';
@@ -29,9 +35,6 @@ export async function* buildApplicationInternal(
2935
options: ApplicationBuilderInternalOptions,
3036
// TODO: Integrate abort signal support into builder system
3137
context: BuilderContext & { signal?: AbortSignal },
32-
infrastructureSettings?: {
33-
write?: boolean;
34-
},
3538
extensions?: ApplicationBuilderExtensions,
3639
): AsyncIterable<Result> {
3740
const { workspaceRoot, logger, target } = context;
@@ -53,11 +56,8 @@ export async function* buildApplicationInternal(
5356
}
5457

5558
const normalizedOptions = await normalizeOptions(context, projectName, options, extensions);
56-
const writeToFileSystem = infrastructureSettings?.write ?? true;
57-
const writeServerBundles =
58-
writeToFileSystem && !!(normalizedOptions.ssrOptions && normalizedOptions.serverEntryPoint);
5959

60-
if (writeServerBundles) {
60+
if (!normalizedOptions.outputOptions.ignoreServer) {
6161
const { browser, server } = normalizedOptions.outputOptions;
6262
if (browser === '') {
6363
context.logger.error(
@@ -88,7 +88,7 @@ export async function* buildApplicationInternal(
8888

8989
yield* runEsBuildBuildAction(
9090
async (rebuildState) => {
91-
const { prerenderOptions, outputOptions, jsonLogs } = normalizedOptions;
91+
const { prerenderOptions, jsonLogs } = normalizedOptions;
9292

9393
const startTime = process.hrtime.bigint();
9494
const result = await executeBuild(normalizedOptions, context, rebuildState);
@@ -106,9 +106,6 @@ export async function* buildApplicationInternal(
106106

107107
const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9;
108108
const hasError = result.errors.length > 0;
109-
if (writeToFileSystem && !hasError) {
110-
result.addLog(`Output location: ${outputOptions.base}\n`);
111-
}
112109

113110
result.addLog(
114111
`Application bundle generation ${hasError ? 'failed' : 'complete'}. [${buildTime.toFixed(3)} seconds]\n`,
@@ -121,7 +118,6 @@ export async function* buildApplicationInternal(
121118
watch: normalizedOptions.watch,
122119
preserveSymlinks: normalizedOptions.preserveSymlinks,
123120
poll: normalizedOptions.poll,
124-
deleteOutputPath: normalizedOptions.deleteOutputPath,
125121
cacheOptions: normalizedOptions.cacheOptions,
126122
outputOptions: normalizedOptions.outputOptions,
127123
verbose: normalizedOptions.verbose,
@@ -131,12 +127,6 @@ export async function* buildApplicationInternal(
131127
clearScreen: normalizedOptions.clearScreen,
132128
colors: normalizedOptions.colors,
133129
jsonLogs: normalizedOptions.jsonLogs,
134-
writeToFileSystem,
135-
// For app-shell and SSG server files are not required by users.
136-
// Omit these when SSR is not enabled.
137-
writeToFileSystemFilter: writeServerBundles
138-
? undefined
139-
: (file) => file.type !== BuildOutputFileType.Server,
140130
logger,
141131
signal,
142132
},
@@ -202,8 +192,80 @@ export async function* buildApplication(
202192
extensions = pluginsOrExtensions;
203193
}
204194

205-
for await (const result of buildApplicationInternal(options, context, undefined, extensions)) {
206-
yield { success: result.kind !== ResultKind.Failure };
195+
let initial = true;
196+
for await (const result of buildApplicationInternal(options, context, extensions)) {
197+
const outputOptions = result.detail?.['outputOptions'] as NormalizedOutputOptions | undefined;
198+
199+
if (initial) {
200+
initial = false;
201+
202+
// Clean the output location if requested.
203+
// Output options may not be present if the build failed.
204+
if (outputOptions?.clean) {
205+
await deleteOutputDir(context.workspaceRoot, outputOptions.base, [
206+
outputOptions.browser,
207+
outputOptions.server,
208+
]);
209+
}
210+
}
211+
212+
if (result.kind === ResultKind.Failure) {
213+
yield { success: false };
214+
continue;
215+
}
216+
217+
assert(outputOptions, 'Application output options are required for builder usage.');
218+
assert(result.kind === ResultKind.Full, 'Application build did not provide a full output.');
219+
220+
// TODO: Restructure output logging to better handle stdout JSON piping
221+
if (!useJSONBuildLogs) {
222+
context.logger.info(`Output location: ${outputOptions.base}\n`);
223+
}
224+
225+
// Writes the output files to disk and ensures the containing directories are present
226+
const directoryExists = new Set<string>();
227+
await emitFilesToDisk(Object.entries(result.files), async ([filePath, file]) => {
228+
if (outputOptions.ignoreServer && file.type === BuildOutputFileType.Server) {
229+
return;
230+
}
231+
232+
let typeDirectory: string;
233+
switch (file.type) {
234+
case BuildOutputFileType.Browser:
235+
case BuildOutputFileType.Media:
236+
typeDirectory = outputOptions.browser;
237+
break;
238+
case BuildOutputFileType.Server:
239+
typeDirectory = outputOptions.server;
240+
break;
241+
case BuildOutputFileType.Root:
242+
typeDirectory = '';
243+
break;
244+
default:
245+
throw new Error(
246+
`Unhandled write for file "${filePath}" with type "${BuildOutputFileType[file.type]}".`,
247+
);
248+
}
249+
// NOTE: 'base' is a fully resolved path at this point
250+
const fullFilePath = path.join(outputOptions.base, typeDirectory, filePath);
251+
252+
// Ensure output subdirectories exist
253+
const fileBasePath = path.dirname(fullFilePath);
254+
if (fileBasePath && !directoryExists.has(fileBasePath)) {
255+
await fs.mkdir(fileBasePath, { recursive: true });
256+
directoryExists.add(fileBasePath);
257+
}
258+
259+
if (file.origin === 'memory') {
260+
// Write file contents
261+
await fs.writeFile(fullFilePath, file.contents);
262+
} else {
263+
// Copy file contents
264+
await fs.copyFile(file.inputPath, fullFilePath, fs.constants.COPYFILE_FICLONE);
265+
}
266+
});
267+
268+
yield { success: true };
207269
}
208270
}
209271

0 commit comments

Comments
 (0)