alist-proxy/source.js
XiaoMo 59f7551913 refactor(缓存): 优化缩略图生成和缓存文件命名逻辑
- 修改缓存元文件名使用 uniqidhex 替代 uniqid 以保持一致性
- 重构 createThumbnail 函数,使其返回缩略图路径并改进参数处理
- 移除冗余的缩略图文件存在性检查,改为统一在函数内处理
- 改进缩略图尺寸检查逻辑,增加有效性验证
- 优化缩略图 ETag 生成方式,优先使用 thumb.uniqid
2025-05-27 17:50:48 +08:00

619 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const http = require('http');
const https = require('https');
const url = require('url');
const querystring = require('querystring');
const fs = require('fs');
const pathModule = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
const CACHE_DIR_NAME = '.cache';
const DEFAULT_PORT = 9001;
const DEFAULT_API_ENDPOINT = 'http://183.6.121.121:9005/get/';
const cacheDir = pathModule.join(__dirname, CACHE_DIR_NAME);
const pathIndex = {};
// 访问计数器
const viewsInfo = {
request: 0,
cacheHit: 0,
apiCall: 0,
cacheCall: 0,
cacheReadError: 0,
fetchApiError: 0,
fetchApiWarning: 0,
increment: function (key) {
if (this.hasOwnProperty(key)) {
this[key]++;
}
}
};
let port = DEFAULT_PORT;
let apiEndpoint = DEFAULT_API_ENDPOINT;
// 解析命令行参数函数
function parseArguments() {
const args = process.argv.slice(2);
args.forEach(arg => {
const cleanArg = arg.startsWith('--') ? arg.substring(2) : arg;
const [key, value] = cleanArg.split('=');
if (key === 'port' && value) {
const parsedPort = parseInt(value, 10);
if (!isNaN(parsedPort)) {
port = parsedPort;
}
} else if (key === 'api' && value) {
apiEndpoint = value;
}
});
}
// 初始化函数,包含参数解析和目录创建
function initializeApp() {
parseArguments();
if (!fs.existsSync(cacheDir)) {
try {
fs.mkdirSync(cacheDir, { recursive: true });
console.log(`Cache directory created: ${cacheDir}`);
} catch (err) {
console.error(`Error creating cache directory ${cacheDir}:`, err);
process.exit(1); // Exit if cache directory cannot be created
}
}
}
initializeApp();
const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const CACHE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const HTTP_STATUS = {
OK: 200,
NO_CONTENT: 204,
REDIRECT: 302,
NOT_MODIFIED: 304,
BAD_REQUEST: 400,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
BAD_GATEWAY: 502,
};
// 定时清理过期缓存数据
setInterval(() => {
const currentTime = Date.now();
for (const key in pathIndex) {
if (currentTime - pathIndex[key].timestamp > CACHE_EXPIRY_MS) {
delete pathIndex[key];
}
}
}, CACHE_CLEANUP_INTERVAL_MS);
// 统一发送错误响应
function sendErrorResponse(res, statusCode, message) {
if (!res.headersSent) {
res.writeHead(statusCode, { 'Content-Type': 'text/plain;charset=UTF-8' });
res.end(message);
}
}
// --- Request Handling Logic ---
async function handleFavicon(req, res) {
res.writeHead(HTTP_STATUS.NO_CONTENT);
res.end();
}
async function handleEndpoint(req, res, parsedUrl) {
if (parsedUrl.query.api) {
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/;
if (urlRegex.test(parsedUrl.query.api)) {
apiEndpoint = parsedUrl.query.api;
console.log(`API endpoint updated to: ${apiEndpoint}`);
}
}
res.writeHead(HTTP_STATUS.OK, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({
code: HTTP_STATUS.OK,
data: {
api: apiEndpoint,
port: port,
cacheDir: cacheDir,
pathIndexCount: Object.keys(pathIndex).length,
viewsInfo: {
request: viewsInfo.request,
cacheHit: viewsInfo.cacheHit,
apiCall: viewsInfo.apiCall,
cacheCall: viewsInfo.cacheCall,
cacheReadError: viewsInfo.cacheReadError,
fetchApiError: viewsInfo.fetchApiError,
fetchApiWarning: viewsInfo.fetchApiWarning,
}
}
}));
}
async function handleApiRedirect(res, apiData) {
res.writeHead(HTTP_STATUS.REDIRECT, { Location: apiData.data.url });
res.end();
}
async function processSuccessfulApiData(apiData, uniqidhex, reqPath, token, sign, res) {
const { url: realUrl, cloudtype, expiration, path: apiPath, headers, uniqid, thumb } = apiData.data;
const data = { realUrl, cloudtype, expiration: expiration * 1000, path: apiPath, headers, uniqid, thumb };
pathIndex[uniqidhex] = { uniqid: data.uniqid, timestamp: Date.now() };
const cacheMetaFile = pathModule.join(cacheDir, `${uniqidhex}.meta`);
const cacheContentFile = pathModule.join(cacheDir, `${data.uniqid}.content`);
const tempCacheContentFile = pathModule.join(cacheDir, `${data.uniqid}_${crypto.randomBytes(16).toString('hex')}.temp`);
try {
fs.writeFileSync(cacheMetaFile, JSON.stringify(data));
} catch (writeError) {
console.error(`Error writing meta file ${cacheMetaFile}:`, writeError);
sendErrorResponse(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Failed to write cache metadata.');
return;
}
if (fs.existsSync(cacheContentFile)) {
const stats = fs.statSync(cacheContentFile);
const contentLength = stats.size;
if (contentLength < 2048 && data.headers['content-length'] && parseInt(data.headers['content-length'], 10) !== contentLength) {
console.warn(`Content length mismatch for ${cacheContentFile}. API: ${data.headers['content-length']}, Cache: ${contentLength}. Re-fetching.`);
fetchAndServe(data, tempCacheContentFile, cacheContentFile, cacheMetaFile, res);
} else {
serveFromCache(data, cacheContentFile, cacheMetaFile, res);
}
} else {
fetchAndServe(data, tempCacheContentFile, cacheContentFile, cacheMetaFile, res);
}
}
async function tryServeFromStaleCacheOrError(uniqidhex, res, errorMessage) {
if (pathIndex[uniqidhex]) {
const cacheMetaFile = pathModule.join(cacheDir, `${uniqidhex}.meta`);
const cacheContentFile = pathModule.join(cacheDir, `${pathIndex[uniqidhex].uniqid}.content`);
if (fs.existsSync(cacheMetaFile) && fs.existsSync(cacheContentFile)) {
console.warn(`API call failed or returned non-200. Serving stale cache for ${uniqidhex}`);
try {
const cacheData = JSON.parse(fs.readFileSync(cacheMetaFile, 'utf8'));
serveFromCache(cacheData, cacheContentFile, cacheMetaFile, res);
return;
} catch (parseError) {
console.error(`Error parsing stale meta file ${cacheMetaFile}:`, parseError);
// Fall through to generic error if stale cache is also broken
}
}
}
sendErrorResponse(res, HTTP_STATUS.BAD_GATEWAY, errorMessage || 'Bad Gateway');
}
async function handleMainRequest(req, res) {
req.url = req.url.replace(/\/{2,}/g, '/');
const parsedUrl = url.parse(req.url, true);
const sign = parsedUrl.query.sign || '';
let reqPath = parsedUrl.pathname.split('/')[1] || ''; // Ensure reqPath is not undefined
let token = parsedUrl.pathname.split('/').slice(2).join('/');
if (reqPath === 'favicon.ico') return handleFavicon(req, res);
if (reqPath === 'endpoint') return handleEndpoint(req, res, parsedUrl);
if (!token && reqPath) { // If token is empty but reqPath is not, assume reqPath is the token
token = reqPath;
reqPath = 'app'; // Default to 'app' if only one path segment is provided
}
const ALLOWED_PATHS = ['avatar', 'go', 'bbs', 'www', 'url', 'thumb', 'app'];
if (!ALLOWED_PATHS.includes(reqPath) || !token) {
return sendErrorResponse(res, HTTP_STATUS.BAD_REQUEST, `Bad Request: Invalid path or missing token.`);
}
viewsInfo.increment('request');
const uniqidhex = crypto.createHash('md5').update(reqPath + token + sign).digest('hex');
let cacheMetaFile = '';
let cacheContentFile = '';
if (pathIndex[uniqidhex]) {
cacheMetaFile = pathModule.join(cacheDir, `${uniqidhex}.meta`);
cacheContentFile = pathModule.join(cacheDir, `${pathIndex[uniqidhex].uniqid}.content`);
}
if (pathIndex[uniqidhex] && isCacheValid(cacheMetaFile, cacheContentFile)) {
const { cacheData, isNotModified } = await checkCacheHeaders(req, cacheMetaFile);
if (isNotModified) {
res.writeHead(HTTP_STATUS.NOT_MODIFIED);
res.end();
} else {
viewsInfo.increment('cacheHit');
serveFromCache(cacheData, cacheContentFile, cacheMetaFile, res);
}
} else {
try {
viewsInfo.increment('apiCall');
const apiData = await fetchApiData(reqPath, token, sign);
if (apiData.code === HTTP_STATUS.REDIRECT || apiData.code === 301) {
return handleApiRedirect(res, apiData);
}
if (apiData.code === HTTP_STATUS.OK && apiData.data && apiData.data.url) {
await processSuccessfulApiData(apiData, uniqidhex, reqPath, token, sign, res);
} else {
viewsInfo.increment('fetchApiWarning');
await tryServeFromStaleCacheOrError(uniqidhex, res, apiData.message);
}
} catch (error) {
viewsInfo.increment('fetchApiError');
console.error('Error in API call or processing:', error);
await tryServeFromStaleCacheOrError(uniqidhex, res, `Bad Gateway: API request failed. ${error.message}`);
}
}
}
const server = http.createServer(handleMainRequest);
// 检查缓存头并返回是否为304
async function checkCacheHeaders(req, cacheMetaFile) {
try {
const metaContent = fs.readFileSync(cacheMetaFile, 'utf8');
const cacheData = JSON.parse(metaContent);
const ifNoneMatch = req.headers['if-none-match'];
const ifModifiedSince = req.headers['if-modified-since'];
// Check ETag first
if (ifNoneMatch && cacheData.uniqid && ifNoneMatch === cacheData.uniqid) {
return { cacheData, isNotModified: true };
}
// Check If-Modified-Since
if (ifModifiedSince && cacheData.headers && cacheData.headers['last-modified']) {
try {
const lastModifiedDate = new Date(cacheData.headers['last-modified']);
const ifModifiedSinceDate = new Date(ifModifiedSince);
// The time resolution of an HTTP date is one second.
// If If-Modified-Since is at least as new as Last-Modified, send 304.
if (lastModifiedDate.getTime() <= ifModifiedSinceDate.getTime()) {
return { cacheData, isNotModified: true };
}
} catch (dateParseError) {
console.warn(`Error parsing date for cache header check (${cacheMetaFile}):`, dateParseError);
// Proceed as if not modified check failed if dates are invalid
}
}
return { cacheData, isNotModified: false };
} catch (error) {
console.error(`Error reading or parsing cache meta file ${cacheMetaFile} in checkCacheHeaders:`, error);
// If we can't read meta, assume cache is invalid or treat as not modified: false
// Returning a dummy cacheData or null might be better depending on how caller handles it.
// For now, let it propagate and potentially fail later if cacheData is expected.
// Or, more safely, indicate cache is not valid / not modified is false.
return { cacheData: null, isNotModified: false }; // Indicate failure to load cacheData
}
}
// 检查缓存是否有效
function isCacheValid(cacheMetaFile, cacheContentFile) {
if (!fs.existsSync(cacheMetaFile) || !fs.existsSync(cacheContentFile)) {
return false;
}
try {
const metaContent = fs.readFileSync(cacheMetaFile, 'utf8');
const cacheData = JSON.parse(metaContent);
// Ensure expiration is a number and in the future
return typeof cacheData.expiration === 'number' && cacheData.expiration > Date.now();
} catch (error) {
console.warn(`Error reading or parsing cache meta file ${cacheMetaFile} for validation:`, error);
return false; // If meta file is corrupt or unreadable, cache is not valid
}
}
// 从 API 获取数据
const API_TIMEOUT_MS = 5000;
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36';
async function fetchApiData(reqPath, token, sign) {
const queryParams = querystring.stringify({
type: reqPath,
sign: sign
});
const apiUrl = `${apiEndpoint}?${queryParams}`;
const parsedApiUrl = new URL(apiUrl);
const protocol = parsedApiUrl.protocol === 'https:' ? https : http;
const options = {
method: 'GET',
headers: {
'Accept': 'application/json; charset=utf-8',
'User-Agent': USER_AGENT,
'token': token
},
timeout: API_TIMEOUT_MS,
rejectUnauthorized: false, // Allow self-signed certificates, use with caution
};
return new Promise((resolve, reject) => {
const apiReq = protocol.request(apiUrl, options, (apiRes) => {
let responseData = '';
apiRes.setEncoding('utf8');
apiRes.on('data', chunk => responseData += chunk);
apiRes.on('end', () => {
try {
if (apiRes.statusCode >= 400) {
// Treat HTTP errors from API as rejections for easier handling
console.error(`API request to ${apiUrl} failed with status ${apiRes.statusCode}: ${responseData}`);
// Attempt to parse for a message, but prioritize status code for error type
let errorPayload = { code: apiRes.statusCode, message: `API Error: ${apiRes.statusCode}` };
try {
const parsedError = JSON.parse(responseData);
if (parsedError && parsedError.message) errorPayload.message = parsedError.message;
} catch (e) { /* Ignore if response is not JSON */ }
resolve(errorPayload); // Resolve with error structure for consistency
return;
}
resolve(JSON.parse(responseData));
} catch (parseError) {
console.error(`Error parsing JSON response from ${apiUrl}:`, parseError, responseData);
reject(new Error(`Failed to parse API response: ${parseError.message}`));
}
});
});
apiReq.on('timeout', () => {
apiReq.destroy(); // Destroy the request to free up resources
console.error(`API request to ${apiUrl} timed out after ${API_TIMEOUT_MS}ms`);
reject(new Error('API request timed out'));
});
apiReq.on('error', (networkError) => {
console.error(`API request to ${apiUrl} failed:`, networkError);
reject(networkError);
});
apiReq.end();
});
}
// createThumbnail
function createThumbnail(data, cacheContentFile) {
const { path, thumb } = data;
const thumbCacheFile = pathModule.join(cacheDir, `thumb_${thumb.uniqid}.jpeg`);
if (fs.existsSync(thumbCacheFile)) return thumbCacheFile;
const isVideo = path && typeof path === 'string' && path.includes('.mp4');
if (isVideo || !thumb) return;
const width = thumb.width && thumb.width > 0 ? thumb.width : undefined;
const height = thumb.height && thumb.height > 0 ? thumb.height : undefined;
if (!width) return;
sharp(cacheContentFile).resize(width, height).toFile(thumbCacheFile);
return thumbCacheFile;
}
// 从真实 URL 获取数据并写入缓存
const REAL_URL_FETCH_TIMEOUT_MS = 0; // 0 means no timeout for the actual file download
const fetchAndServe = (data, tempCacheContentFile, cacheContentFile, cacheMetaFile, res) => {
const protocol = data.realUrl.startsWith('https:') ? https : http;
protocol.get(data.realUrl, { timeout: REAL_URL_FETCH_TIMEOUT_MS, rejectUnauthorized: false }, (realRes) => {
const cacheStream = fs.createWriteStream(tempCacheContentFile, { flags: 'w' });
let isVideo = data.path && typeof data.path === 'string' && data.path.includes('.mp4');
// 确保 content-length 是有效的
const contentLength = realRes.headers['content-length'];
if (contentLength) {
// contentLength 小于 2KB 且与缓存文件大小不一致时,重新获取
if (contentLength < 2048 && data.headers['content-length'] !== contentLength) {
console.warn('Warning: content-length is different for the response from:', data.realUrl);
sendErrorResponse(res, HTTP_STATUS.BAD_GATEWAY, `Bad Gateway: Content-Length mismatch for ${data.realUrl}`);
// Clean up temp file if stream hasn't started or failed early
if (fs.existsSync(tempCacheContentFile)) {
fs.unlinkSync(tempCacheContentFile);
}
return;
}
data.headers['content-length'] = contentLength;
// 更新 data 到缓存 cacheMetaFile
fs.writeFileSync(cacheMetaFile, JSON.stringify(data));
} else {
console.warn('Warning: content-length is undefined for the response from:', data.realUrl);
}
const baseHeaders = {
'Cloud-Type': data.cloudtype,
'Cloud-Expiration': data.expiration,
'ETag': data.uniqid || '',
'Cache-Control': 'public, max-age=31536000', // 1 year
'Expires': new Date(Date.now() + 31536000000).toUTCString(),
'Accept-Ranges': 'bytes',
'Connection': 'keep-alive',
'Date': new Date().toUTCString(), // Should be set by the server, but good for consistency
'Last-Modified': data.headers['last-modified'] || new Date(fs.statSync(cacheMetaFile).mtime).toUTCString(), // Prefer API's Last-Modified if available
};
const responseHeaders = {
...baseHeaders,
'Content-Type': realRes.headers['content-type'] || (isVideo ? 'video/mp4' : 'application/octet-stream'), // Prefer actual content-type
...data.headers, // Allow API to override some headers if necessary
};
res.writeHead(realRes.statusCode, responseHeaders);
realRes.pipe(cacheStream);
realRes.pipe(res);
realRes.on('end', () => {
cacheStream.end(() => { // Ensure stream is fully flushed before renaming
if (fs.existsSync(tempCacheContentFile)) {
try {
// Ensure the target directory exists before renaming
const targetDir = pathModule.dirname(cacheContentFile);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
fs.renameSync(tempCacheContentFile, cacheContentFile);
console.log(`Successfully cached: ${cacheContentFile}`);
// 生成缩略图
if (data.thumb) {
createThumbnail(data, cacheContentFile);
}
} catch (renameError) {
console.error(`Error renaming temp cache file ${tempCacheContentFile} to ${cacheContentFile}:`, renameError);
// If rename fails, try to remove the temp file to avoid clutter
try { fs.unlinkSync(tempCacheContentFile); } catch (e) { /* ignore */ }
}
} else {
// This case might indicate an issue if the stream ended but no temp file was created/found
console.warn(`Temp cache file ${tempCacheContentFile} not found after stream end for ${data.realUrl}`);
}
});
});
realRes.on('error', (streamError) => {
console.error(`Error during response stream from ${data.realUrl}:`, streamError);
cacheStream.end(); // Close the writable stream
handleResponseError(res, tempCacheContentFile, data.realUrl); // tempCacheContentFile might be partially written
});
}).on('error', (requestError) => {
console.error(`Error making GET request to ${data.realUrl}:`, requestError);
// No cacheStream involved here if the request itself fails before response
handleResponseError(res, tempCacheContentFile, data.realUrl); // tempCacheContentFile might not exist or be empty
});
};
// 从缓存中读取数据并返回
function serveFromCache(cacheData, cacheContentFile, cacheMetaFile, res) {
if (!cacheData) { // Added check for null cacheData from checkCacheHeaders failure
console.error(`serveFromCache called with null cacheData for ${cacheContentFile}`);
sendErrorResponse(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Cache metadata unavailable.');
return;
}
const baseHeaders = {
'Cloud-Type': cacheData.cloudtype || 'unknown',
'Cloud-Expiration': cacheData.expiration || 'N/A',
'ETag': cacheData.uniqid || crypto.createHash('md5').update(fs.readFileSync(cacheContentFile)).digest('hex'), // Fallback ETag if missing
'Cache-Control': 'public, max-age=31536000', // 1 year
'Expires': new Date(Date.now() + 31536000000).toUTCString(),
'Accept-Ranges': 'bytes',
'Connection': 'keep-alive',
'Date': new Date().toUTCString(),
'Last-Modified': (cacheData.headers && cacheData.headers['last-modified']) || new Date(fs.statSync(cacheMetaFile).mtime).toUTCString(),
};
if (cacheData.thumb) {
var thumbCacheFile = createThumbnail(cacheData, cacheContentFile)
if (thumbCacheFile && fs.existsSync(thumbCacheFile)) {
cacheData.headers['content-length'] = fs.statSync(thumbCacheFile).size;
const responseHeaders = {
...baseHeaders,
...(cacheData.headers || {}),
'ETag': (cacheData.thumb.uniqid || cacheData.uniqid) + '_thumb',
'Content-Type': 'image/jpeg',
};
res.writeHead(HTTP_STATUS.OK, responseHeaders);
const thumbStream = fs.createReadStream(thumbCacheFile);
thumbStream.pipe(res);
return;
}
}
viewsInfo.increment('cacheCall');
const readStream = fs.createReadStream(cacheContentFile);
const isVideo = cacheData.path && typeof cacheData.path === 'string' && cacheData.path.includes('.mp4');
let currentContentLength = cacheData.headers && cacheData.headers['content-length'] ? parseInt(cacheData.headers['content-length'], 10) : 0;
if (!currentContentLength || currentContentLength === 0) {
try {
const stats = fs.statSync(cacheContentFile);
currentContentLength = stats.size;
if (currentContentLength > 0) {
if (!cacheData.headers) cacheData.headers = {};
cacheData.headers['content-length'] = currentContentLength.toString();
// Update meta file if content-length was missing or zero
fs.writeFileSync(cacheMetaFile, JSON.stringify(cacheData));
console.log(`Updated content-length in ${cacheMetaFile} to ${currentContentLength}`);
} else {
console.warn(`Cached content file ${cacheContentFile} has size 0 or stat failed.`);
// Potentially treat as an error or serve as is if 0 length is valid for some files
}
} catch (statError) {
console.error(`Error stating cache content file ${cacheContentFile}:`, statError);
handleCacheReadError(res, cacheContentFile); // Treat stat error as read error
return;
}
}
readStream.on('open', () => {
const responseHeaders = {
...baseHeaders,
'Content-Type': (cacheData.headers && cacheData.headers['content-type']) || (isVideo ? 'video/mp4' : 'application/octet-stream'),
// Merge other headers from cacheData.headers, letting them override base if necessary
// but ensure our critical headers like Content-Length (if updated) are preserved.
...(cacheData.headers || {}),
};
res.writeHead(HTTP_STATUS.OK, responseHeaders);
readStream.pipe(res);
});
readStream.on('error', (err) => {
console.error(`Read stream error for ${cacheContentFile}:`, err);
handleCacheReadError(res, cacheContentFile);
});
// Handle cases where client closes connection prematurely
res.on('close', () => {
if (!res.writableEnded) {
console.log(`Client closed connection prematurely for ${cacheContentFile}. Destroying read stream.`);
readStream.destroy();
}
});
}
// 处理响应错误
const handleResponseError = (res, tempCacheContentFile, realUrl) => {
viewsInfo.increment('fetchApiError');
console.error(`Error fetching from real URL: ${realUrl}`);
sendErrorResponse(res, HTTP_STATUS.BAD_GATEWAY, `Bad Gateway: Failed to fetch from ${realUrl}`);
if (fs.existsSync(tempCacheContentFile)) {
try {
fs.unlinkSync(tempCacheContentFile);
} catch (unlinkErr) {
console.error(`Error unlinking temp file ${tempCacheContentFile}:`, unlinkErr);
}
}
};
// 处理缓存读取错误
const handleCacheReadError = (res, filePath) => {
viewsInfo.increment('cacheReadError');
console.error(`Error reading cache file: ${filePath}`);
sendErrorResponse(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Internal Server Error: Unable to read cache content file');
};
// 启动服务器
server.listen(port, () => {
console.log(`Proxy server is running on http://localhost:${port}`);
});
// 处理 SIGINT 信号Ctrl+C
process.on('SIGINT', () => {
console.log('Received SIGINT. Shutting down gracefully...');
server.close(() => {
console.log('Server closed.');
process.exit(0);
});
setTimeout(() => {
console.error('Forcing shutdown...');
process.exit(1);
}, 10000);
});