@@ -30,7 +30,7 @@ function getFs() {
3030 return _fs as typeof import ( 'node:fs' )
3131}
3232
33- import type { IncomingMessage } from 'http'
33+ import type { IncomingHttpHeaders , IncomingMessage } from 'http'
3434
3535import type { Logger } from './logger.js'
3636
@@ -74,6 +74,38 @@ function getHttps() {
7474 return _https as typeof import ( 'node:https' )
7575}
7676
77+ /**
78+ * Information passed to the onRequest hook before each request attempt.
79+ */
80+ export interface HttpHookRequestInfo {
81+ headers : Record < string , string >
82+ method : string
83+ timeout : number
84+ url : string
85+ }
86+
87+ /**
88+ * Information passed to the onResponse hook after each request attempt.
89+ */
90+ export interface HttpHookResponseInfo {
91+ duration : number
92+ error ?: Error | undefined
93+ headers ?: IncomingHttpHeaders | undefined
94+ method : string
95+ status ?: number | undefined
96+ statusText ?: string | undefined
97+ url : string
98+ }
99+
100+ /**
101+ * Lifecycle hooks for observing HTTP request/response events.
102+ * Hooks fire per-attempt (retries produce multiple hook calls).
103+ */
104+ export interface HttpHooks {
105+ onRequest ?: ( ( info : HttpHookRequestInfo ) => void ) | undefined
106+ onResponse ?: ( ( info : HttpHookResponseInfo ) => void ) | undefined
107+ }
108+
77109/**
78110 * Configuration options for HTTP/HTTPS requests.
79111 */
@@ -136,6 +168,11 @@ export interface HttpRequestOptions {
136168 * ```
137169 */
138170 followRedirects ?: boolean | undefined
171+ /**
172+ * Lifecycle hooks for observing request/response events.
173+ * Hooks fire per-attempt — retries and redirects each trigger separate hook calls.
174+ */
175+ hooks ?: HttpHooks | undefined
139176 /**
140177 * HTTP headers to send with the request.
141178 * A `User-Agent` header is automatically added if not provided.
@@ -167,6 +204,14 @@ export interface HttpRequestOptions {
167204 * ```
168205 */
169206 maxRedirects ?: number | undefined
207+ /**
208+ * Maximum response body size in bytes. Responses exceeding this limit
209+ * will be rejected with an error. Prevents memory exhaustion from
210+ * unexpectedly large responses.
211+ *
212+ * @default undefined (no limit)
213+ */
214+ maxResponseSize ?: number | undefined
170215 /**
171216 * HTTP method to use for the request.
172217 *
@@ -281,7 +326,7 @@ export interface HttpResponse {
281326 * console.log(response.headers['set-cookie']) // May be string[]
282327 * ```
283328 */
284- headers : Record < string , string | string [ ] | undefined >
329+ headers : IncomingHttpHeaders
285330 /**
286331 * Parse response body as JSON.
287332 * Type parameter `T` allows specifying the expected JSON structure.
@@ -346,6 +391,12 @@ export interface HttpResponse {
346391 * ```
347392 */
348393 text ( ) : string
394+ /**
395+ * The underlying Node.js IncomingMessage for advanced use cases
396+ * (e.g., streaming, custom header inspection). Only available when
397+ * the response was not consumed by the convenience methods.
398+ */
399+ rawResponse ?: IncomingMessage | undefined
349400}
350401
351402/**
@@ -891,8 +942,47 @@ async function httpDownloadAttempt(
891942 } )
892943}
893944
945+ /**
946+ * Build an enriched error message based on the error code.
947+ * Generic guidance (no product-specific branding).
948+ */
949+ export function enrichErrorMessage (
950+ url : string ,
951+ method : string ,
952+ error : NodeJS . ErrnoException ,
953+ ) : string {
954+ const code = error . code
955+ let message = `${ method } request failed: ${ url } `
956+ if ( code === 'ECONNREFUSED' ) {
957+ message +=
958+ '\n→ Connection refused. Server is unreachable.\n→ Check: Network connectivity and firewall settings.'
959+ } else if ( code === 'ENOTFOUND' ) {
960+ message +=
961+ '\n→ DNS lookup failed. Cannot resolve hostname.\n→ Check: Internet connection and DNS settings.'
962+ } else if ( code === 'ETIMEDOUT' ) {
963+ message +=
964+ '\n→ Connection timed out. Network or server issue.\n→ Try: Check network connectivity and retry.'
965+ } else if ( code === 'ECONNRESET' ) {
966+ message +=
967+ '\n→ Connection reset by server. Possible network interruption.\n→ Try: Retry the request.'
968+ } else if ( code === 'EPIPE' ) {
969+ message +=
970+ '\n→ Broken pipe. Server closed connection unexpectedly.\n→ Check: Authentication credentials and permissions.'
971+ } else if (
972+ code === 'CERT_HAS_EXPIRED' ||
973+ code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
974+ ) {
975+ message +=
976+ '\n→ SSL/TLS certificate error.\n→ Check: System time and date are correct.\n→ Try: Update CA certificates on your system.'
977+ } else if ( code ) {
978+ message += `\n→ Error code: ${ code } `
979+ }
980+ return message
981+ }
982+
894983/**
895984 * Single HTTP request attempt (used internally by httpRequest with retry logic).
985+ * Supports hooks (fire per-attempt), maxResponseSize, and rawResponse.
896986 * @private
897987 */
898988async function httpRequestAttempt (
@@ -904,45 +994,65 @@ async function httpRequestAttempt(
904994 ca,
905995 followRedirects = true ,
906996 headers = { } ,
997+ hooks,
907998 maxRedirects = 5 ,
999+ maxResponseSize,
9081000 method = 'GET' ,
9091001 timeout = 30_000 ,
9101002 } = { __proto__ : null , ...options } as HttpRequestOptions
9111003
1004+ const startTime = Date . now ( )
1005+ const mergedHeaders = {
1006+ 'User-Agent' : 'socket-registry/1.0' ,
1007+ ...headers ,
1008+ }
1009+
1010+ hooks ?. onRequest ?.( { method, url, headers : mergedHeaders , timeout } )
1011+
9121012 return await new Promise ( ( resolve , reject ) => {
9131013 const parsedUrl = new URL ( url )
9141014 const isHttps = parsedUrl . protocol === 'https:'
9151015 const httpModule = isHttps ? getHttps ( ) : getHttp ( )
9161016
9171017 const requestOptions : Record < string , unknown > = {
918- headers : {
919- 'User-Agent' : 'socket-registry/1.0' ,
920- ...headers ,
921- } ,
1018+ headers : mergedHeaders ,
9221019 hostname : parsedUrl . hostname ,
9231020 method,
9241021 path : parsedUrl . pathname + parsedUrl . search ,
9251022 port : parsedUrl . port ,
9261023 timeout,
9271024 }
9281025
929- // Pass custom CA certificates for TLS connections.
9301026 if ( ca && isHttps ) {
9311027 requestOptions [ 'ca' ] = ca
9321028 }
9331029
1030+ const emitResponse = ( info : Partial < HttpHookResponseInfo > ) => {
1031+ hooks ?. onResponse ?.( {
1032+ duration : Date . now ( ) - startTime ,
1033+ method,
1034+ url,
1035+ ...info ,
1036+ } )
1037+ }
1038+
9341039 /* c8 ignore start - External HTTP/HTTPS request */
9351040 const request = httpModule . request (
9361041 requestOptions ,
9371042 ( res : IncomingMessage ) => {
938- // Handle redirects
9391043 if (
9401044 followRedirects &&
9411045 res . statusCode &&
9421046 res . statusCode >= 300 &&
9431047 res . statusCode < 400 &&
9441048 res . headers . location
9451049 ) {
1050+ emitResponse ( {
1051+ headers : res . headers ,
1052+ status : res . statusCode ,
1053+ statusText : res . statusMessage ,
1054+ } )
1055+
9461056 if ( maxRedirects <= 0 ) {
9471057 reject (
9481058 new Error (
@@ -952,12 +1062,10 @@ async function httpRequestAttempt(
9521062 return
9531063 }
9541064
955- // Follow redirect
9561065 const redirectUrl = res . headers . location . startsWith ( 'http' )
9571066 ? res . headers . location
9581067 : new URL ( res . headers . location , url ) . toString ( )
9591068
960- // Reject HTTPS-to-HTTP downgrade redirects.
9611069 const redirectParsed = new URL ( redirectUrl )
9621070 if ( isHttps && redirectParsed . protocol !== 'https:' ) {
9631071 reject (
@@ -974,17 +1082,32 @@ async function httpRequestAttempt(
9741082 ca,
9751083 followRedirects,
9761084 headers,
1085+ hooks,
9771086 maxRedirects : maxRedirects - 1 ,
1087+ maxResponseSize,
9781088 method,
9791089 timeout,
9801090 } ) ,
9811091 )
9821092 return
9831093 }
9841094
985- // Collect response data
9861095 const chunks : Buffer [ ] = [ ]
1096+ let totalBytes = 0
1097+
9871098 res . on ( 'data' , ( chunk : Buffer ) => {
1099+ totalBytes += chunk . length
1100+ if ( maxResponseSize && totalBytes > maxResponseSize ) {
1101+ res . destroy ( )
1102+ const sizeMB = ( totalBytes / ( 1024 * 1024 ) ) . toFixed ( 2 )
1103+ const maxMB = ( maxResponseSize / ( 1024 * 1024 ) ) . toFixed ( 2 )
1104+ const err = new Error (
1105+ `Response exceeds maximum size limit (${ sizeMB } MB > ${ maxMB } MB)` ,
1106+ )
1107+ emitResponse ( { error : err } )
1108+ reject ( err )
1109+ return
1110+ }
9881111 chunks . push ( chunk )
9891112 } )
9901113
@@ -1003,60 +1126,55 @@ async function httpRequestAttempt(
10031126 )
10041127 } ,
10051128 body : responseBody ,
1006- headers : res . headers as Record <
1007- string ,
1008- string | string [ ] | undefined
1009- > ,
1129+ headers : res . headers ,
10101130 json < T = unknown > ( ) : T {
10111131 return JSON . parse ( responseBody . toString ( 'utf8' ) ) as T
10121132 } ,
10131133 ok,
1134+ rawResponse : res ,
10141135 status : res . statusCode || 0 ,
10151136 statusText : res . statusMessage || '' ,
10161137 text ( ) : string {
10171138 return responseBody . toString ( 'utf8' )
10181139 } ,
10191140 }
10201141
1142+ emitResponse ( {
1143+ headers : res . headers ,
1144+ status : res . statusCode ,
1145+ statusText : res . statusMessage ,
1146+ } )
1147+
10211148 resolve ( response )
10221149 } )
10231150
10241151 res . on ( 'error' , ( error : Error ) => {
1152+ emitResponse ( { error } )
10251153 reject ( error )
10261154 } )
10271155 } ,
10281156 )
10291157
10301158 request . on ( 'error' , ( error : Error ) => {
1031- const code = ( error as NodeJS . ErrnoException ) . code
1032- let message = `HTTP request failed for ${ url } : ${ error . message } \n`
1033-
1034- if ( code === 'ENOTFOUND' ) {
1035- message +=
1036- 'DNS lookup failed. Check the hostname and your network connection.'
1037- } else if ( code === 'ECONNREFUSED' ) {
1038- message +=
1039- 'Connection refused. Verify the server is running and accessible.'
1040- } else if ( code === 'ETIMEDOUT' ) {
1041- message +=
1042- 'Request timed out. Check your network or increase the timeout value.'
1043- } else if ( code === 'ECONNRESET' ) {
1044- message +=
1045- 'Connection reset. The server may have closed the connection unexpectedly.'
1046- } else {
1047- message +=
1048- 'Check your network connection and verify the URL is correct.'
1049- }
1050-
1051- reject ( new Error ( message , { cause : error } ) )
1159+ const message = enrichErrorMessage (
1160+ url ,
1161+ method ,
1162+ error as NodeJS . ErrnoException ,
1163+ )
1164+ const enhanced = new Error ( message , { cause : error } )
1165+ emitResponse ( { error : enhanced } )
1166+ reject ( enhanced )
10521167 } )
10531168
10541169 request . on ( 'timeout' , ( ) => {
10551170 request . destroy ( )
1056- reject ( new Error ( `Request timed out after ${ timeout } ms` ) )
1171+ const err = new Error (
1172+ `${ method } request timed out after ${ timeout } ms: ${ url } \n→ Server did not respond in time.\n→ Try: Increase timeout or check network connectivity.` ,
1173+ )
1174+ emitResponse ( { error : err } )
1175+ reject ( err )
10571176 } )
10581177
1059- // Send body if present
10601178 if ( body ) {
10611179 request . write ( body )
10621180 }
@@ -1384,7 +1502,9 @@ export async function httpRequest(
13841502 ca,
13851503 followRedirects = true ,
13861504 headers = { } ,
1505+ hooks,
13871506 maxRedirects = 5 ,
1507+ maxResponseSize,
13881508 method = 'GET' ,
13891509 retries = 0 ,
13901510 retryDelay = 1000 ,
@@ -1401,7 +1521,9 @@ export async function httpRequest(
14011521 ca,
14021522 followRedirects,
14031523 headers,
1524+ hooks,
14041525 maxRedirects,
1526+ maxResponseSize,
14051527 method,
14061528 timeout,
14071529 } )
0 commit comments