Skip to content

Commit ef99bf3

Browse files
committed
http: improve performance of shouldUseProxy
1 parent 3c741f7 commit ef99bf3

File tree

2 files changed

+170
-43
lines changed

2 files changed

+170
-43
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
6+
// Benchmark configuration
7+
const bench = common.createBenchmark(main, {
8+
hostname: [
9+
'127.0.0.1',
10+
'localhost',
11+
'www.example.com',
12+
'example.com',
13+
'myexample.com',
14+
],
15+
no_proxy: [
16+
'',
17+
'*',
18+
'127.0.0.1',
19+
'example.com',
20+
'.example.com',
21+
'*.example.com',
22+
],
23+
n: [1e6],
24+
}, {
25+
flags: ['--expose-internals'],
26+
});
27+
28+
function main({ hostname, no_proxy, n }) {
29+
const { parseProxyConfigFromEnv } = require('internal/http');
30+
31+
const protocol = 'https:';
32+
const env = {
33+
no_proxy,
34+
https_proxy: `https://www.example.proxy`,
35+
};
36+
const proxyConfig = parseProxyConfigFromEnv(env, protocol);
37+
38+
// Warm up.
39+
const length = 1024;
40+
const array = [];
41+
for (let i = 0; i < length; ++i) {
42+
array.push(proxyConfig.shouldUseProxy(hostname));
43+
}
44+
45+
// // Benchmark
46+
bench.start();
47+
48+
for (let i = 0; i < n; ++i) {
49+
const index = i % length;
50+
array[index] = proxyConfig.shouldUseProxy(hostname);
51+
}
52+
53+
bench.end(n);
54+
55+
// Verify the entries to prevent dead code elimination from making
56+
// the benchmark invalid.
57+
for (let i = 0; i < length; ++i) {
58+
assert.strictEqual(typeof array[i], 'boolean');
59+
}
60+
}

lib/internal/http.js

Lines changed: 110 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,19 @@ function ipToInt(ip) {
9595
// When the proxy protocol is HTTPS, the modified request needs to be sent after
9696
// TLS handshake with the proxy server. Same goes to the HTTPS request tunnel establishment.
9797

98+
/**
99+
* @callback ProxyBypassMatchFn
100+
* @param {string} host - Host to match against the bypass list.
101+
* @param {string} [hostWithPort] - Host with port to match against the bypass list.
102+
* @returns {boolean} - True if the host should be bypassed, false otherwise.
103+
*/
104+
105+
/**
106+
* @typedef {object} ProxyConnectionOptions
107+
* @property {string} host - Hostname of the proxy server.
108+
* @property {number} port - Port of the proxy server.
109+
*/
110+
98111
/**
99112
* Represents the proxy configuration for an agent. The built-in http and https agent
100113
* implementation have one of this when they are configured to use a proxy.
@@ -105,9 +118,28 @@ function ipToInt(ip) {
105118
* @property {string} protocol - Protocol of the proxy server, e.g. 'http:' or 'https:'.
106119
* @property {string|undefined} auth - proxy-authorization header value, if username or password is provided.
107120
* @property {Array<string>} bypassList - List of hosts to bypass the proxy.
108-
* @property {object} proxyConnectionOptions - Options for connecting to the proxy server.
121+
* @property {ProxyConnectionOptions} proxyConnectionOptions - Options for connecting to the proxy server.
109122
*/
110123
class ProxyConfig {
124+
/** @type {Array<string>} */
125+
#bypassList = [];
126+
/** @type {Array<ProxyBypassMatchFn>} */
127+
#bypassMatchFns = [];
128+
129+
/** @type {ProxyConnectionOptions} */
130+
get proxyConnectionOptions() {
131+
return {
132+
host: this.hostname,
133+
port: this.port,
134+
};
135+
}
136+
137+
/**
138+
* @param {string} proxyUrl - The URL of the proxy server, e.g. 'http://localhost:8080'.
139+
* @param {boolean} [keepAlive] - Whether to keep the connection alive.
140+
* This is not used in the current implementation but can be used in the future.
141+
* @param {string} [noProxyList] - Comma-separated list of hosts to bypass the proxy.
142+
*/
111143
constructor(proxyUrl, keepAlive, noProxyList) {
112144
const { host, hostname, port, protocol, username, password } = new URL(proxyUrl);
113145
this.href = proxyUrl; // Full URL of the proxy server.
@@ -121,59 +153,94 @@ class ProxyConfig {
121153
const auth = `${decodeURIComponent(username)}:${decodeURIComponent(password)}`;
122154
this.auth = `Basic ${Buffer.from(auth).toString('base64')}`;
123155
}
156+
124157
if (noProxyList) {
125-
this.bypassList = noProxyList.split(',').map((entry) => entry.trim().toLowerCase());
126-
} else {
127-
this.bypassList = []; // No bypass list provided.
158+
this.#bypassList = noProxyList
159+
.split(',')
160+
.map((entry) => entry.trim().toLowerCase());
128161
}
129-
this.proxyConnectionOptions = {
130-
host: this.hostname,
131-
port: this.port,
132-
};
133-
}
134162

135-
// See: https://about.gitlab.com/blog/we-need-to-talk-no-proxy
136-
// TODO(joyeecheung): share code with undici.
137-
shouldUseProxy(hostname, port) {
138-
const bypassList = this.bypassList;
139-
if (this.bypassList.length === 0) {
140-
return true; // No bypass list, always use the proxy.
163+
if (this.#bypassList.length === 0) {
164+
this.shouldUseProxy = () => true; // No bypass list, always use the proxy.
165+
} else if (this.#bypassList.includes('*')) {
166+
this.shouldUseProxy = () => false; // '*' in the bypass list means to bypass all hosts.
167+
} else {
168+
this.#buildBypassMatchFns();
169+
// Use the bypass match functions to determine if the proxy should be used.
170+
this.shouldUseProxy = this.#match.bind(this);
141171
}
172+
}
142173

143-
const host = hostname.toLowerCase();
144-
const hostWithPort = port ? `${host}:${port}` : host;
145-
146-
for (let i = 0; i < bypassList.length; i++) {
147-
const entry = bypassList[i];
148-
149-
if (entry === '*') return false; // * bypasses all hosts.
150-
if (entry === host || entry === hostWithPort) return false; // Matching host and host:port
151-
152-
// Follow curl's behavior: strip leading dot before matching suffixes.
153-
if (entry.startsWith('.')) {
154-
const suffix = entry.substring(1);
155-
if (host.endsWith(suffix)) return false;
174+
#buildBypassMatchFns(bypassList = this.#bypassList) {
175+
this.#bypassMatchFns = [];
176+
177+
for (const entry of this.#bypassList) {
178+
if (
179+
// Handle wildcard entries like *.example.com
180+
entry.startsWith('*.') ||
181+
// Follow curl's behavior: strip leading dot before matching suffixes.
182+
entry.startsWith('.')
183+
) {
184+
const suffix = entry.split('');
185+
suffix.shift(); // Remove the leading dot or asterisk.
186+
const suffixLength = suffix.length;
187+
if (suffixLength === 0) {
188+
// If the suffix is empty, it means to match all hosts.
189+
this.#bypassMatchFns.push(() => true);
190+
continue;
191+
}
192+
this.#bypassMatchFns.push((host) => {
193+
const hostLength = host.length;
194+
const offset = hostLength - suffixLength;
195+
if (offset < 0) return false; // Host is shorter than the suffix.
196+
for (let i = 0; i < suffixLength; i++) {
197+
if (host[offset + i] !== suffix[i]) {
198+
return false;
199+
}
200+
}
201+
return true;
202+
});
203+
continue;
156204
}
157205

158-
// Handle wildcards like *.example.com
159-
if (entry.startsWith('*.') && host.endsWith(entry.substring(1))) return false;
160-
161206
// Handle IP ranges (simple format like 192.168.1.0-192.168.1.255)
162207
// TODO(joyeecheung): support IPv6.
163-
if (entry.includes('-') && isIPv4(host)) {
164-
let { 0: startIP, 1: endIP } = entry.split('-');
165-
startIP = startIP.trim();
166-
endIP = endIP.trim();
167-
if (startIP && endIP && isIPv4(startIP) && isIPv4(endIP)) {
168-
const hostInt = ipToInt(host);
169-
const startInt = ipToInt(startIP);
170-
const endInt = ipToInt(endIP);
171-
if (hostInt >= startInt && hostInt <= endInt) return false;
172-
}
208+
const { 0: startIP, 1: endIP } = entry.split('-').map((ip) => ip.trim());
209+
if (entry.includes('-') && startIP && endIP && isIPv4(startIP) && isIPv4(endIP)) {
210+
const startInt = ipToInt(startIP);
211+
const endInt = ipToInt(endIP);
212+
this.#bypassMatchFns.push((host) => {
213+
if (isIPv4(host)) {
214+
const hostInt = ipToInt(host);
215+
return hostInt >= startInt && hostInt <= endInt;
216+
}
217+
return false;
218+
});
219+
continue;
173220
}
174221

175-
// It might be useful to support CIDR notation, but it's not so widely supported
176-
// in other tools as a de-facto standard to follow, so we don't implement it for now.
222+
// Handle simple host or IP entries
223+
this.#bypassMatchFns.push((host, hostWithPort) => {
224+
return (host === entry || hostWithPort === entry);
225+
});
226+
}
227+
}
228+
229+
get bypassList() {
230+
// Return a copy of the bypass list to prevent external modification.
231+
return [...this.#bypassList];
232+
}
233+
234+
// See: https://about.gitlab.com/blog/we-need-to-talk-no-proxy
235+
// TODO(joyeecheung): share code with undici.
236+
#match(hostname, port) {
237+
const host = hostname.toLowerCase();
238+
const hostWithPort = port ? `${host}:${port}` : host;
239+
240+
for (const bypassMatchFn of this.#bypassMatchFns) {
241+
if (bypassMatchFn(host, hostWithPort)) {
242+
return false; // If any bypass function matches, do not use the proxy.
243+
}
177244
}
178245

179246
return true; // If no matches found, use the proxy.

0 commit comments

Comments
 (0)