@@ -2,6 +2,7 @@ import diagnosticsChannel from 'node:diagnostics_channel';
22import type { Channel } from 'node:diagnostics_channel' ;
33import { EventEmitter } from 'node:events' ;
44import { createReadStream } from 'node:fs' ;
5+ import { readFile } from 'node:fs/promises' ;
56import { STATUS_CODES } from 'node:http' ;
67import type { LookupFunction } from 'node:net' ;
78import { basename } from 'node:path' ;
@@ -111,8 +112,18 @@ export type ClientOptions = {
111112} ;
112113
113114export const VERSION : string = 'VERSION' ;
115+ export const isBun : boolean = ! ! process . versions . bun ;
116+
117+ function getRuntimeInfo ( ) : string {
118+ if ( isBun ) {
119+ return `Bun/${ process . versions . bun } ` ;
120+ }
121+ return `Node.js/${ process . version . substring ( 1 ) } ` ;
122+ }
123+
114124// 'node-urllib/4.0.0 Node.js/18.19.0 (darwin; x64)'
115- export const HEADER_USER_AGENT : string = `node-urllib/${ VERSION } Node.js/${ process . version . substring ( 1 ) } (${ process . platform } ; ${ process . arch } )` ;
125+ // 'node-urllib/4.0.0 Bun/1.2.5 (darwin; x64)'
126+ export const HEADER_USER_AGENT : string = `node-urllib/${ VERSION } ${ getRuntimeInfo ( ) } (${ process . platform } ; ${ process . arch } )` ;
116127
117128function getFileName ( stream : Readable ) : string {
118129 const filePath : string = ( stream as any ) . path ;
@@ -427,16 +438,23 @@ export class HttpClient extends EventEmitter {
427438 let maxRedirects = args . maxRedirects ?? 10 ;
428439
429440 try {
441+ // Bun's undici doesn't honor headersTimeout/bodyTimeout,
442+ // use AbortSignal.timeout() as fallback
443+ let requestSignal = args . signal ;
444+ if ( isBun ) {
445+ const bunTimeoutSignal = AbortSignal . timeout ( headersTimeout + bodyTimeout ) ;
446+ requestSignal = args . signal
447+ ? AbortSignal . any ( [ bunTimeoutSignal , args . signal ] )
448+ : bunTimeoutSignal ;
449+ }
430450 const requestOptions : IUndiciRequestOption = {
431451 method,
432- // disable undici auto redirect handler
433- // maxRedirections: 0,
434452 headersTimeout,
435453 headers,
436454 bodyTimeout,
437455 opaque : internalOpaque ,
438456 dispatcher : args . dispatcher ?? this . #dispatcher,
439- signal : args . signal ,
457+ signal : requestSignal ,
440458 reset : false ,
441459 } ;
442460 if ( typeof args . highWaterMark === 'number' ) {
@@ -500,14 +518,24 @@ export class HttpClient extends EventEmitter {
500518 let value : any ;
501519 if ( typeof file === 'string' ) {
502520 fileName = basename ( file ) ;
503- value = createReadStream ( file ) ;
521+ // Bun's CombinedStream can't pipe file streams
522+ value = isBun ? await readFile ( file ) : createReadStream ( file ) ;
504523 } else if ( Buffer . isBuffer ( file ) ) {
505524 fileName = customFileName || `bufferfile${ index } ` ;
506525 value = file ;
507526 } else if ( file instanceof Readable || isReadable ( file as any ) ) {
508527 fileName = getFileName ( file ) || customFileName || `streamfile${ index } ` ;
509- isStreamingRequest = true ;
510- value = file ;
528+ if ( isBun ) {
529+ // Bun's CombinedStream can't pipe Node.js streams
530+ const streamChunks : Buffer [ ] = [ ] ;
531+ for await ( const chunk of file ) {
532+ streamChunks . push ( Buffer . isBuffer ( chunk ) ? chunk : Buffer . from ( chunk ) ) ;
533+ }
534+ value = Buffer . concat ( streamChunks ) ;
535+ } else {
536+ isStreamingRequest = true ;
537+ value = file ;
538+ }
511539 }
512540 const mimeType = mime . lookup ( fileName ) || '' ;
513541 formData . append ( field , value , {
@@ -517,17 +545,26 @@ export class HttpClient extends EventEmitter {
517545 debug ( 'formData append field: %s, mimeType: %s, fileName: %s' , field , mimeType , fileName ) ;
518546 }
519547 Object . assign ( headers , formData . getHeaders ( ) ) ;
520- requestOptions . body = formData ;
548+ if ( isBun ) {
549+ // Bun's undici can't consume Node.js streams as request body
550+ requestOptions . body = await formData . toBuffer ( ) ;
551+ } else {
552+ requestOptions . body = formData ;
553+ }
521554 } else if ( args . content ) {
522555 if ( ! isGETOrHEAD ) {
523556 // handle content
524- requestOptions . body = args . content ;
557+ if ( isBun && args . content instanceof FormData ) {
558+ requestOptions . body = await ( args . content as FormData ) . toBuffer ( ) ;
559+ } else {
560+ requestOptions . body = args . content ;
561+ }
525562 if ( args . contentType ) {
526563 headers [ 'content-type' ] = args . contentType ;
527564 } else if ( typeof args . content === 'string' && ! headers [ 'content-type' ] ) {
528565 headers [ 'content-type' ] = 'text/plain;charset=UTF-8' ;
529566 }
530- isStreamingRequest = isReadable ( args . content ) ;
567+ isStreamingRequest = ! isBun && isReadable ( args . content ) ;
531568 }
532569 } else if ( args . data ) {
533570 const isStringOrBufferOrReadable =
@@ -579,6 +616,11 @@ export class HttpClient extends EventEmitter {
579616 args . socketErrorRetry = 0 ;
580617 }
581618
619+ // Bun's undici can't consume Node.js Readable as request body
620+ if ( isBun && requestOptions . body instanceof Readable ) {
621+ requestOptions . body = Readable . toWeb ( requestOptions . body ) as any ;
622+ }
623+
582624 debug (
583625 'Request#%d %s %s, headers: %j, headersTimeout: %s, bodyTimeout: %s, isStreamingRequest: %s, isStreamingResponse: %s, maxRedirections: %s, redirects: %s' ,
584626 requestId ,
@@ -659,18 +701,20 @@ export class HttpClient extends EventEmitter {
659701 }
660702 }
661703
704+ // Bun's undici auto-decompresses response body, so skip decompression on Bun
705+ const needDecompress = isCompressedContent && ! isBun ;
662706 let data : any = null ;
663707 if ( args . dataType === 'stream' ) {
664708 // only auto decompress on request args.compressed = true
665- if ( args . compressed === true && isCompressedContent ) {
709+ if ( args . compressed === true && needDecompress ) {
666710 // gzip or br
667711 const decoder = contentEncoding === 'gzip' ? createGunzip ( ) : createBrotliDecompress ( ) ;
668712 res = Object . assign ( pipeline ( response . body , decoder , noop ) , res ) ;
669713 } else {
670714 res = Object . assign ( response . body , res ) ;
671715 }
672716 } else if ( args . writeStream ) {
673- if ( args . compressed === true && isCompressedContent ) {
717+ if ( args . compressed === true && needDecompress ) {
674718 const decoder = contentEncoding === 'gzip' ? createGunzip ( ) : createBrotliDecompress ( ) ;
675719 await pipelinePromise ( response . body , decoder , args . writeStream ) ;
676720 } else {
@@ -679,7 +723,7 @@ export class HttpClient extends EventEmitter {
679723 } else {
680724 // buffer
681725 data = Buffer . from ( await response . body . arrayBuffer ( ) ) ;
682- if ( isCompressedContent && data . length > 0 ) {
726+ if ( needDecompress && data . length > 0 ) {
683727 try {
684728 data = contentEncoding === 'gzip' ? gunzipSync ( data ) : brotliDecompressSync ( data ) ;
685729 } catch ( err : any ) {
@@ -769,9 +813,16 @@ export class HttpClient extends EventEmitter {
769813 err = new HttpClientRequestTimeoutError ( bodyTimeout , { cause : err } ) ;
770814 } else if ( err . name === 'InformationalError' && err . message . includes ( 'stream timeout' ) ) {
771815 err = new HttpClientRequestTimeoutError ( bodyTimeout , { cause : err } ) ;
816+ } else if ( isBun && err . name === 'TimeoutError' ) {
817+ // Bun's undici throws TimeoutError instead of HeadersTimeoutError/BodyTimeoutError
818+ err = new HttpClientRequestTimeoutError ( headersTimeout || bodyTimeout , { cause : err } ) ;
819+ } else if ( isBun && err . name === 'TypeError' && / t i m e d ? \s * o u t | t i m e o u t / i. test ( err . message ) ) {
820+ // Bun may wrap timeout as TypeError
821+ err = new HttpClientRequestTimeoutError ( headersTimeout || bodyTimeout , { cause : err } ) ;
772822 } else if ( err . code === 'UND_ERR_CONNECT_TIMEOUT' ) {
773823 err = new HttpClientConnectTimeoutError ( err . message , err . code , { cause : err } ) ;
774- } else if ( err . code === 'UND_ERR_SOCKET' || err . code === 'ECONNRESET' ) {
824+ } else if ( err . code === 'UND_ERR_SOCKET' || err . code === 'ECONNRESET'
825+ || ( isBun && ( err . code === 'ConnectionClosed' || err . message ?. includes ( 'socket' ) ) ) ) {
775826 // auto retry on socket error, https://github.com/node-modules/urllib/issues/454
776827 if ( args . socketErrorRetry > 0 && requestContext . socketErrorRetries < args . socketErrorRetry ) {
777828 requestContext . socketErrorRetries ++ ;
@@ -783,12 +834,19 @@ export class HttpClient extends EventEmitter {
783834 return await this . #requestInternal( url , options , requestContext ) ;
784835 }
785836 }
837+ // Some errors (e.g. DOMException in Bun) may not be extensible
838+ if ( ! Object . isExtensible ( err ) ) {
839+ const wrappedErr : any = new Error ( err . message , { cause : err } ) ;
840+ wrappedErr . name = err . name ;
841+ wrappedErr . code = err . code ;
842+ wrappedErr . stack = err . stack ;
843+ err = wrappedErr ;
844+ }
786845 err . opaque = originalOpaque ;
787846 err . status = res . status ;
788847 err . headers = res . headers ;
789848 err . res = res ;
790849 if ( err . socket ) {
791- // store rawSocket
792850 err . _rawSocket = err . socket ;
793851 }
794852 err . socket = socketInfo ;
0 commit comments