@@ -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,70 @@ 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 (
1043+ new Error ( `Failed to write file: ${ error . message } ` , { cause : error } ) ,
1044+ )
1045+ } )
11411046
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.'
1047+ res . on ( 'data' , ( chunk : Buffer ) => {
1048+ downloadedSize += chunk . length
1049+ if ( onProgress && totalSize > 0 ) {
1050+ onProgress ( downloadedSize , totalSize )
11621051 }
1052+ } )
11631053
1164- reject ( new Error ( message , { cause : error } ) )
1054+ res . on ( 'end' , ( ) => {
1055+ fileStream . close ( ( ) => {
1056+ resolve ( {
1057+ headers : response . headers ,
1058+ ok : true ,
1059+ path : destPath ,
1060+ size : downloadedSize ,
1061+ status : response . status ,
1062+ statusText : response . statusText ,
1063+ } )
1064+ } )
11651065 } )
11661066
1167- request . on ( 'timeout' , ( ) => {
1168- request . destroy ( )
1169- closeStream ( )
1170- reject ( new Error ( `Download timed out after ${ timeout } ms` ) )
1067+ res . on ( 'error' , ( error : Error ) => {
1068+ fileStream . close ( )
1069+ reject ( error )
11711070 } )
11721071
1173- request . end ( )
1174- /* c8 ignore stop */
1072+ res . pipe ( fileStream )
11751073 } )
11761074}
11771075
@@ -1231,6 +1129,7 @@ async function httpRequestAttempt(
12311129 maxRedirects = 5 ,
12321130 maxResponseSize,
12331131 method = 'GET' ,
1132+ stream = false ,
12341133 timeout = 30_000 ,
12351134 } = { __proto__ : null , ...options } as HttpRequestOptions
12361135
@@ -1370,12 +1269,42 @@ async function httpRequestAttempt(
13701269 maxRedirects : maxRedirects - 1 ,
13711270 maxResponseSize,
13721271 method,
1272+ stream,
13731273 timeout,
13741274 } ) ,
13751275 )
13761276 return
13771277 }
13781278
1279+ // Stream mode: resolve immediately with unconsumed response.
1280+ if ( stream ) {
1281+ const status = res . statusCode || 0
1282+ const statusText = res . statusMessage || ''
1283+ const ok = status >= 200 && status < 300
1284+
1285+ emitResponse ( {
1286+ headers : res . headers ,
1287+ status,
1288+ statusText,
1289+ } )
1290+
1291+ const emptyBody = Buffer . alloc ( 0 )
1292+ resolveOnce ( {
1293+ arrayBuffer : ( ) => emptyBody . buffer as ArrayBuffer ,
1294+ body : emptyBody ,
1295+ headers : res . headers ,
1296+ json : ( ) => {
1297+ throw new Error ( 'Cannot parse JSON from a streaming response' )
1298+ } ,
1299+ ok,
1300+ rawResponse : res ,
1301+ status,
1302+ statusText,
1303+ text : ( ) => '' ,
1304+ } )
1305+ return
1306+ }
1307+
13791308 const chunks : Buffer [ ] = [ ]
13801309 let totalBytes = 0
13811310
@@ -1645,8 +1574,8 @@ export async function httpDownload(
16451574 await fs . promises . rename ( tempPath , destPath )
16461575
16471576 return {
1577+ ...result ,
16481578 path : destPath ,
1649- size : result . size ,
16501579 }
16511580 } catch ( e ) {
16521581 lastError = e as Error
@@ -1816,6 +1745,7 @@ export async function httpRequest(
18161745 onRetry,
18171746 retries = 0 ,
18181747 retryDelay = 1000 ,
1748+ stream = false ,
18191749 throwOnError = false ,
18201750 timeout = 30_000 ,
18211751 } = { __proto__ : null , ...options } as HttpRequestOptions
@@ -1846,6 +1776,7 @@ export async function httpRequest(
18461776 maxRedirects,
18471777 maxResponseSize,
18481778 method,
1779+ stream,
18491780 timeout,
18501781 }
18511782
0 commit comments