314 lines
17 KiB
TypeScript
314 lines
17 KiB
TypeScript
import type { ErrorCode } from "./error";
|
|
|
|
/** Server render jobs are deliberately a separate contract from realtime routing. */
|
|
export const SERVER_RENDER_JOB_SCHEMA = 1 as const;
|
|
export const SERVER_RENDER_JOB_BUDGET = {
|
|
maxSourceBytes: 512 * 1024 * 1024,
|
|
maxOutputBytes: 512 * 1024 * 1024,
|
|
maxSettingsBytes: 256 * 1024,
|
|
maxSettingsNodes: 10_000,
|
|
maxJobIdBytes: 128,
|
|
maxBuildVersionBytes: 64,
|
|
} as const;
|
|
export const SERVER_RENDER_JOB_STATUSES = ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const;
|
|
export type ServerRenderJobStatus = typeof SERVER_RENDER_JOB_STATUSES[number];
|
|
|
|
export interface BlenderBuildIdentityIR {
|
|
version: string;
|
|
buildSha256: string;
|
|
}
|
|
|
|
export interface ServerRenderSettingsIR {
|
|
renderEngine: "BLENDER_EEVEE" | "BLENDER_EEVEE_NEXT" | "BLENDER_CYCLES" | "BLENDER_WORKBENCH";
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
resolutionX: number;
|
|
resolutionY: number;
|
|
resolutionPercentage: number;
|
|
samples: number;
|
|
outputMime: "image/png" | "image/openexr";
|
|
transparent: boolean;
|
|
}
|
|
|
|
export interface ServerRenderJobRequestIR {
|
|
schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA;
|
|
jobId: string;
|
|
sourceBlendSha256: string;
|
|
sourceBlendByteLength: number;
|
|
sourceRevision: number;
|
|
blenderBuild: BlenderBuildIdentityIR;
|
|
settings: ServerRenderSettingsIR;
|
|
settingsSha256: string;
|
|
requestSha256: string;
|
|
}
|
|
|
|
export interface ServerRenderJobResultIR {
|
|
schemaVersion: typeof SERVER_RENDER_JOB_SCHEMA;
|
|
jobId: string;
|
|
status: ServerRenderJobStatus;
|
|
sourceBlendSha256: string;
|
|
sourceBlendByteLength: number;
|
|
sourceRevision: number;
|
|
blenderBuild: BlenderBuildIdentityIR;
|
|
settingsSha256: string;
|
|
outputMime?: ServerRenderSettingsIR["outputMime"];
|
|
outputSha256?: string;
|
|
outputByteLength?: number;
|
|
requestSha256: string;
|
|
resultSha256: string;
|
|
errorCode?: ErrorCode;
|
|
}
|
|
|
|
export class ServerRenderJobValidationError extends Error {
|
|
readonly code: ErrorCode;
|
|
|
|
constructor(code: ErrorCode, message: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "ServerRenderJobValidationError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const JOB_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
const BUILD_VERSION = /^5\.2\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/;
|
|
const OUTPUT_MIMES = ["image/png", "image/openexr"] as const;
|
|
|
|
function record(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], name: string, code: ErrorCode = "SERVER_RENDER_REQUEST_INVALID"): void {
|
|
const allowedSet = new Set(allowed);
|
|
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
|
|
throw new ServerRenderJobValidationError(code, `${name} contains undeclared fields`);
|
|
}
|
|
}
|
|
|
|
function digest(value: unknown, name: string): string {
|
|
if (typeof value !== "string" || !SHA256.test(value)) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_HASH_INVALID", `${name} must be a lowercase SHA-256 digest`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number {
|
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `${name} is outside the render budget`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function stableJSON(value: unknown, state = { nodes: 0, depth: 0 }): string {
|
|
state.nodes += 1;
|
|
if (state.nodes > SERVER_RENDER_JOB_BUDGET.maxSettingsNodes) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the node 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)) {
|
|
if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested");
|
|
const result = `[${value.map((item) => stableJSON(item, state)).join(",")}]`;
|
|
state.depth -= 1;
|
|
return result;
|
|
}
|
|
if (record(value)) {
|
|
if (++state.depth > 16) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings are too deeply nested");
|
|
const result = `{${Object.keys(value).sort().map((key) => {
|
|
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(key) || value[key] === undefined) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", `render setting key ${key} is invalid`);
|
|
}
|
|
return `${JSON.stringify(key)}:${stableJSON(value[key], state)}`;
|
|
}).join(",")}}`;
|
|
state.depth -= 1;
|
|
return result;
|
|
}
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings contain a non-JSON value");
|
|
}
|
|
|
|
async function sha256(data: ArrayBuffer | string): Promise<string> {
|
|
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
|
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function parseBuild(value: unknown): BlenderBuildIdentityIR {
|
|
if (!record(value) || typeof value.version !== "string" || !BUILD_VERSION.test(value.version) ||
|
|
new TextEncoder().encode(value.version).byteLength > SERVER_RENDER_JOB_BUDGET.maxBuildVersionBytes) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_BUILD_INVALID", "Blender build version is invalid or outside the 5.2 contract");
|
|
}
|
|
exactKeys(value, ["version", "buildSha256"], "blenderBuild", "SERVER_RENDER_BUILD_INVALID");
|
|
return { version: value.version, buildSha256: digest(value.buildSha256, "blenderBuild.buildSha256") };
|
|
}
|
|
|
|
function parseSettings(value: unknown): ServerRenderSettingsIR {
|
|
if (!record(value)) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings must be an object");
|
|
exactKeys(value, ["renderEngine", "frameStart", "frameEnd", "resolutionX", "resolutionY", "resolutionPercentage", "samples", "outputMime", "transparent"], "settings", "SERVER_RENDER_SETTINGS_INVALID");
|
|
const renderEngine = value.renderEngine;
|
|
if (!(["BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "BLENDER_CYCLES", "BLENDER_WORKBENCH"] as string[]).includes(renderEngine as string)) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render engine is not declared");
|
|
}
|
|
const outputMime = value.outputMime;
|
|
if (!OUTPUT_MIMES.includes(outputMime as typeof OUTPUT_MIMES[number])) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "output MIME is not declared");
|
|
}
|
|
const settings = {
|
|
...value,
|
|
renderEngine,
|
|
frameStart: boundedInteger(value.frameStart, "frameStart", -1_000_000, 1_000_000),
|
|
frameEnd: boundedInteger(value.frameEnd, "frameEnd", -1_000_000, 1_000_000),
|
|
resolutionX: boundedInteger(value.resolutionX, "resolutionX", 1, 16_384),
|
|
resolutionY: boundedInteger(value.resolutionY, "resolutionY", 1, 16_384),
|
|
resolutionPercentage: boundedInteger(value.resolutionPercentage, "resolutionPercentage", 1, 100),
|
|
samples: boundedInteger(value.samples, "samples", 1, 65_536),
|
|
outputMime,
|
|
transparent: value.transparent,
|
|
} as ServerRenderSettingsIR;
|
|
if (typeof settings.transparent !== "boolean") throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "transparent must be boolean");
|
|
if (settings.frameEnd !== settings.frameStart) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "schema 1 binds one still frame per output hash");
|
|
const encoded = new TextEncoder().encode(stableJSON(settings));
|
|
if (encoded.byteLength > SERVER_RENDER_JOB_BUDGET.maxSettingsBytes) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_INVALID", "render settings exceed the byte budget");
|
|
return settings;
|
|
}
|
|
|
|
function canonicalRequest(request: Omit<ServerRenderJobRequestIR, "requestSha256">): string {
|
|
return stableJSON({
|
|
schemaVersion: request.schemaVersion,
|
|
jobId: request.jobId,
|
|
sourceBlendSha256: request.sourceBlendSha256,
|
|
sourceBlendByteLength: request.sourceBlendByteLength,
|
|
sourceRevision: request.sourceRevision,
|
|
blenderBuild: request.blenderBuild,
|
|
settingsSha256: request.settingsSha256,
|
|
});
|
|
}
|
|
|
|
function canonicalResult(result: Omit<ServerRenderJobResultIR, "resultSha256">): string {
|
|
return stableJSON(result);
|
|
}
|
|
|
|
export async function createServerRenderJobRequest(
|
|
sourceBlend: ArrayBuffer,
|
|
blenderBuildValue: unknown,
|
|
settingsValue: unknown,
|
|
options: { jobId?: string; sourceRevision?: number } = {},
|
|
): Promise<ServerRenderJobRequestIR> {
|
|
if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength < 1 || sourceBlend.byteLength > SERVER_RENDER_JOB_BUDGET.maxSourceBytes) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_INVALID", "source .blend bytes are empty or exceed the budget");
|
|
}
|
|
const jobId = options.jobId ?? "render-request";
|
|
if (!JOB_ID.test(jobId) || new TextEncoder().encode(jobId).byteLength > SERVER_RENDER_JOB_BUDGET.maxJobIdBytes) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid");
|
|
}
|
|
const sourceRevision = options.sourceRevision ?? 0;
|
|
boundedInteger(sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER);
|
|
const blenderBuild = parseBuild(blenderBuildValue);
|
|
const settings = parseSettings(settingsValue);
|
|
const settingsSha256 = await sha256(stableJSON(settings));
|
|
const unsigned = {
|
|
schemaVersion: SERVER_RENDER_JOB_SCHEMA,
|
|
jobId,
|
|
sourceBlendSha256: await sha256(sourceBlend),
|
|
sourceBlendByteLength: sourceBlend.byteLength,
|
|
sourceRevision,
|
|
blenderBuild,
|
|
settings,
|
|
settingsSha256,
|
|
} as Omit<ServerRenderJobRequestIR, "requestSha256">;
|
|
return { ...unsigned, requestSha256: await sha256(canonicalRequest(unsigned)) };
|
|
}
|
|
|
|
export function parseServerRenderJobRequest(value: unknown): ServerRenderJobRequestIR {
|
|
if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job request schema");
|
|
exactKeys(value, ["schemaVersion", "jobId", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settings", "settingsSha256", "requestSha256"], "request");
|
|
const jobId = value.jobId;
|
|
if (typeof jobId !== "string" || !JOB_ID.test(jobId)) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_INVALID", "jobId is invalid");
|
|
const sourceBlendByteLength = boundedInteger(value.sourceBlendByteLength, "sourceBlendByteLength", 1, SERVER_RENDER_JOB_BUDGET.maxSourceBytes);
|
|
const sourceRevision = boundedInteger(value.sourceRevision, "sourceRevision", 0, Number.MAX_SAFE_INTEGER);
|
|
const blenderBuild = parseBuild(value.blenderBuild);
|
|
const settings = parseSettings(value.settings);
|
|
const settingsSha256 = digest(value.settingsSha256, "settingsSha256");
|
|
const requestSha256 = digest(value.requestSha256, "requestSha256");
|
|
const request = { schemaVersion: SERVER_RENDER_JOB_SCHEMA, jobId, sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"), sourceBlendByteLength, sourceRevision, blenderBuild, settings, settingsSha256, requestSha256 };
|
|
return request;
|
|
}
|
|
|
|
export async function verifyServerRenderJobRequest(value: unknown, sourceBlend?: ArrayBuffer): Promise<ServerRenderJobRequestIR> {
|
|
const request = parseServerRenderJobRequest(value);
|
|
if (await sha256(stableJSON(request.settings)) !== request.settingsSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_SETTINGS_HASH_MISMATCH", "settings do not match settingsSha256");
|
|
const { requestSha256, ...unsigned } = request;
|
|
if (await sha256(canonicalRequest(unsigned)) !== requestSha256) throw new ServerRenderJobValidationError("SERVER_RENDER_REQUEST_HASH_MISMATCH", "request binding hash does not match canonical metadata");
|
|
if (sourceBlend !== undefined) {
|
|
if (!(sourceBlend instanceof ArrayBuffer) || sourceBlend.byteLength !== request.sourceBlendByteLength || await sha256(sourceBlend) !== request.sourceBlendSha256) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_SOURCE_HASH_MISMATCH", "source .blend bytes do not match the submitted request");
|
|
}
|
|
}
|
|
return request;
|
|
}
|
|
|
|
export async function createServerRenderJobResult(
|
|
requestValue: unknown,
|
|
output: ArrayBuffer,
|
|
options: { status?: "SUCCEEDED"; errorCode?: never } = {},
|
|
): Promise<ServerRenderJobResultIR> {
|
|
const request = await verifyServerRenderJobRequest(requestValue);
|
|
if (!(output instanceof ArrayBuffer) || output.byteLength < 1 || output.byteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "render output is empty or exceeds the budget");
|
|
}
|
|
const unsigned = {
|
|
schemaVersion: SERVER_RENDER_JOB_SCHEMA,
|
|
jobId: request.jobId,
|
|
status: options.status ?? "SUCCEEDED",
|
|
sourceBlendSha256: request.sourceBlendSha256,
|
|
sourceBlendByteLength: request.sourceBlendByteLength,
|
|
sourceRevision: request.sourceRevision,
|
|
blenderBuild: request.blenderBuild,
|
|
settingsSha256: request.settingsSha256,
|
|
outputMime: request.settings.outputMime,
|
|
outputSha256: await sha256(output),
|
|
outputByteLength: output.byteLength,
|
|
requestSha256: request.requestSha256,
|
|
} as Omit<ServerRenderJobResultIR, "resultSha256">;
|
|
return { ...unsigned, resultSha256: await sha256(canonicalResult(unsigned)) };
|
|
}
|
|
|
|
export async function verifyServerRenderJobResult(value: unknown, requestValue: unknown, output?: ArrayBuffer): Promise<ServerRenderJobResultIR> {
|
|
const request = await verifyServerRenderJobRequest(requestValue);
|
|
if (!record(value) || value.schemaVersion !== SERVER_RENDER_JOB_SCHEMA) throw new ServerRenderJobValidationError("PROTOCOL_MISMATCH", "unsupported server render job result schema");
|
|
exactKeys(value, ["schemaVersion", "jobId", "status", "sourceBlendSha256", "sourceBlendByteLength", "sourceRevision", "blenderBuild", "settingsSha256", "outputMime", "outputSha256", "outputByteLength", "requestSha256", "resultSha256", "errorCode"], "result");
|
|
const result = { ...value } as unknown as ServerRenderJobResultIR;
|
|
if (result.jobId !== request.jobId || result.requestSha256 !== request.requestSha256 || result.sourceBlendSha256 !== request.sourceBlendSha256 ||
|
|
result.sourceBlendByteLength !== request.sourceBlendByteLength || result.sourceRevision !== request.sourceRevision ||
|
|
result.settingsSha256 !== request.settingsSha256 || JSON.stringify(result.blenderBuild) !== JSON.stringify(request.blenderBuild)) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_BINDING_MISMATCH", "server result is bound to different source, build, settings or request");
|
|
}
|
|
if (!SERVER_RENDER_JOB_STATUSES.includes(result.status)) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status is invalid");
|
|
if (result.status === "SUCCEEDED") {
|
|
if (result.errorCode !== undefined || result.outputMime !== request.settings.outputMime || !OUTPUT_MIMES.includes(result.outputMime) || typeof result.outputByteLength !== "number" ||
|
|
!Number.isSafeInteger(result.outputByteLength) || result.outputByteLength < 1 || result.outputByteLength > SERVER_RENDER_JOB_BUDGET.maxOutputBytes) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_INVALID", "successful result has inconsistent output metadata");
|
|
}
|
|
digest(result.outputSha256, "outputSha256");
|
|
if (output !== undefined && (output.byteLength !== result.outputByteLength || await sha256(output) !== result.outputSha256)) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_OUTPUT_HASH_MISMATCH", "render output bytes do not match outputSha256");
|
|
}
|
|
} else {
|
|
if (result.outputSha256 !== undefined || result.outputByteLength !== undefined || result.outputMime !== undefined) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "non-successful result must not publish output bytes");
|
|
}
|
|
const expectedErrorCode = result.status === "FAILED" ? "SERVER_RENDER_FAILED" : result.status === "CANCELLED" ? "SERVER_RENDER_CANCELLED" : undefined;
|
|
if (result.errorCode !== expectedErrorCode) throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_INVALID", "server result status and errorCode disagree");
|
|
}
|
|
const { resultSha256, ...unsigned } = result;
|
|
if (typeof resultSha256 !== "string" || !SHA256.test(resultSha256) || await sha256(canonicalResult(unsigned)) !== resultSha256) {
|
|
throw new ServerRenderJobValidationError("SERVER_RENDER_RESULT_HASH_MISMATCH", "result binding hash does not match canonical metadata");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function createServerRenderJobKey(requestValue: unknown): Promise<string> {
|
|
const request = await verifyServerRenderJobRequest(requestValue);
|
|
const key = await sha256(stableJSON({ schemaVersion: SERVER_RENDER_JOB_SCHEMA, sourceBlendSha256: request.sourceBlendSha256, sourceRevision: request.sourceRevision, blenderBuild: request.blenderBuild, settingsSha256: request.settingsSha256, outputMime: request.settings.outputMime }));
|
|
return `render-${key}`;
|
|
}
|