(
url: string,
destPath: string,
options: DownloadOptions = {},
)
| 37 | */ |
| 38 | // fallow-ignore-next-line complexity |
| 39 | export async function downloadToFile( |
| 40 | url: string, |
| 41 | destPath: string, |
| 42 | options: DownloadOptions = {}, |
| 43 | ): Promise<DownloadResult> { |
| 44 | const fetchImpl = options.fetchImpl ?? fetch; |
| 45 | const res = await fetchImpl(url, { signal: options.signal }); |
| 46 | if (!res.ok) { |
| 47 | throw new Error(`Failed to download ${url}: HTTP ${res.status} ${res.statusText}`); |
| 48 | } |
| 49 | if (!res.body) { |
| 50 | throw new Error(`Failed to download ${url}: empty response body`); |
| 51 | } |
| 52 | |
| 53 | mkdirSync(dirname(destPath), { recursive: true }); |
| 54 | |
| 55 | const totalHeader = res.headers.get("content-length"); |
| 56 | const total = totalHeader ? Number.parseInt(totalHeader, 10) : undefined; |
| 57 | const totalOpt = total !== undefined && Number.isFinite(total) ? total : undefined; |
| 58 | |
| 59 | const file = createWriteStream(destPath); |
| 60 | let bytes = 0; |
| 61 | let errored = false; |
| 62 | try { |
| 63 | for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) { |
| 64 | if (options.signal?.aborted) { |
| 65 | throw options.signal.reason instanceof Error |
| 66 | ? options.signal.reason |
| 67 | : new Error("Download aborted"); |
| 68 | } |
| 69 | bytes += chunk.byteLength; |
| 70 | options.onProgress?.(bytes, totalOpt); |
| 71 | if (!file.write(chunk)) { |
| 72 | await waitForDrain(file, options.signal); |
| 73 | } |
| 74 | } |
| 75 | if (totalOpt !== undefined && bytes !== totalOpt) { |
| 76 | throw new Error( |
| 77 | `Truncated download: got ${bytes} bytes, expected ${totalOpt} (content-length). ` + |
| 78 | `The presigned URL may have expired mid-transfer — refetch via \`hyperframes cloud get\`.`, |
| 79 | ); |
| 80 | } |
| 81 | } catch (err) { |
| 82 | errored = true; |
| 83 | throw err; |
| 84 | } finally { |
| 85 | await closeFile(file); |
| 86 | if (errored) { |
| 87 | // Don't let a partial file pose as the final artifact. Best- |
| 88 | // effort unlink — if it fails (already gone, permission), we |
| 89 | // re-throw the original error. |
| 90 | try { |
| 91 | unlinkSync(destPath); |
| 92 | } catch { |
| 93 | /* swallow */ |
| 94 | } |
| 95 | } |
| 96 | } |
no test coverage detected