Skip to content

Commit 462a377

Browse files
authored
Merge pull request #2333 from D4N14L/danade/copy-plugin
[heft] Introduce initial implementation of 'copyFiles' action
2 parents 1abf3a2 + c3984b1 commit 462a377

36 files changed

+1075
-290
lines changed

apps/heft/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"chokidar": "~3.4.0",
4747
"glob-escape": "~0.0.2",
4848
"glob": "~7.0.5",
49+
"fast-glob": "~3.2.4",
4950
"jest-snapshot": "~25.4.0",
5051
"node-sass": "4.14.1",
5152
"postcss-modules": "~1.5.0",

apps/heft/src/pluginFramework/PluginManager.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '../utilities/CoreConfigFiles';
1515

1616
// Default plugins
17+
import { CopyFilesPlugin } from '../plugins/CopyFilesPlugin';
1718
import { TypeScriptPlugin } from '../plugins/TypeScriptPlugin/TypeScriptPlugin';
1819
import { DeleteGlobsPlugin } from '../plugins/DeleteGlobsPlugin';
1920
import { CopyStaticAssetsPlugin } from '../plugins/CopyStaticAssetsPlugin';
@@ -46,6 +47,7 @@ export class PluginManager {
4647
public initializeDefaultPlugins(): void {
4748
this._applyPlugin(new TypeScriptPlugin());
4849
this._applyPlugin(new CopyStaticAssetsPlugin());
50+
this._applyPlugin(new CopyFilesPlugin());
4951
this._applyPlugin(new DeleteGlobsPlugin());
5052
this._applyPlugin(new ApiExtractorPlugin());
5153
this._applyPlugin(new JestPlugin());
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import * as chokidar from 'chokidar';
5+
import * as path from 'path';
6+
import glob from 'fast-glob';
7+
import { performance } from 'perf_hooks';
8+
import { AlreadyExistsBehavior, FileSystem } from '@rushstack/node-core-library';
9+
import { TapOptions } from 'tapable';
10+
11+
import { IHeftPlugin } from '../pluginFramework/IHeftPlugin';
12+
import { HeftSession } from '../pluginFramework/HeftSession';
13+
import { HeftConfiguration } from '../configuration/HeftConfiguration';
14+
import { ScopedLogger } from '../pluginFramework/logging/ScopedLogger';
15+
import { Async } from '../utilities/Async';
16+
import {
17+
IHeftEventActions,
18+
CoreConfigFiles,
19+
HeftEvent,
20+
IExtendedSharedCopyConfiguration
21+
} from '../utilities/CoreConfigFiles';
22+
import {
23+
IBuildStageContext,
24+
IBundleSubstage,
25+
ICompileSubstage,
26+
IPostBuildSubstage,
27+
IPreCompileSubstage
28+
} from '../stages/BuildStage';
29+
import { Constants } from '../utilities/Constants';
30+
31+
const PLUGIN_NAME: string = 'CopyFilesPlugin';
32+
const HEFT_STAGE_TAP: TapOptions<'promise'> = {
33+
name: PLUGIN_NAME,
34+
stage: Number.MAX_SAFE_INTEGER / 2 // This should give us some certainty that this will run after other plugins
35+
};
36+
37+
interface ICopyFileDescriptor {
38+
sourceFilePath: string;
39+
destinationFilePaths: string[];
40+
hardlink: boolean;
41+
}
42+
43+
export interface ICopyFilesOptions {
44+
buildFolder: string;
45+
copyConfigurations: IExtendedSharedCopyConfiguration[];
46+
logger: ScopedLogger;
47+
watchMode: boolean;
48+
}
49+
50+
export interface ICopyFilesResult {
51+
copiedFileCount: number;
52+
linkedFileCount: number;
53+
}
54+
55+
export class CopyFilesPlugin implements IHeftPlugin {
56+
public readonly pluginName: string = PLUGIN_NAME;
57+
58+
public apply(heftSession: HeftSession, heftConfiguration: HeftConfiguration): void {
59+
heftSession.hooks.build.tap(PLUGIN_NAME, (build: IBuildStageContext) => {
60+
const logger: ScopedLogger = heftSession.requestScopedLogger('copy-files');
61+
build.hooks.preCompile.tap(PLUGIN_NAME, (preCompile: IPreCompileSubstage) => {
62+
preCompile.hooks.run.tapPromise(HEFT_STAGE_TAP, async () => {
63+
await this._runCopyFilesForHeftEvent(HeftEvent.preCompile, logger, heftConfiguration);
64+
});
65+
});
66+
67+
build.hooks.compile.tap(PLUGIN_NAME, (compile: ICompileSubstage) => {
68+
compile.hooks.run.tapPromise(HEFT_STAGE_TAP, async () => {
69+
await this._runCopyFilesForHeftEvent(HeftEvent.compile, logger, heftConfiguration);
70+
});
71+
});
72+
73+
build.hooks.bundle.tap(PLUGIN_NAME, (bundle: IBundleSubstage) => {
74+
bundle.hooks.run.tapPromise(HEFT_STAGE_TAP, async () => {
75+
await this._runCopyFilesForHeftEvent(HeftEvent.bundle, logger, heftConfiguration);
76+
});
77+
});
78+
79+
build.hooks.postBuild.tap(PLUGIN_NAME, (postBuild: IPostBuildSubstage) => {
80+
postBuild.hooks.run.tapPromise(HEFT_STAGE_TAP, async () => {
81+
await this._runCopyFilesForHeftEvent(HeftEvent.postBuild, logger, heftConfiguration);
82+
});
83+
});
84+
});
85+
}
86+
87+
private async _runCopyFilesForHeftEvent(
88+
heftEvent: HeftEvent,
89+
logger: ScopedLogger,
90+
heftConfiguration: HeftConfiguration
91+
): Promise<void> {
92+
const eventActions: IHeftEventActions = await CoreConfigFiles.getConfigConfigFileEventActionsAsync(
93+
logger.terminal,
94+
heftConfiguration
95+
);
96+
97+
const copyConfigurations: IExtendedSharedCopyConfiguration[] = [];
98+
for (const copyFilesEventAction of eventActions.copyFiles.get(heftEvent) || []) {
99+
copyConfigurations.push(...copyFilesEventAction.copyOperations);
100+
}
101+
102+
await this.runCopyAsync({
103+
buildFolder: heftConfiguration.buildFolder,
104+
copyConfigurations,
105+
logger,
106+
watchMode: false
107+
});
108+
}
109+
110+
protected async runCopyAsync(options: ICopyFilesOptions): Promise<void> {
111+
const { logger, buildFolder, copyConfigurations } = options;
112+
113+
const startTime: number = performance.now();
114+
const copyDescriptors: ICopyFileDescriptor[] = await this._getCopyFileDescriptorsAsync(
115+
buildFolder,
116+
copyConfigurations
117+
);
118+
119+
if (copyDescriptors.length === 0) {
120+
// No need to run copy and print to console
121+
return;
122+
}
123+
124+
const { copiedFileCount, linkedFileCount } = await this.copyFilesAsync(copyDescriptors);
125+
const duration: number = performance.now() - startTime;
126+
logger.terminal.writeLine(
127+
`Copied ${copiedFileCount} file${copiedFileCount === 1 ? '' : 's'} and ` +
128+
`linked ${linkedFileCount} file${linkedFileCount === 1 ? '' : 's'} in ${Math.round(duration)}ms`
129+
);
130+
131+
// Then enter watch mode if requested
132+
if (options.watchMode) {
133+
await this._runWatchAsync(options);
134+
}
135+
}
136+
137+
protected async copyFilesAsync(copyDescriptors: ICopyFileDescriptor[]): Promise<ICopyFilesResult> {
138+
if (copyDescriptors.length === 0) {
139+
return { copiedFileCount: 0, linkedFileCount: 0 };
140+
}
141+
142+
let copiedFileCount: number = 0;
143+
let linkedFileCount: number = 0;
144+
await Async.forEachLimitAsync(
145+
copyDescriptors,
146+
Constants.maxParallelism,
147+
async (copyDescriptor: ICopyFileDescriptor) => {
148+
if (copyDescriptor.hardlink) {
149+
const hardlinkPromises: Promise<void>[] = copyDescriptor.destinationFilePaths.map(
150+
(destinationFilePath) => {
151+
return FileSystem.createHardLinkAsync({
152+
linkTargetPath: copyDescriptor.sourceFilePath,
153+
newLinkPath: destinationFilePath,
154+
alreadyExistsBehavior: AlreadyExistsBehavior.Overwrite
155+
});
156+
}
157+
);
158+
await Promise.all(hardlinkPromises);
159+
160+
linkedFileCount++;
161+
} else {
162+
// If it's a copy, we will call the copy function
163+
if (copyDescriptor.destinationFilePaths.length === 1) {
164+
await FileSystem.copyFileAsync({
165+
sourcePath: copyDescriptor.sourceFilePath,
166+
destinationPath: copyDescriptor.destinationFilePaths[0],
167+
alreadyExistsBehavior: AlreadyExistsBehavior.Overwrite
168+
});
169+
} else {
170+
await FileSystem.copyFileToManyAsync({
171+
sourcePath: copyDescriptor.sourceFilePath,
172+
destinationPaths: copyDescriptor.destinationFilePaths,
173+
alreadyExistsBehavior: AlreadyExistsBehavior.Overwrite
174+
});
175+
}
176+
177+
copiedFileCount++;
178+
}
179+
}
180+
);
181+
182+
return {
183+
copiedFileCount,
184+
linkedFileCount
185+
};
186+
}
187+
188+
private async _getCopyFileDescriptorsAsync(
189+
buildFolder: string,
190+
copyConfigurations: IExtendedSharedCopyConfiguration[]
191+
): Promise<ICopyFileDescriptor[]> {
192+
// Create a map to deduplicate and prevent double-writes. The key in this map is the copy/link destination
193+
// file path
194+
const destinationCopyDescriptors: Map<string, ICopyFileDescriptor> = new Map();
195+
// And a map to contain the actual results. The key in this map is the copy/link source file path
196+
const sourceCopyDescriptors: Map<string, ICopyFileDescriptor> = new Map();
197+
198+
for (const copyConfiguration of copyConfigurations) {
199+
// Resolve the source folder path which is where the glob will be run from
200+
const resolvedSourceFolderPath: string = path.resolve(buildFolder, copyConfiguration.sourceFolder);
201+
const sourceFileRelativePaths: Set<string> = new Set<string>(
202+
await glob(this._getIncludedGlobPatterns(copyConfiguration), {
203+
cwd: resolvedSourceFolderPath,
204+
ignore: copyConfiguration.excludeGlobs,
205+
dot: true,
206+
onlyFiles: true
207+
})
208+
);
209+
210+
// Dedupe and throw if a double-write is detected
211+
for (const destinationFolderRelativePath of copyConfiguration.destinationFolders) {
212+
for (const sourceFileRelativePath of sourceFileRelativePaths) {
213+
// Only include the relative path from the sourceFolder if flatten is false
214+
const resolvedSourceFilePath: string = path.join(resolvedSourceFolderPath, sourceFileRelativePath);
215+
const resolvedDestinationFilePath: string = path.resolve(
216+
buildFolder,
217+
destinationFolderRelativePath,
218+
copyConfiguration.flatten ? '.' : path.dirname(sourceFileRelativePath),
219+
path.basename(sourceFileRelativePath)
220+
);
221+
222+
// Throw if a duplicate copy target with a different source or options is specified
223+
const existingDestinationCopyDescriptor:
224+
| ICopyFileDescriptor
225+
| undefined = destinationCopyDescriptors.get(resolvedDestinationFilePath);
226+
if (existingDestinationCopyDescriptor) {
227+
if (
228+
existingDestinationCopyDescriptor.sourceFilePath === resolvedSourceFilePath &&
229+
existingDestinationCopyDescriptor.hardlink === !!copyConfiguration.hardlink
230+
) {
231+
// Found a duplicate, avoid adding again
232+
continue;
233+
}
234+
throw new Error(
235+
`Cannot copy different files to the same destination "${resolvedDestinationFilePath}"`
236+
);
237+
}
238+
239+
// Finally, add to the map and default hardlink to false
240+
let sourceCopyDescriptor: ICopyFileDescriptor | undefined = sourceCopyDescriptors.get(
241+
resolvedSourceFilePath
242+
);
243+
if (!sourceCopyDescriptor) {
244+
sourceCopyDescriptor = {
245+
sourceFilePath: resolvedSourceFilePath,
246+
destinationFilePaths: [resolvedDestinationFilePath],
247+
hardlink: !!copyConfiguration.hardlink
248+
};
249+
sourceCopyDescriptors.set(resolvedSourceFilePath, sourceCopyDescriptor);
250+
} else {
251+
sourceCopyDescriptor.destinationFilePaths.push(resolvedDestinationFilePath);
252+
}
253+
254+
// Add to other map to allow deduping
255+
destinationCopyDescriptors.set(resolvedDestinationFilePath, sourceCopyDescriptor);
256+
}
257+
}
258+
}
259+
260+
// We're done with the map, grab the values and return
261+
return Array.from(sourceCopyDescriptors.values());
262+
}
263+
264+
private _getIncludedGlobPatterns(copyConfiguration: IExtendedSharedCopyConfiguration): string[] {
265+
const patternsToGlob: Set<string> = new Set<string>();
266+
267+
// Glob file extensions with a specific glob to increase perf
268+
const escapedFileExtensions: Set<string> = new Set<string>();
269+
for (const fileExtension of copyConfiguration.fileExtensions || []) {
270+
let escapedFileExtension: string;
271+
if (fileExtension.charAt(0) === '.') {
272+
escapedFileExtension = fileExtension.substr(1);
273+
} else {
274+
escapedFileExtension = fileExtension;
275+
}
276+
277+
escapedFileExtension = glob.escapePath(escapedFileExtension);
278+
escapedFileExtensions.add(escapedFileExtension);
279+
}
280+
281+
if (escapedFileExtensions.size > 1) {
282+
patternsToGlob.add(`**/*.{${Array.from(escapedFileExtensions).join(',')}}`);
283+
} else if (escapedFileExtensions.size === 1) {
284+
patternsToGlob.add(`**/*.${Array.from(escapedFileExtensions)[0]}`);
285+
}
286+
287+
// Now include the other globs as well
288+
for (const include of copyConfiguration.includeGlobs || []) {
289+
patternsToGlob.add(include);
290+
}
291+
292+
return Array.from(patternsToGlob);
293+
}
294+
295+
private async _runWatchAsync(options: ICopyFilesOptions): Promise<void> {
296+
const { buildFolder, copyConfigurations, logger } = options;
297+
298+
for (const copyConfiguration of copyConfigurations) {
299+
// Obtain the glob patterns to provide to the watcher
300+
const globsToWatch: string[] = this._getIncludedGlobPatterns(copyConfiguration);
301+
if (globsToWatch.length) {
302+
const resolvedSourceFolderPath: string = path.join(buildFolder, copyConfiguration.sourceFolder);
303+
const resolvedDestinationFolderPaths: string[] = copyConfiguration.destinationFolders.map(
304+
(destinationFolder) => {
305+
return path.join(buildFolder, destinationFolder);
306+
}
307+
);
308+
309+
const watcher: chokidar.FSWatcher = chokidar.watch(globsToWatch, {
310+
cwd: resolvedSourceFolderPath,
311+
ignoreInitial: true,
312+
ignored: copyConfiguration.excludeGlobs
313+
});
314+
315+
const copyAsset: (assetPath: string) => Promise<void> = async (assetPath: string) => {
316+
const { copiedFileCount, linkedFileCount } = await this.copyFilesAsync([
317+
{
318+
sourceFilePath: path.join(resolvedSourceFolderPath, assetPath),
319+
destinationFilePaths: resolvedDestinationFolderPaths.map((resolvedDestinationFolderPath) => {
320+
return path.join(
321+
resolvedDestinationFolderPath,
322+
copyConfiguration.flatten ? path.basename(assetPath) : assetPath
323+
);
324+
}),
325+
hardlink: !!copyConfiguration.hardlink
326+
}
327+
]);
328+
logger.terminal.writeLine(
329+
copyConfiguration.hardlink
330+
? `Linked ${linkedFileCount} file${linkedFileCount === 1 ? '' : 's'}`
331+
: `Copied ${copiedFileCount} file${copiedFileCount === 1 ? '' : 's'}`
332+
);
333+
};
334+
335+
watcher.on('add', copyAsset);
336+
watcher.on('change', copyAsset);
337+
watcher.on('unlink', (assetPath) => {
338+
let deleteCount: number = 0;
339+
for (const resolvedDestinationFolder of resolvedDestinationFolderPaths) {
340+
FileSystem.deleteFile(path.resolve(resolvedDestinationFolder, assetPath));
341+
deleteCount++;
342+
}
343+
logger.terminal.writeLine(`Deleted ${deleteCount} file${deleteCount === 1 ? '' : 's'}`);
344+
});
345+
}
346+
}
347+
348+
return new Promise(() => {
349+
/* never resolve */
350+
});
351+
}
352+
}

0 commit comments

Comments
 (0)