import * as plugins from './plugins.js'; import type { IArchiveCreationOptions, IArchiveEntry, IArchiveExtractionOptions, IArchiveEntryInfo, IArchiveInfo, TArchiveFormat, TCompressionLevel, } from './interfaces.js'; import { Bzip2Tools } from './classes.bzip2tools.js'; import { GzipTools } from './classes.gziptools.js'; import { TarTools } from './classes.tartools.js'; import { ZipTools } from './classes.ziptools.js'; import { ArchiveAnalyzer, type IAnalyzedResult } from './classes.archiveanalyzer.js'; /** * Main class for archive manipulation * Supports TAR, ZIP, GZIP, and BZIP2 formats */ export class SmartArchive { // ============================================ // STATIC FACTORY METHODS - EXTRACTION // ============================================ /** * Create SmartArchive from a URL */ public static async fromUrl(urlArg: string): Promise { const smartArchiveInstance = new SmartArchive(); smartArchiveInstance.sourceUrl = urlArg; return smartArchiveInstance; } /** * Create SmartArchive from a local file path */ public static async fromFile(filePathArg: string): Promise { const smartArchiveInstance = new SmartArchive(); smartArchiveInstance.sourceFilePath = filePathArg; return smartArchiveInstance; } /** * Create SmartArchive from a readable stream */ public static async fromStream( streamArg: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform ): Promise { const smartArchiveInstance = new SmartArchive(); smartArchiveInstance.sourceStream = streamArg; return smartArchiveInstance; } /** * Create SmartArchive from an in-memory buffer */ public static async fromBuffer(buffer: Buffer): Promise { const smartArchiveInstance = new SmartArchive(); smartArchiveInstance.sourceStream = plugins.stream.Readable.from(buffer); return smartArchiveInstance; } // ============================================ // STATIC FACTORY METHODS - CREATION // ============================================ /** * Create a new archive from a directory */ public static async fromDirectory( directoryPath: string, options: IArchiveCreationOptions ): Promise { const smartArchiveInstance = new SmartArchive(); smartArchiveInstance.creationOptions = options; const tarTools = new TarTools(); if (options.format === 'tar' || options.format === 'tar.gz' || options.format === 'tgz') { if (options.format === 'tar') { const pack = await tarTools.packDirectory(directoryPath); pack.finalize(); smartArchiveInstance.archiveBuffer = await SmartArchive.streamToBuffer(pack); } else { smartArchiveInstance.archiveBuffer = await tarTools.packDirectoryToTarGz( directoryPath, options.compressionLevel ); } } else if (options.format === 'zip') { const zipTools = new ZipTools(); const fileTree = await plugins.listFileTree(directoryPath, '**/*'); const entries: IArchiveEntry[] = []; for (const filePath of fileTree) { const absolutePath = plugins.path.join(directoryPath, filePath); const content = await plugins.fsPromises.readFile(absolutePath); entries.push({ archivePath: filePath, content, }); } smartArchiveInstance.archiveBuffer = await zipTools.createZip(entries, options.compressionLevel); } else { throw new Error(`Unsupported format for directory packing: ${options.format}`); } return smartArchiveInstance; } /** * Create a new archive from an array of entries */ public static async fromFiles( files: IArchiveEntry[], options: IArchiveCreationOptions ): Promise { const smartArchiveInstance = new SmartArchive(); smartArchiveInstance.creationOptions = options; if (options.format === 'tar' || options.format === 'tar.gz' || options.format === 'tgz') { const tarTools = new TarTools(); if (options.format === 'tar') { smartArchiveInstance.archiveBuffer = await tarTools.packFiles(files); } else { smartArchiveInstance.archiveBuffer = await tarTools.packFilesToTarGz(files, options.compressionLevel); } } else if (options.format === 'zip') { const zipTools = new ZipTools(); smartArchiveInstance.archiveBuffer = await zipTools.createZip(files, options.compressionLevel); } else if (options.format === 'gz') { if (files.length !== 1) { throw new Error('GZIP format only supports a single file'); } const gzipTools = new GzipTools(); let content: Buffer; if (typeof files[0].content === 'string') { content = Buffer.from(files[0].content); } else if (Buffer.isBuffer(files[0].content)) { content = files[0].content; } else { throw new Error('GZIP format requires string or Buffer content'); } smartArchiveInstance.archiveBuffer = await gzipTools.compress(content, options.compressionLevel); } else { throw new Error(`Unsupported format: ${options.format}`); } return smartArchiveInstance; } /** * Start building an archive incrementally using a builder pattern */ public static create(options: IArchiveCreationOptions): SmartArchive { const smartArchiveInstance = new SmartArchive(); smartArchiveInstance.creationOptions = options; smartArchiveInstance.pendingEntries = []; return smartArchiveInstance; } /** * Helper to convert a stream to buffer */ private static async streamToBuffer(stream: plugins.stream.Readable): Promise { const chunks: Buffer[] = []; return new Promise((resolve, reject) => { stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on('error', reject); }); } // ============================================ // INSTANCE PROPERTIES // ============================================ public tarTools = new TarTools(); public zipTools = new ZipTools(); public gzipTools = new GzipTools(); public bzip2Tools = new Bzip2Tools(this); public archiveAnalyzer = new ArchiveAnalyzer(this); public sourceUrl?: string; public sourceFilePath?: string; public sourceStream?: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform; private archiveBuffer?: Buffer; private creationOptions?: IArchiveCreationOptions; private pendingEntries?: IArchiveEntry[]; constructor() {} // ============================================ // BUILDER METHODS (for incremental creation) // ============================================ /** * Add a file to the archive (builder pattern) */ public addFile(archivePath: string, content: string | Buffer): this { if (!this.pendingEntries) { throw new Error('addFile can only be called on archives created with SmartArchive.create()'); } this.pendingEntries.push({ archivePath, content }); return this; } /** * Add a SmartFile to the archive (builder pattern) */ public addSmartFile(file: plugins.smartfile.SmartFile, archivePath?: string): this { if (!this.pendingEntries) { throw new Error('addSmartFile can only be called on archives created with SmartArchive.create()'); } this.pendingEntries.push({ archivePath: archivePath || file.relative, content: file, }); return this; } /** * Add a StreamFile to the archive (builder pattern) */ public addStreamFile(file: plugins.smartfile.StreamFile, archivePath?: string): this { if (!this.pendingEntries) { throw new Error('addStreamFile can only be called on archives created with SmartArchive.create()'); } this.pendingEntries.push({ archivePath: archivePath || file.relativeFilePath, content: file, }); return this; } /** * Build the archive from pending entries */ public async build(): Promise { if (!this.pendingEntries || !this.creationOptions) { throw new Error('build can only be called on archives created with SmartArchive.create()'); } const built = await SmartArchive.fromFiles(this.pendingEntries, this.creationOptions); this.archiveBuffer = built.archiveBuffer; this.pendingEntries = undefined; return this; } // ============================================ // EXTRACTION METHODS // ============================================ /** * Get the original archive stream */ public async toStream(): Promise { if (this.archiveBuffer) { return plugins.stream.Readable.from(this.archiveBuffer); } if (this.sourceStream) { return this.sourceStream; } if (this.sourceUrl) { const response = await plugins.smartrequest.SmartRequest.create() .url(this.sourceUrl) .get(); const webStream = response.stream(); return plugins.stream.Readable.fromWeb(webStream as any); } if (this.sourceFilePath) { return plugins.fs.createReadStream(this.sourceFilePath); } throw new Error('No archive source configured'); } /** * Get archive as a Buffer */ public async toBuffer(): Promise { if (this.archiveBuffer) { return this.archiveBuffer; } const stream = await this.toStream(); return SmartArchive.streamToBuffer(stream); } /** * Write archive to a file */ public async toFile(filePath: string): Promise { const buffer = await this.toBuffer(); await plugins.fsPromises.mkdir(plugins.path.dirname(filePath), { recursive: true }); await plugins.fsPromises.writeFile(filePath, buffer); } /** * Extract archive to filesystem */ public async extractToDirectory( targetDir: string, options?: Partial ): Promise { const done = plugins.smartpromise.defer(); const streamFileStream = await this.extractToStream(); streamFileStream.pipe( new plugins.smartstream.SmartDuplex({ objectMode: true, writeFunction: async (streamFileArg: plugins.smartfile.StreamFile) => { const innerDone = plugins.smartpromise.defer(); const streamFile = streamFileArg; let relativePath = streamFile.relativeFilePath || options?.fileName || 'extracted_file'; // Apply stripComponents if specified if (options?.stripComponents && options.stripComponents > 0) { const parts = relativePath.split('/'); relativePath = parts.slice(options.stripComponents).join('/'); if (!relativePath) { innerDone.resolve(); return; } } // Apply filter if specified if (options?.filter) { const entryInfo: IArchiveEntryInfo = { path: relativePath, size: 0, isDirectory: false, isFile: true, }; if (!options.filter(entryInfo)) { innerDone.resolve(); return; } } const readStream = await streamFile.createReadStream(); await plugins.fsPromises.mkdir(targetDir, { recursive: true }); const writePath = plugins.path.join(targetDir, relativePath); await plugins.fsPromises.mkdir(plugins.path.dirname(writePath), { recursive: true }); const writeStream = plugins.fs.createWriteStream(writePath); readStream.pipe(writeStream); writeStream.on('finish', () => { innerDone.resolve(); }); await innerDone.promise; }, finalFunction: async () => { done.resolve(); }, }) ); return done.promise; } /** * Extract archive to a stream of StreamFile objects */ public async extractToStream(): Promise> { const streamFileIntake = new plugins.smartstream.StreamIntake({ objectMode: true, }); // Guard to prevent multiple signalEnd calls let hasSignaledEnd = false; const safeSignalEnd = () => { if (!hasSignaledEnd) { hasSignaledEnd = true; streamFileIntake.signalEnd(); } }; const archiveStream = await this.toStream(); const createAnalyzedStream = () => this.archiveAnalyzer.getAnalyzedStream(); const createUnpackStream = () => plugins.smartstream.createTransformFunction( async (analyzedResultChunk) => { if (analyzedResultChunk.fileType?.mime === 'application/x-tar') { const tarStream = analyzedResultChunk.decompressionStream as plugins.tarStream.Extract; tarStream.on('entry', async (header, stream, next) => { if (header.type === 'directory') { stream.resume(); stream.on('end', () => next()); return; } const passThrough = new plugins.stream.PassThrough(); const streamfile = plugins.smartfile.StreamFile.fromStream(passThrough, header.name); streamFileIntake.push(streamfile); stream.pipe(passThrough); stream.on('end', () => { passThrough.end(); next(); }); }); tarStream.on('finish', () => { safeSignalEnd(); }); analyzedResultChunk.resultStream.pipe(analyzedResultChunk.decompressionStream); } else if (analyzedResultChunk.fileType?.mime === 'application/zip') { analyzedResultChunk.resultStream .pipe(analyzedResultChunk.decompressionStream) .pipe( new plugins.smartstream.SmartDuplex({ objectMode: true, writeFunction: async (streamFileArg: plugins.smartfile.StreamFile) => { streamFileIntake.push(streamFileArg); }, finalFunction: async () => { safeSignalEnd(); }, }) ); } else if (analyzedResultChunk.isArchive && analyzedResultChunk.decompressionStream) { // For nested archives (like gzip containing tar) analyzedResultChunk.resultStream .pipe(analyzedResultChunk.decompressionStream) .pipe(createAnalyzedStream()) .pipe(createUnpackStream()); } else { const streamFile = plugins.smartfile.StreamFile.fromStream( analyzedResultChunk.resultStream, analyzedResultChunk.fileType?.ext ); streamFileIntake.push(streamFile); safeSignalEnd(); } }, { objectMode: true } ); archiveStream.pipe(createAnalyzedStream()).pipe(createUnpackStream()); return streamFileIntake; } /** * Extract archive to an array of SmartFile objects (in-memory) */ public async extractToSmartFiles(): Promise { const streamFiles = await this.extractToStream(); const smartFiles: plugins.smartfile.SmartFile[] = []; return new Promise((resolve, reject) => { streamFiles.on('data', async (streamFile: plugins.smartfile.StreamFile) => { try { const smartFile = await streamFile.toSmartFile(); smartFiles.push(smartFile); } catch (err) { reject(err); } }); streamFiles.on('end', () => resolve(smartFiles)); streamFiles.on('error', reject); }); } /** * Extract a single file from the archive by path */ public async extractFile(filePath: string): Promise { const streamFiles = await this.extractToStream(); return new Promise((resolve, reject) => { let found = false; streamFiles.on('data', async (streamFile: plugins.smartfile.StreamFile) => { if (streamFile.relativeFilePath === filePath || streamFile.relativeFilePath?.endsWith(filePath)) { found = true; try { const smartFile = await streamFile.toSmartFile(); resolve(smartFile); } catch (err) { reject(err); } } }); streamFiles.on('end', () => { if (!found) { resolve(null); } }); streamFiles.on('error', reject); }); } // ============================================ // ANALYSIS METHODS // ============================================ /** * Analyze the archive and return metadata */ public async analyze(): Promise { const stream = await this.toStream(); const firstChunk = await this.readFirstChunk(stream); const fileType = await plugins.fileType.fileTypeFromBuffer(firstChunk); let format: TArchiveFormat | null = null; let isCompressed = false; let isArchive = false; if (fileType) { switch (fileType.mime) { case 'application/gzip': format = 'gz'; isCompressed = true; isArchive = true; break; case 'application/zip': format = 'zip'; isCompressed = true; isArchive = true; break; case 'application/x-tar': format = 'tar'; isArchive = true; break; case 'application/x-bzip2': format = 'bz2'; isCompressed = true; isArchive = true; break; } } return { format, isCompressed, isArchive, }; } /** * List all entries in the archive without extracting */ public async listEntries(): Promise { const entries: IArchiveEntryInfo[] = []; const streamFiles = await this.extractToStream(); return new Promise((resolve, reject) => { streamFiles.on('data', (streamFile: plugins.smartfile.StreamFile) => { entries.push({ path: streamFile.relativeFilePath || 'unknown', size: 0, // Size not available without reading isDirectory: false, isFile: true, }); }); streamFiles.on('end', () => resolve(entries)); streamFiles.on('error', reject); }); } /** * Check if a specific file exists in the archive */ public async hasFile(filePath: string): Promise { const entries = await this.listEntries(); return entries.some((e) => e.path === filePath || e.path.endsWith(filePath)); } /** * Helper to read first chunk from stream */ private async readFirstChunk(stream: plugins.stream.Readable): Promise { return new Promise((resolve, reject) => { const onData = (chunk: Buffer) => { stream.removeListener('data', onData); stream.removeListener('error', reject); resolve(chunk); }; stream.on('data', onData); stream.on('error', reject); }); } }