Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
at v0.4.5 258 lines 7.3 kB view raw
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}