140 lines
5.1 KiB
TypeScript
140 lines
5.1 KiB
TypeScript
import type { WebEngineVariantV2 } from "../../../protocol/manifest";
|
|
import type {
|
|
EngineVariantResourceState,
|
|
EngineVariantSession,
|
|
} from "./engine-variant-bootstrap";
|
|
import { EngineVariantLoadError } from "./engine-variant-bootstrap";
|
|
|
|
interface EmscriptenPThreadRuntime {
|
|
unusedWorkers: Worker[];
|
|
runningWorkers: Worker[];
|
|
terminateAllThreads(): void;
|
|
}
|
|
|
|
interface BrowserVariantModule {
|
|
HEAPU8: Uint8Array;
|
|
_malloc(size: number): number;
|
|
_free(pointer: number): void;
|
|
_web_engine_create(): number;
|
|
_web_engine_destroy(handle: number): void;
|
|
_web_engine_get_live_handles(): number;
|
|
_web_engine_open_blend(handle: number, data: number, length: number): number;
|
|
PThread?: EmscriptenPThreadRuntime;
|
|
}
|
|
|
|
type BrowserVariantFactory = (options: { wasmBinary: ArrayBuffer }) => Promise<BrowserVariantModule>;
|
|
|
|
async function verifiedBytes(url: string, expectedSha256: string, controllers: Set<AbortController>): Promise<ArrayBuffer> {
|
|
const controller = new AbortController();
|
|
controllers.add(controller);
|
|
try {
|
|
const response = await fetch(url, { cache: "no-store", signal: controller.signal });
|
|
if (!response.ok) {
|
|
throw new EngineVariantLoadError(
|
|
"ENGINE_VARIANT_RESOURCE_REQUEST_FAILED",
|
|
`${url} returned ${response.status}`,
|
|
);
|
|
}
|
|
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 !== expectedSha256) {
|
|
throw new EngineVariantLoadError("ENGINE_VARIANT_RESOURCE_HASH_MISMATCH", url);
|
|
}
|
|
return bytes;
|
|
}
|
|
finally {
|
|
controllers.delete(controller);
|
|
}
|
|
}
|
|
|
|
class BrowserEngineVariantSession implements EngineVariantSession<ArrayBuffer> {
|
|
readonly variant: WebEngineVariantV2;
|
|
private readonly module: BrowserVariantModule;
|
|
private readonly pendingRequests: Set<AbortController>;
|
|
private readonly timers = new Set<ReturnType<typeof setTimeout>>();
|
|
private handle: number;
|
|
|
|
constructor(
|
|
variant: WebEngineVariantV2,
|
|
module: BrowserVariantModule,
|
|
handle: number,
|
|
pendingRequests: Set<AbortController>,
|
|
) {
|
|
this.variant = variant;
|
|
this.module = module;
|
|
this.handle = handle;
|
|
this.pendingRequests = pendingRequests;
|
|
}
|
|
|
|
async openProject(project: ArrayBuffer): Promise<void> {
|
|
if (this.handle <= 0) throw new Error("ENGINE_VARIANT_SESSION_DISPOSED");
|
|
const pointer = this.module._malloc(project.byteLength);
|
|
if (project.byteLength > 0 && pointer <= 0) throw new Error("ENGINE_VARIANT_PROJECT_ALLOCATION_FAILED");
|
|
try {
|
|
if (project.byteLength > 0) this.module.HEAPU8.set(new Uint8Array(project), pointer);
|
|
const result = this.module._web_engine_open_blend(this.handle, pointer, project.byteLength);
|
|
if (result !== 0) throw new Error(`ENGINE_VARIANT_PROJECT_OPEN_FAILED: ${result}`);
|
|
}
|
|
finally {
|
|
if (pointer > 0) this.module._free(pointer);
|
|
}
|
|
}
|
|
|
|
resourceState(): EngineVariantResourceState {
|
|
const pthread = this.module.PThread;
|
|
return {
|
|
handles: this.handle > 0 ? this.module._web_engine_get_live_handles() : 0,
|
|
workers: pthread ? pthread.unusedWorkers.length + pthread.runningWorkers.length : 0,
|
|
timers: this.timers.size,
|
|
pendingRequests: this.pendingRequests.size,
|
|
};
|
|
}
|
|
|
|
testOnlySeedTrackedResources(): void {
|
|
this.pendingRequests.add(new AbortController());
|
|
const timer = setTimeout(() => this.timers.delete(timer), 60_000);
|
|
this.timers.add(timer);
|
|
}
|
|
|
|
async dispose(): Promise<EngineVariantResourceState> {
|
|
for (const controller of this.pendingRequests) controller.abort();
|
|
this.pendingRequests.clear();
|
|
for (const timer of this.timers) clearTimeout(timer);
|
|
this.timers.clear();
|
|
if (this.handle > 0) {
|
|
this.module._web_engine_destroy(this.handle);
|
|
this.handle = 0;
|
|
}
|
|
this.module.PThread?.terminateAllThreads();
|
|
return this.resourceState();
|
|
}
|
|
}
|
|
|
|
export async function createBrowserEngineVariantSession(
|
|
variant: WebEngineVariantV2,
|
|
): Promise<EngineVariantSession<ArrayBuffer>> {
|
|
const controllers = new Set<AbortController>();
|
|
const verifiedResources = new Map<string, ArrayBuffer>();
|
|
const verify = async (resource: { url: string; sha256: string }): Promise<ArrayBuffer> => {
|
|
const existing = verifiedResources.get(resource.url);
|
|
if (existing) return existing;
|
|
const bytes = await verifiedBytes(resource.url, resource.sha256, controllers);
|
|
verifiedResources.set(resource.url, bytes);
|
|
return bytes;
|
|
};
|
|
await verify(variant.resources.js);
|
|
const wasmBinary = await verify(variant.resources.wasm);
|
|
if (variant.id === "pthread") await verify(variant.resources.pthreadWorker);
|
|
const imported = await import(/* @vite-ignore */ variant.resources.js.url) as { default: BrowserVariantFactory };
|
|
const module = await imported.default({ wasmBinary });
|
|
const handle = module._web_engine_create();
|
|
if (handle <= 0) {
|
|
module.PThread?.terminateAllThreads();
|
|
throw new Error("ENGINE_VARIANT_HANDLE_CREATION_FAILED");
|
|
}
|
|
return new BrowserEngineVariantSession(variant, module, handle, controllers);
|
|
}
|