node-network.ts
1 import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici'; 2 3 const LOOPBACK_NO_PROXY_ENTRIES = ['127.0.0.1', 'localhost', '::1']; 4 5 type ProxyEnvKey = 6 | 'http_proxy' 7 | 'https_proxy' 8 | 'all_proxy' 9 | 'HTTP_PROXY' 10 | 'HTTPS_PROXY' 11 | 'ALL_PROXY'; 12 13 const PROXY_ENV_BY_PROTOCOL: Record<'http:' | 'https:', ProxyEnvKey[]> = { 14 'http:': ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'], 15 'https:': ['https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY'], 16 }; 17 const DEFAULT_PORT_BY_PROTOCOL: Record<'http:' | 'https:', string> = { 18 'http:': '80', 19 'https:': '443', 20 }; 21 22 export interface ProxyDecision { 23 mode: 'direct' | 'proxy'; 24 proxyUrl?: string; 25 } 26 27 interface NoProxyEntry { 28 host: string; 29 port?: string; 30 } 31 32 interface ProxyConfig { 33 httpProxy?: string; 34 httpsProxy?: string; 35 noProxy?: string; 36 noProxyEntries: NoProxyEntry[]; 37 } 38 39 let installed = false; 40 const directDispatcher = new Agent(); 41 const proxyDispatcherCache = new Map<string, Dispatcher>(); 42 const nativeFetch = globalThis.fetch.bind(globalThis); 43 44 function readEnv(env: NodeJS.ProcessEnv, lower: string, upper: string): string | undefined { 45 const lowerValue = env[lower]; 46 if (typeof lowerValue === 'string' && lowerValue.trim() !== '') return lowerValue; 47 const upperValue = env[upper]; 48 if (typeof upperValue === 'string' && upperValue.trim() !== '') return upperValue; 49 return undefined; 50 } 51 52 function readProxyEnv(env: NodeJS.ProcessEnv, keys: ProxyEnvKey[]): string | undefined { 53 for (const key of keys) { 54 const value = env[key]; 55 if (typeof value === 'string' && value.trim() !== '') return value; 56 } 57 return undefined; 58 } 59 60 function normalizeHostname(hostname: string): string { 61 return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase(); 62 } 63 64 function splitNoProxy(raw: string | undefined): string[] { 65 return (raw ?? '') 66 .split(/[,\s]+/) 67 .map((token) => token.trim()) 68 .filter(Boolean); 69 } 70 71 function parseNoProxyEntry(entry: string): NoProxyEntry { 72 if (entry === '*') return { host: '*' }; 73 74 const trimmed = entry.trim().replace(/^\*?\./, ''); 75 if (trimmed.startsWith('[')) { 76 const end = trimmed.indexOf(']'); 77 if (end !== -1) { 78 const host = trimmed.slice(1, end); 79 const rest = trimmed.slice(end + 1); 80 if (rest.startsWith(':')) return { host: normalizeHostname(host), port: rest.slice(1) }; 81 return { host: normalizeHostname(host) }; 82 } 83 } 84 85 const colonCount = (trimmed.match(/:/g) ?? []).length; 86 if (colonCount === 1) { 87 const [host, port] = trimmed.split(':'); 88 return { host: normalizeHostname(host), port }; 89 } 90 91 return { host: normalizeHostname(trimmed) }; 92 } 93 94 function effectiveNoProxyEntries(env: NodeJS.ProcessEnv): NoProxyEntry[] { 95 const raw = readEnv(env, 'no_proxy', 'NO_PROXY'); 96 const entries = splitNoProxy(raw).map(parseNoProxyEntry); 97 const seen = new Set(entries.map((entry) => `${entry.host}:${entry.port ?? ''}`)); 98 for (const rawEntry of LOOPBACK_NO_PROXY_ENTRIES) { 99 const entry = parseNoProxyEntry(rawEntry); 100 const key = `${entry.host}:${entry.port ?? ''}`; 101 if (seen.has(key)) continue; 102 entries.push(entry); 103 seen.add(key); 104 } 105 return entries; 106 } 107 108 function serializeNoProxyEntry(entry: NoProxyEntry): string { 109 if (entry.host === '*') return '*'; 110 111 const host = entry.host.includes(':') ? `[${entry.host}]` : entry.host; 112 return entry.port ? `${host}:${entry.port}` : host; 113 } 114 115 function effectiveNoProxyValue(entries: NoProxyEntry[]): string | undefined { 116 if (entries.length === 0) return undefined; 117 118 return entries.map(serializeNoProxyEntry).join(','); 119 } 120 121 function matchesNoProxyEntry(url: URL, entry: NoProxyEntry): boolean { 122 const { host, port } = entry; 123 if (host === '*') return true; 124 125 const hostname = normalizeHostname(url.hostname); 126 const urlPort = url.port || DEFAULT_PORT_BY_PROTOCOL[url.protocol as 'http:' | 'https:'] || undefined; 127 if (port && port !== urlPort) return false; 128 return hostname === host || hostname.endsWith(`.${host}`); 129 } 130 131 function resolveProxyConfig(env: NodeJS.ProcessEnv = process.env): ProxyConfig { 132 const noProxyEntries = effectiveNoProxyEntries(env); 133 return { 134 httpProxy: readProxyEnv(env, PROXY_ENV_BY_PROTOCOL['http:']), 135 httpsProxy: readProxyEnv(env, [ 136 'https_proxy', 137 'HTTPS_PROXY', 138 'http_proxy', 139 'HTTP_PROXY', 140 'all_proxy', 141 'ALL_PROXY', 142 ]), 143 noProxy: effectiveNoProxyValue(noProxyEntries), 144 noProxyEntries, 145 }; 146 } 147 148 function createProxyDispatcher(config: ProxyConfig): Dispatcher { 149 const cacheKey = JSON.stringify([ 150 config.httpProxy ?? '', 151 config.httpsProxy ?? '', 152 config.noProxy ?? '', 153 ]); 154 const cached = proxyDispatcherCache.get(cacheKey); 155 if (cached) return cached; 156 const dispatcher = new EnvHttpProxyAgent({ 157 httpProxy: config.httpProxy, 158 httpsProxy: config.httpsProxy, 159 noProxy: config.noProxy, 160 }); 161 proxyDispatcherCache.set(cacheKey, dispatcher); 162 return dispatcher; 163 } 164 165 function resolveUrl(input: RequestInfo | URL): URL | null { 166 if (typeof input === 'string') return new URL(input); 167 if (input instanceof URL) return input; 168 if (typeof Request !== 'undefined' && input instanceof Request) return new URL(input.url); 169 return null; 170 } 171 172 export function hasProxyEnv(env: NodeJS.ProcessEnv = process.env): boolean { 173 const config = resolveProxyConfig(env); 174 return Boolean(config.httpProxy || config.httpsProxy); 175 } 176 177 export function decideProxy(url: URL, env: NodeJS.ProcessEnv = process.env): ProxyDecision { 178 const config = resolveProxyConfig(env); 179 if (config.noProxyEntries.some((entry) => matchesNoProxyEntry(url, entry))) { 180 return { mode: 'direct' }; 181 } 182 183 const proxyUrl = url.protocol === 'https:' ? config.httpsProxy : config.httpProxy; 184 if (!proxyUrl) return { mode: 'direct' }; 185 return { mode: 'proxy', proxyUrl }; 186 } 187 188 export function getDispatcherForUrl(url: URL, env: NodeJS.ProcessEnv = process.env): Dispatcher { 189 const config = resolveProxyConfig(env); 190 if (!config.httpProxy && !config.httpsProxy) return directDispatcher; 191 return createProxyDispatcher(config); 192 } 193 194 export async function fetchWithNodeNetwork(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> { 195 const url = resolveUrl(input); 196 if (!url || !hasProxyEnv()) { 197 return nativeFetch(input, init); 198 } 199 200 return (await undiciFetch(input as Parameters<typeof undiciFetch>[0], { 201 ...init, 202 dispatcher: getDispatcherForUrl(url), 203 } as Parameters<typeof undiciFetch>[1])) as unknown as Response; 204 } 205 206 export function installNodeNetwork(): void { 207 if (installed) return; 208 209 globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => ( 210 fetchWithNodeNetwork(input, init) 211 )) as typeof globalThis.fetch; 212 installed = true; 213 }