@xhmikosr/downloader
Version:
Download and extract files
151 lines (116 loc) • 3.76 kB
JavaScript
import {createHash} from 'node:crypto';
import events from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {buffer, text} from 'node:stream/consumers';
import {parse} from 'content-disposition';
import archiveType from '@xhmikosr/archive-type';
import decompress from '@xhmikosr/decompress';
import extName from 'ext-name';
import {fileTypeFromBuffer} from 'file-type';
import filenamify from 'filenamify';
import got from 'got';
const defaultGotOptions = {
responseType: 'buffer',
https: {
rejectUnauthorized: process.env.npm_config_strict_ssl !== 'false',
},
};
const getExtFromMime = response => {
const header = response.headers['content-type'];
if (!header) {
return null;
}
const exts = extName.mime(header.split(';')[0].trim());
return exts.length === 1 ? exts[0].ext : null;
};
const getFilename = async (response, data) => {
const header = response.headers['content-disposition'];
if (header) {
const parsed = parse(header);
if (parsed.parameters?.filename) {
return parsed.parameters.filename;
}
}
let filename = path.basename(new URL(response.requestUrl).pathname);
if (!path.extname(filename)) {
const fileType = await fileTypeFromBuffer(data);
const ext = fileType?.ext || getExtFromMime(response);
if (ext) {
filename = `${filename}.${ext}`;
}
}
return filename;
};
const filterEvents = async (emitter, event) => {
for await (const [message] of events.on(emitter, event)) {
if (message) {
return message;
}
}
};
const verifyHash = (data, hash) => {
if (!hash) {
return;
}
const colon = typeof hash === 'string' ? hash.indexOf(':') : -1;
const algorithm = colon === -1 ? '' : hash.slice(0, colon);
const value = colon === -1 ? '' : hash.slice(colon + 1);
if (!algorithm || !value) {
throw new Error('Invalid `hash` option, expected "<algorithm>:<hex>".');
}
let actual;
try {
actual = createHash(algorithm).update(data).digest('hex');
} catch {
throw new Error(`Unsupported hash algorithm: ${algorithm}`);
}
if (actual.toLowerCase() !== value.toLowerCase()) {
throw new Error(`Hash mismatch, expected ${algorithm} ${value.toLowerCase()} but got ${actual}`);
}
};
const mergeDefinedOptions = (defaults, overrides = {}) => {
const merged = {...defaults};
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) {
merged[key] = value;
}
}
return merged;
};
const download = (uri, output, options = {}) => {
if (typeof output === 'object') {
options = output;
output = null;
}
options = {
...options,
got: mergeDefinedOptions(defaultGotOptions, options.got),
decompress: options.decompress ?? {},
};
const stream = got.stream(uri, options.got);
const promise = (async () => {
const response = await filterEvents(stream, 'response');
const streamData = options.got.responseType === 'buffer' ? buffer(stream) : text(stream);
const data = await streamData;
verifyHash(data, options.hash);
const hasArchiveData = options.extract && await archiveType(data);
if (!output) {
return hasArchiveData ? decompress(data, options.decompress) : data;
}
const filename = options.filename || filenamify(await getFilename(response, data));
const outputFilepath = path.join(output, filename);
if (hasArchiveData) {
return decompress(data, path.dirname(outputFilepath), options.decompress);
}
await fs.mkdir(path.dirname(outputFilepath), {recursive: true});
await fs.writeFile(outputFilepath, data);
return data;
})();
// eslint-disable-next-line unicorn/no-thenable
stream.then = promise.then.bind(promise);
stream.catch = promise.catch.bind(promise);
return stream;
};
export default download;