69 lines
7.7 KiB
JavaScript
69 lines
7.7 KiB
JavaScript
/** 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'};
|
|
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:[]}})]);
|
|
const original=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
|
|
let image=null,leader,fence=0,server=original,version=1;
|
|
const files=new Map(),requests=new Map();
|
|
const check=handle=>{assert.strictEqual(handle,leader);assert.equal(handle.active,true);};
|
|
const storage={
|
|
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;},
|
|
async recover(handle){check(handle);return structuredClone(image);},
|
|
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};
|
|
},
|
|
async readFile(handle,id){check(handle);return files.get(id);},close(){assert.equal(leader,undefined);},
|
|
};
|
|
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:[]});
|
|
const transport=async input=>{
|
|
input.signal?.throwIfAborted();
|
|
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);
|
|
if(known){assert.equal(known.body,input.body);return{status:200,body:known.receipt};}
|
|
assert.deepEqual(body.expected_versions,{[scope.projectId]:version});
|
|
server=applyChanges(server,body.operations.flatMap(operation=>operation.changes));validateDocument(server,registry);version++;
|
|
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};
|
|
};
|
|
const persistence=createFoundryPersistence({workspaceId:scope.workspaceId,rootId:scope.projectId,registry,transport});
|
|
const open=()=>openProjectEngine({scope,registry,document:original,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:randomUUID});
|
|
let engine=await open();
|
|
try{
|
|
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();
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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'));
|
|
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));
|
|
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();}
|