This commit is contained in:
2026-07-11 11:14:12 +08:00
parent ee631cdc54
commit 74c6634186
18 changed files with 594 additions and 300 deletions
+168 -28
View File
@@ -2,37 +2,177 @@ const http = require('http');
const fs = require('fs');
const path = require('path');
const hostname = '127.0.0.1';
const port = 3000;
const hostname = process.env.IMAGE_HOST || '127.0.0.1';
const port = Number.parseInt(process.env.IMAGE_PORT || '3000', 10);
const directoryPath = path.resolve(__dirname, '../images');
const maxCacheBytes = Number.parseInt(process.env.IMAGE_CACHE_MAX_MB || '128', 10) * 1024 * 1024;
const maxCacheItemBytes =
Number.parseInt(process.env.IMAGE_CACHE_ITEM_MAX_MB || '4', 10) * 1024 * 1024;
const browserCacheSeconds = Number.parseInt(
process.env.IMAGE_BROWSER_CACHE_SECONDS || '3600',
10,
);
const ImageServer = http.createServer((req, res) => {
// 图片存储的文件夹路径
const directoryPath = path.join(__dirname, '../images');
// 请求的图片文件名
const filePath = path.join(directoryPath, decodeURIComponent(req.url));
// 确保文件存在且是文件,不是目录
fs.access(filePath, fs.constants.F_OK | fs.constants.R_OK, (err) => {
if (err) {
res.writeHead(404, 'Not Found');
res.end('Oops. Image not found!');
} else {
// 读取图片文件并发送响应
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(500, 'Server Error');
res.end('Oops. There was a server error!');
} else {
res.writeHead(200, { 'Content-Type': 'image/jpeg' });
res.end(data);
}
});
}
});
const mimeTypes = {
'.avif': 'image/avif',
'.bmp': 'image/bmp',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
};
/** @type {Map<string, { data: Buffer, contentType: string, etag: string, modified: string }>} */
const memoryCache = new Map();
/** @type {Map<string, Promise<{ data: Buffer, contentType: string, etag: string, modified: string }>>} */
const pendingReads = new Map();
let cachedBytes = 0;
function removeCacheEntry(filePath) {
const entry = memoryCache.get(filePath);
if (!entry) return;
cachedBytes -= entry.data.byteLength;
memoryCache.delete(filePath);
}
function cacheEntry(filePath, entry) {
if (entry.data.byteLength > maxCacheItemBytes || maxCacheBytes <= 0) return;
removeCacheEntry(filePath);
while (cachedBytes + entry.data.byteLength > maxCacheBytes && memoryCache.size) {
const oldestPath = memoryCache.keys().next().value;
if (!oldestPath) break;
removeCacheEntry(oldestPath);
}
memoryCache.set(filePath, entry);
cachedBytes += entry.data.byteLength;
}
function getCachedEntry(filePath) {
const entry = memoryCache.get(filePath);
if (!entry) return undefined;
memoryCache.delete(filePath);
memoryCache.set(filePath, entry);
return entry;
}
async function readImage(filePath) {
const cached = getCachedEntry(filePath);
if (cached) return cached;
const pending = pendingReads.get(filePath);
if (pending) return pending;
const readPromise = (async () => {
const handle = await fs.promises.open(filePath, 'r');
try {
const stat = await handle.stat();
if (!stat.isFile()) {
const error = new Error('Not a file');
error.code = 'ENOENT';
throw error;
}
const data = await handle.readFile();
const entry = {
data,
contentType: mimeTypes[path.extname(filePath).toLowerCase()] || 'application/octet-stream',
etag: `W/"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`,
modified: stat.mtime.toUTCString(),
};
cacheEntry(filePath, entry);
return entry;
} finally {
await handle.close();
}
})();
pendingReads.set(filePath, readPromise);
try {
return await readPromise;
} finally {
pendingReads.delete(filePath);
}
}
function resolveImagePath(requestUrl) {
let pathname;
try {
pathname = decodeURIComponent(new URL(requestUrl, 'http://localhost').pathname);
} catch {
return undefined;
}
const relativePath = pathname.replace(/^[/\\]+/, '');
const filePath = path.resolve(directoryPath, relativePath);
const relativeToRoot = path.relative(directoryPath, filePath);
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) return undefined;
return filePath;
}
function sendText(res, statusCode, message) {
res.writeHead(statusCode, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(message),
});
res.end(message);
}
const ImageServer = http.createServer(async (req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Allow', 'GET, HEAD');
sendText(res, 405, 'Method not allowed');
return;
}
const filePath = resolveImagePath(req.url || '/');
if (!filePath) {
sendText(res, 400, 'Invalid image path');
return;
}
try {
const entry = await readImage(filePath);
const headers = {
'Content-Type': entry.contentType,
'Content-Length': entry.data.byteLength,
'Cache-Control': `public, max-age=${browserCacheSeconds}`,
ETag: entry.etag,
'Last-Modified': entry.modified,
'X-Content-Type-Options': 'nosniff',
};
if (req.headers['if-none-match'] === entry.etag) {
res.writeHead(304, headers);
res.end();
return;
}
res.writeHead(200, headers);
res.end(req.method === 'HEAD' ? undefined : entry.data);
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'EISDIR')) {
sendText(res, 404, 'Image not found');
return;
}
console.error(`Failed to serve ${filePath}:`, error);
sendText(res, 500, 'Image server error');
}
});
ImageServer.keepAliveTimeout = 65_000;
ImageServer.headersTimeout = 66_000;
ImageServer.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
console.log('Press CTRL+C to stop the server');
console.log(`Image server running at http://${hostname}:${port}/`);
console.log(
`Memory cache: ${Math.round(maxCacheBytes / 1024 / 1024)} MB, browser cache: ${browserCacheSeconds}s`,
);
console.log('Press CTRL+C to stop the server');
});
exports.ImageServer = ImageServer;
exports.ImageServer = ImageServer;