@@ -41,6 +41,14 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) {
4141 // Optional: network capture (fetch + XHR)
4242 const networkEnabled = ! ! opts . networkLogs ?. enabled ;
4343 const networkFull = ! ! opts . networkLogs ?. captureFull ;
44+ const bodiesCfg = opts . networkLogs ?. bodies || { } ;
45+ const bodyReqEnabled = ! ! bodiesCfg . request ;
46+ const bodyResEnabled = ! ! bodiesCfg . response ;
47+ const bodyMaxBytes = ( bodiesCfg . maxBytes ?? 2048 ) | 0 ;
48+ const bodyPrettyJson = bodiesCfg . prettyJson !== false ;
49+ const bodyAllowed : string [ ] = ( Array . isArray ( bodiesCfg . allowContentTypes ) && bodiesCfg . allowContentTypes . length )
50+ ? bodiesCfg . allowContentTypes . map ( ( s ) => String ( s ) . toLowerCase ( ) )
51+ : [ 'application/json' , 'text/' , 'application/x-www-form-urlencoded' ] ;
4452 if ( networkEnabled ) {
4553 try { installFetchCapture ( ) ; } catch { }
4654 try { installXhrCapture ( ) ; } catch { }
@@ -146,31 +154,90 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) {
146154 const start = performance . now ( ) ;
147155 const method = ( init ?. method || ( typeof input === 'object' && input ?. method ) || 'GET' ) . toUpperCase ( ) ;
148156 const url = normalizeUrlString ( input ) ;
149- const emit = ( status : number , ok : boolean , extra ?: string ) => {
150- const dur = Math . max ( 0 , Math . round ( performance . now ( ) - start ) ) ;
157+ const baseLine = ( status : number , durMs : number ) => {
151158 const statusText = isFinite ( status as any ) ? String ( status ) : 'ERR' ;
152- const text = `[NETWORK] [${ method } ] [${ url || '(request)' } ] [${ statusText } ] [${ dur } ms]${ extra ? ' ' + extra : '' } ` ;
153- enqueue ( { level : ok ? 'info' : 'warn' , text, time : Date . now ( ) , tag : '[network]' } ) ;
159+ return `[NETWORK] [${ method } ] [${ url || '(request)' } ] [${ statusText } ] [${ durMs } ms]` ;
160+ } ;
161+ const getReqSnippet = ( ) : Promise < string > => {
162+ if ( ! bodyReqEnabled ) return Promise . resolve ( '' ) ;
163+ try {
164+ // Prefer Request.clone() if available
165+ if ( input && typeof input === 'object' && typeof ( input as any ) . clone === 'function' ) {
166+ const req : any = input ;
167+ const headers = ( req . headers && typeof req . headers . get === 'function' ) ? req . headers : null ;
168+ const ct = getHeader ( headers , 'content-type' ) || ( init ?. headers ? getHeader ( init ?. headers , 'content-type' ) : '' ) ;
169+ if ( ! isAllowedContentType ( ct ) ) return Promise . resolve ( '' ) ;
170+ return req . clone ( ) . text ( ) . then ( ( txt : string ) => formatBodySnippet ( txt , ct ) ) ;
171+ }
172+ // Fallback to init.body as string/urlencoded
173+ const ct = init ?. headers ? getHeader ( init . headers , 'content-type' ) : '' ;
174+ const body = init ?. body ;
175+ if ( typeof body === 'string' ) {
176+ if ( ! ct || isAllowedContentType ( ct ) || isLikelyText ( body ) ) return Promise . resolve ( formatBodySnippet ( body , ct ) ) ;
177+ } else if ( body && typeof ( body as any ) . toString === 'function' && ( body instanceof URLSearchParams ) ) {
178+ const s = ( body as URLSearchParams ) . toString ( ) ;
179+ const reqCt = ct || 'application/x-www-form-urlencoded' ;
180+ if ( isAllowedContentType ( reqCt ) ) return Promise . resolve ( formatBodySnippet ( s , reqCt ) ) ;
181+ } else if ( body && typeof ( body as any ) . size === 'number' ) {
182+ const size = Number ( ( body as any ) . size ) | 0 ;
183+ return Promise . resolve ( `[binary: ${ size } bytes]` ) ;
184+ }
185+ } catch { }
186+ return Promise . resolve ( '' ) ;
187+ } ;
188+ const getResSnippet = ( res : any ) : Promise < string > => {
189+ if ( ! bodyResEnabled ) return Promise . resolve ( '' ) ;
190+ try {
191+ const headers = res ?. headers ;
192+ const ct = getHeader ( headers , 'content-type' ) ;
193+ if ( ! isAllowedContentType ( ct ) ) return Promise . resolve ( '' ) ;
194+ if ( res && typeof res . clone === 'function' ) {
195+ try {
196+ const clone = res . clone ( ) ;
197+ if ( clone && clone . body && typeof clone . body . getReader === 'function' ) {
198+ return readStreamSnippet ( clone , ct ) ;
199+ }
200+ return clone . text ( ) . then ( ( txt : string ) => formatBodySnippet ( txt , ct ) ) ;
201+ } catch { }
202+ }
203+ } catch { }
204+ return Promise . resolve ( '' ) ;
154205 } ;
155206 try {
156207 const p = orig ( input , init ) ;
157208 return Promise . resolve ( p ) . then ( ( res : any ) => {
158- try {
159- if ( networkFull ) {
160- const headers : any = { } ;
161- try { res . headers && res . headers . forEach && res . headers . forEach ( ( v : string , k : string ) => { headers [ k ] = v ; } ) ; } catch { }
162- emit ( Number ( res ?. status ?? 0 ) | 0 , ! ! res ?. ok , `[size:${ Number ( res ?. headers ?. get ?.( 'content-length' ) || 0 ) | 0 } ]` ) ;
163- } else {
164- emit ( Number ( res ?. status ?? 0 ) | 0 , ! ! res ?. ok ) ;
165- }
166- } catch { }
209+ const dur = Math . max ( 0 , Math . round ( performance . now ( ) - start ) ) ;
210+ const statusNum = Number ( res ?. status ?? 0 ) | 0 ;
211+ const ok = ! ! res ?. ok ;
212+ const extra = networkFull ? ` [size:${ Number ( res ?. headers ?. get ?.( 'content-length' ) || 0 ) | 0 } ]` : '' ;
213+ // Prepare body snippets asynchronously
214+ Promise . all ( [ getReqSnippet ( ) , getResSnippet ( res ) ] ) . then ( ( [ reqS , resS ] ) => {
215+ let line = baseLine ( statusNum , dur ) + extra ;
216+ if ( reqS ) line += `\n req: ${ reqS } ` ;
217+ if ( resS ) line += `\n res: ${ resS } ` ;
218+ enqueue ( { level : ok ? 'info' : 'warn' , text : line , time : Date . now ( ) , tag : '[network]' } ) ;
219+ } ) . catch ( ( ) => {
220+ const line = baseLine ( statusNum , dur ) + extra ;
221+ enqueue ( { level : ok ? 'info' : 'warn' , text : line , time : Date . now ( ) , tag : '[network]' } ) ;
222+ } ) ;
167223 return res ;
168224 } ) . catch ( ( err : any ) => {
169- emit ( 0 , false , err ?. message ? String ( err . message ) : 'fetch failed' ) ;
225+ const dur = Math . max ( 0 , Math . round ( performance . now ( ) - start ) ) ;
226+ Promise . resolve ( getReqSnippet ( ) ) . then ( ( reqS ) => {
227+ let line = baseLine ( 0 , dur ) ;
228+ line += ` fetch failed` ;
229+ if ( reqS ) line += `\n req: ${ reqS } ` ;
230+ enqueue ( { level : 'warn' , text : line , time : Date . now ( ) , tag : '[network]' } ) ;
231+ } ) . catch ( ( ) => {
232+ const line = baseLine ( 0 , dur ) + ' fetch failed' ;
233+ enqueue ( { level : 'warn' , text : line , time : Date . now ( ) , tag : '[network]' } ) ;
234+ } ) ;
170235 throw err ;
171236 } ) ;
172237 } catch ( err : any ) {
173- emit ( 0 , false , err ?. message ? String ( err . message ) : 'fetch failed' ) ;
238+ const dur = Math . max ( 0 , Math . round ( performance . now ( ) - start ) ) ;
239+ let line = baseLine ( 0 , dur ) + ' fetch failed' ;
240+ enqueue ( { level : 'warn' , text : line , time : Date . now ( ) , tag : '[network]' } ) ;
174241 throw err ;
175242 }
176243 } ;
@@ -181,23 +248,58 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) {
181248 if ( ! XHR || ! XHR . prototype ) return ;
182249 const origOpen = XHR . prototype . open ;
183250 const origSend = XHR . prototype . send ;
251+ const origSetHeader = XHR . prototype . setRequestHeader ;
184252 XHR . prototype . open = function ( method : string , url : string ) {
185253 try { ( this as any ) . __be_method__ = String ( method || 'GET' ) . toUpperCase ( ) ; } catch { }
186254 try { ( this as any ) . __be_url__ = String ( url || '' ) ; } catch { }
187255 return origOpen . apply ( this , arguments as any ) ;
188256 } as any ;
257+ if ( origSetHeader ) {
258+ XHR . prototype . setRequestHeader = function ( name : string , value : string ) {
259+ try {
260+ const k = String ( name || '' ) . toLowerCase ( ) ;
261+ if ( k === 'content-type' ) { ( this as any ) . __be_req_ct__ = String ( value || '' ) ; }
262+ } catch { }
263+ return origSetHeader . apply ( this , arguments as any ) ;
264+ } as any ;
265+ }
189266 XHR . prototype . send = function ( ) {
190267 const start = performance . now ( ) ;
268+ try { if ( bodyReqEnabled ) { ( this as any ) . __be_req_body__ = arguments && arguments [ 0 ] ; } } catch { }
191269 const onEnd = ( ) => {
192270 try {
193271 const dur = Math . max ( 0 , Math . round ( performance . now ( ) - start ) ) ;
194272 const method = ( this as any ) . __be_method__ || 'GET' ;
195273 const u = ( this as any ) . __be_url__ || '' ;
196274 const status = Number ( ( this as any ) . status ?? 0 ) | 0 ;
197275 const ok = status >= 200 && status < 400 ;
198- const extra = networkFull ? `ready:${ ( this as any ) . readyState } ` : '' ;
199- const text = `[NETWORK] [${ method } ] [${ u } ] [${ status || 'ERR' } ] [${ dur } ms]${ extra ? ' ' + extra : '' } ` ;
200- enqueue ( { level : ok ? 'info' : 'warn' , text, time : Date . now ( ) , tag : '[network]' } ) ;
276+ const extra = networkFull ? ` ready:${ ( this as any ) . readyState } ` : '' ;
277+ let line = `[NETWORK] [${ method } ] [${ u } ] [${ status || 'ERR' } ] [${ dur } ms]${ extra } ` ;
278+ // Bodies
279+ if ( bodyReqEnabled ) {
280+ try {
281+ const reqCt = String ( ( this as any ) . __be_req_ct__ || '' ) . toLowerCase ( ) ;
282+ const reqBody = ( this as any ) . __be_req_body__ ;
283+ const reqSnippet = formatRequestBodySync ( reqBody , reqCt ) ;
284+ if ( reqSnippet ) line += `\n req: ${ reqSnippet } ` ;
285+ } catch { }
286+ }
287+ if ( bodyResEnabled ) {
288+ try {
289+ const resCt = String ( ( this as any ) . getResponseHeader ?.( 'Content-Type' ) || '' ) . toLowerCase ( ) ;
290+ if ( isAllowedContentType ( resCt ) ) {
291+ let snippet = '' ;
292+ const rt = ( this as any ) . responseType ;
293+ if ( ! rt || rt === 'text' ) {
294+ try { snippet = formatBodySnippet ( String ( ( this as any ) . responseText || '' ) , resCt ) ; } catch { }
295+ } else if ( rt === 'json' ) {
296+ try { snippet = formatBodySnippet ( JSON . stringify ( ( this as any ) . response ?? null ) , 'application/json' ) ; } catch { }
297+ }
298+ if ( snippet ) line += `\n res: ${ snippet } ` ;
299+ }
300+ } catch { }
301+ }
302+ enqueue ( { level : ok ? 'info' : 'warn' , text : line , time : Date . now ( ) , tag : '[network]' } ) ;
201303 } catch { }
202304 try {
203305 this . removeEventListener ( 'loadend' , onEnd ) ;
@@ -245,6 +347,120 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) {
245347 } ) ;
246348 }
247349
350+ function getHeader ( headers : any , name : string ) : string {
351+ try {
352+ if ( ! headers ) return '' ;
353+ const key = String ( name ) . toLowerCase ( ) ;
354+ if ( typeof headers . get === 'function' ) {
355+ const v = headers . get ( name ) || headers . get ( key ) || '' ;
356+ return String ( v || '' ) . toLowerCase ( ) ;
357+ }
358+ if ( Array . isArray ( headers ) ) {
359+ for ( const [ k , v ] of headers ) {
360+ if ( String ( k ) . toLowerCase ( ) === key ) return String ( v || '' ) . toLowerCase ( ) ;
361+ }
362+ }
363+ if ( typeof headers === 'object' ) {
364+ for ( const k of Object . keys ( headers ) ) {
365+ if ( k . toLowerCase ( ) === key ) return String ( ( headers as any ) [ k ] || '' ) . toLowerCase ( ) ;
366+ }
367+ }
368+ } catch { }
369+ return '' ;
370+ }
371+
372+ function isAllowedContentType ( ct : string ) : boolean {
373+ try {
374+ const c = String ( ct || '' ) . toLowerCase ( ) ;
375+ if ( ! c ) return false ;
376+ for ( const a of bodyAllowed ) {
377+ const al = String ( a ) ;
378+ if ( c . startsWith ( al ) ) return true ;
379+ }
380+ } catch { }
381+ return false ;
382+ }
383+
384+ function isLikelyText ( s : string ) : boolean {
385+ const trimmed = String ( s || '' ) . trim ( ) ;
386+ if ( ! trimmed ) return true ;
387+ if ( trimmed . startsWith ( '{' ) || trimmed . startsWith ( '[' ) ) return true ;
388+ return / ^ [ \x09 \x0A \x0D \x20 - \x7E \u00A0 - \uFFFF ] * $ / . test ( trimmed ) ;
389+ }
390+
391+ function formatBodySnippet ( raw : string , contentType : string ) : string {
392+ try {
393+ let text = String ( raw ?? '' ) ;
394+ const ct = String ( contentType || '' ) . toLowerCase ( ) ;
395+ if ( bodyPrettyJson && ( ct . startsWith ( 'application/json' ) || ( text . trim ( ) . startsWith ( '{' ) || text . trim ( ) . startsWith ( '[' ) ) ) ) {
396+ try { text = JSON . stringify ( JSON . parse ( text ) , null , 2 ) ; } catch { }
397+ }
398+ const enc = new TextEncoder ( ) ;
399+ const bytes = enc . encode ( text ) ;
400+ if ( bytes . length <= bodyMaxBytes ) return text ;
401+ const sliced = bytes . slice ( 0 , Math . max ( 0 , bodyMaxBytes ) ) ;
402+ const dec = new TextDecoder ( ) ;
403+ const shown = dec . decode ( sliced ) ;
404+ const extra = bytes . length - sliced . length ;
405+ return `${ shown } … (+${ extra } bytes)` ;
406+ } catch { return '' ; }
407+ }
408+
409+ function formatRequestBodySync ( body : any , contentType : string ) : string {
410+ try {
411+ const ct = String ( contentType || '' ) . toLowerCase ( ) ;
412+ if ( ! ct || ! isAllowedContentType ( ct ) ) {
413+ if ( typeof body === 'string' && isLikelyText ( body ) ) return formatBodySnippet ( body , '' ) ;
414+ return '' ;
415+ }
416+ if ( typeof body === 'string' ) return formatBodySnippet ( body , ct ) ;
417+ if ( body instanceof URLSearchParams ) return formatBodySnippet ( body . toString ( ) , 'application/x-www-form-urlencoded' ) ;
418+ if ( body && typeof body . size === 'number' ) return `[binary: ${ Number ( body . size ) | 0 } bytes]` ;
419+ } catch { }
420+ return '' ;
421+ }
422+
423+ async function readStreamSnippet ( resClone : any , contentType : string ) : Promise < string > {
424+ try {
425+ const reader = resClone . body ?. getReader ?.( ) ;
426+ if ( ! reader ) return resClone . text ( ) . then ( ( t : string ) => formatBodySnippet ( t , contentType ) ) ;
427+ const chunks : Uint8Array [ ] = [ ] ;
428+ let received = 0 ;
429+ while ( true ) {
430+ const { done, value } = await reader . read ( ) ;
431+ if ( done ) break ;
432+ if ( value ) {
433+ const v = value as Uint8Array ;
434+ if ( received < bodyMaxBytes ) {
435+ const need = bodyMaxBytes - received ;
436+ chunks . push ( need >= v . length ? v : v . slice ( 0 , need ) ) ;
437+ }
438+ received += v . length ;
439+ if ( received >= bodyMaxBytes ) {
440+ try { reader . cancel && reader . cancel ( ) ; } catch { }
441+ break ;
442+ }
443+ }
444+ }
445+ const merged = mergeUint8Arrays ( chunks ) ;
446+ const dec = new TextDecoder ( ) ;
447+ const shown = dec . decode ( merged ) ;
448+ if ( received <= bodyMaxBytes ) return formatBodySnippet ( shown , contentType ) ;
449+ const extra = received - merged . length ;
450+ return `${ shown } … (+${ extra } bytes)` ;
451+ } catch {
452+ try { const t = await resClone . text ( ) ; return formatBodySnippet ( t , contentType ) ; } catch { return '' ; }
453+ }
454+ }
455+
456+ function mergeUint8Arrays ( arrays : Uint8Array [ ] ) : Uint8Array {
457+ const total = arrays . reduce ( ( n , a ) => n + a . length , 0 ) ;
458+ const out = new Uint8Array ( total ) ;
459+ let off = 0 ;
460+ for ( const a of arrays ) { out . set ( a , off ) ; off += a . length ; }
461+ return out ;
462+ }
463+
248464 function randomId ( ) {
249465 try {
250466 const arr = new Uint8Array ( 8 ) ;
0 commit comments