Files
Endoleap/src/lib/canvas-runtime/host.ts
T

54 lines
3.7 KiB
TypeScript

import type { Id, Registry } from '../canvas-core/types.ts';
import { fault, readMap } from '../canvas-core/values.ts';
import type { WorkspaceRuntime } from './workspace.ts';
import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.ts';
import type { CanonicalReadResult } from './contracts.ts';
/** Host lifetime matches the signed-in application, independently of navigation. */
export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort<CanonicalReadResult>; onError?: (error: unknown) => void }) {
let accountId: Id | null = null, authEpoch = 0, disposed = false;
const workspaces = new Map<Id, WorkspaceRuntime>(), retired = new Set<WorkspaceRuntime>();
const readers = new Map<Id, ScopedLoader<CanonicalReadResult>>();
const closeRetired = async () => {
const results = await Promise.allSettled([...retired].map(async runtime => { await runtime.close(); retired.delete(runtime); }));
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
if (errors.length) throw new AggregateError(errors, 'host_close_failed');
};
function retire() { authEpoch++; accountId = null; for (const reader of readers.values()) reader.close();readers.clear();for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); }
function activate(account: Id) {
if (disposed) throw fault('host_disposed');
if (accountId !== account) {
retire(); accountId = account;
void closeRetired().catch(error => { try { options.onError?.(error); } catch { /* Reporting cannot change scope. */ } });
}
}
return Object.freeze({
registry: options.registry,
bindAccount(account: Id) { activate(account); const epoch = authEpoch; return () => !disposed && accountId === account && authEpoch === epoch; },
identity: () => Object.freeze({ accountId, authEpoch }),
getWorkspaces: () => readMap(workspaces),
async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number}={}) {
activate(account);
const readEpoch=authEpoch;
if(!options.modelLoader)throw fault('model_loader_unavailable');
let reader=readers.get(workspace);
if(!reader){const epoch=authEpoch,current=()=>!disposed&&accountId===account&&authEpoch===epoch;reader=createScopedLoader({load:options.modelLoader(account,workspace,epoch,current),current:()=>current(),maxCacheEntries:0});readers.set(workspace,reader);}
const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal});
try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete)throw fault('incomplete_load');return last.value;}finally{handle.release();}
},
workspace(account: Id, workspace: Id) {
activate(account);
let runtime = workspaces.get(workspace);
if (!runtime) {
const epoch = authEpoch;
runtime = options.workspace(account, workspace, epoch, () => !disposed && accountId === account && authEpoch === epoch);
workspaces.set(workspace, runtime);
}
return runtime;
},
retireIdentity() { retire(); return closeRetired(); },
close() { disposed = true; retire(); return closeRetired(); },
});
}
export type RuntimeHost = ReturnType<typeof createRuntimeHost>;