http-proxy-middleware 如何拦截并修改 WebSocket 握手响应头?
成功的 WebSocket 握手需要在 proxyReqWs 中监听上游 ClientRequest 的 upgrade 事件,并在该事件的回调中同步修改响应头。对于底层使用 http-proxy 的实现,成功升级不会经过普通 HTTP 的 proxyRes 处理路径。
原代码还存在变量遮蔽:if 内的 const requestId 创建了新变量,外层的 requestId 始终是 undefined。因此,即使该回调执行,也不会添加 x-xyz-request-id。
为什么能转发响应,却没有 proxyRes 日志?
Node.js 为客户端请求提供独立的 upgrade 事件。连接成功升级时,响应和 socket 通过该事件交付。Node.js HTTP 文档
在所核对的 http-proxy WebSocket 实现中,代理先触发 proxyReqWs,再注册内部 upgrade 监听器。内部监听器会直接将 proxyRes.headers 写入客户端 socket,然后连接两端的数据流。因此,可以在 proxyReqWs 中提前注册监听器,在内部监听器写出握手响应前修改响应头。WebSocket 转发源码
问题没有提供依赖版本。下面沿用原代码的 on: { ... } 配置形式,以 http-proxy-middleware v3 使用的 http-proxy 实现为依据。其他版本需要确认底层事件顺序,不能直接假定相同。v3 文档
如何修改代理?
下面的代码沿用项目已有的 ProxyConfig 类型。第二个参数 reportExchange 由业务代码实现,用于调用外部服务,接收请求信息和后端响应中的请求 ID。
import { createProxyMiddleware } from 'http-proxy-middleware';
import type { IncomingMessage } from 'node:http';
type Exchange = {
method: string | undefined;
url: string | undefined;
statusCode: number | undefined;
requestId: string | undefined;
};
type ReportExchange = (event: Exchange) => Promise<void>;
export const setupProxy = (
proxyConfig: ProxyConfig,
reportExchange: ReportExchange,
) => {
function inspectResponse(
proxyRes: IncomingMessage,
req: IncomingMessage,
): void {
const value = proxyRes.headers['xyz-req-id'];
const requestId = Array.isArray(value) ? value[0] : value;
if (requestId !== undefined) {
proxyRes.headers['x-xyz-request-id'] = requestId;
delete proxyRes.headers['xyz-req-id'];
}
const event: Exchange = {
method: req.method,
url: req.url,
statusCode: proxyRes.statusCode,
requestId,
};
// Keep header changes synchronous; report separately.
void Promise.resolve()
.then(() => reportExchange(event))
.catch((error: unknown) => {
console.error('External service call failed:', error);
});
}
return createProxyMiddleware({
target: proxyConfig.endpoint,
changeOrigin: true,
secure: true,
ws: true,
on: {
proxyReq(proxyReq) {
proxyReq.setHeader(
'Authorization',
`Bearer ${proxyConfig.details.apiKey}`,
);
},
proxyRes(proxyRes, req) {
inspectResponse(proxyRes, req);
},
proxyReqWs(proxyReq, req) {
proxyReq.setHeader(
'Authorization',
`Bearer ${proxyConfig.details.apiKey}`,
);
proxyReq.once('upgrade', (proxyRes) => {
inspectResponse(proxyRes, req);
});
// An upstream refusal, such as HTTP 401, is not an upgrade.
proxyReq.once('response', (proxyRes) => {
inspectResponse(proxyRes, req);
});
},
error(err, req, res) {
console.error(
'Proxy Error:', err.message,
'Target:', proxyConfig.endpoint,
);
if ('writeHead' in res) {
if (!res.headersSent) {
res.writeHead(502, {
'Content-Type': 'application/json',
});
res.end(JSON.stringify({ error: 'Bad Gateway' }));
} else {
res.destroy();
}
} else {
res.destroy();
}
},
},
});
};
代码保留了三条响应处理入口:普通 HTTP 使用 proxyRes;WebSocket 成功升级使用上游请求的 upgrade;升级被拒绝并返回普通 HTTP 响应时,使用上游请求的 response。普通 HTTP 的 proxyRes 事件由另一条转发路径触发。HTTP 转发源码
示例对数组形式的请求 ID 取第一项。如果后端允许该字段包含多个有意义的值,应按接口约定调整。
server.js 需要改哪里?
将外部服务调用函数传入代理,其他启动逻辑可以保留:
// reportExchange is your implementation of the external API call.
const proxy = setupProxy(config, reportExchange);
app.use('/', proxy);
const server = http.createServer(app);
server.on('upgrade', proxy.upgrade);
reportExchange 需要按实际接口实现,并设置请求超时、检查失败响应和处理必要的重试。原问题没有提供外部服务的地址、鉴权方式或请求格式,因此无法给出真实调用参数。
如果原来的 RequestHandler 来自 Express,显式声明返回该类型会丢失代理特有的 .upgrade 类型信息。示例让 TypeScript 推断返回类型,也可以使用 http-proxy-middleware 导出的对应类型。
调用外部服务时要注意什么?
响应头必须在同步回调内修改完成。即使将监听器写成 async 并等待外部服务返回,代理的后续监听器也不会等待;执行到 await 之后时,握手响应可能已经发给客户端。
上述实现适用于记录日志、上报请求关联信息等无需阻塞握手的场景。如果外部服务的返回结果决定是否允许连接,或决定最终响应头,就需要自行控制握手转发时机,并处理超时、拒绝、socket 和 head 数据,不能直接使用这个异步上报方案。
不要修改 Sec-WebSocket-Accept 等握手必需字段,也不要把 Authorization 或 API Key 随日志和上报数据发送出去。这里只处理握手请求与响应;连接建立后的 WebSocket 消息帧需要另行处理。
如何确认修复有效?
重新建立 WebSocket 连接,在开发者工具中检查握手响应:状态应为 101;后端返回请求 ID 时,应出现 x-xyz-request-id,原来的 xyz-req-id 应消失。同时检查外部服务是否收到对应的请求信息和请求 ID。
再检查后端拒绝升级的情况,确认普通错误响应仍可转发,且没有误判为成功连接。这些是建议验证的行为,示例没有在问题所用环境中实际运行。
备注:内容仅供参考。
