Skip to content

Commit 7823990

Browse files
clydinangular-robot[bot]
authored andcommitted
refactor(@angular-devkit/build-angular): consolidate result file writes in esbuild builder
As a preparation step to allow for in-memory build outputs to support the development server, The output result files of the build are now written to the file system in one location. This includes the generated files from the bundling steps as well as any assets and service worker files.
1 parent a832c20 commit 7823990

File tree

3 files changed

+153
-53
lines changed

3 files changed

+153
-53
lines changed

packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
1010
import type { BuildOptions, OutputFile } from 'esbuild';
1111
import assert from 'node:assert';
12-
import * as fs from 'node:fs/promises';
13-
import * as path from 'node:path';
12+
import { constants as fsConstants } from 'node:fs';
13+
import fs from 'node:fs/promises';
14+
import path from 'node:path';
1415
import { deleteOutputDir } from '../../utils';
1516
import { copyAssets } from '../../utils/copy-assets';
1617
import { assertIsError } from '../../utils/error';
@@ -205,38 +206,41 @@ async function execute(
205206
}
206207

207208
// Copy assets
209+
let assetFiles;
208210
if (assets) {
209-
await copyAssets(assets, [outputPath], workspaceRoot);
211+
// The webpack copy assets helper is used with no base paths defined. This prevents the helper
212+
// from directly writing to disk. This should eventually be replaced with a more optimized helper.
213+
assetFiles = await copyAssets(assets, [], workspaceRoot);
210214
}
211215

212-
// Write output files
213-
await Promise.all(
214-
outputFiles.map((file) => fs.writeFile(path.join(outputPath, file.path), file.contents)),
215-
);
216-
217216
// Write metafile if stats option is enabled
218217
if (options.stats) {
219-
await fs.writeFile(path.join(outputPath, 'stats.json'), JSON.stringify(metafile, null, 2));
218+
outputFiles.push(createOutputFileFromText('stats.json', JSON.stringify(metafile, null, 2)));
220219
}
221220

222221
// Extract and write licenses for used packages
223222
if (options.extractLicenses) {
224-
await fs.writeFile(
225-
path.join(outputPath, '3rdpartylicenses.txt'),
226-
await extractLicenses(metafile, workspaceRoot),
223+
outputFiles.push(
224+
createOutputFileFromText(
225+
'3rdpartylicenses.txt',
226+
await extractLicenses(metafile, workspaceRoot),
227+
),
227228
);
228229
}
229230

230231
// Augment the application with service worker support
231-
// TODO: This should eventually operate on the in-memory files prior to writing the output files
232232
if (serviceWorkerOptions) {
233233
try {
234-
await augmentAppWithServiceWorkerEsbuild(
234+
const serviceWorkerResult = await augmentAppWithServiceWorkerEsbuild(
235235
workspaceRoot,
236236
serviceWorkerOptions,
237-
outputPath,
238237
options.baseHref || '/',
238+
outputFiles,
239+
assetFiles || [],
239240
);
241+
outputFiles.push(createOutputFileFromText('ngsw.json', serviceWorkerResult.manifest));
242+
assetFiles ??= [];
243+
assetFiles.push(...serviceWorkerResult.assetFiles);
240244
} catch (error) {
241245
context.logger.error(error instanceof Error ? error.message : `${error}`);
242246

@@ -249,12 +253,55 @@ async function execute(
249253
}
250254
}
251255

256+
// Write output files
257+
await writeResultFiles(outputFiles, assetFiles, outputPath);
258+
252259
const buildTime = Number(process.hrtime.bigint() - startTime) / 10 ** 9;
253260
context.logger.info(`Complete. [${buildTime.toFixed(3)} seconds]`);
254261

255262
return new ExecutionResult(true, codeBundleContext, globalStylesBundleContext, codeBundleCache);
256263
}
257264

265+
async function writeResultFiles(
266+
outputFiles: OutputFile[],
267+
assetFiles: { source: string; destination: string }[] | undefined,
268+
outputPath: string,
269+
) {
270+
const directoryExists = new Set<string>();
271+
await Promise.all(
272+
outputFiles.map(async (file) => {
273+
// Ensure output subdirectories exist
274+
const basePath = path.dirname(file.path);
275+
if (basePath && !directoryExists.has(basePath)) {
276+
await fs.mkdir(path.join(outputPath, basePath), { recursive: true });
277+
directoryExists.add(basePath);
278+
}
279+
// Write file contents
280+
await fs.writeFile(path.join(outputPath, file.path), file.contents);
281+
}),
282+
);
283+
284+
if (assetFiles?.length) {
285+
await Promise.all(
286+
assetFiles.map(async ({ source, destination }) => {
287+
// Ensure output subdirectories exist
288+
const basePath = path.dirname(destination);
289+
if (basePath && !directoryExists.has(basePath)) {
290+
await fs.mkdir(path.join(outputPath, basePath), { recursive: true });
291+
directoryExists.add(basePath);
292+
}
293+
// Copy file contents
294+
await fs.copyFile(
295+
source,
296+
path.join(outputPath, destination),
297+
// This is not yet available from `fs/promises` in Node.js v16.13
298+
fsConstants.COPYFILE_FICLONE,
299+
);
300+
}),
301+
);
302+
}
303+
}
304+
258305
function createOutputFileFromText(path: string, text: string): OutputFile {
259306
return {
260307
path,

packages/angular_devkit/build_angular/src/utils/copy-assets.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export async function copyAssets(
2828
) {
2929
const defaultIgnore = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db'];
3030

31+
const outputFiles: { source: string; destination: string }[] = [];
32+
3133
for (const entry of entries) {
3234
const cwd = path.resolve(root, entry.input);
3335
const files = await globPromise(entry.glob, {
@@ -49,6 +51,9 @@ export async function copyAssets(
4951
}
5052

5153
const filePath = entry.flatten ? path.basename(file) : file;
54+
55+
outputFiles.push({ source: src, destination: path.join(entry.output, filePath) });
56+
5257
for (const base of basePaths) {
5358
const dest = path.join(base, entry.output, filePath);
5459
const dir = path.dirname(dest);
@@ -62,4 +67,6 @@ export async function copyAssets(
6267
}
6368
}
6469
}
70+
71+
return outputFiles;
6572
}

packages/angular_devkit/build_angular/src/utils/service-worker.ts

Lines changed: 84 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
import type { Config, Filesystem } from '@angular/service-worker/config';
1010
import * as crypto from 'crypto';
11-
import { constants as fsConstants, promises as fsPromises } from 'fs';
11+
import type { OutputFile } from 'esbuild';
12+
import { existsSync, constants as fsConstants, promises as fsPromises } from 'node:fs';
1213
import * as path from 'path';
1314
import { assertIsError } from './error';
1415
import { loadEsmModule } from './load-esm';
@@ -61,6 +62,49 @@ class CliFilesystem implements Filesystem {
6162
}
6263
}
6364

65+
class ResultFilesystem implements Filesystem {
66+
private readonly fileReaders = new Map<string, () => Promise<string>>();
67+
68+
constructor(outputFiles: OutputFile[], assetFiles: { source: string; destination: string }[]) {
69+
for (const file of outputFiles) {
70+
this.fileReaders.set('/' + file.path.replace(/\\/g, '/'), async () => file.text);
71+
}
72+
for (const file of assetFiles) {
73+
this.fileReaders.set('/' + file.destination.replace(/\\/g, '/'), () =>
74+
fsPromises.readFile(file.source, 'utf-8'),
75+
);
76+
}
77+
}
78+
79+
async list(dir: string): Promise<string[]> {
80+
if (dir !== '/') {
81+
throw new Error('Serviceworker manifest generator should only list files from root.');
82+
}
83+
84+
return [...this.fileReaders.keys()];
85+
}
86+
87+
read(file: string): Promise<string> {
88+
const reader = this.fileReaders.get(file);
89+
if (reader === undefined) {
90+
throw new Error('File does not exist.');
91+
}
92+
93+
return reader();
94+
}
95+
96+
async hash(file: string): Promise<string> {
97+
return crypto
98+
.createHash('sha1')
99+
.update(await this.read(file))
100+
.digest('hex');
101+
}
102+
103+
write(): never {
104+
throw new Error('Serviceworker manifest generator should not attempted to write.');
105+
}
106+
}
107+
64108
export async function augmentAppWithServiceWorker(
65109
appRoot: string,
66110
workspaceRoot: string,
@@ -93,22 +137,37 @@ export async function augmentAppWithServiceWorker(
93137
}
94138
}
95139

96-
return augmentAppWithServiceWorkerCore(
140+
const result = await augmentAppWithServiceWorkerCore(
97141
config,
98-
outputPath,
142+
new CliFilesystem(outputFileSystem, outputPath),
99143
baseHref,
100-
inputputFileSystem,
101-
outputFileSystem,
102144
);
145+
146+
const copy = async (src: string, dest: string): Promise<void> => {
147+
const resolvedDest = path.join(outputPath, dest);
148+
149+
return inputputFileSystem === outputFileSystem
150+
? // Native FS (Builder).
151+
inputputFileSystem.copyFile(src, resolvedDest, fsConstants.COPYFILE_FICLONE)
152+
: // memfs (Webpack): Read the file from the input FS (disk) and write it to the output FS (memory).
153+
outputFileSystem.writeFile(resolvedDest, await inputputFileSystem.readFile(src));
154+
};
155+
156+
await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), result.manifest);
157+
158+
for (const { source, destination } of result.assetFiles) {
159+
await copy(source, destination);
160+
}
103161
}
104162

105163
// This is currently used by the esbuild-based builder
106164
export async function augmentAppWithServiceWorkerEsbuild(
107165
workspaceRoot: string,
108166
configPath: string,
109-
outputPath: string,
110167
baseHref: string,
111-
): Promise<void> {
168+
outputFiles: OutputFile[],
169+
assetFiles: { source: string; destination: string }[],
170+
): Promise<{ manifest: string; assetFiles: { source: string; destination: string }[] }> {
112171
// Read the configuration file
113172
let config: Config | undefined;
114173
try {
@@ -128,17 +187,18 @@ export async function augmentAppWithServiceWorkerEsbuild(
128187
}
129188
}
130189

131-
// TODO: Return the output files and any errors/warnings
132-
return augmentAppWithServiceWorkerCore(config, outputPath, baseHref);
190+
return augmentAppWithServiceWorkerCore(
191+
config,
192+
new ResultFilesystem(outputFiles, assetFiles),
193+
baseHref,
194+
);
133195
}
134196

135197
export async function augmentAppWithServiceWorkerCore(
136198
config: Config,
137-
outputPath: string,
199+
serviceWorkerFilesystem: Filesystem,
138200
baseHref: string,
139-
inputputFileSystem = fsPromises,
140-
outputFileSystem = fsPromises,
141-
): Promise<void> {
201+
): Promise<{ manifest: string; assetFiles: { source: string; destination: string }[] }> {
142202
// Load ESM `@angular/service-worker/config` using the TypeScript dynamic import workaround.
143203
// Once TypeScript provides support for keeping the dynamic import this workaround can be
144204
// changed to a direct dynamic import.
@@ -149,41 +209,27 @@ export async function augmentAppWithServiceWorkerCore(
149209
).Generator;
150210

151211
// Generate the manifest
152-
const generator = new GeneratorConstructor(
153-
new CliFilesystem(outputFileSystem, outputPath),
154-
baseHref,
155-
);
212+
const generator = new GeneratorConstructor(serviceWorkerFilesystem, baseHref);
156213
const output = await generator.process(config);
157214

158215
// Write the manifest
159216
const manifest = JSON.stringify(output, null, 2);
160-
await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), manifest);
161217

162218
// Find the service worker package
163219
const workerPath = require.resolve('@angular/service-worker/ngsw-worker.js');
164220

165-
const copy = async (src: string, dest: string): Promise<void> => {
166-
const resolvedDest = path.join(outputPath, dest);
167-
168-
return inputputFileSystem === outputFileSystem
169-
? // Native FS (Builder).
170-
inputputFileSystem.copyFile(src, resolvedDest, fsConstants.COPYFILE_FICLONE)
171-
: // memfs (Webpack): Read the file from the input FS (disk) and write it to the output FS (memory).
172-
outputFileSystem.writeFile(resolvedDest, await inputputFileSystem.readFile(src));
221+
const result = {
222+
manifest,
223+
// Main worker code
224+
assetFiles: [{ source: workerPath, destination: 'ngsw-worker.js' }],
173225
};
174226

175-
// Write the worker code
176-
await copy(workerPath, 'ngsw-worker.js');
177-
178227
// If present, write the safety worker code
179-
try {
180-
const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js');
181-
await copy(safetyPath, 'worker-basic.min.js');
182-
await copy(safetyPath, 'safety-worker.js');
183-
} catch (error) {
184-
assertIsError(error);
185-
if (error.code !== 'ENOENT') {
186-
throw error;
187-
}
228+
const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js');
229+
if (existsSync(safetyPath)) {
230+
result.assetFiles.push({ source: safetyPath, destination: 'worker-basic.min.js' });
231+
result.assetFiles.push({ source: safetyPath, destination: 'safety-worker.js' });
188232
}
233+
234+
return result;
189235
}

0 commit comments

Comments
 (0)