feat(proxy): add outbound HTTP proxy support for OAuth + provider requests

- Patch Node fetch via undici ProxyAgent when HTTP_PROXY/HTTPS_PROXY/ALL_PROXY is set
- Ensure proxy patch is loaded for both chat pipeline and OAuth token exchange
- Add Dashboard Settings → Network to edit outbound proxy and apply immediately
- Persist outbound proxy settings in local db and initialize on server startup
- Move proxy helpers to src/lib/network/ for better structure
- Rename src/proxy.js → src/dashboardGuard.js to avoid naming confusion
- Re-apply proxy env after DB import
- Fix: close old dispatcher on proxy URL change to prevent connection pool leak
- Fix: idempotency guard to avoid patching globalThis.fetch multiple times

Made-with: Cursor
This commit is contained in:
gen
2026-02-28 10:11:53 +07:00
parent 833069caac
commit 5a015e5b4d
14 changed files with 450 additions and 29 deletions
+34 -22
View File
@@ -1,9 +1,8 @@
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
const originalFetch = globalThis.fetch;
let proxyAgent = null;
let socksAgent = null;
let proxyDispatcher = null;
let proxyDispatcherUrl = null;
/**
* Get proxy URL from environment
@@ -36,24 +35,36 @@ function getProxyUrl(targetUrl) {
}
/**
* Create proxy agent lazily
* Normalize proxy URL (allow host:port)
*/
async function getAgent(proxyUrl) {
const proxyProtocol = new URL(proxyUrl).protocol;
if (proxyProtocol === "socks:" || proxyProtocol === "socks5:" || proxyProtocol === "socks4:") {
if (!socksAgent) {
const { SocksProxyAgent } = await import("socks-proxy-agent");
socksAgent = new SocksProxyAgent(proxyUrl);
}
return socksAgent;
function normalizeProxyUrl(proxyUrl) {
if (!proxyUrl) return null;
try {
// eslint-disable-next-line no-new
new URL(proxyUrl);
return proxyUrl;
} catch {
// Allow "127.0.0.1:7890" style values
return `http://${proxyUrl}`;
}
if (!proxyAgent) {
const { HttpsProxyAgent } = await import("https-proxy-agent");
proxyAgent = new HttpsProxyAgent(proxyUrl);
}
/**
* Create proxy dispatcher lazily (undici-compatible)
* Closes old dispatcher when proxy URL changes to prevent connection pool leak
*/
async function getDispatcher(proxyUrl) {
const normalized = normalizeProxyUrl(proxyUrl);
if (!normalized) return null;
if (!proxyDispatcher || proxyDispatcherUrl !== normalized) {
try { proxyDispatcher?.close?.(); } catch { /* ignore */ }
const { ProxyAgent } = await import("undici");
proxyDispatcher = new ProxyAgent({ uri: normalized });
proxyDispatcherUrl = normalized;
}
return proxyAgent;
return proxyDispatcher;
}
/**
@@ -61,12 +72,12 @@ async function getAgent(proxyUrl) {
*/
async function patchedFetch(url, options = {}) {
const targetUrl = typeof url === "string" ? url : url.toString();
const proxyUrl = getProxyUrl(targetUrl);
const proxyUrl = normalizeProxyUrl(getProxyUrl(targetUrl));
if (proxyUrl) {
try {
const agent = await getAgent(proxyUrl);
return await originalFetch(url, { ...options, dispatcher: agent });
const dispatcher = await getDispatcher(proxyUrl);
return await originalFetch(url, { ...options, dispatcher });
} catch (proxyError) {
// Fallback to direct connection if proxy fails
console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`);
@@ -77,7 +88,8 @@ async function patchedFetch(url, options = {}) {
return originalFetch(url, options);
}
if (!isCloud) {
// Idempotency guard — only patch once to avoid wrapping multiple times
if (!isCloud && globalThis.fetch !== patchedFetch) {
globalThis.fetch = patchedFetch;
}