Skip to content

Commit f14f1fb

Browse files
authored
feat(http-request): add stream option and response metadata to downloads (#151)
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 f14f1fb

3 files changed

Lines changed: 222 additions & 183 deletions

File tree

src/http-request.ts

Lines changed: 111 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,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

Comments
 (0)