1// Serves repo sites, each on its own hostname (see hosts.ts), behind Caddy.2// Static files the way Cloudflare Pages serves them, so a site built for3// there works here unchanged:4//5// - _headers and _redirects at the site root are honored (and not served)6// - /about serves about.html or about/index.html; directories get a slash7// - a missing page gets the nearest 404.html with status 404, or, when the8// site has no 404.html at all, index.html (a single-page app)9//10// Old path URLs (/sites/<owner>/<repo>/<site>/...) redirect to the site's11// hostname once it has one; deploys from before hostnames keep their paths.12import http from 'node:http';13import fs from 'node:fs';14import path from 'node:path';15import { fileURLToPath } from 'node:url';16import { Hosts, hostsConfig, parseTarget } from './hosts.ts';17import type { HostsConfig } from './hosts.ts';18import { timeouts } from './limits.ts';1920const TYPES: Record<string, string> = {21 html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8', css: 'text/css; charset=utf-8',22 js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8', cjs: 'text/javascript; charset=utf-8',23 json: 'application/json; charset=utf-8', map: 'application/json; charset=utf-8', webmanifest: 'application/manifest+json; charset=utf-8',24 txt: 'text/plain; charset=utf-8', md: 'text/markdown; charset=utf-8', xml: 'application/xml; charset=utf-8', rss: 'application/rss+xml; charset=utf-8',25 atom: 'application/atom+xml; charset=utf-8', csv: 'text/csv; charset=utf-8', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',26 gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon', bmp: 'image/bmp', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf',27 otf: 'font/otf', wasm: 'application/wasm', pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip', mp4: 'video/mp4', webm: 'video/webm',28 mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', vtt: 'text/vtt; charset=utf-8',29};3031interface Rule {32 re: RegExp;33 absolute: boolean;34}35interface HeaderRule extends Rule {36 set: [string, string][];37 unset: string[];38}39interface RedirectRule extends Rule {40 to: string;41 status: number;42}43interface Site {44 root: string;45 headers: HeaderRule[];46 redirects: RedirectRule[];47 has404: boolean;48}4950// "/blog/:slug/*" -> a regex with named groups; one splat, any placeholders.51function compile(pattern: string): Rule | null {52 const absolute = /^https?:\/\//.test(pattern);53 let src = '';54 let splat = false;55 for (const part of pattern.replace(/^https?:\/\//, '').split(/(\*|:[A-Za-z]\w*)/)) {56 if (part === '*') {57 src += splat ? '.*' : '(?<splat>.*)';58 splat = true;59 } else if (/^:[A-Za-z]\w*$/.test(part)) src += `(?<${part.slice(1)}>[^/]+)`;60 else src += part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');61 }62 try {63 return { re: new RegExp(`^${src}$`), absolute };64 } catch {65 return null;66 }67}6869const fill = (s: string, groups: Record<string, string | undefined>): string =>70 s.replace(/:([A-Za-z]\w*)/g, (m, k: string) => (k in groups ? groups[k] ?? '' : m));7172function match(rule: Rule, host: string, pathname: string): Record<string, string | undefined> | null {73 const m = rule.re.exec(rule.absolute ? host + pathname : pathname);74 return m ? { ...m.groups } : null;75}7677function readRules(file: string): string[] {78 try {79 return fs.readFileSync(file, 'utf8').split(/\r?\n/);80 } catch {81 return [];82 }83}8485function parseHeaders(lines: string[]): HeaderRule[] {86 const rules: HeaderRule[] = [];87 let cur: HeaderRule | null = null;88 for (const line of lines) {89 if (!line.trim() || line.trim().startsWith('#')) continue;90 if (!/^\s/.test(line)) {91 const r = compile(line.trim());92 cur = r ? { ...r, set: [], unset: [] } : null;93 if (cur) rules.push(cur);94 } else if (cur) {95 const t = line.trim();96 if (t.startsWith('!')) cur.unset.push(t.slice(1).trim().toLowerCase());97 else {98 const i = t.indexOf(':');99 if (i > 0) cur.set.push([t.slice(0, i).trim(), t.slice(i + 1).trim()]);100 }101 }102 }103 return rules;104}105106function parseRedirects(lines: string[]): RedirectRule[] {107 const rules: RedirectRule[] = [];108 for (const line of lines) {109 const [from, to, code] = line.trim().split(/\s+/);110 if (!from || from.startsWith('#') || !to) continue;111 const status = code ? Number(code.replace(/!$/, '')) : 302;112 if (![200, 301, 302, 303, 307, 308, 404].includes(status)) continue;113 const r = compile(from);114 if (r) rules.push({ ...r, to, status });115 }116 return rules;117}118119const isFile = (p: string): boolean => {120 try {121 return fs.statSync(p).isFile();122 } catch {123 return false;124 }125};126127export function servePages(cfg: HostsConfig, port: number): http.Server {128 const hosts = new Hosts(cfg);129 let hostsSeen = 0;130 const sites = new Map<string, Site>();131132 // The registry and each site's rules are reread only when they change.133 const refreshHosts = (): void => {134 let m = 0;135 try {136 m = fs.statSync(hosts.dir).mtimeMs;137 } catch {}138 if (m !== hostsSeen) {139 hostsSeen = m;140 hosts.load();141 }142 };143 const site = (dir: string): Site | null => {144 let root: string;145 try {146 root = fs.realpathSync(dir);147 } catch {148 return null;149 }150 let s = sites.get(root);151 if (!s) {152 s = {153 root,154 headers: parseHeaders(readRules(path.join(root, '_headers'))),155 redirects: parseRedirects(readRules(path.join(root, '_redirects'))),156 has404: isFile(path.join(root, '404.html')),157 };158 // Deploys are immutable (content-addressed), so entries only go stale159 // by being replaced; keep the map from growing without bound.160 if (sites.size > 200) sites.clear();161 sites.set(root, s);162 }163 return s;164 };165166 // A path inside the site, or null if it escapes it (e.g. via a symlink).167 const inside = (s: Site, rel: string): string | null => {168 const p = path.join(s.root, rel);169 try {170 const real = fs.realpathSync(p);171 return real === s.root || real.startsWith(s.root + path.sep) ? real : null;172 } catch {173 return null;174 }175 };176177 type Found = { file: string; status: number } | { redirect: string };178 function resolve(s: Site, pathname: string): Found | null {179 if (/^\/_(headers|redirects)$/.test(pathname)) return null;180 if (pathname.endsWith('/')) {181 const index = inside(s, pathname + 'index.html');182 if (index && isFile(index)) return { file: index, status: 200 };183 const html = pathname.length > 1 ? inside(s, pathname.slice(0, -1) + '.html') : null;184 if (html && isFile(html)) return { redirect: pathname.slice(0, -1) };185 return null;186 }187 const p = inside(s, pathname);188 if (p && isFile(p)) return { file: p, status: 200 };189 const index = inside(s, pathname + '/index.html');190 if (index && isFile(index)) return { redirect: pathname + '/' };191 const html = inside(s, pathname + '.html');192 if (html && isFile(html)) return { file: html, status: 200 };193 return null;194 }195196 function notFound(s: Site, pathname: string): { file: string; status: number } | null {197 if (!s.has404) {198 const index = inside(s, '/index.html');199 return index && isFile(index) ? { file: index, status: 200 } : null;200 }201 for (let dir = path.posix.dirname(pathname.endsWith('/') ? pathname + 'x' : pathname); ; dir = path.posix.dirname(dir)) {202 const p = inside(s, path.posix.join(dir, '404.html'));203 if (p && isFile(p)) return { file: p, status: 404 };204 if (dir === '/') return null;205 }206 }207208 function send(req: http.IncomingMessage, res: http.ServerResponse, s: Site, host: string, pathname: string, file: string, status: number, preview: boolean): void {209 const st = fs.statSync(file);210 const headers: Record<string, string> = {211 'Content-Type': TYPES[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream',212 'Cache-Control': 'public, max-age=0, must-revalidate',213 'X-Content-Type-Options': 'nosniff',214 'Referrer-Policy': 'strict-origin-when-cross-origin',215 'Access-Control-Allow-Origin': '*',216 'Accept-Ranges': 'bytes',217 ETag: `"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`,218 };219 if (preview) headers['X-Robots-Tag'] = 'noindex';220 // The site's _headers replace these defaults; a header set by more than221 // one matching rule gets the values joined.222 const named = (k: string): string | undefined => Object.keys(headers).find((h) => h.toLowerCase() === k.toLowerCase());223 const custom = new Set<string>();224 for (const rule of s.headers) {225 const g = match(rule, host, pathname);226 if (!g) continue;227 for (const u of rule.unset) for (let k = named(u); k; k = named(u)) delete headers[k];228 for (const [k, v] of rule.set) {229 const have = named(k);230 if (have && custom.has(k.toLowerCase())) headers[have] += `, ${fill(v, g)}`;231 else {232 if (have) delete headers[have];233 headers[k] = fill(v, g);234 custom.add(k.toLowerCase());235 }236 }237 }238 if (status === 200 && req.headers['if-none-match'] === headers.ETag) {239 res.writeHead(304, headers).end();240 return;241 }242 let start = 0;243 let end = st.size - 1;244 const range = status === 200 ? /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? '') : null;245 if (range && (range[1] || range[2])) {246 if (range[1]) {247 start = Number(range[1]);248 if (range[2]) end = Math.min(Number(range[2]), end);249 } else start = Math.max(0, st.size - Number(range[2]));250 if (start > end) {251 res.writeHead(416, { 'Content-Range': `bytes */${st.size}` }).end();252 return;253 }254 status = 206;255 headers['Content-Range'] = `bytes ${start}-${end}/${st.size}`;256 }257 headers['Content-Length'] = String(st.size ? end - start + 1 : 0);258 res.writeHead(status, headers);259 if (req.method === 'HEAD' || !st.size) res.end();260 else fs.createReadStream(file, { start, end }).pipe(res);261 }262263 // prefix: the old path a deploy without a hostname is served under.264 function serveSite(req: http.IncomingMessage, res: http.ServerResponse, s: Site, host: string, pathname: string, search: string, preview: boolean, prefix = ''): void {265 for (const rule of s.redirects) {266 const g = match(rule, host, pathname) ?? (pathname.length > 1 ? match(rule, host, pathname.endsWith('/') ? pathname.slice(0, -1) : pathname + '/') : null);267 if (!g) continue;268 const to = fill(rule.to, g);269 if (rule.status === 200 || rule.status === 404) {270 // Rewrites only fill in for paths that have no file of their own.271 if (resolve(s, pathname)) break;272 if (!to.startsWith('/')) continue;273 const hit = resolve(s, path.posix.normalize(to.replace(/[?#].*$/, '')));274 if (hit && 'file' in hit) return send(req, res, s, host, pathname, hit.file, rule.status, preview);275 continue;276 }277 const loc = to.includes('?') || !search ? to : to + search;278 res.writeHead(rule.status, { Location: loc }).end();279 return;280 }281 const hit = resolve(s, pathname) ?? notFound(s, pathname);282 if (!hit) {283 res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Not found\n');284 return;285 }286 if ('redirect' in hit) {287 res.writeHead(308, { Location: prefix + hit.redirect + search }).end();288 return;289 }290 send(req, res, s, host, pathname, hit.file, hit.status, preview);291 }292293 const suffix = cfg.pagesDomain ? `.${cfg.pagesDomain}` : null;294 return timeouts(http.createServer((req, res) => {295 try {296 if (req.method !== 'GET' && req.method !== 'HEAD') {297 res.writeHead(405, { Allow: 'GET, HEAD' }).end();298 return;299 }300 const host = (req.headers.host ?? '').toLowerCase().replace(/:\d+$/, '');301 const raw = req.url ?? '/';302 const q = raw.indexOf('?');303 const search = q < 0 ? '' : raw.slice(q);304 let pathname: string;305 try {306 pathname = decodeURIComponent(q < 0 ? raw : raw.slice(0, q));307 } catch {308 res.writeHead(400).end();309 return;310 }311 if (!pathname.startsWith('/') || pathname.includes('\0')) {312 res.writeHead(400).end();313 return;314 }315 pathname = path.posix.normalize(pathname);316 refreshHosts();317318 // A site's own hostname.319 if (suffix && host.endsWith(suffix)) {320 const label = host.slice(0, -suffix.length);321 const t = hosts.targetOf(label);322 const s = t ? site(path.join(cfg.www, t)) : null;323 if (!t || !s) {324 res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end(`No site at ${host}\n`);325 return;326 }327 serveSite(req, res, s, host, pathname, search, t.startsWith('previews/'));328 return;329 }330331 // Old path URLs: the hostname if the deploy has one, else the path.332 const m = /^\/(sites\/[^/]+\/[^/]+\/[^/]+|previews\/[^/]+\/[^/]+\/[^/]+\/[^/]+)(\/.*)?$/.exec(pathname);333 if (m && parseTarget(m[1])) {334 const t = m[1];335 const label = hosts.labelOf(t);336 if (label && suffix) {337 res.writeHead(301, { Location: `${cfg.scheme}://${label}${suffix}${encodeURI(m[2] ?? '/')}${search}` }).end();338 return;339 }340 const s = site(path.join(cfg.www, t));341 if (s) {342 if (!m[2]) res.writeHead(308, { Location: `/${t}/${search}` }).end();343 else serveSite(req, res, s, host, m[2], search, t.startsWith('previews/'), `/${t}`);344 return;345 }346 }347 res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Not found\n');348 } catch (e) {349 console.error(e);350 if (!res.headersSent) res.writeHead(500).end();351 else res.destroy();352 }353 })).listen(port, '127.0.0.1', () => console.log(`pages on 127.0.0.1:${port} for *${suffix ?? ' (paths only)'}, from ${cfg.www}`));354}355356// Run directly: node lib/pages-server.ts357if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {358 servePages(hostsConfig(), Number(process.env.FORGE_PAGES_PORT || 8092));359}