Files
hyperframes/packages/cli/src/utils/download.ts
T

71 lines
2.0 KiB
TypeScript

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<void> {
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);
});
}