@@ -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 ?: Record < string , string | string [ ] | undefined > | 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 *
@@ -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,48 @@ 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+ * @private
949+ */
950+ function enrichErrorMessage (
951+ url : string ,
952+ method : string ,
953+ error : NodeJS . ErrnoException ,
954+ ) : string {
955+ const code = error . code
956+ let message = `${ method } request failed: ${ url } `
957+ if ( code === 'ECONNREFUSED' ) {
958+ message +=
959+ '\n→ Connection refused. Server is unreachable.\n→ Check: Network connectivity and firewall settings.'
960+ } else if ( code === 'ENOTFOUND' ) {
961+ message +=
962+ '\n→ DNS lookup failed. Cannot resolve hostname.\n→ Check: Internet connection and DNS settings.'
963+ } else if ( code === 'ETIMEDOUT' ) {
964+ message +=
965+ '\n→ Connection timed out. Network or server issue.\n→ Try: Check network connectivity and retry.'
966+ } else if ( code === 'ECONNRESET' ) {
967+ message +=
968+ '\n→ Connection reset by server. Possible network interruption.\n→ Try: Retry the request.'
969+ } else if ( code === 'EPIPE' ) {
970+ message +=
971+ '\n→ Broken pipe. Server closed connection unexpectedly.\n→ Check: Authentication credentials and permissions.'
972+ } else if (
973+ code === 'CERT_HAS_EXPIRED' ||
974+ code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
975+ ) {
976+ message +=
977+ '\n→ SSL/TLS certificate error.\n→ Check: System time and date are correct.\n→ Try: Update CA certificates on your system.'
978+ } else if ( code ) {
979+ message += `\n→ Error code: ${ code } `
980+ }
981+ return message
982+ }
983+
894984/**
895985 * Single HTTP request attempt (used internally by httpRequest with retry logic).
986+ * Supports hooks (fire per-attempt), maxResponseSize, and rawResponse.
896987 * @private
897988 */
898989async function httpRequestAttempt (
@@ -904,45 +995,68 @@ async function httpRequestAttempt(
904995 ca,
905996 followRedirects = true ,
906997 headers = { } ,
998+ hooks,
907999 maxRedirects = 5 ,
1000+ maxResponseSize,
9081001 method = 'GET' ,
9091002 timeout = 30_000 ,
9101003 } = { __proto__ : null , ...options } as HttpRequestOptions
9111004
1005+ const startTime = Date . now ( )
1006+ const mergedHeaders = {
1007+ 'User-Agent' : 'socket-registry/1.0' ,
1008+ ...headers ,
1009+ }
1010+
1011+ hooks ?. onRequest ?.( { method, url, headers : mergedHeaders , timeout } )
1012+
9121013 return await new Promise ( ( resolve , reject ) => {
9131014 const parsedUrl = new URL ( url )
9141015 const isHttps = parsedUrl . protocol === 'https:'
9151016 const httpModule = isHttps ? getHttps ( ) : getHttp ( )
9161017
9171018 const requestOptions : Record < string , unknown > = {
918- headers : {
919- 'User-Agent' : 'socket-registry/1.0' ,
920- ...headers ,
921- } ,
1019+ headers : mergedHeaders ,
9221020 hostname : parsedUrl . hostname ,
9231021 method,
9241022 path : parsedUrl . pathname + parsedUrl . search ,
9251023 port : parsedUrl . port ,
9261024 timeout,
9271025 }
9281026
929- // Pass custom CA certificates for TLS connections.
9301027 if ( ca && isHttps ) {
9311028 requestOptions [ 'ca' ] = ca
9321029 }
9331030
1031+ const emitResponse = ( info : Partial < HttpHookResponseInfo > ) => {
1032+ hooks ?. onResponse ?.( {
1033+ duration : Date . now ( ) - startTime ,
1034+ method,
1035+ url,
1036+ ...info ,
1037+ } )
1038+ }
1039+
9341040 /* c8 ignore start - External HTTP/HTTPS request */
9351041 const request = httpModule . request (
9361042 requestOptions ,
9371043 ( res : IncomingMessage ) => {
938- // Handle redirects
9391044 if (
9401045 followRedirects &&
9411046 res . statusCode &&
9421047 res . statusCode >= 300 &&
9431048 res . statusCode < 400 &&
9441049 res . headers . location
9451050 ) {
1051+ emitResponse ( {
1052+ headers : res . headers as Record <
1053+ string ,
1054+ string | string [ ] | undefined
1055+ > ,
1056+ status : res . statusCode ,
1057+ statusText : res . statusMessage ,
1058+ } )
1059+
9461060 if ( maxRedirects <= 0 ) {
9471061 reject (
9481062 new Error (
@@ -952,12 +1066,10 @@ async function httpRequestAttempt(
9521066 return
9531067 }
9541068
955- // Follow redirect
9561069 const redirectUrl = res . headers . location . startsWith ( 'http' )
9571070 ? res . headers . location
9581071 : new URL ( res . headers . location , url ) . toString ( )
9591072
960- // Reject HTTPS-to-HTTP downgrade redirects.
9611073 const redirectParsed = new URL ( redirectUrl )
9621074 if ( isHttps && redirectParsed . protocol !== 'https:' ) {
9631075 reject (
@@ -974,17 +1086,32 @@ async function httpRequestAttempt(
9741086 ca,
9751087 followRedirects,
9761088 headers,
1089+ hooks,
9771090 maxRedirects : maxRedirects - 1 ,
1091+ maxResponseSize,
9781092 method,
9791093 timeout,
9801094 } ) ,
9811095 )
9821096 return
9831097 }
9841098
985- // Collect response data
9861099 const chunks : Buffer [ ] = [ ]
1100+ let totalBytes = 0
1101+
9871102 res . on ( 'data' , ( chunk : Buffer ) => {
1103+ totalBytes += chunk . length
1104+ if ( maxResponseSize && totalBytes > maxResponseSize ) {
1105+ res . destroy ( )
1106+ const sizeMB = ( totalBytes / ( 1024 * 1024 ) ) . toFixed ( 2 )
1107+ const maxMB = ( maxResponseSize / ( 1024 * 1024 ) ) . toFixed ( 2 )
1108+ const err = new Error (
1109+ `Response exceeds maximum size limit (${ sizeMB } MB > ${ maxMB } MB)` ,
1110+ )
1111+ emitResponse ( { error : err } )
1112+ reject ( err )
1113+ return
1114+ }
9881115 chunks . push ( chunk )
9891116 } )
9901117
@@ -1011,52 +1138,53 @@ async function httpRequestAttempt(
10111138 return JSON . parse ( responseBody . toString ( 'utf8' ) ) as T
10121139 } ,
10131140 ok,
1141+ rawResponse : res ,
10141142 status : res . statusCode || 0 ,
10151143 statusText : res . statusMessage || '' ,
10161144 text ( ) : string {
10171145 return responseBody . toString ( 'utf8' )
10181146 } ,
10191147 }
10201148
1149+ emitResponse ( {
1150+ headers : res . headers as Record <
1151+ string ,
1152+ string | string [ ] | undefined
1153+ > ,
1154+ status : res . statusCode ,
1155+ statusText : res . statusMessage ,
1156+ } )
1157+
10211158 resolve ( response )
10221159 } )
10231160
10241161 res . on ( 'error' , ( error : Error ) => {
1162+ emitResponse ( { error } )
10251163 reject ( error )
10261164 } )
10271165 } ,
10281166 )
10291167
10301168 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 } ) )
1169+ const message = enrichErrorMessage (
1170+ url ,
1171+ method ,
1172+ error as NodeJS . ErrnoException ,
1173+ )
1174+ const enhanced = new Error ( message , { cause : error } )
1175+ emitResponse ( { error : enhanced } )
1176+ reject ( enhanced )
10521177 } )
10531178
10541179 request . on ( 'timeout' , ( ) => {
10551180 request . destroy ( )
1056- reject ( new Error ( `Request timed out after ${ timeout } ms` ) )
1181+ const err = new Error (
1182+ `${ method } request timed out after ${ timeout } ms: ${ url } \n→ Server did not respond in time.\n→ Try: Increase timeout or check network connectivity.` ,
1183+ )
1184+ emitResponse ( { error : err } )
1185+ reject ( err )
10571186 } )
10581187
1059- // Send body if present
10601188 if ( body ) {
10611189 request . write ( body )
10621190 }
@@ -1384,7 +1512,9 @@ export async function httpRequest(
13841512 ca,
13851513 followRedirects = true ,
13861514 headers = { } ,
1515+ hooks,
13871516 maxRedirects = 5 ,
1517+ maxResponseSize,
13881518 method = 'GET' ,
13891519 retries = 0 ,
13901520 retryDelay = 1000 ,
@@ -1401,7 +1531,9 @@ export async function httpRequest(
14011531 ca,
14021532 followRedirects,
14031533 headers,
1534+ hooks,
14041535 maxRedirects,
1536+ maxResponseSize,
14051537 method,
14061538 timeout,
14071539 } )
0 commit comments