Advance M7 workflows and release operations
This commit is contained in:
53
web/protocol/dirty-state.ts
Normal file
53
web/protocol/dirty-state.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface DirtyState {
|
||||
currentMainRevision: number;
|
||||
committedMainRevision: number;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export type DirtyStateResult =
|
||||
| { ok: true; state: DirtyState }
|
||||
| { ok: false; state: DirtyState; errorCode: "DIRTY_REVISION_INVALID" | "DIRTY_REVISION_STALE" | "DIRTY_SAVE_REVISION_MISMATCH" };
|
||||
|
||||
function validRevision(revision: number): boolean {
|
||||
return Number.isSafeInteger(revision) && revision >= 0;
|
||||
}
|
||||
|
||||
export function createDirtyState(revision = 0): DirtyState {
|
||||
if (!validRevision(revision)) throw new Error("DIRTY_REVISION_INVALID");
|
||||
return { currentMainRevision: revision, committedMainRevision: revision, dirty: false };
|
||||
}
|
||||
|
||||
export function recoverDirtyState(currentMainRevision: number, committedMainRevision: number): DirtyState {
|
||||
if (!validRevision(currentMainRevision) || !validRevision(committedMainRevision) || currentMainRevision < committedMainRevision) {
|
||||
throw new Error("DIRTY_REVISION_INVALID");
|
||||
}
|
||||
return { currentMainRevision, committedMainRevision, dirty: currentMainRevision !== committedMainRevision };
|
||||
}
|
||||
|
||||
export function acceptMainTransaction(state: DirtyState, revision: number): DirtyStateResult {
|
||||
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
|
||||
if (revision <= state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_REVISION_STALE" };
|
||||
return {
|
||||
ok: true,
|
||||
state: { ...state, currentMainRevision: revision, dirty: revision !== state.committedMainRevision },
|
||||
};
|
||||
}
|
||||
|
||||
export function acceptMainSave(state: DirtyState, revision: number): DirtyStateResult {
|
||||
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
|
||||
if (revision !== state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_SAVE_REVISION_MISMATCH" };
|
||||
return { ok: true, state: { currentMainRevision: revision, committedMainRevision: revision, dirty: false } };
|
||||
}
|
||||
|
||||
export function acceptHistoryTransaction(state: DirtyState, revision: number, matchesCommittedContent: boolean): DirtyStateResult {
|
||||
if (!validRevision(revision)) return { ok: false, state, errorCode: "DIRTY_REVISION_INVALID" };
|
||||
if (revision <= state.currentMainRevision) return { ok: false, state, errorCode: "DIRTY_REVISION_STALE" };
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
currentMainRevision: revision,
|
||||
committedMainRevision: matchesCommittedContent ? revision : state.committedMainRevision,
|
||||
dirty: !matchesCommittedContent,
|
||||
},
|
||||
};
|
||||
}
|
||||
96
web/protocol/engine-variant.ts
Normal file
96
web/protocol/engine-variant.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
|
||||
import type { WebEngineManifestV2, WebEngineVariantV2 } from "./manifest";
|
||||
|
||||
export interface WasmThreadingCapabilities {
|
||||
crossOriginIsolated: boolean;
|
||||
sharedArrayBuffer: boolean;
|
||||
worker: boolean;
|
||||
}
|
||||
|
||||
export type WebEngineVariantPolicy = "AUTO" | "SINGLE_REQUIRED" | "PTHREAD_REQUIRED";
|
||||
|
||||
export interface WebEngineVariantSelection {
|
||||
policy: WebEngineVariantPolicy;
|
||||
selectedVariant: WebEngineVariantV2 | null;
|
||||
pthreadGate: CapabilityGateResult;
|
||||
}
|
||||
|
||||
export type WebEngineReleaseBinding =
|
||||
| { status: "READY"; releaseId: string; manifest: WebEngineManifestV2 }
|
||||
| {
|
||||
status: "REFRESH_REQUIRED";
|
||||
expectedReleaseId: string;
|
||||
actualReleaseId: string;
|
||||
manifest: null;
|
||||
};
|
||||
|
||||
export function bindWebEngineRelease(
|
||||
expectedReleaseId: string,
|
||||
manifest: WebEngineManifestV2,
|
||||
): WebEngineReleaseBinding {
|
||||
if (manifest.releaseId === expectedReleaseId) {
|
||||
return { status: "READY", releaseId: manifest.releaseId, manifest };
|
||||
}
|
||||
return {
|
||||
status: "REFRESH_REQUIRED",
|
||||
expectedReleaseId,
|
||||
actualReleaseId: manifest.releaseId,
|
||||
manifest: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function gateWasmThreadingCapability(capabilities: WasmThreadingCapabilities): CapabilityGateResult {
|
||||
const issues = [];
|
||||
if (!capabilities.crossOriginIsolated) {
|
||||
issues.push(capabilityIssue(
|
||||
"PLATFORM_CAPABILITY_UNAVAILABLE",
|
||||
"Cross-origin isolation is required for the pthread WASM engine",
|
||||
"crossOriginIsolated",
|
||||
));
|
||||
}
|
||||
if (!capabilities.sharedArrayBuffer) {
|
||||
issues.push(capabilityIssue(
|
||||
"PLATFORM_CAPABILITY_UNAVAILABLE",
|
||||
"SharedArrayBuffer is required for the pthread WASM engine",
|
||||
"sharedArrayBuffer",
|
||||
));
|
||||
}
|
||||
if (!capabilities.worker) {
|
||||
issues.push(capabilityIssue(
|
||||
"PLATFORM_CAPABILITY_UNAVAILABLE",
|
||||
"Worker is required for the pthread WASM engine",
|
||||
"worker",
|
||||
));
|
||||
}
|
||||
return issues.length > 0
|
||||
? blockedGate("M6-02", "WASM_PTHREAD_ENGINE", issues)
|
||||
: readyGate("M6-02", "WASM_PTHREAD_ENGINE");
|
||||
}
|
||||
|
||||
export function selectWebEngineVariant(
|
||||
manifest: WebEngineManifestV2,
|
||||
policy: WebEngineVariantPolicy,
|
||||
capabilities: WasmThreadingCapabilities,
|
||||
): WebEngineVariantSelection {
|
||||
const single = manifest.variants.find((variant) => variant.id === "single");
|
||||
const pthread = manifest.variants.find((variant) => variant.id === "pthread");
|
||||
if (!single || !pthread) throw new Error("ENGINE_MANIFEST_VARIANTS_MISSING");
|
||||
|
||||
const pthreadGate = gateWasmThreadingCapability(capabilities);
|
||||
if (policy === "SINGLE_REQUIRED") return { policy, selectedVariant: single, pthreadGate };
|
||||
if (policy === "AUTO") {
|
||||
return {
|
||||
policy,
|
||||
selectedVariant: pthreadGate.status === "READY" ? pthread : single,
|
||||
pthreadGate,
|
||||
};
|
||||
}
|
||||
if (policy === "PTHREAD_REQUIRED") {
|
||||
return {
|
||||
policy,
|
||||
selectedVariant: pthreadGate.status === "READY" ? pthread : null,
|
||||
pthreadGate,
|
||||
};
|
||||
}
|
||||
throw new Error("ENGINE_VARIANT_POLICY_INVALID");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type ErrorCode =
|
||||
| "WASM_INIT_FAILED"
|
||||
| "WASM_OUT_OF_MEMORY"
|
||||
| "BLEND_READ_FAILED"
|
||||
| "OPEN_CANCELLED"
|
||||
| "BLEND_WRITE_FAILED"
|
||||
| "STORAGE_QUOTA"
|
||||
| "STORAGE_TRANSACTION"
|
||||
|
||||
132
web/protocol/file-byte-reader.ts
Normal file
132
web/protocol/file-byte-reader.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
export const LARGE_FILE_IMPORT_BYTES = 512 * 1024;
|
||||
export const DEFAULT_FILE_READ_PROGRESS_CHUNK_BYTES = 1024 * 1024;
|
||||
export const DEFAULT_FILE_READ_YIELD_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export type FileReadPhase = "STARTED" | "READING" | "COMPLETED" | "CANCELLED";
|
||||
|
||||
export interface FileReadProgress {
|
||||
phase: FileReadPhase;
|
||||
bytesRead: number;
|
||||
totalBytes: number;
|
||||
fraction: number;
|
||||
}
|
||||
|
||||
export interface FileByteSource {
|
||||
readonly size: number;
|
||||
stream(): ReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface FileByteReadOptions {
|
||||
signal: AbortSignal;
|
||||
onProgress?: (progress: FileReadProgress) => void;
|
||||
onResourceState?: (state: FileByteReadResourceState) => void;
|
||||
yieldControl?: () => Promise<void>;
|
||||
progressChunkBytes?: number;
|
||||
yieldEveryBytes?: number;
|
||||
}
|
||||
|
||||
export interface FileByteReadResourceState {
|
||||
liveReaders: number;
|
||||
liveInputBytes: number;
|
||||
liveStagingFiles: number;
|
||||
}
|
||||
|
||||
export type FileByteReadErrorCode =
|
||||
| "FILE_READ_CANCELLED"
|
||||
| "FILE_READ_ALLOCATION_FAILED"
|
||||
| "FILE_READ_SIZE_MISMATCH";
|
||||
|
||||
export class FileByteReadError extends Error {
|
||||
readonly code: FileByteReadErrorCode;
|
||||
|
||||
constructor(code: FileByteReadErrorCode, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "FileByteReadError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function progress(phase: FileReadPhase, bytesRead: number, totalBytes: number): FileReadProgress {
|
||||
return {
|
||||
phase,
|
||||
bytesRead,
|
||||
totalBytes,
|
||||
fraction: totalBytes === 0 ? (phase === "COMPLETED" ? 1 : 0) : bytesRead / totalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readFileBytes(source: FileByteSource, options: FileByteReadOptions): Promise<ArrayBuffer> {
|
||||
const { signal, onProgress, onResourceState, yieldControl } = options;
|
||||
const totalBytes = source.size;
|
||||
const progressChunkBytes = options.progressChunkBytes ?? DEFAULT_FILE_READ_PROGRESS_CHUNK_BYTES;
|
||||
const yieldEveryBytes = options.yieldEveryBytes ?? DEFAULT_FILE_READ_YIELD_BYTES;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes < 0) {
|
||||
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "file size is not a non-negative safe integer");
|
||||
}
|
||||
if (!Number.isSafeInteger(progressChunkBytes) || progressChunkBytes <= 0 || !Number.isSafeInteger(yieldEveryBytes) || yieldEveryBytes <= 0) {
|
||||
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "progress or yield byte interval is invalid");
|
||||
}
|
||||
|
||||
let outputBuffer: ArrayBuffer;
|
||||
try {
|
||||
outputBuffer = new ArrayBuffer(totalBytes);
|
||||
}
|
||||
catch {
|
||||
throw new FileByteReadError("FILE_READ_ALLOCATION_FAILED", `could not allocate ${totalBytes} bytes`);
|
||||
}
|
||||
const output = new Uint8Array(outputBuffer);
|
||||
|
||||
const reader = source.stream().getReader();
|
||||
onResourceState?.({ liveReaders: 1, liveInputBytes: totalBytes, liveStagingFiles: 0 });
|
||||
let bytesRead = 0;
|
||||
let nextYield = yieldEveryBytes;
|
||||
let aborted = signal.aborted;
|
||||
let completed = false;
|
||||
const abort = (): void => {
|
||||
aborted = true;
|
||||
void reader.cancel("FILE_READ_CANCELLED").catch(() => undefined);
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
onProgress?.(progress("STARTED", 0, totalBytes));
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
|
||||
const { done, value } = await reader.read();
|
||||
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
|
||||
if (done) break;
|
||||
if (!value || bytesRead + value.byteLength > totalBytes) {
|
||||
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", "stream exceeded the declared file size");
|
||||
}
|
||||
for (let offset = 0; offset < value.byteLength; offset += progressChunkBytes) {
|
||||
if (aborted) throw new FileByteReadError("FILE_READ_CANCELLED", `cancelled after ${bytesRead} bytes`);
|
||||
const chunk = value.subarray(offset, Math.min(value.byteLength, offset + progressChunkBytes));
|
||||
output.set(chunk, bytesRead);
|
||||
bytesRead += chunk.byteLength;
|
||||
onProgress?.(progress("READING", bytesRead, totalBytes));
|
||||
if (yieldControl && bytesRead >= nextYield && bytesRead < totalBytes) {
|
||||
nextYield = bytesRead + yieldEveryBytes;
|
||||
await yieldControl();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bytesRead !== totalBytes) {
|
||||
throw new FileByteReadError("FILE_READ_SIZE_MISMATCH", `stream ended at ${bytesRead} of ${totalBytes} bytes`);
|
||||
}
|
||||
onProgress?.(progress("COMPLETED", bytesRead, totalBytes));
|
||||
completed = true;
|
||||
return outputBuffer;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof FileByteReadError && error.code === "FILE_READ_CANCELLED") {
|
||||
onProgress?.(progress("CANCELLED", bytesRead, totalBytes));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
reader.releaseLock();
|
||||
if (!completed) output.fill(0);
|
||||
onResourceState?.({ liveReaders: 0, liveInputBytes: 0, liveStagingFiles: 0 });
|
||||
}
|
||||
}
|
||||
@@ -19,26 +19,256 @@ export interface WebEngineManifest {
|
||||
wasm: WasmResource[];
|
||||
}
|
||||
|
||||
export async function loadWebEngineManifest(url = "/engine-manifest.json"): Promise<WebEngineManifest> {
|
||||
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 Partial<WebEngineManifest>;
|
||||
if (manifest.schemaVersion !== 1 || manifest.protocolVersion !== 1) {
|
||||
throw new Error("Unsupported engine manifest version");
|
||||
}
|
||||
if (!manifest.engine || !manifest.memory || !Array.isArray(manifest.wasm)) {
|
||||
throw new Error("Invalid engine manifest shape");
|
||||
}
|
||||
return manifest as WebEngineManifest;
|
||||
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 async function verifyWasmResource(resource: WasmResource): Promise<void> {
|
||||
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" });
|
||||
if (!response.ok) throw new Error(`WASM resource request failed: ${resource.id}`);
|
||||
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: ${resource.id}`);
|
||||
throw new Error(`WASM resource hash mismatch: ${resourceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ export interface ProgressEvent {
|
||||
operation: string;
|
||||
phase: ProgressPhase;
|
||||
fraction?: number;
|
||||
bytesRead?: number;
|
||||
totalBytes?: number;
|
||||
message?: string;
|
||||
revision?: number;
|
||||
errorCode?: string;
|
||||
stage?: string;
|
||||
cancellable?: boolean;
|
||||
}
|
||||
|
||||
79
web/protocol/project-action-mutex.ts
Normal file
79
web/protocol/project-action-mutex.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export const PROJECT_ACTION_KINDS = ["OPEN", "SAVE", "CLOSE"] as const;
|
||||
export type ProjectActionKind = typeof PROJECT_ACTION_KINDS[number];
|
||||
|
||||
export interface ProjectActionIdentity {
|
||||
actionId: string;
|
||||
kind: ProjectActionKind;
|
||||
}
|
||||
|
||||
export interface ProjectActionMutexState {
|
||||
owner: ProjectActionIdentity | null;
|
||||
}
|
||||
|
||||
export type ProjectActionConflictReason =
|
||||
| "REPEATED_OPEN"
|
||||
| "CONCURRENT_SAVE"
|
||||
| "CLOSE_DURING_SAVE"
|
||||
| "PROJECT_ACTION_EXCLUSIVE";
|
||||
|
||||
export interface ProjectActionConflict {
|
||||
code: "USER_ACTION_CONFLICT";
|
||||
reason: ProjectActionConflictReason;
|
||||
requested: ProjectActionIdentity;
|
||||
owner: ProjectActionIdentity;
|
||||
}
|
||||
|
||||
export type ProjectActionAcquireResult =
|
||||
| { granted: true; state: ProjectActionMutexState; conflict: null }
|
||||
| { granted: false; state: ProjectActionMutexState; conflict: ProjectActionConflict };
|
||||
|
||||
export type ProjectActionReleaseResult =
|
||||
| { released: true; state: ProjectActionMutexState; errorCode: null }
|
||||
| {
|
||||
released: false;
|
||||
state: ProjectActionMutexState;
|
||||
errorCode: "PROJECT_ACTION_LOCK_NOT_HELD" | "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH";
|
||||
};
|
||||
|
||||
export const PROJECT_ACTION_CONFLICT_MATRIX: Readonly<Record<ProjectActionKind, Readonly<Record<ProjectActionKind, boolean>>>> = {
|
||||
OPEN: { OPEN: true, SAVE: true, CLOSE: true },
|
||||
SAVE: { OPEN: true, SAVE: true, CLOSE: true },
|
||||
CLOSE: { OPEN: true, SAVE: true, CLOSE: true },
|
||||
};
|
||||
|
||||
export function createProjectActionMutexState(): ProjectActionMutexState {
|
||||
return { owner: null };
|
||||
}
|
||||
|
||||
function conflictReason(requested: ProjectActionKind, owner: ProjectActionKind): ProjectActionConflictReason {
|
||||
if (requested === "OPEN" && owner === "OPEN") return "REPEATED_OPEN";
|
||||
if (requested === "SAVE" && owner === "SAVE") return "CONCURRENT_SAVE";
|
||||
if (requested === "CLOSE" && owner === "SAVE") return "CLOSE_DURING_SAVE";
|
||||
return "PROJECT_ACTION_EXCLUSIVE";
|
||||
}
|
||||
|
||||
export function acquireProjectAction(state: ProjectActionMutexState, requested: ProjectActionIdentity): ProjectActionAcquireResult {
|
||||
const owner = state.owner;
|
||||
if (!owner) return { granted: true, state: { owner: requested }, conflict: null };
|
||||
if (!PROJECT_ACTION_CONFLICT_MATRIX[requested.kind][owner.kind]) {
|
||||
return { granted: true, state: { owner: requested }, conflict: null };
|
||||
}
|
||||
return {
|
||||
granted: false,
|
||||
state,
|
||||
conflict: {
|
||||
code: "USER_ACTION_CONFLICT",
|
||||
reason: conflictReason(requested.kind, owner.kind),
|
||||
requested,
|
||||
owner,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseProjectAction(state: ProjectActionMutexState, identity: ProjectActionIdentity): ProjectActionReleaseResult {
|
||||
if (!state.owner) return { released: false, state, errorCode: "PROJECT_ACTION_LOCK_NOT_HELD" };
|
||||
if (state.owner.kind !== identity.kind || state.owner.actionId !== identity.actionId) {
|
||||
return { released: false, state, errorCode: "PROJECT_ACTION_LOCK_IDENTITY_MISMATCH" };
|
||||
}
|
||||
return { released: true, state: createProjectActionMutexState(), errorCode: null };
|
||||
}
|
||||
81
web/protocol/save-transaction.ts
Normal file
81
web/protocol/save-transaction.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export const SAVE_ATTEMPT_STAGES = ["SERIALIZE", "OPFS_STAGE", "SCENE_COMMIT", "METADATA_COMMIT"] as const;
|
||||
export type SaveAttemptStage = typeof SAVE_ATTEMPT_STAGES[number];
|
||||
|
||||
export interface SaveCommitIdentity {
|
||||
revision: number;
|
||||
sha256: string | null;
|
||||
}
|
||||
|
||||
export interface SaveTransactionState {
|
||||
status: "IDLE" | "RUNNING" | "SUCCEEDED" | "FAILED";
|
||||
stage: SaveAttemptStage | null;
|
||||
committed: SaveCommitIdentity;
|
||||
candidate: SaveCommitIdentity | null;
|
||||
errorCode: string | null;
|
||||
}
|
||||
|
||||
export type SaveTransactionResult =
|
||||
| { ok: true; state: SaveTransactionState }
|
||||
| { ok: false; state: SaveTransactionState; errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" | "SAVE_TRANSACTION_COMMIT_MISMATCH" };
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function validIdentity(identity: SaveCommitIdentity, allowNullHash: boolean): boolean {
|
||||
return Number.isSafeInteger(identity.revision) && identity.revision >= 0 &&
|
||||
(allowNullHash ? identity.sha256 === null || SHA256.test(identity.sha256) : typeof identity.sha256 === "string" && SHA256.test(identity.sha256));
|
||||
}
|
||||
|
||||
export function createSaveTransactionState(committed: SaveCommitIdentity): SaveTransactionState {
|
||||
if (!validIdentity(committed, true)) throw new Error("SAVE_TRANSACTION_COMMIT_INVALID");
|
||||
return { status: "IDLE", stage: null, committed, candidate: null, errorCode: null };
|
||||
}
|
||||
|
||||
export function beginSaveTransaction(state: SaveTransactionState, targetRevision: number): SaveTransactionResult {
|
||||
if (state.status === "RUNNING" || !Number.isSafeInteger(targetRevision) || targetRevision < state.committed.revision) {
|
||||
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
status: "RUNNING",
|
||||
stage: "SERIALIZE",
|
||||
committed: state.committed,
|
||||
candidate: { revision: targetRevision, sha256: null },
|
||||
errorCode: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceSaveTransaction(state: SaveTransactionState, stage: SaveAttemptStage, candidateSha256?: string): SaveTransactionResult {
|
||||
if (state.status !== "RUNNING" || !state.stage || !state.candidate) {
|
||||
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
|
||||
}
|
||||
const currentIndex = SAVE_ATTEMPT_STAGES.indexOf(state.stage);
|
||||
const nextIndex = SAVE_ATTEMPT_STAGES.indexOf(stage);
|
||||
if (nextIndex !== currentIndex + 1 || (stage === "OPFS_STAGE" && (!candidateSha256 || !SHA256.test(candidateSha256)))) {
|
||||
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
...state,
|
||||
stage,
|
||||
candidate: stage === "OPFS_STAGE" ? { ...state.candidate, sha256: candidateSha256! } : state.candidate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function failSaveTransaction(state: SaveTransactionState, errorCode: string): SaveTransactionState {
|
||||
if (state.status !== "RUNNING" || !/^[A-Z][A-Z0-9_]*$/.test(errorCode)) return state;
|
||||
return { ...state, status: "FAILED", errorCode };
|
||||
}
|
||||
|
||||
export function commitSaveTransaction(state: SaveTransactionState, persisted: SaveCommitIdentity): SaveTransactionResult {
|
||||
if (state.status !== "RUNNING" || state.stage !== "METADATA_COMMIT" || !state.candidate || !validIdentity(persisted, false)) {
|
||||
return { ok: false, state, errorCode: "SAVE_TRANSACTION_INVALID_TRANSITION" };
|
||||
}
|
||||
if (persisted.revision !== state.candidate.revision || persisted.sha256 !== state.candidate.sha256) {
|
||||
return { ok: false, state, errorCode: "SAVE_TRANSACTION_COMMIT_MISMATCH" };
|
||||
}
|
||||
return { ok: true, state: { status: "SUCCEEDED", stage: "METADATA_COMMIT", committed: persisted, candidate: persisted, errorCode: null } };
|
||||
}
|
||||
@@ -192,7 +192,7 @@ export interface StorageRequest {
|
||||
| { type: "smoke" }
|
||||
| { type: "info" }
|
||||
| { type: "ensureProject"; projectId: string }
|
||||
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "quota" }
|
||||
| { type: "saveProject"; projectId: string; revision: number; buffer: ArrayBuffer; faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota" }
|
||||
| { type: "recoverProject"; projectId: string }
|
||||
| { type: "readProject"; projectId: string }
|
||||
| { type: "appendOperation"; id: string; projectId: string; revision: number; payload: unknown; inversePayload?: unknown }
|
||||
|
||||
109
web/protocol/user-action-state.ts
Normal file
109
web/protocol/user-action-state.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export const USER_ACTION_KINDS = ["IMPORT", "OPEN", "SAVE", "SAVE_AS", "EXPORT"] as const;
|
||||
export type UserActionKind = typeof USER_ACTION_KINDS[number];
|
||||
|
||||
export const USER_ACTION_STATUSES = ["IDLE", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"] as const;
|
||||
export type UserActionStatus = typeof USER_ACTION_STATUSES[number];
|
||||
|
||||
export interface UserActionIdentity {
|
||||
actionId: string;
|
||||
kind: UserActionKind;
|
||||
}
|
||||
|
||||
export interface UserActionState {
|
||||
kind: UserActionKind;
|
||||
status: UserActionStatus;
|
||||
identity: UserActionIdentity | null;
|
||||
errorCode: string | null;
|
||||
}
|
||||
|
||||
export type UserActionEvent =
|
||||
| { type: "START"; identity: UserActionIdentity }
|
||||
| { type: "SUCCEED"; identity: UserActionIdentity }
|
||||
| { type: "FAIL"; identity: UserActionIdentity; errorCode: string }
|
||||
| { type: "CANCEL"; identity: UserActionIdentity; errorCode?: string }
|
||||
| { type: "RESET"; kind: UserActionKind };
|
||||
|
||||
export type UserActionTransitionErrorCode =
|
||||
| "USER_ACTION_KIND_MISMATCH"
|
||||
| "USER_ACTION_IDENTITY_MISMATCH"
|
||||
| "USER_ACTION_IDENTITY_REUSED"
|
||||
| "USER_ACTION_INVALID_ERROR_CODE"
|
||||
| "USER_ACTION_INVALID_TRANSITION";
|
||||
|
||||
export type UserActionTransitionResult =
|
||||
| { ok: true; state: UserActionState }
|
||||
| { ok: false; state: UserActionState; errorCode: UserActionTransitionErrorCode };
|
||||
|
||||
export type UserActionStates = Record<UserActionKind, UserActionState>;
|
||||
|
||||
export const USER_ACTION_ALLOWED_TRANSITIONS: Readonly<Record<UserActionStatus, readonly UserActionStatus[]>> = {
|
||||
IDLE: ["RUNNING"],
|
||||
RUNNING: ["SUCCEEDED", "FAILED", "CANCELLED"],
|
||||
SUCCEEDED: ["IDLE", "RUNNING"],
|
||||
FAILED: ["IDLE", "RUNNING"],
|
||||
CANCELLED: ["IDLE", "RUNNING"],
|
||||
};
|
||||
|
||||
function idleState(kind: UserActionKind): UserActionState {
|
||||
return { kind, status: "IDLE", identity: null, errorCode: null };
|
||||
}
|
||||
|
||||
export function createInitialUserActionStates(): UserActionStates {
|
||||
return Object.fromEntries(USER_ACTION_KINDS.map((kind) => [kind, idleState(kind)])) as UserActionStates;
|
||||
}
|
||||
|
||||
function rejected(state: UserActionState, errorCode: UserActionTransitionErrorCode): UserActionTransitionResult {
|
||||
return { ok: false, state, errorCode };
|
||||
}
|
||||
|
||||
function isStableErrorCode(value: string): boolean {
|
||||
return /^[A-Z][A-Z0-9_]*$/.test(value);
|
||||
}
|
||||
|
||||
export function transitionUserAction(state: UserActionState, event: UserActionEvent): UserActionTransitionResult {
|
||||
if (event.type === "RESET") {
|
||||
if (event.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH");
|
||||
if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("IDLE")) {
|
||||
return rejected(state, "USER_ACTION_INVALID_TRANSITION");
|
||||
}
|
||||
return { ok: true, state: idleState(state.kind) };
|
||||
}
|
||||
|
||||
if (event.identity.kind !== state.kind) return rejected(state, "USER_ACTION_KIND_MISMATCH");
|
||||
if (!event.identity.actionId) return rejected(state, "USER_ACTION_IDENTITY_MISMATCH");
|
||||
|
||||
if (event.type === "START") {
|
||||
if (!USER_ACTION_ALLOWED_TRANSITIONS[state.status].includes("RUNNING")) {
|
||||
return rejected(state, "USER_ACTION_INVALID_TRANSITION");
|
||||
}
|
||||
if (state.identity?.actionId === event.identity.actionId) {
|
||||
return rejected(state, "USER_ACTION_IDENTITY_REUSED");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: { kind: state.kind, status: "RUNNING", identity: event.identity, errorCode: null },
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status !== "RUNNING") return rejected(state, "USER_ACTION_INVALID_TRANSITION");
|
||||
if (state.identity?.actionId !== event.identity.actionId) {
|
||||
return rejected(state, "USER_ACTION_IDENTITY_MISMATCH");
|
||||
}
|
||||
|
||||
if (event.type === "SUCCEED") {
|
||||
return { ok: true, state: { ...state, status: "SUCCEEDED", errorCode: null } };
|
||||
}
|
||||
|
||||
const errorCode = event.type === "CANCEL" ? event.errorCode ?? "USER_ACTION_CANCELLED" : event.errorCode;
|
||||
if (!isStableErrorCode(errorCode)) return rejected(state, "USER_ACTION_INVALID_ERROR_CODE");
|
||||
return {
|
||||
ok: true,
|
||||
state: { ...state, status: event.type === "CANCEL" ? "CANCELLED" : "FAILED", errorCode },
|
||||
};
|
||||
}
|
||||
|
||||
export function reduceUserActionStates(states: UserActionStates, event: UserActionEvent): UserActionStates {
|
||||
const kind = event.type === "RESET" ? event.kind : event.identity.kind;
|
||||
const result = transitionUserAction(states[kind], event);
|
||||
return result.ok ? { ...states, [kind]: result.state } : states;
|
||||
}
|
||||
@@ -137,6 +137,8 @@ export type WebEngineEditCommand =
|
||||
export type WebEngineRequest =
|
||||
| { requestId: string; command: { type: "init" } }
|
||||
| { requestId: string; command: { type: "openBlend"; buffer: ArrayBuffer }; }
|
||||
| { requestId: string; command: { type: "cancelOpen"; targetRequestId: string } }
|
||||
| { requestId: string; command: { type: "openResourceStatus" } }
|
||||
| { requestId: string; command: { type: "snapshot" } }
|
||||
| { requestId: string; command: { type: "applyCommand"; payload: WebEngineEditCommand } }
|
||||
| { requestId: string; command: { type: "generateLOD"; payload: LODGenerationRequest } }
|
||||
@@ -154,6 +156,13 @@ export interface WebEngineStatus {
|
||||
allocatedBytes: number;
|
||||
}
|
||||
|
||||
export interface WebEngineOpenResourceStatus {
|
||||
activeRequests: number;
|
||||
liveInputBytes: number;
|
||||
liveNativeHandles: number;
|
||||
liveStagingFiles: number;
|
||||
}
|
||||
|
||||
export interface AssetRequestResult {
|
||||
assetId: string;
|
||||
status: "external" | "packed" | "packed-unavailable" | "missing" | "blocked";
|
||||
@@ -202,6 +211,7 @@ export interface WebEngineResult {
|
||||
capabilityGate?: CapabilityGateResult;
|
||||
depsgraph?: DepsgraphEvaluationIR;
|
||||
blend?: ArrayBuffer;
|
||||
openResources?: WebEngineOpenResourceStatus;
|
||||
}
|
||||
|
||||
export type WebEngineResponse =
|
||||
|
||||
Reference in New Issue
Block a user