275 lines
11 KiB
TypeScript
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}`);
|
|
}
|
|
}
|