Files
OmniRoute/open-sse/utils/proxyDispatcher.ts
Paijo 6c0437f136 fix(proxy): restore connection pooling on proxy/relay paths (#9100) (#9158)
Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log
2026-08-05 21:43:55 -03:00

566 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "./setupPolyfill.ts";
import { Agent, ProxyAgent, type Dispatcher } from "undici";
import { socksDispatcher } from "fetch-socks";
import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
import { stripIpv6Brackets, detectIpLiteralFamily, parseProxyFamily } from "./proxyFamily.ts";
import { createSocksDispatcherWithFamily } from "./socksConnectorWithFamily.ts";
import {
clearDispatcherCache,
createRoundRobinDispatcher,
getDefaultCachedDispatcher,
getDispatcherCache,
getRetryCachedDispatcher,
setDefaultCachedDispatcher,
setDispatcherCacheEntry,
setRetryCachedDispatcher,
} from "./proxyDispatcherCache.ts";
export { __cacheProxyDispatcherForTest, clearDispatcherCache } from "./proxyDispatcherCache.ts";
const SUPPORTED_PROTOCOLS = new Set(["http:", "https:", "socks5:"]);
// Edge-relay proxy types. These do NOT go through an HTTP/SOCKS dispatcher —
// the caller wraps the upstream URL with buildRelayHeaders() and fetches the
// relay endpoint directly. Keep this set as the single source of truth so
// every dispatch decision stays in sync when a new relay backend lands.
export const RELAY_TYPES: ReadonlySet<string> = new Set(["vercel", "deno", "cloudflare"]);
export function isRelayType(type: string | undefined | null): boolean {
return typeof type === "string" && RELAY_TYPES.has(type);
}
const DEFAULT_PROXY_DISPATCHER_CONNECTIONS = 32;
const MAX_PROXY_DISPATCHER_CONNECTIONS = 256;
type SocksDispatcherOptions = {
type: number;
host: string;
port: number;
userId?: string;
password?: string;
};
type ProxyConfigObject = {
type?: string;
host?: string;
port?: string | number | null;
username?: string;
password?: string;
family?: string;
};
function getDispatcherOptions() {
const timeouts = getUpstreamTimeoutConfig(process.env, (message) => {
console.warn(`[ProxyDispatcher] ${message}`);
});
return {
headersTimeout: timeouts.fetchHeadersTimeoutMs,
bodyTimeout: timeouts.fetchBodyTimeoutMs,
connectTimeout: timeouts.fetchConnectTimeoutMs,
keepAliveTimeout: timeouts.fetchKeepAliveTimeoutMs,
// Without this, an upstream Keep-Alive: timeout=N header clamps
// keepAliveTimeout UP to undici's default keepAliveMaxTimeout (600 s),
// completely overriding the configured 1 s and restoring zombie-socket risk.
keepAliveMaxTimeout: timeouts.fetchKeepAliveTimeoutMs,
// 9router#1237: RFC 8305 Happy Eyeballs. undici does not
// enable it by default, so when DNS returns both AAAA (IPv6) and A (IPv4)
// and the IPv6 route is broken (e.g. NAT64 `64:ff9b::` without routing),
// the direct egress connect hangs until ETIMEDOUT — even though `curl`
// (which has Happy Eyeballs) reaches the same host. Race both families and
// use whichever connects first. The proxy path pins family via `proxyTls`
// and ProxyAgent ignores `connect`, so this only affects direct egress.
// undici types `connect` as a union whose TcpNetConnectOpts member nominally
// requires `port`; at runtime undici merges these into net.connect (the origin
// already carries host:port), so the partial pin is valid — cast to suppress
// the spurious missing-`port` error, mirroring the `proxyTls` cast below.
connect: {
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 1000,
} as ProxyAgent.Options["proxyTls"],
};
}
export function getProxyDispatcherConnectionLimit(
env: Record<string, string | undefined> = process.env
): number {
const raw = env.OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS;
if (raw == null || raw.trim() === "") return DEFAULT_PROXY_DISPATCHER_CONNECTIONS;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 1) {
console.warn(
`[ProxyDispatcher] Invalid OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS="${raw}". Using default ${DEFAULT_PROXY_DISPATCHER_CONNECTIONS}.`
);
return DEFAULT_PROXY_DISPATCHER_CONNECTIONS;
}
return Math.min(Math.floor(parsed), MAX_PROXY_DISPATCHER_CONNECTIONS);
}
function getProxyDispatcherOptions(env: Record<string, string | undefined> = process.env) {
const options = getDispatcherOptions();
// #9100: restore keep-alive on the proxy path. The previous hard-coded
// keepAliveTimeout: 1 (1ms) destroyed the pooled socket right after every
// response, forcing a fresh TCP+TLS+CONNECT handshake per request. Proxies
// that throttle connection churn then serialized concurrent requests behind
// ~30s stalls (5 concurrent → 1 fast + 4× ~29.5s). The socket now stays
// alive for at least 30s (the default fetchKeepAliveTimeoutMs is 4s), and
// keepAliveMaxTimeout is raised so an upstream Keep-Alive header cannot
// clamp it back down to a sub-second value.
//
// Stale pooled sockets (a proxy that silently drops idle ones) are recovered
// by the retry-once-with-fresh-socket path in proxyFetch.ts (mirrors the
// direct-path #4252 fix) instead of by killing all idle sockets after 1ms.
//
// Pipelining 4 lets concurrent SSE streams multiplex over the pooled
// connection instead of each opening its own socket (#4163 regression).
return {
...options,
connections: getProxyDispatcherConnectionLimit(env),
keepAliveTimeout: Math.max(options.keepAliveTimeout, 30_000),
keepAliveMaxTimeout: Math.max(options.keepAliveMaxTimeout, 60_000),
pipelining: 4,
};
}
export function getDefaultDispatcherConnectionLimit(
env: Record<string, string | undefined> = process.env
): number {
const raw = env.OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS;
if (raw == null || raw.trim() === "") return DEFAULT_PROXY_DISPATCHER_CONNECTIONS;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 1) {
console.warn(
`[ProxyDispatcher] Invalid OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS="${raw}". Using default ${DEFAULT_PROXY_DISPATCHER_CONNECTIONS}.`
);
return DEFAULT_PROXY_DISPATCHER_CONNECTIONS;
}
return Math.min(Math.floor(parsed), MAX_PROXY_DISPATCHER_CONNECTIONS);
}
function getDefaultDispatcherOptions(env: Record<string, string | undefined> = process.env) {
const options = getDispatcherOptions();
// #4580 — On the direct egress path, undici's default pipelining (1) let a long
// SSE stream monopolize the single pooled socket per origin. Keep the public
// connection-limit option here, but getDefaultDispatcher() fans it out across
// independent one-connection Agents; in production traces, one multi-connection
// Agent could still queue same-origin Codex streams behind prior trailers.
return {
...options,
connections: getDefaultDispatcherConnectionLimit(env),
pipelining: 0,
};
}
function createRoundRobinDirectDispatcher(connectionLimit: number): Dispatcher {
const baseOptions = getDispatcherOptions();
const perAgentOptions = {
...baseOptions,
connections: 1,
pipelining: 0,
};
const dispatchers = Array.from({ length: connectionLimit }, () => new Agent(perAgentOptions));
return createRoundRobinDispatcher(dispatchers);
}
export function getDefaultDispatcher(): Dispatcher {
let dispatcher = getDefaultCachedDispatcher();
if (!dispatcher) {
dispatcher = createRoundRobinDirectDispatcher(getDefaultDispatcherConnectionLimit());
setDefaultCachedDispatcher(dispatcher);
}
return dispatcher;
}
/**
* Dispatcher for RETRYING a direct request that just failed with a transient
* socket error (UND_ERR_SOCKET / "other side closed" / ECONNRESET).
*
* The default direct dispatcher pools keep-alive sockets for up to
* `fetchKeepAliveTimeoutMs` (4 s). Edges such as nvidia / opencode-zen silently
* close idle keep-alive sockets within that window, so the next request that
* reuses the pooled socket fails — and these failures arrive in bursts (#4252).
* Retrying on the SAME pooled dispatcher can grab ANOTHER stale socket, so the
* retry uses this no-keep-alive / no-pipelining dispatcher (mirroring the proxy
* dispatcher mitigation) to force a fresh socket. Healthy keep-alive reuse on
* the first attempt is preserved — only the retry pays the fresh-socket cost.
*/
export function getRetryDispatcher(): Dispatcher {
let dispatcher = getRetryCachedDispatcher();
if (!dispatcher) {
dispatcher = new Agent({
...getDispatcherOptions(),
keepAliveTimeout: 1,
keepAliveMaxTimeout: 1,
pipelining: 0,
});
setRetryCachedDispatcher(dispatcher);
}
return dispatcher;
}
/**
* Extract the port from a proxy URL string before URL parsing.
* `new URL("http://host:80")` strips port 80 since it's the HTTP default,
* but proxy servers commonly listen on port 80/443, so we need to preserve it.
*/
function extractExplicitPort(urlStr: string): string | null {
try {
const idx = urlStr.indexOf("://");
if (idx === -1) return null;
const authorityStart = idx + 3;
const authorityEnd = urlStr.indexOf("/", authorityStart);
const authority =
authorityEnd === -1
? urlStr.slice(authorityStart)
: urlStr.slice(authorityStart, authorityEnd);
const lastColon = authority.lastIndexOf(":");
const atSign = authority.lastIndexOf("@");
if (lastColon !== -1 && lastColon > atSign) {
const portStr = authority.slice(lastColon + 1);
if (/^\d+$/.test(portStr)) {
const port = Number(portStr);
if (Number.isInteger(port) && port >= 1 && port <= 65535) return String(port);
}
}
} catch {}
return null;
}
function defaultPortForProtocol(protocol: string): string {
if (protocol === "https:" || protocol === "wss:") return "443";
if (protocol === "socks5:") return "1080";
return "8080";
}
function normalizePort(port: string | number | null | undefined, protocol: string): string {
if (!port) return defaultPortForProtocol(protocol);
const parsed = Number(port);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("[ProxyDispatcher] Invalid proxy port");
}
return String(parsed);
}
/**
* Build a proxy URL string manually from parsed URL components.
* We cannot use URL.toString() because the URL serializer silently strips
* default ports (80 for http, 443 for https). Proxy servers commonly
* listen on these ports, so we must always include the port explicitly.
*/
function buildProxyUrlString(parsed: URL, port: string): string {
const auth = parsed.username
? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@`
: "";
return `${parsed.protocol}//${auth}${parsed.hostname}:${port}`;
}
/**
* SOCKS5 proxy support defaults ON (opt-OUT). A fresh deploy with no env set
* should honour SOCKS5 proxies out of the box — they were silently rejected
* before (default OFF), making accounts fall back to the host IP. Only an
* explicit falsey value (false/0/no/off) disables it.
*/
export function isSocks5ProxyEnabled(): boolean {
const raw = (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase();
return !["false", "0", "no", "off"].includes(raw);
}
export function proxyUrlForLogs(proxyUrl: string): string {
const explicitPort = extractExplicitPort(proxyUrl);
const parsed = new URL(proxyUrl);
const port = explicitPort || parsed.port || defaultPortForProtocol(parsed.protocol);
return `${parsed.protocol}//${parsed.hostname}:${port}`;
}
export function normalizeProxyUrl(
proxyUrl: string,
source = "proxy",
{ allowSocks5 = isSocks5ProxyEnabled() } = {}
): string {
// Strip a trailing synthetic `?family=ipv4|ipv6` marker BEFORE anything else.
// `extractExplicitPort` slices the authority off the raw string, so a marker
// turns the port substring into e.g. `80?family=ipv6`, which fails the digit
// test and silently falls back to the default port (8080 for http) — rewriting
// an http:80 proxy to :8080. We work on the marker-free string for both port
// extraction and URL parsing, then re-append the marker exactly once below.
const familyMatch = proxyUrl.match(/\?family=(ipv4|ipv6)$/);
const familySuffix = familyMatch ? familyMatch[0] : "";
const baseUrl = familySuffix ? proxyUrl.slice(0, -familySuffix.length) : proxyUrl;
// Extract the explicit port from the raw URL string BEFORE parsing,
// because `new URL()` silently strips default ports (80 for http,
// 443 for https), which are valid and common for proxy servers.
const explicitPort = extractExplicitPort(baseUrl);
let parsed;
try {
parsed = new URL(baseUrl);
} catch {
throw new Error(`[ProxyDispatcher] Invalid ${source} URL`);
}
if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) {
throw new Error(
`[ProxyDispatcher] Unsupported ${source} protocol: ${parsed.protocol.replace(":", "")}`
);
}
if (parsed.protocol === "socks5:" && !allowSocks5) {
throw new Error(
"[ProxyDispatcher] SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)"
);
}
if (!parsed.hostname) {
throw new Error(`[ProxyDispatcher] Invalid ${source} host`);
}
// Use the explicit port from the raw string if present, otherwise apply default.
const port = explicitPort || normalizePort(parsed.port, parsed.protocol);
// Build the URL string manually instead of using parsed.toString(),
// which would strip default ports (80/443) and break the proxy connection.
// Preserve a synthetic `?family=` directive (the only query param we emit)
// so the connect-family pin survives normalization and reaches the dispatcher.
// The directive may arrive either as the stripped trailing marker (familySuffix)
// or as an inline query on `baseUrl`; resolve both, append the marker once.
const fam = parseProxyFamily(
(familyMatch ? familyMatch[1] : parsed.searchParams.get("family")) ?? undefined
);
const base = buildProxyUrlString(parsed, port);
return fam === "auto" ? base : `${base}?family=${fam}`;
}
export function buildVercelRelayHeaders(
targetUrl: string,
relayAuth: string
): Record<string, string> {
const parsed = new URL(targetUrl);
return {
"x-relay-target": `${parsed.protocol}//${parsed.host}`,
"x-relay-path": parsed.pathname + parsed.search,
"x-relay-auth": relayAuth,
};
}
// Vercel + Deno Deploy share the same x-relay-{target,path,auth} envelope.
// Use this alias when the call is intentionally backend-agnostic; the named
// vercel-specific export above stays for direct callers that already use it.
export const buildRelayHeaders = buildVercelRelayHeaders;
export function proxyConfigToUrl(
proxyConfig: unknown,
{ allowSocks5 = isSocks5ProxyEnabled() } = {}
): string | null {
if (!proxyConfig) return null;
if (typeof proxyConfig === "string") {
return normalizeProxyUrl(proxyConfig, "context proxy", { allowSocks5 });
}
if (typeof proxyConfig !== "object" || Array.isArray(proxyConfig)) {
throw new Error("[ProxyDispatcher] Invalid context proxy config");
}
const config = proxyConfig as ProxyConfigObject;
// Partial / empty config object — treat as no proxy instead of crashing
if (!config.host) return null;
const type = String(config.type || "http").toLowerCase();
// Edge-relay entries (vercel / deno / cloudflare) carry the relay URL in
// `host` — no dispatcher needed; callers should use buildRelayHeaders() and
// fetch the relay endpoint directly. All relay types share the exact same
// x-relay-target / x-relay-path / x-relay-auth header spec (only the
// deployment target differs).
if (RELAY_TYPES.has(type)) {
return config.host ? `https://${config.host}` : null;
}
const protocol = `${type}:`;
if (!SUPPORTED_PROTOCOLS.has(protocol)) {
throw new Error(`[ProxyDispatcher] Unsupported context proxy protocol: ${type}`);
}
if (protocol === "socks5:" && !allowSocks5) {
throw new Error(
"[ProxyDispatcher] SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)"
);
}
const port = normalizePort(config.port, protocol);
// Build the URL string manually to preserve the port through normalization.
const auth = config.username
? `${encodeURIComponent(config.username)}:${config.password ? encodeURIComponent(config.password) : ""}@`
: "";
const proxyUrlStr = `${type}://${auth}${config.host}:${port}`;
const fam = parseProxyFamily(config.family);
const normalized = normalizeProxyUrl(proxyUrlStr, "context proxy", { allowSocks5 });
return fam === "auto" ? normalized : `${normalized}?family=${fam}`;
}
/** Resolve the concrete connect family for a proxy URL, fail-closed on contradictions. */
function resolveDispatcherFamily(parsed: URL): 4 | 6 | null {
const directive = parseProxyFamily(parsed.searchParams.get("family") ?? undefined);
const literal = detectIpLiteralFamily(parsed.hostname);
if (directive === "auto") return literal;
const want = directive === "ipv6" ? 6 : 4;
if (literal !== null && literal !== want) {
throw new Error(
`[ProxyDispatcher] Proxy family directive ${directive} contradicts ${literal === 6 ? "IPv6" : "IPv4"} literal host`
);
}
return want;
}
/** Test-only accessor for the resolved family. */
export function __resolveDispatcherFamilyForTest(proxyUrl: string): 4 | 6 | null {
return resolveDispatcherFamily(new URL(proxyUrl));
}
/** Test-only accessor for proxy dispatcher pool options. */
export function __getProxyDispatcherOptionsForTest(
env: Record<string, string | undefined> = process.env
) {
return getProxyDispatcherOptions(env);
}
export function __getDefaultDispatcherOptionsForTest(
env: Record<string, string | undefined> = process.env
) {
return getDefaultDispatcherOptions(env);
}
export function __createRoundRobinDispatcherForTest(dispatchers: Dispatcher[]): Dispatcher {
return createRoundRobinDispatcher(dispatchers);
}
/**
* Build a ProxyAgent / socks dispatcher for a normalized proxy URL using the
* given options. Shared by the pooled dispatcher (keep-alive, pipelining 4)
* and the retry dispatcher (fresh no-keep-alive socket, mirrors #4252).
*/
function buildProxyDispatcher(
normalizedUrl: string,
options: ReturnType<typeof getProxyDispatcherOptions>
): Dispatcher {
const parsed = new URL(normalizedUrl);
const family = resolveDispatcherFamily(parsed);
parsed.searchParams.delete("family");
const cleanUri = normalizedUrl.replace(/\?family=(ipv4|ipv6)$/, "");
const explicitPort = extractExplicitPort(cleanUri);
const port = explicitPort || normalizePort(parsed.port, parsed.protocol);
if (parsed.protocol === "socks5:") {
const socksOptions: SocksDispatcherOptions = {
type: 5,
host: stripIpv6Brackets(parsed.hostname),
port: Number(port),
};
if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username);
if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password);
return family === null
? (socksDispatcher(
socksOptions as Parameters<typeof socksDispatcher>[0],
options
) as Dispatcher)
: createSocksDispatcherWithFamily(
socksOptions as unknown as Parameters<typeof createSocksDispatcherWithFamily>[0],
family,
options
);
}
// ProxyAgent omits `connect`; the client->proxy socket is built from `proxyTls`.
// undici 8.4.1 types `proxyTls?: buildConnector.BuildOptions`, a union whose
// `TcpNetConnectOpts` member nominally requires `port` — so TS rejects a bare
// `{ family, autoSelectFamily }` pin. At runtime undici merges these options into
// net.connect (the uri already carries the host:port), so the partial pin is
// valid; the cast suppresses the spurious missing-`port` error.
return new ProxyAgent({
uri: cleanUri,
// undici 8.6+ forwards plain-HTTP requests through the proxy as an origin
// request (GET http://host/…) instead of a CONNECT tunnel; upstream proxies
// that only speak CONNECT then reject it (501). OmniRoute tunnels ALL proxied
// traffic (HTTP + HTTPS) via CONNECT, so force tunneling. Unknown option on
// undici <8.6 → silently ignored (that version already tunneled by default).
proxyTunnel: true,
...options,
...(family !== null
? { proxyTls: { family, autoSelectFamily: false } as ProxyAgent.Options["proxyTls"] }
: {}),
});
}
export function createProxyDispatcher(proxyUrl: string): Dispatcher {
const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher");
const dispatcherCache = getDispatcherCache();
let dispatcher = dispatcherCache.get(normalizedUrl);
if (dispatcher) return dispatcher;
dispatcher = buildProxyDispatcher(normalizedUrl, getProxyDispatcherOptions());
// A concurrent caller may have built + cached the same URL while we were
// building. If so, drop our duplicate (avoid leaking sockets) and reuse theirs.
const winner = dispatcherCache.get(normalizedUrl);
if (winner) {
void dispatcher.close().catch(() => {});
return winner;
}
setDispatcherCacheEntry(normalizedUrl, dispatcher);
return dispatcher;
}
/**
* Dispatcher for RETRYING a proxied request that just failed with a transient
* socket error. Mirrors {@link getRetryDispatcher} for the direct path (#4252):
* the retry forces a FRESH socket by disabling keep-alive and pipelining, so a
* stale pooled socket (a proxy that silently dropped it) is recovered instead
* of re-hitting the dead connection. Cached per normalized proxy URL.
*/
export function getProxyRetryDispatcher(proxyUrl: string): Dispatcher {
const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher");
const dispatcherCache = getDispatcherCache();
const retryKey = `retry:${normalizedUrl}`;
let dispatcher = dispatcherCache.get(retryKey);
if (dispatcher) return dispatcher;
dispatcher = buildProxyDispatcher(normalizedUrl, {
...getProxyDispatcherOptions(),
// Retry needs exactly one fresh socket (not the inherited connection pool).
connections: 1,
keepAliveTimeout: 1,
keepAliveMaxTimeout: 1,
pipelining: 0,
});
const winner = dispatcherCache.get(retryKey);
if (winner) {
void dispatcher.close().catch(() => {});
return winner;
}
setDispatcherCacheEntry(retryKey, dispatcher);
return dispatcher;
}
/** Test-only: returns the SOCKS dispatcher options that would be built for a URL. */
export function __getSocksOptionsForTest(proxyUrl: string): SocksDispatcherOptions {
const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher");
const parsed = new URL(normalizedUrl);
parsed.searchParams.delete("family");
const explicitPort = extractExplicitPort(normalizedUrl);
const port = explicitPort || normalizePort(parsed.port, parsed.protocol);
const socksOptions: SocksDispatcherOptions = {
type: 5,
host: stripIpv6Brackets(parsed.hostname),
port: Number(port),
};
if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username);
if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password);
return socksOptions;
}