import {validateCanvasElementCounts,type CanvasElementCounts} from '../canvas-runtime/scope-counts.ts'; import type { CanvasRecord, ElementRecord, Id, Registry } from '../canvas-core/types.ts'; import { createDocument, validateDocument } from '../canvas-core/document.ts'; import { fault, freeze, readMap, validJson } from '../canvas-core/values.ts'; import type { CanonicalReadUpdate } from '../canvas-runtime/contracts.ts'; export const modelStreamType = 'application/vnd.foundry.canvas-model+ndjson;version=1'; const maxBytes=64*1024*1024,maxLine=8*1024*1024,maxLines=4096; const uuid=/^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i; const object=(value:unknown):Record=>{if(!value||typeof value!=='object'||Array.isArray(value)||!validJson(value))throw fault('invalid_model_stream');return value as Record;}; /** Bounded NDJSON framing. EOF is required even after a terminal record. */ async function* lines(source:AsyncIterable,signal:AbortSignal){ const decoder=new TextDecoder('utf-8',{fatal:true}); const decode=(bytes?:Uint8Array,stream=false)=>{try{return decoder.decode(bytes,{stream});}catch{throw fault('invalid_model_stream');}}; let pending='',total=0,lineBytes=0,count=0; for await(const bytes of source){ signal.throwIfAborted();if(!(bytes instanceof Uint8Array))throw fault('invalid_model_stream');total+=bytes.byteLength;if(total>maxBytes)throw fault('model_stream_budget'); let start=0; for(let index=0;indexmaxLine)throw fault('model_stream_budget'); pending+=decode(bytes.subarray(start,index),true);pending+=decode();if(++count>maxLines||!pending)throw fault('invalid_model_stream'); let raw:unknown;try{raw=JSON.parse(pending);}catch{throw fault('invalid_model_stream');} yield{raw,byteLength:lineBytes+1};pending='';lineBytes=0;start=index+1; } lineBytes+=bytes.length-start;if(lineBytes>maxLine)throw fault('model_stream_budget');pending+=decode(bytes.subarray(start),true); } signal.throwIfAborted();pending+=decode();if(pending||lineBytes)throw fault('incomplete_model_stream'); } export async function* decodeModelStream(source:AsyncIterable,canvasId:Id,registry:Registry,signal:AbortSignal){ let sequence=0,token='',projectId='',expected=0,ended=false; let terminal: {sequence:number;snapshotToken:string;projectId:string;versions:ReadonlyMap;complete:boolean;byteLength:number;coverage:string[];value:CanonicalReadUpdate}|undefined; let canvases:CanvasRecord[]=[],versions:ReadonlyMap=new Map(),tombstones:ReadonlyMap=new Map(); let canvasElementCounts:CanvasElementCounts|undefined;const ownerCounts=new Map(); const elements:ElementRecord[]=[],ids=new Set(),canvasIds=new Set(); for await(const line of lines(source,signal)){ const raw=object(line.raw); if(ended||raw.streamVersion!==1||raw.sequence!==sequence++||typeof raw.snapshotToken!=='string'||!raw.snapshotToken||raw.snapshotToken.length>256)throw fault('invalid_model_stream'); if(sequence===1){ if(raw.kind!=='header'||raw.documentFormatVersion!==1||typeof raw.projectId!=='string'||!uuid.test(raw.projectId)||!Number.isSafeInteger(raw.elementCount)||(raw.elementCount as number)<0||!Array.isArray(raw.canvases)||raw.canvases.length>256)throw fault('invalid_model_stream'); token=raw.snapshotToken;projectId=raw.projectId;expected=raw.elementCount as number;canvases=raw.canvases as CanvasRecord[]; for(const canvas of canvases){object(canvas);if(typeof canvas.id!=='string'||!uuid.test(canvas.id)||canvasIds.has(canvas.id))throw fault('invalid_model_stream');canvasIds.add(canvas.id);} if(!canvasIds.has(canvasId)||!canvasIds.has(projectId))throw fault('project_mismatch'); const versionMap=new Map(),dead=new Map(); for(const [id,value]of Object.entries(object(raw.versions))){if(!uuid.test(id)||!Number.isSafeInteger(value)||(value as number)<1)throw fault('invalid_canonical_version');versionMap.set(id,value as number);} if([...canvasIds].some(id=>!versionMap.has(id)))throw fault('invalid_model_stream'); if(!Array.isArray(raw.removedCanvasIds)||new Set(raw.removedCanvasIds).size!==raw.removedCanvasIds.length||raw.removedCanvasIds.some(id=>typeof id!=='string'||canvasIds.has(id)||!versionMap.has(id))||versionMap.size!==canvasIds.size+raw.removedCanvasIds.length)throw fault('invalid_model_stream'); for(const [id,value]of Object.entries(object(raw.tombstones))){if(!id||!Number.isSafeInteger(value)||(value as number)<1)throw fault('invalid_model_stream');dead.set(id,value as number);} versions=readMap(versionMap);tombstones=readMap(dead); if(Object.hasOwn(raw,'canvasElementCounts')){validateCanvasElementCounts(raw.canvasElementCounts,[...canvasIds],expected);canvasElementCounts=freeze({...raw.canvasElementCounts});} const value:CanonicalReadUpdate=freeze({status:'receiving',...(canvasElementCounts?{canvasElementCounts}:{}),projectId,canvases,elements:[],tombstones,versions,receivedElements:0,totalElements:expected}); yield{sequence:0,snapshotToken:token,projectId,versions,complete:false,byteLength:line.byteLength,coverage:['model-header'],value};continue; } if(raw.snapshotToken!==token)throw fault('load_snapshot_changed'); if(raw.kind==='elements'){ if(!Array.isArray(raw.elements)||!raw.elements.length||raw.elements.length>64||elements.length+raw.elements.length>expected)throw fault('invalid_model_stream'); for(const item of raw.elements){const element=object(item) as unknown as ElementRecord;if(typeof element.id!=='string'||!element.id||ids.has(element.id)||!canvasIds.has(element.ownerCanvasId))throw fault('invalid_model_stream');const count=(ownerCounts.get(element.ownerCanvasId)??0)+1;if(canvasElementCounts&&count>canvasElementCounts[element.ownerCanvasId])throw fault('invalid_model_coverage');ownerCounts.set(element.ownerCanvasId,count);ids.add(element.id);elements.push(element);} const value:CanonicalReadUpdate=freeze({status:'receiving',projectId,canvases:[],elements:raw.elements as ElementRecord[],tombstones:readMap(new Map()),versions,receivedElements:elements.length,totalElements:expected}); yield{sequence:sequence-1,snapshotToken:token,projectId,versions,complete:false,byteLength:line.byteLength,coverage:raw.elements.map(item=>`element:${(item as ElementRecord).id}`),value}; }else if(raw.kind==='complete'){ if(raw.elementCount!==expected||elements.length!==expected)throw fault('incomplete_model_stream'); const document=createDocument(projectId,canvases,elements,tombstones);validateDocument(document,registry);ended=true; const value:CanonicalReadUpdate=Object.freeze({status:'ready',document,versions}); terminal={sequence:sequence-1,snapshotToken:token,projectId,versions,complete:true,byteLength:line.byteLength,coverage:[...canvasIds].map(id=>`canvas:${id}`),value}; }else throw fault('invalid_model_stream'); } if(!ended||!terminal)throw fault('incomplete_model_stream'); yield terminal; }