feat: 智能 H1/H2 自适应 — 首次 H1 秒挂自动切 H2 并缓存
Some checks failed
CI / test (push) Failing after 3s
CI / golangci-lint (push) Failing after 3s
Security Scan / backend-security (push) Failing after 3s
Security Scan / frontend-security (push) Failing after 3s

- 首次请求走 HTTP/1.1,如果 socket hang up < 2s 自动切 HTTP/2
- H2 主机缓存在内存中,后续请求直接走 H2(如 googleapis.com)
- H2 session 池复用 + 空闲超时自动清理
- 详细日志:proxy_request → proxy_response/error,含协议标识
- 解决 googleapis.com 强制 H2 导致请求失败的问题
This commit is contained in:
win 2026-03-22 02:06:10 +08:00
parent 4ea945bb56
commit 88432f9438

View File

@ -2,6 +2,7 @@
const http = require('http');
const https = require('https');
const http2 = require('http2');
const net = require('net');
// ─── 配置 ───────────────────────────────────────────────
@ -12,7 +13,6 @@ const UPSTREAM_PROXY = process.env.UPSTREAM_PROXY || '';
const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10);
const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10);
// ─── 日志 ───────────────────────────────────────────────
const log = (level, msg, extra = {}) => {
const entry = { time: new Date().toISOString(), level, msg, ...extra };
process.stderr.write(JSON.stringify(entry) + '\n');
@ -20,149 +20,237 @@ const log = (level, msg, extra = {}) => {
const HEALTH_PATH = '/__health';
// ─── 通过 HTTP 代理建立 CONNECT 隧道 ──────────────────────
// ─── 协议缓存:记录哪些主机需要 H2 ──────────────────────
// 首次请求用 H1如果秒挂socket hang up < 2s自动切 H2 并缓存
const h2Hosts = new Set();
// ─── H2 会话池 ──────────────────────────────────────────
const h2Sessions = new Map();
function getH2Session(host) {
const existing = h2Sessions.get(host);
if (existing && !existing.closed && !existing.destroyed) return existing;
const session = http2.connect(`https://${host}`);
session.on('error', (err) => {
log('warn', 'h2_session_error', { host, error: err.message });
h2Sessions.delete(host);
});
session.on('close', () => h2Sessions.delete(host));
session.setTimeout(IDLE_TIMEOUT, () => {
session.close();
h2Sessions.delete(host);
});
h2Sessions.set(host, session);
return session;
}
// ─── CONNECT 隧道 ────────────────────────────────────────
function connectViaProxy(proxyUrl, targetHost, targetPort) {
return new Promise((resolve, reject) => {
const proxy = new URL(proxyUrl);
const proxyPort = parseInt(proxy.port || '80', 10);
const conn = net.connect(proxyPort, proxy.hostname, () => {
const connectReq =
`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` +
`Host: ${targetHost}:${targetPort}\r\n`;
const conn = net.connect(parseInt(proxy.port || '80', 10), proxy.hostname, () => {
const auth = proxy.username
? `Proxy-Authorization: Basic ${Buffer.from(
`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password || '')}`
).toString('base64')}\r\n`
: '';
conn.write(connectReq + auth + '\r\n');
conn.write(
`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` +
`Host: ${targetHost}:${targetPort}\r\n` + auth + '\r\n'
);
});
conn.once('error', reject);
conn.setTimeout(CONNECT_TIMEOUT, () => {
conn.destroy(new Error('proxy CONNECT timeout'));
});
conn.setTimeout(CONNECT_TIMEOUT, () => conn.destroy(new Error('proxy CONNECT timeout')));
let buf = '';
const onData = (chunk) => {
buf += chunk.toString();
const idx = buf.indexOf('\r\n\r\n');
if (idx === -1) return;
conn.removeListener('data', onData);
const statusLine = buf.slice(0, buf.indexOf('\r\n'));
const statusCode = parseInt(statusLine.split(' ')[1], 10);
if (statusCode === 200) {
const code = parseInt(buf.split(' ')[1], 10);
if (code === 200) {
conn.setTimeout(0);
const remainder = buf.slice(idx + 4);
if (remainder.length > 0) {
conn.unshift(Buffer.from(remainder));
}
const rest = buf.slice(idx + 4);
if (rest.length > 0) conn.unshift(Buffer.from(rest));
resolve(conn);
} else {
conn.destroy();
reject(new Error(`proxy CONNECT failed: ${statusLine}`));
reject(new Error(`CONNECT failed: ${code}`));
}
};
conn.on('data', onData);
});
}
// ─── 代理请求 ───────────────────────────────────────────
async function proxyRequest(req, res) {
// 动态确定上游主机
const targetHost = req.headers['x-forwarded-host'] || UPSTREAM_HOST;
// ─── H1 代理 ─────────────────────────────────────────────
function proxyViaH1(targetHost, req, res) {
return new Promise((resolve) => {
const headers = { ...req.headers };
headers.host = targetHost;
delete headers['x-forwarded-host'];
delete headers['connection'];
delete headers['keep-alive'];
delete headers['proxy-connection'];
delete headers['transfer-encoding'];
log('info', 'proxy_request', { host: targetHost, method: req.method, path: req.url });
const opts = {
hostname: targetHost, port: 443, path: req.url,
method: req.method, headers, servername: targetHost,
timeout: CONNECT_TIMEOUT,
};
// 构建上游请求头
const headers = { ...req.headers };
headers.host = targetHost;
delete headers['x-forwarded-host'];
delete headers['connection'];
delete headers['keep-alive'];
delete headers['proxy-connection'];
delete headers['transfer-encoding'];
const startTime = Date.now();
let proxyReq;
const opts = {
hostname: targetHost,
port: 443,
path: req.url,
method: req.method,
headers,
servername: targetHost,
timeout: CONNECT_TIMEOUT,
// 不设置任何自定义 TLS 选项 → Node.js 默认 TLS stack → JA3/JA4 天然匹配
};
const doRequest = (requestOpts) => {
proxyReq = https.request(requestOpts);
let proxyReq;
proxyReq.on('response', (proxyRes) => {
log('info', 'proxy_response', { host: targetHost, status: proxyRes.statusCode, path: req.url, proto: 'h1' });
const rh = { ...proxyRes.headers };
delete rh['connection'];
delete rh['keep-alive'];
res.writeHead(proxyRes.statusCode, rh);
proxyRes.pipe(res, { end: true });
proxyRes.on('error', (e) => { log('error', 'h1_response_error', { error: e.message }); res.end(); });
resolve('ok');
});
if (UPSTREAM_PROXY) {
try {
const socket = await connectViaProxy(UPSTREAM_PROXY, targetHost, 443);
opts.socket = socket;
opts.agent = false;
proxyReq = https.request(opts);
} catch (err) {
log('error', 'proxy tunnel failed', { error: err.message, host: targetHost });
if (!res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'proxy_tunnel_error', message: err.message }));
}
return;
proxyReq.on('error', (err) => {
const elapsed = Date.now() - startTime;
// socket hang up < 2 秒 = 服务器拒绝 H1切换到 H2
if (err.message === 'socket hang up' && elapsed < 2000) {
log('info', 'h1_rejected_switching_to_h2', { host: targetHost, elapsed });
h2Hosts.add(targetHost);
proxyViaH2(targetHost, req, res);
resolve('h2_fallback');
return;
}
log('error', 'h1_upstream_error', { error: err.message, host: targetHost, path: req.url });
if (!res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'upstream_error', message: err.message }));
}
resolve('error');
});
proxyReq.on('timeout', () => proxyReq.destroy(new Error('upstream timeout')));
req.on('close', () => { if (!proxyReq.destroyed) proxyReq.destroy(); });
req.pipe(proxyReq, { end: true });
};
if (UPSTREAM_PROXY) {
connectViaProxy(UPSTREAM_PROXY, targetHost, 443)
.then((socket) => { opts.socket = socket; opts.agent = false; doRequest(opts); })
.catch((err) => {
log('error', 'proxy_tunnel_failed', { error: err.message, host: targetHost });
if (!res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'proxy_tunnel_error' }));
}
resolve('error');
});
} else {
doRequest(opts);
}
} else {
proxyReq = https.request(opts);
}
// 上游响应
proxyReq.on('response', (proxyRes) => {
const responseHeaders = { ...proxyRes.headers };
delete responseHeaders['connection'];
delete responseHeaders['keep-alive'];
log('info', 'proxy_response', {
host: targetHost,
status: proxyRes.statusCode,
path: req.url,
});
res.writeHead(proxyRes.statusCode, responseHeaders);
proxyRes.pipe(res, { end: true });
proxyRes.on('error', (err) => {
log('error', 'upstream response error', { error: err.message, host: targetHost });
res.end();
});
});
}
// 上游连接错误
proxyReq.on('error', (err) => {
log('error', 'upstream request error', {
error: err.message,
host: targetHost,
path: req.url,
method: req.method,
// ─── H2 代理 ─────────────────────────────────────────────
function proxyViaH2(targetHost, req, res) {
try {
const session = getH2Session(targetHost);
const headers = {};
// 只拷贝合法的 H2 头(跳过 H1 专用头和连接头)
const skipHeaders = new Set([
'host', 'connection', 'keep-alive', 'proxy-connection',
'transfer-encoding', 'upgrade', 'x-forwarded-host',
'http2-settings',
]);
for (const [k, v] of Object.entries(req.headers)) {
if (!skipHeaders.has(k.toLowerCase())) {
headers[k] = v;
}
}
// H2 伪头
headers[':method'] = req.method;
headers[':path'] = req.url;
headers[':authority'] = targetHost;
headers[':scheme'] = 'https';
const h2Stream = session.request(headers);
let responded = false;
h2Stream.on('response', (h2Headers) => {
responded = true;
const status = h2Headers[':status'] || 502;
const respHeaders = {};
for (const [k, v] of Object.entries(h2Headers)) {
if (!k.startsWith(':')) respHeaders[k] = v;
}
log('info', 'proxy_response', { host: targetHost, status, path: req.url, proto: 'h2' });
res.writeHead(status, respHeaders);
h2Stream.pipe(res, { end: true });
});
h2Stream.on('error', (err) => {
log('error', 'h2_stream_error', { error: err.message, host: targetHost, path: req.url });
h2Sessions.delete(targetHost); // 清理坏 session
if (!responded && !res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'h2_error', message: err.message }));
}
});
h2Stream.on('close', () => {
if (!responded && !res.headersSent) {
log('warn', 'h2_stream_closed_no_response', { host: targetHost, path: req.url });
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'h2_no_response' }));
}
});
// 超时
h2Stream.setTimeout(CONNECT_TIMEOUT, () => {
log('warn', 'h2_timeout', { host: targetHost, path: req.url });
h2Stream.close();
});
req.on('close', () => {
if (!h2Stream.destroyed) h2Stream.close();
});
// pipe 请求体
req.pipe(h2Stream, { end: true });
} catch (err) {
log('error', 'h2_proxy_exception', { error: err.message, host: targetHost });
h2Sessions.delete(targetHost);
if (!res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'upstream_error', message: err.message }));
res.end(JSON.stringify({ error: 'h2_exception', message: err.message }));
}
});
}
}
proxyReq.on('timeout', () => {
log('warn', 'upstream request timeout', { host: targetHost, path: req.url });
proxyReq.destroy(new Error('upstream timeout'));
});
// ─── 请求入口 ─────────────────────────────────────────────
async function proxyRequest(req, res) {
const targetHost = req.headers['x-forwarded-host'] || UPSTREAM_HOST;
log('info', 'proxy_request', { host: targetHost, method: req.method, path: req.url });
req.on('close', () => {
if (!proxyReq.destroyed) proxyReq.destroy();
});
// 已知需要 H2 的主机直接走 H2
if (h2Hosts.has(targetHost)) {
proxyViaH2(targetHost, req, res);
return;
}
req.pipe(proxyReq, { end: true });
// 首次请求走 H1如果秒挂自动切 H2
await proxyViaH1(targetHost, req, res);
}
// ─── HTTP 服务器 ─────────────────────────────────────────
@ -175,12 +263,12 @@ const server = http.createServer((req, res) => {
node: process.version,
openssl: process.versions.openssl,
uptime: process.uptime(),
h2Hosts: [...h2Hosts],
}));
return;
}
proxyRequest(req, res).catch((err) => {
log('error', 'unhandled proxy error', { error: err.message });
log('error', 'unhandled_error', { error: err.message });
if (!res.headersSent) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'internal_error' }));
@ -202,24 +290,17 @@ server.listen(LISTEN_PORT, LISTEN_HOST, () => {
});
});
// ─── 优雅关闭 ─────────────────────────────────────────────
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
log('info', `received ${signal}, shutting down`);
server.close(() => {
log('info', 'server closed');
process.exit(0);
});
for (const s of h2Sessions.values()) s.close();
h2Sessions.clear();
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('uncaughtException', (err) => {
log('error', 'uncaught exception', { error: err.message, stack: err.stack });
});
process.on('unhandledRejection', (reason) => {
log('error', 'unhandled rejection', { error: String(reason) });
});
process.on('uncaughtException', (err) => log('error', 'uncaught', { error: err.message, stack: err.stack }));
process.on('unhandledRejection', (r) => log('error', 'unhandled_rejection', { error: String(r) }));