feat: add streamArchiveEntry to archiveReader and tests

- Añade `streamArchiveEntry` que devuelve un stream para entradas internas de ZIP/7z
- Añade tests unitarios que mockean `child_process.spawn` (7z + unzip fallback)
- Mantiene `listArchiveEntries` y documenta dependencia de binarios en CI
This commit is contained in:
2026-02-09 19:15:55 +01:00
parent a702310da4
commit ab63361e66
5 changed files with 276 additions and 133 deletions

View File

@@ -15,7 +15,7 @@
* las llamadas a `child_process.exec`.
*/
import path from 'path';
import { exec } from 'child_process';
import { exec, spawn } from 'child_process';
export type ArchiveEntry = { name: string; size: number };
@@ -86,4 +86,92 @@ export async function listArchiveEntries(
}
}
export default { listArchiveEntries };
export async function streamArchiveEntry(
filePath: string,
entryPath: string,
logger: { warn?: (...args: any[]) => void } = console
): Promise<NodeJS.ReadableStream | null> {
const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
if (!['zip', '7z'].includes(ext)) return null;
const waitForStreamOrError = (proc: any): Promise<NodeJS.ReadableStream | null> =>
new Promise((resolve) => {
let settled = false;
const onProcError = () => {
if (settled) return;
settled = true;
resolve(null);
};
const onStdoutError = () => {
if (settled) return;
settled = true;
resolve(null);
};
const onData = () => {
if (settled) return;
settled = true;
try {
proc.removeListener('error', onProcError);
} catch (e) {}
if (proc.stdout && proc.stdout.removeListener) {
try {
proc.stdout.removeListener('error', onStdoutError);
proc.stdout.removeListener('readable', onData);
proc.stdout.removeListener('data', onData);
} catch (e) {}
}
resolve(proc.stdout);
};
proc.once('error', onProcError);
if (proc.stdout && proc.stdout.once) {
proc.stdout.once('error', onStdoutError);
proc.stdout.once('readable', onData);
proc.stdout.once('data', onData);
} else {
// no stdout available
resolve(null);
}
proc.once('close', () => {
if (!settled) {
settled = true;
resolve(null);
}
});
});
// Try 7z first
try {
let proc: any;
try {
proc = spawn('7z', ['x', '-so', filePath, entryPath]);
} catch (err) {
throw err;
}
const stream = await waitForStreamOrError(proc);
if (stream) return stream;
} catch (err) {
logger.warn?.({ err, filePath }, 'archiveReader: 7z spawn failed');
}
// Fallback for zip
if (ext === 'zip') {
try {
const proc2: any = spawn('unzip', ['-p', filePath, entryPath]);
const stream2 = await waitForStreamOrError(proc2);
if (stream2) return stream2;
} catch (err2) {
logger.warn?.({ err2, filePath }, 'archiveReader: unzip spawn failed');
}
}
return null;
}
export default { listArchiveEntries, streamArchiveEntry };