Package portable headless runtime with independent Node and browser consumers
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.headless-dist/
|
||||
.env*
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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.
|
||||
@@ -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();}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,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>();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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};
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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
@@ -37,6 +37,7 @@
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
"node_modules",
|
||||
".headless-dist"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user