Files
workinf_Blender_Wasm/tools/web/server-operator-job-server.mjs
mes123456 e9bfef0c5a
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
feat(web): complete Blender route runtime execution
2026-09-07 00:12:32 +08:00

166 lines
11 KiB
JavaScript

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 } from "./server-job-process.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; throw error; }
function digest(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); }
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 propertiesSha256 = digest(Buffer.from(stableJSON(request.properties ?? {})));
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 (/^(?: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)) 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"));
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) {
const cachePrefix = `${options.blenderBinary ?? "blender"}:${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-"));
try {
const handle = startBoundedServerJobProcess(options.pythonBinary ?? "python3", [RUNNER], { cwd: workspace, timeoutMs: options.describeTimeoutMs ?? 120000, stdio: ["pipe", "pipe", "pipe"], maxOutputBytes: 2 * 1024 * 1024 });
const chunks = [];
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;
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);
const buildKey = result.blenderBuild?.buildSha256 ?? "unknown";
operatorSchemaCache.set(`${cachePrefix}:${buildKey}`, result);
return result;
}
finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
}
async function finishJob(entry) {
if (entry.result) return entry.result;
await entry.handle.completion;
const resultPath = path.join(entry.workspace, "result.json");
const result = fs.existsSync(resultPath) ? JSON.parse(fs.readFileSync(resultPath, "utf8")) : { schemaVersion: 1, route: "SERVER_NATIVE", jobId: entry.request.jobId, status: "FAILED", requestSha256: entry.request.requestSha256, resultSha256: digest(Buffer.from("")), errorMessage: "SERVER_OPERATOR_PROCESS_FAILED" };
if (result.status === "SUCCEEDED") {
const blendPath = path.join(entry.workspace, "result.blend");
if (!fs.existsSync(blendPath)) {
result.status = "FAILED";
result.errorMessage = "SERVER_OPERATOR_RESULT_BLEND_MISSING";
}
else result.resultBlendBase64 = fs.readFileSync(blendPath).toString("base64");
}
entry.result = result;
if (entry.workspace) {
fs.rmSync(entry.workspace, { recursive: true, force: true });
entry.workspace = null;
}
return result;
}
async function handle(request, response, options) {
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 (action === "describe") {
const body = JSON.parse((await collect(request, 64 * 1024)).toString("utf8") || "{}");
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);
writeJson(response, 200, { status: { ready: true }, result });
return true;
}
if (action === "submit") {
const { request: jobRequest, source } = readEnvelope(await collect(request, MAX_ENVELOPE));
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.stdin.write(pythonPayload(jobRequest, sourcePath, workspace, options.blenderBinary ?? "blender"));
handle.child.stdin.end();
const result = await finishJob(entry);
writeJson(response, 200, { status: { ready: true }, result });
return true;
}
const body = request.method === "GET" ? {} : JSON.parse((await collect(request, 64 * 1024)).toString("utf8") || "{}");
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) await cancelServerJobProcess(entry.handle, async () => {
if (entry.workspace) {
fs.rmSync(entry.workspace, { recursive: true, force: true });
entry.workspace = null;
}
}, { graceMs: 500 });
const result = entry.result ?? { schemaVersion: 1, route: "SERVER_NATIVE", jobId, status: "CANCELLED", sourceBlendSha256: entry.request.sourceBlendSha256, sourceRevision: entry.request.sourceRevision, blenderBuild: entry.request.blenderBuild, propertiesSha256: entry.request.propertiesSha256, requestSha256, resultSha256: digest(Buffer.from("CANCELLED")) };
entry.result = { ...result, status: "CANCELLED" };
writeJson(response, 200, { status: { ready: true }, result: entry.result });
return true;
}
const result = entry.result ?? { schemaVersion: 1, route: "SERVER_NATIVE", jobId, status: "RUNNING", sourceBlendSha256: entry.request.sourceBlendSha256, sourceRevision: entry.request.sourceRevision, blenderBuild: entry.request.blenderBuild, propertiesSha256: entry.request.propertiesSha256, requestSha256, resultSha256: digest(Buffer.from("RUNNING")) };
writeJson(response, 200, { status: { ready: true }, result });
return true;
} catch (error) { writeJson(response, error.statusCode ?? 500, { status: { ready: false }, result: { status: "FAILED", errorMessage: String(error.message ?? error) } }); return true; }
}
export function serverOperatorJobProvider(options = {}) {
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 = {
blenderBinary: process.env.BLENDER_BIN ?? "blender",
pythonBinary: process.env.PYTHON_BIN ?? "python3",
workspaceRoot: process.env.SERVER_OPERATOR_WORKSPACE_ROOT,
};
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" } });
}).catch((error) => writeJson(response, 500, { status: { ready: false }, result: { status: "FAILED", errorMessage: String(error?.message ?? error) } }));
}).listen(port, host, () => process.stdout.write(`server-operator-provider-ready http://${host}:${port}/api/server-operator-job\n`));
}