// CF-2 出口统一:每条对外 HTTP 请求走哪条出口由代码显式决定,不再隐式继承容器的 HTTP(S)_PROXY。 // // Deno 2.9 的语义(2026-09-30 本机实测,见 docs/runbook/sites.md「出口」): // - `fetch` 缺省客户端与 `Deno.createHttpClient({})` 都会读 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY(大小写都认)与 NO_PROXY; // 读取发生在**客户端创建时**(缺省客户端在进程第一次 fetch 时创建); // - `createHttpClient` 没有"不走代理"的选项,`NO_PROXY=*` 也不认;显式 `proxy` 会排在环境变量代理前面, // 但 NO_PROXY 命中的主机连显式代理也绕过; // - 所以直连客户端的做法是:创建那一刻把这几个环境变量临时拿掉(同步调用,同一个 isolate 内不会被别的代码插进来),建完立即还原。 // 代理客户端同样在拿掉环境变量后创建,保证只走给定的代理。 // // 出口表: // - 站点请求(适配器、RSS、登录、用户数据):站点开"使用代理"且设了代理地址 → 该代理;否则直连(siteClient); // - TMDB、Brave、图片缓存等外网请求:照旧用环境变量里的代理(行为不变),但改成显式读 env 建客户端(externalFetch)。 const PROXY_ENV = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const; const NO_PROXY_ENV = ["NO_PROXY", "no_proxy"] as const; /** 进程启动时的代理环境变量快照(外网请求显式使用;之后的临时改动不影响) */ const ENV_SNAPSHOT: Record = (() => { const out: Record = {}; try { for (const k of [...PROXY_ENV, ...NO_PROXY_ENV]) out[k] = Deno.env.get(k); } catch { // 没有 env 权限:当作没有代理 } return out; })(); /** 在"没有代理环境变量"的状态下同步执行 fn(建 HttpClient 用),结束立即还原 */ function withoutProxyEnv(fn: () => T): T { const saved = new Map(); try { for (const k of [...PROXY_ENV, ...NO_PROXY_ENV]) { const v = Deno.env.get(k); if (v !== undefined) { saved.set(k, v); Deno.env.delete(k); } } } catch { // 没有 env 权限时也没有可读的代理变量,直接建 } try { return fn(); } finally { for (const [k, v] of saved) Deno.env.set(k, v); } } let direct: Deno.HttpClient | null = null; const proxied = new Map(); /** 明确直连的客户端(不读任何代理环境变量) */ export function directHttpClient(): Deno.HttpClient { direct ??= withoutProxyEnv(() => Deno.createHttpClient({})); return direct; } /** 只走给定代理的客户端(同一地址复用) */ export function proxyHttpClient(url: string): Deno.HttpClient { let c = proxied.get(url); if (!c) { c = withoutProxyEnv(() => Deno.createHttpClient({ proxy: { url } })); proxied.set(url, c); } return c; } /** 用指定客户端发请求的 fetch */ export function fetchVia(client: Deno.HttpClient): typeof globalThis.fetch { return ((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, { ...init, client } as RequestInit)) as typeof globalThis.fetch; } /** 站点请求的出口:开代理且有代理地址 → proxy,否则 direct */ export interface SiteEgress { mode: "direct" | "proxy"; proxyUrl: string | null; } export function siteEgress(proxy: boolean, proxyUrl: string | null | undefined): SiteEgress { return proxy && proxyUrl ? { mode: "proxy", proxyUrl } : { mode: "direct", proxyUrl: null }; } /** 出口的比较键(不含代理口令):direct / proxy:<主机:端口> */ export function egressKey(e: SiteEgress): string { if (e.mode === "direct" || !e.proxyUrl) return "direct"; try { const u = new URL(e.proxyUrl); return `proxy:${u.protocol}//${u.host}`; } catch { return "proxy:?"; } } export function siteFetchFor(e: SiteEgress): typeof globalThis.fetch { return fetchVia(e.mode === "proxy" && e.proxyUrl ? proxyHttpClient(e.proxyUrl) : directHttpClient()); } // ---------- 外网请求(TMDB / Brave / 图片):显式按环境变量 ---------- /** NO_PROXY 匹配(与常见实现一致:逗号分隔;`.a.com` / `a.com` 匹配自身与子域;`*` 匹配全部;IP 精确匹配) */ export function noProxyMatches(host: string, noProxy: string | undefined): boolean { if (!noProxy) return false; const h = host.toLowerCase().replace(/^\[|\]$/g, ""); for (const raw of noProxy.split(",")) { const p = raw.trim().toLowerCase().replace(/:\d+$/, ""); if (!p) continue; if (p === "*") return true; const d = p.replace(/^\*?\./, ""); if (h === d || h.endsWith(`.${d}`)) return true; } return false; } /** 外网请求按环境变量应走的代理(进程启动时的快照);没有 / NO_PROXY 命中为 null */ export function envProxyFor(target: URL, env: Record = ENV_SNAPSHOT): string | null { const get = (k: string) => env[k] ?? env[k.toLowerCase()]; if (noProxyMatches(target.hostname, get("NO_PROXY"))) return null; const byScheme = target.protocol === "https:" ? get("HTTPS_PROXY") : get("HTTP_PROXY"); const p = byScheme || get("ALL_PROXY"); return p ? p : null; } /** 外网请求用的 fetch:按环境变量显式选代理 / 直连(与之前隐式继承的行为一致) */ export const externalFetch: typeof globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { let target: URL | null = null; try { target = new URL(input instanceof Request ? input.url : String(input)); } catch { // 交给 fetch 报错 } const proxy = target ? envProxyFor(target) : null; const client = proxy ? proxyHttpClient(proxy) : directHttpClient(); return globalThis.fetch(input, { ...init, client } as RequestInit); }) as typeof globalThis.fetch;