import {extract, fs, https, path} from './contextuals'
import {createHash} from 'crypto'
/**
* Download a file from a url and report on its progress
* @param {string} downloadUrl
* @param {string} targetPath
* @param {function} onProgress
* @param {AbortSignal} abortSignal
* @returns {Promise<*>}
*/
export const download = async (downloadUrl, targetPath, onProgress = ({percent, size, duration, speed}) => {}, abortSignal) => {
return new Promise(async (resolve, reject) => {
// if the target path already exists, delete it
const deleteIfExists = (filePath) => {
return new Promise(res => {
fs.access(path.resolve(filePath), fs.constants.R_OK, (err) => {
if (!err) {
fs.unlink(path.resolve(filePath), res);
} else {
res();
}
})
})
}
if (abortSignal?.aborted) {
return reject(new Error('Download aborted before it started'));
}
// Listen for the abort signal
const abortHandler = () => {
if (download.response) {
download.response.destroy()
}
if (writeStream) {
writeStream.close();
}
fs.pathExists(actualTargetPath).then(exists => {
if (exists) {
fs.unlink(actualTargetPath, () => {})
}
})
reject(new Error('Download aborted by the user'));
};
if (abortSignal) {
abortSignal.addEventListener('abort', abortHandler);
}
const isArchive = targetPath.includes('.zip');
const partialContentFile = path.resolve(targetPath.replace('.zip', '.part'));
const actualTargetPath = isArchive ? partialContentFile : targetPath;
if (isArchive) {
await deleteIfExists(partialContentFile)
}
await deleteIfExists(targetPath);
await deleteIfExists(actualTargetPath);
try {
await fs.ensureFile(actualTargetPath);
} catch (err) {
reject(new Error(`Failed to ensure downloaded files can exist: ${err.toString()}`));
}
const startTime = Date.now();
const writeStream = fs.createWriteStream(actualTargetPath)
const speedMeasurements = [];
const download = {
response: null
}
let receivedBytes = 0
let failedToWrite = false
let lastProgress = 0;
writeStream.on('error', (err) => {
writeStream.end()
failedToWrite = true
reject(new Error(`Failed to write data to the partial archive on disk: ${err.toString()}`));
})
https.get(downloadUrl, {}, (response) => {
const contentLength = response.headers['content-length'];
const contentType = response.headers['content-type'];
const statusCode = response.statusCode
if (statusCode !== 200) {
reject(new Error(`Failed to download archive, received non-200 http status code`))
return
}
if (isArchive && contentType !== 'application/zip' && contentType !== 'application/x-zip-compressed') {
reject(new Error(`Failed to download archive, content-type does not meet requirements: ${contentType}`))
return
}
download.response = response
response.on('data', (chunk) => {
if (failedToWrite) {
return
}
writeStream.write(chunk)
receivedBytes += chunk.length
const currentProgress = Math.ceil((receivedBytes / contentLength) * 100);
if (currentProgress !== lastProgress) {
const timeElapsed = Date.now() - startTime;
const bitsTransferred = receivedBytes * 8; // number of bits transferred
const speedKbps = ((bitsTransferred / 1000) / (timeElapsed / 1000)).toFixed(2);
const speedMbps = (speedKbps / 1000).toFixed(2);
const speedGbps = (speedMbps / 1000).toFixed(2);
speedMeasurements.push(speedKbps);
const averageKbps = speedMeasurements.reduce((all, one, _, src) => all += one / src.length, 0)
const averageMbps = (averageKbps / 1000).toFixed(2);
const averageGbps = (averageMbps / 1000).toFixed(2);
onProgress({
percent: currentProgress,
size: contentLength,
duration: timeElapsed,
speed: {
kbps: {
average: averageKbps,
current: speedKbps
},
mbps: {
average: averageMbps,
current: speedMbps
},
gbps: {
average: averageGbps,
current: speedGbps
}
}
})
}
lastProgress = currentProgress;
})
response.on('error', err => {
reject(new Error(`Error receiving download response: ${err.toString()}`));
})
response.on('end', () => {
if (abortSignal) {
abortSignal.removeEventListener('abort', abortHandler)
}
writeStream.close()
if (isArchive) {
fs.rename(partialContentFile, targetPath, err => {
if (err) {
reject(new Error(`Error renaming partial archive to .zip: ${err.toString()}`));
} else {
resolve();
}
});
} else {
resolve();
}
})
}).on('error', err => {
if (abortSignal) {
abortSignal.removeEventListener('abort', abortHandler)
}
reject(err)
})
})
}
/**
* Extract the contents of a zip file into a directory and report on its progress.
* @param {string} src
* @param {string} dest
* @returns {Promise<*>}
*/
export const unzip = async (src, dest) => {
return extract(src, { dir: path.resolve(path.normalize(dest)) });
}
/**
* Stream file chunks and create a md5 hash.
* @param {string} filePath
* @returns {Promise<string>}
*/
export const fileToMd5 = async (filePath) => {
return new Promise((resolve, reject) => {
const hash = createHash('md5')
const stream = fs.createReadStream(filePath)
stream.on('error', err => reject(err))
stream.on('data', chunk => hash.update(chunk, 'utf8'))
stream.on('end', () => resolve(hash.digest('hex')))
})
}