由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - serve-proxy:API_TARGET 生效、SPA 404 语义、流式静态文件、hop-by-hop/超时/断连/穿越防护 - migrate.sh:密码不进 argv/不泄漏 pm2、原子锁防重入、DML-only 事务说明、就绪诊断 - deploy.sh/CI:保护 .env、总是 npm ci(迁移依赖 devDeps)、健康检查、并发锁 - ecosystem/oxlint/.env.example:优雅停机、React 版本对齐、TRUST_PROXY 说明 Reviewed-by: OCR (open-codereview.ai)
265 lines
10 KiB
JavaScript
265 lines
10 KiB
JavaScript
// 轻量静态文件 + 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`);
|
||
});
|