http-proxy 如何等待外部请求后再转发 HTTP 和 WebSocket?
可以调用外部服务,但 http-proxy 不会等待事件监听器返回的 Promise。要解决请求头报错,应先完成外部调用,再执行 proxy.web() 或 proxy.ws()。如果普通 HTTP 响应必须等待外部调用,应通过 selfHandleResponse: true 接管转发。
WebSocket 握手响应需要单独处理:selfHandleResponse 不会让 WebSocket 升级流程等待异步监听器。
为什么在 await 后设置请求头会报错?
proxyReq 是代理发往目标服务器的 ClientRequest,不是客户端传入的 req。代码执行到 await fetch(...) 时,监听器先返回,代理继续发送请求。等外部服务返回,proxyReq 的请求头可能已经发出,再调用 setHeader() 就会触发 ERR_HTTP_HEADERS_SENT。
将监听器声明为 async,或在里面再包一层异步立即执行函数,都不会改变这个时序。req.socket.pause() 也不能可靠地阻止已创建的出站请求发送请求头。
HTTP 转发源码会继续将请求流写入 proxyReq;WebSocket 转发源码则会在触发 proxyReqWs 后继续执行 proxyReq.end()。可以查看项目的 HTTP 请求处理源码和 WebSocket 处理源码。原问题没有提供安装版本,核对内部实现时应与实际使用的版本对照。
请求侧:先获取请求头,再启动代理
把外部调用从 proxyReq、proxyReqWs 中移到服务器的请求入口。下面的核心代码需要合并到现有服务器中;proxy、externalServiceUrl 和 authorizationToken 由应用实际配置。
示例要求运行环境提供 fetch 和 AbortSignal.timeout()。外部调用的超时设为 5000 毫秒,这是一个可调整的示例值。
async function callExternal(path, options = {}) {
const response = await fetch(`${externalServiceUrl}${path}`, {
...options,
headers: {
'content-type': 'application/json',
authorization: authorizationToken,
...options.headers
},
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`External service returned ${response.status}`);
}
return response;
}
async function prepareRequest(req) {
const response = await callExternal('/getAdditionalHeaders');
const additionalHeaders = await response.json();
if (typeof additionalHeaders.header1 !== 'string' ||
typeof additionalHeaders.header2 !== 'string') {
throw new Error('Invalid additional headers');
}
req.headers['additional-header-1'] = additionalHeaders.header1;
req.headers['additional-header-2'] = additionalHeaders.header2;
}
function failHttp(res) {
if (res.destroyed || res.writableEnded) return;
if (res.headersSent) {
res.destroy();
return;
}
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Bad Gateway' }));
}
function failSocket(socket) {
if (socket.destroyed || !socket.writable) return;
socket.end(
'HTTP/1.1 502 Bad Gateway\r\n' +
'Connection: close\r\n' +
'Content-Length: 0\r\n\r\n'
);
}
const server = http.createServer(async (req, res) => {
// 等待外部服务期间,不提前消费请求体。
req.pause();
try {
await prepareRequest(req);
if (req.destroyed || res.destroyed) return;
// 后面的 proxyRes 示例负责返回 HTTP 响应。
proxy.web(req, res, { selfHandleResponse: true }, (err) => {
console.error(err);
failHttp(res);
});
} catch (err) {
console.error(err);
failHttp(res);
}
});
server.on('upgrade', async (req, socket, head) => {
socket.on('error', (err) => {
console.error(err);
socket.destroy();
});
socket.pause();
try {
await prepareRequest(req);
if (socket.destroyed || !socket.writable) return;
proxy.ws(req, socket, head, {}, (err) => {
console.error(err);
failSocket(socket);
});
} catch (err) {
console.error(err);
failSocket(socket);
}
});
这段代码在创建出站请求之前修改 req.headers,代理随后构造目标请求。普通 HTTP 请求的管道会恢复读取;WebSocket 成功升级后,双向管道会接管读取。等待上游握手时不要额外调用 socket.resume(),以免过早消费后续数据。
移除原来的异步 onRequest,避免重复调用同一外部服务。如果其他中间件已经读取请求体,还需要另行解决请求体重放,不能只靠这段代码恢复。
HTTP 响应侧:等待外部服务后再发送响应
默认情况下,proxyRes 事件触发后,库会继续处理响应头并转发响应体。在监听器里使用 await 不会延迟这个过程。
启用 selfHandleResponse: true 后,需要自行处理状态码、响应头、响应体和错误。http-proxy 官方说明也说明了该选项的用途。
下面的处理器与前面的 HTTP 请求入口配套使用,用来替换原来的 HTTP onResponse。响应体仍以流式方式转发,不会完整缓存在内存中。
import { pipeline } from 'node:stream';
function getCorrelationId(headers) {
const value = headers['set-cookie'];
const cookies = Array.isArray(value) ? value : value ? [value] : [];
let correlationId;
for (const cookie of cookies) {
const match = cookie.match(/(?:^|;\s*)x-correlation-id=([^;]+)/);
if (match) correlationId = match[1];
}
return correlationId;
}
function getForwardHeaders(headers) {
const result = { ...headers };
const connectionHeaders = String(headers.connection || '')
.split(',')
.map((name) => name.trim().toLowerCase())
.filter(Boolean);
for (const name of [
...connectionHeaders,
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'transfer-encoding',
'upgrade'
]) {
delete result[name];
}
return result;
}
proxy.on('proxyRes', async (proxyRes, req, res) => {
proxyRes.pause();
// 在 await 之前处理流错误和客户端断开。
proxyRes.once('error', (err) => {
console.error(err);
failHttp(res);
});
res.once('close', () => proxyRes.destroy());
try {
const correlationId = getCorrelationId(proxyRes.headers);
if (correlationId) {
const response = await callExternal('/updateRequestId', {
method: 'POST',
body: JSON.stringify({ proxyRequestId: correlationId })
});
// 此接口的返回内容在这里不参与后续处理。
await response.body?.cancel();
}
if (res.destroyed || res.writableEnded || proxyRes.destroyed) {
proxyRes.destroy();
return;
}
const headers = getForwardHeaders(proxyRes.headers);
if (correlationId) headers['proxy_request_id'] = correlationId;
// 沿用原代码删除全部 Set-Cookie 的行为。
delete headers['set-cookie'];
res.writeHead(proxyRes.statusCode ?? 502, headers);
pipeline(proxyRes, res, (err) => {
if (err) {
console.error(err);
failHttp(res);
}
});
} catch (err) {
console.error(err);
proxyRes.destroy();
failHttp(res);
}
});
示例假设只有提取到 correlationId 时才通知外部服务,通知失败则向客户端返回 502。如果没有 ID 时也必须调用接口,需要先明确接口接受什么参数,再调整条件。
示例没有修改响应体,因此保留原有 content-length。如果要改写响应体,应重新计算或移除该头。手动接管响应后,原先由库执行的响应头改写也需要自行处理;需要保留的 CORS 设置可以在 writeHead() 前加入。
WebSocket 握手响应:分清通知和必须等待的操作
通过 proxyReqWs 取得 proxyReq 并监听它的 upgrade,并不代表已经接管握手。异步监听器等待外部服务期间,库自己的监听器仍会向客户端发送 101 Switching Protocols 并建立管道。selfHandleResponse 用于普通 HTTP 响应,不能控制这条 WebSocket 路径。具体流程可参见 WebSocket 握手与管道实现。
可根据业务要求选择处理方式:
- 外部调用只用于记录握手结果:可以在
upgrade监听器中发起异步通知,并自行捕获错误,但不能保证通知在客户端收到握手响应之前完成。 - 外部调用必须完成后才能允许升级:如果判断不依赖目标服务器的握手响应,应在前面的
proxy.ws()之前执行。 - 必须读取目标服务器的握手响应,等待外部服务后才向客户端返回
101:需要自行实现或调整 WebSocket 握手转发流程,由同一段逻辑独占握手响应的写入权。
最后一种情况需要依次处理目标连接、upgrade 响应、等待期间的流暂停、双方的 head 数据、握手头、双向管道及断开清理。仅给现有监听器加 await 无法完成这些工作,也不宜通过移除内部监听器或修改私有字段强行延迟。
原代码还需要修正哪些地方?
- 两次
setHeader()都使用了additional-header-1,第二次会覆盖第一次。如果需要两个独立请求头,第二个名称应按接口约定改为additional-header-2。 fetch()的 JSON 请求体需要使用JSON.stringify(...),不能直接传入普通对象。设置content-type: application/json不会自动序列化。fetch()收到 HTTP 4xx、5xx 时不会仅因状态码自动抛出异常,需要检查response.ok。- 代码片段中没有展示
authorizationToken、PROXY_REQUEST_ID和logger的定义,需要确保实际程序已提供。通过req.headers读取字段时,应使用小写键名。 - 上游拒绝 WebSocket 升级并返回错误响应时,不应将其改写成
101 Switching Protocols。发送101后立即socket.end()也不能建立可用的 WebSocket 隧道;现有自定义响应逻辑还可能与库默认转发发生重复写入。 delete proxyRes.headers['set-cookie']会删除全部 Cookie。如果只想隐藏关联 ID,应只过滤对应 Cookie,保留业务需要的其他 Cookie。
如果外部调用决定是否放行请求或响应,失败时应停止转发并返回约定错误;如果它只用于记录日志,可以采用失败后继续的策略。处理逻辑需要明确采用哪种策略,不能只记录异常后默认继续。
备注:内容仅供参考。
