Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement automatic `HTTP_PROXY`/`HTTPS_PROXY` agent configuration (#8)

authored by

Phil Pluckthun and committed by
GitHub
2dc1451b 7d6a172a

+402
+5
.changeset/tricky-pants-rest.md
··· 1 + --- 2 + 'fetch-nodeshim': minor 3 + --- 4 + 5 + Add automatic configuration for `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` similar to the upcoming Node 24+ built-in support. Agents will automatically be created and used when these environment variables are set.
+1
package.json
··· 69 69 "lint-staged": "^15.4.3", 70 70 "npm-run-all": "^4.1.5", 71 71 "prettier": "^3.4.2", 72 + "proxy-chain": "^2.7.1", 72 73 "rimraf": "^6.0.1", 73 74 "rollup": "^4.32.1", 74 75 "rollup-plugin-cjs-check": "^1.0.3",
+59
pnpm-lock.yaml
··· 59 59 prettier: 60 60 specifier: ^3.4.2 61 61 version: 3.4.2 62 + proxy-chain: 63 + specifier: ^2.7.1 64 + version: 2.7.1 62 65 rimraf: 63 66 specifier: ^6.0.1 64 67 version: 6.0.1 ··· 710 713 engines: {node: '>=0.4.0'} 711 714 hasBin: true 712 715 716 + agent-base@7.1.4: 717 + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} 718 + engines: {node: '>= 14'} 719 + 713 720 ansi-colors@4.1.3: 714 721 resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} 715 722 engines: {node: '>=6'} ··· 1218 1225 resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} 1219 1226 engines: {node: '>= 0.4'} 1220 1227 1228 + ip-address@10.1.0: 1229 + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} 1230 + engines: {node: '>= 12'} 1231 + 1221 1232 is-array-buffer@3.0.5: 1222 1233 resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} 1223 1234 engines: {node: '>= 0.4'} ··· 1647 1658 resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==} 1648 1659 engines: {node: '>=14'} 1649 1660 hasBin: true 1661 + 1662 + proxy-chain@2.7.1: 1663 + resolution: {integrity: sha512-LtXu0miohJYrHWJxv8wA6EoGreRcX1hxKb7qlE1pMFH+BXE7bqMvpyhzR/JvR6M5SzYKzyHFpvfmYJrZeMtwAg==} 1664 + engines: {node: '>=14'} 1650 1665 1651 1666 queue-microtask@1.2.3: 1652 1667 resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} ··· 1820 1835 resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} 1821 1836 engines: {node: '>=18'} 1822 1837 1838 + smart-buffer@4.2.0: 1839 + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} 1840 + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} 1841 + 1823 1842 smob@1.5.0: 1824 1843 resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} 1844 + 1845 + socks-proxy-agent@8.0.5: 1846 + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} 1847 + engines: {node: '>= 14'} 1848 + 1849 + socks@2.8.7: 1850 + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} 1851 + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} 1825 1852 1826 1853 source-map-js@1.2.1: 1827 1854 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} ··· 1951 1978 1952 1979 tr46@0.0.3: 1953 1980 resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} 1981 + 1982 + tslib@2.8.1: 1983 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 1954 1984 1955 1985 typed-array-buffer@1.0.3: 1956 1986 resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} ··· 2827 2857 2828 2858 acorn@8.14.0: {} 2829 2859 2860 + agent-base@7.1.4: {} 2861 + 2830 2862 ansi-colors@4.1.3: {} 2831 2863 2832 2864 ansi-escapes@7.0.0: ··· 3397 3429 hasown: 2.0.2 3398 3430 side-channel: 1.1.0 3399 3431 3432 + ip-address@10.1.0: {} 3433 + 3400 3434 is-array-buffer@3.0.5: 3401 3435 dependencies: 3402 3436 call-bind: 1.0.8 ··· 3785 3819 3786 3820 prettier@3.4.2: {} 3787 3821 3822 + proxy-chain@2.7.1: 3823 + dependencies: 3824 + socks: 2.8.7 3825 + socks-proxy-agent: 8.0.5 3826 + tslib: 2.8.1 3827 + transitivePeerDependencies: 3828 + - supports-color 3829 + 3788 3830 queue-microtask@1.2.3: {} 3789 3831 3790 3832 randombytes@2.1.0: ··· 4004 4046 ansi-styles: 6.2.1 4005 4047 is-fullwidth-code-point: 5.0.0 4006 4048 4049 + smart-buffer@4.2.0: {} 4050 + 4007 4051 smob@1.5.0: {} 4008 4052 4053 + socks-proxy-agent@8.0.5: 4054 + dependencies: 4055 + agent-base: 7.1.4 4056 + debug: 4.4.1 4057 + socks: 2.8.7 4058 + transitivePeerDependencies: 4059 + - supports-color 4060 + 4061 + socks@2.8.7: 4062 + dependencies: 4063 + ip-address: 10.1.0 4064 + smart-buffer: 4.2.0 4065 + 4009 4066 source-map-js@1.2.1: {} 4010 4067 4011 4068 source-map-support@0.5.21: ··· 4134 4191 is-number: 7.0.0 4135 4192 4136 4193 tr46@0.0.3: {} 4194 + 4195 + tslib@2.8.1: {} 4137 4196 4138 4197 typed-array-buffer@1.0.3: 4139 4198 dependencies:
+77
src/__tests__/fetch-proxied.test.ts
··· 1 + import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; 2 + import { Server as ProxyServer } from 'proxy-chain'; 3 + 4 + import TestServer from './utils/server.js'; 5 + import { fetch } from '../fetch'; 6 + 7 + async function startHttpProxy() { 8 + const server = new ProxyServer(); 9 + const port = await new Promise(resolve => { 10 + server.listen(() => { 11 + resolve(server.port); 12 + }); 13 + }); 14 + 15 + return { 16 + url: `http://localhost:${port}`, 17 + close() { 18 + return new Promise<void>((resolve, reject) => { 19 + server.close(true, err => { 20 + if (err) { 21 + reject(err); 22 + } else { 23 + resolve(); 24 + } 25 + }); 26 + }); 27 + }, 28 + }; 29 + } 30 + 31 + const proxy = await startHttpProxy(); 32 + const local = new TestServer(); 33 + let baseURL: string; 34 + 35 + beforeEach(async () => { 36 + await local.start(); 37 + baseURL = `http://${local.hostname}:${local.port}/`; 38 + }); 39 + 40 + afterEach(async () => { 41 + delete process.env.HTTP_PROXY; 42 + delete process.env.HTTPS_PROXY; 43 + await local.stop(); 44 + }); 45 + 46 + afterAll(async () => { 47 + await proxy.close(); 48 + }); 49 + 50 + const testCI = process.env.CI ? it : it.skip; 51 + 52 + describe('fetch via HTTP proxy', () => { 53 + it('performs an HTTP request when HTTP_PROXY is set (tunnel via CONNECT to HTTP)', async () => { 54 + process.env.HTTP_PROXY = proxy.url; 55 + const response = await fetch(new URL('inspect', baseURL)); 56 + expect(response.status).toBe(200); 57 + expect(await response.json()).toEqual({ 58 + body: '', 59 + headers: expect.objectContaining({ 60 + connection: 'keep-alive', 61 + }), 62 + inspect: true, 63 + method: 'GET', 64 + url: '/inspect', 65 + }); 66 + }); 67 + 68 + testCI( 69 + 'performs an HTTPs request when HTTPS_PROXY is set (tunnel via CONNECT to HTTPs)', 70 + async () => { 71 + process.env.HTTPS_PROXY = proxy.url; 72 + const response = await fetch('https://api.expo.dev'); 73 + expect(response.status).toBe(200); 74 + expect(await response.text()).toBe('OK'); 75 + } 76 + ); 77 + });
+254
src/agent.ts
··· 1 + import * as https from 'node:https'; 2 + import * as http from 'node:http'; 3 + import * as net from 'node:net'; 4 + 5 + declare module 'https' { 6 + interface Agent { 7 + createConnection( 8 + opts: https.RequestOptions, 9 + callback?: (err: Error | null, socket: net.Socket | null) => void 10 + ): net.Socket; 11 + } 12 + } 13 + 14 + declare module 'net' { 15 + export function _normalizeArgs( 16 + options: unknown 17 + ): asserts options is net.NetConnectOpts; 18 + } 19 + 20 + const getHttpProxyUrl = () => process.env.HTTP_PROXY ?? process.env.http_proxy; 21 + const getHttpsProxyUrl = () => 22 + process.env.HTTPS_PROXY ?? process.env.https_proxy; 23 + const getNoProxy = () => process.env.NO_PROXY ?? process.env.no_proxy; 24 + 25 + const createProxyPattern = (pattern: string): RegExp => { 26 + pattern = pattern.trim().replace(/\./g, '\\.').replace(/\*/g, '[\w.]+'); 27 + if (!pattern.startsWith('.')) pattern = `^${pattern}`; 28 + if (!pattern.endsWith('.') || pattern.includes(':')) pattern += '$'; 29 + return new RegExp(pattern, 'i'); 30 + }; 31 + 32 + const matchesNoProxy = (options: { 33 + host?: string | null; 34 + hostname?: string | null; 35 + port?: string | number | null; 36 + defaultPort?: string | number; 37 + }): boolean => { 38 + const NO_PROXY = getNoProxy(); 39 + if (NO_PROXY === '*' || NO_PROXY === '1' || NO_PROXY === 'true') { 40 + return true; 41 + } else if (NO_PROXY) { 42 + for (const noProxyPattern of NO_PROXY.split(',')) { 43 + const hostPattern = createProxyPattern(noProxyPattern); 44 + const hostname = options.hostname || options.host; 45 + const origin = 46 + hostname && `${hostname}:${options.port || options.defaultPort || 80}`; 47 + if ( 48 + (hostname && hostPattern.test(hostname)) || 49 + (origin && hostPattern.test(origin)) 50 + ) { 51 + return true; 52 + } 53 + } 54 + return false; 55 + } else { 56 + return false; 57 + } 58 + }; 59 + 60 + const defaultAgentOpts = { 61 + keepAlive: true, 62 + keepAliveMsecs: 1000, 63 + }; 64 + 65 + let _httpAgentUrl: string | undefined; 66 + let _httpAgent: http.Agent | undefined; 67 + 68 + export const getHttpAgent = ( 69 + options: http.RequestOptions 70 + ): http.RequestOptions['agent'] => { 71 + const HTTP_PROXY = getHttpProxyUrl(); 72 + if (!HTTP_PROXY) { 73 + _httpAgent = undefined; 74 + return undefined; 75 + } else if (matchesNoProxy(options)) { 76 + return undefined; 77 + } else if (!_httpAgentUrl || _httpAgentUrl !== HTTP_PROXY) { 78 + _httpAgent = undefined; 79 + try { 80 + _httpAgentUrl = HTTP_PROXY; 81 + _httpAgent = new HttpProxyAgent(new URL(HTTP_PROXY), defaultAgentOpts); 82 + } catch (error: any) { 83 + const wrapped = new Error( 84 + `Invalid HTTP_PROXY URL: "${HTTP_PROXY}".\n` + error?.message || error 85 + ); 86 + (wrapped as any).cause = error; 87 + throw wrapped; 88 + } 89 + return _httpAgent; 90 + } else { 91 + return _httpAgent; 92 + } 93 + }; 94 + 95 + let _httpsAgentUrl: string | undefined; 96 + let _httpsAgent: https.Agent | undefined; 97 + 98 + export const getHttpsAgent = ( 99 + options: https.RequestOptions 100 + ): https.RequestOptions['agent'] => { 101 + const HTTPS_PROXY = getHttpsProxyUrl() ?? getHttpProxyUrl(); 102 + if (!HTTPS_PROXY) { 103 + _httpsAgent = undefined; 104 + return undefined; 105 + } else if (matchesNoProxy(options)) { 106 + return undefined; 107 + } else if (!_httpsAgentUrl || _httpsAgentUrl !== HTTPS_PROXY) { 108 + _httpsAgent = undefined; 109 + try { 110 + _httpsAgentUrl = HTTPS_PROXY; 111 + _httpsAgent = new HttpsProxyAgent(new URL(HTTPS_PROXY), defaultAgentOpts); 112 + } catch (error: any) { 113 + const wrapped = new Error( 114 + `Invalid HTTPS_PROXY URL: "${HTTPS_PROXY}".\n` + error?.message || error 115 + ); 116 + (wrapped as any).cause = error; 117 + throw wrapped; 118 + } 119 + return _httpsAgent; 120 + } else { 121 + return _httpsAgent; 122 + } 123 + }; 124 + 125 + const createRequestOptions = ( 126 + proxy: URL, 127 + keepAlive: boolean, 128 + options: http.RequestOptions 129 + ) => { 130 + const proxyHeaders: Record<string, string> = { 131 + host: `${options.host}:${options.port}`, 132 + connection: keepAlive ? 'keep-alive' : 'close', 133 + }; 134 + if (proxy.username || proxy.password) { 135 + const username = decodeURIComponent(proxy.username || ''); 136 + const password = decodeURIComponent(proxy.password || ''); 137 + const auth = Buffer.from(`${username}:${password}`).toString('base64'); 138 + proxyHeaders['proxy-authorization'] = `Basic ${auth}`; 139 + } 140 + return { 141 + method: 'CONNECT', 142 + host: proxy.hostname, 143 + port: proxy.port, 144 + path: `${options.host}:${options.port}`, 145 + setHost: false, 146 + agent: false, 147 + proxyEnv: {}, 148 + timeout: 8_000, 149 + headers: proxyHeaders, 150 + servername: proxy.protocol === 'https:' ? proxy.hostname : undefined, 151 + }; 152 + }; 153 + 154 + // See: https://github.com/delvedor/hpagent 155 + // `hpagent` served as a template for how to create proxy agents like below minimally 156 + // MIT License, Copyright (c) 2020 Tomas Della Vedova 157 + 158 + class HttpProxyAgent extends http.Agent { 159 + _keepAlive: boolean; 160 + _proxy: URL; 161 + 162 + constructor(proxy: URL, options: http.AgentOptions) { 163 + super(options); 164 + this._proxy = proxy; 165 + this._keepAlive = !!options.keepAlive; 166 + } 167 + 168 + createConnection( 169 + options: http.RequestOptions, 170 + callback: (err: Error | null, socket: net.Socket | null) => void 171 + ): void { 172 + const request = (this._proxy.protocol === 'http:' ? http : https).request( 173 + createRequestOptions(this._proxy, this._keepAlive, options) 174 + ); 175 + 176 + request.once('connect', (response, socket, _head) => { 177 + request.removeAllListeners(); 178 + socket.removeAllListeners(); 179 + if (response.statusCode === 200) { 180 + callback(null, socket); 181 + } else { 182 + socket.destroy(); 183 + callback( 184 + new Error( 185 + `HTTP Proxy Network Error: ${response.statusMessage || response.statusCode}` 186 + ), 187 + null 188 + ); 189 + } 190 + }); 191 + 192 + request.once('timeout', () => { 193 + request.destroy(new Error('HTTP Proxy timed out')); 194 + }); 195 + 196 + request.once('error', error => { 197 + request.removeAllListeners(); 198 + callback(error, null); 199 + }); 200 + 201 + request.end(); 202 + } 203 + } 204 + 205 + class HttpsProxyAgent extends https.Agent { 206 + _proxy: URL; 207 + _keepAlive: boolean; 208 + 209 + constructor(proxy: URL, options: https.AgentOptions) { 210 + super(options); 211 + this._proxy = proxy; 212 + this._keepAlive = !!options.keepAlive; 213 + } 214 + 215 + createConnection( 216 + options: https.RequestOptions, 217 + callback?: (err: Error | null, socket: net.Socket | null) => void 218 + ): net.Socket { 219 + const request = (this._proxy.protocol === 'http:' ? http : https).request( 220 + createRequestOptions(this._proxy, this._keepAlive, options) 221 + ); 222 + 223 + request.once('connect', (response, socket, _head) => { 224 + request.removeAllListeners(); 225 + socket.removeAllListeners(); 226 + if (response.statusCode === 200) { 227 + const netOpts = { ...options, socket }; 228 + net._normalizeArgs(netOpts); 229 + const secureSocket = super.createConnection(netOpts); 230 + callback?.(null, secureSocket); 231 + } else { 232 + socket.destroy(); 233 + callback?.( 234 + new Error( 235 + `HTTP Proxy Network Error: ${response.statusMessage || response.statusCode}` 236 + ), 237 + null 238 + ); 239 + } 240 + }); 241 + 242 + request.once('timeout', () => { 243 + request.destroy(new Error('HTTP Proxy timed out')); 244 + }); 245 + 246 + request.once('error', err => { 247 + request.removeAllListeners(); 248 + callback?.(err, null); 249 + }); 250 + 251 + request.end(); 252 + return request.socket!; 253 + } 254 + }
+6
src/fetch.ts
··· 6 6 import { extractBody } from './body'; 7 7 import { createContentDecoder } from './encoding'; 8 8 import { URL, Request, RequestInit, Response } from './webstd'; 9 + import { getHttpsAgent, getHttpAgent } from './agent'; 9 10 10 11 /** Maximum allowed redirects (matching Chromium's limit) */ 11 12 const MAX_REDIRECTS = 20; ··· 131 132 ), 132 133 signal, 133 134 } satisfies http.RequestOptions; 135 + 136 + requestOptions.agent = 137 + requestOptions.protocol === 'https:' 138 + ? getHttpsAgent(requestOptions) 139 + : getHttpAgent(requestOptions); 134 140 135 141 function _call( 136 142 resolve: (response: Response | Promise<Response>) => void,