export interface WasmResource { id: string; fileName: string; url: string; sha256: string; required: boolean; } export interface WebEngineManifest { schemaVersion: number; protocolVersion: number; engineVersion: string; engine: "mock" | "blender-wasm"; memory: { initialPages: number; maximumPages: number; shared: boolean; }; wasm: WasmResource[]; } export const WEB_ENGINE_MANIFEST_V2_SCHEMA = 2 as const; export const WEB_ENGINE_WASM_PAGE_BYTES = 65_536 as const; export const WEB_ENGINE_MEMORY_LIMITS = { minimumInitialPages: 256, maximumPages: 32_768, } as const; export type WebEngineVariantId = "single" | "pthread"; export interface WebEngineVariantResourceV2 { fileName: string; url: string; sha256: string; } export interface WebEngineVariantMemoryV2 { initialPages: number; maximumPages: number; shared: Shared; } export interface WebEngineSingleVariantV2 { id: "single"; memory: WebEngineVariantMemoryV2; resources: { js: WebEngineVariantResourceV2; wasm: WebEngineVariantResourceV2; pthreadWorker?: never; }; } export interface WebEnginePthreadVariantV2 { id: "pthread"; memory: WebEngineVariantMemoryV2; resources: { js: WebEngineVariantResourceV2; wasm: WebEngineVariantResourceV2; pthreadWorker: WebEngineVariantResourceV2; }; } export type WebEngineVariantV2 = WebEngineSingleVariantV2 | WebEnginePthreadVariantV2; export interface WebEngineManifestV2 { schemaVersion: typeof WEB_ENGINE_MANIFEST_V2_SCHEMA; protocolVersion: 1; releaseId: string; engineVersion: string; engine: "blender-wasm"; variants: [WebEngineSingleVariantV2, WebEnginePthreadVariantV2]; } export type LoadedWebEngineManifest = WebEngineManifest | WebEngineManifestV2; export type WebEngineManifestValidationCode = "PROTOCOL_MISMATCH" | "ENGINE_MANIFEST_INVALID"; export class WebEngineManifestValidationError extends Error { readonly code: WebEngineManifestValidationCode; readonly path?: string; constructor(code: WebEngineManifestValidationCode, message: string, path?: string) { super(message); this.name = "WebEngineManifestValidationError"; this.code = code; this.path = path; } } const SHA256 = /^[a-f0-9]{64}$/; const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const ENGINE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/; function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function fail(code: WebEngineManifestValidationCode, message: string, path?: string): never { throw new WebEngineManifestValidationError(code, message, path); } function exactKeys(value: Record, allowed: readonly string[], path: string): void { const allowedKeys = new Set(allowed); const unexpected = Object.keys(value).find((key) => !allowedKeys.has(key)); if (unexpected) fail("ENGINE_MANIFEST_INVALID", `${path}.${unexpected} is not allowed`, `${path}.${unexpected}`); } function boundedText(value: unknown, path: string, pattern: RegExp): string { if (typeof value !== "string" || !pattern.test(value)) { fail("ENGINE_MANIFEST_INVALID", `${path} is invalid`, path); } return value; } function parseResource( value: unknown, path: string, extension: ".js" | ".wasm", ): WebEngineVariantResourceV2 { if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is required`, path); exactKeys(value, ["fileName", "url", "sha256"], path); const fileName = boundedText(value.fileName, `${path}.fileName`, FILE_NAME); if (!fileName.endsWith(extension)) { fail("ENGINE_MANIFEST_INVALID", `${path}.fileName must end with ${extension}`, `${path}.fileName`); } if (typeof value.url !== "string" || !value.url.startsWith("/") || /[?#\\\\]/.test(value.url)) { fail("ENGINE_MANIFEST_INVALID", `${path}.url must be a root-relative URL`, `${path}.url`); } const segments = value.url.slice(1).split("/"); if (segments.length === 0 || segments.some((segment) => !FILE_NAME.test(segment) || segment === "." || segment === "..")) { fail("ENGINE_MANIFEST_INVALID", `${path}.url contains an invalid path segment`, `${path}.url`); } if (segments.at(-1) !== fileName) { fail("ENGINE_MANIFEST_INVALID", `${path}.url must end with fileName`, `${path}.url`); } const sha256 = boundedText(value.sha256, `${path}.sha256`, SHA256); return { fileName, url: value.url, sha256 }; } function parseMemory(value: unknown, id: WebEngineVariantId, path: string): WebEngineVariantMemoryV2 { if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is required`, path); exactKeys(value, ["initialPages", "maximumPages", "shared"], path); const { initialPages, maximumPages, shared } = value; if (!Number.isSafeInteger(initialPages) || (initialPages as number) < WEB_ENGINE_MEMORY_LIMITS.minimumInitialPages || (initialPages as number) > WEB_ENGINE_MEMORY_LIMITS.maximumPages) { fail("ENGINE_MANIFEST_INVALID", `${path}.initialPages is outside the supported range`, `${path}.initialPages`); } if (!Number.isSafeInteger(maximumPages) || (maximumPages as number) < (initialPages as number) || (maximumPages as number) > WEB_ENGINE_MEMORY_LIMITS.maximumPages) { fail("ENGINE_MANIFEST_INVALID", `${path}.maximumPages is outside the supported range`, `${path}.maximumPages`); } if (shared !== (id === "pthread")) { fail("ENGINE_MANIFEST_INVALID", `${path}.shared must be ${id === "pthread"}`, `${path}.shared`); } return { initialPages: initialPages as number, maximumPages: maximumPages as number, shared }; } function parseVariant(value: unknown, index: number): WebEngineVariantV2 { const path = `variants[${index}]`; if (!record(value)) fail("ENGINE_MANIFEST_INVALID", `${path} is invalid`, path); exactKeys(value, ["id", "memory", "resources"], path); if (value.id !== "single" && value.id !== "pthread") { fail("ENGINE_MANIFEST_INVALID", `${path}.id is invalid`, `${path}.id`); } const id = value.id; if (!record(value.resources)) fail("ENGINE_MANIFEST_INVALID", `${path}.resources is required`, `${path}.resources`); const resourcePath = `${path}.resources`; exactKeys(value.resources, id === "pthread" ? ["js", "wasm", "pthreadWorker"] : ["js", "wasm"], resourcePath); if (id === "pthread" && value.resources.pthreadWorker === undefined) { fail("ENGINE_MANIFEST_INVALID", `${resourcePath}.pthreadWorker is required`, `${resourcePath}.pthreadWorker`); } const js = parseResource(value.resources.js, `${resourcePath}.js`, ".js"); const wasm = parseResource(value.resources.wasm, `${resourcePath}.wasm`, ".wasm"); const memory = parseMemory(value.memory, id, `${path}.memory`); if (id === "pthread") { const pthreadWorker = parseResource( value.resources.pthreadWorker, `${resourcePath}.pthreadWorker`, ".js", ); return { id, memory: { ...memory, shared: true }, resources: { js, wasm, pthreadWorker } }; } return { id, memory: { ...memory, shared: false }, resources: { js, wasm } }; } export function validateWebEngineManifestV2(value: unknown): WebEngineManifestV2 { if (!record(value) || value.schemaVersion !== WEB_ENGINE_MANIFEST_V2_SCHEMA) { fail("PROTOCOL_MISMATCH", "Unsupported engine manifest schema", "schemaVersion"); } exactKeys(value, ["schemaVersion", "protocolVersion", "releaseId", "engineVersion", "engine", "variants"], "manifest"); if (value.protocolVersion !== 1) { fail("PROTOCOL_MISMATCH", "Unsupported WebEngine protocol version", "protocolVersion"); } if (value.engine !== "blender-wasm") { fail("ENGINE_MANIFEST_INVALID", "engine must be blender-wasm", "engine"); } const engineVersion = boundedText(value.engineVersion, "engineVersion", ENGINE_VERSION); const releaseId = boundedText(value.releaseId, "releaseId", ENGINE_VERSION); if (!Array.isArray(value.variants) || value.variants.length !== 2) { fail("ENGINE_MANIFEST_INVALID", "variants must contain exactly single and pthread", "variants"); } const variants = value.variants.map(parseVariant); const byId = new Map(variants.map((variant) => [variant.id, variant])); if (byId.size !== 2 || !byId.has("single") || !byId.has("pthread")) { fail("ENGINE_MANIFEST_INVALID", "variants must contain exactly one single and one pthread", "variants"); } const single = byId.get("single") as WebEngineSingleVariantV2; const pthread = byId.get("pthread") as WebEnginePthreadVariantV2; for (const role of ["js", "wasm"] as const) { if (single.resources[role].url === pthread.resources[role].url) { fail( "ENGINE_MANIFEST_INVALID", `single and pthread ${role} resources must have distinct URLs`, `variants.${role}`, ); } } if (pthread.resources.pthreadWorker.url === single.resources.js.url) { fail( "ENGINE_MANIFEST_INVALID", "pthread worker must not alias the single JS resource", "variants.pthreadWorker", ); } if (pthread.resources.pthreadWorker.url === pthread.resources.js.url && pthread.resources.pthreadWorker.sha256 !== pthread.resources.js.sha256) { fail( "ENGINE_MANIFEST_INVALID", "resources that share a URL must share the same SHA-256", "variants.pthreadWorker.sha256", ); } return { schemaVersion: WEB_ENGINE_MANIFEST_V2_SCHEMA, protocolVersion: 1, releaseId, engineVersion, engine: "blender-wasm", variants: [single, pthread], }; } export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise { const response = await fetch(url, { cache: "no-store" }); if (!response.ok) throw new Error(`Engine manifest request failed: ${response.status}`); const manifest = await response.json() as unknown; if (record(manifest) && manifest.schemaVersion === WEB_ENGINE_MANIFEST_V2_SCHEMA) { return validateWebEngineManifestV2(manifest); } const legacyManifest = manifest as Partial; if (legacyManifest.schemaVersion !== 1 || legacyManifest.protocolVersion !== 1) { throw new Error("Unsupported engine manifest version"); } if (!legacyManifest.engine || !legacyManifest.memory || !Array.isArray(legacyManifest.wasm)) { throw new Error("Invalid engine manifest shape"); } return legacyManifest as WebEngineManifest; } export async function verifyWasmResource( resource: Pick & Partial>, ): Promise { const response = await fetch(resource.url, { cache: "no-store" }); const resourceId = resource.id ?? resource.url; if (!response.ok) throw new Error(`WASM resource request failed: ${resourceId}`); const bytes = await response.arrayBuffer(); const digest = await crypto.subtle.digest("SHA-256", bytes); const actual = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); if (actual !== resource.sha256.toLowerCase()) { throw new Error(`WASM resource hash mismatch: ${resourceId}`); } }