Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
184 lines
8.4 KiB
JavaScript
184 lines
8.4 KiB
JavaScript
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";
|
|
import { handleServerOperatorJobRequest, serverOperatorJobOptions } from "./server-operator-job-server.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const authenticatedDeploymentServers = new WeakSet();
|
|
|
|
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(), serverOperator = serverOperatorJobOptions(), accessUsername = process.env.WEB_BLENDER_USERNAME || "owner", accessPassword = process.env.WEB_BLENDER_PASSWORD || "" }) {
|
|
const staticRoot = path.resolve(root);
|
|
const expectedAuthorization = accessPassword ? crypto.createHash("sha256").update(`Basic ${Buffer.from(`${accessUsername}:${accessPassword}`).toString("base64")}`).digest() : null;
|
|
const server = 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 (expectedAuthorization && !crypto.timingSafeEqual(expectedAuthorization, crypto.createHash("sha256").update(request.headers.authorization ?? "").digest())) { response.writeHead(401, { ...commonHeaders, "WWW-Authenticate": 'Basic realm="Web Blender", charset="UTF-8"', "Cache-Control": "no-store", "Content-Length": "0" }); response.end(); return; }
|
|
if (expectedAuthorization && !["GET", "HEAD"].includes(request.method) && request.headers.origin) { let allowedOrigin = false; try { const origin = new URL(request.headers.origin); allowedOrigin = ["http:", "https:"].includes(origin.protocol) && origin.host === request.headers.host; } catch {} if (!allowedOrigin) { response.writeHead(403, { ...commonHeaders, "Cache-Control": "no-store", "Content-Length": "0" }); response.end(); return; } }
|
|
if (["/api/server-operator-job", "/api/blender-workflow", "/api/blender-session", "/api/blender-resources"].includes(pathname)) {
|
|
for (const [name, value] of Object.entries(commonHeaders)) response.setHeader(name, value);
|
|
response.setHeader("Cache-Control", "no-store");
|
|
void handleServerOperatorJobRequest(request, response, { ...serverOperator, path: pathname }).catch(() => {
|
|
if (!response.headersSent) response.writeHead(500, { "Content-Length": "0" });
|
|
response.end();
|
|
});
|
|
return;
|
|
}
|
|
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();
|
|
if (expectedAuthorization) { headers["Cache-Control"] = "private, no-store"; headers.Vary = "Authorization"; }
|
|
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);
|
|
});
|
|
if (expectedAuthorization) authenticatedDeploymentServers.add(server); return server;
|
|
}
|
|
|
|
export async function listenDeploymentHttpServer(server, { host = "127.0.0.1", port = 0 } = {}) {
|
|
if (!["127.0.0.1", "::1"].includes(host) && !authenticatedDeploymentServers.has(server)) throw new Error("Private deployment requires WEB_BLENDER_PASSWORD before non-loopback binding");
|
|
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`);
|
|
}
|