PAYATHON 2026

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 无法完成这些工作,也不宜通过移除内部监听器或修改私有字段强行延迟。

原代码还需要修正哪些地方?

  1. 两次 setHeader() 都使用了 additional-header-1,第二次会覆盖第一次。如果需要两个独立请求头,第二个名称应按接口约定改为 additional-header-2。
  2. fetch() 的 JSON 请求体需要使用 JSON.stringify(...),不能直接传入普通对象。设置 content-type: application/json 不会自动序列化。
  3. fetch() 收到 HTTP 4xx、5xx 时不会仅因状态码自动抛出异常,需要检查 response.ok。
  4. 代码片段中没有展示 authorizationToken、PROXY_REQUEST_ID 和 logger 的定义,需要确保实际程序已提供。通过 req.headers 读取字段时,应使用小写键名。
  5. 上游拒绝 WebSocket 升级并返回错误响应时,不应将其改写成 101 Switching Protocols。发送 101 后立即 socket.end() 也不能建立可用的 WebSocket 隧道;现有自定义响应逻辑还可能与库默认转发发生重复写入。
  6. delete proxyRes.headers['set-cookie'] 会删除全部 Cookie。如果只想隐藏关联 ID,应只过滤对应 Cookie,保留业务需要的其他 Cookie。

如果外部调用决定是否放行请求或响应,失败时应停止转发并返回约定错误;如果它只用于记录日志,可以采用失败后继续的策略。处理逻辑需要明确采用哪种策略,不能只记录异常后默认继续。

备注:内容仅供参考。