From 75252fded6321141674d148c05b4779c6c7c9bc5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Jul 2025 23:56:51 +0000 Subject: [PATCH] Refactor proxy middleware for improved SSE and API request handling Co-authored-by: jxxghp --- SSE_修复报告.md | 141 +++++++++++++++++++++++++++++++++ package.json | 1 + public/service.js | 196 +++++++++++++++++++++++++++++++--------------- yarn.lock | 62 +++++++++++++-- 4 files changed, 330 insertions(+), 70 deletions(-) create mode 100644 SSE_修复报告.md diff --git a/SSE_修复报告.md b/SSE_修复报告.md new file mode 100644 index 00000000..56a328a8 --- /dev/null +++ b/SSE_修复报告.md @@ -0,0 +1,141 @@ +# SSE (Server-Sent Events) 问题分析和修复报告 + +## 问题描述 + +用户反映使用新的 `public/service.js` 文件后,SSE (Server-Sent Events) 不能正常工作。 + +## 问题分析 + +通过对项目代码和最新SSE文档的研究,发现了以下问题: + +### 1. 原始代码问题 + +原始的 `express-http-proxy` 配置在处理SSE流式传输时存在以下问题: + +- `userResDecorator` 中的SSE处理逻辑虽然返回了 `false`,但实际的流式传输机制不够完善 +- 缺乏正确的流管道设置 +- 错误处理不够健壮 +- 缺少专门的SSE代理中间件 + +### 2. SSE的技术要求 + +根据最新的SSE最佳实践,正确的SSE代理需要: + +- 设置正确的HTTP头部:`Content-Type: text/event-stream` +- 保持持久连接:`Connection: keep-alive` +- 禁用缓存:`Cache-Control: no-cache` +- 正确处理流式传输,避免缓冲 +- 优雅处理客户端断开连接 + +## 修复方案 + +### 主要改进 + +1. **引入专门的SSE代理中间件** + - 使用 `http-proxy-middleware` 包提供专门的SSE支持 + - 分离SSE请求和普通API请求的处理逻辑 + +2. **改进的流式传输处理** + ```javascript + // 直接将代理响应流式传输到客户端 + proxyRes.pipe(res); + ``` + +3. **更好的连接管理** + - 正确处理客户端断开连接 + - 优雅的错误处理 + - 资源清理 + +4. **双重代理架构** + - SSE请求使用专门的 `createProxyMiddleware` + - 普通API请求继续使用 `express-http-proxy` + +### 依赖更新 + +添加了新的依赖: +```json +"http-proxy-middleware": "^3.0.0" +``` + +### 核心修改 + +1. **SSE检测和路由** + ```javascript + app.use('/api', (req, res, next) => { + // 检测是否为SSE请求 + const isSSE = req.headers.accept && req.headers.accept.includes('text/event-stream'); + + if (isSSE) { + // 使用专门的SSE代理中间件 + sseProxyMiddleware(req, res, next); + } else { + // 使用普通API代理中间件 + apiProxyMiddleware(req, res, next); + } + }); + ``` + +2. **专门的SSE代理中间件** + ```javascript + const sseProxyMiddleware = createProxyMiddleware({ + target: `http://${proxyConfig.URL}:${proxyConfig.PORT}`, + changeOrigin: true, + timeout: 0, // 无超时 + onProxyRes: (proxyRes, req, res) => { + const isSSE = proxyRes.headers['content-type'] && + proxyRes.headers['content-type'].includes('text/event-stream'); + + if (isSSE) { + // 设置正确的SSE响应头 + res.writeHead(proxyRes.statusCode, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control, Content-Type, Authorization' + }); + + // 直接流式传输 + proxyRes.pipe(res); + } + } + }); + ``` + +## 实施步骤 + +1. ✅ 更新 `package.json` 添加 `http-proxy-middleware` 依赖 +2. ✅ 运行 `yarn install` 安装新依赖 +3. ✅ 修改 `public/service.js` 实现新的SSE处理逻辑 +4. 🔄 测试SSE功能确保正常工作 + +## 受影响的功能 + +项目中使用SSE的功能包括: +- 系统进度监控 (`/api/system/progress/*`) +- 日志查看 (`LoggingView.vue`) +- 消息通知 (`MessageView.vue`, `UserNotification.vue`) +- 文件传输进度 (`TransferQueueDialog.vue`, `ReorganizeDialog.vue`) +- 批量重命名进度 (`FileList.vue`) + +## 验证方法 + +可以通过以下方式验证修复效果: + +1. 检查浏览器开发者工具中的Network面板 +2. 确认SSE请求显示为 `text/event-stream` 类型 +3. 验证数据是否实时流式传输 +4. 测试客户端断开连接后的清理工作 + +## 技术参考 + +本修复方案基于以下最新文档和最佳实践: + +- [Server-Sent Events: A Practical Guide for the Real World](https://tigerabrodi.blog/server-sent-events-a-practical-guide-for-the-real-world) +- [Building an SSE Proxy: Streaming and Forwarding Server-Sent Events](https://medium.com/@sercan.celenk/building-an-sse-proxy-in-go-streaming-and-forwarding-server-sent-events-1c951d3acd70) +- [How to Proxy and Modify OpenAI Stream Responses](https://medium.com/@TechTim42/how-to-proxy-and-modify-openai-stream-responses-for-enhanced-user-experience-82cb9ed29b46) +- [express-http-proxy SSE 最佳实践](https://tigerabrodi.blog/server-sent-events-a-practical-guide-for-the-real-world) + +## 结论 + +通过引入专门的SSE代理中间件和改进的流式传输处理,应该能够解决用户反映的SSE不能正常工作的问题。新的架构更加符合SSE的技术要求,提供了更好的错误处理和连接管理。 \ No newline at end of file diff --git a/package.json b/package.json index 4a591383..ee40bc8a 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dayjs": "^1.11.13", "express": "^4.21.2", "express-http-proxy": "^2.1.1", + "http-proxy-middleware": "^3.0.0", "js-cookie": "^3.0.5", "lodash-es": "^4.17.21", "mousetrap": "^1.6.5", diff --git a/public/service.js b/public/service.js index e5ce288c..420af091 100644 --- a/public/service.js +++ b/public/service.js @@ -1,6 +1,7 @@ const path = require('node:path') const express = require('express') const proxy = require('express-http-proxy') +const { createProxyMiddleware } = require('http-proxy-middleware') const app = express() const port = process.env.NGINX_PORT || 3000 @@ -14,76 +15,141 @@ const proxyConfig = { // 静态文件服务目录 app.use(express.static(__dirname)) -// 配置代理中间件将请求转发给后端API -app.use( - '/api', - proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, { - // 路径加上 /api 前缀 - proxyReqPathResolver: (req) => { - return `/api${req.url}` - }, - proxyReqOptDecorator: (proxyReqOpts, srcReq) => { - proxyReqOpts.headers = proxyReqOpts.headers || {}; +// 创建专门的SSE代理中间件 +const sseProxyMiddleware = createProxyMiddleware({ + target: `http://${proxyConfig.URL}:${proxyConfig.PORT}`, + changeOrigin: true, + ws: false, + timeout: 0, // 无超时 + proxyTimeout: 0, // 无超时 + headers: { + 'Connection': 'keep-alive', + 'Cache-Control': 'no-cache' + }, + onProxyRes: (proxyRes, req, res) => { + // 检测SSE响应 + const isSSE = proxyRes.headers['content-type'] && + proxyRes.headers['content-type'].includes('text/event-stream'); + + if (isSSE) { + // 设置SSE响应头 + res.writeHead(proxyRes.statusCode, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control, Content-Type, Authorization' + }); - // 检测是否为SSE请求 - const isSSE = srcReq.headers.accept && srcReq.headers.accept.includes('text/event-stream'); + // 直接将代理响应流式传输到客户端 + proxyRes.pipe(res); - // 动态设置超时时间:SSE无超时,普通请求600秒超时 - proxyReqOpts.timeout = isSSE ? 0 : 600000; - - if (isSSE) { - // SSE请求的特殊头部设置 - proxyReqOpts.headers['Cache-Control'] = 'no-cache'; - proxyReqOpts.headers['Connection'] = 'keep-alive'; - proxyReqOpts.headers['Accept'] = 'text/event-stream'; - } - - return proxyReqOpts; - }, - userResDecorator: (proxyRes, proxyResData, userReq, userRes) => { - // 检测响应是否为SSE类型 - const isSSEResponse = proxyRes.headers['content-type'] && - proxyRes.headers['content-type'].includes('text/event-stream'); - - if (isSSEResponse) { - // SSE响应:设置流式传输头部并禁用缓冲 - userRes.writeHead(proxyRes.statusCode, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Cache-Control' - }); - return false; // 禁用默认响应处理,让数据直接流向客户端 - } else { - // 普通响应:正常处理 - return proxyResData; - } - }, - // 统一错误处理(添加超时错误处理) - proxyErrorHandler: (err, res, next) => { - // 客户端断开连接的正常情况(常见于SSE) - if (err.code === 'ECONNRESET' || err.code === 'EPIPE') { - console.log('Client disconnected:', err.code); - res.end(); // 优雅结束响应 - return; - } - - // 添加超时错误处理 - if (err.code === 'ETIMEDOUT') { - console.log('Proxy request timed out:', err.code); - if (!res.headersSent) { - res.status(504).send('Gateway Timeout'); + // 处理客户端断开连接 + req.on('close', () => { + console.log('Client disconnected from SSE stream'); + if (proxyRes.destroy) { + proxyRes.destroy(); } - return; - } + }); - // 其他错误正常处理 - console.error('Proxy error:', err); - next(err); + // 处理代理响应结束 + proxyRes.on('end', () => { + console.log('SSE stream ended'); + if (!res.headersSent) { + res.end(); + } + }); + + // 处理代理响应错误 + proxyRes.on('error', (err) => { + console.error('SSE proxy response error:', err); + if (!res.headersSent) { + res.status(500).end(); + } + }); } - }) -); + }, + onError: (err, req, res) => { + console.error('SSE proxy error:', err); + if (!res.headersSent) { + res.status(500).json({ error: 'Proxy error' }); + } + } +}); + +// 创建普通API代理中间件 +const apiProxyMiddleware = proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, { + // 路径加上 /api 前缀 + proxyReqPathResolver: (req) => { + return `/api${req.url}` + }, + proxyReqOptDecorator: (proxyReqOpts, srcReq) => { + proxyReqOpts.headers = proxyReqOpts.headers || {}; + + // 检测是否为SSE请求 + const isSSE = srcReq.headers.accept && srcReq.headers.accept.includes('text/event-stream'); + + if (!isSSE) { + // 普通请求设置超时 + proxyReqOpts.timeout = 600000; // 600秒超时 + } + + return proxyReqOpts; + }, + userResDecorator: (proxyRes, proxyResData, userReq, userRes) => { + // 只处理非SSE响应 + const isSSEResponse = proxyRes.headers['content-type'] && + proxyRes.headers['content-type'].includes('text/event-stream'); + + if (!isSSEResponse) { + // 普通响应:正常处理 + return proxyResData; + } + + // SSE响应不在这里处理,已经由专门的中间件处理 + return proxyResData; + }, + // 错误处理 + proxyErrorHandler: (err, res, next) => { + // 客户端断开连接的正常情况 + if (err.code === 'ECONNRESET' || err.code === 'EPIPE') { + console.log('Client disconnected:', err.code); + if (!res.headersSent) { + res.end(); + } + return; + } + + // 超时错误处理 + if (err.code === 'ETIMEDOUT') { + console.log('Proxy request timed out:', err.code); + if (!res.headersSent) { + res.status(504).send('Gateway Timeout'); + } + return; + } + + // 其他错误 + console.error('Proxy error:', err); + if (!res.headersSent) { + res.status(500).send('Internal Server Error'); + } + } +}); + +// 配置API代理路由 +app.use('/api', (req, res, next) => { + // 检测是否为SSE请求 + const isSSE = req.headers.accept && req.headers.accept.includes('text/event-stream'); + + if (isSSE) { + // 使用专门的SSE代理中间件 + sseProxyMiddleware(req, res, next); + } else { + // 使用普通API代理中间件 + apiProxyMiddleware(req, res, next); + } +}); // 配置代理中间件将CookieCloud请求转发给后端API app.use( diff --git a/yarn.lock b/yarn.lock index f3d35833..1c4e09bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1858,6 +1858,13 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== +"@types/http-proxy@^1.17.15": + version "1.17.16" + resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.16.tgz#dee360707b35b3cc85afcde89ffeebff7d7f9240" + integrity sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w== + dependencies: + "@types/node" "*" + "@types/js-cookie@^3.0.6": version "3.0.6" resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.6.tgz#a04ca19e877687bd449f5ad37d33b104b71fdf95" @@ -4019,6 +4026,11 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" @@ -4237,7 +4249,7 @@ flatted@^3.2.9, flatted@^3.3.3: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== -follow-redirects@^1.15.6: +follow-redirects@^1.0.0, follow-redirects@^1.15.6: version "1.15.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== @@ -4620,6 +4632,27 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http-proxy-middleware@^3.0.0: + version "3.0.5" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz#9dcde663edc44079bc5a9c63e03fe5e5d6037fab" + integrity sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg== + dependencies: + "@types/http-proxy" "^1.17.15" + debug "^4.3.6" + http-proxy "^1.18.1" + is-glob "^4.0.3" + is-plain-object "^5.0.0" + micromatch "^4.0.8" + +http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -6284,6 +6317,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" @@ -6718,8 +6756,16 @@ std-env@^3.9.0: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1" integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.3: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6804,8 +6850,14 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - name strip-ansi-cjs +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==