BREAKING CHANGE(SmartArchive): Refactor public API: rename factory/extraction methods, introduce typed interfaces and improved compression tools

This commit is contained in:
2025-11-25 12:32:13 +00:00
parent e609c023bc
commit 2b91c1586c
17 changed files with 1554 additions and 557 deletions

View File

@@ -1,75 +1,267 @@
import * as plugins from './plugins.js';
import * as paths from './paths.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';
import {
ArchiveAnalyzer,
type IAnalyzedResult,
} from './classes.archiveanalyzer.js';
import type { from } from '@push.rocks/smartrx/dist_ts/smartrx.plugins.rxjs.js';
/**
* Main class for archive manipulation
* Supports TAR, ZIP, GZIP, and BZIP2 formats
*/
export class SmartArchive {
// STATIC
public static async fromArchiveUrl(urlArg: string): Promise<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;
}
public static async fromArchiveFile(
filePathArg: string,
): Promise<SmartArchive> {
/**
* Create SmartArchive from a local file path
*/
public static async fromFile(filePathArg: string): Promise<SmartArchive> {
const smartArchiveInstance = new SmartArchive();
smartArchiveInstance.sourceFilePath = filePathArg;
return smartArchiveInstance;
}
public static async fromArchiveStream(
streamArg:
| plugins.stream.Readable
| plugins.stream.Duplex
| plugins.stream.Transform,
/**
* 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;
}
// INSTANCE
/**
* 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;
public sourceUrl?: string;
public sourceFilePath?: string;
public sourceStream?: plugins.stream.Readable | plugins.stream.Duplex | plugins.stream.Transform;
public archiveName: string;
public singleFileMode: boolean = false;
public addedDirectories: string[] = [];
public addedFiles: (
| plugins.smartfile.SmartFile
| plugins.smartfile.StreamFile
)[] = [];
public addedUrls: string[] = [];
private archiveBuffer?: Buffer;
private creationOptions?: IArchiveCreationOptions;
private pendingEntries?: IArchiveEntry[];
constructor() {}
// ============================================
// BUILDER METHODS (for incremental creation)
// ============================================
/**
* gets the original archive stream
* Add a file to the archive (builder pattern)
*/
public async getArchiveStream() {
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;
}
@@ -78,161 +270,320 @@ export class SmartArchive {
.url(this.sourceUrl)
.get();
const webStream = response.stream();
// @ts-ignore - Web stream to Node.js stream conversion
const urlStream = plugins.stream.Readable.fromWeb(webStream);
return urlStream;
return plugins.stream.Readable.fromWeb(webStream as any);
}
if (this.sourceFilePath) {
const fileStream = plugins.fs.createReadStream(this.sourceFilePath);
return fileStream;
return plugins.fs.createReadStream(this.sourceFilePath);
}
throw new Error('No archive source configured');
}
public async exportToTarGzStream() {
const tarPackStream = await this.tarTools.getPackStream();
const gzipStream = await this.gzipTools.getCompressionStream();
// const archiveStream = tarPackStream.pipe(gzipStream);
// return archiveStream;
/**
* 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);
}
public async exportToFs(
/**
* 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,
fileNameArg?: string,
options?: Partial<IArchiveExtractionOptions>
): Promise<void> {
const done = plugins.smartpromise.defer<void>();
const streamFileStream = await this.exportToStreamOfStreamFiles();
const streamFileStream = await this.extractToStream();
streamFileStream.pipe(
new plugins.smartstream.SmartDuplex({
objectMode: true,
writeFunction: async (
streamFileArg: plugins.smartfile.StreamFile,
streamtools,
) => {
const done = plugins.smartpromise.defer<void>();
console.log(
streamFileArg.relativeFilePath
? streamFileArg.relativeFilePath
: 'no relative path',
);
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,
streamFile.relativeFilePath || fileNameArg,
);
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', () => {
done.resolve();
innerDone.resolve();
});
await done.promise;
await innerDone.promise;
},
finalFunction: async () => {
done.resolve();
},
}),
})
);
return done.promise;
}
public async exportToStreamOfStreamFiles() {
const streamFileIntake =
new plugins.smartstream.StreamIntake<plugins.smartfile.StreamFile>({
objectMode: true,
});
const archiveStream = await this.getArchiveStream();
/**
* 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();
// lets create a function that can be called multiple times to unpack layers of archives
const createUnpackStream = () =>
plugins.smartstream.createTransformFunction<IAnalyzedResult, any>(
plugins.smartstream.createTransformFunction<IAnalyzedResult, void>(
async (analyzedResultChunk) => {
if (analyzedResultChunk.fileType?.mime === 'application/x-tar') {
const tarStream =
analyzedResultChunk.decompressionStream as plugins.tarStream.Extract;
const tarStream = analyzedResultChunk.decompressionStream as plugins.tarStream.Extract;
tarStream.on('entry', async (header, stream, next) => {
if (header.type === 'directory') {
console.log(
`tar stream directory: ${header.name} ... skipping!`,
);
stream.resume(); // Consume directory stream
stream.resume();
stream.on('end', () => next());
return;
}
console.log(`tar stream file: ${header.name}`);
// Create a PassThrough stream to buffer the data
const passThrough = new plugins.stream.PassThrough();
const streamfile = plugins.smartfile.StreamFile.fromStream(
passThrough,
header.name,
);
// Push the streamfile immediately
const streamfile = plugins.smartfile.StreamFile.fromStream(passThrough, header.name);
streamFileIntake.push(streamfile);
// Pipe the tar entry stream to the passthrough
stream.pipe(passThrough);
// Move to next entry when this one ends
stream.on('end', () => {
passThrough.end();
next();
});
});
tarStream.on('finish', function () {
console.log('tar extraction finished');
// Only signal end if this is the final stream
streamFileIntake.signalEnd();
tarStream.on('finish', () => {
safeSignalEnd();
});
analyzedResultChunk.resultStream.pipe(
analyzedResultChunk.decompressionStream,
);
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,
streamtools,
) => {
writeFunction: async (streamFileArg: plugins.smartfile.StreamFile) => {
streamFileIntake.push(streamFileArg);
},
finalFunction: async () => {
streamFileIntake.signalEnd();
safeSignalEnd();
},
}),
})
);
} else if (
analyzedResultChunk.isArchive &&
analyzedResultChunk.decompressionStream
) {
} else if (analyzedResultChunk.isArchive && analyzedResultChunk.decompressionStream) {
// For nested archives (like gzip containing tar)
const nestedStream = analyzedResultChunk.resultStream
analyzedResultChunk.resultStream
.pipe(analyzedResultChunk.decompressionStream)
.pipe(createAnalyzedStream())
.pipe(createUnpackStream());
// Don't signal end here - let the nested unpacker handle it
} else {
const streamFile = plugins.smartfile.StreamFile.fromStream(
analyzedResultChunk.resultStream,
analyzedResultChunk.fileType?.ext,
analyzedResultChunk.fileType?.ext
);
streamFileIntake.push(streamFile);
streamFileIntake.signalEnd();
safeSignalEnd();
}
},
{
objectMode: true,
},
{ 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);
});
}
}