// 轻量静态文件 + API 代理服务器 // PM2 启动: node serve-proxy.js const http = require('http'); const https = require('https'); const fs = require('fs/promises'); const fsStream = require('fs'); const path = require('path'); // FRONTEND_PORT 非法(非整数)或 ≤0 时回退 5173,不抛错 const parsedPort = Number(process.env.FRONTEND_PORT || 5173); const PORT = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : 5173; // API_TARGET 解析失败或协议不是 http/https 时回退默认值,不抛错(与 FRONTEND_PORT 回退风格一致) let API_TARGET; try { const parsed = new URL(process.env.API_TARGET || 'http://127.0.0.1:3000'); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`API_TARGET 协议仅支持 http/https: ${parsed.protocol}`); } API_TARGET = parsed; } catch { API_TARGET = new URL('http://127.0.0.1:3000'); } const STATIC_DIR = process.env.FRONTEND_DIR ? path.resolve(process.env.FRONTEND_DIR) : path.join(__dirname, 'apps/admin/dist'); const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff2': 'font/woff2', }; // RFC 7230 hop-by-hop headers — 不能透传给客户端 const HOP_BY_HOP = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ]); function stripHopByHop(headers) { const connection = headers.connection; const out = { ...headers }; for (const name of HOP_BY_HOP) delete out[name]; if (typeof connection === 'string') { for (const name of connection.split(',')) delete out[name.trim().toLowerCase()]; } return out; } async function serveStatic(res, filePath) { // 客户端断开/写失败兜底:所有分支(成功/404/SPA fallback/500)共用,避免 uncaughtException res.on('error', () => {}); const ext = path.extname(filePath); const mime = MIME[ext] || 'application/octet-stream'; let stat; try { stat = await fs.stat(filePath); } catch { // SPA fallback 只用于导航请求(无扩展名或 index.html);缺失的静态资源(.js/.css 等)必须 404 const isNavigation = ext === '' || path.basename(filePath) === 'index.html'; if (!isNavigation) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); return; } // SPA fallback 的 index.html 单文件较小,保留 readFile 全量读取 let content; try { content = await fs.readFile(path.join(STATIC_DIR, 'index.html')); } catch { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); return; } // SPA fallback 固定按 HTML 返回,且不能长缓存 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', }); res.end(content); return; } // 目录请求:stat 会成功但 createReadStream 会 EISDIR,必须在写头前拦截 if (!stat.isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); return; } // 防 symlink 穿越:stat/createReadStream 会跟随符号链接,realpath 解析后必须仍落在 STATIC_DIR 内 try { const [realRoot, realFile] = await Promise.all([ fs.realpath(STATIC_DIR), fs.realpath(filePath), ]); if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); return; } } catch { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); return; } // 静态文件流式化:先 stat 拿大小并写头(含 Content-Length),再 pipe 读流,避免全量缓冲 res.writeHead(200, { 'Content-Type': mime, 'Content-Length': stat.size, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800', }); const stream = fsStream.createReadStream(filePath); // 读流中途出错:头未发送时回 404/500,已发送则只能销毁连接 stream.on('error', () => { if (!res.headersSent) { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); return; } res.destroy(); }); // 客户端提前断开/连接关闭:销毁底层读流,避免 fd/socket 泄漏 res.on('close', () => stream.destroy()); stream.pipe(res); } const server = http.createServer((req, res) => { // /api 精确匹配(不带尾斜杠):不代理,也不走 SPA fallback。 // 若落到 SPA fallback 会返回 HTML 200,误导 API 客户端以为存在资源,直接 404 更明确。 if (req.url.split('?')[0] === '/api') { res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Not Found'); return; } // API 代理 if (req.url.startsWith('/api/')) { const client = API_TARGET.protocol === 'https:' ? https : http; const opts = { hostname: API_TARGET.hostname, port: API_TARGET.port || (API_TARGET.protocol === 'https:' ? 443 : 80), // 去掉 API_TARGET.pathname 的尾斜杠,避免拼出 /base//api/... 双斜杠;pathname 为 '/' 时得到空串 path: (API_TARGET.pathname.replace(/\/+$/, '') || '') + req.url, method: req.method, headers: { ...stripHopByHop(req.headers), host: API_TARGET.host, // 代理是部署网络内的可信边界:补充客户端真实 IP/协议供后端审计与限流使用 // 注意:不能赋 undefined(Node setHeader 会抛 ERR_HTTP_INVALID_HEADER_VALUE),为空时干脆不设 ...(req.socket?.remoteAddress ? { 'x-forwarded-for': String(req.socket.remoteAddress).split(',')[0].trim() } : {}), 'x-forwarded-proto': 'http', }, }; let proxy; let timedOut = false; try { proxy = client.request(opts, (proxyRes) => { // 响应头一到就清除 30s 超时:超时只在「等待响应头」阶段生效, // 避免大文件下载中途被空闲超时截断 proxy.setTimeout(0); // 上游中途断开:避免未监听 error 事件导致进程崩溃。 // res 可能已被 proxy.on('error') 分支销毁(双触发),先判断避免二次 destroy。 proxyRes.on('error', () => { if (res.destroyed) return; res.destroy(); }); try { // 上游响应头含非法字符(如 ERR_INVALID_CHAR)时 writeHead 会抛错,需兜底 res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers)); proxyRes.pipe(res); } catch { // 异常时若头未发送回 502,已发送则销毁连接;同时释放上游 socket proxyRes.unpipe(res); proxy.destroy(); proxyRes.destroy(); if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('API unavailable'); } else { res.destroy(); } } }); } catch { if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('API unavailable'); } else { res.destroy(); } return; } // 客户端断开/上游异常时 res 可能抛错,兜底监听防 uncaughtException res.on('error', () => {}); proxy.on('error', () => { // 超时回调已自行回 504 并 destroy,这里直接忽略,避免竞态下重复写响应 if (timedOut) return; // proxyRes.on('error') 分支可能已销毁 res(双触发),避免对已销毁响应二次写/destroy if (res.destroyed) return; if (!res.headersSent) { res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('API unavailable'); } else { res.destroy(); } }); // 客户端断开:销毁上游请求,避免 socket 泄漏。 // 注意:Node 中 req 在「请求体正常接收完毕」时也会触发 close(并非只有客户端断开), // 无条件销毁会把仍在等待上游响应的正常请求误杀(上游 ECONNRESET → 502), // 因此仅在连接确实已关闭(socket 已销毁或响应侧已销毁)时才销毁上游。 req.on('close', () => { if (req.socket?.destroyed || res.destroyed) proxy.destroy(); }); req.on('error', () => proxy.destroy()); // 响应侧兜底:连接在响应写完前关闭(writableFinished=false)即客户端提前断开, // 这是最可靠的断开信号(覆盖 req close/aborted 场景),此时销毁上游避免 socket 泄漏。 res.on('close', () => { if (!res.writableFinished) proxy.destroy(); }); // 上游 30s 无响应视为超时:先标记并 destroy(不带 error,避免触发 error 处理器二次写响应),再显式回 504 proxy.setTimeout(30000, () => { timedOut = true; proxy.destroy(); if (!res.headersSent) { res.writeHead(504, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('API timeout'); } else { res.destroy(); } }); req.pipe(proxy); return; } // 静态文件(防目录穿越) const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0]; const filePath = path.normalize(path.join(STATIC_DIR, urlPath)); if (filePath !== STATIC_DIR && !filePath.startsWith(STATIC_DIR + path.sep)) { res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Forbidden'); return; } serveStatic(res, filePath).catch(() => { if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); } res.end('Internal Server Error'); }); }); server.listen(PORT, () => { process.stdout.write(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET.href}\n`); });