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

85 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 应用级运行宿主,生命周期跨越页面导航;同一账号下复用 workspace runtime 和共享读取器。
* 账号/退出登录变化先递增 authEpoch 使旧回调失效,再异步关闭退休 runtime;关闭失败仍保留退休实例供重试。
* registry 是共享定义,工作文档归 project engine,读取预览也不能成为第二份可写文档。
*/
import {createModelPreview,type LiveModelPreview} from './model-preview.ts';
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 { CanonicalReadUpdate } from './contracts.ts';
/** Host lifetime matches the signed-in application, independently of navigation. */
/** 宿主注入 workspace/loader 工厂;current 闭包绑定精确账号与 epoch,作为异步结果准入围栏。 */
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<CanonicalReadUpdate>; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) {
let accountId: Id | null = null, authEpoch = 0, disposed = false;
// active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。
const workspaces = new Map<Id, WorkspaceRuntime>(), retired = new Set<WorkspaceRuntime>();
const readers = new Map<Id, ScopedLoader<CanonicalReadUpdate>>();
/** 并行尝试关闭退休实例,成功才移出集合;汇总失败而不提前释放失败实例。 */
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');
};
/** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */
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(); }
/** 账号变化才退休旧作用域;后台关闭错误走 onError,报告失败不能改变新账号的身份事实。 */
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({
/** 只读共享定义,不含各 workspace 的可变运行状态。 */
registry: options.registry,
/** 返回绑定当前 epoch 的校验函数;同账号退出再登录也因 epoch 变化而使旧函数失效。 */
bindAccount(account: Id) { activate(account); const epoch = authEpoch; return () => !disposed && accountId === account && authEpoch === epoch; },
/** 捕获当前身份摘要,供导航/宿主检查,不携带认证凭证。 */
identity: () => Object.freeze({ accountId, authEpoch }),
/** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */
getWorkspaces: () => readMap(workspaces),
/** 通过共享 scoped loader 读 canonical 源;增量可产生只读预览,terminal 完整后才返回最终模型。 */
async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) {
activate(account);
const readEpoch=authEpoch;
if(!options.modelLoader)throw fault('model_loader_unavailable');
let reader=readers.get(workspace);
// 同 workspace 复用并发读取;maxCacheEntries=0 不把一次已完成读取当成长期授权缓存。
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);}
// 每个订阅者自己解算预览/seen 游标,观察者异常只终止该预览,不更改共享读取事实。
const preview=settings.onPreview?createModelPreview(options.registry):undefined;let seen=0,previewFailed=false;let declaredCanvasIds:readonly Id[]=[];
/** 先检查身份/取消,再消费尚未处理的块;头部范围证据决定 loading 版本与可用覆盖范围。 */
const onUpdate=(state:import('./loader.ts').LoadSnapshot<CanonicalReadUpdate>)=>{
if(!preview||previewFailed||disposed||accountId!==account||authEpoch!==readEpoch||settings.signal?.aborted)return;
try{for(;seen<state.chunks.length;seen++){const chunk=state.chunks[seen],value=preview.update(chunk.value);if(chunk.value.status==='receiving'&&value){if(chunk.sequence===0)declaredCanvasIds=Object.freeze(chunk.value.canvases.map(canvas=>canvas.id));settings.onPreview?.(Object.freeze({...value,versions:readMap(new Map(Object.entries(chunk.serverVersions))),loading:Object.freeze({...(value.canvasElementCounts?value.elementOwners?{version:6 as const,canvasElementCounts:value.canvasElementCounts,elementOwners:value.elementOwners,sourceCanvases:value.sourceCanvases!}:{version:3 as const,canvasElementCounts:value.canvasElementCounts}:{version:1 as const}),sourceToken:chunk.snapshotToken,declaredCanvasIds,receivedElements:value.receivedElements,totalElements:value.totalElements})}));}}}
catch(error){previewFailed=true;try{options.onObserverError?.(error);}catch{}}
};
// key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。
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,onUpdate});
// ready 后再查身份和 terminal/complete;finally 始终释放本订阅,不能泄漏 reader 引用。
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||last.value.status==='receiving')throw fault('incomplete_load');return last.value.status==='ready'?Object.freeze({...last.value,sourceToken:last.snapshotToken}):last.value;}finally{handle.release();preview?.close();}
},
/** 按当前账号激活并懒创建 workspace;导航到同工作区不会重新创建资源池或项目。 */
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;
},
/** 退出身份但保留 host 可再次绑定;返回旧 runtime 收尾 Promise,让宿主处理未保存错误。 */
retireIdentity() { retire(); return closeRetired(); },
/** 永久关闭 host 并退休全部作用域;失败仍可再次调用 close 重试退休集合。 */
close() { disposed = true; retire(); return closeRetired(); },
});
}
/** 公共宿主 API 由工厂返回值推导,避免接口声明与实现两份维护。 */
export type RuntimeHost = ReturnType<typeof createRuntimeHost>;