const http = require('http'); const fs = require('fs'); const path = require('path'); 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 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} */ const memoryCache = new Map(); /** @type {Map>} */ 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(`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;