import { createServer } from 'node:http'; import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import { walk } from './walk.mjs'; export const dist = path.resolve('dist'); const INDEX_FILE = 'index.html'; export async function requireDist() { try { await stat(dist); } catch { throw new Error('dist/ does not exist. Run npm run build first.'); } } // Derives the site's route list from the HTML files in dist/. export async function discoverRoutes() { const files = await walk(dist); const routes = new Set(); for (const file of files) { if (!file.endsWith('.html')) continue; const rel = path.relative(dist, file).replaceAll(path.sep, '/'); if (rel === '404.html') continue; if (rel === INDEX_FILE) { routes.add('/'); } else if (rel.endsWith(`/${INDEX_FILE}`)) { routes.add('/' + rel.slice(0, -INDEX_FILE.length)); } else { routes.add('/' + rel.replace(/\.html$/, '/')); } } return [...routes].sort(); } // Maps a request URL (or pathname) to a file in dist/, mirroring static-host // semantics: the path itself, its directory index.html, or the extensionless // .html variant. Returns null when nothing matches. export async function resolveFile(url) { const parsed = new URL(url, 'http://localhost'); const safePath = path .normalize(decodeURIComponent(parsed.pathname)) .replace(/^\/+/, '') .replace(/^(\.\.(\/|\\|$))+/, ''); const candidate = path.join(dist, safePath); const candidates = [ candidate, path.join(candidate, 'index.html'), path.join(dist, `${safePath}.html`), ]; for (const file of candidates) { try { const fileStat = await stat(file); if (fileStat.isFile()) return file; } catch { // Try the next candidate. } } return null; } const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.mp4': 'video/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt; charset=utf-8', '.pdf': 'application/pdf', }; export function contentType(file) { const ext = path.extname(file).toLowerCase(); return MIME[ext] ?? 'application/octet-stream'; } // Serves dist/ on an ephemeral localhost port, falling back to 404.html. // Returns the server (callers own closing it) and the assigned port. export async function startDistServer() { const server = createServer(async (req, res) => { try { const file = (await resolveFile(req.url ?? '/')) ?? path.join(dist, '404.html'); const body = await readFile(file); res.writeHead(200, { 'content-type': contentType(file) }); res.end(body); } catch (error) { res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); res.end(String(error)); } }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); return { server, port: server.address().port }; }