From ba3fa44c5b346a70a56013027aa87f824d34563f Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Sat, 28 Feb 2026 01:41:20 -0300 Subject: [PATCH] refactor: extract shared proxy-fetch utility from Telegram module Move makeProxyFetch to src/infra/net/proxy-fetch.ts and add resolveProxyFetchFromEnv which reads standard proxy env vars (HTTPS_PROXY, HTTP_PROXY, and lowercase variants) and returns a proxy-aware fetch via undici's EnvHttpProxyAgent. Telegram re-exports from the shared location to avoid duplication. --- src/infra/net/proxy-fetch.test.ts | 139 ++++++++++++++++++++++++++++++ src/infra/net/proxy-fetch.ts | 45 ++++++++++ src/telegram/proxy.ts | 18 +--- 3 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 src/infra/net/proxy-fetch.test.ts create mode 100644 src/infra/net/proxy-fetch.ts diff --git a/src/infra/net/proxy-fetch.test.ts b/src/infra/net/proxy-fetch.test.ts new file mode 100644 index 000000000..48a2e4d73 --- /dev/null +++ b/src/infra/net/proxy-fetch.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { ProxyAgent, EnvHttpProxyAgent, undiciFetch, proxyAgentSpy, envAgentSpy, getLastAgent } = + vi.hoisted(() => { + const undiciFetch = vi.fn(); + const proxyAgentSpy = vi.fn(); + const envAgentSpy = vi.fn(); + class ProxyAgent { + static lastCreated: ProxyAgent | undefined; + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + ProxyAgent.lastCreated = this; + proxyAgentSpy(proxyUrl); + } + } + class EnvHttpProxyAgent { + static lastCreated: EnvHttpProxyAgent | undefined; + constructor() { + EnvHttpProxyAgent.lastCreated = this; + envAgentSpy(); + } + } + + return { + ProxyAgent, + EnvHttpProxyAgent, + undiciFetch, + proxyAgentSpy, + envAgentSpy, + getLastAgent: () => ProxyAgent.lastCreated, + }; + }); + +vi.mock("undici", () => ({ + ProxyAgent, + EnvHttpProxyAgent, + fetch: undiciFetch, +})); + +import { makeProxyFetch, resolveProxyFetchFromEnv } from "./proxy-fetch.js"; + +describe("makeProxyFetch", () => { + beforeEach(() => vi.clearAllMocks()); + + it("uses undici fetch with ProxyAgent dispatcher", async () => { + const proxyUrl = "http://proxy.test:8080"; + undiciFetch.mockResolvedValue({ ok: true }); + + const proxyFetch = makeProxyFetch(proxyUrl); + await proxyFetch("https://api.example.com/v1/audio"); + + expect(proxyAgentSpy).toHaveBeenCalledWith(proxyUrl); + expect(undiciFetch).toHaveBeenCalledWith( + "https://api.example.com/v1/audio", + expect.objectContaining({ dispatcher: getLastAgent() }), + ); + }); +}); + +describe("resolveProxyFetchFromEnv", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.unstubAllEnvs()); + + it("returns undefined when no proxy env vars are set", () => { + vi.stubEnv("HTTPS_PROXY", ""); + vi.stubEnv("HTTP_PROXY", ""); + vi.stubEnv("https_proxy", ""); + vi.stubEnv("http_proxy", ""); + + expect(resolveProxyFetchFromEnv()).toBeUndefined(); + }); + + it("returns proxy fetch using EnvHttpProxyAgent when HTTPS_PROXY is set", async () => { + // Stub empty vars first — on Windows, process.env is case-insensitive so + // HTTPS_PROXY and https_proxy share the same slot. Value must be set LAST. + vi.stubEnv("HTTP_PROXY", ""); + vi.stubEnv("https_proxy", ""); + vi.stubEnv("http_proxy", ""); + vi.stubEnv("HTTPS_PROXY", "http://proxy.test:8080"); + undiciFetch.mockResolvedValue({ ok: true }); + + const fetchFn = resolveProxyFetchFromEnv(); + expect(fetchFn).toBeDefined(); + expect(envAgentSpy).toHaveBeenCalled(); + + await fetchFn!("https://api.example.com"); + expect(undiciFetch).toHaveBeenCalledWith( + "https://api.example.com", + expect.objectContaining({ dispatcher: EnvHttpProxyAgent.lastCreated }), + ); + }); + + it("returns proxy fetch when HTTP_PROXY is set", () => { + vi.stubEnv("HTTPS_PROXY", ""); + vi.stubEnv("https_proxy", ""); + vi.stubEnv("http_proxy", ""); + vi.stubEnv("HTTP_PROXY", "http://fallback.test:3128"); + + const fetchFn = resolveProxyFetchFromEnv(); + expect(fetchFn).toBeDefined(); + expect(envAgentSpy).toHaveBeenCalled(); + }); + + it("returns proxy fetch when lowercase https_proxy is set", () => { + vi.stubEnv("HTTPS_PROXY", ""); + vi.stubEnv("HTTP_PROXY", ""); + vi.stubEnv("http_proxy", ""); + vi.stubEnv("https_proxy", "http://lower.test:1080"); + + const fetchFn = resolveProxyFetchFromEnv(); + expect(fetchFn).toBeDefined(); + expect(envAgentSpy).toHaveBeenCalled(); + }); + + it("returns proxy fetch when lowercase http_proxy is set", () => { + vi.stubEnv("HTTPS_PROXY", ""); + vi.stubEnv("HTTP_PROXY", ""); + vi.stubEnv("https_proxy", ""); + vi.stubEnv("http_proxy", "http://lower-http.test:1080"); + + const fetchFn = resolveProxyFetchFromEnv(); + expect(fetchFn).toBeDefined(); + expect(envAgentSpy).toHaveBeenCalled(); + }); + + it("returns undefined when EnvHttpProxyAgent constructor throws", () => { + vi.stubEnv("HTTP_PROXY", ""); + vi.stubEnv("https_proxy", ""); + vi.stubEnv("http_proxy", ""); + vi.stubEnv("HTTPS_PROXY", "not-a-valid-url"); + envAgentSpy.mockImplementationOnce(() => { + throw new Error("Invalid URL"); + }); + + const fetchFn = resolveProxyFetchFromEnv(); + expect(fetchFn).toBeUndefined(); + }); +}); diff --git a/src/infra/net/proxy-fetch.ts b/src/infra/net/proxy-fetch.ts new file mode 100644 index 000000000..44738bd2e --- /dev/null +++ b/src/infra/net/proxy-fetch.ts @@ -0,0 +1,45 @@ +import { EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; + +/** + * Create a fetch function that routes requests through the given HTTP proxy. + * Uses undici's ProxyAgent under the hood. + */ +export function makeProxyFetch(proxyUrl: string): typeof fetch { + const agent = new ProxyAgent(proxyUrl); + // undici's fetch is runtime-compatible with global fetch but the types diverge + // on stream/body internals. Single cast at the boundary keeps the rest type-safe. + return ((input: RequestInfo | URL, init?: RequestInit) => + undiciFetch(input as string | URL, { + ...(init as Record), + dispatcher: agent, + }) as unknown as Promise) as typeof fetch; +} + +/** + * Resolve a proxy-aware fetch from standard environment variables + * (HTTPS_PROXY, HTTP_PROXY, https_proxy, http_proxy). + * Respects NO_PROXY / no_proxy exclusions via undici's EnvHttpProxyAgent. + * Returns undefined when no proxy is configured. + * Gracefully returns undefined if the proxy URL is malformed. + */ +export function resolveProxyFetchFromEnv(): typeof fetch | undefined { + const proxyUrl = + process.env.HTTPS_PROXY || + process.env.HTTP_PROXY || + process.env.https_proxy || + process.env.http_proxy; + if (!proxyUrl?.trim()) { + return undefined; + } + try { + const agent = new EnvHttpProxyAgent(); + return ((input: RequestInfo | URL, init?: RequestInit) => + undiciFetch(input as string | URL, { + ...(init as Record), + dispatcher: agent, + }) as unknown as Promise) as typeof fetch; + } catch { + // Malformed proxy URL in env — fall back to direct fetch. + return undefined; + } +} diff --git a/src/telegram/proxy.ts b/src/telegram/proxy.ts index d917b26f6..c4cb7129a 100644 --- a/src/telegram/proxy.ts +++ b/src/telegram/proxy.ts @@ -1,17 +1 @@ -import { ProxyAgent, fetch as undiciFetch } from "undici"; - -export function makeProxyFetch(proxyUrl: string): typeof fetch { - const agent = new ProxyAgent(proxyUrl); - // undici's fetch is runtime-compatible with global fetch but the types diverge - // on stream/body internals. Single cast at the boundary keeps the rest type-safe. - // Keep proxy dispatching request-scoped. Replacing the global dispatcher breaks - // env-driven HTTP(S)_PROXY behavior for unrelated outbound requests. - const fetcher = ((input: RequestInfo | URL, init?: RequestInit) => - undiciFetch(input as string | URL, { - ...(init as Record), - dispatcher: agent, - }) as unknown as Promise) as typeof fetch; - // Return raw proxy fetch; call sites that need AbortSignal normalization - // should opt into resolveFetch/wrapFetchWithAbortSignal once at the edge. - return fetcher; -} +export { makeProxyFetch } from "../infra/net/proxy-fetch.js";