142 lines
5.9 KiB
TypeScript
142 lines
5.9 KiB
TypeScript
// 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<string, string | undefined> = (() => {
|
|
const out: Record<string, string | undefined> = {};
|
|
try {
|
|
for (const k of [...PROXY_ENV, ...NO_PROXY_ENV]) out[k] = Deno.env.get(k);
|
|
} catch {
|
|
// 没有 env 权限:当作没有代理
|
|
}
|
|
return out;
|
|
})();
|
|
|
|
/** 在"没有代理环境变量"的状态下同步执行 fn(建 HttpClient 用),结束立即还原 */
|
|
function withoutProxyEnv<T>(fn: () => T): T {
|
|
const saved = new Map<string, string>();
|
|
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<string, Deno.HttpClient>();
|
|
|
|
/** 明确直连的客户端(不读任何代理环境变量) */
|
|
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;
|
|
}
|
|
|
|
/** 站点请求的出口:开代理 → 设置里的站点代理地址,没填则用进程的 HTTPS_PROXY / HTTP_PROXY;都没有 → direct */
|
|
export interface SiteEgress {
|
|
mode: "direct" | "proxy";
|
|
proxyUrl: string | null;
|
|
}
|
|
|
|
export function siteEgress(
|
|
proxy: boolean,
|
|
proxyUrl: string | null | undefined,
|
|
env: Record<string, string | undefined> = ENV_SNAPSHOT,
|
|
): SiteEgress {
|
|
const url = proxy ? proxyUrl || env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy : null;
|
|
return url ? { mode: "proxy", proxyUrl: url } : { 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<string, string | undefined> = 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;
|