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:
committed by
Peter Steinberger
parent
5897eed6e9
commit
ba3fa44c5b
139
src/infra/net/proxy-fetch.test.ts
Normal file
139
src/infra/net/proxy-fetch.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
45
src/infra/net/proxy-fetch.ts
Normal file
45
src/infra/net/proxy-fetch.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user