import crypto from "node:crypto"; import fs from "node:fs"; import http from "node:http"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export function loadDeploymentContract( contractPath = path.join(repoRoot, "docs/web/deployment-contract.json"), ) { return JSON.parse(fs.readFileSync(contractPath, "utf8")); } function cacheControl(pathname, routes) { for (const route of routes) { for (const pattern of route.patterns) { if (pattern === "*" || pattern === pathname || (pattern.endsWith("*") && pathname.startsWith(pattern.slice(0, -1)))) { return route.cacheControl; } } } return "no-cache"; } function resolvedFile(root, pathname) { let decoded; try { decoded = decodeURIComponent(pathname); } catch { return null; } if (decoded.includes("\0")) return null; const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, ""); const filePath = path.resolve(root, relative); const rootPrefix = `${path.resolve(root)}${path.sep}`; if (!filePath.startsWith(rootPrefix)) return null; return filePath; } function strongEtag(filePath) { const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); return `"sha256-${hash}"`; } function matchesIfNoneMatch(value, etag) { if (typeof value !== "string") return false; return value === "*" || value.split(",").some((candidate) => candidate.trim() === etag); } function parseRange(value, size) { if (typeof value !== "string" || !value.startsWith("bytes=") || value.includes(",")) return null; const match = value.match(/^bytes=(\d*)-(\d*)$/); if (!match || (!match[1] && !match[2])) return null; if (!match[1]) { const suffixLength = Number(match[2]); if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0 || suffixLength > size) return null; return { start: size - suffixLength, end: size - 1 }; } const start = Number(match[1]); const end = match[2] ? Number(match[2]) : size - 1; if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= size || end < start || end >= size) return null; return { start, end }; } function responseHeaders(contract, pathname, filePath, stat) { const extension = path.extname(filePath).toLowerCase(); const mime = contract.mimeTypes[extension]; if (!mime) return null; return { ...contract.responseHeaders.allResponses, "Cache-Control": cacheControl(pathname, contract.responseHeaders.routes), "Content-Type": mime, "Content-Length": String(stat.size), }; } export function createDeploymentHttpServer({ root, contract = loadDeploymentContract() }) { const staticRoot = path.resolve(root); return http.createServer((request, response) => { const requestUrl = new URL(request.url ?? "/", "http://deployment.local"); const pathname = requestUrl.pathname; const commonHeaders = { ...contract.responseHeaders.allResponses, "Cache-Control": "no-cache" }; if (!contract.methods.includes(request.method)) { response.writeHead(405, { ...commonHeaders, Allow: contract.methods.join(", "), "Content-Length": "0" }); response.end(); return; } const filePath = resolvedFile(staticRoot, pathname); if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { response.writeHead(404, { ...commonHeaders, "Content-Length": "0" }); response.end(); return; } const stat = fs.statSync(filePath); const headers = responseHeaders(contract, pathname, filePath, stat); if (!headers) { response.writeHead(415, { ...commonHeaders, "Content-Length": "0" }); response.end(); return; } const extension = path.extname(filePath).toLowerCase(); const rangeEnabled = contract.rangeRequests.extensions.includes(extension); const etag = strongEtag(filePath); headers.ETag = etag; if (rangeEnabled) headers["Accept-Ranges"] = contract.rangeRequests.unit; if (matchesIfNoneMatch(request.headers["if-none-match"], etag)) { response.writeHead(304, { ...headers, "Content-Length": "0" }); response.end(); return; } const requestedRange = request.headers.range; const ifRangeMatches = !request.headers["if-range"] || request.headers["if-range"] === etag; if (requestedRange && rangeEnabled && ifRangeMatches) { const range = parseRange(requestedRange, stat.size); if (!range) { response.writeHead(contract.rangeRequests.unsatisfiedStatus, { ...headers, "Content-Range": `bytes */${stat.size}`, "Content-Length": "0", }); response.end(); return; } const length = range.end - range.start + 1; response.writeHead(contract.rangeRequests.satisfiedStatus, { ...headers, "Content-Range": `bytes ${range.start}-${range.end}/${stat.size}`, "Content-Length": String(length), }); if (request.method === "HEAD") response.end(); else fs.createReadStream(filePath, { start: range.start, end: range.end }).pipe(response); return; } response.writeHead(200, headers); if (request.method === "HEAD") response.end(); else fs.createReadStream(filePath).pipe(response); }); } export async function listenDeploymentHttpServer(server, { host = "127.0.0.1", port = 0 } = {}) { await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, host, () => { server.off("error", reject); resolve(); }); }); const address = server.address(); if (!address || typeof address === "string") throw new Error("deployment server did not bind a TCP port"); return `http://${host}:${address.port}`; } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const root = path.resolve(process.argv[2] ?? path.join(repoRoot, "web/dist")); const port = Number.parseInt(process.env.BLENDER_WEB_HTTP_PORT ?? "8080", 10); const server = createDeploymentHttpServer({ root }); const origin = await listenDeploymentHttpServer(server, { port }); process.stdout.write(`deployment-http-server ${origin} root=${root}\n`); }