diff --git a/.gitignore b/.gitignore index 91240fc..2dbfd3b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .next/ +.headless-dist/ .env* !.env.example *.tsbuildinfo diff --git a/headless/README.md b/headless/README.md new file mode 100644 index 0000000..b639fb0 --- /dev/null +++ b/headless/README.md @@ -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. diff --git a/headless/consumer.mjs b/headless/consumer.mjs new file mode 100644 index 0000000..010d801 --- /dev/null +++ b/headless/consumer.mjs @@ -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();} diff --git a/headless/package.json b/headless/package.json new file mode 100644 index 0000000..a689e9f --- /dev/null +++ b/headless/package.json @@ -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"] +} diff --git a/package.json b/package.json index e52ce7d..a138389 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-headless.mjs b/scripts/build-headless.mjs new file mode 100644 index 0000000..adc366d --- /dev/null +++ b/scripts/build-headless.mjs @@ -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'); diff --git a/src/components/workspace/canvas-runtime-host.tsx b/src/components/workspace/canvas-runtime-host.tsx index bc2aeee..9109df5 100644 --- a/src/components/workspace/canvas-runtime-host.tsx +++ b/src/components/workspace/canvas-runtime-host.tsx @@ -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(null); diff --git a/src/lib/canvas-runtime/browser.ts b/src/lib/canvas-runtime/browser.ts new file mode 100644 index 0000000..f0bb8fe --- /dev/null +++ b/src/lib/canvas-runtime/browser.ts @@ -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'; diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 0ba5097..6c06065 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.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 = { diff --git a/src/lib/canvas-runtime/index.ts b/src/lib/canvas-runtime/index.ts new file mode 100644 index 0000000..c5c9c3d --- /dev/null +++ b/src/lib/canvas-runtime/index.ts @@ -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'; diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts index 9f11e76..2274448 100644 --- a/src/lib/canvas-runtime/recovery-encoding.ts +++ b/src/lib/canvas-runtime/recovery-encoding.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(),sealed=new WeakSet(); diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index 2b6538b..fd8d432 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -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. */ diff --git a/src/lib/canvas-runtime/storage-port.ts b/src/lib/canvas-runtime/storage-port.ts new file mode 100644 index 0000000..cd3d6b0 --- /dev/null +++ b/src/lib/canvas-runtime/storage-port.ts @@ -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 }; +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; + readonly fileDeletion?: 'atomic-v1'; + claim(scope: StorageScope): Promise; + recover(leader: StorageLeader): Promise; + commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>; + readFile(leader: StorageLeader, id: string): Promise; + close(): void; +}; diff --git a/src/lib/canvas-runtime/storage.ts b/src/lib/canvas-runtime/storage.ts index b04d50c..8252a57 100644 --- a/src/lib/canvas-runtime/storage.ts +++ b/src/lib/canvas-runtime/storage.ts @@ -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 }; -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; - readonly fileDeletion?: 'atomic-v1'; - claim(scope: StorageScope): Promise; - recover(leader: StorageLeader): Promise; - commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>; - readFile(leader: StorageLeader, id: string): Promise; - 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;patches:JournalPatch[];checksum:string}; diff --git a/src/lib/canvas-runtime/workspace.ts b/src/lib/canvas-runtime/workspace.ts index 1cb45da..e8374a7 100644 --- a/src/lib/canvas-runtime/workspace.ts +++ b/src/lib/canvas-runtime/workspace.ts @@ -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>; diff --git a/tests/headless/browser-storage.mjs b/tests/headless/browser-storage.mjs index 4061dcf..79cb99a 100644 --- a/tests/headless/browser-storage.mjs +++ b/tests/headless/browser-storage.mjs @@ -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('Headless storage checks'); 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 { diff --git a/tests/headless/package-browser-cases.mjs b/tests/headless/package-browser-cases.mjs new file mode 100644 index 0000000..92429ce --- /dev/null +++ b/tests/headless/package-browser-cases.mjs @@ -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);} +} diff --git a/tests/headless/package.integration.mjs b/tests/headless/package.integration.mjs new file mode 100644 index 0000000..fee29e6 --- /dev/null +++ b/tests/headless/package.integration.mjs @@ -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}); +} diff --git a/tsconfig.headless.json b/tsconfig.headless.json new file mode 100644 index 0000000..e7db69b --- /dev/null +++ b/tsconfig.headless.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json index 854f429..2fdbbcc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + ".headless-dist" ] }