Expose portable scene bindings and an explicit React Flow peer adapter

This commit is contained in:
2026-11-09 15:07:18 +08:00
parent bceaf2eca6
commit 196ca4514e
19 changed files with 218 additions and 73 deletions
+33 -4
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, readFile, realpath, rm, writeFile, mkdir, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -17,7 +17,7 @@ try {
const metadata=JSON.parse(await readFile(join(packageRoot,'package.json'),'utf8'));
assert.equal(metadata.private,true);assert.equal(metadata.type,'module');
assert.equal(Object.keys(metadata.dependencies??{}).length,0);
assert.equal(Object.keys(metadata.peerDependencies??{}).length,0);
assert.deepEqual(Object.keys(metadata.peerDependencies??{}).sort(),['@xyflow/react','react','react-dom']);assert.ok(Object.values(metadata.peerDependenciesMeta).every(value=>value.optional===true));
// Resolve every import through the packed artifact, never checkout node_modules.
await writeFile(join(temporary,'guard-loader.mjs'),`
import {fileURLToPath} from 'node:url';
@@ -36,11 +36,12 @@ try {
for(const name of ['window','document','navigator','indexedDB','Worker'])Object.defineProperty(globalThis,name,{configurable:true,get(){throw Error('Browser global: '+name);}});
`);
const report=JSON.parse(run(process.execPath,['--import',join(temporary,'guard.mjs'),join(packageRoot,'consumer.mjs')],{cwd:temporary}));
assert.equal(report.status,'ok');assert.equal(report.reopened,true);assert.equal(report.elements,2);
assert.equal(report.status,'ok');assert.equal(report.portableBindings,true);assert.equal(report.reopened,true);assert.equal(report.elements,2);
// No DOM library: browser adapter declarations must not leak into portable ports.
await writeFile(join(packageRoot,'consumer-types.mts'),`
import {createRegistry,builtinComponents,createDocument} from '@endoleap/headless-canvas/core';
import {openProjectEngine,type StoragePort,type StorageScope,type LoadingState} from '@endoleap/headless-canvas/runtime';
import {projectCanvas,hitProjectedCanvas,type ProjectionNode,type ProjectionEdge} from '@endoleap/headless-canvas/bindings';
import {createFoundryPersistence,type ServiceTransport} from '@endoleap/headless-canvas/service';
const registry=createRegistry(builtinComponents());
export async function completePartial(engine:Awaited<ReturnType<typeof openProjectEngine>>,document:Parameters<typeof openProjectEngine>[0]['document'],versions:ReadonlyMap<string,number>,state:LoadingState){
@@ -60,14 +61,42 @@ try {
const complete:boolean=queries.membersResult(engine.getSnapshot().document.projectId).complete;
return {id,complete,projectComplete:queries.coverage.projectComplete};
}
export function project(engine:Awaited<ReturnType<typeof openProjectEngine>>){const snapshot=engine.getSnapshot(),queries=engine.editor.getQueries();const scene=projectCanvas(snapshot.document,snapshot.document.projectId,registry,new Set(),()=>undefined);const nodes:ProjectionNode[]=scene.nodes,edges:ProjectionEdge[]=scene.edges;return {nodes,edges,hit:hitProjectedCanvas(scene,queries,registry,'view',{space:'scene',point:{x:0,y:0},zoom:1})};}
export function open(scope:StorageScope,storage:StoragePort,transport:ServiceTransport){
return openProjectEngine({scope,storage,registry,document:createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]),versions:new Map([[scope.projectId,1]]),persistence:createFoundryPersistence({registry,transport,rootId:scope.projectId,workspaceId:scope.workspaceId})});
}
`);
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022','--types','node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'consumer-types.mts')],{cwd:temporary});
// A separate consumer opts into framework peers. The portable guard above
// remains strict and has already proved /bindings does not resolve any peers.
await mkdir(join(packageRoot,'node_modules/@xyflow'),{recursive:true});
for(const peer of ['react','react-dom','@xyflow/react'])await symlink(await realpath(join(root,'node_modules',peer)),join(packageRoot,'node_modules',peer),'dir');
await mkdir(join(packageRoot,'node_modules/@types'),{recursive:true});
for(const peer of ['react','react-dom'])await symlink(await realpath(join(root,'node_modules/@types',peer)),join(packageRoot,'node_modules/@types',peer),'dir');
await writeFile(join(packageRoot,'react-consumer.mjs'),`
import assert from 'node:assert/strict';
import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';
import {createReactRenderers,CanvasRendererCommandsContext,useCanvasRendererCommands} from '@endoleap/headless-canvas/react-flow';
import {createRegistry,builtinComponents} from '@endoleap/headless-canvas/core';
const registry=createRegistry(builtinComponents());let dispatched=0,seen;const commands={dispatch(){dispatched++;return{status:'applied',revision:1,operationIds:[]};},transaction(){throw Error('unused');}};
function View(){seen=useCanvasRendererCommands();return React.createElement('span',null,'peer renderer');}
const renderers=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'fixture',render:View}],registry);
const html=renderToStaticMarkup(React.createElement(CanvasRendererCommandsContext.Provider,{value:commands},React.createElement(renderers.nodeTypes.fixture)));
assert.equal(html,'<span>peer renderer</span>');assert.strictEqual(seen,commands);seen.dispatch({type:'element.data',target:{id:'fixture',incarnation:1},patch:{title:'command'}});assert.equal(dispatched,1);console.log('PASS explicit React peer renderer/context consumer');
`);
const react=run(process.execPath,[join(packageRoot,'react-consumer.mjs')],{cwd:temporary});process.stdout.write(react);
await writeFile(join(packageRoot,'react-consumer-types.mts'),`
import {createElement} from 'react';import type {NodeProps,EdgeProps} from '@xyflow/react';
import {createReactRenderers,type ReactFlowProjectionNode,type ReactFlowProjectionEdge} from '@endoleap/headless-canvas/react-flow';
function Card({data}:NodeProps<ReactFlowProjectionNode>){return createElement('div',null,data.title);}
function Link({data}:EdgeProps<ReactFlowProjectionEdge>){return createElement('span',null,data?.__headlessRecord?.id);}
export const adapter=createReactRenderers([{componentId:'text',dataVersion:1,structure:'node',rendererId:'card',render:Card},{componentId:'connection',dataVersion:1,structure:'connection',rendererId:'link',render:Link}]);
`);
run(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'--noEmit','--strict','--target','ES2022','--module','NodeNext','--moduleResolution','NodeNext','--lib','ES2022,DOM,DOM.Iterable','--types','react,react-dom,node','--typeRoots',join(root,'node_modules/@types'),join(packageRoot,'react-consumer-types.mts')],{cwd:temporary});
const browser=run(process.execPath,['tests/headless/browser-storage.mjs','--package']);
process.stdout.write(browser);
console.log(JSON.stringify({compiledBrowserAdapters:true,status:'ok',packedFiles:packed[0].files.length,consumer:report,nodeTypes:true,browserGlobalsBlocked:true,externalRuntimeDependenciesBlocked:true}));
console.log(JSON.stringify({compiledBrowserAdapters:true,portableBindings:true,explicitReactPeers:true,status:'ok',packedFiles:packed[0].files.length,consumer:report,nodeTypes:true,browserGlobalsBlocked:true,externalRuntimeDependenciesBlocked:true}));
} catch(error){
if(error.stdout)process.stderr.write(error.stdout);
if(error.stderr)process.stderr.write(error.stderr);
+3 -3
View File
@@ -1,9 +1,9 @@
"use client";
import { Handle, Position, BaseEdge, getBezierPath, type NodeProps, type EdgeProps } from '@xyflow/react';
import { defineComponent, editable, movable } from '@/lib/canvas-core/index';
import type { ProjectionNode, ProjectionEdge } from '@/lib/canvas-bindings/projection';
import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-renderers';
import { useCanvasRendererCommands } from '@/lib/canvas-bindings/renderer-context';
import type {ReactFlowProjectionNode as ProjectionNode,ReactFlowProjectionEdge as ProjectionEdge} from '@/lib/canvas-bindings/react-flow';
import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-flow';
import { useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow';
function Meter({data}:NodeProps<ProjectionNode>){
const commands=useCanvasRendererCommands(),record=data.__headlessRecord!;
return <div data-renderer-fixture="meter" style={{width:'100%',height:'100%',background:'white',border:'1px solid #ded7ce',padding:16}}>