char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
e880b1f
main
1// One headless firefox per load, driven over webdriver bidi: started on first use, one tab per call, closed when idle. 2 3import { mkdtemp , rm } from "node:fs/promises" ; 4import { tmpdir } from "node:os" ; 5import { join } from "node:path" ; 6 7type BidiMessage = { 8id ?:number ; 9type ?:string ; 10result ?:unknown ; 11error ?:string ; 12message ?:string ; 13}; 14 15type Session = { 16request < R > ( method :string , params :object ) :Promise < R >; 17readonly closed :Promise < void >; 18close () :Promise < void >; 19}; 20 21const COMMAND_TIMEOUT_MS = 30_000 ; 22 23async function launch ( profiles :string ) :Promise < Session > { 24const profile = await mkdtemp ( join ( profiles , "berry-firefox-" )); 25const child = new Deno . Command ( "firefox" , { 26args :[ 27"--headless" , 28"--no-remote" , 29"--profile" , 30profile , 31"--remote-debugging-port" , 32"0" , 33"about:blank" , 34], 35stdin :"null" , 36stdout :"null" , 37stderr :"piped" , 38}). spawn (); 39const exited = child . status . then (() => rm ( profile , { recursive :true , force :true })); 40 41// keep draining stderr, or firefox blocks on a full pipe 42const endpoint = await new Promise < string >(( resolve , reject ) => { 43const timeout = setTimeout ( 44() => reject ( new Error ( "Firefox did not start within 10 seconds" )), 4510_000 , 46); 47let seen = "" ; 48void ( async () => { 49for await ( const chunk of child . stderr . pipeThrough ( new TextDecoderStream ())) { 50seen = ( seen + chunk ). slice ( - 16_384 ); 51const match = seen . match ( / WebDriver BiDi listening on (ws:\/\/\S+) / ); 52if ( match ) { 53clearTimeout ( timeout ); 54resolve ( ` ${ match [ 1 ]} /session` ); 55} 56} 57clearTimeout ( timeout ); 58reject ( new Error ( seen . trim () || "Firefox exited before starting" )); 59})(); 60}); 61 62const socket = new WebSocket ( endpoint ); 63await new Promise < void >(( resolve , reject ) => { 64socket . addEventListener ( "open" , () => resolve (), { once :true }); 65socket . addEventListener ( "error" , () => reject ( new Error ( "Could not connect to Firefox" )), { 66once :true , 67}); 68}); 69 70let nextId = 0 ; 71const pending = new Map < number , { resolve ( value :unknown ) :void ; reject ( error :Error ) :void }>(); 72socket . addEventListener ( "message" , ( event ) => { 73const message = JSON . parse ( String ( event . data )) as BidiMessage ; 74const request = message . id === undefined ?undefined :pending . get ( message . id ); 75if ( request === undefined ) return ; 76pending . delete ( message . id ! ); 77if ( message . type === "success" ) request . resolve ( message . result ); 78else request . reject ( new Error ( message . message || message . error || "Firefox command failed" )); 79}); 80const closed = new Promise < void >(( resolve ) => 81socket . addEventListener ( "close" , () => { 82for ( const request of pending . values ()) { 83request . reject ( new Error ( "Firefox connection closed" )); 84} 85pending . clear (); 86resolve (); 87}) 88); 89 90const session :Session = { 91request < R > ( method :string , params :object ) { 92const id = ++ nextId ; 93return new Promise < R >(( resolve , reject ) => { 94const timeout = setTimeout (() => { 95pending . delete ( id ); 96reject ( new Error ( `Firefox command ${ method } timed out` )); 97}, COMMAND_TIMEOUT_MS ); 98pending . set ( id , { 99resolve :( value ) => ( clearTimeout ( timeout ), resolve ( value as R )), 100reject :( error ) => ( clearTimeout ( timeout ), reject ( error )), 101}); 102socket . send ( JSON . stringify ({ id, method, params})); 103}); 104}, 105 closed, 106async close () { 107socket . close (); 108try { 109child . kill (); 110} catch { 111// already gone 112} 113const killer = setTimeout (() => { 114try { 115child . kill ( "SIGKILL" ); 116} catch { 117// already gone 118} 119}, 2_000 ); 120await exited ; 121clearTimeout ( killer ); 122}, 123}; 124await session . request ( "session.new" , { 125capabilities :{ alwaysMatch :{ timeouts :{ pageLoad :25_000 , script :25_000 } } }, 126}); 127return session ; 128} 129 130/** `work`, unless `signal` aborts first; then the abort's reason, at once. */ 131function raced < T > ( work :Promise < T >, signal ?:AbortSignal ) :Promise < T > { 132if ( signal === undefined ) return work ; 133return new Promise (( resolve , reject ) => { 134const abort = () => reject ( signal . reason ); 135// it may have fired already, while the browser was starting 136if ( signal . aborted ) abort (); 137signal . addEventListener ( "abort" , abort , { once :true }); 138work . then ( resolve , reject ). finally (() => signal . removeEventListener ( "abort" , abort )); 139}); 140} 141 142export type Browser = { 143/** Load `url` in a fresh tab and return the json value `expression` resolves to there. */ 144evaluate < T > ( url :string , expression :string , signal ?:AbortSignal ) :Promise < T >; 145close () :Promise < void >; 146}; 147 148/** `profiles` is where each browser keeps its profile while it runs. */ 149export function createBrowser ({ idleMs= 60_000 , profiles= tmpdir () } = {}) :Browser { 150let session :Promise < Session > | undefined ; 151let active = 0 ; 152let idle :ReturnType < typeof setTimeout > | undefined ; 153 154function acquire () :Promise < Session > { 155if ( session === undefined ) { 156const started = launch ( profiles ); 157session = started ; 158// a crashed or closed browser starts afresh on the next call 159started . then (( s ) => s . closed . then (() => session === started && ( session = undefined )), () => { 160if ( session === started ) session = undefined ; 161}); 162} 163return session ; 164} 165 166let closing = Promise . resolve (); 167/** Close the running browser; a later call waits for an earlier one instead of finding nothing to do. */ 168function shutdown () { 169const current = session ; 170session = undefined ; 171closing = closing . then ( async () => await ( await current ?.catch (() => undefined ))?. close ()); 172return closing ; 173} 174 175return { 176async evaluate < T > ( url :string , expression :string , signal ?:AbortSignal ) :Promise < T > { 177signal ?. throwIfAborted (); 178active ++ ; 179clearTimeout ( idle ); 180try { 181const browser = await acquire (); 182const { context} = await browser . request <{ context :string }>( "browsingContext.create" , { 183type :"tab" , 184}); 185const work = ( async () => { 186await browser . request ( "browsingContext.navigate" , { context, url, wait :"complete" }); 187const evaluation = await browser . request < 188| { type :"success" ; result :{ type :string ; value ?:unknown } } 189| { type :"exception" ; exceptionDetails :{ text :string } } 190>( "script.evaluate" , { 191 expression, 192target :{ context}, 193awaitPromise :true , 194userActivation :false , 195resultOwnership :"none" , 196}); 197if ( evaluation . type === "exception" ) throw new Error ( evaluation . exceptionDetails . text ); 198if ( evaluation . result . type !== "string" ) { 199throw new Error ( `Firefox returned ${ evaluation . result . type } , not text` ); 200} 201return JSON . parse ( evaluation . result . value as string ) as T ; 202})(); 203try { 204return await raced ( work , signal ); 205} finally { 206// closing the tab also stops whatever an abandoned call is still doing in it 207const closing = browser . request ( "browsingContext.close" , { context}). catch (() => {}); 208if ( ! signal ?. aborted ) await closing ; 209} 210} finally { 211if ( -- active === 0 ) { 212idle = setTimeout ( shutdown , idleMs ); 213Deno . unrefTimer ( idle ); 214} 215} 216}, 217async close () { 218clearTimeout ( idle ); 219await shutdown (); 220}, 221}; 222}