Spaces:
Paused
Paused
| // @ts-nocheck | |
| import { AsyncLocalStorage } from "node:async_hooks"; | |
| import { fetch as undiciFetch } from "undici"; | |
| import { | |
| 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<TlsFingerprintStore>(); | |
| type FetchWithDispatcherOptions = RequestInit & { dispatcher?: unknown }; | |
| type FetchWithDispatcher = ( | |
| input: RequestInfo | URL, | |
| init?: FetchWithDispatcherOptions | |
| ) => Promise<Response>; | |
| type PatchState = { | |
| originalFetch: typeof globalThis.fetch; | |
| proxyContext: AsyncLocalStorage<unknown>; | |
| 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) | |
| if (patternHost.includes("*")) { | |
| const regexStr = | |
| "^" + | |
| patternHost | |
| .split("*") | |
| .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) | |
| .join(".*") + | |
| "$"; | |
| if (new RegExp(regexStr).test(hostname)) 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"); | |
| } | |
| 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. | |
| if (resolvedProxyUrl) { | |
| 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 = {}) { | |
| 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.) | |
| return (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>)(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). | |
| try { | |
| return await (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>)(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 fallback for connection/dispatcher errors, not HTTP errors | |
| if (msg.includes("fetch failed") || msg.includes("ECONNREFUSED") || msg.includes("UND_ERR")) { | |
| console.warn(`[ProxyFetch] Undici dispatcher failed, falling back to native fetch: ${msg}`); | |
| return originalFetchWithDispatcher(input, options); | |
| } | |
| throw dispatcherError; | |
| } | |
| } | |
| try { | |
| const dispatcher = createProxyDispatcher(proxyUrl); | |
| return await (undiciFetch as unknown as (...args: unknown[]) => Promise<Response>)(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; | |
| } | |
| } | |
| 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; | |