Files
nodeagent/apps/console/sidecar/proxy.mjs
T
jkooe b34e8c73e0 fix(console): 让「检查更新」在境内真的能用 —— 自持出网 + 系统代理发现
真机症状:控制台点「检查更新」必报 `检查更新失败:fetch failed`。
上一轮曾把此项归因为「环境网络问题」而收工;本轮按「即使环境本身不通,
代码也不该让用户无从自救」重新定位,确认为**产品缺陷**。

三条根因(均经隔离实测)
- Node 内建 fetch(undici)默认不读任何代理环境变量。`NODE_USE_ENV_PROXY=1` 可开,
  未开时 env 被**完全忽略** —— `env -i` 对照实验:带 flag 得 `fetch failed`(证明 flag 生效),
  不带得 `timeout`(证明 env 被忽略)。
- 系统代理(WinINET / macOS 网络设置)Node 亦不读;而 `fetch(url,{dispatcher})` 走不通 ——
  dispatcher 必须是 undici 的 Dispatcher 实例,Node 未把 undici 暴露为可导入模块,
  整包 vendored 会撑胖 sidecar 且其依赖 .wasm 解析器,esbuild 单文件打包有风险。
- 内部超时 20s 对真实重定向链必然失败:`latest.json` → 版本化 URL → CDN,
  实测耗时 2.0 / 10.0 / 39.7 / 68.6 / >109 秒。

修法
- 新增 `apps/console/sidecar/proxy.mjs`(自持出网,零新依赖):https 目标走 `CONNECT host:443`
  隧道后叠 TLS(隧道返回的 socket 已是 TLS,须用 `http.request` 承载,用 `https.request` 会二次握手;
  且**不可把 URL 对象当第一参**,否则会覆盖手搭的代理字段退化成直连);http 目标走绝对形态请求行;
  含 NO_PROXY 匹配、系统代理发现(`reg query` / `scutil --proxy`,PAC 不可解析时记 note 而非静默)、
  socks5 等看不懂的形态一律记 note。
- `check_update` / `download` 全部改走 `httpGet`,整条重定向链共享一个超时预算与 AbortController。
- 内外超时成对下发:`sidecar.rs` 新增 `UPDATE_CHECK_TIMEOUT_MS`/`_RPC_TIMEOUT_SECS` 与
  `UPDATE_DOWNLOAD_TIMEOUT_MS`/`_RPC_TIMEOUT_SECS` 两组常量 + **编译期不变式**
  (`const _: () => assert!(...)`),保证「出网超时 < RPC 超时」——否则用户只会看到
  丢掉真实原因的「check_update 超时」假失败。订正 6 处因超时改动而变陈旧的注释。
- 失败信息带 `url` + `via`/`proxy`/`proxy_source`/`timeout_ms`/`hint`;界面新增「出网」行,
  并修掉重复前缀(`检查更新失败:检查更新失败:…`)。

顺带修掉两个真机暴露的缺陷
- **隧道/TLS 阶段的 abort 无人受理**:`createConnection` 回调返回前外层请求尚未建立,
  外层 `signal`/`timeout` 无人受理 → 真机实测配 75s 超时却挂了 109s。
- **`ECONNRESET` 被归入 abort 类** → 报成「请求超时」(真机:耗时 82.9s 却报「120000ms 内未完成」)。
  改为只认真 abort,并新增网络错误翻译(ENOTFOUND/EAI_AGAIN/ECONNREFUSED/ECONNRESET/EPIPE)。

守卫与验证
- 新增 `tests/unit/sidecar-proxy.test.mjs`(16 用例,四段:纯函数 / 真发请求 / 端到端
  spawn sidecar 跑 JSON-RPC / 形态守卫)。夹具用本机真 `scutil --proxy` 与真 `reg query` 输出,
  地址改为 RFC 5737 文档段,不写内网 IP、不写工具实名。
- 守卫落在「最终下发的请求」与「产物字节」上,逐条**注入故障实测会红、还原后逐字节一致**:
  ① `check_update` 退回裸 `fetch` → 代理未收到该次请求 → 红
  ② 去掉 `isBypassed` 后缀匹配 → 红
  ③ 出网/RPC 超时改回反序 → 红 / `error[E0080]`
  ④ 去掉隧道 abort 挂载 → 红(`实际 4002ms`)
  ⑤ `ECONNRESET` 塞回 abort 类 → 红
- 全量:`unit` **341/341**、`rust` **18/18**、`clippy -D warnings` 干净、`vue-tsc` 退出码 0。

未覆盖项(如实记档)
- https 经 CONNECT 隧道叠 TLS 无离线自动化用例:`selfsigned` 只在 `apps/agent` 包内,
  测试进程解析不到 → 无法离线造受信任 TLS 站点;该路径仅真机验证。
- 真机「经代理取 GitHub 成功」的那一组未取到(宿主两条代理线路当时都取不到 GitHub),
  属环境而非产品缺陷;错误信息已可操作(含 `via`/`proxy_source`/`hint`)。
- 同类缺陷**第三处未改**:`apps/agent/src/capabilities/selfupdate.ts` 仍是裸 `fetch`
  (属被控端变更,改则需升 PATCH)。

注:本项为上壳变更,不触发 release patch;`apps/console` 至今未接入 `release.mjs`。
2026-10-13 22:41:02 +08:00

524 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 出站代理支持 —— 只为「检查更新 / 下载更新包」这两条 HTTPS 出网路径服务。
*
* ## 为什么必须自己实现(这是缺陷,不是优化)
*
* Node 内置 `fetch`(undici)**默认不读任何代理环境变量** —— 实测:设了 `HTTPS_PROXY`
* 仍然 `fetch failed`。能开的只有 `NODE_USE_ENV_PROXY=1`(Node 22.x 实验性、24 正式),
* 而它**只能读环境变量**,读不到 Windows 系统代理(WinINET)或 macOS 系统代理
* —— 而中文用户用 Clash / v2rayN 时配置的恰恰是「系统代理」,不是环境变量。
* 结果:境内用户即使代理一切正常,「检查更新」也**必然**报 `fetch failed`。
* (真机取证 2026-10-13:VM 上 DNS 与 TCP 443 都通、但 `fetch failed`;同一台机器
* 直连 GitHub 被阻断、经代理取 `latest.json` 返回 302。)
*
* 另外 `fetch(url, { dispatcher })` 也不通:`dispatcher` 必须是 undici 的 Dispatcher 实例,
* 而 Node 没有把 undici 暴露成可导入的模块(只能整包 vendored 进来,那会把 sidecar
* 从 227KB 撑胖一大截,且 undici 依赖 `.wasm` 解析器、esbuild 单文件打包有风险)。
*
* ## 做法
*
* 不用 `fetch`,改用 `node:http` / `node:https` 自己发请求:
* - **https 目标经代理**:先向代理发 `CONNECT host:443` 建隧道,再在隧道 socket 上做 TLS
* (通过 `createConnection` 交给 `http.request` —— 注意这里刻意用 `http` 而非 `https`,
* 因为我们交出去的**已经是 TLS socket**,用 `https.request` 会二次握手);
* - **http 目标经代理**:按 HTTP 代理规范发**绝对形态**请求行(`GET http://… HTTP/1.1`);
* - **无代理**:与改动前一致,直连(走 `https.request`)。
*
* ## 代理来源优先级
*
* ① `NA_PROXY`(显式覆盖,便于排查与将来做配置项)
* ② 目标协议对应的环境变量(`HTTPS_PROXY`/`https_proxy` → `ALL_PROXY`/`all_proxy` → …)
* ③ **系统代理**(Windows 读注册表 WinINET;macOS 读 `scutil --proxy`)
*
* `NO_PROXY` / `no_proxy` 命中的主机一律直连。只支持 **HTTP 代理**(`http(s)://`)——
* HTTPS 目标经 HTTP 代理走 CONNECT,是通用做法;`socks5://` 会**明确**记一条 note
* 而不是静默直连(静默正是本次缺陷最恶劣的地方)。
*
* ## 安全
*
* 只做「出站取数据」,不引入任何新依赖;TLS 证书默认校验,不提供跳过开关。
*/
import http from 'node:http';
import https from 'node:https';
import tls from 'node:tls';
import { execFile } from 'node:child_process';
/** 代理来源优先级(按目标协议分列)。`NA_PROXY` 恒为第一优先。 */
const ENV_ORDER = {
'https:': ['NA_PROXY', 'HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'HTTP_PROXY', 'http_proxy'],
'http:': ['NA_PROXY', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'],
};
const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
const UA = 'nodeagent-console-sidecar';
/**
* 把环境里/配置里写的各种代理形态归一成可用的 URL。
*
* 接受 `http://127.0.0.1:7890`、`127.0.0.1:7890`(补 `http://`)、带账密的 URL;
* 其余(`socks5://`、空串、无法解析)返回 `null` —— 由调用方把「看到了但用不了」
* 明确报出来,**不许静默直连**。
*
* @param {unknown} raw
* @returns {URL | null}
*/
export function normalizeProxyUrl(raw) {
if (typeof raw !== 'string') return null;
const s = raw.trim();
if (!s) return null;
let u;
try {
u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `http://${s}`);
} catch {
return null;
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (!u.hostname) return null;
return u;
}
/** 代理 URL 的主机端口(缺省按代理自身协议补 80/443)。 */
function proxyEndpoint(u) {
return { host: u.hostname, port: u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80 };
}
/** 代理 URL 里的账密 → `Proxy-Authorization` 头(没有则 null)。 */
function proxyAuthHeader(u) {
if (!u.username) return null;
const raw = `${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`;
return 'Basic ' + Buffer.from(raw).toString('base64');
}
/**
* `NO_PROXY` 命中判定。支持:精确主机、后缀(`example.com` 或 `.example.com` 都匹配
* `a.example.com`)、`host:port`、`*`。大小写不敏感。
*
* ⚠️ 刻意**不支持 CIDR**(如 `169.254/16`):本模块的目标只有公网域名,
* 而 CIDR 主机名判定极易写错、错了就是「该走代理的没走」。遇到疑似 CIDR 条目
* 由调用方记 note(见 `pickEnvProxy` 的 notes 语义)。
*
* @param {string} targetUrl
* @param {string} noProxy
* @returns {boolean}
*/
export function isBypassed(targetUrl, noProxy) {
if (!noProxy) return false;
let host;
let port;
try {
const u = new URL(targetUrl);
host = u.hostname.toLowerCase();
port = u.port || (u.protocol === 'https:' ? '443' : '80');
} catch {
return false;
}
for (const rawEntry of String(noProxy).split(',')) {
const entry = rawEntry.trim().toLowerCase();
if (!entry) continue;
if (entry === '*') return true;
let eHost = entry;
let ePort = null;
const i = entry.lastIndexOf(':');
// 排除 IPv6 字面量(`[::1]:8080`)里的冒号误判
if (i > 0 && !entry.slice(0, i).includes(':')) {
eHost = entry.slice(0, i);
ePort = entry.slice(i + 1);
}
if (ePort && ePort !== port) continue;
const bare = eHost.replace(/^\./, '');
if (!bare) continue;
if (host === bare || host.endsWith(`.${bare}`)) return true;
}
return false;
}
/**
* 从环境变量里挑代理(纯函数,便于单测)。
*
* @param {Record<string, string | undefined>} env
* @param {string} targetUrl
* @returns {{ url: URL | null, source: string | null, notes: string[] }}
*/
export function pickEnvProxy(env, targetUrl) {
let proto = 'https:';
try {
proto = new URL(targetUrl).protocol;
} catch {
/* 保持默认 */
}
const keys = ENV_ORDER[proto] ?? ENV_ORDER['https:'];
const notes = [];
for (const k of keys) {
const v = env?.[k];
if (!v) continue;
const u = normalizeProxyUrl(v);
if (u) return { url: u, source: k, notes };
notes.push(`${k}=${String(v).trim()} 不是受支持的代理协议(仅支持 http/https 代理),已忽略`);
}
return { url: null, source: null, notes };
}
/**
* 解析 macOS `scutil --proxy` 的输出。
*
* 形如:
* <dictionary> {
* HTTPSEnable : 1
* HTTPSPort : 7890
* HTTPSProxy : 127.0.0.1
* ExceptionsList : <array> { 0 : *.local 1 : 169.254/16 }
* }
*
* 注意 macOS 上 HTTPS 与 HTTP 是**分别开关**的;这里 https 优先,取不到再退 HTTP。
* `ProxyAutoConfigEnable : 1`(PAC 自动配置脚本)**无法解析** —— 返回 notes 说明,
* 让上层给出「请显式设 HTTPS_PROXY」这类可操作提示,而不是含糊的 fetch failed。
*
* @param {string} text
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
*/
export function parseScutilProxy(text) {
if (typeof text !== 'string' || !text.includes('{')) return null;
const scalar = (key) => {
const m = new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*$`, 'm').exec(text);
return m ? m[1].trim() : null;
};
const isOn = (v) => v === '1' || v === 'true';
const notes = [];
// ExceptionsList 是 <array> 块里的 “序号 : 值” 行 —— 收集成逗号分隔的绕过列表
const bypass = [];
const arrIdx = text.indexOf('ExceptionsList');
if (arrIdx >= 0) {
const body = text.slice(arrIdx, text.indexOf('}', arrIdx) + 1);
const re = /^\s*\d+\s*:\s*(.+?)\s*$/gm;
let m;
while ((m = re.exec(body)) !== null) bypass.push(m[1].trim());
}
for (const [flag, hostKey, portKey, dflt] of [
['HTTPSEnable', 'HTTPSProxy', 'HTTPSPort', '443'],
['HTTPEnable', 'HTTPProxy', 'HTTPPort', '80'],
]) {
if (!isOn(scalar(flag))) continue;
const host = scalar(hostKey);
if (!host) continue;
const port = scalar(portKey) ?? dflt;
return { url: `${host}:${port}`, bypass: bypass.join(','), notes };
}
if (isOn(scalar('ProxyAutoConfigEnable'))) {
notes.push('系统代理使用 PAC 自动配置脚本(ProxyAutoConfigEnable=1),本程序无法解析脚本内容,已按「无代理」处理');
}
return { url: null, bypass: bypass.join(','), notes };
}
/**
* 解析 Windows `reg query …\Internet Settings` 的输出。
*
* 形如(`REG_SZ` / `REG_DWORD` 是**类型名,不随系统语言变化**,可安全按 token 解析):
* ProxyEnable REG_DWORD 0x1
* ProxyOverride REG_SZ *.local;<local>
* ProxyServer REG_SZ 127.0.0.1:7890 (也可能是 http=…;https=… 分协议形态)
*
* `ProxyEnable=0x0` 视为**显式关闭** → 无代理(哪怕 ProxyServer 有值)。
* `<local>` 是「本机地址直连」的占位符,不是域名,从绕过列表里剔除。
*
* @param {string} text
* @returns {{ url: string | null, bypass: string, notes: string[] } | null}
*/
export function parseRegQueryProxy(text) {
if (typeof text !== 'string' || !text.includes('REG_')) return null;
const value = (name) => {
const m = new RegExp(`^\\s*${name}\\s+REG_(?:SZ|EXPAND_SZ|DWORD|BINARY)\\s+(.*?)\\s*$`, 'm').exec(text);
return m ? m[1].trim() : null;
};
const notes = [];
const enable = value('ProxyEnable');
if (enable !== null && /^0x0+$/i.test(enable)) return { url: null, bypass: '', notes };
const server = value('ProxyServer');
if (!server) return { url: null, bypass: '', notes };
const override = value('ProxyOverride');
const bypass = override
? override
.split(';')
.map((s) => s.trim())
.filter((s) => s && !/^<local>$/i.test(s))
.join(',')
: '';
// 分协议形态:http=127.0.0.1:7890;https=127.0.0.1:7891 —— https 优先
let url = server;
if (server.includes('=')) {
const map = new Map();
for (const part of server.split(';')) {
const i = part.indexOf('=');
if (i > 0) map.set(part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim());
}
url = map.get('https') ?? map.get('http') ?? null;
if (!url) notes.push(`系统代理仅配置了非 http(s) 协议(ProxyServer=${server})`);
}
return { url, bypass, notes };
}
/** 跑一个只读命令,失败/超时一律返回 null(探测系统代理**绝不允许**把检查更新搞挂)。 */
function runQuiet(file, args) {
return new Promise((resolve) => {
execFile(
file,
args,
// windowsHide:本进程是被 CREATE_NO_WINDOW 拉起的「无控制台」进程,
// 若再 spawn 控制台程序(reg.exe),Windows 会**新开一个控制台** → 黑窗闪一下。
{ windowsHide: true, timeout: 5000, encoding: 'utf8', maxBuffer: 1024 * 1024 },
(err, stdout) => resolve(err ? null : String(stdout)),
);
});
}
/**
* 探测系统代理。`run` 可注入(单测用假 runner,不必真去读注册表)。
*
* @param {NodeJS.Platform} [platform]
* @param {(file: string, args: string[]) => Promise<string | null>} [run]
* @returns {Promise<{ url: string | null, bypass: string, notes: string[] } | null>}
*/
export async function detectSystemProxy(platform = process.platform, run = runQuiet) {
try {
if (platform === 'win32') {
const out = await run('reg', [
'query',
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings',
]);
return out ? parseRegQueryProxy(out) : null;
}
if (platform === 'darwin') {
const out = await run('scutil', ['--proxy']);
return out ? parseScutilProxy(out) : null;
}
// Linux:桌面环境各异(gsettings / 环境变量 / PAC),不做猜测 —— 只认环境变量
return null;
} catch {
return null;
}
}
/** 系统代理只探测一次(同一进程内结果不变;用户改代理需重启控制台)。 */
let systemProxyPromise;
export function systemProxy() {
if (!systemProxyPromise) systemProxyPromise = detectSystemProxy().catch(() => null);
return systemProxyPromise;
}
/** 单测用:清掉系统代理缓存。 */
export function __resetSystemProxyCache() {
systemProxyPromise = undefined;
}
/**
* 决议本次请求该用哪个代理。
*
* @param {{ targetUrl: string, env?: Record<string, string | undefined>, system?: unknown }} opts
* `system` 显式传入时不做探测(单测用);传 `null` 表示「确认没有系统代理」。
* @returns {Promise<{ proxy: URL | null, source: string | null, notes: string[] }>}
*/
export async function resolveProxy({ targetUrl, env = process.env, system } = {}) {
const notes = [];
const noProxy = NO_PROXY_KEYS.map((k) => env?.[k]).find(Boolean) ?? '';
if (noProxy && isBypassed(targetUrl, noProxy)) {
return { proxy: null, source: null, notes: [`目标命中 NO_PROXY=${noProxy},按直连处理`] };
}
const fromEnv = pickEnvProxy(env ?? {}, targetUrl);
notes.push(...fromEnv.notes);
if (fromEnv.url) return { proxy: fromEnv.url, source: fromEnv.source, notes };
const sys = system === undefined ? await systemProxy() : system;
if (sys?.url) {
notes.push(...(sys.notes ?? []));
if (sys.bypass && isBypassed(targetUrl, sys.bypass)) {
notes.push(`目标命中系统代理的绕过列表(${sys.bypass}),按直连处理`);
return { proxy: null, source: null, notes };
}
const u = normalizeProxyUrl(String(sys.url));
if (u) return { proxy: u, source: 'system', notes };
notes.push(`系统代理 ${sys.url} 不受支持(仅支持 http/https 代理),已按直连处理`);
return { proxy: null, source: null, notes };
}
notes.push(...(sys?.notes ?? []));
return { proxy: null, source: null, notes };
}
/**
* 造一个 `createConnection`:向代理发 CONNECT 建隧道,https 目标再在隧道上叠 TLS。
*
* 返回的 socket 交给 `http.request` 使用(那已经是 TLS socket,故用 `http` 模块)。
*
* ## ⚠️ 为什么必须自己管 `signal`(真机踩过)
* `createConnection` 的回调**返回之前**,外层 `http.request` 还没建立 —— 此时
* 外层请求上的 `signal` / `timeout` 都**无人受理**。真机形态:代理 TCP 能连上、却对
* CONNECT 或随后的 TLS 握手**永不响应**,于是「75s 超时」形同虚设,实际挂了 109s。
* 所以:CONNECT 请求要带 signal,**拿到 socket 与 tlsSock 之后还要自己挂 abort**,
* 失败/成功时都要摘掉监听,避免泄漏与误伤。
*/
export function tunnelConnection(proxy, target, signal) {
const pe = proxyEndpoint(proxy);
return (_opts, cb) => {
let settled = false;
let tunnelSock = null;
let tlsSock = null;
const detach = () => {
if (signal) signal.removeEventListener('abort', onAbort);
if (tunnelSock) tunnelSock.removeListener('error', onSockError);
};
const fail = (err) => {
if (settled) return;
settled = true;
detach();
// 半开的隧道必须销毁,否则这个悬挂连接会一直占着(用户点几次就攒一堆)
try {
tlsSock?.destroy();
tunnelSock?.destroy();
} catch {
/* 已经没了 */
}
cb(err);
};
const onSockError = (e) => fail(e);
const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error('请求已中止'));
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener('abort', onAbort, { once: true });
const port = target.port || (target.protocol === 'https:' ? 443 : 80);
const headers = { host: `${target.hostname}:${port}` };
const auth = proxyAuthHeader(proxy);
if (auth) headers['proxy-authorization'] = auth;
const req = http.request({
host: pe.host,
port: pe.port,
method: 'CONNECT',
path: `${target.hostname}:${port}`,
headers,
agent: false,
signal,
});
req.once('connect', (res, socket, head) => {
tunnelSock = socket;
if (res.statusCode !== 200) {
fail(new Error(`代理拒绝 CONNECT(HTTP ${res.statusCode})`));
return;
}
if (head && head.length) socket.unshift(head);
if (target.protocol !== 'https:') {
settled = true;
detach();
cb(null, socket);
return;
}
socket.once('error', onSockError);
tlsSock = tls.connect({ socket, servername: target.hostname });
tlsSock.once('secureConnect', () => {
if (settled) return;
settled = true;
detach();
cb(null, tlsSock);
});
tlsSock.once('error', onSockError);
});
req.once('error', onSockError);
req.end();
};
}
/** 单跳请求(不跟随重定向)。 */
function once(urlStr, { proxy, timeoutMs, signal }) {
return new Promise((resolve, reject) => {
const u = new URL(urlStr);
const isHttps = u.protocol === 'https:';
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
reject(new Error(`不支持的协议:${u.protocol}`));
return;
}
// ⚠️ 手搭 options,**不要**把 URL 对象当第一参再覆盖字段 —— 传 URL 时 Node 会用
// URL 里的 host/port/path 覆盖后面的同名字段(原型实测踩过:代理形同无效、退化成直连)。
const opts = {
method: 'GET',
signal,
timeout: timeoutMs,
host: u.hostname,
port: u.port || (isHttps ? 443 : 80),
path: `${u.pathname}${u.search}`,
headers: { accept: '*/*', 'user-agent': UA, host: u.host },
};
let req;
if (proxy && !isHttps) {
// http 目标经代理 → 绝对形态请求行,且请求行交给代理
const pe = proxyEndpoint(proxy);
opts.host = pe.host;
opts.port = pe.port;
opts.path = u.href;
const auth = proxyAuthHeader(proxy);
if (auth) opts.headers['proxy-authorization'] = auth;
req = http.request(opts);
} else if (proxy) {
opts.createConnection = tunnelConnection(proxy, u, signal);
req = http.request(opts);
} else {
req = (isHttps ? https : http).request(opts);
}
req.once('timeout', () => req.destroy(new Error(`请求超时(${timeoutMs}ms 无响应)`)));
req.once('response', (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.once('error', reject);
res.once('end', () =>
resolve({ status: res.statusCode, statusText: res.statusMessage, headers: res.headers, body: Buffer.concat(chunks) }),
);
});
req.once('error', reject);
req.end();
});
}
/**
* GET 一个 URL(跟随重定向)。
*
* 与 `fetch(url, {redirect:'follow'})` 的差异:不自动解压(不发 `accept-encoding`,
* 服务端会回 identity)、不解析 cookies —— 这两条对本用途都无影响。
*
* 整个重定向链共享**同一个**超时预算与 abort 信号(而不是每跳各自计时),
* 否则 5 跳 × 30s = 150s 会穿过上层的 RPC 超时。
*
* @param {string} url
* @param {{ proxy?: URL | null, timeoutMs?: number, maxRedirects?: number }} [opts]
* @returns {Promise<{ status: number, statusText: string, headers: Record<string, unknown>, body: Buffer, url: string }>}
*/
export async function httpGet(url, { proxy = null, timeoutMs = 30_000, maxRedirects = 5 } = {}) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
let current = String(url);
for (let hop = 0; ; hop += 1) {
const res = await once(current, { proxy, timeoutMs, signal: ac.signal });
const loc = res.headers?.location;
if (REDIRECT_STATUS.has(res.status) && typeof loc === 'string' && hop < maxRedirects) {
current = new URL(loc, current).href;
continue;
}
return { ...res, url: current };
}
} finally {
clearTimeout(timer);
}
}