browser-worker.mjs (15403B)
1 import {WASI,File,OpenFile,ConsoleStdout,PreopenDirectory} from './shim/index.js'; 2 import {Session} from './site/io-session.js'; 3 import {constructors,runPrivate} from './site/io-private.js'; 4 import pins,{compilerHash} from './pins.mjs'; 5 import {moduleDependencies} from './site/module-stage.js'; 6 import {fetchBytes} from './site/bundle.js'; 7 import {environmentProfile} from './site/packages.js'; 8 import {frame,authenticatedBundle,loadFields,application,evaluation,snapshotFields,snapshotBundle,exportedTree,environmentFields,emptyEnvironment,dagBytes} from './bridge.mjs'; 9 let instance,booted=false,dead=false,stderr="",storedBundle=null,busy=false,compilerReady=false; 10 const dataFiles=new Map(); 11 let privateRun=0n,privateSession=null; 12 const encoder=new TextEncoder(),decoder=new TextDecoder('utf-8',{fatal:true}); 13 function scalarText(s,limit){ 14 if(typeof s!=='string')throw Error('Expected string'); 15 let count=0; 16 for(const c of s){const n=c.codePointAt(0);if(++count>limit||n>=0xd800&&n<=0xdfff)throw Error('Invalid Unicode or scalar limit');} 17 return s; 18 } 19 async function checkedFile(name,corrupt){ 20 const response=await fetch(new URL('data/'+name,import.meta.url)); 21 if(!response.ok)throw Error('Data fetch failed'); 22 const bytes=new Uint8Array(await response.arrayBuffer()); 23 // Test-only fault injection can corrupt, never replace the trusted pin. 24 if(corrupt&&bytes.length)bytes[0]^=1; 25 const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),b=>b.toString(16).padStart(2,'0')).join(''); 26 if(hash!==pins[name])throw Error('Prepared input SHA-256 mismatch'); 27 return new File(bytes,{readonly:true}); 28 } 29 function call(name,text){ 30 const e=instance.exports; 31 let ptr=0,status; 32 try{ 33 if(text===undefined)status=e[name](); 34 else{ 35 const bytes=typeof text==='string'?encoder.encode(text):text; 36 if(bytes.length>16777216)throw Error('Input byte limit'); 37 ptr=e.malloc(Math.max(bytes.length,1));if(!ptr)throw Error('Allocation failed'); 38 new Uint8Array(e.memory.buffer,ptr,bytes.length).set(bytes); 39 status=e[name](ptr,bytes.length); 40 } 41 }finally{if(ptr)e.free(ptr);} 42 const length=e.tricu_response_size(); 43 if(length<0||length>16777216)throw Error('Invalid response length'); 44 ptr=e.malloc(Math.max(length,1));if(!ptr)throw Error('Allocation failed'); 45 let output; 46 try{ 47 if(e.tricu_response_copy(ptr,length)!==length)throw Error('Response copy failed'); 48 // Refresh the view after every call: memory.grow detaches old views. 49 output=new Uint8Array(e.memory.buffer,ptr,length).slice(); 50 }finally{e.free(ptr);} 51 const decoded=decoder.decode(output); 52 if(status!==0){const error=Error(decoded.replace(/^error\n/,''));error.recoverable=status===1;throw error;} 53 if(!decoded.startsWith('ok\n'))throw Error('Invalid response tag'); 54 return output.subarray(3).slice().buffer; 55 } 56 onmessage=async({data:m})=>{ 57 if(busy){postMessage({id:m.id,ok:false,error:'Worker is busy',fatal:false});return;} 58 busy=true; 59 const begin=performance.now(); 60 try{ 61 if(dead)throw Error('Disposed runtime'); 62 let bytes,phases,scopeNames; 63 const encode=value=>encoder.encode(JSON.stringify(value)).buffer; 64 const operation=fields=>call('tricu_request',frame(fields)); 65 const safe=async action=>{try{return await action();}catch(e){e.recoverable=true;throw e;}}; 66 if(m.type==='init'){ 67 if(booted)throw Error('Already initialized');booted=true; 68 const files=m.compiler===false?[]:await Promise.all(Object.keys(pins).map(async name=>[name,await checkedFile(name,m.corrupt===name)])); 69 for(const [name,file] of files)dataFiles.set(name,file); 70 const fetched=performance.now(); 71 const response=await fetch(new URL('reactor.wasm',import.meta.url)); 72 if(!response.ok)throw Error('WASM fetch failed'); 73 const module=await WebAssembly.compileStreaming(response); 74 const compiled=performance.now(); 75 const wasi=new WASI([],[],[new OpenFile(new File([])),ConsoleStdout.lineBuffered(console.log),ConsoleStdout.lineBuffered(s=>{stderr=(stderr+s+"\n").slice(-4096);}),new PreopenDirectory('/data',dataFiles)]); 76 instance=await WebAssembly.instantiate(module,{wasi_snapshot_preview1:wasi.wasiImport}); 77 wasi.initialize(instance);instance.exports.tricu_rts_init(); 78 const initialized=performance.now(); 79 bytes=call(m.compiler===false?'tricu_initialize_empty':'tricu_initialize'); 80 compilerReady=m.compiler!==false; 81 phases={data_ms:fetched-begin,wasm_fetch_compile_ms:compiled-fetched,instantiate_rts_ms:initialized-compiled,load_ms:performance.now()-initialized}; 82 }else{ 83 if(!instance)throw Error('Not initialized'); 84 if(privateSession&&!['io-session-turn','io-public-view','io-public-clear'].includes(m.type))await safe(()=>{throw Error('Private IO session is pending');}); 85 if(m.type==='compiler-init'){ 86 if(!compilerReady){ 87 const files=await safe(()=>Promise.all(Object.keys(pins).map(async name=>[name,await checkedFile(name)]))); 88 for(const [name,file] of files)dataFiles.set(name,file); 89 bytes=call('tricu_initialize');compilerReady=true; 90 }else bytes=encoder.encode('ready').buffer; 91 }else if(m.type==='io-session-start'){ 92 const args=await safe(()=>application(m.name,m.arguments)); 93 if(privateRun===0xffffffffffffffffn)throw Error('Run identity exhausted'); 94 const start=JSON.parse(decoder.decode(operation(['io-tree-start',...args]))); 95 const invoke=(name,...fields)=>JSON.parse(decoder.decode(operation(['io-tree',name,...fields.map(String)]))); 96 try{ 97 const tree=constructors({leaf:start.leaf,observe:h=>invoke('observe',h),stem:h=>invoke('stem',h),fork:(a,b)=>invoke('fork',a,b),app:(a,b)=>invoke('app',a,b)}); 98 const run=String(++privateRun),session=new Session(tree,start.action,run,roots=>invoke('retain',...new Set(roots))); 99 privateSession={session,clear:()=>invoke('clear')}; 100 const report=await session.turn({run,kind:'poll'});bytes=encode(report); 101 if(report.done){privateSession.clear();privateSession=null;} 102 }catch(error){privateSession=null;try{invoke('clear');}catch{}throw error;} 103 }else if(m.type==='io-public-view'){ 104 await safe(()=>{if(!privateSession?.public||m.run!==privateSession.session.run||privateSession.session.core.result?.kind!=='completed')throw Error('No completed IO result');}); 105 bytes=encode(privateSession.view(m.output)); 106 }else if(m.type==='io-public-clear'){ 107 await safe(()=>{if(!privateSession?.public||m.run!==privateSession.session.run||privateSession.started&&!privateSession.session.core.result)throw Error('HostIO cleanup is pending');}); 108 privateSession.clear();privateSession=null;bytes=encode({}); 109 }else if(m.type==='io-session-turn'){ 110 await safe(()=>{if(!privateSession||m.run!==privateSession.session.run)throw Error('Stale session run');if(!['poll','reply','stop'].includes(m.kind))throw Error('Invalid session command');}); 111 privateSession.started=true; 112 const report=await privateSession.session.turn({run:m.run,kind:m.kind,reply:m.reply});bytes=encode(report); 113 if(report.done&&!privateSession.public){privateSession.clear();privateSession=null;} 114 }else if(m.type==='io-core'){ 115 const args=await safe(()=>application(m.name,m.arguments)); 116 if(privateRun===0xffffffffffffffffn)throw Error('Run identity exhausted'); 117 const start=JSON.parse(decoder.decode(operation(['io-tree-start',...args]))); 118 const invoke=(name,...fields)=>JSON.parse(decoder.decode(operation(['io-tree',name,...fields.map(String)]))); 119 try{ 120 const tree=constructors({leaf:start.leaf,observe:h=>invoke('observe',h),stem:h=>invoke('stem',h),fork:(a,b)=>invoke('fork',a,b),app:(a,b)=>invoke('app',a,b)}); 121 bytes=encode(await runPrivate(tree,start.action,++privateRun,roots=>invoke('retain',...new Set(roots)))); 122 }finally{invoke('clear');} 123 }else if(m.type==='io-header'){ 124 const args=await safe(()=>application(m.name,m.arguments)); 125 bytes=operation(['io-header',...args]); 126 }else if(m.type==='expression'){ 127 await safe(()=>{scalarText(m.source,16384);if(typeof m.name!=='string'||!/^browserExpression[0-9]+$/.test(m.name)||!['pure','io'].includes(m.mode))throw Error('Invalid expression request');}); 128 bytes=operation(['expression',m.name,m.source,m.mode,m.output]); 129 }else if(m.type==='module-header'){ 130 const source=await safe(()=>scalarText(m.source,16384)); 131 bytes=operation(['module-header',source]); 132 }else if(m.type==='compile'){ 133 try { 134 const source=await safe(()=>{const text=scalarText(m.source,16384);if(!text.trim())throw Error('Enter a source unit or native module');return text;}); 135 const stage=JSON.parse(decoder.decode(operation(['module-stage',source]))); 136 const base=await safe(()=>{const url=new URL(m.base??'../../',import.meta.url);if(url.origin!==location.origin)throw Error('Cross-origin site base');return url;}); 137 if(stage.native){ 138 const dependencies=await safe(()=>moduleDependencies(base,stage,m.localModules)); 139 for(const [ref,bundle] of dependencies){ 140 const fields=await safe(()=>['module-dependency',ref,dagBytes(bundle.nodes),...bundle.exports.flatMap(e=>[e.name,String(e.root)])]); 141 operation(fields); 142 } 143 bytes=operation(['module-compile']); 144 }else{ 145 let environment=m.environment; 146 if(m.profileName!==undefined){ 147 environment=await safe(async()=>{ 148 const raw=JSON.parse(decoder.decode(await fetchBytes(new URL('environments.json',base),32768))); 149 const selected=environmentProfile(raw,m.profileName); 150 if(selected.compiler!==compilerHash)throw Error('Demo environment/compiler pin mismatch'); 151 return selected.profile?{bytes:new Uint8Array(await fetchBytes(new URL(`/_arboricx/bundle/hash/${selected.profile.hash}`,base))).buffer,hash:selected.profile.hash,names:selected.profile.names}:null; 152 }); 153 } 154 if(environment===undefined)bytes=call('tricu_compile',source); 155 else{ 156 let fields; 157 if(environment===null)fields=emptyEnvironment(); 158 else{ 159 const env=await safe(()=>authenticatedBundle(environment.bytes,environment.hash)); 160 fields=await safe(()=>environmentFields(env,environment.names)); 161 } 162 bytes=operation(['compile',source,...fields]); 163 } 164 } 165 scopeNames=JSON.parse(decoder.decode(operation(['module-scope']))); 166 storedBundle=null; 167 }catch(error){ 168 if(error.recoverable)operation(['module-abort']); 169 throw error; 170 } 171 }else if(m.type==='load'){ 172 const bundle=await safe(()=>authenticatedBundle(m.bytes,m.hash)); 173 const fields=await safe(()=>loadFields(bundle)); 174 bytes=operation(fields);storedBundle=bundle; 175 }else if(m.type==='probe-io'){ 176 const fields=await safe(()=>{const f=evaluation(m.name,[],'tree');return ['inspect-definition',f[1],'io-envelope'];}); 177 bytes=operation(fields); 178 }else if(m.type==='inspect-definition'){ 179 const fields=await safe(()=>{const f=evaluation(m.name,[],m.output);return ['inspect-definition',f[1],f[2]];}); 180 bytes=operation(fields); 181 }else if(m.type==='evaluate'){ 182 const fields=await safe(()=>evaluation(m.name,m.arguments,m.output)); 183 bytes=operation(fields); 184 }else if(m.type==='module-snapshot'){ 185 bytes=operation(['module-snapshot']); 186 }else if(m.type==='preview'||m.type==='snapshot'){ 187 const fields=await safe(()=>snapshotFields(m.name,m.arguments,m.limit??(m.type==='preview'?64:100000),m.type==='snapshot')); 188 bytes=operation(fields); 189 }else if(m.type==='export'){ 190 if(storedBundle)bytes=encode(await safe(()=>exportedTree(storedBundle,m.name,m.format))); 191 else{ 192 const fields=await safe(()=>snapshotFields(m.name,[],m.limit??100000,true)); 193 const snapshot=JSON.parse(decoder.decode(operation(fields))); 194 bytes=encode(await safe(()=>exportedTree(snapshotBundle(snapshot,m.name),m.name,m.format))); 195 } 196 }else if(m.type==='io-definition-start'){ 197 const fields=await safe(()=>{const f=evaluation(m.name,[],'tree');return ['io-definition-start',f[1]];}); 198 bytes=operation(fields); 199 }else if(m.type==='io-start'){ 200 const args=await safe(()=>application(m.name,m.arguments)); 201 bytes=operation(['io-start',...args]); 202 }else if(m.type==='stats'){ 203 bytes=encode({linearMemoryBytes:instance.exports.memory.buffer.byteLength}); 204 }else if(m.type==='observe'){ 205 // The private wire uses newline-separated names/ternary arguments. 206 if(typeof m.name!=='string'||!m.name||m.name.length>128||/[\r\n]/.test(m.name)||!Array.isArray(m.arguments)||m.arguments.length>16||m.arguments.some(x=>typeof x!=='string'||x.length>50000||!/^[012]+$/.test(x))){const e=Error('Invalid observation request');e.recoverable=true;throw e;} 207 bytes=call('tricu_observe',[m.name,...m.arguments].join('\n')); 208 }else if(['dump','reset','collect'].includes(m.type)){bytes=call('tricu_'+m.type);if(m.type==='reset')storedBundle=null;} 209 else{const e=Error('Unknown operation');e.recoverable=true;throw e;} 210 } 211 if(['io-start','io-definition-start'].includes(m.type)||m.type==='expression'&&m.mode==='io'){ 212 const selected=JSON.parse(decoder.decode(bytes)); 213 if(selected.kind==='v2'){ 214 if(privateRun===0xffffffffffffffffn)throw Error('Run identity exhausted'); 215 const operation=fields=>call('tricu_request',frame(fields)); 216 const invoke=(name,...fields)=>JSON.parse(decoder.decode(operation(['io-tree',name,...fields.map(String)]))); 217 if(m.selectIO!==true){invoke('clear');const error=Error('Unsupported tricuIO version for legacy worker client');error.recoverable=true;throw error;} 218 try{ 219 const start=selected.tree,run=String(++privateRun); 220 const tree=constructors({leaf:start.leaf,observe:h=>invoke('observe',h),stem:h=>invoke('stem',h),fork:(a,b)=>invoke('fork',a,b),app:(a,b)=>invoke('app',a,b)}); 221 const session=new Session(tree,start.action,run,roots=>invoke('retain',...new Set(roots)),{summarize:false,steps:m.ioStep===true?1:128,backend:'haskell-wasm'}); 222 privateSession={session,public:true,started:false,clear:()=>invoke('clear'),view:output=>invoke('view',session.core.result.value,output)}; 223 bytes=encoder.encode(JSON.stringify({kind:'v2',run})).buffer; 224 }catch(error){privateSession=null;try{invoke('clear');}catch{}throw error;} 225 } 226 } 227 postMessage({id:m.id,ok:true,bytes,milliseconds:performance.now()-begin,linearBytes:instance.exports.memory.buffer.byteLength,phases,scopeNames,compilerHash:['init','compiler-init'].includes(m.type)?(compilerReady?compilerHash:null):undefined},[bytes]); 228 }catch(error){ 229 const fatal=m.type==='init'||!error.recoverable; 230 if(fatal)dead=true; 231 postMessage({id:m.id,ok:false,error:error.message+(stderr?"\n"+stderr:""),fatal}); 232 if(fatal)close(); 233 }finally{busy=false;} 234 };