Files
workinf_Blender_Wasm/web/protocol/corrective-resource-provider.ts
mes123456 a081c68c87
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Reorganize Blender Web execution around capabilities
2026-08-24 18:37:46 -04:00

154 lines
7.5 KiB
TypeScript

export const CORRECTIVE_RESOURCE_SCHEMA = 1 as const;
export type CatalogSourceKind = "LOCAL" | "EXTERNAL" | "PACKED";
export type ResourceProviderKind = "LOCAL" | "EXTERNAL_LIBRARY" | "PACKED_BLEND";
export interface ActiveLibraryContext {
libraryId: string;
sourceKind: CatalogSourceKind;
sourceId: string;
generation: number;
revision: number;
readOnly: boolean;
}
export interface ResourceRequest {
schemaVersion: typeof CORRECTIVE_RESOURCE_SCHEMA;
requestId: string;
sourceKind: CatalogSourceKind;
sourceId: string;
sha256: string;
}
export interface ResourceRecord {
schemaVersion: typeof CORRECTIVE_RESOURCE_SCHEMA;
provider: ResourceProviderKind;
sourceKind: CatalogSourceKind;
sourceId: string;
sha256: string;
bytes: Uint8Array;
}
export class CorrectiveResourceError extends Error {
readonly code: "RESOURCE_MALFORMED" | "RESOURCE_REQUIRED" | "RESOURCE_NOT_FOUND" | "RESOURCE_HASH_MISMATCH" | "CANCELLED" | "CONTEXT_INVALID";
constructor(code: CorrectiveResourceError["code"], message: string) {
super(`${code}: ${message}`);
this.name = "CorrectiveResourceError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/u;
const REQUEST_ID = /^[A-Za-z0-9._:-]{1,256}$/u;
const SOURCE_ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
const LIBRARY_ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
const SOURCE_KINDS = new Set<CatalogSourceKind>(["LOCAL", "EXTERNAL", "PACKED"]);
function assertText(value: unknown, pattern: RegExp, label: string): string {
if (typeof value !== "string" || !pattern.test(value)) throw new CorrectiveResourceError("RESOURCE_MALFORMED", `${label} is invalid`);
return value;
}
function assertInteger(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new CorrectiveResourceError("RESOURCE_MALFORMED", `${label} is invalid`);
return value;
}
function bytesCopy(value: Uint8Array): Uint8Array {
return new Uint8Array(value);
}
async function digest(bytes: Uint8Array): Promise<string> {
const hash = await globalThis.crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
return [...new Uint8Array(hash)].map((value) => value.toString(16).padStart(2, "0")).join("");
}
export function validateActiveLibraryContext(value: unknown): ActiveLibraryContext {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CorrectiveResourceError("CONTEXT_INVALID", "active library context must be an object");
const input = value as Record<string, unknown>;
const keys = Object.keys(input);
if (keys.some((key) => !["libraryId", "sourceKind", "sourceId", "generation", "revision", "readOnly"].includes(key))) throw new CorrectiveResourceError("CONTEXT_INVALID", "active library context has unknown fields");
const libraryId = assertText(input.libraryId, LIBRARY_ID, "libraryId");
const sourceKind = input.sourceKind;
if (typeof sourceKind !== "string" || !SOURCE_KINDS.has(sourceKind as CatalogSourceKind)) throw new CorrectiveResourceError("CONTEXT_INVALID", "sourceKind is invalid");
const sourceId = assertText(input.sourceId, SOURCE_ID, "sourceId");
const generation = assertInteger(input.generation, "generation");
const revision = assertInteger(input.revision, "revision");
if (typeof input.readOnly !== "boolean") throw new CorrectiveResourceError("CONTEXT_INVALID", "readOnly is invalid");
return { libraryId, sourceKind: sourceKind as CatalogSourceKind, sourceId, generation, revision, readOnly: input.readOnly };
}
export function validateResourceRequest(value: unknown): ResourceRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "resource request must be an object");
const input = value as Record<string, unknown>;
if (Object.keys(input).some((key) => !["schemaVersion", "requestId", "sourceKind", "sourceId", "sha256"].includes(key))) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "resource request has unknown fields");
if (input.schemaVersion !== CORRECTIVE_RESOURCE_SCHEMA) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "schemaVersion is unsupported");
const requestId = assertText(input.requestId, REQUEST_ID, "requestId");
if (typeof input.sourceKind !== "string" || !SOURCE_KINDS.has(input.sourceKind as CatalogSourceKind)) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "sourceKind is invalid");
const sourceId = assertText(input.sourceId, SOURCE_ID, "sourceId");
const sha256 = assertText(input.sha256, SHA256, "sha256");
return { schemaVersion: CORRECTIVE_RESOURCE_SCHEMA, requestId, sourceKind: input.sourceKind as CatalogSourceKind, sourceId, sha256 };
}
export class InMemoryCatalogProvider {
readonly provider: ResourceProviderKind;
private readonly records = new Map<string, Uint8Array>();
constructor(provider: ResourceProviderKind) {
this.provider = provider;
}
async put(sourceId: string, bytes: Uint8Array): Promise<string> {
assertText(sourceId, SOURCE_ID, "sourceId");
if (!(bytes instanceof Uint8Array)) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "bytes must be Uint8Array");
const copy = bytesCopy(bytes);
const hash = await digest(copy);
this.records.set(sourceId, copy);
return hash;
}
async load(sourceId: string, signal?: AbortSignal): Promise<Uint8Array> {
if (signal?.aborted) throw new CorrectiveResourceError("CANCELLED", "resource lookup cancelled");
const value = this.records.get(sourceId);
if (!value) throw new CorrectiveResourceError("RESOURCE_NOT_FOUND", `resource ${sourceId} is not available`);
await Promise.resolve();
if (signal?.aborted) throw new CorrectiveResourceError("CANCELLED", "resource lookup cancelled");
return bytesCopy(value);
}
}
export class AssetCatalogResourceResolver {
private readonly providers = new Map<ResourceProviderKind, InMemoryCatalogProvider>();
private active: ActiveLibraryContext | null = null;
setActiveLibraryContext(value: unknown): ActiveLibraryContext {
this.active = validateActiveLibraryContext(value);
return { ...this.active };
}
registerProvider(provider: InMemoryCatalogProvider): this {
this.providers.set(provider.provider, provider);
return this;
}
activeLibraryContext(): ActiveLibraryContext | null {
return this.active ? { ...this.active } : null;
}
async resolve(value: unknown, signal?: AbortSignal): Promise<ResourceRecord> {
const request = validateResourceRequest(value);
if (signal?.aborted) throw new CorrectiveResourceError("CANCELLED", "resource lookup cancelled");
if (!this.active || this.active.sourceKind !== request.sourceKind || this.active.sourceId !== request.sourceId) {
throw new CorrectiveResourceError("CONTEXT_INVALID", "request does not match active library context");
}
const providerKind: ResourceProviderKind = request.sourceKind === "LOCAL" ? "LOCAL" : request.sourceKind === "EXTERNAL" ? "EXTERNAL_LIBRARY" : "PACKED_BLEND";
const provider = this.providers.get(providerKind);
if (!provider) throw new CorrectiveResourceError("RESOURCE_REQUIRED", `${providerKind} provider is required`);
const bytes = await provider.load(request.sourceId, signal);
const observed = await digest(bytes);
if (observed !== request.sha256) throw new CorrectiveResourceError("RESOURCE_HASH_MISMATCH", `resource hash ${observed} does not match request`);
return { schemaVersion: CORRECTIVE_RESOURCE_SCHEMA, provider: providerKind, sourceKind: request.sourceKind, sourceId: request.sourceId, sha256: observed, bytes };
}
}