Files
workinf_Blender_Wasm/web/protocol/manifest.ts
mes123456 7c16b279ae
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
Advance M7 workflows and release operations
2026-08-15 17:43:53 -04:00

275 lines
11 KiB
TypeScript

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<Shared extends boolean = boolean> {
initialPages: number;
maximumPages: number;
shared: Shared;
}
export interface WebEngineSingleVariantV2 {
id: "single";
memory: WebEngineVariantMemoryV2<false>;
resources: {
js: WebEngineVariantResourceV2;
wasm: WebEngineVariantResourceV2;
pthreadWorker?: never;
};
}
export interface WebEnginePthreadVariantV2 {
id: "pthread";
memory: WebEngineVariantMemoryV2<true>;
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<string, unknown> {
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<string, unknown>, 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<LoadedWebEngineManifest> {
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<WebEngineManifest>;
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<WasmResource, "url" | "sha256"> & Partial<Pick<WasmResource, "id">>,
): Promise<void> {
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}`);
}
}