Reorganize Blender Web execution around capabilities
This commit is contained in:
153
web/protocol/corrective-resource-provider.ts
Normal file
153
web/protocol/corrective-resource-provider.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user