Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
1import * as https from 'node:https';
2import * as http from 'node:http';
3import * as net from 'node:net';
4
5declare module 'https' {
6 interface Agent {
7 createConnection(
8 opts: https.RequestOptions,
9 callback?: (err: Error | null, socket: net.Socket | null) => void
10 ): net.Socket | null;
11 }
12}
13
14declare module 'net' {
15 export function _normalizeArgs(
16 options: unknown
17 ): asserts options is net.NetConnectOpts;
18}
19
20const getHttpProxyUrl = () => process.env.HTTP_PROXY ?? process.env.http_proxy;
21const getHttpsProxyUrl = () =>
22 process.env.HTTPS_PROXY ?? process.env.https_proxy;
23const getNoProxy = () => process.env.NO_PROXY ?? process.env.no_proxy;
24
25const createProxyPattern = (pattern: string): RegExp | null => {
26 pattern = pattern.trim();
27 if (!pattern.startsWith('.')) pattern = `^${pattern}`;
28 if (!pattern.endsWith('.') || pattern.includes(':')) pattern += '$';
29 pattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '[\\w.]+');
30 return pattern ? new RegExp(pattern, 'i') : null;
31};
32
33const matchesNoProxy = (options: {
34 host?: string | null;
35 hostname?: string | null;
36 port?: string | number | null;
37 defaultPort?: string | number;
38}): boolean => {
39 const NO_PROXY = getNoProxy();
40 if (NO_PROXY === '*' || NO_PROXY === '1' || NO_PROXY === 'true') {
41 return true;
42 } else if (NO_PROXY) {
43 for (const noProxyPattern of NO_PROXY.split(',')) {
44 const hostPattern = createProxyPattern(noProxyPattern);
45 if (hostPattern) {
46 const hostname = options.hostname || options.host;
47 const origin =
48 hostname &&
49 `${hostname}:${options.port || options.defaultPort || 80}`;
50 if (
51 (hostname && hostPattern.test(hostname)) ||
52 (origin && hostPattern.test(origin))
53 ) {
54 return true;
55 }
56 }
57 }
58 return false;
59 } else {
60 return false;
61 }
62};
63
64export const defaultAgentOpts = {
65 keepAlive: true,
66 keepAliveMsecs: 1000,
67};
68
69let _httpAgentUrl: string | undefined;
70let _httpAgent: http.Agent | undefined;
71
72export const getHttpAgent = (
73 options: http.RequestOptions
74): http.RequestOptions['agent'] => {
75 const HTTP_PROXY = getHttpProxyUrl();
76 if (!HTTP_PROXY) {
77 _httpAgent = undefined;
78 return undefined;
79 } else if (matchesNoProxy(options)) {
80 return undefined;
81 } else if (!_httpAgentUrl || _httpAgentUrl !== HTTP_PROXY) {
82 _httpAgent = undefined;
83 try {
84 _httpAgentUrl = HTTP_PROXY;
85 _httpAgent = new HttpProxyAgent(new URL(HTTP_PROXY), defaultAgentOpts);
86 } catch (error: any) {
87 const wrapped = new Error(
88 `Invalid HTTP_PROXY URL: "${HTTP_PROXY}".\n` + error?.message || error
89 );
90 (wrapped as any).cause = error;
91 throw wrapped;
92 }
93 return _httpAgent;
94 } else {
95 return _httpAgent;
96 }
97};
98
99let _httpsAgentUrl: string | undefined;
100let _httpsAgent: https.Agent | undefined;
101
102export const getHttpsAgent = (
103 options: https.RequestOptions
104): https.RequestOptions['agent'] => {
105 const HTTPS_PROXY = getHttpsProxyUrl() ?? getHttpProxyUrl();
106 if (!HTTPS_PROXY) {
107 _httpsAgent = undefined;
108 return undefined;
109 } else if (matchesNoProxy(options)) {
110 return undefined;
111 } else if (!_httpsAgentUrl || _httpsAgentUrl !== HTTPS_PROXY) {
112 _httpsAgent = undefined;
113 try {
114 _httpsAgentUrl = HTTPS_PROXY;
115 _httpsAgent = new HttpsProxyAgent(new URL(HTTPS_PROXY), defaultAgentOpts);
116 } catch (error: any) {
117 const wrapped = new Error(
118 `Invalid HTTPS_PROXY URL: "${HTTPS_PROXY}".\n` + error?.message || error
119 );
120 (wrapped as any).cause = error;
121 throw wrapped;
122 }
123 return _httpsAgent;
124 } else {
125 return _httpsAgent;
126 }
127};
128
129const createRequestOptions = (
130 proxy: URL,
131 keepAlive: boolean,
132 options: http.RequestOptions
133) => {
134 const proxyHeaders: Record<string, string> = {
135 host: `${options.host}:${options.port}`,
136 connection: keepAlive ? 'keep-alive' : 'close',
137 };
138 if (proxy.username || proxy.password) {
139 const username = decodeURIComponent(proxy.username || '');
140 const password = decodeURIComponent(proxy.password || '');
141 const auth = Buffer.from(`${username}:${password}`).toString('base64');
142 proxyHeaders['proxy-authorization'] = `Basic ${auth}`;
143 }
144 return {
145 method: 'CONNECT',
146 host: proxy.hostname,
147 port: proxy.port,
148 path: `${options.host}:${options.port}`,
149 setHost: false,
150 agent: false,
151 proxyEnv: {},
152 timeout: 5_000,
153 headers: proxyHeaders,
154 servername: proxy.protocol === 'https:' ? proxy.hostname : undefined,
155 };
156};
157
158// See: https://github.com/delvedor/hpagent
159// `hpagent` served as a template for how to create proxy agents like below minimally
160// MIT License, Copyright (c) 2020 Tomas Della Vedova
161
162class HttpProxyAgent extends http.Agent {
163 _keepAlive: boolean;
164 _proxy: URL;
165
166 constructor(proxy: URL, options: http.AgentOptions) {
167 super(options);
168 this._proxy = proxy;
169 this._keepAlive = !!options.keepAlive;
170 }
171
172 createConnection(
173 options: http.RequestOptions,
174 callback: (err: Error | null, socket: net.Socket | null) => void
175 ): void {
176 const request = (this._proxy.protocol === 'http:' ? http : https).request(
177 createRequestOptions(this._proxy, this._keepAlive, options)
178 );
179
180 request.once('connect', (response, socket, _head) => {
181 request.removeAllListeners();
182 socket.removeAllListeners();
183 if (response.statusCode === 200) {
184 callback(null, socket);
185 } else {
186 socket.destroy();
187 callback(
188 new Error(
189 `HTTP Proxy Network Error: ${response.statusMessage || response.statusCode}`
190 ),
191 null
192 );
193 }
194 });
195
196 request.once('timeout', () => {
197 request.destroy(new Error('HTTP Proxy timed out'));
198 });
199
200 request.once('error', error => {
201 request.removeAllListeners();
202 callback(error, null);
203 });
204
205 request.end();
206 }
207}
208
209class HttpsProxyAgent extends https.Agent {
210 _proxy: URL;
211 _keepAlive: boolean;
212
213 constructor(proxy: URL, options: https.AgentOptions) {
214 super(options);
215 this._proxy = proxy;
216 this._keepAlive = !!options.keepAlive;
217 }
218
219 createConnection(
220 options: https.RequestOptions,
221 callback?: (err: Error | null, socket: net.Socket | null) => void
222 ): net.Socket | null {
223 const request = (this._proxy.protocol === 'http:' ? http : https).request(
224 createRequestOptions(this._proxy, this._keepAlive, options)
225 );
226
227 request.once('connect', (response, socket, _head) => {
228 request.removeAllListeners();
229 socket.removeAllListeners();
230 if (response.statusCode === 200) {
231 const netOpts = { ...options, socket };
232 net._normalizeArgs(netOpts);
233 const secureSocket = super.createConnection(netOpts);
234 callback?.(null, secureSocket);
235 } else {
236 socket.destroy();
237 callback?.(
238 new Error(
239 `HTTP Proxy Network Error: ${response.statusMessage || response.statusCode}`
240 ),
241 null
242 );
243 }
244 });
245
246 request.once('timeout', () => {
247 request.destroy(new Error('HTTP Proxy timed out'));
248 });
249
250 request.once('error', err => {
251 request.removeAllListeners();
252 callback?.(err, null);
253 });
254
255 request.end();
256 return request.socket;
257 }
258}