char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
42f80d8
main
1import { inflate , oidBytes , parseCommit , parseLoose , parseTag , parseTree , toHex } from "./codec.ts" ; 2import type { Pack } from "./pack.ts" ; 3import * as j from "@char/justin" ; 4import { 5type Commit , 6type GitInfo , 7GitInfoSchema , 8type GitObject , 9type TreeEntry , 10} from "./types.ts" ; 11 12/** 13* fetch a repo-site-relative path (e.g. `.git/objects/info/packs`). 14* `range` is [start, end) into the resource, end=null meaning to-end; 15* `query` sends a QUERY request with an application/json body; 16* `total` reports the full resource size. resolves null on 404. 17*/ 18export type Fetcher = ( 19path :string , 20range ?:[ number , number | null ], 21query ?:Uint8Array < ArrayBuffer >, 22) => Promise <{ bytes :Uint8Array ; total :number } | null >; 23 24export class HttpError extends Error { 25constructor ( readonly status :number , path :string ) { 26super ( `fetching ${ path } : HTTP ${ status } ` ); 27} 28} 29 30export interface TransferProgress { 31id :number ; 32path :string ; 33loaded :number ; 34total ?:number ; 35phase :"start" | "progress" | "done" ; 36} 37 38export const httpFetcher = ( 39base :string , 40progress ?:( transfer :TransferProgress ) => void , 41signal ?:() => AbortSignal , 42) :Fetcher => { 43let nextId = 0 ; 44return ( path , range , query ) => new Promise (( resolve , reject ) => { 45const active = signal ?.(); 46if ( active ?. aborted ) return reject ( new Error ( `fetching ${ path } : aborted` )); 47const id = nextId ++ ; 48const request = new XMLHttpRequest (); 49const onAbort = () => request . abort (); 50active ?. addEventListener ( "abort" , onAbort ); 51request . onloadend = () => active ?. removeEventListener ( "abort" , onAbort ); 52request . open ( query ?"QUERY" :"GET" , ` ${ base } / ${ path } ` ); 53request . responseType = "arraybuffer" ; 54if ( query ) { 55request . setRequestHeader ( "Content-Type" , "application/json" ); 56request . setRequestHeader ( "Accept" , "application/x-git-object-bundle" ); 57request . setRequestHeader ( "Cache-Control" , "no-store" ); 58} 59if ( range ) request . setRequestHeader ( "Range" , `bytes= ${ range [ 0 ]} - ${ range [ 1 ] === null ? "" : range [ 1 ] - 1 } ` ); 60const expected = range && range [ 1 ] !== null ?range [ 1 ] - range [ 0 ] :undefined ; 61let loaded = 0 ; 62progress ?.({ id, path, loaded, total :expected , phase :"start" }); 63request . onprogress = event => { 64loaded = event . loaded ; 65progress ?.({ 66 id, 67 path, 68 loaded, 69total :event . lengthComputable ?event . total :expected , 70phase :"progress" , 71}); 72}; 73request . onerror = () => { 74progress ?.({ id, path, loaded, total :expected , phase :"done" }); 75reject ( new Error ( `fetching ${ path } : network error` )); 76}; 77request . onabort = () => { 78progress ?.({ id, path, loaded, total :expected , phase :"done" }); 79reject ( new Error ( `fetching ${ path } : aborted` )); 80}; 81request . onload = () => { 82const bytes = new Uint8Array ( request . response ); 83progress ?.({ id, path, loaded :bytes . length , total :bytes . length , phase :"done" }); 84if ( request . status === 404 ) return resolve ( null ); 85if ( request . status !== 200 && request . status !== 206 ) { 86return reject ( new HttpError ( request . status , path )); 87} 88const contentRange = request . getResponseHeader ( "content-range" ); 89const total = contentRange ?Number ( contentRange . split ( "/" )[ 1 ]) :bytes . length ; 90resolve ({ bytes, total}); 91}; 92request . send ( query ?? null ); 93}); 94}; 95 96const validateGitInfo = j . validation . compile ( GitInfoSchema ); 97const BUNDLED_OBJECT_TYPES = [ undefined , "commit" , "tree" , "blob" , "tag" ] as const ; 98/** a payload-less frame naming a commit a path-history walk resumes from */ 99const FRONTIER_FRAME = 5 ; 100const OBJECT_QUERY_CHUNK_SIZE = 64 ; 101const OBJECT_QUERY_CONCURRENCY = 2 ; 102const OBJECT_FALLBACK_CONCURRENCY = 4 ; 103const PACK_PATH = / \/pack-[0-9a-f]+\.(?:idx|pack)$ / ; 104const PACK_CACHE_ORIGIN = "https://sorcery-pack-cache.invalid" ; 105const OBJECT_CACHE_ORIGIN = "https://sorcery-object-cache.invalid" ; 106 107export type ObjectQuery = 108| { depth :number } 109| { smart :"tree" | "commit-diff" } 110| { smart :"commit-pagination" ; limit :number }; 111 112type PathHistoryQuery = { smart :"path-history" ; limit :number ; path :string [] }; 113 114interface ObjectBundle { 115/** in bundle order, which smart queries make meaningful */ 116objects :Map < string , GitObject >; 117frontier :string []; 118} 119 120function parseObjectBundle ( bytes :Uint8Array ) :ObjectBundle { 121if ( bytes . length < 6 || new TextDecoder (). decode ( bytes . subarray ( 0 , 4 )) !== "SOBJ" || bytes [ 4 ] !== 1 ) { 122throw new Error ( "invalid object bundle" ); 123} 124const objects = new Map < string , GitObject >(); 125const frontier :string [] = []; 126let position = 6 ; 127while ( position < bytes . length ) { 128const oidBytes = bytes [ position ++ ]; 129if (( oidBytes !== 20 && oidBytes !== 32 ) || position + oidBytes + 5 > bytes . length ) { 130throw new Error ( "invalid object bundle frame" ); 131} 132const oid = toHex ( bytes . subarray ( position , position + oidBytes )); 133position += oidBytes ; 134const kind = bytes [ position ++ ]; 135const size = new DataView ( bytes . buffer , bytes . byteOffset + position , 4 ). getUint32 ( 0 ); 136position += 4 ; 137if ( position + size > bytes . length ) throw new Error ( "truncated object bundle" ); 138if ( kind === FRONTIER_FRAME ) { 139frontier . push ( oid ); 140} else { 141const type = BUNDLED_OBJECT_TYPES [ kind ]; 142if ( ! type ) throw new Error ( "invalid bundled object type" ); 143objects . set ( oid , { type, data :bytes . subarray ( position , position + size ) }); 144} 145position += size ; 146} 147return { objects, frontier}; 148} 149 150export class GitRepo { 151 #info?:Promise < GitInfo >; 152 #packs?:Promise < Pack []>; 153 #objectQueryEndpoint= true ; 154 #storage:Promise < Cache | null >; 155 156constructor ( readonly fetch :Fetcher , cacheName ?:string ) { 157this . #storage= cacheName && "caches" in globalThis 158 ?caches . open ( cacheName ). catch (() => null ) 159 :Promise . resolve ( null ); 160} 161 162info () :Promise < GitInfo > { 163if ( ! this . #info) { 164this . #info= this . #cachedFetch( "gitinfo.json" ). then ( res => { 165if ( ! res ) throw new Error ( "missing gitinfo.json" ); 166const { value, errors} = validateGitInfo ( JSON . parse ( new TextDecoder (). decode ( res . bytes ))); 167if ( errors ) throw new Error ( `bad gitinfo.json: ${ errors . map ( e => ` ${ e . path } ${ e . msg } ` ). join ( ", " )} ` ); 168return value ; 169}); 170this . #info. catch (() => ( this . #info= undefined )); 171} 172return this . #info; 173} 174 175async object ( oid :string ) :Promise < GitObject > { 176const cached = await this . #cachedObject( oid ); 177if ( cached ) return cached ; 178 179const bundled = ( await this . #query([ oid ], { depth :0 }))?. objects . get ( oid ); 180if ( bundled ) return bundled ; 181 182const packs = await this . #allPacks(); 183const indexes = await Promise . all ( packs . map ( pack => pack . index ())); 184let object :GitObject | null = null ; 185for ( let i = 0 ; i < packs . length ; i ++ ) { 186const offset = await indexes [ i ]. lookup ( oid ); 187if ( offset !== null ) { 188object = await packs [ i ]. readAt ( offset , this ); 189break ; 190} 191} 192if ( ! object ) { 193const loose = await this . #cachedFetch( `.git/objects/ ${ oid . slice ( 0 , 2 )} / ${ oid . slice ( 2 )} ` ); 194if ( loose ) object = parseLoose ( await inflate ( loose . bytes )); 195} 196if ( ! object ) throw new Error ( `object ${ oid } not found` ); 197await this . #storeObjects([[ oid , object ]]); 198return object ; 199} 200 201async prefetch ( oids :string [], objectQuery :ObjectQuery ) :Promise < void > { 202const roots = [ ...new Set ( oids )]; 203let cached :boolean []; 204if ( "smart" in objectQuery && objectQuery . smart === "commit-pagination" ) { 205cached = roots . map (() => false ); 206} else { 207cached = await Promise . all ( roots . map ( oid => 208"smart" in objectQuery ?this . #hasSmart( objectQuery . smart , oid ) :this . #hasObject( oid ) 209)); 210} 211const pending = roots . filter (( _ , i ) => ! cached [ i ]); 212if ( pending . length === 0 ) return ; 213 214const chunks = Array . from ( 215{ length :Math . ceil ( pending . length / OBJECT_QUERY_CHUNK_SIZE ) }, 216( _ , i ) => pending . slice ( i * OBJECT_QUERY_CHUNK_SIZE , ( i + 1 ) * OBJECT_QUERY_CHUNK_SIZE ), 217); 218const loaded = new Set < string >(); 219let nextChunk = 0 ; 220const query = async () => { 221while ( nextChunk < chunks . length ) { 222for ( const oid of ( await this . #query( chunks [ nextChunk ++ ], objectQuery ))?. objects . keys () ?? []) loaded . add ( oid ); 223} 224}; 225await Promise . all ( Array . from ({ length :Math . min ( OBJECT_QUERY_CONCURRENCY , chunks . length ) }, query )); 226 227const present = await Promise . all ( pending . map ( oid => loaded . has ( oid ) || this . #hasObject( oid ))); 228const missing = pending . filter (( _ , i ) => ! present [ i ]); 229let next = 0 ; 230const load = async () => { 231while ( next < missing . length ) await this . object ( missing [ next ++ ]); 232}; 233await Promise . all ( Array . from ({ length :Math . min ( OBJECT_FALLBACK_CONCURRENCY , missing . length ) }, load )); 234} 235 236/** 237* a page of `git log -- path` computed by the daemon, resuming from 238* `frontier` (see `src/history.rs`). the commits' trees along `path` come 239* bundled too. null when the daemon's query endpoint is unavailable. 240*/ 241async pathHistory ( 242frontier :string [], 243path :string [], 244limit :number , 245) :Promise <{ commits :Commit []; frontier :string [] } | null > { 246const bundle = await this . #query( frontier , { smart :"path-history" , limit, path}); 247if ( ! bundle ) return null ; 248const commits = []; 249for ( const [ oid , object ] of bundle . objects ) { 250if ( object . type === "commit" ) commits . push ( parseCommit ( oid , object . data )); 251} 252return { commits, frontier :bundle . frontier }; 253} 254 255/** 256* null means the server has no object query endpoint (404 or 501), which 257* is the only reason to read packfiles instead; any other failure is 258* reported, not silently downgraded 259*/ 260async #query( oids :string [], query :ObjectQuery | PathHistoryQuery ) :Promise < ObjectBundle | null > { 261if ( ! this . #objectQueryEndpoint) return null ; 262const body = new TextEncoder (). encode ( JSON . stringify ({ oids, ...query })); 263let result ; 264try { 265result = await this . fetch ( "obj" , undefined , body ); 266} catch ( err ) { 267if ( ! ( err instanceof HttpError && err . status === 501 )) throw err ; 268result = null ; 269} 270if ( ! result ) { 271this . #objectQueryEndpoint= false ; 272return null ; 273} 274const bundle = parseObjectBundle ( result . bytes ); 275await this . #storeObjects( bundle . objects ); 276if ( "smart" in query && ( query . smart === "tree" || query . smart === "commit-diff" )) { 277await this . #markSmart( query . smart , oids ); 278} 279return bundle ; 280} 281 282/** the pack reader is the fallback when the daemon's `/obj` is unavailable, so it loads lazily */ 283 #allPacks() :Promise < Pack []> { 284if ( ! this . #packs) { 285const fetch :Fetcher = ( path , range ) => this . #cachedFetch( path , range ); 286this . #packs= Promise . all ([ this . info (), import ( "./pack.ts" )]) 287. then (([ info , { Pack}]) => info . packs . map ( stem => new Pack ( fetch , stem ))); 288this . #packs. catch (() => ( this . #packs= undefined )); 289} 290return this . #packs; 291} 292 293async #cachedFetch( 294path :string , 295range ?:[ number , number | null ], 296) :ReturnType < Fetcher > { 297if ( path === "gitinfo.json" ) { 298const result = await this . fetch ( path , range ); 299if ( result ) void this . #prunePacks( result . bytes ); 300return result ; 301} 302const cache = PACK_PATH . test ( path ) ?await this . #storage :null ; 303if ( ! cache ) return this . fetch ( path , range ); 304 305const key = ` ${ PACK_CACHE_ORIGIN } / ${ path } ?range= ${ range ? ` ${ range [ 0 ]} - ${ range [ 1 ] ?? "" } ` : "all" } ` ; 306const hit = await cache . match ( key ). catch (() => undefined ); 307if ( hit ) { 308return { 309bytes :new Uint8Array ( await hit . arrayBuffer ()), 310total :Number ( hit . headers . get ( "x-sorcery-total" )), 311}; 312} 313const result = await this . fetch ( path , range ); 314if ( result ) { 315await cache . put ( key , new Response ( result . bytes . slice (), { 316headers :{ "x-sorcery-total" :String ( result . total ) }, 317})). catch (() => {}); 318} 319return result ; 320} 321 322async #cachedObject( oid :string ) :Promise < GitObject | null > { 323const cache = await this . #storage; 324const hit = await cache ?. match ( ` ${ OBJECT_CACHE_ORIGIN } /object/ ${ oid } ` ). catch (() => undefined ); 325if ( ! hit ) return null ; 326const type = hit . headers . get ( "x-sorcery-object-type" ); 327if ( type !== "commit" && type !== "tree" && type !== "blob" && type !== "tag" ) return null ; 328return { type, data :new Uint8Array ( await hit . arrayBuffer ()) }; 329} 330 331async #hasObject( oid :string ) :Promise < boolean > { 332const cache = await this . #storage; 333return !! await cache ?. match ( ` ${ OBJECT_CACHE_ORIGIN } /object/ ${ oid } ` ). catch (() => undefined ); 334} 335 336async #storeObjects( objects :Iterable <[ string , GitObject ]>) :Promise < void > { 337const cache = await this . #storage; 338if ( ! cache ) return ; 339await Promise . all ( Array . from ( objects , ([ oid , object ]) => 340cache . put ( ` ${ OBJECT_CACHE_ORIGIN } /object/ ${ oid } ` , new Response ( object . data . slice (), { 341headers :{ "x-sorcery-object-type" :object . type }, 342})). catch (() => {}) 343)); 344} 345 346async #hasSmart( smart :"tree" | "commit-diff" , oid :string ) :Promise < boolean > { 347const cache = await this . #storage; 348return !! await cache ?. match ( ` ${ OBJECT_CACHE_ORIGIN } /smart-v1/ ${ smart } / ${ oid } ` ). catch (() => undefined ); 349} 350 351async #markSmart( smart :"tree" | "commit-diff" , oids :string []) :Promise < void > { 352const cache = await this . #storage; 353if ( ! cache ) return ; 354await Promise . all ( oids . map ( oid => 355cache . put ( ` ${ OBJECT_CACHE_ORIGIN } /smart-v1/ ${ smart } / ${ oid } ` , new Response ()). catch (() => {}) 356)); 357} 358 359async #prunePacks( manifest :Uint8Array ) :Promise < void > { 360const cache = await this . #storage; 361if ( ! cache ) return ; 362try { 363const packs = ( JSON . parse ( new TextDecoder (). decode ( manifest )) as { packs ?:string [] }). packs ; 364const live = new Set ( packs ?? []); 365for ( const request of await cache . keys ()) { 366const stem = new URL ( request . url ). pathname . match ( / (pack-[0-9a-f]+)\.[a-z]+$ / )?.[ 1 ]; 367if ( stem && ! live . has ( stem )) await cache . delete ( request ); 368} 369} catch { 370// a malformed manifest fails schema validation upstream; nothing to do here 371} 372} 373 374async commit ( oid :string ) :Promise < Commit > { 375const object = await this . object ( oid ); 376if ( object . type === "tag" ) return this . commit ( parseTag ( object . data ). object ); 377if ( object . type !== "commit" ) throw new Error ( ` ${ oid } is a ${ object . type } , not a commit` ); 378return parseCommit ( oid , object . data ); 379} 380 381async tree ( oid :string ) :Promise < TreeEntry []> { 382const object = await this . object ( oid ); 383if ( object . type !== "tree" ) throw new Error ( ` ${ oid } is a ${ object . type } , not a tree` ); 384return parseTree ( object . data , oidBytes ( oid ). length ); 385} 386 387async blob ( oid :string ) :Promise < Uint8Array > { 388const object = await this . object ( oid ); 389if ( object . type !== "blob" ) throw new Error ( ` ${ oid } is a ${ object . type } , not a blob` ); 390return object . data ; 391} 392}