Skip to content

Commit e0c924a

Browse files
committed
feat(http-request): add stream option and response metadata to downloads
Add stream option to httpRequest — resolves with HttpResponse immediately after headers arrive, leaving rawResponse unconsumed for piping to files. Refactor httpDownloadAttempt to use httpRequestAttempt with stream: true, eliminating ~120 lines of duplicated HTTP plumbing (redirect handling, protocol selection, error enrichment). Add headers, ok, status, and statusText to HttpDownloadResult so callers can inspect response metadata after streaming downloads.
1 parent 6b7b886 commit e0c924a

3 files changed

Lines changed: 206 additions & 183 deletions

File tree

src/http-request.ts

Lines changed: 107 additions & 180 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
825839
export 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
*/
989995
async 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

Comments
 (0)