Skip to content

Commit ea0cf4d

Browse files
WSL0809jackwener
andauthored
fix(network): honor proxy env for node requests (#512)
* fix(network): honor proxy env for node requests * fix(network): honor default ports in NO_PROXY * refactor(network): normalize proxy config handling * refactor(network): delegate proxy env handling to undici --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 9919f03 commit ea0cf4d

7 files changed

Lines changed: 389 additions & 42 deletions

File tree

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"commander": "^14.0.3",
5959
"js-yaml": "^4.1.0",
6060
"turndown": "^7.2.2",
61+
"undici": "^7.24.6",
6162
"ws": "^8.18.0"
6263
},
6364
"devDependencies": {

src/download/index.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import * as fs from 'node:fs';
22
import * as http from 'node:http';
33
import * as os from 'node:os';
44
import * as path from 'node:path';
5-
import { afterEach, describe, expect, it } from 'vitest';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
66
import { formatCookieHeader, httpDownload, resolveRedirectUrl } from './index.js';
77

88
const servers: http.Server[] = [];
99
const tempDirs: string[] = [];
1010

1111
afterEach(async () => {
12+
vi.unstubAllEnvs();
1213
await Promise.all(servers.map((server) => new Promise<void>((resolve, reject) => {
1314
server.close((err) => (err ? reject(err) : resolve()));
1415
})));
@@ -114,4 +115,21 @@ describe('download helpers', { retry: process.platform === 'win32' ? 2 : 0 }, ()
114115
expect(forwardedCookie).toBeUndefined();
115116
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
116117
});
118+
119+
it('bypasses proxy settings for loopback downloads', async () => {
120+
vi.stubEnv('HTTP_PROXY', 'http://127.0.0.1:9');
121+
122+
const baseUrl = await startServer((_req, res) => {
123+
res.statusCode = 200;
124+
res.end('ok');
125+
});
126+
127+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-dl-'));
128+
tempDirs.push(tempDir);
129+
const destPath = path.join(tempDir, 'loopback.txt');
130+
const result = await httpDownload(`${baseUrl}/ok`, destPath);
131+
132+
expect(result).toEqual({ success: true, size: 2 });
133+
expect(fs.readFileSync(destPath, 'utf8')).toBe('ok');
134+
});
117135
});

src/download/index.ts

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@
55
import { spawn } from 'node:child_process';
66
import * as fs from 'node:fs';
77
import * as path from 'node:path';
8-
import * as https from 'node:https';
9-
import * as http from 'node:http';
108
import * as os from 'node:os';
11-
import { Transform } from 'node:stream';
9+
import { Readable, Transform } from 'node:stream';
10+
import type { ReadableStream as WebReadableStream } from 'node:stream/web';
1211
import { pipeline } from 'node:stream/promises';
1312
import { URL } from 'node:url';
1413
import type { ProgressBar } from './progress.js';
1514
import { isBinaryInstalled } from '../external.js';
1615
import type { BrowserCookie } from '../types.js';
1716
import { getErrorMessage } from '../errors.js';
17+
import { fetchWithNodeNetwork } from '../node-network.js';
1818

1919
export type { BrowserCookie } from '../types.js';
2020

@@ -89,9 +89,6 @@ export async function httpDownload(
8989
const { cookies, headers = {}, timeout = 30000, onProgress, maxRedirects = 10 } = options;
9090

9191
return new Promise((resolve) => {
92-
const parsedUrl = new URL(url);
93-
const protocol = parsedUrl.protocol === 'https:' ? https : http;
94-
9592
const requestHeaders: Record<string, string> = {
9693
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
9794
...headers,
@@ -118,37 +115,52 @@ export async function httpDownload(
118115
}
119116
};
120117

121-
const request = protocol.get(url, { headers: requestHeaders, timeout }, (response) => {
122-
void (async () => {
118+
void (async () => {
119+
const controller = new AbortController();
120+
const timer = setTimeout(() => controller.abort(), timeout);
121+
try {
122+
const response = await fetchWithNodeNetwork(url, {
123+
headers: requestHeaders,
124+
signal: controller.signal,
125+
redirect: 'manual',
126+
});
127+
clearTimeout(timer);
128+
123129
// Handle redirects before creating any file handles.
124-
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
125-
response.resume();
126-
if (redirectCount >= maxRedirects) {
127-
finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
130+
if (response.status >= 300 && response.status < 400) {
131+
const location = response.headers.get('location');
132+
if (location) {
133+
if (redirectCount >= maxRedirects) {
134+
finish({ success: false, size: 0, error: `Too many redirects (> ${maxRedirects})` });
135+
return;
136+
}
137+
const redirectUrl = resolveRedirectUrl(url, location);
138+
const originalHost = new URL(url).hostname;
139+
const redirectHost = new URL(redirectUrl).hostname;
140+
const redirectOptions = originalHost === redirectHost
141+
? options
142+
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
143+
finish(await httpDownload(
144+
redirectUrl,
145+
destPath,
146+
redirectOptions,
147+
redirectCount + 1,
148+
));
128149
return;
129150
}
130-
const redirectUrl = resolveRedirectUrl(url, response.headers.location);
131-
const originalHost = new URL(url).hostname;
132-
const redirectHost = new URL(redirectUrl).hostname;
133-
const redirectOptions = originalHost === redirectHost
134-
? options
135-
: { ...options, cookies: undefined, headers: stripCookieHeaders(options.headers) };
136-
finish(await httpDownload(
137-
redirectUrl,
138-
destPath,
139-
redirectOptions,
140-
redirectCount + 1,
141-
));
151+
}
152+
153+
if (response.status !== 200) {
154+
finish({ success: false, size: 0, error: `HTTP ${response.status}` });
142155
return;
143156
}
144157

145-
if (response.statusCode !== 200) {
146-
response.resume();
147-
finish({ success: false, size: 0, error: `HTTP ${response.statusCode}` });
158+
if (!response.body) {
159+
finish({ success: false, size: 0, error: 'Empty response body' });
148160
return;
149161
}
150162

151-
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
163+
const totalSize = parseInt(response.headers.get('content-length') || '0', 10);
152164
let received = 0;
153165
const progressStream = new Transform({
154166
transform(chunk, _encoding, callback) {
@@ -160,26 +172,23 @@ export async function httpDownload(
160172

161173
try {
162174
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
163-
await pipeline(response, progressStream, fs.createWriteStream(tempPath));
175+
await pipeline(
176+
Readable.fromWeb(response.body as unknown as WebReadableStream),
177+
progressStream,
178+
fs.createWriteStream(tempPath),
179+
);
164180
await fs.promises.rename(tempPath, destPath);
165181
finish({ success: true, size: received });
166182
} catch (err) {
167183
await cleanupTempFile();
168184
finish({ success: false, size: 0, error: getErrorMessage(err) });
169185
}
170-
})();
171-
});
172-
173-
request.on('error', (err) => {
174-
void (async () => {
186+
} catch (err) {
187+
clearTimeout(timer);
175188
await cleanupTempFile();
176-
finish({ success: false, size: 0, error: err.message });
177-
})();
178-
});
179-
180-
request.on('timeout', () => {
181-
request.destroy(new Error('Timeout'));
182-
});
189+
finish({ success: false, size: 0, error: err instanceof Error ? err.message : String(err) });
190+
}
191+
})();
183192
});
184193
}
185194

src/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ import { discoverClis, discoverPlugins } from './discovery.js';
2020
import { getCompletions } from './completion.js';
2121
import { runCli } from './cli.js';
2222
import { emitHook } from './hooks.js';
23+
import { installNodeNetwork } from './node-network.js';
2324
import { registerUpdateNoticeOnExit, checkForUpdateBackground } from './update-check.js';
2425

26+
installNodeNetwork();
27+
2528
const __filename = fileURLToPath(import.meta.url);
2629
const __dirname = path.dirname(__filename);
2730
const BUILTIN_CLIS = path.resolve(__dirname, 'clis');

src/node-network.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { decideProxy, hasProxyEnv } from './node-network.js';
4+
5+
describe('node network proxy decisions', () => {
6+
it('detects common proxy env variables', () => {
7+
expect(hasProxyEnv({ https_proxy: 'http://127.0.0.1:7897' })).toBe(true);
8+
expect(hasProxyEnv({ HTTP_PROXY: 'http://proxy.example:8080' })).toBe(true);
9+
expect(hasProxyEnv({})).toBe(false);
10+
});
11+
12+
it('routes external https traffic through https_proxy', () => {
13+
const decision = decideProxy(
14+
new URL('https://www.v2ex.com/api/topics/latest.json'),
15+
{ https_proxy: 'http://127.0.0.1:7897' },
16+
);
17+
18+
expect(decision).toEqual({
19+
mode: 'proxy',
20+
proxyUrl: 'http://127.0.0.1:7897',
21+
});
22+
});
23+
24+
it('falls back to HTTP_PROXY for https traffic when HTTPS_PROXY is absent', () => {
25+
const decision = decideProxy(
26+
new URL('https://www.v2ex.com/api/topics/latest.json'),
27+
{ HTTP_PROXY: 'http://127.0.0.1:7897' },
28+
);
29+
30+
expect(decision).toEqual({
31+
mode: 'proxy',
32+
proxyUrl: 'http://127.0.0.1:7897',
33+
});
34+
});
35+
36+
it('bypasses proxies for loopback addresses', () => {
37+
const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
38+
39+
expect(decideProxy(new URL('http://127.0.0.1:19825/status'), env)).toEqual({ mode: 'direct' });
40+
expect(decideProxy(new URL('http://localhost:19825/status'), env)).toEqual({ mode: 'direct' });
41+
expect(decideProxy(new URL('http://[::1]:19825/status'), env)).toEqual({ mode: 'direct' });
42+
});
43+
44+
it('honors NO_PROXY domain matches', () => {
45+
const decision = decideProxy(
46+
new URL('https://api.example.com/v1/items'),
47+
{
48+
https_proxy: 'http://127.0.0.1:7897',
49+
no_proxy: '.example.com',
50+
},
51+
);
52+
53+
expect(decision).toEqual({ mode: 'direct' });
54+
});
55+
56+
it('supports wildcard-style NO_PROXY subdomain entries', () => {
57+
const decision = decideProxy(
58+
new URL('https://api.example.com/v1/items'),
59+
{
60+
https_proxy: 'http://127.0.0.1:7897',
61+
no_proxy: '*.example.com',
62+
},
63+
);
64+
65+
expect(decision).toEqual({ mode: 'direct' });
66+
});
67+
68+
it('matches NO_PROXY entries that rely on the default URL port', () => {
69+
const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
70+
71+
expect(decideProxy(
72+
new URL('https://example.com/'),
73+
{ ...env, NO_PROXY: 'example.com:443' },
74+
)).toEqual({ mode: 'direct' });
75+
76+
expect(decideProxy(
77+
new URL('http://example.com/health'),
78+
{ ...env, NO_PROXY: 'example.com:80' },
79+
)).toEqual({ mode: 'direct' });
80+
});
81+
82+
it('falls back to ALL_PROXY when protocol-specific settings are absent', () => {
83+
const decision = decideProxy(
84+
new URL('http://example.net/data'),
85+
{ ALL_PROXY: 'socks5://127.0.0.1:1080' },
86+
);
87+
88+
expect(decision).toEqual({
89+
mode: 'proxy',
90+
proxyUrl: 'socks5://127.0.0.1:1080',
91+
});
92+
});
93+
});

0 commit comments

Comments
 (0)