import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import path from 'node:path'; const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', }; /** * Resolve a URL pathname to a file inside `root`, or null if it escapes it. * Exported for tests. */ export function resolveSafe(root, pathname) { let decoded; try { decoded = decodeURIComponent(pathname); } catch { return null; } if (decoded.includes('\0')) return null; const target = path.resolve(root, '.' + path.posix.normalize('/' + decoded)); return target === root || target.startsWith(root + path.sep) ? target : null; } /** Serve files from `root`. Revalidates with ETag so edits show up on refresh. */ export function createStaticHandler(root) { const base = path.resolve(root); return async function serve(req, res, pathname, headers) { let file = resolveSafe(base, pathname === '/' ? '/index.html' : pathname); if (!file) return send(res, 400, headers, 'Bad request'); let info; try { info = await stat(file); if (info.isDirectory()) { file = path.join(file, 'index.html'); info = await stat(file); } } catch { return send(res, 404, headers, 'Not found'); } const etag = `W/"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`; const type = MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream'; const out = { ...headers, 'Content-Type': type, 'Cache-Control': 'no-cache', ETag: etag }; if (req.headers['if-none-match'] === etag) { res.writeHead(304, out); return res.end(); } res.writeHead(200, { ...out, 'Content-Length': info.size }); if (req.method === 'HEAD') return res.end(); createReadStream(file).on('error', () => res.destroy()).pipe(res); }; } function send(res, status, headers, text) { res.writeHead(status, { ...headers, 'Content-Type': 'text/plain; charset=utf-8' }); res.end(text); }