Package portable headless runtime with independent Node and browser consumers

This commit is contained in:
2026-11-09 01:53:53 +08:00
parent 8219209284
commit 0205f24904
20 changed files with 320 additions and 29 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
.next/
.headless-dist/
.env*
!.env.example
*.tsbuildinfo
+36
View File
@@ -0,0 +1,36 @@
# Headless Canvas: portable ESM preview
Build from the Endoleap checkout with `npm run build:headless`. The generated
`.headless-dist` directory is a private package; nothing is published to a registry.
Copy it elsewhere, or use `npm pack --offline --ignore-scripts` inside that directory.
Public exports:
| Import | Responsibility |
| --- | --- |
| `@endoleap/headless-canvas` or `/core` | Document, registry, capabilities, transactions, history, sessions, geometry and queries |
| `/runtime` | Project/Workspace/Host, storage and service port types, recovery, scoped loader and compute/display pools |
| `/service` | Foundry protocol codecs and transport adapters; credentials remain in the host |
| `/browser` | Explicit IndexedDB/Web Locks and module Worker adapters |
The portable entries have no React, Next, React Flow or other runtime package
dependency. They use standard platform APIs such as AbortController, Blob, URL,
TextEncoder and Web Crypto. The sample runs on Node 22+. Browser adapters require
their browser APIs; importing `/runtime` does not import the browser adapter.
Run `node consumer.mjs` inside the generated package. It uses a custom counter
component, rejects an atomic invalid transaction, commits edits, undoes/redoes,
cancels a gesture preview, deep-copies through the runtime, and reopens the engine.
The storage and protocol-2 transport are in-memory examples. Reopening demonstrates
the codec and host contract, not cross-process crash durability or a deployed server.
`npm run test:headless:package` builds, packs/unpacks into an isolated directory,
blocks framework imports and browser-only globals, runs the sample, checks Node
TypeScript consumers without the DOM library, and exercises compiled browser adapters
and module Worker URLs in isolated Chromium. The browser checks load the emitted JS
directly, without TypeScript stripping or a Next bundler.
This preview does not yet package the React renderer/bindings. Their legacy
projection DTO types still require an explicit adapter boundary. No registry
publication, release support promise, server deployment or full-goal completion is
implied by this local package.
+64
View File
@@ -0,0 +1,64 @@
/** 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 { 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'));
console.log(JSON.stringify({status:'ok',elements:2,count:2,undoDepth,reopened:true,transientViews:0,protocol:2,requests:requests.size,sourceFiles:files.size}));
}finally{await engine.close();storage.close();}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@endoleap/headless-canvas",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"exports": {
".": { "types": "./modules/canvas-core/index.d.ts", "import": "./modules/canvas-core/index.js" },
"./core": { "types": "./modules/canvas-core/index.d.ts", "import": "./modules/canvas-core/index.js" },
"./runtime": { "types": "./modules/canvas-runtime/index.d.ts", "import": "./modules/canvas-runtime/index.js" },
"./browser": { "types": "./modules/canvas-runtime/browser.d.ts", "import": "./modules/canvas-runtime/browser.js" },
"./service": { "types": "./modules/canvas-service/index.d.ts", "import": "./modules/canvas-service/index.js" }
},
"files": ["modules", "consumer.mjs", "README.md"]
}
+3 -1
View File
@@ -11,7 +11,9 @@
"test:headless:browser": "node tests/headless/browser-storage.mjs",
"test:headless:proxy": "node tests/headless/proxy.integration.mjs",
"test:headless:renderer": "node tests/headless/renderer.integration.mjs",
"test:headless:performance": "node tests/headless/renderer.integration.mjs --performance"
"test:headless:performance": "node tests/headless/renderer.integration.mjs --performance",
"build:headless": "node scripts/build-headless.mjs",
"test:headless:package": "node tests/headless/package.integration.mjs"
},
"dependencies": {
"@radix-ui/react-dialog": "1.3.1",
+30
View File
@@ -0,0 +1,30 @@
import { execFileSync } from 'node:child_process';
import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join, resolve } from 'node:path';
const root=resolve(fileURLToPath(new URL('..',import.meta.url))),output=join(root,'.headless-dist');
await rm(output,{recursive:true,force:true});
execFileSync(process.execPath,[join(root,'node_modules/typescript/bin/tsc'),'-p',join(root,'tsconfig.headless.json')],{cwd:root,stdio:'inherit'});
async function declarations(directory){
for(const entry of await readdir(directory,{withFileTypes:true})){
const path=join(directory,entry.name);
if(entry.isDirectory())await declarations(path);
else if(entry.name.endsWith('.d.ts')){
const source=await readFile(path,'utf8');
// Native TS rewrites runtime imports, but leaves source suffixes in declarations.
// Only module specifiers change; data/schema string literals remain intact.
const next=source.replace(/((?:from\s+|import\s*\()\s*['"])(\.\.?\/[^'"]+)\.ts(['"])/g,'$1$2.js$3');
await writeFile(path,next);
}
}
}
await declarations(join(output,'modules'));
for(const [module,worker]of [['browser-digests','digest.worker'],['browser-images','image.worker']]){
const path=join(output,'modules/canvas-runtime',`${module}.js`),source=await readFile(path,'utf8'),before=`./${worker}.ts`;
if(source.split(before).length!==2)throw Error(`Unexpected Worker URL in ${module}`);
await readFile(join(output,'modules/canvas-runtime',`${worker}.js`));
await writeFile(path,source.replace(before,`./${worker}.js`));
}
for(const name of ['package.json','consumer.mjs','README.md'])await cp(join(root,'headless',name),join(output,name));
console.log('Built .headless-dist: ESM, declarations, browser adapters and Worker modules');
@@ -6,11 +6,8 @@ import { createReactRenderers, type CanvasRuntimeExtension } from "@/lib/canvas-
import { notify } from "@/lib/notifications";
import { errorText } from "@/lib/api";
import { createRegistry, builtinComponents } from "@/lib/canvas-core/index";
import { createRuntimeHost, type RuntimeHost } from "@/lib/canvas-runtime/host";
import { createWorkspaceRuntime } from "@/lib/canvas-runtime/workspace";
import { createBrowserDigests } from "@/lib/canvas-runtime/browser-digests";
import {createBrowserImageDecoder} from "@/lib/canvas-runtime/browser-images";
import { createBrowserStorage } from "@/lib/canvas-runtime/storage";
import { createRuntimeHost, createWorkspaceRuntime, type RuntimeHost } from "@/lib/canvas-runtime/index";
import { createBrowserDigests, createBrowserImageDecoder, createBrowserStorage } from "@/lib/canvas-runtime/browser";
import { createFoundryAssets, createFoundryPersistence, createFoundryTransport, createFoundryRuns,createFoundryModelLoader } from "@/lib/canvas-service/index";
const Context = createContext<RuntimeHost | null>(null);
+4
View File
@@ -0,0 +1,4 @@
/** Opt-in browser adapters. Importing the portable runtime does not load these. */
export { createBrowserStorage } from './storage.ts';
export { createBrowserDigests } from './browser-digests.ts';
export { createBrowserImageDecoder } from './browser-images.ts';
+1 -1
View File
@@ -31,7 +31,7 @@ import { createScheduler, type Clock, type OperationStatus } from './scheduler.t
import { planConflictRecovery, type ConflictPlan, type RecoveryChoice } from './conflicts.ts';
import { stageAppliedRecovery } from './applied-recovery.ts';
import { conflictsWith, maxResourceBytes, operationFootprint, referencedResources, type AssetPort, type ResourceRecord, type ResourceHandle, type StagedFile } from './resources.ts';
import type { StoragePort, StorageScope } from './storage.ts';
import type { StoragePort, StorageScope } from './storage-port.ts';
const digestFailures=new Set(['digest_capacity','digest_worker_failed','digest_worker_unavailable','invalid_digest_response','digest_pool_closed']);
export type EngineOptions = {
+24
View File
@@ -0,0 +1,24 @@
/** Portable runtime entry. Browser adapters are an explicit separate entry. */
export { openProjectEngine } from './engine.ts';
export type { EngineOptions, RecoverySession } from './engine.ts';
export { createWorkspaceRuntime } from './workspace.ts';
export type { WorkspaceOptions, WorkspaceRuntime, ProjectEngine } from './workspace.ts';
export { createRuntimeHost } from './host.ts';
export type { RuntimeHost } from './host.ts';
export type * from './contracts.ts';
export type * from './storage-port.ts';
export type { AssetPort, ResourceRecord, ResourceHandle, StagedFile, StoredAsset, DisplayHandle } from './resources.ts';
export type { RunPort, RunRecord, RunPage, RunEvent, RunStatus, RunModel } from './runs.ts';
export type { FrozenRequest, LedgerSnapshot, OperationRecord, Presence } from './ledger.ts';
export type { Clock, OperationStatus, CompiledRequest } from './scheduler.ts';
export type { ImportJob, ImportSource, ImportLine, ImportUnit, ImportFailure, NativeImportSource } from './imports.ts';
export type { RecoveryChoice, ConflictPlan, RecoveryAudit } from './conflicts.ts';
export { encodeRecovery, decodeRecovery } from './recovery.ts';
export { createScopedLoader, loadQueryKey } from './loader.ts';
export type { LoadScope, LoadChunk, LoadSnapshot, LoadPort, LoadHandle, ScopedLoader } from './loader.ts';
export { createDisplayCache } from './display-cache.ts';
export type { DisplayCache, DisplaySource, DisplayLease } from './display-cache.ts';
export { createDigestPool } from './digest-pool.ts';
export type { DigestPort } from './digest-pool.ts';
export { createImageDecodePool } from './image-decoder.ts';
export type { ImageDecoderPort, ImageDimensions, ImageDecodeState } from './image-decoder.ts';
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Json } from '../canvas-core/types.ts';
import { fault, freeze, validJson } from '../canvas-core/values.ts';
import type { RecoveryImage } from './storage.ts';
import type { RecoveryImage } from './storage-port.ts';
type Encoded = {value:Json|undefined;height:number;reusable:boolean};
const immutable=new WeakMap<object,Encoded>(),sealed=new WeakSet<object>();
+1 -1
View File
@@ -16,7 +16,7 @@ import type { EngineSnapshot, RuntimeState } from './contracts.ts';
import type { FrozenRequest, LedgerSnapshot, OperationRecord } from './ledger.ts';
import { validateRunInput, type RunRecord } from './runs.ts';
import type { ResourceRecord } from './resources.ts';
import type { RecoveryImage } from './storage.ts';
import type { RecoveryImage } from './storage-port.ts';
const documentValue = (document: Document) => ({ projectId: document.projectId, canvases: [...document.canvases.values()], elements: [...document.elements.values()], tombstones: [...document.tombstones] });
/** Plain records only: functions, credentials, renderer objects, URLs and Map methods never serialize. */
+18
View File
@@ -0,0 +1,18 @@
import type { Json } from '../canvas-core/types.ts';
/** Portable durable-store contract. Browser database types belong to the adapter. */
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] };
export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> };
export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string};
export type FileInventoryPage={readonly files:readonly StoredFileInfo[];readonly rootRevision:number;readonly nextAfter?:string};
export type StoragePort = {
scanFiles?(leader:StorageLeader,input:{readonly expectedRootRevision:number;readonly after?:string;readonly limit:number}):Promise<FileInventoryPage>;
readonly fileDeletion?: 'atomic-v1';
claim(scope: StorageScope): Promise<StorageLeader>;
recover(leader: StorageLeader): Promise<RecoveryImage | null>;
commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>;
readFile(leader: StorageLeader, id: string): Promise<Blob | undefined>;
close(): void;
};
+2 -15
View File
@@ -3,21 +3,8 @@ import { isSealedRecoveryImage } from './recovery-encoding.ts';
import type { Json } from '../canvas-core/types.ts';
import { clone, equal, fault, freeze, validJson } from '../canvas-core/values.ts';
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] };
export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> };
export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string};
export type FileInventoryPage={readonly files:readonly StoredFileInfo[];readonly rootRevision:number;readonly nextAfter?:string};
export type StoragePort = {
scanFiles?(leader:StorageLeader,input:{readonly expectedRootRevision:number;readonly after?:string;readonly limit:number}):Promise<FileInventoryPage>;
readonly fileDeletion?: 'atomic-v1';
claim(scope: StorageScope): Promise<StorageLeader>;
recover(leader: StorageLeader): Promise<RecoveryImage | null>;
commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>;
readFile(leader: StorageLeader, id: string): Promise<Blob | undefined>;
close(): void;
};
import type {StorageScope,RecoveryImage,DurableBatch,StorageLeader,StoredFileInfo,FileInventoryPage,StoragePort} from './storage-port.ts';
export type {StorageScope,RecoveryImage,DurableBatch,StorageLeader,StoredFileInfo,FileInventoryPage,StoragePort} from './storage-port.ts';
type Metadata = { fence: number; throughRevision?: number; checkpointRevision?: number };
type StoredImage = { readonly image: RecoveryImage; readonly checksum: string };
type StoredDelta = {journalVersion:1;baseRevision:number;baseChecksum:string;header:Omit<RecoveryImage,'payload'>;patches:JournalPatch[];checksum:string};
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Document, Id, Registry } from '../canvas-core/types.ts';
import { fault, readMap } from '../canvas-core/values.ts';
import { openProjectEngine, type EngineOptions } from './engine.ts';
import {createDigestPool,type DigestPort} from './digest-pool.ts';
import type { StoragePort } from './storage.ts';
import type { StoragePort } from './storage-port.ts';
import { createDisplayCache } from './display-cache.ts';
export type ProjectEngine = Awaited<ReturnType<typeof openProjectEngine>>;
+5 -3
View File
@@ -8,6 +8,7 @@ import { stripTypeScriptTypes } from 'node:module';
import { setTimeout as pause } from 'node:timers/promises';
const root = resolve(import.meta.dirname, '../..'), profile = await mkdtemp(join(tmpdir(), 'endoleap-storage-browser-'));
const packageMode=process.argv.includes('--package');
const readStreams=new Map();
const server = createServer(async (request, response) => {
try {
@@ -25,8 +26,9 @@ const server = createServer(async (request, response) => {
response.setHeader('Content-Type','application/json');response.end(JSON.stringify({reads:state.reads}));return;
}
if (path === '/') { response.setHeader('Content-Type', 'text/html'); response.end('<title>Headless storage checks</title>'); return; }
const filename = resolve(root, `.${path}`);
if (!filename.startsWith(`${root}/src/lib/`) && !filename.startsWith(`${root}/tests/headless/`)) { response.writeHead(404).end(); return; }
const isPackage=packageMode&&path.startsWith('/package/');
const filename = isPackage?resolve(root,'.headless-dist',path.slice('/package/'.length)):resolve(root, `.${path}`);
if (!(isPackage&&filename.startsWith(`${root}/.headless-dist/`)) && !filename.startsWith(`${root}/src/lib/`) && !filename.startsWith(`${root}/tests/headless/`)) { response.writeHead(404).end(); return; }
let source = await readFile(filename, 'utf8');
if (filename.endsWith('.ts')) source = stripTypeScriptTypes(source);
response.setHeader('Content-Type', 'text/javascript'); response.end(source);
@@ -56,7 +58,7 @@ try {
await pause(50);
}
if (!loaded) throw new Error('browser test origin did not finish loading');
const result = await call('Runtime.evaluate', { expression: "import('/tests/headless/browser-storage-cases.mjs').then(module => module.run())", awaitPromise: true, returnByValue: true, timeout: 30000 });
const result = await call('Runtime.evaluate', { expression: `import('/tests/headless/${packageMode?'package-browser-cases':'browser-storage-cases'}.mjs').then(module => module.run())`, awaitPromise: true, returnByValue: true, timeout: 30000 });
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
for (const message of result.result.value) console.log(`PASS ${message}`);
} finally {
+32
View File
@@ -0,0 +1,32 @@
/** Imports compiled ESM directly: no TypeScript stripping or application bundler. */
import {createRegistry,builtinComponents,createDocument} from '/package/modules/canvas-core/index.js';
import {openProjectEngine} from '/package/modules/canvas-runtime/index.js';
import {createFoundryPersistence} from '/package/modules/canvas-service/index.js';
import {createBrowserStorage,createBrowserDigests,createBrowserImageDecoder} from '/package/modules/canvas-runtime/browser.js';
export async function run(){
const assert=(condition,message)=>{if(!condition)throw Error(message);},results=[];
const name=`headless-package-${crypto.randomUUID()}`,storage=createBrowserStorage({name}),digests=createBrowserDigests(),images=createBrowserImageDecoder();
const scope={accountId:crypto.randomUUID(),workspaceId:crypto.randomUUID(),projectId:crypto.randomUUID()},registry=createRegistry(builtinComponents()),document=createDocument(scope.projectId,[{id:scope.projectId,parent:null}],[]);
const workerUrls=[],NativeWorker=globalThis.Worker;let active=0;
globalThis.Worker=class extends NativeWorker{constructor(url,...args){super(url,...args);workerUrls.push(String(url));active++;this.live=true;}terminate(){if(this.live){this.live=false;active--;}super.terminate();}};
let engine;
try{
const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});
const dimensions=await images.decode(bytes);assert(dimensions.width===4&&dimensions.height===2,'compiled image Worker dimensions');
const digest=await digests.digest(bytes),expected=[...new Uint8Array(await crypto.subtle.digest('SHA-256',await bytes.arrayBuffer()))].map(value=>value.toString(16).padStart(2,'0')).join('');assert(digest===expected,'compiled digest Worker SHA256');
assert(workerUrls.some(url=>url.endsWith('/image.worker.js'))&&workerUrls.some(url=>url.endsWith('/digest.worker.js')),'Worker URLs did not resolve emitted JS');assert(active===0,'compiled Workers leaked');
results.push('compiled image and digest module Workers load emitted JS, decode a real PNG, match native SHA256 and terminate');
// Keep networking explicitly held: verify the actual browser durability adapter
// independently of an invented backend protocol implementation.
const persistence={...createFoundryPersistence({registry,rootId:scope.projectId,workspaceId:scope.workspaceId,transport:async()=>{throw Error('unexpected network');}}),supportsRequest:()=>false};
const open=()=>openProjectEngine({scope,registry,document,versions:new Map([[scope.projectId,1]]),storage,persistence,createId:()=>crypto.randomUUID()});
engine=await open();
const target={id:crypto.randomUUID(),incarnation:1};
assert(engine.editor.dispatch({type:'element.create',element:{...target,ownerCanvasId:scope.projectId,componentId:'text',dataVersion:1,structure:'node',data:{title:'Packed recovery',text:'persisted'},geometry:{x:12,y:24,width:160,height:80,rotation:0}}}).status==='applied','compiled command rejected');
engine.sessions.attach('temporary-view');
await engine.settle();const revision=engine.getSnapshot().rootRevision;await engine.close();engine=await open();
assert(engine.getSnapshot().rootRevision>=revision,'compiled IndexedDB recovery revision regressed');assert(engine.getSnapshot().document.elements.get(target.id)?.data.text==='persisted','compiled durable document lost');assert(engine.getSnapshot().history.undo.length===1,'compiled durable history lost');assert(engine.sessions.getSnapshot().views.size===0,'transient sessions persisted');await engine.settle();
results.push('compiled browser storage and engine claim/release Web Locks and recover an edited document/history from IndexedDB without persisting views');
return results;
}finally{await engine?.close();images.close();digests.close();storage.close();globalThis.Worker=NativeWorker;indexedDB.deleteDatabase(name);}
}
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
const temporary=await realpath(await mkdtemp(join(tmpdir(),'endoleap-headless-package-')));
const run=(program,args,options={})=>execFileSync(program,args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe'],...options});
try {
run(process.execPath,['scripts/build-headless.mjs']);
const packed=JSON.parse(run('npm',['pack','--offline','--ignore-scripts','--json','--pack-destination',temporary],{cwd:join(root,'.headless-dist'),env:{...process.env,npm_config_cache:join(temporary,'npm-cache')}}));
assert.equal(packed.length,1);
run('tar',['-xzf',join(temporary,packed[0].filename),'-C',temporary]);
const packageRoot=join(temporary,'package');
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);
// Resolve every import through the packed artifact, never checkout node_modules.
await writeFile(join(temporary,'guard-loader.mjs'),`
import {fileURLToPath} from 'node:url';
import {sep} from 'node:path';
const root=${JSON.stringify(packageRoot)};
export async function resolve(specifier,context,next){
if(specifier.startsWith('node:'))return next(specifier,context);
const result=await next(specifier,context);
if(!result.url.startsWith('file:')||!fileURLToPath(result.url).startsWith(root+sep))throw Error('External dependency: '+specifier);
return result;
}
`);
await writeFile(join(temporary,'guard.mjs'),`
import {register} from 'node:module';
register('./guard-loader.mjs',import.meta.url);
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);
// 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} from '@endoleap/headless-canvas/runtime';
import {createFoundryPersistence,type ServiceTransport} from '@endoleap/headless-canvas/service';
const registry=createRegistry(builtinComponents());
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});
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}));
} catch(error){
if(error.stdout)process.stderr.write(error.stdout);
if(error.stderr)process.stderr.write(error.stderr);
throw error;
} finally {
await rm(temporary,{recursive:true,force:true});
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"rootDir": "src/lib",
"outDir": ".headless-dist/modules",
"noEmit": false,
"declaration": true,
"incremental": false,
"rewriteRelativeImportExtensions": true,
"types": [],
"plugins": []
},
"include": ["src/lib/canvas-core/**/*.ts", "src/lib/canvas-runtime/**/*.ts", "src/lib/canvas-service/**/*.ts"],
"exclude": ["node_modules", ".headless-dist"]
}
+2 -1
View File
@@ -37,6 +37,7 @@
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
"node_modules",
".headless-dist"
]
}