Skip to content

Commit 660b2e2

Browse files
authored
fix: prevent silent exit on premature download close (#195)
## Problem Downloads that get truncated or aborted mid-stream (e.g. a dropped connection during a Docker build) could cause `pkg-fetch` to **exit silently with an incomplete file** instead of failing loudly. The old `downloadUrl` piped `res.body` (from `node-fetch`) into the write stream and only watched the **destination** via `stream.finished(ws, ...)`. A premature close errors on the **source**, which never propagated to the destination's `finished` callback — so the promise settled as success and a partial/corrupt asset was left behind. ## Fix Stream the response body through `stream/promises` `pipeline(body, ws)`, which propagates errors from **both** source and destination. A premature close now rejects loudly and the partial `.downloading` tempfile is cleaned up. While here, migrate from `node-fetch` + `https-proxy-agent` to **undici**: - `undici` is what Node's native `fetch` is built on (maintained by the Node team). - Net **fewer** dependencies: removes `node-fetch`, `https-proxy-agent`, and `@types/node-fetch`; adds one runtime dep (`undici`, self-typed). - `ProxyAgent` preserves existing `HTTPS_PROXY`/`HTTP_PROXY` support — native `fetch` cannot proxy on its own, so undici is required regardless. - `@types/node` bumped to `^20` for `Readable.fromWeb`. ## Verification - `yarn build` ✅   `yarn lint` ✅ - Functional test against a local HTTP server (no Node build): - **Full download** → correct SHA-256, tempfile cleaned. ✅ - **Truncated download** (Content-Length claims full size, socket destroyed mid-stream) → now **rejects** (`TypeError: terminated`) and removes the `.downloading` tempfile, instead of silently resolving. ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 1a783a2 commit 660b2e2

3 files changed

Lines changed: 76 additions & 100 deletions

File tree

lib/utils.ts

Lines changed: 56 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import fetch from 'node-fetch';
1+
import { fetch, ProxyAgent } from 'undici';
22
import crypto from 'crypto';
33
import { createReadStream, createWriteStream, mkdirSync, renameSync, rmSync } from 'fs';
44
import { chmod, stat } from 'fs/promises';
5-
import httpsProxyAgent from 'https-proxy-agent';
65
import path from 'path';
6+
import { Readable } from 'stream';
7+
import { pipeline } from 'stream/promises';
78
import { spawnSync, SpawnSyncOptions } from 'child_process';
8-
import stream from 'stream';
99

1010
import { coerce } from 'semver';
1111
import { log, wasReported } from './log';
@@ -20,51 +20,66 @@ export async function downloadUrl(url: string, file: string): Promise<void> {
2020
process.env.HTTP_PROXY ??
2121
process.env.http_proxy;
2222

23-
let res;
23+
// Created once and closed in `finally` so its connection pool never leaks,
24+
// even across the multiple throw paths below.
25+
const dispatcher = proxy ? new ProxyAgent(proxy) : undefined;
26+
2427
try {
25-
res = await fetch(
26-
url,
27-
proxy ? { agent: httpsProxyAgent(proxy) } : undefined
28-
);
29-
} catch (err) {
30-
log.disableProgress();
31-
throw wasReported(`Network error during fetch: ${(err as Error).message}`);
32-
}
28+
let res;
29+
try {
30+
res = await fetch(url, dispatcher ? { dispatcher } : undefined);
31+
} catch (err) {
32+
log.disableProgress();
33+
throw wasReported(
34+
`Network error during fetch: ${(err as Error).message}`
35+
);
36+
}
3337

34-
if (!res.ok) {
35-
log.disableProgress();
36-
throw wasReported(`${res.status}: ${res.statusText}`);
37-
}
38+
if (!res.ok) {
39+
log.disableProgress();
40+
throw wasReported(`${res.status}: ${res.statusText}`);
41+
}
3842

39-
const tempFile = `${file}.downloading`;
40-
mkdirSync(path.dirname(tempFile), { recursive: true });
41-
const ws = createWriteStream(tempFile);
43+
if (!res.body) {
44+
log.disableProgress();
45+
throw wasReported(`Empty response body for ${url}`);
46+
}
4247

43-
const totalSize = Number(res.headers.get('content-length'));
44-
let currentSize = 0;
48+
const tempFile = `${file}.downloading`;
49+
mkdirSync(path.dirname(tempFile), { recursive: true });
50+
const ws = createWriteStream(tempFile);
4551

46-
res.body.on('data', (chunk: Buffer) => {
47-
if (totalSize != null && totalSize !== 0) {
48-
currentSize += chunk.length;
49-
log.showProgress((currentSize / totalSize) * 100);
50-
}
51-
});
52-
res.body.pipe(ws);
53-
54-
return new Promise<void>((resolve, reject) => {
55-
stream.finished(ws, (err) => {
56-
if (err) {
57-
log.disableProgress();
58-
rmSync(tempFile);
59-
reject(wasReported(`${err.name}: ${err.message}`));
60-
} else {
61-
log.showProgress(100);
62-
log.disableProgress();
63-
renameSync(tempFile, file);
64-
resolve();
52+
const totalSize = Number(res.headers.get('content-length'));
53+
let currentSize = 0;
54+
55+
const body = Readable.fromWeb(res.body);
56+
body.on('data', (chunk: Buffer) => {
57+
// Falsy for both a missing header (`NaN`) and a zero length, avoiding a
58+
// `NaN%` progress reading.
59+
if (totalSize) {
60+
currentSize += chunk.length;
61+
log.showProgress((currentSize / totalSize) * 100);
6562
}
6663
});
67-
});
64+
65+
// `pipeline` propagates errors from the source (`body`) as well as the
66+
// destination (`ws`), so a truncated/aborted download rejects loudly
67+
// instead of leaving the promise unsettled and the process exiting
68+
// silently.
69+
try {
70+
await pipeline(body, ws);
71+
} catch (err) {
72+
log.disableProgress();
73+
rmSync(tempFile, { force: true });
74+
throw wasReported(`${(err as Error).name}: ${(err as Error).message}`);
75+
}
76+
77+
log.showProgress(100);
78+
log.disableProgress();
79+
renameSync(tempFile, file);
80+
} finally {
81+
await dispatcher?.close();
82+
}
6883
}
6984

7085
export async function hash(filePath: string): Promise<string> {

package.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,16 @@
1818
"patches/*"
1919
],
2020
"dependencies": {
21-
"https-proxy-agent": "^5.0.0",
22-
"node-fetch": "^2.6.6",
2321
"picocolors": "^1.1.0",
2422
"progress": "^2.0.3",
2523
"semver": "^7.3.5",
2624
"tar-fs": "^3.1.1",
25+
"undici": "^7.28.0",
2726
"yargs": "^16.2.0"
2827
},
2928
"devDependencies": {
3029
"@release-it/conventional-changelog": "^7.0.2",
31-
"@types/node": "^16.18.113",
32-
"@types/node-fetch": "^2.5.12",
30+
"@types/node": "^20.19.43",
3331
"@types/progress": "^2.0.5",
3432
"@types/semver": "^7.3.9",
3533
"@types/tar-fs": "^2.0.1",

yarn.lock

Lines changed: 18 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -287,23 +287,17 @@
287287
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
288288
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
289289

290-
"@types/node-fetch@^2.5.12":
291-
version "2.6.6"
292-
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.6.tgz#b72f3f4bc0c0afee1c0bc9cff68e041d01e3e779"
293-
integrity sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==
294-
dependencies:
295-
"@types/node" "*"
296-
form-data "^4.0.0"
297-
298290
"@types/node@*":
299291
version "20.8.2"
300292
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.2.tgz#d76fb80d87d0d8abfe334fc6d292e83e5524efc4"
301293
integrity sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==
302294

303-
"@types/node@^16.18.113":
304-
version "16.18.113"
305-
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.113.tgz#fbe99013933c4997db5838d20497494a7e01f4ab"
306-
integrity sha512-4jHxcEzSXpF1cBNxogs5FVbVSFSKo50sFCn7Xg7vmjJTbWFWgeuHW3QnoINlfmfG++MFR/q97RZE5RQXKeT+jg==
295+
"@types/node@^20.19.43":
296+
version "20.19.43"
297+
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.43.tgz#fcecf580ba42a0db55cf404c372c97973c376c97"
298+
integrity sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==
299+
dependencies:
300+
undici-types "~6.21.0"
307301

308302
"@types/normalize-package-data@^2.4.1":
309303
version "2.4.2"
@@ -447,13 +441,6 @@ add-stream@^1.0.0:
447441
resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa"
448442
integrity sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==
449443

450-
agent-base@6:
451-
version "6.0.2"
452-
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
453-
integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
454-
dependencies:
455-
debug "4"
456-
457444
agent-base@^7.0.2, agent-base@^7.1.0:
458445
version "7.1.0"
459446
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.0.tgz#536802b76bc0b34aa50195eb2442276d613e3434"
@@ -652,11 +639,6 @@ async-retry@1.3.3:
652639
dependencies:
653640
retry "0.13.1"
654641

655-
asynckit@^0.4.0:
656-
version "0.4.0"
657-
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
658-
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
659-
660642
available-typed-arrays@^1.0.5:
661643
version "1.0.5"
662644
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
@@ -976,13 +958,6 @@ colorette@^2.0.16:
976958
resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a"
977959
integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==
978960

979-
combined-stream@^1.0.8:
980-
version "1.0.8"
981-
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
982-
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
983-
dependencies:
984-
delayed-stream "~1.0.0"
985-
986961
commander@^6.2.0:
987962
version "6.2.1"
988963
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
@@ -1314,11 +1289,6 @@ degenerator@^5.0.0:
13141289
escodegen "^2.1.0"
13151290
esprima "^4.0.1"
13161291

1317-
delayed-stream@~1.0.0:
1318-
version "1.0.0"
1319-
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
1320-
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
1321-
13221292
deprecation@^2.0.0:
13231293
version "2.3.1"
13241294
resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
@@ -1883,15 +1853,6 @@ form-data-encoder@^2.1.2:
18831853
resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5"
18841854
integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==
18851855

1886-
form-data@^4.0.0:
1887-
version "4.0.0"
1888-
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
1889-
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
1890-
dependencies:
1891-
asynckit "^0.4.0"
1892-
combined-stream "^1.0.8"
1893-
mime-types "^2.1.12"
1894-
18951856
formdata-polyfill@^4.0.10:
18961857
version "4.0.10"
18971858
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
@@ -2243,14 +2204,6 @@ http2-wrapper@^2.1.10:
22432204
quick-lru "^5.1.1"
22442205
resolve-alpn "^1.2.0"
22452206

2246-
https-proxy-agent@^5.0.0:
2247-
version "5.0.1"
2248-
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
2249-
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
2250-
dependencies:
2251-
agent-base "6"
2252-
debug "4"
2253-
22542207
https-proxy-agent@^7.0.2:
22552208
version "7.0.2"
22562209
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz#e2645b846b90e96c6e6f347fb5b2e41f1590b09b"
@@ -2968,7 +2921,7 @@ mime-db@1.52.0:
29682921
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
29692922
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
29702923

2971-
mime-types@2.1.35, mime-types@^2.1.12:
2924+
mime-types@2.1.35:
29722925
version "2.1.35"
29732926
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
29742927
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
@@ -3070,7 +3023,7 @@ node-fetch@3.3.2:
30703023
fetch-blob "^3.1.4"
30713024
formdata-polyfill "^4.0.10"
30723025

3073-
node-fetch@^2.6.6, node-fetch@^2.6.7:
3026+
node-fetch@^2.6.7:
30743027
version "2.7.0"
30753028
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
30763029
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
@@ -4297,6 +4250,16 @@ unbox-primitive@^1.0.2:
42974250
has-symbols "^1.0.3"
42984251
which-boxed-primitive "^1.0.2"
42994252

4253+
undici-types@~6.21.0:
4254+
version "6.21.0"
4255+
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
4256+
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
4257+
4258+
undici@^7.28.0:
4259+
version "7.28.0"
4260+
resolved "https://registry.yarnpkg.com/undici/-/undici-7.28.0.tgz#97d64564198b285bc281f0e8e29597e3d11fe7ec"
4261+
integrity sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==
4262+
43004263
unique-string@^3.0.0:
43014264
version "3.0.0"
43024265
resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-3.0.0.tgz#84a1c377aff5fd7a8bc6b55d8244b2bd90d75b9a"

0 commit comments

Comments
 (0)