// A static server for a built site (export's `start`, and the report-site e2e // in playwright.report.config.ts), serving a directory the way Cloudflare // Pages does: `/dir/` is `dir/index.html`, `/dir` redirects to `/dir/`, `/x` // is `x.html` when there is one. // // Not `serve`: serve-handler stats a path whose last segment looks like it has // an extension as a file, and lists the directory instead of serving its // index.html — and a span moment's directory is named `-` in // seconds with two decimals (`3126.00-3151.00`), so every video and audio // moment page came back as a directory listing. // // Byte ranges are honoured (a media element asks for them). // // The directory's `_headers` (compose writes it: common/lib/archive/headers.ts) // is applied as Pages applies it — a rule's path a literal, a trailing `*` // splat or `:name` placeholders, each of its headers set on what it matches, // a `Content-Type` over the extension's — so what a spec sees (CORS, a feed's // media type) is what a deployed site sends. Read once, at start. // // node scripts/serve-out.mjs // // Every interface, as `serve` did; `HOST=127.0.0.1` keeps a private site on this machine. import fs from "node:fs"; import http from "node:http"; import path from "node:path"; const [rootArg, portArg] = process.argv.slice(2); if (!rootArg || !portArg) { console.error("usage: serve-out.mjs "); process.exit(2); } const ROOT = path.resolve(rootArg); const PORT = Number(portArg); const TYPES = { ".html": "text/html; charset=utf-8", ".txt": "text/plain; charset=utf-8", ".json": "application/json; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".csv": "text/csv; charset=utf-8", ".xml": "application/xml; charset=utf-8", ".webmanifest": "application/manifest+json", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".woff": "font/woff", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".mp4": "video/mp4", ".webm": "video/webm", ".m4a": "audio/mp4", ".mp3": "audio/mpeg", ".vtt": "text/vtt; charset=utf-8", ".srt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8", ".zip": "application/zip", ".pdf": "application/pdf", ".wasm": "application/wasm", }; // `_headers` as [{ re, headers: [[name, value]] }], in file order. function readHeaderRules(root) { let text; try { text = fs.readFileSync(path.join(root, "_headers"), "utf8"); } catch { return []; } const rules = []; for (const line of text.split("\n")) { if (!line.trim() || line.trimStart().startsWith("#")) continue; if (/^\s/.test(line)) { const at = line.indexOf(":"); if (at > 0 && rules.length > 0) rules[rules.length - 1].headers.push([line.slice(0, at).trim(), line.slice(at + 1).trim()]); continue; } const pattern = line .trim() .replace(/[.+?^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/:[A-Za-z_]\w*/g, "[^/]+"); rules.push({ re: new RegExp(`^${pattern}$`), headers: [] }); } return rules; } const HEADER_RULES = readHeaderRules(ROOT); function ruleHeaders(pathname) { const out = {}; for (const rule of HEADER_RULES) { if (!rule.re.test(pathname)) continue; for (const [name, value] of rule.headers) { const key = Object.keys(out).find((k) => k.toLowerCase() === name.toLowerCase()) ?? name; out[key] = value; } } return out; } function statOrNull(p) { try { return fs.statSync(p); } catch { return null; } } function sendFile(req, res, file, status = 200, pathname) { const size = fs.statSync(file).size; const fromRules = status === 200 && pathname ? ruleHeaders(pathname) : {}; const type = Object.keys(fromRules).find((k) => k.toLowerCase() === "content-type"); const headers = { "Content-Type": TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream", "Accept-Ranges": "bytes", ...fromRules, }; if (type && type !== "Content-Type") { headers["Content-Type"] = fromRules[type]; delete headers[type]; } const range = status === 200 ? /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "") : null; if (range && (range[1] || range[2])) { let start = range[1] ? Number(range[1]) : size - Number(range[2]); let end = range[1] && range[2] ? Number(range[2]) : size - 1; start = Math.max(0, start); end = Math.min(end, size - 1); if (start > end) { res.writeHead(416, { "Content-Range": `bytes */${size}` }).end(); return; } res.writeHead(206, { ...headers, "Content-Range": `bytes ${start}-${end}/${size}`, "Content-Length": end - start + 1 }); if (req.method === "HEAD") return void res.end(); fs.createReadStream(file, { start, end }).pipe(res); return; } res.writeHead(status, { ...headers, "Content-Length": size }); if (req.method === "HEAD") return void res.end(); fs.createReadStream(file).pipe(res); } http .createServer((req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); let pathname; try { pathname = decodeURIComponent(url.pathname); } catch { return void res.writeHead(400).end(); } const file = path.join(ROOT, pathname); if (file !== ROOT && !file.startsWith(ROOT + path.sep)) return void res.writeHead(403).end(); const st = statOrNull(file); if (st?.isDirectory()) { if (!pathname.endsWith("/")) { return void res.writeHead(308, { Location: `${url.pathname}/${url.search}` }).end(); } const index = path.join(file, "index.html"); if (statOrNull(index)?.isFile()) return sendFile(req, res, index, 200, pathname); } else if (st?.isFile()) { return sendFile(req, res, file, 200, pathname); } else if (!pathname.endsWith("/") && statOrNull(`${file}.html`)?.isFile()) { return sendFile(req, res, `${file}.html`, 200, pathname); } const notFound = path.join(ROOT, "404.html"); if (statOrNull(notFound)?.isFile()) return sendFile(req, res, notFound, 404); res.writeHead(404).end(); }) .listen(PORT, process.env.HOST || undefined, () => console.log(`[serve-out] serving ${ROOT} on http://${process.env.HOST || "localhost"}:${PORT}`));