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.
This commit is contained in:
Marcus Castro
2026-02-28 01:41:20 -03:00
committed by Peter Steinberger
parent 5897eed6e9
commit ba3fa44c5b
3 changed files with 185 additions and 17 deletions

View File

@@ -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();
});
});

View File

@@ -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<string, unknown>),
dispatcher: agent,
}) as unknown as Promise<Response>) 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<string, unknown>),
dispatcher: agent,
}) as unknown as Promise<Response>) as typeof fetch;
} catch {
// Malformed proxy URL in env — fall back to direct fetch.
return undefined;
}
}

View File

@@ -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<string, unknown>),
dispatcher: agent,
}) as unknown as Promise<Response>) 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";