import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import crypto from "node:crypto"; import http from "node:http"; import { fileURLToPath } from "node:url"; import { startBoundedServerJobProcess, cancelServerJobProcess, validateServerJobBudget } from "./server-job-process.mjs"; import { handleNativeWorkflowRequest } from "./native-workflow-server.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const RUNNER = path.join(ROOT, "tools/web/server-operator-job.py"); const MAGIC = Buffer.from("WBJ1"); const MAX_ENVELOPE = 513 * 1024 * 1024; const jobs = new Map(); const operatorSchemaCache = new Map(); function fail(message, status = 400) { const error = new Error(message); error.statusCode = status; error.errorCode = message; throw error; } function digest(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); } function executableFingerprint(binary) { try { const resolved = path.isAbsolute(binary) ? binary : (process.env.PATH ?? "").split(path.delimiter).map((dir) => path.join(dir, binary)).find((candidate) => { try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; } }) ?? binary; const stat = fs.statSync(resolved); return `${resolved}:${stat.size}:${stat.mtimeMs}`; } catch { return String(binary); } } function stableJSON(value) { if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`; return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`; } function validateBindings(request) { const allowed = new Set(["schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "operator", "properties", "propertiesSha256", "timeoutMs", "memoryBytes", "resultBinding", "context", "requestSha256"]); if (Object.keys(request).some((key) => !allowed.has(key))) fail("SERVER_OPERATOR_HTTP_REQUEST_INVALID"); if (request.schemaVersion !== 1 || request.route !== "SERVER_NATIVE" || typeof request.jobId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(request.jobId) || typeof request.operator !== "string" || !/^[A-Za-z_][A-Za-z0-9_.-]{0,127}$/u.test(request.operator) || typeof request.requestSha256 !== "string" || !request.properties || typeof request.properties !== "object" || Array.isArray(request.properties)) fail("SERVER_OPERATOR_HTTP_REQUEST_INVALID"); if (!request.blenderBuild || typeof request.blenderBuild !== "object" || !/^[a-f0-9]{64}$/iu.test(String(request.blenderBuild.buildSha256 ?? "")) || !request.resultBinding || typeof request.resultBinding !== "object" || !["json", "snapshot", "scene-delta"].includes(request.resultBinding.kind) || typeof request.resultBinding.key !== "string" || !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/u.test(request.resultBinding.key)) fail("SERVER_OPERATOR_HTTP_REQUEST_INVALID"); if (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs < 1000 || request.timeoutMs > 600000 || !Number.isSafeInteger(request.memoryBytes) || request.memoryBytes < 64 * 1024 * 1024 || request.memoryBytes > 4 * 1024 * 1024 * 1024 || Object.keys(request.properties).length > 128 || Object.keys(request.properties).some((key) => !/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/u.test(key))) fail("SERVER_OPERATOR_HTTP_REQUEST_INVALID"); const propertiesJSON = stableJSON(request.properties); if (Buffer.byteLength(propertiesJSON) > 64 * 1024 || Object.values(request.properties).some((value) => value === null || (value && typeof value === "object" && !Array.isArray(value)) || (typeof value === "string" && Buffer.byteLength(value) > 4096) || (Array.isArray(value) && (value.length > 64 || value.some((item) => item === null || (item && typeof item === "object")) || value.length > 0 && value.some((item) => typeof item !== typeof value[0]))))) fail("SERVER_OPERATOR_HTTP_PROPERTIES_INVALID"); const propertiesSha256 = digest(Buffer.from(propertiesJSON)); if (propertiesSha256 !== request.propertiesSha256) fail("SERVER_OPERATOR_HTTP_PROPERTIES_HASH_MISMATCH", 422); const { requestSha256, ...unsigned } = request; const canonical = stableJSON({ ...unsigned, context: unsigned.context ?? "BACKGROUND" }); if (digest(Buffer.from(canonical)) !== requestSha256) fail("SERVER_OPERATOR_HTTP_REQUEST_HASH_MISMATCH", 422); if (request.context !== undefined && request.context !== "BACKGROUND") fail("SERVER_OPERATOR_CONTEXT_BLOCKED", 422); if (/^(?:wm|window|screen|file|outliner|view3d)\./iu.test(String(request.operator ?? ""))) fail("SERVER_OPERATOR_CONTEXT_BLOCKED", 422); } function readEnvelope(buffer) { if (buffer.length < 16 || !buffer.subarray(0, 4).equals(MAGIC) || buffer.readUInt32LE(4) !== 1) fail("SERVER_OPERATOR_HTTP_INVALID_ENVELOPE"); const requestBytes = buffer.readUInt32LE(8); const sourceBytes = buffer.readUInt32LE(12); if (buffer.length > MAX_ENVELOPE || requestBytes < 1 || sourceBytes < 1 || 16 + requestBytes + sourceBytes !== buffer.length) fail("SERVER_OPERATOR_HTTP_INVALID_LENGTH"); const request = JSON.parse(buffer.subarray(16, 16 + requestBytes).toString("utf8")); if (!request || typeof request !== "object" || Array.isArray(request)) fail("SERVER_OPERATOR_HTTP_REQUEST_INVALID"); const source = buffer.subarray(16 + requestBytes); if (source.length !== request.sourceBlendByteLength || digest(source) !== request.sourceBlendSha256) fail("SERVER_OPERATOR_HTTP_SOURCE_HASH_MISMATCH", 422); validateBindings(request); return { request, source }; } function writeJson(response, status, value) { response.statusCode = status; response.setHeader("Content-Type", "application/json"); response.setHeader("Cache-Control", "no-store"); response.end(JSON.stringify(value)); } function collect(request, maxBytes) { return new Promise((resolve, reject) => { const chunks = []; let total = 0; request.on("data", (chunk) => { total += chunk.length; if (total > maxBytes) { reject(Object.assign(new Error("SERVER_OPERATOR_HTTP_BODY_TOO_LARGE"), { statusCode: 413 })); request.destroy(); return; } chunks.push(chunk); }); request.on("end", () => resolve(Buffer.concat(chunks))); request.on("error", reject); }); } function pythonPayload(request, sourcePath, workspaceRoot, blenderBinary) { return JSON.stringify({ action: "submit", request, sourceBlendPath: sourcePath, workspaceRoot, blenderBinary }); } async function describeServerOperator(operator, options, clientRequest) { const adapterHash = digest(Buffer.concat([fs.readFileSync(RUNNER), fs.readFileSync(path.join(ROOT, "tools/web/server_operator_properties.py")), fs.readFileSync(path.join(ROOT, "tools/web/server_operator_remesh.py"))])); const binary = options.blenderBinary ?? "blender"; const cachePrefix = `${binary}:${executableFingerprint(binary)}:${adapterHash}:${operator}`; const cached = [...operatorSchemaCache.entries()].find(([key]) => key.startsWith(`${cachePrefix}:`)); if (cached) return cached[1]; const workspace = fs.mkdtempSync(path.join(options.workspaceRoot ?? os.tmpdir(), "server-operator-describe-")); let abort = null; try { const handle = startBoundedServerJobProcess(options.pythonBinary ?? "python3", [RUNNER], { cwd: workspace, timeoutMs: options.describeTimeoutMs ?? 120000, stdio: ["pipe", "pipe", "pipe"], maxOutputBytes: 2 * 1024 * 1024 }); const chunks = []; let processError = null; handle.child.on("error", (error) => { processError = error; }); handle.child.stdin.on("error", (error) => { processError = error; }); abort = () => { void cancelServerJobProcess(handle, async () => {}, { graceMs: 250 }).catch(() => undefined); }; clientRequest?.once("aborted", abort); handle.child.stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk))); handle.child.stdin.end(JSON.stringify({ action: "describe", operator, blenderBinary: options.blenderBinary ?? "blender" })); await handle.completion; if (handle.receipt.cancelRequested) fail("SERVER_OPERATOR_SCHEMA_TIMEOUT", 504); if (processError) fail("SERVER_OPERATOR_SCHEMA_PROCESS_FAILED", 502); const output = Buffer.concat(chunks).toString("utf8").trim(); if (!output) fail("SERVER_OPERATOR_SCHEMA_EMPTY", 502); const result = JSON.parse(output); if (result.status === "FAILED") fail(result.errorMessage ?? "SERVER_OPERATOR_SCHEMA_FAILED", 422); if (handle.child.exitCode !== 0) fail("SERVER_OPERATOR_SCHEMA_PROCESS_FAILED", 502); const buildKey = result.blenderBuild?.buildSha256 ?? "unknown"; operatorSchemaCache.set(`${cachePrefix}:${buildKey}`, result); return result; } finally { if (abort) clientRequest?.off("aborted", abort); fs.rmSync(workspace, { recursive: true, force: true }); } } function jobStatus(entry, status, errorMessage) { const { jobId, sourceBlendSha256, sourceRevision, blenderBuild, propertiesSha256, requestSha256 } = entry.request; const errorCode = typeof errorMessage === "string" ? errorMessage.match(/^([A-Z][A-Z0-9_.-]*)(?::|$)/u)?.[1] : undefined; return { schemaVersion: 1, route: "SERVER_NATIVE", jobId, status, sourceBlendSha256, sourceRevision, blenderBuild, propertiesSha256, requestSha256, resultSha256: digest(Buffer.from(status)), ...(errorMessage ? { errorMessage, ...(errorCode ? { errorCode } : {}) } : {}) }; } function cleanupJob(entry) { if (entry.result && !entry.retentionTimer) { // Cover the client timeout + 2s deadline and its 2s cancellation request. entry.retentionTimer = setTimeout(() => { if (jobs.get(entry.request.jobId) === entry) jobs.delete(entry.request.jobId); }, entry.request.timeoutMs + 4_000); entry.retentionTimer.unref(); } if (entry.workspace) { fs.rmSync(entry.workspace, { recursive: true, force: true }); entry.workspace = null; } } async function finishJob(entry) { try { await entry.handle.completion; // Cancellation publishes its terminal state before waiting for process teardown. if (entry.result) return; if (entry.handle.receipt.cancelRequested) throw new Error("SERVER_OPERATOR_TIMEOUT"); if (entry.processError || entry.handle.child.exitCode !== 0) throw new Error("SERVER_OPERATOR_PROCESS_FAILED"); const result = JSON.parse(fs.readFileSync(path.join(entry.workspace, "result.json"), "utf8")); for (const key of ["schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceRevision", "propertiesSha256", "requestSha256"]) { if (result[key] !== entry.request[key]) throw new Error("SERVER_OPERATOR_RESULT_BINDING_MISMATCH"); } if (typeof result.resultSha256 !== "string" || !/^[a-f0-9]{64}$/iu.test(result.resultSha256)) throw new Error("SERVER_OPERATOR_RESULT_HASH_INVALID"); if (stableJSON(result.blenderBuild) !== stableJSON(entry.request.blenderBuild)) throw new Error("SERVER_OPERATOR_RESULT_BUILD_MISMATCH"); if (result.status !== "SUCCEEDED" && result.status !== "FAILED") throw new Error("SERVER_OPERATOR_RESULT_STATUS_INVALID"); if (result.status === "SUCCEEDED") { const blendPath = path.join(entry.workspace, "result.blend"); const size = fs.statSync(blendPath).size; if (size < 1 || size > 512 * 1024 * 1024 || size !== result.resultBlendByteLength) throw new Error("SERVER_OPERATOR_RESULT_LENGTH_MISMATCH"); const bytes = fs.readFileSync(blendPath); if (digest(bytes) !== result.resultBlendSha256) throw new Error("SERVER_OPERATOR_RESULT_HASH_MISMATCH"); result.resultBlendBase64 = bytes.toString("base64"); } entry.result = result; } catch (error) { entry.result ??= jobStatus(entry, "FAILED", String(error.message ?? error)); } finally { cleanupJob(entry); } } export function serverOperatorJobOptions() { return { blenderBinary: process.env.BLENDER_BIN ?? "blender", pythonBinary: process.env.PYTHON_BIN ?? "python3", workspaceRoot: process.env.SERVER_OPERATOR_WORKSPACE_ROOT, }; } async function handle(request, response, options = serverOperatorJobOptions()) { if (await handleNativeWorkflowRequest(request, response, options)) return true; const pathname = (request.url ?? "/").split("?", 1)[0]; if (pathname !== (options.path ?? "/api/server-operator-job")) return false; const headerAction = request.headers["x-server-operator-action"]; const action = headerAction ?? ((new URL(request.url, "http://vite.local")).searchParams.has("jobId") ? "read" : "submit"); try { if (!["describe", "submit", "read", "cancel"].includes(action)) fail("SERVER_OPERATOR_HTTP_ACTION_INVALID"); if (request.method !== "POST" && !(request.method === "GET" && action === "read")) { response.setHeader("Allow", action === "read" ? "GET, POST" : "POST"); fail("SERVER_OPERATOR_HTTP_METHOD_INVALID", 405); } if (action === "describe") { const body = JSON.parse((await collect(request, 64 * 1024)).toString("utf8") || "{}"); if (!body || typeof body !== "object" || Array.isArray(body)) fail("SERVER_OPERATOR_HTTP_BODY_INVALID"); const operator = String(body.operator ?? "").trim(); if (!/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(operator) || Buffer.byteLength(operator) > 128) fail("SERVER_OPERATOR_SCHEMA_OPERATOR_INVALID"); const result = await describeServerOperator(operator, options, request); writeJson(response, 200, { status: { ready: true }, result }); return true; } if (action === "submit") { const { request: jobRequest, source } = readEnvelope(await collect(request, MAX_ENVELOPE)); if (jobs.has(jobRequest.jobId)) fail("SERVER_OPERATOR_HTTP_DUPLICATE_JOB", 409); if ([...jobs.values()].filter((entry) => !entry.result).length >= 2) fail("SERVER_OPERATOR_HTTP_CAPACITY", 429); validateServerJobBudget({ timeoutMs: jobRequest.timeoutMs }); const workspace = fs.mkdtempSync(path.join(options.workspaceRoot ?? os.tmpdir(), "server-operator-http-")); const sourcePath = path.join(workspace, "source.blend"); fs.writeFileSync(sourcePath, source, { mode: 0o600 }); const handle = startBoundedServerJobProcess(options.pythonBinary ?? "python3", [RUNNER], { cwd: workspace, timeoutMs: jobRequest.timeoutMs, stdio: ["pipe", "ignore", "pipe"], maxOutputBytes: 8 * 1024 * 1024 }); const entry = { request: jobRequest, workspace, handle, result: null }; jobs.set(jobRequest.jobId, entry); handle.child.on("error", (error) => { entry.processError = error; }); handle.child.stdin.on("error", (error) => { entry.processError = error; }); const abort = () => { if (!entry.result) { entry.result = jobStatus(entry, "CANCELLED"); void cancelServerJobProcess(entry.handle, async () => cleanupJob(entry), { graceMs: 500 }).catch(() => undefined); } }; request.once("aborted", abort); handle.child.stdin.write(pythonPayload(jobRequest, sourcePath, workspace, options.blenderBinary ?? "blender")); handle.child.stdin.end(); entry.completion = finishJob(entry).catch((error) => { entry.result = jobStatus(entry, "FAILED", String(error.message ?? error)); }).finally(() => request.off("aborted", abort)); writeJson(response, 202, { status: { ready: true }, result: jobStatus(entry, "RUNNING") }); return true; } const body = request.method === "GET" ? {} : JSON.parse((await collect(request, 64 * 1024)).toString("utf8") || "{}"); if (!body || typeof body !== "object" || Array.isArray(body)) fail("SERVER_OPERATOR_HTTP_BODY_INVALID"); const url = new URL(request.url, "http://vite.local"); const jobId = String(body.jobId ?? url.searchParams.get("jobId") ?? ""); const requestSha256 = String(body.requestSha256 ?? url.searchParams.get("requestSha256") ?? ""); const entry = jobs.get(jobId); if (!entry || entry.request.requestSha256 !== requestSha256) fail("SERVER_OPERATOR_HTTP_JOB_NOT_FOUND", 404); if (action === "cancel") { if (!entry.result) { entry.result = jobStatus(entry, "CANCELLED"); try { await cancelServerJobProcess(entry.handle, async () => cleanupJob(entry), { graceMs: 500 }); } catch (error) { cleanupJob(entry); entry.result.errorMessage = `SERVER_OPERATOR_CANCEL_CLEANUP_FAILED: ${String(error.message ?? error)}`; } } writeJson(response, 200, { status: { ready: true }, result: entry.result }); return true; } const result = entry.result ?? jobStatus(entry, "RUNNING"); writeJson(response, 200, { status: { ready: true }, result }); return true; } catch (error) { if (error.statusCode === 429) response.setHeader("Retry-After", "1"); writeJson(response, error.statusCode ?? (error instanceof SyntaxError ? 400 : 500), { status: { ready: false }, result: { status: "FAILED", errorMessage: String(error.message ?? error), ...(typeof error.errorCode === "string" ? { errorCode: error.errorCode } : {}) } }); return true; } } export function serverOperatorJobProvider(options = serverOperatorJobOptions()) { return { name: "server-operator-http-provider", configureServer(server) { server.middlewares.use((request, response, next) => { void handle(request, response, options).then((handled) => { if (!handled) next(); }).catch(() => next()); }); }, configurePreviewServer(server) { server.middlewares.use((request, response, next) => { void handle(request, response, options).then((handled) => { if (!handled) next(); }).catch(() => next()); }); } }; } export { handle as handleServerOperatorJobRequest }; if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const port = Number.parseInt(process.env.SERVER_OPERATOR_PORT ?? "8787", 10); const host = process.env.SERVER_OPERATOR_HOST ?? "127.0.0.1"; if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("SERVER_OPERATOR_PORT_INVALID"); const options = serverOperatorJobOptions(); http.createServer((request, response) => { void handle(request, response, options).then((handled) => { if (!handled) writeJson(response, 404, { status: { ready: false }, result: { status: "FAILED", errorMessage: "NOT_FOUND", errorCode: "SERVER_OPERATOR_HTTP_NOT_FOUND" } }); }).catch((error) => writeJson(response, 500, { status: { ready: false }, result: { status: "FAILED", errorMessage: String(error?.message ?? error), ...(typeof error?.errorCode === "string" ? { errorCode: error.errorCode } : {}) } })); }).listen(port, host, () => process.stdout.write(`server-operator-provider-ready http://${host}:${port}/api/server-operator-job\n`)); }