char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
d1201e2
main
1// Each extension's `web.tsx`, built by vite on its own, apart from the app's bundle, and served for the app to import. 2 3import { decodeBase64 , encodeBase64 } from "@std/encoding/base64" ; 4import { encodeHex } from "@std/encoding/hex" ; 5import { fromFileUrl , join , resolve } from "@std/path" ; 6import type { FrontendSource } from "@berry/api/extension-startup.ts" ; 7import { denoExecutable } from "./deno.ts" ; 8import type { FrontendBuild } from "./web-build.ts" ; 9import { HOST_GLOBAL , HOST_MODULES } from "@berry/api/host.ts" ; 10 11/** Where the app finds an extension's frontend. */ 12export type WebEntry = { module :string ; styles :string [] }; 13 14const TYPES :Record < string , string > = { 15wasm :"application/wasm" , 16svg :"image/svg+xml" , 17png :"image/png" , 18woff2 :"font/woff2" , 19json :"application/json" , 20}; 21 22const contentType = ( file :string ) => 23TYPES [ file . split ( "." ). at ( - 1 ) ?? "" ] ?? "application/octet-stream" ; 24 25async function hash ( text :string ) { 26return encodeHex ( await crypto . subtle . digest ( "SHA-256" , new TextEncoder (). encode ( text ))). slice ( 270 , 2816 , 29); 30} 31 32type CachedBuild = { 33pipeline :string ; 34inputs :[ string , string ][]; 35entry :WebEntry ; 36files :{ path :string ; body :string ; digest :string ; binary :boolean ; type :string }[]; 37}; 38 39const root = fromFileUrl ( new URL ( "../" , import . meta. url )); 40const builder = fromFileUrl ( new URL ( "./web-build.ts" , import . meta. url )); 41const pipelineFiles = [ 42fromFileUrl ( import . meta. url ), 43builder , 44join ( root , "deno.json" ), 45join ( root , "deno.lock" ), 46]; 47// the running build pipeline may predate edits that are waiting for a restart 48const pipeline = await hash ( JSON . stringify ([ 49Deno . version . deno , 50HOST_GLOBAL , 51HOST_MODULES , 52 ...await Promise . all ( pipelineFiles . map (( path ) => Deno . readTextFile ( path ))), 53])); 54 55async function inputs ( paths :readonly string []) { 56return await Promise . all ( 57[ ...new Set ( paths )]. sort (). map ( async ( path ) :Promise <[ string , string ]> => { 58if (( await Deno . stat ( path )). isDirectory ) { 59const entries = []; 60for await ( const entry of Deno . readDir ( path )) { 61entries . push ([ entry . name , entry . isDirectory , entry . isSymlink ]); 62} 63return [ path , await hash ( JSON . stringify ( entries . sort ()))]; 64} 65const bytes = await Deno . readFile ( path ); 66return [ path , encodeHex ( await crypto . subtle . digest ( "SHA-256" , bytes ))]; 67}), 68); 69} 70 71export function extensionWeb ({ cacheDirectory} :{ 72cacheDirectory ?:string ; 73} = {}) { 74/** Built files by url path; a build replaces its extension's files, and content-hashed names never change meaning. */ 75const files = new Map < string , { body :string | Uint8Array < ArrayBuffer >; type :string }>(); 76const paths = new Map < string , string []>(); 77 78return { 79/** Build an extension's frontend; a failed build leaves the previous one in place and rejects. */ 80async build ( 81name :string , 82entry :string , 83onSource ?:( source :FrontendSource ) => void , 84) :Promise < WebEntry > { 85entry = resolve ( entry ); 86const builtAfter = Date . now (); 87const cache = cacheDirectory === undefined ?undefined :join ( 88cacheDirectory , 89` ${ await hash ( JSON . stringify ([ name , entry ]))} .json` , 90); 91if ( cache !== undefined ) { 92try { 93const cached :CachedBuild = JSON . parse ( await Deno . readTextFile ( cache )); 94if ( 95cached . pipeline === pipeline && 96JSON . stringify ( cached . inputs ) === 97JSON . stringify ( await inputs ( cached . inputs . map (([ path ]) => path ))) 98) { 99const restored = await Promise . all ( cached . files . map ( async ( file ) => { 100if ( await hash ( file . body ) !== file . digest ) throw new Error ( "damaged artifact" ); 101return [ file . path , { 102body :file . binary ?decodeBase64 ( file . body ) :file . body , 103type :file . type , 104}] as const ; 105})); 106if ( 107[ cached . entry . module , ...cached . entry . styles ]. some (( path ) => 108! restored . some (([ served ]) => served === path ) 109) 110) throw new Error ( "missing artifact" ); 111for ( const path of paths . get ( name ) ?? []) files . delete ( path ); 112for ( const [ path , file ] of restored ) files . set ( path , file ); 113paths . set ( name , restored . map (([ path ]) => path )); 114onSource ?.( "cache" ); 115return cached . entry ; 116} 117} catch { 118// a missing, stale, or damaged cache is just a rebuild 119} 120} 121onSource ?.( "build" ); 122const { success, code, stdout, stderr} = await new Deno . Command ( denoExecutable (), { 123args :[ "run" , "-A" , "--quiet" , "--config" , join ( root , "deno.json" ), builder , entry ], 124cwd :root , 125stdout :"piped" , 126stderr :"piped" , 127}). output (); 128if ( ! success ) { 129throw new Error ( 130new TextDecoder (). decode ( stderr ). trim () || `frontend build exited with code ${ code } ` , 131); 132} 133const { chunks, assets, dependencies} :FrontendBuild = JSON . parse ( 134new TextDecoder (). decode ( stdout ), 135); 136const css = assets . filter (( o ) => o . fileName . endsWith ( ".css" )) 137. map (( o ) => String ( o . source )) 138. join ( "\n" ); 139 140// chunks import each other by name, so the module keeps its content-hashed one 141const module = `/ext/ ${ name } / ${ chunks . find (( chunk ) => chunk . isEntry ) ! . fileName } ` ; 142const styles = css . trim () === "" ?[] :[ `/ext/ ${ name } / ${ await hash ( css )} .css` ]; 143for ( const path of paths . get ( name ) ?? []) files . delete ( path ); 144const scripts = chunks . map (( chunk ) => { 145const path = `/ext/ ${ name } / ${ chunk . fileName } ` ; 146files . set ( path , { body :chunk . code , type :"text/javascript; charset=utf-8" }); 147return path ; 148}); 149if ( styles . length > 0 ) files . set ( styles [ 0 ], { body :css , type :"text/css; charset=utf-8" }); 150const others = assets . filter (( o ) => ! o . fileName . endsWith ( ".css" )). map (( asset ) => { 151const path = `/ext/ ${ name } / ${ asset . fileName } ` ; 152const body = asset . binary ?decodeBase64 ( asset . source ) :asset . source ; 153files . set ( path , { body, type :contentType ( asset . fileName ) }); 154return path ; 155}); 156paths . set ( name , [ ...scripts , ...styles , ...others ]); 157const built = { module, styles}; 158if ( cache !== undefined ) { 159let temporary :string | undefined ; 160try { 161const fingerprints = await inputs ([ ...pipelineFiles , ...dependencies ]); 162// do not cache a build whose inputs changed while vite was reading them 163const changed = await Promise . all ( 164fingerprints . map ( async ([ path ]) => 165( await Deno . stat ( path )). mtime ! . getTime () > builtAfter 166), 167); 168if ( ! changed . some ( Boolean )) { 169const cached :CachedBuild = { 170 pipeline, 171inputs :fingerprints , 172entry :built , 173files :await Promise . all ( paths . get ( name ) ! . map ( async ( path ) => { 174const { body, type} = files . get ( path ) ! ; 175const encoded = typeof body === "string" ?body :encodeBase64 ( body ); 176return { 177 path, 178body :encoded , 179digest :await hash ( encoded ), 180binary :typeof body !== "string" , 181 type, 182}; 183})), 184}; 185await Deno . mkdir ( cacheDirectory ! , { recursive :true , mode :0o700 }); 186temporary = await Deno . makeTempFile ({ dir :cacheDirectory , prefix :".build-" }); 187await Deno . writeTextFile ( temporary , JSON . stringify ( cached )); 188await Deno . rename ( temporary , cache ); 189} 190} catch ( error ) { 191console . error ( `[berry] ${ name } frontend cache:` , error ); 192} finally { 193if ( temporary !== undefined ) await Deno . remove ( temporary ). catch (() => {}); 194} 195} 196return built ; 197}, 198 199/** Answers a request for a built file; `undefined` for anything else. */ 200serve ( path :string ) :Response | undefined { 201const file = files . get ( path ); 202if ( file === undefined ) return undefined ; 203return new Response ( file . body , { 204headers :{ 205"content-type" :file . type , 206"cache-control" :"public, max-age=31536000, immutable" , 207}, 208}); 209}, 210}; 211} 212 213export type ExtensionWeb = ReturnType < typeof extensionWeb >;