590 lines
19 KiB
TypeScript
590 lines
19 KiB
TypeScript
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<SmartArchive> {
|
|
const smartArchiveInstance = new SmartArchive();
|
|
smartArchiveInstance.sourceUrl = urlArg;
|
|
return smartArchiveInstance;
|
|
}
|
|
|
|
/**
|
|
* Create SmartArchive from a local file path
|
|
*/
|
|
public static async fromFile(filePathArg: string): Promise<SmartArchive> {
|
|
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<SmartArchive> {
|
|
const smartArchiveInstance = new SmartArchive();
|
|
smartArchiveInstance.sourceStream = streamArg;
|
|
return smartArchiveInstance;
|
|
}
|
|
|
|
/**
|
|
* Create SmartArchive from an in-memory buffer
|
|
*/
|
|
public static async fromBuffer(buffer: Buffer): Promise<SmartArchive> {
|
|
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<SmartArchive> {
|
|
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<SmartArchive> {
|
|
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<Buffer> {
|
|
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<SmartArchive> {
|
|
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<plugins.stream.Readable> {
|
|
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<Buffer> {
|
|
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<void> {
|
|
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<IArchiveExtractionOptions>
|
|
): Promise<void> {
|
|
const done = plugins.smartpromise.defer<void>();
|
|
const streamFileStream = await this.extractToStream();
|
|
|
|
streamFileStream.pipe(
|
|
new plugins.smartstream.SmartDuplex({
|
|
objectMode: true,
|
|
writeFunction: async (streamFileArg: plugins.smartfile.StreamFile) => {
|
|
const innerDone = plugins.smartpromise.defer<void>();
|
|
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<plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>> {
|
|
const streamFileIntake = new plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>({
|
|
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<IAnalyzedResult, void>(
|
|
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<plugins.smartfile.SmartFile[]> {
|
|
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<plugins.smartfile.SmartFile | null> {
|
|
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<IArchiveInfo> {
|
|
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<IArchiveEntryInfo[]> {
|
|
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<boolean> {
|
|
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<Buffer> {
|
|
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);
|
|
});
|
|
}
|
|
}
|