import type { ErrorCode } from "./error"; import type { BlenderOperatorDescriptor, BlenderOperatorPropertyValue } from "./blender-operator"; export const SERVER_OPERATOR_JOB_SCHEMA = 1 as const; export const SERVER_OPERATOR_JOB_ROUTE = "SERVER_NATIVE" as const; export const SERVER_OPERATOR_JOB_BUDGET = { maxJobIdBytes: 128, maxOperatorBytes: 128, maxPropertiesBytes: 64 * 1024, maxPropertyCount: 128, maxStringPropertyBytes: 4096, maxSourceBytes: 512 * 1024 * 1024, minTimeoutMs: 1000, maxTimeoutMs: 10 * 60 * 1000, minMemoryBytes: 64 * 1024 * 1024, maxMemoryBytes: 4 * 1024 * 1024 * 1024, maxEnvelopeBytes: 512 * 1024 * 1024 + 1024 * 1024, } as const; export const SERVER_OPERATOR_JOB_STATUSES = ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const; export type ServerOperatorJobStatus = typeof SERVER_OPERATOR_JOB_STATUSES[number]; export type ServerOperatorResultBinding = "json" | "snapshot" | "scene-delta"; export interface ServerOperatorBuildIdentityIR { version: string; buildSha256: string; } export interface ServerOperatorSchemaResultIR { schemaVersion: typeof SERVER_OPERATOR_JOB_SCHEMA; route: typeof SERVER_OPERATOR_JOB_ROUTE; operator: BlenderOperatorDescriptor; blenderBuild: ServerOperatorBuildIdentityIR; } export interface ServerOperatorJobRequestIR { schemaVersion: typeof SERVER_OPERATOR_JOB_SCHEMA; route: typeof SERVER_OPERATOR_JOB_ROUTE; jobId: string; sourceBlendSha256: string; sourceBlendByteLength: number; sourceRevision: number; blenderBuild: ServerOperatorBuildIdentityIR; operator: string; properties: Record; propertiesSha256: string; timeoutMs: number; memoryBytes: number; resultBinding: { kind: ServerOperatorResultBinding; key: string }; context?: "BACKGROUND"; requestSha256: string; } export interface ServerOperatorJobResultIR { schemaVersion: typeof SERVER_OPERATOR_JOB_SCHEMA; route: typeof SERVER_OPERATOR_JOB_ROUTE; jobId: string; status: ServerOperatorJobStatus; sourceBlendSha256: string; sourceRevision: number; blenderBuild: ServerOperatorBuildIdentityIR; propertiesSha256: string; requestSha256: string; resultSha256: string; resultBlendSha256?: string; resultBlendByteLength?: number; resultBlendBase64?: string; result?: unknown; errorCode?: ErrorCode; errorMessage?: string; } const SERVER_OPERATOR_ENVELOPE_MAGIC = "WBJ1"; const SERVER_OPERATOR_ENVELOPE_HEADER_BYTES = 16; export function canonicalServerOperatorProperties(properties: Record): string { return stableJSON(properties); } export function encodeServerOperatorJobEnvelope(request: ServerOperatorJobRequestIR, sourceBlend: ArrayBuffer | Uint8Array): ArrayBuffer { const validated = parseServerOperatorJobRequest(request); const source = sourceBlend instanceof Uint8Array ? sourceBlend : new Uint8Array(sourceBlend); if (source.byteLength !== validated.sourceBlendByteLength) throw new ServerOperatorJobValidationError("source blend byte length does not match request"); const requestBytes = new TextEncoder().encode(JSON.stringify(validated)); const total = SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes.byteLength + source.byteLength; if (total > SERVER_OPERATOR_JOB_BUDGET.maxEnvelopeBytes) throw new ServerOperatorJobValidationError("binary envelope exceeds the budget"); const envelope = new Uint8Array(total); envelope.set(new TextEncoder().encode(SERVER_OPERATOR_ENVELOPE_MAGIC), 0); const view = new DataView(envelope.buffer); view.setUint32(4, SERVER_OPERATOR_JOB_SCHEMA, true); view.setUint32(8, requestBytes.byteLength, true); view.setUint32(12, source.byteLength, true); envelope.set(requestBytes, SERVER_OPERATOR_ENVELOPE_HEADER_BYTES); envelope.set(source, SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes.byteLength); return envelope.buffer; } export function decodeServerOperatorJobEnvelope(value: ArrayBuffer | Uint8Array): { request: ServerOperatorJobRequestIR; sourceBlend: Uint8Array } { const envelope = value instanceof Uint8Array ? value : new Uint8Array(value); if (envelope.byteLength < SERVER_OPERATOR_ENVELOPE_HEADER_BYTES || new TextDecoder().decode(envelope.slice(0, 4)) !== SERVER_OPERATOR_ENVELOPE_MAGIC) { throw new ServerOperatorJobValidationError("binary envelope header is invalid"); } const view = new DataView(envelope.buffer, envelope.byteOffset, envelope.byteLength); if (view.getUint32(4, true) !== SERVER_OPERATOR_JOB_SCHEMA) throw new ServerOperatorJobValidationError("binary envelope schema is invalid"); const requestBytes = view.getUint32(8, true); const sourceBytes = view.getUint32(12, true); if (requestBytes < 1 || sourceBytes < 1 || SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes + sourceBytes !== envelope.byteLength || envelope.byteLength > SERVER_OPERATOR_JOB_BUDGET.maxEnvelopeBytes) { throw new ServerOperatorJobValidationError("binary envelope lengths are invalid"); } const request = parseServerOperatorJobRequest(JSON.parse(new TextDecoder().decode(envelope.slice(SERVER_OPERATOR_ENVELOPE_HEADER_BYTES, SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes)))); const sourceBlend = envelope.slice(SERVER_OPERATOR_ENVELOPE_HEADER_BYTES + requestBytes); if (sourceBlend.byteLength !== request.sourceBlendByteLength) throw new ServerOperatorJobValidationError("binary envelope source length is invalid"); return { request, sourceBlend }; } export class ServerOperatorJobValidationError extends Error { readonly code: ErrorCode; constructor(message: string, code: ErrorCode = "INVALID_ARGUMENT") { super(`${code}: ${message}`); this.name = "ServerOperatorJobValidationError"; this.code = code; } } const SHA256 = /^[a-f0-9]{64}$/; const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/; const KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/; function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function exactKeys(value: Record, allowed: readonly string[], name: string): void { const set = new Set(allowed); if (Object.keys(value).some((key) => !set.has(key))) throw new ServerOperatorJobValidationError(`${name} contains undeclared fields`); } function digest(value: unknown, name: string): string { if (typeof value !== "string" || !SHA256.test(value)) throw new ServerOperatorJobValidationError(`${name} must be a lowercase SHA-256 digest`); return value; } function stableJSON(value: unknown, state = { nodes: 0, depth: 0 }): string { state.nodes += 1; if (state.nodes > SERVER_OPERATOR_JOB_BUDGET.maxPropertyCount * 4 || state.depth > 8) throw new ServerOperatorJobValidationError("properties exceed the JSON budget"); if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map((item) => stableJSON(item, { ...state, depth: state.depth + 1 })).join(",")}]`; if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key], { ...state, depth: state.depth + 1 })}`).join(",")}}`; throw new ServerOperatorJobValidationError("properties contain a non-JSON value"); } function validateProperties(value: unknown): Record { if (!record(value)) throw new ServerOperatorJobValidationError("properties must be an object"); const entries = Object.entries(value); if (entries.length > SERVER_OPERATOR_JOB_BUDGET.maxPropertyCount) throw new ServerOperatorJobValidationError("properties exceed the property-count budget"); for (const [key, item] of entries) { if (!KEY.test(key)) throw new ServerOperatorJobValidationError(`property key ${key} is invalid`); if (!((typeof item === "string" && new TextEncoder().encode(item).byteLength <= SERVER_OPERATOR_JOB_BUDGET.maxStringPropertyBytes) || (typeof item === "number" && Number.isFinite(item)) || typeof item === "boolean")) { throw new ServerOperatorJobValidationError(`property ${key} must be a bounded typed scalar`); } } if (new TextEncoder().encode(stableJSON(value)).byteLength > SERVER_OPERATOR_JOB_BUDGET.maxPropertiesBytes) throw new ServerOperatorJobValidationError("properties exceed the byte budget"); return value as Record; } export function canonicalServerOperatorJobRequest(request: Omit): string { return stableJSON({ ...request, context: request.context ?? "BACKGROUND" }); } export function parseServerOperatorJobRequest(value: unknown): ServerOperatorJobRequestIR { if (!record(value)) throw new ServerOperatorJobValidationError("request must be an object"); exactKeys(value, ["schemaVersion", "route", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "operator", "properties", "propertiesSha256", "timeoutMs", "memoryBytes", "resultBinding", "context", "requestSha256"], "request"); if (value.schemaVersion !== SERVER_OPERATOR_JOB_SCHEMA || value.route !== SERVER_OPERATOR_JOB_ROUTE) throw new ServerOperatorJobValidationError("schemaVersion and route do not match SERVER_NATIVE contract"); if (typeof value.jobId !== "string" || !JOB_ID.test(value.jobId)) throw new ServerOperatorJobValidationError("jobId is invalid"); const sourceBlendByteLength = value.sourceBlendByteLength; if (typeof sourceBlendByteLength !== "number" || !Number.isSafeInteger(sourceBlendByteLength) || sourceBlendByteLength < 1 || sourceBlendByteLength > SERVER_OPERATOR_JOB_BUDGET.maxSourceBytes) throw new ServerOperatorJobValidationError("source blend size is outside the budget"); if (typeof value.sourceRevision !== "number" || !Number.isSafeInteger(value.sourceRevision) || value.sourceRevision < 0) throw new ServerOperatorJobValidationError("sourceRevision is invalid"); if (typeof value.operator !== "string" || value.operator.length === 0 || new TextEncoder().encode(value.operator).byteLength > SERVER_OPERATOR_JOB_BUDGET.maxOperatorBytes || !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value.operator)) throw new ServerOperatorJobValidationError("operator is invalid"); if (!record(value.blenderBuild)) throw new ServerOperatorJobValidationError("blenderBuild must be an object"); exactKeys(value.blenderBuild, ["version", "buildSha256"], "blenderBuild"); if (typeof value.blenderBuild.version !== "string" || value.blenderBuild.version.length === 0 || value.blenderBuild.version.length > 64) throw new ServerOperatorJobValidationError("blenderBuild.version is invalid"); const blenderBuild = { version: value.blenderBuild.version, buildSha256: digest(value.blenderBuild.buildSha256, "blenderBuild.buildSha256") }; const properties = validateProperties(value.properties); const timeoutMs = value.timeoutMs; const memoryBytes = value.memoryBytes; if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < SERVER_OPERATOR_JOB_BUDGET.minTimeoutMs || timeoutMs > SERVER_OPERATOR_JOB_BUDGET.maxTimeoutMs) throw new ServerOperatorJobValidationError("timeoutMs is outside the budget"); if (typeof memoryBytes !== "number" || !Number.isSafeInteger(memoryBytes) || memoryBytes < SERVER_OPERATOR_JOB_BUDGET.minMemoryBytes || memoryBytes > SERVER_OPERATOR_JOB_BUDGET.maxMemoryBytes) throw new ServerOperatorJobValidationError("memoryBytes is outside the budget"); if (!record(value.resultBinding) || !["json", "snapshot", "scene-delta"].includes(value.resultBinding.kind as string) || typeof value.resultBinding.key !== "string" || !KEY.test(value.resultBinding.key)) throw new ServerOperatorJobValidationError("resultBinding is invalid"); if (value.context !== undefined && value.context !== "BACKGROUND") throw new ServerOperatorJobValidationError("UI, window and file-browser contexts are not supported"); return { schemaVersion: SERVER_OPERATOR_JOB_SCHEMA, route: SERVER_OPERATOR_JOB_ROUTE, jobId: value.jobId, sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"), sourceBlendByteLength, sourceRevision: value.sourceRevision, blenderBuild, operator: value.operator, properties, propertiesSha256: digest(value.propertiesSha256, "propertiesSha256"), timeoutMs, memoryBytes, resultBinding: { kind: value.resultBinding.kind as ServerOperatorResultBinding, key: value.resultBinding.key }, ...(value.context === undefined ? {} : { context: "BACKGROUND" as const }), requestSha256: digest(value.requestSha256, "requestSha256"), }; }