clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / static.ts
static.ts 4.7K
1// Static files the way Caddy's file_server sends them, for the servers that2// serve built pages themselves (lib/private-server.ts, demo/serve.ts): the3// content type by extension, ETag and Last-Modified with 304s, and one4// byte range (206), which code search reads code.db with, unless If-Range5// names another version of the file. Cache-Control is the caller's.6import fs from 'node:fs';7import path from 'node:path';8import type http from 'node:http';910export const TYPES: Record<string, string> = {11 '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml',12 '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon',13 '.woff2': 'font/woff2',14 '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.json': 'application/json', '.wasm': 'application/wasm', '.db': 'application/vnd.sqlite3', '.parquet': 'application/vnd.apache.parquet',15};1617// Hashed assets never change; pages and the indexes they fetch revalidate.18export const HASHED = /^\/assets\/([^/]+\.)?[0-9A-Za-z_-]{10}\.[a-z]+$/;1920// Release downloads are saved, not opened, on the forge's origin: they are21// anyone's files, so they never render as a page of it (Caddy sends the same).22export const DOWNLOAD = /^\/[^/]+\/[^/]+\/releases\/download\/./;23export const DOWNLOAD_HEADERS = { 'Content-Disposition': 'attachment', 'X-Content-Type-Options': 'nosniff' };2425// One byte range, as code search reads code.db: [start, end] inclusive, or26// null for a malformed or unsatisfiable one. Several ranges get the whole file.27export function byteRange(h: string, size: number): [number, number] | null | undefined {28 const m = /^bytes=(\d*)-(\d*)$/.exec(h.trim());29 if (!m || (!m[1] && !m[2])) return h.includes(',') ? undefined : null;30 const [start, end] = m[1] ? [Number(m[1]), m[2] ? Math.min(Number(m[2]), size - 1) : size - 1] : [Math.max(0, size - Number(m[2])), size - 1];31 return start <= end && start < size ? [start, end] : null;32}3334// The file a URL path names under root: itself, or a directory's35// index.html. "redirect" for a directory asked for without its slash, null36// for nothing (or anything that leaves root, through .. or a symlink).37export function resolve(root: string, pathname: string): string | 'redirect' | null {38 let rel: string;39 try {40 rel = decodeURIComponent(pathname);41 } catch {42 return null;43 }44 if (rel.includes('\0') || rel.split('/').includes('..')) return null;45 let real: string;46 let top: string;47 try {48 top = fs.realpathSync(root);49 real = fs.realpathSync(path.join(root, rel));50 } catch {51 return null;52 }53 if (real !== top && !real.startsWith(top + path.sep)) return null;54 const st = fs.statSync(real);55 if (st.isFile()) return rel.endsWith('/') ? null : real;56 if (!st.isDirectory()) return null;57 const index = path.join(real, 'index.html');58 if (!fs.existsSync(index)) return null;59 return rel.endsWith('/') ? index : 'redirect';60}6162export function sendFile(req: http.IncomingMessage, res: http.ServerResponse, file: string, extra: http.OutgoingHttpHeaders = {}): void {63 const st = fs.statSync(file);64 const etag = `"${Math.floor(st.mtimeMs).toString(36)}-${st.size.toString(36)}"`;65 const headers: http.OutgoingHttpHeaders = {66 'Content-Type': TYPES[path.extname(file)] ?? 'application/octet-stream', ETag: etag, 'Last-Modified': st.mtime.toUTCString(), 'Accept-Ranges': 'bytes', ...extra,67 };68 const since = req.headers['if-modified-since'];69 const match = req.headers['if-none-match'];70 if (match ? match === etag : since && Date.parse(since) >= Math.floor(st.mtimeMs / 1000) * 1000) {71 res.writeHead(304, headers).end();72 return;73 }74 // A range of the file, unless If-Range names another version of it.75 const ifRange = req.headers['if-range'];76 const r = req.headers.range && (!ifRange || ifRange === etag || ifRange === headers['Last-Modified']) ? byteRange(req.headers.range, st.size) : undefined;77 if (r === null) {78 res.writeHead(416, { ...headers, 'Content-Range': `bytes */${st.size}` }).end();79 return;80 }81 if (r) {82 const [start, end] = r;83 const buf = Buffer.alloc(end - start + 1);84 const fd = fs.openSync(file, 'r');85 try {86 fs.readSync(fd, buf, 0, buf.length, start);87 } finally {88 fs.closeSync(fd);89 }90 res.writeHead(206, { ...headers, 'Content-Range': `bytes ${start}-${end}/${st.size}`, 'Content-Length': buf.length });91 res.end(req.method === 'HEAD' ? undefined : buf);92 return;93 }94 res.writeHead(200, { ...headers, 'Content-Length': st.size });95 if (req.method === 'HEAD') res.end();96 else fs.createReadStream(file).pipe(res);97}