kist
Version:
Lightweight Package Pipeline Processor with Plugin Architecture
288 lines (248 loc) • 9.63 kB
text/typescript
// ============================================================================
// Import
// ============================================================================
import fs from "fs";
import path from "path";
import { pipeline } from "stream/promises";
import { Action } from "../../core/pipeline/Action.js";
import { FileCache } from "../../core/cache/FileCache.js";
import { ActionOptionsType } from "../../types/ActionOptionsType.js";
// ============================================================================
// Constants
// ============================================================================
/** Threshold for using streaming copy (5MB) */
const STREAMING_THRESHOLD = 5 * 1024 * 1024;
// ============================================================================
// Classes
// ============================================================================
/**
* FileCopyAction is a step action responsible for copying files from a source
* location to a destination directory. This action handles file path
* resolution and ensures that existing files in the destination can be
* replaced if necessary.
*
* Performance features:
* - Uses streaming for large files to reduce memory usage
* - Supports file caching to skip unchanged files
* - Parallel batch copy support
*/
export class FileCopyAction extends Action {
// Parameters
// ========================================================================
private fileCache: FileCache;
// Constructor
// ========================================================================
constructor() {
super();
this.fileCache = FileCache.getInstance();
}
// Methods
// ========================================================================
/**
* Executes the file copy action.
* @param options - The options specific to file copying, including source
* file and destination directory.
* @returns A Promise that resolves when the file has been successfully
* copied, or rejects with an error if the action fails.
*/
async execute(options: ActionOptionsType): Promise<void> {
const srcFile = options.srcFile as string | undefined;
const srcFiles = options.srcFiles as string[] | undefined;
const destDir = options.destDir as string;
const useCache = options.useCache as boolean | undefined;
const parallel = options.parallel as boolean | undefined;
if ((!srcFile && !srcFiles) || !destDir) {
throw new Error(
"Missing required options: srcFile/srcFiles or destDir.",
);
}
// Handle batch copy
if (srcFiles && srcFiles.length > 0) {
await this.copyMultipleFiles(srcFiles, destDir, {
useCache,
parallel,
});
return;
}
// Handle single file copy
if (srcFile) {
await this.copySingleFile(srcFile, destDir, { useCache });
}
}
/**
* Copies a single file with optional caching.
*/
private async copySingleFile(
srcFile: string,
destDir: string,
options: { useCache?: boolean } = {},
): Promise<void> {
// Check cache if enabled
if (options.useCache) {
const hasChanged = await this.fileCache.hasFileChanged(srcFile);
if (!hasChanged) {
this.logDebug(`Skipping unchanged file: ${srcFile}`);
return;
}
}
this.logInfo(`Copying file from ${srcFile} to ${destDir}.`);
try {
await this.copyFileToDirectory(srcFile, destDir);
// Update cache
if (options.useCache) {
await this.fileCache.updateFileEntry(srcFile);
}
this.logInfo(
`File copied successfully from ${srcFile} to ${destDir}.`,
);
} catch (error) {
this.logError(
`Error copying file from ${srcFile} to ${destDir}: ${error}`,
);
throw error;
}
}
/**
* Copies multiple files with optional parallel execution.
*/
private async copyMultipleFiles(
srcFiles: string[],
destDir: string,
options: { useCache?: boolean; parallel?: boolean } = {},
): Promise<void> {
const startTime = performance.now();
let filesToCopy = srcFiles;
// Filter unchanged files if caching is enabled
if (options.useCache) {
filesToCopy = await this.fileCache.getChangedFiles(srcFiles);
const skipped = srcFiles.length - filesToCopy.length;
if (skipped > 0) {
this.logInfo(`Skipping ${skipped} unchanged files.`);
}
}
if (filesToCopy.length === 0) {
this.logInfo("All files are up to date, nothing to copy.");
return;
}
this.logInfo(`Copying ${filesToCopy.length} files to ${destDir}.`);
try {
if (options.parallel) {
// Parallel copy with concurrency limit
await this.copyFilesInParallel(filesToCopy, destDir, 10);
} else {
// Sequential copy
for (const file of filesToCopy) {
await this.copyFileToDirectory(file, destDir);
}
}
// Update cache for all copied files
if (options.useCache) {
await this.fileCache.updateFileEntries(filesToCopy);
}
const duration = performance.now() - startTime;
this.logInfo(
`Copied ${filesToCopy.length} files in ${duration.toFixed(2)}ms.`,
);
} catch (error) {
this.logError(`Error copying files: ${error}`);
throw error;
}
}
/**
* Copies files in parallel with concurrency control.
*/
private async copyFilesInParallel(
srcFiles: string[],
destDir: string,
maxConcurrent: number = 10,
): Promise<void> {
const executing = new Set<Promise<void>>();
for (const srcFile of srcFiles) {
const copyPromise = this.copyFileToDirectory(
srcFile,
destDir,
).finally(() => executing.delete(copyPromise));
executing.add(copyPromise);
if (executing.size >= maxConcurrent) {
await Promise.race(executing);
}
}
await Promise.all(executing);
}
/**
* Copies a file from a specified source to a destination directory.
* Uses streaming for large files to reduce memory usage.
*
* @param srcFile - The path of the source file to be copied.
* @param destDir - The destination directory where the file should
* be placed.
* @returns A Promise that resolves when the file has been successfully
* copied.
* @throws {Error} If the file cannot be copied, including due to
* permission errors or the source file not existing.
*/
private async copyFileToDirectory(
srcFile: string,
destDir: string,
): Promise<void> {
try {
// Ensure the destination directory exists
await this.ensureDirectoryExists(destDir);
// Resolve the destination file path
const fileName = path.basename(srcFile);
const destFilePath = path.join(destDir, fileName);
// Check file size to determine copy method
const stat = await fs.promises.stat(srcFile);
if (stat.size > STREAMING_THRESHOLD) {
// Use streaming for large files
await this.streamCopyFile(srcFile, destFilePath);
} else {
// Use standard copy for smaller files
await fs.promises.copyFile(srcFile, destFilePath);
}
} catch (error) {
this.logError(`Error copying file: ${error}`);
throw error;
}
}
/**
* Copies a file using streams for memory-efficient handling of large files.
*/
private async streamCopyFile(
srcFile: string,
destFile: string,
): Promise<void> {
const readStream = fs.createReadStream(srcFile);
const writeStream = fs.createWriteStream(destFile);
await pipeline(readStream, writeStream);
}
/**
* Ensures that the given directory exists, creating it if it does not.
* @param dirPath - The path of the directory to check and create.
*/
private async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
await fs.promises.mkdir(dirPath, { recursive: true });
} catch (error) {
if (error instanceof Error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== "EEXIST") {
throw nodeError;
}
} else {
throw error;
}
}
}
/**
* Provides a description of the action.
* @returns A string description of the action.
*/
describe(): string {
return "Copies a file from a source location to a destination directory, ensuring directories exist and handling errors gracefully.";
}
}
// ============================================================================
// Export
// ============================================================================
// export default FileCopyAction;