You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Express路由内WebSocket仅首次调用正常问题排查

问题分析与解决方案

问题描述

搭建ExpressJS服务器并结合ws包实现WebSocket功能,首次调用/download接口时,WebSocket消息正常发送且文件能成功下载;但第二次刷新客户端页面后,消息虽能发送,下载却失败,服务器抛出Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client错误并崩溃。

服务端代码

import express from 'express';
import { WebSocketServer } from 'ws';
import stream from 'stream'; // 原代码缺失该导入,需补充

const app = express();

const server = app.listen(3000);

const wss = new WebSocketServer({ server });

// 将wss实例存入app,方便路由中获取
app.set('wss', wss);

app.get('/download', (req, res) => {
  const wss = req.app.get('wss');
  wss.on('connection', async (ws) => {
    ws.send('hello world');
    // 模拟异步数据处理
    const myData = 'test,data\n1,2\n3,4'; // 原代码未定义该变量,需补充业务逻辑
    const fileContents = Buffer.from(myData);

    const readStream = new stream.PassThrough();
    readStream.end(fileContents);

    res.set('Content-disposition', `attachment; filename=test.csv`);
    res.set('Content-Type', 'text/csv');

    readStream.pipe(res);
    ws.close()
  })
})

客户端代码

axios.get('http://'+ window.location.host +'/download', {
  responseType: 'blob',
}).then(response=>{
  // 创建浏览器内存中的文件链接
  const href = URL.createObjectURL(response.data);

  // 创建a标签并触发下载
  const link = document.createElement('a');
  link.href = href;
  link.setAttribute('download', `file.csv`); 
  document.body.appendChild(link);
  link.click();

  // 清理资源
  document.body.removeChild(link);
  URL.revokeObjectURL(href);
})
const ws = new WebSocket('ws://' + window.location.host);

ws.addEventListener('message', function(m) {
  // 处理WebSocket消息
  console.log('收到消息:', m.data);
})

错误原因

核心问题是每次请求/download接口时,都会给全局的wss对象新增一个connection事件监听器:

  1. 第一次请求时,绑定第一个监听器,WebSocket连接触发该监听器,正常响应res并完成下载。
  2. 第二次请求时,又绑定第二个监听器;当新的WebSocket连接建立时,wss会触发所有已绑定的connection监听器(包括第一次的)。
  3. 第一次监听器中的res已经在第一次请求时完成响应,再次执行res.set()和readStream.pipe(res)时,就会触发Cannot set headers after they are sent to the client错误——同一个res对象被重复尝试发送响应头。

此外,客户端先发起下载请求再创建WebSocket连接的时序,可能导致请求与WebSocket连接的匹配混乱。

修复方案

方案1:通过请求ID匹配WebSocket连接(推荐,无竞态问题)

  1. 路由生成唯一请求ID,通过响应头传递给客户端。
  2. 客户端携带该ID建立WebSocket连接,服务端根据ID匹配对应请求的res。

修改后的服务端代码:

import express from 'express';
import { WebSocketServer } from 'ws';
import stream from 'stream';
import { v4 as uuidv4 } from 'uuid'; // 需安装uuid包:npm install uuid

const app = express();

const server = app.listen(3000);

const wss = new WebSocketServer({ server });
const pendingRequests = new Map(); // 存储待匹配的请求ID与res

// 全局绑定一次connection监听器
wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    const requestId = data.toString();
    const res = pendingRequests.get(requestId);
    if (res) {
      ws.send('hello world');
      // 业务数据处理逻辑
      const myData = 'test,data\n1,2\n3,4';
      const fileContents = Buffer.from(myData);

      const readStream = new stream.PassThrough();
      readStream.end(fileContents);

      res.set('Content-disposition', `attachment; filename=test.csv`);
      res.set('Content-Type', 'text/csv');

      readStream.pipe(res);
      ws.close();
      pendingRequests.delete(requestId); // 清理已完成的请求
    }
  });
});

app.get('/download', (req, res) => {
  const requestId = uuidv4();
  res.set('X-Request-ID', requestId); // 将ID通过响应头传给客户端
  pendingRequests.set(requestId, res);
});

修改后的客户端代码:

axios.get('http://'+ window.location.host +'/download', {
  responseType: 'blob',
})
.then(response=>{
  const requestId = response.headers['x-request-id'];
  // 拿到请求ID后再建立WebSocket连接
  const ws = new WebSocket('ws://' + window.location.host);
  ws.addEventListener('open', () => {
    ws.send(requestId); // 发送请求ID给服务端匹配
  });
  ws.addEventListener('message', function(m) {
    console.log('收到消息:', m.data);
  });

  // 处理文件下载
  const href = URL.createObjectURL(response.data);
  const link = document.createElement('a');
  link.href = href;
  link.setAttribute('download', `file.csv`); 
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(href);
})
.catch(err => console.error('下载失败:', err));

方案2:使用once绑定临时监听器(简单但有竞态风险)

在路由中用once替代on绑定connection事件,确保监听器仅执行一次,避免重复绑定:

服务端代码修改:

app.get('/download', (req, res) => {
  const wss = req.app.get('wss');
  // 使用once,监听器仅触发一次
  wss.once('connection', async (ws) => {
    ws.send('hello world');
    const myData = 'test,data\n1,2\n3,4';
    const fileContents = Buffer.from(myData);

    const readStream = new stream.PassThrough();
    readStream.end(fileContents);

    res.set('Content-disposition', `attachment; filename=test.csv`);
    res.set('Content-Type', 'text/csv');

    readStream.pipe(res);
    ws.close();
  });
});

注意:此方案存在小概率竞态问题——若多个客户端同时发起请求,可能出现监听器被错误匹配的情况,方案1更可靠。

额外注意事项

  • 补充原代码缺失的stream模块导入。
  • 完善myData变量的业务数据逻辑。
  • 客户端需调整时序,确保拿到请求ID后再建立WebSocket连接,避免匹配失败。

内容的提问来源于stack exchange,提问作者Nate

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 11:39:56