Files
OmniRoute/open-sse/utils/proxyDispatcher.ts
Diego Rodrigues de Sa e Souza dc40911583 Release v3.8.39 (#5164)
* chore(release): open v3.8.39 development cycle

* docs(changelog): backfill 5 v3.8.38 bullets merged after release finalize

These PRs squash-merged into release/v3.8.38 between the CHANGELOG finalize
(ff57be32f) and the merge-to-main (ae6e2342d), so they shipped in the v3.8.38
tag but had no bullet:

- feat(compression): Ionizer engine (lossy JSON-array sampling + CCR) (#5148)
- fix(sse): preserve non-stream reasoning fields (#5155, @rdself)
- fix(i18n): add missing English UI labels (#5153, @rdself)
- test(combo): gated live smoke (#5151) + release-expectations refresh (#5150, @KooshaPari)

(#5129 exact-host Anthropic baseUrl is already covered by the #5130 bullet — same CodeQL #674.)
Synced 41 i18n CHANGELOG mirrors.

* feat(compression): TOON best-of-N candidate encoder + encoder A/B table (#5163)

Integrated into release/v3.8.39. TOON best-of-N candidate encoder (GCF default, fail-open). 17/17 unit tests pass on merge result; CI reds were base-stale + Quality Ratchet DRIFT.

* fix(zenmux): normalize vendor-prefixed GLM system roles (#5158)

Integrated into release/v3.8.39. ZenMux vendor-prefixed GLM system-role normalization; 12/12 role-normalizer tests pass on merge result. CI reds base-stale.

* [codex] fix xAI OAuth test and reasoning effort (#5157)

Integrated into release/v3.8.39. xAI reasoning-effort normalization (max/xhigh→high) + OAuth test config; 46/46 xai-translator tests pass on merge result. CI reds base-stale.

* docs(i18n): add Traditional Chinese (zh-TW) README and update zh-CN to latest (#5162)

Integrated into release/v3.8.39. Traditional Chinese (zh-TW) README + zh-CN refresh; docs-only.

* test(security): guard PII redaction stays opt-in (default off) + Hard Rule #20 (#5159)

Integrated into release/v3.8.39. PII opt-in regression guard + Hard Rule #20; rebased to strip base-drift (+81/-1). 5/5 guard tests pass; flip-proof verified.

* test(combo): deterministic context-relay universal-handoff coverage (closes phase-2 TODO) (#5168)

Integrated into release/v3.8.39. Deterministic context-relay universal-handoff coverage (3 tests); 3/3 pass on merge result.

* docs(i18n): full sync zh-TW and zh-CN README with canonical English v3.8.39 (#5171)

Integrated into release/v3.8.39. Full zh-TW docs tree + zh-CN sync with canonical English v3.8.39; docs-only.

* fix(serve): honour HOSTNAME from .env instead of hardcoding 0.0.0.0 (#5134) (#5170)

Integrated into release/v3.8.39. HOSTNAME env override in serve (#5134) + regression test (4/4, TDD flip-proof verified).

* fix(sse): resolve nameless deepseek-web tool blocks via parameter-schema match (#5154) (#5173)

Integrated into release/v3.8.39. Schema-based nameless deepseek-web tool-block resolution (#5154); 6/6 tests pass on merge result (incl. ambiguous/no-match negatives + named-tag no-regression).

* fix(sse): normalize array user content for Command Code to avoid upstream 400 (#5166) (#5174)

Integrated into release/v3.8.39. Normalize array user content for Command Code (#5166, user-array/400 symptom); 4/4 tests pass on merge result.

* fix(sse): defer </think> close so it never leaks before tool_calls (#5123) (#5175)

Integrated into release/v3.8.39. Defer </think> close so it never leaks before tool_calls (#5123); 4/4 tests pass (incl. #4633 no-regression). CHANGELOG synced to keep all 3 v3.8.39 fixes.

* fix(dashboard): use amber for home update-step warning icon (#5176)

Integrated into release/v3.8.39. Amber for home update-step warning icon; 1/1 UI test.

* fix(api): LAN/Tailscale dashboard — host-aware CSP + GET-exempt version route + combo field errors (#5083) (#5177)

Integrated into release/v3.8.39. Host-aware CSP (ReDoS/injection-safe host validation) + GET-exempt /api/system/version (POST/spawn stays LOCAL_ONLY, exact-match safe-methods-only) + COMBO_002 firstField. 44/44 tests + route-guard membership gate green. CHANGELOG synced to keep all 4 v3.8.39 fixes.

* fix(api): replace #5083 global middleware CSP with declarative ws: scheme (#5083)

Follow-up to PR #5177 (merged): that version implemented the LAN-CSP fix (Bug 1)
with a new global `src/middleware.ts` + `src/server/csp.ts`, which contradicts the
project's documented architecture — 'No global Next.js middleware — interception is
route-specific' (CLAUDE.md / AGENTS.md) — and was merged unverified (middleware vs
next.config header precedence was never confirmed in a real build).

This replaces that approach with the minimal, declarative equivalent:
  • next.config.mjs: connect-src now permits the bare `ws:` scheme (symmetric with the
    bare `wss:` already allowed) so the dashboard can reach its own Live WS server from
    a LAN/Tailscale host. No middleware.
  • Removes src/middleware.ts, src/server/csp.ts, and tests/unit/csp-host-aware.test.ts.
  • Adds tests/unit/csp-lan-ws-5083.test.ts (incl. a guard asserting src/middleware.ts
    does NOT exist, so the global-middleware approach cannot silently return).

Bugs 2 (GET-exempt /api/system/version) and 3 (COMBO_002 field surfacing) from #5177
are unaffected and remain in place.

Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com>

* test(combo): end-to-end quota-share DRR routing-decision coverage (matrix parity) (#5179)

Integrated into release/v3.8.39. Quota-share DRR routing-decision coverage (matrix parity); 2/2 pass on merge result.

* feat(agent-bridge): graceful cert-install fallback with manual guide for containers (#4546) (#5178)

Integrated into release/v3.8.39. Agent-bridge graceful cert-install fallback + manual guide (#4546); 6/6 tests pass on merge result.

* fix(antigravity): family-scoped quota lockout (gemini/claude buckets) (#5180)

Integrated into release/v3.8.39 — family-scoped antigravity quota lockout. Rebased from v3.8.37 + validated (vitest 5/5, typecheck clean, full combo-matrix green, model-lockout 99/0). Same-model cross-account retry (chat.ts) deferred pending live antigravity VPS validation.

* fix(cli): force NODE_ENV to match dev/start run mode in custom Next server (#5189)

Integrated into release/v3.8.39. Force NODE_ENV to match dev/start run mode in custom Next server; 2/2 source-scan+ordering tests pass on merge result.

* feat(compression): CCR ranged/grep/stats retrieval (ReDoS-safe, backward-compat) (#5187)

Integrated into release/v3.8.39. CCR ranged/grep/stats retrieval (safe-regex ReDoS guard + length/match caps); 17/17 tests pass on merge result.

* docs(combo): sync all combo/routing-strategy docs to current state + document test coverage (#5185)

Integrated into release/v3.8.39. Combo/routing-strategy docs sync; docs-only.

* fix(mcp): return 404 (not 400) for unknown Streamable HTTP session id (#5169) (#5191)

* fix(api): respect blocked Auto (Zero-Config) provider in /v1/models catalog (#5192) (#5194)

* test(combo): deterministic context-relay codex quota-handoff coverage (closes last gap) (#5195)

* test(ci): wire antigravity-quota-family under test:vitest (fix test-discovery orphan) (#5196)

* fix(oauth): antigravity login no longer hangs — fire-and-forget onboarding + bounded post-exchange (#5193)

Antigravity OAuth hang fix (no-PKCE/no-openid + bounded post-exchange + exchange-500 fix). Includes #5200 (Koosha) revert + owner rebaseline to keep documented comments. Integrated into release/v3.8.39.

* feat(oauth): remote Antigravity login via local helper + paste-credentials (#5203)

Remote Antigravity login: local helper (omniroute login antigravity) + paste-credentials. Integrated into release/v3.8.39.

* fix(translator): accept Claude Messages shape in non-stream malformed-200 guard (#5156)

Integrated into release/v3.8.39

* fix(cli): default dev bundler to Turbopack (16.2.x panic no longer reproduces) (#5206)

Integrated into release/v3.8.39

* fix(cli): auto-calibrate server V8 heap from physical RAM (#5172) (#5213)

The server was spawned with a fixed --max-old-space-size=512 (omniroute serve)
or no heap flag at all (Electron), so RAM-rich boxes still OOM-crashed under
load (Ineffective mark-compacts near heap limit ~500MB) with many providers/
accounts and large model catalogs. New calibrateHeapFallbackMb(os.totalmem())
defaults the heap to ~35% of RAM clamped [512,4096], wired into serve.mjs and
electron/main.js. Explicit OMNIROUTE_MEMORY_MB still wins (#2939 unchanged).

Also addresses #5160 (same OOM root); #5152 (docker) benefits via the same knob.

Closes #5172

* fix(proxy): coalesce fast-fail health probes (#5208)

Integrated into release/v3.8.39

* fix(proxy): close dispatchers when clearing cache (#5202)

Integrated into release/v3.8.39

* fix(cli): raise dev server Node heap limit to 8GB to prevent OOM (#5198)

Integrated into release/v3.8.39

* fix(auth): allow synthetic no-auth fallback for mimocode (#5205)

Integrated into release/v3.8.39

* fix(oauth): preserve Antigravity refresh_token on empty/omitted upstream response (#3850) (#5214)

Google's OAuth refresh tokens are non-rotating: the refresh response usually
omits refresh_token and occasionally returns it as an empty string. The
Antigravity executor used `typeof tokens.refresh_token === "string" ? ... `
which accepts "" (typeof "" === "string") and overwrote the stored token with
empty, nulling it on first refresh. Now treats non-string OR empty as absent and
preserves credentials.refreshToken, matching refreshGoogleToken semantics.

Closes #3850

* fix(responses): normalize non-array input (#5204)

Integrated into release/v3.8.39

* fix(stream): normalize safety finish reasons via shared helper (#5197)

Integrated into release/v3.8.39

* fix(request-logger): never render negative '(-100%)' compression badge (#5201)

Integrated into release/v3.8.39

* fix(combo): reject empty responses api output (#5207)

Integrated into release/v3.8.39 — combo failover now rejects empty Responses API output (validateQuality). Baseline rebaseline dropped (main-measured drift; maintainer rebaselines at release).

* fix(pwa): prefer cached navigation before offline page (#5209)

Integrated into release/v3.8.39 — PWA service worker prefers cached navigation before offline page (#5165).

* chore(release): v3.8.39 — 2026-06-28

* chore(release): rebaseline openapi+i18n coverage ratchet drift for v3.8.39

---------

Co-authored-by: Arthur Bodera <abodera@gmail.com>
Co-authored-by: Nguyen Minh <lop123thcs@gmail.com>
Co-authored-by: lunkerchen <labanchen@gmail.com>
Co-authored-by: Ankit <177378174+anki1kr@users.noreply.github.com>
Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com>
Co-authored-by: Ardem2025 <ardemb22@gmail.com>
Co-authored-by: backryun <bakryun0718@proton.me>
Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Wilson <pedbookmed@gmail.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
2026-06-28 06:58:29 -03:00

487 lines
19 KiB
TypeScript

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,
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,
};
}
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();
// Disable keep-alive and pipelining for proxy connections.
// Cheap proxy servers aggressively drop idle sockets without sending TCP RST,
// causing "socket hang up" or "Client network socket disconnected" errors
// on subsequent requests that try to reuse the pooled connection.
//
// Keep multiple connections available anyway: with pipelining disabled, long
// SSE streams such as Codex /v1/responses otherwise bottleneck through the
// cached proxy dispatcher under concurrency (#4163).
return {
...options,
connections: getProxyDispatcherConnectionLimit(env),
keepAliveTimeout: 1,
keepAliveMaxTimeout: 1,
pipelining: 0,
};
}
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);
}
export function createProxyDispatcher(proxyUrl: string): Dispatcher {
const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher");
const dispatcherCache = getDispatcherCache();
const proxyDispatcherOptions = getProxyDispatcherOptions();
let dispatcher = dispatcherCache.get(normalizedUrl);
if (dispatcher) return 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);
dispatcher =
family === null
? (socksDispatcher(
socksOptions as Parameters<typeof socksDispatcher>[0],
proxyDispatcherOptions
) as Dispatcher)
: createSocksDispatcherWithFamily(
socksOptions as unknown as Parameters<typeof createSocksDispatcherWithFamily>[0],
family,
proxyDispatcherOptions
);
} else {
// 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.
dispatcher = new ProxyAgent({
uri: cleanUri,
...proxyDispatcherOptions,
...(family !== null
? { proxyTls: { family, autoSelectFamily: false } as ProxyAgent.Options["proxyTls"] }
: {}),
});
}
dispatcherCache.set(normalizedUrl, 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;
}