Skip to content

Commit adacfa0

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

File tree

2 files changed

+186
-49
lines changed

2 files changed

+186
-49
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
'126.255.255.1-127.0.0.255',
19+
'127.0.0.1',
20+
'example.com',
21+
'.example.com',
22+
'*.example.com',
23+
],
24+
n: [1e6],
25+
}, {
26+
flags: ['--expose-internals'],
27+
});
28+
29+
function main({ hostname, no_proxy, n }) {
30+
const { parseProxyConfigFromEnv } = require('internal/http');
31+
32+
const protocol = 'https:';
33+
const env = {
34+
no_proxy,
35+
https_proxy: `https://www.example.proxy`,
36+
};
37+
const proxyConfig = parseProxyConfigFromEnv(env, protocol);
38+
39+
// Warm up.
40+
const length = 1024;
41+
const array = [];
42+
for (let i = 0; i < length; ++i) {
43+
array.push(proxyConfig.shouldUseProxy(hostname));
44+
}
45+
46+
// // Benchmark
47+
bench.start();
48+
49+
for (let i = 0; i < n; ++i) {
50+
const index = i % length;
51+
array[index] = proxyConfig.shouldUseProxy(hostname);
52+
}
53+
54+
bench.end(n);
55+
56+
// Verify the entries to prevent dead code elimination from making
57+
// the benchmark invalid.
58+
for (let i = 0; i < length; ++i) {
59+
assert.strictEqual(typeof array[i], 'boolean');
60+
}
61+
}

lib/internal/http.js

Lines changed: 125 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,22 @@ function traceEnd(...args) {
5858
}
5959

6060
function ipToInt(ip) {
61-
const octets = ip.split('.');
62-
let result = 0;
63-
for (let i = 0; i < octets.length; i++) {
64-
result = (result << 8) + NumberParseInt(octets[i]);
61+
let result = 0
62+
let multiplier = 1
63+
let octetShift = 0
64+
let code = 0
65+
66+
for (let i = ip.length - 1; i >= 0; --i) {
67+
code = ip.charCodeAt(i)
68+
if (code !== 46) {
69+
result += ((code - 48) * multiplier) << octetShift
70+
multiplier *= 10
71+
} else {
72+
octetShift += 8
73+
multiplier = 1
74+
}
6575
}
66-
// Force unsigned 32-bit result
67-
return result >>> 0;
76+
return result >>> 0
6877
}
6978

7079
// There are two factors in play when proxying the request:
@@ -95,6 +104,19 @@ function ipToInt(ip) {
95104
// When the proxy protocol is HTTPS, the modified request needs to be sent after
96105
// TLS handshake with the proxy server. Same goes to the HTTPS request tunnel establishment.
97106

107+
/**
108+
* @callback ProxyBypassMatchFn
109+
* @param {string} host - Host to match against the bypass list.
110+
* @param {string} [hostWithPort] - Host with port to match against the bypass list.
111+
* @returns {boolean} - True if the host should be bypassed, false otherwise.
112+
*/
113+
114+
/**
115+
* @typedef {object} ProxyConnectionOptions
116+
* @property {string} host - Hostname of the proxy server.
117+
* @property {number} port - Port of the proxy server.
118+
*/
119+
98120
/**
99121
* Represents the proxy configuration for an agent. The built-in http and https agent
100122
* implementation have one of this when they are configured to use a proxy.
@@ -105,9 +127,28 @@ function ipToInt(ip) {
105127
* @property {string} protocol - Protocol of the proxy server, e.g. 'http:' or 'https:'.
106128
* @property {string|undefined} auth - proxy-authorization header value, if username or password is provided.
107129
* @property {Array<string>} bypassList - List of hosts to bypass the proxy.
108-
* @property {object} proxyConnectionOptions - Options for connecting to the proxy server.
130+
* @property {ProxyConnectionOptions} proxyConnectionOptions - Options for connecting to the proxy server.
109131
*/
110132
class ProxyConfig {
133+
/** @type {Array<string>} */
134+
#bypassList = [];
135+
/** @type {Array<ProxyBypassMatchFn>} */
136+
#bypassMatchFns = [];
137+
138+
/** @type {ProxyConnectionOptions} */
139+
get proxyConnectionOptions() {
140+
return {
141+
host: this.hostname,
142+
port: this.port,
143+
};
144+
}
145+
146+
/**
147+
* @param {string} proxyUrl - The URL of the proxy server, e.g. 'http://localhost:8080'.
148+
* @param {boolean} [keepAlive] - Whether to keep the connection alive.
149+
* This is not used in the current implementation but can be used in the future.
150+
* @param {string} [noProxyList] - Comma-separated list of hosts to bypass the proxy.
151+
*/
111152
constructor(proxyUrl, keepAlive, noProxyList) {
112153
const { host, hostname, port, protocol, username, password } = new URL(proxyUrl);
113154
this.href = proxyUrl; // Full URL of the proxy server.
@@ -121,59 +162,94 @@ class ProxyConfig {
121162
const auth = `${decodeURIComponent(username)}:${decodeURIComponent(password)}`;
122163
this.auth = `Basic ${Buffer.from(auth).toString('base64')}`;
123164
}
165+
124166
if (noProxyList) {
125-
this.bypassList = noProxyList.split(',').map((entry) => entry.trim().toLowerCase());
126-
} else {
127-
this.bypassList = []; // No bypass list provided.
167+
this.#bypassList = noProxyList
168+
.split(',')
169+
.map((entry) => entry.trim().toLowerCase());
128170
}
129-
this.proxyConnectionOptions = {
130-
host: this.hostname,
131-
port: this.port,
132-
};
133-
}
134171

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.
172+
if (this.#bypassList.length === 0) {
173+
this.shouldUseProxy = () => true; // No bypass list, always use the proxy.
174+
} else if (this.#bypassList.includes('*')) {
175+
this.shouldUseProxy = () => false; // '*' in the bypass list means to bypass all hosts.
176+
} else {
177+
this.#buildBypassMatchFns();
178+
// Use the bypass match functions to determine if the proxy should be used.
179+
this.shouldUseProxy = this.#match.bind(this);
141180
}
181+
}
142182

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;
183+
#buildBypassMatchFns(bypassList = this.#bypassList) {
184+
this.#bypassMatchFns = [];
185+
186+
for (const entry of this.#bypassList) {
187+
if (
188+
// Handle wildcard entries like *.example.com
189+
entry.startsWith('*.') ||
190+
// Follow curl's behavior: strip leading dot before matching suffixes.
191+
entry.startsWith('.')
192+
) {
193+
const suffix = entry.split('');
194+
suffix.shift(); // Remove the leading dot or asterisk.
195+
const suffixLength = suffix.length;
196+
if (suffixLength === 0) {
197+
// If the suffix is empty, it means to match all hosts.
198+
this.#bypassMatchFns.push(() => true);
199+
continue;
200+
}
201+
this.#bypassMatchFns.push((host) => {
202+
const hostLength = host.length;
203+
const offset = hostLength - suffixLength;
204+
if (offset < 0) return false; // Host is shorter than the suffix.
205+
for (let i = 0; i < suffixLength; i++) {
206+
if (host[offset + i] !== suffix[i]) {
207+
return false;
208+
}
209+
}
210+
return true;
211+
});
212+
continue;
156213
}
157214

158-
// Handle wildcards like *.example.com
159-
if (entry.startsWith('*.') && host.endsWith(entry.substring(1))) return false;
160-
161215
// Handle IP ranges (simple format like 192.168.1.0-192.168.1.255)
162216
// 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-
}
217+
const { 0: startIP, 1: endIP } = entry.split('-').map((ip) => ip.trim());
218+
if (entry.includes('-') && startIP && endIP && isIPv4(startIP) && isIPv4(endIP)) {
219+
const startInt = ipToInt(startIP);
220+
const endInt = ipToInt(endIP);
221+
this.#bypassMatchFns.push((host) => {
222+
if (isIPv4(host)) {
223+
const hostInt = ipToInt(host);
224+
return hostInt >= startInt && hostInt <= endInt;
225+
}
226+
return false;
227+
});
228+
continue;
173229
}
174230

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.
231+
// Handle simple host or IP entries
232+
this.#bypassMatchFns.push((host, hostWithPort) => {
233+
return (host === entry || hostWithPort === entry);
234+
});
235+
}
236+
}
237+
238+
get bypassList() {
239+
// Return a copy of the bypass list to prevent external modification.
240+
return [...this.#bypassList];
241+
}
242+
243+
// See: https://about.gitlab.com/blog/we-need-to-talk-no-proxy
244+
// TODO(joyeecheung): share code with undici.
245+
#match(hostname, port) {
246+
const host = hostname.toLowerCase();
247+
const hostWithPort = port ? `${host}:${port}` : host;
248+
249+
for (const bypassMatchFn of this.#bypassMatchFns) {
250+
if (bypassMatchFn(host, hostWithPort)) {
251+
return false; // If any bypass function matches, do not use the proxy.
252+
}
177253
}
178254

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

0 commit comments

Comments
 (0)