100 lines
11 KiB
JavaScript
100 lines
11 KiB
JavaScript
/**
|
||
* 独立SDK的第二宿主示例与可执行断言:用自定义counter组件串起命令、事务拒绝、历史、手势、深复制、重开及投影命中。
|
||
* 包导入只使用core/runtime/bindings/service,验证这条路径不需要React、DOM或Next;示例需在已构建SDK包的解析环境执行。
|
||
* 存储与协议transport都是进程内模型,用于演示port契约,不实现生产数据库、身份鉴权、跨进程锁或崩溃持久化。
|
||
*/
|
||
/** A second host: no DOM, React, Next, React Flow or network service required.
|
||
* Storage and protocol-2 transport below are in-memory examples, not a disk or
|
||
* production server implementation. Replace them with your host's real ports.
|
||
*/
|
||
import assert from 'node:assert/strict';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { createDocument, createRegistry, builtinComponents, defineComponent, editable, movable, resizable, rectangleGeometry, applyChanges, validateDocument } from '@endoleap/headless-canvas/core';
|
||
import { openProjectEngine } from '@endoleap/headless-canvas/runtime';
|
||
import {projectCanvas,createRendererCatalog,builtinRendererDefinitions,createGeometryProjectionCache,hitProjectedCanvas} from '@endoleap/headless-canvas/bindings';
|
||
import { createFoundryPersistence, loadFoundryModel } from '@endoleap/headless-canvas/service';
|
||
|
||
// 示例固定账户/工作区/项目作用域;真实宿主应从已验证身份和项目上下文建立隔离范围。
|
||
const scope={accountId:'10000000-0000-4000-8000-000000000001',workspaceId:'10000000-0000-4000-8000-000000000002',projectId:'10000000-0000-4000-8000-000000000003'};
|
||
// 内置组件之外注册counter:count要求非负安全整数,能力与矩形几何显式声明,资源与复制引用列表为空。
|
||
const registry=createRegistry([...builtinComponents(),defineComponent({id:'example.counter',dataVersion:1,structure:'node',validate:data=>typeof data.title==='string'&&Number.isSafeInteger(data.count)&&data.count>=0,features:[editable(),movable(),resizable()],geometry:rectangleGeometry,resourceFields:[],copy:{references:[],omit:[]}})]);
|
||
// 空项目只有根canvas;server/version模拟服务端基线,image模拟本地恢复镜像,两者职责分开。
|
||
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
|
||
let image=null,leader,fence=0,server=original,version=1;
|
||
// files保存示例字节,requests按requestId保存原body和回执,供相同请求重试查询;均在进程结束后丢失。
|
||
const files=new Map(),requests=new Map();
|
||
/** 通过对象身份及active校验本地leader句柄;仅演示单进程fence,不是数据库条件写。 */
|
||
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
|
||
// StoragePort示例:claim取得独占句柄,recover读克隆镜像,commit先准备镜像/文件再一次同步发布。
|
||
const storage={
|
||
/** 只允许一个活跃leader并递增fence;release幂等失效该句柄,不清除已提交镜像。 */
|
||
async claim(requested){assert.equal(leader,undefined);assert.deepEqual(requested,scope);let active=true;const handle={scope:requested,fence:++fence,get active(){return active;},async release(){active=false;if(leader===handle)leader=undefined;}};leader=handle;return handle;},
|
||
/** 返回镜像克隆,调用方不能直接修改存储事实;空image代表首次打开。 */
|
||
async recover(handle){check(handle);return structuredClone(image);},
|
||
/** 先检查文件新增不覆盖和删除列表,准备完成才发布;返回实际镜像rootRevision作为本地确认。 */
|
||
async commit(handle,batch){
|
||
check(handle);const cut=structuredClone(batch.image),nextFiles=new Map(files);
|
||
for(const file of batch.files??[]){assert.equal(nextFiles.has(file.id),false);nextFiles.set(file.id,file.bytes);}
|
||
for(const id of batch.deleteFiles??[])nextFiles.delete(id);
|
||
// Prepare before publishing. This is atomic in this process, not crash-durable storage.
|
||
image=cut;files.clear();for(const entry of nextFiles)files.set(...entry);return{durableRootRevision:cut.rootRevision};
|
||
},
|
||
/** 文件读取也校验leader;close断言所有engine已释放领导权,不负责清理生产存储。 */
|
||
async readFile(handle,id){check(handle);return files.get(id);},close(){assert.equal(leader,undefined);},
|
||
};
|
||
/** 将模拟server的Map转成协议模型数组,带当前版本与墓碑;不把整个engine状态冒充服务端模型。 */
|
||
const model=()=>({documentFormatVersion:1,projectId:scope.projectId,complete:true,canvases:[...server.canvases.values()],elements:[...server.elements.values()],tombstones:Object.fromEntries(server.tombstones),versions:{[scope.projectId]:version},removedCanvasIds:[]});
|
||
/** 最小协议2传输适配:模型GET、请求回执GET及操作POST;未实现任意Foundry端点或HTTP鉴权。 */
|
||
const transport=async input=>{
|
||
input.signal?.throwIfAborted();
|
||
// 请求身份查询没命中时返回unknown,不伪造确定未应用回执;本示例记录不跨进程保存。
|
||
if(input.method==='GET'){
|
||
if(input.path.endsWith('/canvas/model'))return{status:200,body:{model:model()}};
|
||
const id=input.path.split('/').at(-1),known=requests.get(id);
|
||
return known?{status:200,body:known.receipt}:{status:404,body:{protocol_version:2,request_id:id,status:'unknown'}};
|
||
}
|
||
const body=JSON.parse(input.body),known=requests.get(input.requestId);
|
||
assert.equal(body.protocol_version,2);assert.equal(body.request_id,input.requestId);
|
||
// 已有requestId只能复用完全相同的body并返回原回执,防止幂等键承载两种意图。
|
||
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
|
||
// 示例只含一个canvas版本,先要求版本一致,再应用变更并验证整个文档;每次接受使版本递增一次。
|
||
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
|
||
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++;
|
||
// 回执逐一覆盖operations逻辑ID,并携complete=false的模型切片;记录原body方便重试校验。
|
||
const receipt={protocol_version:2,request_id:input.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{...model(),complete:false}};
|
||
requests.set(input.requestId,{body:input.body,receipt});return{status:200,body:receipt};
|
||
};
|
||
// service把transport包成runtime持久化接口;open示例总以初始模型输入,由StoragePort恢复已提交本地状态。
|
||
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:scope.projectId,registry,transport});
|
||
/** 新建engine注入scope/registry/storage/persistence与ID生成器,所有宿主依赖显式传入。 */
|
||
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
|
||
let engine=await open();
|
||
try{
|
||
// 先创建一个counter并settle,后续断言分别验证本地发布及模拟server状态;元素带独立incarnation。
|
||
const target={id:randomUUID(),incarnation:1};
|
||
const element={...target,ownerCanvasId:scope.projectId,componentId:'example.counter',dataVersion:1,structure:'node',data:{title:'Portable counter',count:1},geometry:{x:0,y:0,width:160,height:80,rotation:0}};
|
||
assert.equal(engine.editor.dispatch({type:'element.create',element}).status,'applied');await engine.settle();
|
||
// 非法事务先移动再写负count,最终必须整体rejected且根对象不变,不能泄露事务中间结果。
|
||
const before=engine.getSnapshot();
|
||
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:-1}});}).status,'rejected');assert.strictEqual(engine.getSnapshot(),before);
|
||
// 合法事务将移动和增量组合成一个历史单元;undo/redo之后均等待同步,再检查server的count。
|
||
assert.equal(engine.editor.transaction(tx=>{tx.dispatch({type:'element.geometry',target,patch:{x:32}});tx.dispatch({type:'element.data',target,patch:{count:2}});},{label:'Move and increment'}).status,'applied');await engine.settle();
|
||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,1);
|
||
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(server.elements.get(target.id).data.count,2);
|
||
// 临时手势的有效几何可变化,但cancel后文档仍x=32且请求数不增加,证明预览未被当持久编辑发送。
|
||
engine.sessions.attach('headless-view');const writes=requests.size;
|
||
assert.equal(engine.sessions.begin('headless-view',[target]).enabled,true);engine.sessions.update('headless-view',target,{x:90});assert.equal(engine.sessions.effectiveGeometry(target.id).x,90);engine.sessions.cancel('example-cancel');await engine.settle();assert.equal(requests.size,writes);assert.equal(engine.getSnapshot().document.elements.get(target.id).geometry.x,32);
|
||
// 深复制走imports任务并等待settle,检查任务完成及本地/模拟服务端元素数;随后验证service模型可读。
|
||
const copyId=await engine.imports.duplicate([target],{worldOffset:{x:24,y:24}});await engine.settle();assert.equal(engine.imports.getJobs().get(copyId).status,'completed',JSON.stringify(engine.imports.getJobs().get(copyId)));assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(server.elements.size,2);
|
||
const loaded=await loadFoundryModel(transport,scope.workspaceId,scope.projectId,registry);assert.equal(loaded.status,'ready');assert.equal(loaded.document.elements.size,2);
|
||
// 关闭再打开依赖image恢复内容、历史和ledger;视图属于临时session,重开后不恢复。
|
||
const snapshot=engine.getSnapshot(),undoDepth=snapshot.history.undo.length;await engine.close();engine=await open();await engine.settle();
|
||
assert.equal(engine.getSnapshot().document.elements.size,2);assert.equal(engine.getSnapshot().document.elements.get(target.id).data.count,2);assert.equal(engine.getSnapshot().history.undo.length,undoDepth);assert.equal(engine.sessions.getSnapshot().views.size,0);assert.ok([...engine.getSnapshot().runtime.ledger.operations.values()].every(record=>record.phase==='resolved'));
|
||
// 自定义renderer只声明记录投影,不引入UI组件;配查询与几何缓存生成场景,再验证纯数据命中结果。
|
||
const current=engine.getSnapshot(),queries=engine.editor.getQueries(),renderers=createRendererCatalog([...builtinRendererDefinitions,{componentId:'example.counter',dataVersion:1,structure:'node',rendererId:'counter',projection:'record-v1'}]);
|
||
const projection=projectCanvas(current.document,scope.projectId,registry,new Set(),()=>undefined,null,renderers,{queries,geometry:createGeometryProjectionCache()});assert.equal(projection.nodes.length,2);assert.ok(projection.nodes.every(node=>node.type==='counter'&&node.data.__headlessRecord));
|
||
const hit=hitProjectedCanvas(projection,queries,registry,'portable-view',{space:'scene',point:{x:40,y:10},zoom:1});assert.equal(hit.complete,true);assert.ok(hit.hits.some(hit=>hit.target.id===target.id));
|
||
// 输出可机器读取的结果摘要;finally即使断言失败也关闭engine并验证存储领导权已释放。
|
||
console.log(JSON.stringify({status:'ok',portableBindings:true,elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
|
||
}finally{await engine.close();storage.close();}
|