/ src / node-network.ts
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  }