import { createWriteStream, renameSync, unlinkSync } from "node:fs"; import { get as httpsGet } from "node:https"; import { pipeline } from "node:stream/promises"; const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000; export interface DownloadOptions { /** Abort after this many milliseconds without network activity. */ timeoutMs?: number; } function removePartialFile(path: string): void { try { unlinkSync(path); } catch { // Missing/locked partial files are handled by the next atomic download. } } /** * Download a file from a URL, following redirects. * Uses atomic write (download to .tmp, rename on success) to prevent * corrupt partial files from persisting in the cache on interruption. */ export function downloadFile( url: string, dest: string, options: DownloadOptions = {}, ): Promise { const tmp = `${dest}.tmp`; const timeoutMs = options.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; return new Promise((resolve, reject) => { const follow = (u: string) => { const request = httpsGet(u, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { const location = res.headers.location; if (location) { res.resume(); follow(location); return; } } if (res.statusCode !== 200) { res.resume(); removePartialFile(tmp); reject(new Error(`Download failed: HTTP ${res.statusCode}`)); return; } const file = createWriteStream(tmp); pipeline(res, file) .then(() => { renameSync(tmp, dest); resolve(); }) .catch((err) => { removePartialFile(tmp); reject(err); }); }); request.setTimeout(timeoutMs, () => { request.destroy(new Error(`Download timed out after ${timeoutMs}ms`)); }); request.on("error", (err) => { removePartialFile(tmp); reject(err); }); }; follow(url); }); }