Source: utils/files.js

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')))
  })
}