// @ts-nocheck import "./setupPolyfill.ts"; import { AsyncLocalStorage } from "node:async_hooks"; import { fetch as undiciFetch } from "undici"; import { buildVercelRelayHeaders, createProxyDispatcher, getDefaultDispatcher, normalizeProxyUrl, proxyConfigToUrl, proxyUrlForLogs, } from "./proxyDispatcher.ts"; import tlsClient from "./tlsClient.ts"; import { isProxyReachable } from "@/lib/proxyHealth"; function isTlsFingerprintEnabled() { return process.env.ENABLE_TLS_FINGERPRINT === "true"; } /** Per-request tracking of whether TLS fingerprint was used */ type TlsFingerprintStore = { used: boolean }; const tlsFingerprintContext = new AsyncLocalStorage(); type FetchWithDispatcherOptions = RequestInit & { dispatcher?: unknown }; type FetchWithDispatcher = ( input: RequestInfo | URL, init?: FetchWithDispatcherOptions ) => Promise; /** Injectable dependencies for testability (Approach B DI). */ export type ProxyFetchDeps = { undiciFetch?: FetchWithDispatcher; nativeFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; }; type PatchState = { originalFetch: typeof globalThis.fetch; proxyContext: AsyncLocalStorage; isPatched: boolean; }; const isCloud = typeof caches !== "undefined" && typeof caches === "object"; const PATCH_STATE_KEY = Symbol.for("omniroute.proxyFetch.state"); function getPatchState(): PatchState { const scopedGlobal = globalThis as typeof globalThis & { [PATCH_STATE_KEY]?: PatchState; }; if (!scopedGlobal[PATCH_STATE_KEY]) { scopedGlobal[PATCH_STATE_KEY] = { originalFetch: globalThis.fetch, proxyContext: new AsyncLocalStorage(), isPatched: false, }; } return scopedGlobal[PATCH_STATE_KEY]; } const patchState = getPatchState(); const originalFetch = patchState.originalFetch; const originalFetchWithDispatcher = originalFetch as FetchWithDispatcher; const proxyContext = patchState.proxyContext; function noProxyMatch(targetUrl) { const noProxy = process.env.NO_PROXY || process.env.no_proxy; if (!noProxy) return false; let target; try { target = new URL(targetUrl); } catch { return false; } const hostname = target.hostname.toLowerCase(); const port = target.port || (target.protocol === "https:" ? "443" : "80"); const patterns = noProxy .split(",") .map((p) => p.trim().toLowerCase()) .filter(Boolean); return patterns.some((pattern) => { if (pattern === "*") return true; const [patternHost, patternPort] = pattern.split(":"); if (patternPort && patternPort !== port) return false; if (!patternHost) return false; // Support wildcard matching (e.g. 192.168.* or *.local). // Uses a linear glob scan instead of dynamic RegExp to avoid ReDoS. if (patternHost.includes("*")) { const parts = patternHost.split("*"); let pos = 0; let ok = hostname.startsWith(parts[0]); if (ok) { pos = parts[0].length; for (let i = 1; i < parts.length && ok; i++) { const seg = parts[i]; if (i === parts.length - 1) { ok = seg === "" || (hostname.endsWith(seg) && hostname.length - seg.length >= pos); } else { const idx = seg ? hostname.indexOf(seg, pos) : pos; if (idx === -1) { ok = false; } else { pos = idx + seg.length; } } } } if (ok) return true; } if (patternHost.startsWith(".")) { return hostname.endsWith(patternHost) || hostname === patternHost.slice(1); } return hostname === patternHost || hostname.endsWith(`.${patternHost}`); }); } function isLocalAddress(hostname: string): boolean { if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return true; if (hostname.startsWith("192.168.")) return true; if (hostname.startsWith("10.")) return true; if (hostname.match(/^172\.(1[6-9]|2\d|3[0-1])\./)) return true; if (hostname.endsWith(".local") || hostname.endsWith(".lan")) return true; return false; } function resolveEnvProxyUrl(targetUrl) { if (noProxyMatch(targetUrl)) return null; let protocol; try { protocol = new URL(targetUrl).protocol; } catch { return null; } const proxyUrl = protocol === "https:" ? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy : process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy; if (!proxyUrl) return null; return normalizeProxyUrl(proxyUrl, "environment proxy"); } export function resolveProxyForRequest(targetUrl) { let target; try { target = new URL(targetUrl); } catch { target = null; } // Always bypass proxy for local/LAN addresses if (target && isLocalAddress(target.hostname.toLowerCase())) { return { source: "direct", proxyUrl: null }; } const contextProxy = proxyContext.getStore(); if (contextProxy) { return { source: "context", proxyUrl: proxyConfigToUrl(contextProxy) }; } const envProxyUrl = resolveEnvProxyUrl(targetUrl); if (envProxyUrl) { return { source: "env", proxyUrl: envProxyUrl }; } return { source: "direct", proxyUrl: null }; } function getTargetUrl(input) { if (typeof input === "string") return input; if (input && typeof input.url === "string") return input.url; return String(input); } export async function runWithProxyContext(proxyConfig, fn) { if (typeof fn !== "function") { throw new TypeError("runWithProxyContext requires a callback function"); } // Inherit existing context if no specific proxyConfig is provided const currentContext = proxyContext.getStore(); const effectiveProxyConfig = proxyConfig || currentContext || null; const resolvedProxyUrl = effectiveProxyConfig ? proxyConfigToUrl(effectiveProxyConfig) : null; // T14: Proxy Fast-Fail // Perform a short TCP reachability check before issuing upstream requests. // Skip for vercel-relay type: proxyConfigToUrl returns "https://" which is the // relay endpoint itself, not a proxy — the actual routing is handled via relay headers. const isVercelRelay = (effectiveProxyConfig as { type?: string })?.type === "vercel"; if (resolvedProxyUrl && !isVercelRelay) { const reachable = await isProxyReachable(resolvedProxyUrl); if (!reachable) { const proxyLabel = proxyUrlForLogs(resolvedProxyUrl); const err = new Error(`[Proxy Fast-Fail] Proxy unreachable: ${proxyLabel}`) as Error & { code?: string; statusCode?: number; }; err.code = "PROXY_UNREACHABLE"; err.statusCode = 503; throw err; } } return proxyContext.run(effectiveProxyConfig, async () => { if (resolvedProxyUrl && effectiveProxyConfig !== currentContext) { console.log( `[ProxyFetch] Applied request proxy context: ${proxyUrlForLogs(resolvedProxyUrl)}` ); } return fn(); }); } async function patchedFetch( input: RequestInfo | URL, options: FetchWithDispatcherOptions = {}, deps: ProxyFetchDeps = {} ) { if (options?.dispatcher) { // When a dispatcher is present, we MUST use the undici library fetch // to ensure version compatibility. Node 22 built-in fetch (undici v6) // is incompatible with undici v8 dispatchers (missing onRequestStart, etc.) const _undiciDispatcher = deps.undiciFetch ?? (undiciFetch as unknown as (...args: unknown[]) => Promise); return _undiciDispatcher(input, options); } const targetUrl = getTargetUrl(input); let resolved; try { resolved = resolveProxyForRequest(targetUrl); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[ProxyFetch] Proxy configuration error: ${message}`); throw error; } const { source, proxyUrl } = resolved; if (!proxyUrl) { // TLS fingerprint spoofing for direct connections (no proxy configured) if (isTlsFingerprintEnabled() && tlsClient.available) { try { const store = tlsFingerprintContext.getStore(); if (store) store.used = true; return await tlsClient.fetch(targetUrl, { ...options, headers: options.headers, signal: options.signal ?? undefined, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn( `[ProxyFetch] TLS fingerprint failed, falling back to native fetch: ${message}` ); const store = tlsFingerprintContext.getStore(); if (store) store.used = false; } } // Direct connection (no proxy) — use undici with custom dispatcher for timeout control. // Falls back to original native fetch if dispatcher initialization fails (#1054). // Retries once on transient dispatcher errors before falling back (fix: proxyfetch-undici-retry). // // ReadableStream/Blob body guard: if the body is non-replayable, skip the retry because // the first attempt drains the stream; a second attempt would silently send an empty body. // ReadableStream check: cast through unknown to avoid explicit-any budget (T11). const _bodyUnknown = options.body as unknown; const bodyIsStream = _bodyUnknown !== null && _bodyUnknown !== undefined && typeof _bodyUnknown === "object" && (typeof (_bodyUnknown as Record).getReader === "function" || // ReadableStream typeof (_bodyUnknown as Record).stream === "function"); // Blob const maxAttempts = bodyIsStream ? 1 : 2; const _undiciDirect = deps.undiciFetch ?? (undiciFetch as unknown as (...args: unknown[]) => Promise); const _nativeFallback = (deps.nativeFetch as FetchWithDispatcher | undefined) ?? originalFetchWithDispatcher; let lastDispatcherError: unknown = null; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await _undiciDirect(input, { ...options, dispatcher: getDefaultDispatcher(), }); } catch (dispatcherError) { const msg = dispatcherError instanceof Error ? dispatcherError.message : String(dispatcherError); // CAUTION: Do NOT fallback to native fetch if the error is a version mismatch (invalid onRequestStart) // because the native fetch will definitely fail with the undici v8 dispatcher. if (msg.includes("onRequestStart")) { console.error( `[ProxyFetch] Fatal version mismatch: Dispatcher (v8) vs Fetch (v6/native). Hardware upgrade or SOCKS5 config isolation required. Error: ${msg}` ); throw dispatcherError; } // Only retry/fallback for connection/dispatcher errors, not HTTP errors. // Prefer the .code property when available (more stable across undici // versions than message-string matching); fall back to substring match // for errors that lack a structured code. const errCode = (dispatcherError as { code?: string })?.code; if ( msg.includes("fetch failed") || errCode === "ECONNREFUSED" || msg.includes("ECONNREFUSED") || (typeof errCode === "string" && errCode.startsWith("UND_ERR")) || msg.includes("UND_ERR") ) { if (attempt === 0 && maxAttempts > 1) { // First failure — retry once with a short jittered delay before giving up. lastDispatcherError = dispatcherError; await new Promise((r) => setTimeout(r, 25 + Math.random() * 50)); continue; } // All attempts exhausted — fall back to native fetch. // Preserve original phrase intact for monitoring: "Undici dispatcher failed, falling back to native fetch" console.warn( `[ProxyFetch] Undici dispatcher failed, falling back to native fetch (after retry): ${msg}` ); return _nativeFallback(input, options); } throw dispatcherError; } } // Should not be reached, but satisfy TypeScript control-flow. throw lastDispatcherError; } // Vercel Relay: instead of routing through an HTTP proxy dispatcher, we send // relay headers to the Vercel edge function which forwards the request upstream. const contextProxy = proxyContext.getStore(); if ( contextProxy && typeof contextProxy === "object" && (contextProxy as { type?: string }).type === "vercel" ) { const vc = contextProxy as { host?: string; relayAuth?: string }; if (!vc.relayAuth) { // Generic message without internal labels — this throw can bubble up to // catch blocks that put error.message in response bodies (combo per-model // timeout, executor catch-all). Don't leak "[ProxyFetch]" diagnostics. throw new Error("Vercel relay configuration error: missing relayAuth"); } const targetUrl = getTargetUrl(input); const relayHeaders = buildVercelRelayHeaders(targetUrl, vc.relayAuth); const mergedHeaders = new Headers(options?.headers); for (const [k, v] of Object.entries(relayHeaders)) mergedHeaders.set(k, v); // Pass host through proxyUrlForLogs so the same redaction policy applies // to relay routing logs (the rest of this module already follows that rule). const hostForLogs = proxyUrlForLogs(vc.host ? `https://${vc.host}` : ""); if (process.env.OMNIROUTE_PROXY_FETCH_DEBUG === "true") { console.debug(`[ProxyFetch] Routing via Vercel relay: ${hostForLogs}`); } return await originalFetch(`https://${vc.host}`, { ...options, headers: mergedHeaders, duplex: "half", }); } try { const dispatcher = createProxyDispatcher(proxyUrl); const _undiciProxy = deps.undiciFetch ?? (undiciFetch as unknown as (...args: unknown[]) => Promise); return await _undiciProxy(input, { ...options, dispatcher, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[ProxyFetch] Proxy request failed (${source}, fail-closed): ${message}`); throw error; } } /** * Named export for proxyFetch — identical to the patched globalThis.fetch but * accepts an optional ProxyFetchDeps for unit test dependency injection. * Production code should use globalThis.fetch (or the default export) instead. */ export async function proxyFetch( input: RequestInfo | URL, options: RequestInit = {}, deps: ProxyFetchDeps = {} ): Promise { return patchedFetch(input, options as FetchWithDispatcherOptions, deps); } if (!isCloud && !patchState.isPatched) { globalThis.fetch = patchedFetch; patchState.isPatched = true; } /** * Run a function with TLS fingerprint tracking context. * After fn completes, returns { result, tlsFingerprintUsed }. */ export async function runWithTlsTracking(fn) { const store = { used: false }; const result = await tlsFingerprintContext.run(store, fn); return { result, tlsFingerprintUsed: store.used }; } /** Check if TLS fingerprint is enabled and available */ export function isTlsFingerprintActive() { return isTlsFingerprintEnabled() && tlsClient.available; } export default isCloud ? originalFetch : patchedFetch;