win 37a1b404e9 feat: 智能 H1/H2 自适应 — 首次 H1 秒挂自动切 H2 并缓存
- 首次请求走 HTTP/1.1,如果 socket hang up < 2s 自动切 HTTP/2
- H2 主机缓存在内存中,后续请求直接走 H2(如 googleapis.com)
- H2 session 池复用 + 空闲超时自动清理
- 详细日志:proxy_request → proxy_response/error,含协议标识
- 解决 googleapis.com 强制 H2 导致请求失败的问题
2026-03-25 11:37:27 +08:00

307 lines
11 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.

'use strict';
const http = require('http');
const https = require('https');
const http2 = require('http2');
const net = require('net');
// ─── 配置 ───────────────────────────────────────────────
const UPSTREAM_HOST = process.env.UPSTREAM_HOST || 'api.anthropic.com';
const LISTEN_PORT = parseInt(process.env.PROXY_PORT || '3456', 10);
const LISTEN_HOST = process.env.PROXY_HOST || '127.0.0.1';
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');
};
const HEALTH_PATH = '/__health';
// ─── 协议缓存:记录哪些主机需要 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 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(
`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')));
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 code = parseInt(buf.split(' ')[1], 10);
if (code === 200) {
conn.setTimeout(0);
const rest = buf.slice(idx + 4);
if (rest.length > 0) conn.unshift(Buffer.from(rest));
resolve(conn);
} else {
conn.destroy();
reject(new Error(`CONNECT failed: ${code}`));
}
};
conn.on('data', onData);
});
}
// ─── 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'];
const opts = {
hostname: targetHost, port: 443, path: req.url,
method: req.method, headers, servername: targetHost,
timeout: CONNECT_TIMEOUT,
};
const startTime = Date.now();
let proxyReq;
const doRequest = (requestOpts) => {
proxyReq = https.request(requestOpts);
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');
});
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);
}
});
}
// ─── 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: 'h2_exception', message: err.message }));
}
}
}
// ─── 请求入口 ─────────────────────────────────────────────
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 });
// 已知需要 H2 的主机直接走 H2
if (h2Hosts.has(targetHost)) {
proxyViaH2(targetHost, req, res);
return;
}
// 首次请求走 H1如果秒挂自动切 H2
await proxyViaH1(targetHost, req, res);
}
// ─── HTTP 服务器 ─────────────────────────────────────────
const server = http.createServer((req, res) => {
if (req.url === HEALTH_PATH) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
upstream: UPSTREAM_HOST,
node: process.version,
openssl: process.versions.openssl,
uptime: process.uptime(),
h2Hosts: [...h2Hosts],
}));
return;
}
proxyRequest(req, res).catch((err) => {
log('error', 'unhandled_error', { error: err.message });
if (!res.headersSent) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'internal_error' }));
}
});
});
server.timeout = 0;
server.keepAliveTimeout = IDLE_TIMEOUT;
server.headersTimeout = 60000;
server.listen(LISTEN_PORT, LISTEN_HOST, () => {
log('info', 'node-tls-proxy started', {
listen: `${LISTEN_HOST}:${LISTEN_PORT}`,
upstream: `${UPSTREAM_HOST}:443`,
proxy: UPSTREAM_PROXY || '(direct)',
node: process.version,
openssl: process.versions.openssl,
});
});
let shuttingDown = false;
function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
log('info', `received ${signal}, shutting down`);
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', { error: err.message, stack: err.stack }));
process.on('unhandledRejection', (r) => log('error', 'unhandled_rejection', { error: String(r) }));