@@ -348,6 +348,20 @@ export interface HttpRequestOptions {
348348 * })
349349 * ```
350350 */
351+ /**
352+ * When true, resolve with an HttpResponse whose body is NOT buffered.
353+ * The `rawResponse` property contains the unconsumed IncomingResponse
354+ * stream for piping to files or other destinations.
355+ *
356+ * `body`, `text()`, `json()`, and `arrayBuffer()` return empty/zero
357+ * values since the stream has not been read.
358+ *
359+ * Incompatible with `maxResponseSize` (size enforcement requires
360+ * reading the body).
361+ *
362+ * @default false
363+ */
364+ stream ?: boolean | undefined
351365 throwOnError ?: boolean | undefined
352366 /**
353367 * Request timeout in milliseconds.
@@ -823,26 +837,18 @@ export interface HttpDownloadOptions {
823837 * Result of a successful file download.
824838 */
825839export interface HttpDownloadResult {
826- /**
827- * Absolute path where the file was saved.
828- *
829- * @example
830- * ```ts
831- * const result = await httpDownload('https://example.com/file.zip', '/tmp/file.zip')
832- * console.log(`Downloaded to: ${result.path}`)
833- * ```
834- */
840+ /** HTTP response headers from the final response (after redirects). */
841+ headers : IncomingHttpHeaders
842+ /** Whether the download succeeded (status 200-299). Always true on success (non-2xx throws). */
843+ ok : true
844+ /** Absolute path where the file was saved. */
835845 path : string
836- /**
837- * Total size of downloaded file in bytes.
838- *
839- * @example
840- * ```ts
841- * const result = await httpDownload('https://example.com/file.zip', '/tmp/file.zip')
842- * console.log(`Downloaded ${result.size} bytes`)
843- * ```
844- */
846+ /** Total size of downloaded file in bytes. */
845847 size : number
848+ /** HTTP status code from the final response (after redirects). */
849+ status : number
850+ /** HTTP status message from the final response (after redirects). */
851+ statusText : string
846852}
847853
848854/**
@@ -983,7 +989,7 @@ export async function fetchChecksums(
983989}
984990
985991/**
986- * Single download attempt (used internally by httpDownload with retry logic) .
992+ * Single download attempt using httpRequestAttempt with stream: true .
987993 * @private
988994 */
989995async function httpDownloadAttempt (
@@ -1000,178 +1006,68 @@ async function httpDownloadAttempt(
10001006 timeout = 120_000 ,
10011007 } = { __proto__ : null , ...options } as HttpDownloadOptions
10021008
1003- return await new Promise ( ( resolve , reject ) => {
1004- const parsedUrl = new URL ( url )
1005- const isHttps = parsedUrl . protocol === 'https:'
1006- const httpModule = isHttps ? getHttps ( ) : getHttp ( )
1007-
1008- const requestOptions : Record < string , unknown > = {
1009- headers : {
1010- 'User-Agent' : 'socket-registry/1.0' ,
1011- ...headers ,
1012- } ,
1013- hostname : parsedUrl . hostname ,
1014- method : 'GET' ,
1015- path : parsedUrl . pathname + parsedUrl . search ,
1016- port : parsedUrl . port ,
1017- timeout,
1018- }
1019-
1020- // Pass custom CA certificates for TLS connections.
1021- if ( ca && isHttps ) {
1022- requestOptions [ 'ca' ] = ca
1023- }
1024-
1025- const { createWriteStream } = getFs ( )
1026-
1027- let fileStream : ReturnType < typeof createWriteStream > | undefined
1028- let streamClosed = false
1029-
1030- const closeStream = ( ) => {
1031- if ( ! streamClosed && fileStream ) {
1032- streamClosed = true
1033- fileStream . close ( )
1034- }
1035- }
1036-
1037- /* c8 ignore start - External HTTP/HTTPS download request */
1038- const request = httpModule . request (
1039- requestOptions ,
1040- ( res : IncomingResponse ) => {
1041- // Handle redirects
1042- if (
1043- followRedirects &&
1044- res . statusCode &&
1045- res . statusCode >= 300 &&
1046- res . statusCode < 400 &&
1047- res . headers . location
1048- ) {
1049- if ( maxRedirects <= 0 ) {
1050- reject (
1051- new Error (
1052- `Too many redirects (exceeded maximum: ${ maxRedirects } )` ,
1053- ) ,
1054- )
1055- return
1056- }
1057-
1058- // Follow redirect
1059- const redirectUrl = res . headers . location . startsWith ( 'http' )
1060- ? res . headers . location
1061- : new URL ( res . headers . location , url ) . toString ( )
1062-
1063- // Reject HTTPS-to-HTTP downgrade redirects.
1064- const redirectParsed = new URL ( redirectUrl )
1065- if ( isHttps && redirectParsed . protocol !== 'https:' ) {
1066- reject (
1067- new Error (
1068- `Redirect from HTTPS to HTTP is not allowed: ${ redirectUrl } ` ,
1069- ) ,
1070- )
1071- return
1072- }
1073-
1074- resolve (
1075- httpDownloadAttempt ( redirectUrl , destPath , {
1076- ca,
1077- followRedirects,
1078- headers,
1079- maxRedirects : maxRedirects - 1 ,
1080- onProgress,
1081- timeout,
1082- } ) ,
1083- )
1084- return
1085- }
1086-
1087- // Check status code
1088- if ( ! res . statusCode || res . statusCode < 200 || res . statusCode >= 300 ) {
1089- closeStream ( )
1090- reject (
1091- new Error (
1092- `Download failed: HTTP ${ res . statusCode } ${ res . statusMessage } ` ,
1093- ) ,
1094- )
1095- return
1096- }
1097-
1098- const totalSize = Number . parseInt (
1099- res . headers [ 'content-length' ] || '0' ,
1100- 10 ,
1101- )
1102- let downloadedSize = 0
1103-
1104- // Create write stream
1105- fileStream = createWriteStream ( destPath )
1009+ const response = await httpRequestAttempt ( url , {
1010+ ca,
1011+ followRedirects,
1012+ headers,
1013+ maxRedirects,
1014+ method : 'GET' ,
1015+ stream : true ,
1016+ timeout,
1017+ } )
11061018
1107- fileStream . on ( 'error' , ( error : Error ) => {
1108- closeStream ( )
1109- const err = new Error ( `Failed to write file: ${ error . message } ` , {
1110- cause : error ,
1111- } )
1112- reject ( err )
1113- } )
1019+ if ( ! response . ok ) {
1020+ throw new Error (
1021+ `Download failed: HTTP ${ response . status } ${ response . statusText } ` ,
1022+ )
1023+ }
11141024
1115- res . on ( 'data' , ( chunk : Buffer ) => {
1116- downloadedSize += chunk . length
1117- if ( onProgress && totalSize > 0 ) {
1118- onProgress ( downloadedSize , totalSize )
1119- }
1120- } )
1025+ const res = response . rawResponse
1026+ if ( ! res ) {
1027+ throw new Error ( 'Stream response missing rawResponse' )
1028+ }
11211029
1122- res . on ( 'end' , ( ) => {
1123- fileStream ?. close ( ( ) => {
1124- streamClosed = true
1125- resolve ( {
1126- path : destPath ,
1127- size : downloadedSize ,
1128- } )
1129- } )
1130- } )
1030+ const { createWriteStream } = getFs ( )
1031+ const totalSize = Number . parseInt (
1032+ ( response . headers [ 'content-length' ] as string ) || '0' ,
1033+ 10 ,
1034+ )
11311035
1132- res . on ( 'error' , ( error : Error ) => {
1133- closeStream ( )
1134- reject ( error )
1135- } )
1036+ return await new Promise ( ( resolve , reject ) => {
1037+ let downloadedSize = 0
1038+ const fileStream = createWriteStream ( destPath )
11361039
1137- // Pipe response to file
1138- res . pipe ( fileStream )
1139- } ,
1140- )
1040+ fileStream . on ( 'error' , ( error : Error ) => {
1041+ fileStream . close ( )
1042+ reject ( new Error ( `Failed to write file: ${ error . message } ` , { cause : error } ) )
1043+ } )
11411044
1142- request . on ( 'error' , ( error : Error ) => {
1143- closeStream ( )
1144- const code = ( error as NodeJS . ErrnoException ) . code
1145- let message = `HTTP download failed for ${ url } : ${ error . message } \n`
1146-
1147- if ( code === 'ENOTFOUND' ) {
1148- message +=
1149- 'DNS lookup failed. Check the hostname and your network connection.'
1150- } else if ( code === 'ECONNREFUSED' ) {
1151- message +=
1152- 'Connection refused. Verify the server is running and accessible.'
1153- } else if ( code === 'ETIMEDOUT' ) {
1154- message +=
1155- 'Request timed out. Check your network or increase the timeout value.'
1156- } else if ( code === 'ECONNRESET' ) {
1157- message +=
1158- 'Connection reset. The server may have closed the connection unexpectedly.'
1159- } else {
1160- message +=
1161- 'Check your network connection and verify the URL is correct.'
1045+ res . on ( 'data' , ( chunk : Buffer ) => {
1046+ downloadedSize += chunk . length
1047+ if ( onProgress && totalSize > 0 ) {
1048+ onProgress ( downloadedSize , totalSize )
11621049 }
1050+ } )
11631051
1164- reject ( new Error ( message , { cause : error } ) )
1052+ res . on ( 'end' , ( ) => {
1053+ fileStream . close ( ( ) => {
1054+ resolve ( {
1055+ headers : response . headers ,
1056+ ok : true ,
1057+ path : destPath ,
1058+ size : downloadedSize ,
1059+ status : response . status ,
1060+ statusText : response . statusText ,
1061+ } )
1062+ } )
11651063 } )
11661064
1167- request . on ( 'timeout' , ( ) => {
1168- request . destroy ( )
1169- closeStream ( )
1170- reject ( new Error ( `Download timed out after ${ timeout } ms` ) )
1065+ res . on ( 'error' , ( error : Error ) => {
1066+ fileStream . close ( )
1067+ reject ( error )
11711068 } )
11721069
1173- request . end ( )
1174- /* c8 ignore stop */
1070+ res . pipe ( fileStream )
11751071 } )
11761072}
11771073
@@ -1231,6 +1127,7 @@ async function httpRequestAttempt(
12311127 maxRedirects = 5 ,
12321128 maxResponseSize,
12331129 method = 'GET' ,
1130+ stream = false ,
12341131 timeout = 30_000 ,
12351132 } = { __proto__ : null , ...options } as HttpRequestOptions
12361133
@@ -1370,12 +1267,40 @@ async function httpRequestAttempt(
13701267 maxRedirects : maxRedirects - 1 ,
13711268 maxResponseSize,
13721269 method,
1270+ stream,
13731271 timeout,
13741272 } ) ,
13751273 )
13761274 return
13771275 }
13781276
1277+ // Stream mode: resolve immediately with unconsumed response.
1278+ if ( stream ) {
1279+ const status = res . statusCode || 0
1280+ const statusText = res . statusMessage || ''
1281+ const ok = status >= 200 && status < 300
1282+
1283+ emitResponse ( {
1284+ headers : res . headers ,
1285+ status,
1286+ statusText,
1287+ } )
1288+
1289+ const emptyBody = Buffer . alloc ( 0 )
1290+ resolveOnce ( {
1291+ arrayBuffer : ( ) => emptyBody . buffer as ArrayBuffer ,
1292+ body : emptyBody ,
1293+ headers : res . headers ,
1294+ json : ( ) => { throw new Error ( 'Cannot parse JSON from a streaming response' ) } ,
1295+ ok,
1296+ rawResponse : res ,
1297+ status,
1298+ statusText,
1299+ text : ( ) => '' ,
1300+ } )
1301+ return
1302+ }
1303+
13791304 const chunks : Buffer [ ] = [ ]
13801305 let totalBytes = 0
13811306
@@ -1645,8 +1570,8 @@ export async function httpDownload(
16451570 await fs . promises . rename ( tempPath , destPath )
16461571
16471572 return {
1573+ ...result ,
16481574 path : destPath ,
1649- size : result . size ,
16501575 }
16511576 } catch ( e ) {
16521577 lastError = e as Error
@@ -1816,6 +1741,7 @@ export async function httpRequest(
18161741 onRetry,
18171742 retries = 0 ,
18181743 retryDelay = 1000 ,
1744+ stream = false ,
18191745 throwOnError = false ,
18201746 timeout = 30_000 ,
18211747 } = { __proto__ : null , ...options } as HttpRequestOptions
@@ -1846,6 +1772,7 @@ export async function httpRequest(
18461772 maxRedirects,
18471773 maxResponseSize,
18481774 method,
1775+ stream,
18491776 timeout,
18501777 }
18511778
0 commit comments