@socketsecurity/lib 5.13.0 → 5.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.15.0](https://github.com/SocketDev/socket-lib/releases/tag/v5.15.0) - 2026-04-06
9
+
10
+ ### Added — http-request
11
+
12
+ - `stream` option on `HttpRequestOptions` — resolves with `HttpResponse` immediately after headers arrive, leaving `rawResponse` unconsumed for piping to files
13
+ - `headers`, `ok`, `status`, `statusText` fields on `HttpDownloadResult`
14
+
15
+ ### Changed — http-request
16
+
17
+ - `httpDownload` now uses `httpRequest` with `stream: true` internally, eliminating ~120 lines of duplicated HTTP plumbing
18
+
19
+ ## [5.14.0](https://github.com/SocketDev/socket-lib/releases/tag/v5.14.0) - 2026-04-06
20
+
21
+ ### Added — http-request
22
+
23
+ - `HttpResponseError` class — thrown on non-2xx when `throwOnError` is enabled, carries the full `HttpResponse`
24
+ - `throwOnError` option on `HttpRequestOptions` — non-2xx responses throw instead of resolving with `ok: false`, enabling retry of HTTP errors
25
+ - `onRetry` callback on `HttpRequestOptions` — customize retry behavior per-attempt (return `false` to stop, a `number` to override delay, `undefined` for default backoff)
26
+ - Streaming body support — `body` accepts `Readable` streams (incl. `form-data` npm package), auto-merges `getHeaders()` when present
27
+ - `parseRetryAfterHeader()` — standalone RFC 7231 §7.1.3 `Retry-After` header parser (strict integer seconds + HTTP-date formats)
28
+ - `sanitizeHeaders()` — redact sensitive headers (`authorization`, `cookie`, `set-cookie`, `proxy-authorization`, `proxy-authenticate`, `www-authenticate`) for safe logging
29
+
30
+ ### Changed — http-request
31
+
32
+ - `HttpRequestOptions.body` type widened from `Buffer | string` to `Buffer | Readable | string`
33
+ - Redirect responses now drained via `res.resume()` to free sockets
34
+ - `maxResponseSize` exceeded now cleans up both response and request
35
+ - `onResponse` hooks wrapped in try/catch — user hook errors can no longer leave promises pending
36
+
8
37
  ## [5.13.0](https://github.com/SocketDev/socket-lib/releases/tag/v5.13.0) - 2026-04-05
9
38
 
10
39
  ### Added — http-request
@@ -1,3 +1,19 @@
1
+ /**
2
+ * @fileoverview HTTP/HTTPS request utilities using Node.js built-in modules with retry logic, redirects, and download support.
3
+ *
4
+ * This module provides a fetch-like API built on top of Node.js native `http` and `https` modules.
5
+ * It supports automatic retries with exponential backoff, redirect following, streaming downloads,
6
+ * and provides a familiar fetch-style response interface.
7
+ *
8
+ * Key Features:
9
+ * - Automatic retries with exponential backoff for failed requests.
10
+ * - Redirect following with configurable max redirects.
11
+ * - Streaming downloads with progress callbacks.
12
+ * - Fetch-like response interface (`.json()`, `.text()`, `.arrayBuffer()`).
13
+ * - Timeout support for all operations.
14
+ * - Zero dependencies on external HTTP libraries.
15
+ */
16
+ import type { Readable } from 'node:stream';
1
17
  import type { IncomingHttpHeaders, IncomingMessage } from 'http';
2
18
  /** IncomingMessage received as a response to a client request (http.request callback). */
3
19
  export type IncomingResponse = IncomingMessage;
@@ -39,7 +55,17 @@ export interface HttpHooks {
39
55
  export interface HttpRequestOptions {
40
56
  /**
41
57
  * Request body to send.
42
- * Can be a string (e.g., JSON) or Buffer (e.g., binary data).
58
+ * Can be a string, Buffer, or Readable stream.
59
+ *
60
+ * When a Readable stream is provided, it is piped directly to the request.
61
+ * If the stream has a `getHeaders()` method (duck-typed, e.g., the `form-data`
62
+ * npm package), its headers (Content-Type with boundary) are automatically
63
+ * merged into the request headers.
64
+ *
65
+ * **Note:** Streaming bodies are one-shot — they cannot be replayed. Using a
66
+ * Readable body with `retries > 0` throws an error. Buffer the body as a
67
+ * string/Buffer if retries are needed. Redirects are also disabled for
68
+ * streaming bodies since the stream is consumed on the first request.
43
69
  *
44
70
  * @example
45
71
  * ```ts
@@ -56,9 +82,18 @@ export interface HttpRequestOptions {
56
82
  * method: 'POST',
57
83
  * body: buffer
58
84
  * })
85
+ *
86
+ * // Stream form-data (npm package, not native FormData)
87
+ * import FormData from 'form-data'
88
+ * const form = new FormData()
89
+ * form.append('file', createReadStream('data.json'))
90
+ * await httpRequest('https://api.example.com/upload', {
91
+ * method: 'POST',
92
+ * body: form // auto-merges form.getHeaders()
93
+ * })
59
94
  * ```
60
95
  */
61
- body?: Buffer | string | undefined;
96
+ body?: Buffer | Readable | string | undefined;
62
97
  /**
63
98
  * Custom CA certificates for TLS connections.
64
99
  * When provided, these certificates are combined with the default trust
@@ -162,6 +197,38 @@ export interface HttpRequestOptions {
162
197
  * ```
163
198
  */
164
199
  method?: string | undefined;
200
+ /**
201
+ * Callback invoked before each retry attempt.
202
+ * Allows customizing retry behavior per-attempt (e.g., skip 4xx, honor Retry-After).
203
+ *
204
+ * @param attempt - Current retry attempt number (1-based)
205
+ * @param error - The error that triggered the retry (HttpResponseError for HTTP errors)
206
+ * @param delay - The calculated delay in ms before next retry
207
+ * @returns `false` to stop retrying and rethrow,
208
+ * a `number` to override the delay (ms),
209
+ * or `undefined` to use the calculated delay
210
+ *
211
+ * @example
212
+ * ```ts
213
+ * await httpRequest('https://api.example.com/data', {
214
+ * retries: 3,
215
+ * throwOnError: true,
216
+ * onRetry: (attempt, error, delay) => {
217
+ * // Don't retry client errors (except 429)
218
+ * if (error instanceof HttpResponseError) {
219
+ * if (error.response.status === 429) {
220
+ * const retryAfter = parseRetryAfterHeader(error.response.headers['retry-after'])
221
+ * return retryAfter ?? undefined
222
+ * }
223
+ * if (error.response.status >= 400 && error.response.status < 500) {
224
+ * return false
225
+ * }
226
+ * }
227
+ * }
228
+ * })
229
+ * ```
230
+ */
231
+ onRetry?: ((attempt: number, error: unknown, delay: number) => boolean | number | undefined) | undefined;
165
232
  /**
166
233
  * Number of retry attempts for failed requests.
167
234
  * Uses exponential backoff: delay = `retryDelay` * 2^attempt.
@@ -194,6 +261,37 @@ export interface HttpRequestOptions {
194
261
  * ```
195
262
  */
196
263
  retryDelay?: number | undefined;
264
+ /**
265
+ * When true, non-2xx HTTP responses throw an `HttpResponseError` instead
266
+ * of resolving with `response.ok === false`. This makes HTTP error
267
+ * responses eligible for retry via the `retries` option.
268
+ *
269
+ * @default false
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * // Throw on 4xx/5xx responses (enabling retry for 5xx)
274
+ * await httpRequest('https://api.example.com/data', {
275
+ * throwOnError: true,
276
+ * retries: 3
277
+ * })
278
+ * ```
279
+ */
280
+ /**
281
+ * When true, resolve with an HttpResponse whose body is NOT buffered.
282
+ * The `rawResponse` property contains the unconsumed IncomingResponse
283
+ * stream for piping to files or other destinations.
284
+ *
285
+ * `body`, `text()`, `json()`, and `arrayBuffer()` return empty/zero
286
+ * values since the stream has not been read.
287
+ *
288
+ * Incompatible with `maxResponseSize` (size enforcement requires
289
+ * reading the body).
290
+ *
291
+ * @default false
292
+ */
293
+ stream?: boolean | undefined;
294
+ throwOnError?: boolean | undefined;
197
295
  /**
198
296
  * Request timeout in milliseconds.
199
297
  * If the request takes longer than this, it will be aborted.
@@ -333,6 +431,56 @@ export interface HttpResponse {
333
431
  * into the standard HttpResponse interface.
334
432
  */
335
433
  export declare function readIncomingResponse(msg: IncomingResponse): Promise<HttpResponse>;
434
+ /**
435
+ * Error thrown when an HTTP response has a non-2xx status code
436
+ * and `throwOnError` is enabled. Carries the full `HttpResponse`
437
+ * so callers can inspect status, headers, and body.
438
+ */
439
+ export declare class HttpResponseError extends Error {
440
+ response: HttpResponse;
441
+ constructor(response: HttpResponse, message?: string | undefined);
442
+ }
443
+ /**
444
+ * Parse a `Retry-After` HTTP header value into milliseconds.
445
+ *
446
+ * Supports both formats defined in RFC 7231 §7.1.3:
447
+ * - **delay-seconds**: integer number of seconds (e.g., `"120"`)
448
+ * - **HTTP-date**: an absolute date/time (e.g., `"Fri, 31 Dec 2027 23:59:59 GMT"`)
449
+ *
450
+ * When the header is an array (multiple values), the first element is used.
451
+ *
452
+ * @param value - The raw Retry-After header value(s)
453
+ * @returns Delay in milliseconds, or `undefined` if the value cannot be parsed
454
+ *
455
+ * @example
456
+ * ```ts
457
+ * const delay = parseRetryAfterHeader(response.headers['retry-after'])
458
+ * if (delay !== undefined) {
459
+ * await new Promise(resolve => setTimeout(resolve, delay))
460
+ * }
461
+ * ```
462
+ */
463
+ export declare function parseRetryAfterHeader(value: string | string[] | undefined): number | undefined;
464
+ /**
465
+ * Redact sensitive HTTP headers for safe logging and telemetry.
466
+ *
467
+ * Replaces values of sensitive headers (Authorization, Cookie, etc.)
468
+ * with `[REDACTED]`. Non-sensitive headers are passed through unchanged.
469
+ * Array values are joined with `', '`.
470
+ *
471
+ * @param headers - HTTP headers to sanitize
472
+ * @returns A new object with sensitive values redacted
473
+ *
474
+ * @example
475
+ * ```ts
476
+ * const safe = sanitizeHeaders({
477
+ * 'authorization': 'Bearer secret',
478
+ * 'content-type': 'application/json'
479
+ * })
480
+ * // { authorization: '[REDACTED]', 'content-type': 'application/json' }
481
+ * ```
482
+ */
483
+ export declare function sanitizeHeaders(headers: Record<string, unknown> | undefined): Record<string, string>;
336
484
  /**
337
485
  * Configuration options for file downloads.
338
486
  */
@@ -523,26 +671,18 @@ export interface HttpDownloadOptions {
523
671
  * Result of a successful file download.
524
672
  */
525
673
  export interface HttpDownloadResult {
526
- /**
527
- * Absolute path where the file was saved.
528
- *
529
- * @example
530
- * ```ts
531
- * const result = await httpDownload('https://example.com/file.zip', '/tmp/file.zip')
532
- * console.log(`Downloaded to: ${result.path}`)
533
- * ```
534
- */
674
+ /** HTTP response headers from the final response (after redirects). */
675
+ headers: IncomingHttpHeaders;
676
+ /** Whether the download succeeded (status 200-299). Always true on success (non-2xx throws). */
677
+ ok: true;
678
+ /** Absolute path where the file was saved. */
535
679
  path: string;
536
- /**
537
- * Total size of downloaded file in bytes.
538
- *
539
- * @example
540
- * ```ts
541
- * const result = await httpDownload('https://example.com/file.zip', '/tmp/file.zip')
542
- * console.log(`Downloaded ${result.size} bytes`)
543
- * ```
544
- */
680
+ /** Total size of downloaded file in bytes. */
545
681
  size: number;
682
+ /** HTTP status code from the final response (after redirects). */
683
+ status: number;
684
+ /** HTTP status message from the final response (after redirects). */
685
+ statusText: string;
546
686
  }
547
687
  /**
548
688
  * Map of filenames to their SHA256 hashes.
@@ -19,6 +19,7 @@ var __copyProps = (to, from, except, desc) => {
19
19
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
20
  var http_request_exports = {};
21
21
  __export(http_request_exports, {
22
+ HttpResponseError: () => HttpResponseError,
22
23
  enrichErrorMessage: () => enrichErrorMessage,
23
24
  fetchChecksums: () => fetchChecksums,
24
25
  httpDownload: () => httpDownload,
@@ -26,7 +27,9 @@ __export(http_request_exports, {
26
27
  httpRequest: () => httpRequest,
27
28
  httpText: () => httpText,
28
29
  parseChecksums: () => parseChecksums,
29
- readIncomingResponse: () => readIncomingResponse
30
+ parseRetryAfterHeader: () => parseRetryAfterHeader,
31
+ readIncomingResponse: () => readIncomingResponse,
32
+ sanitizeHeaders: () => sanitizeHeaders
30
33
  });
31
34
  module.exports = __toCommonJS(http_request_exports);
32
35
  var import_fs = require("./fs.js");
@@ -85,6 +88,64 @@ async function readIncomingResponse(msg) {
85
88
  text: () => body.toString("utf8")
86
89
  };
87
90
  }
91
+ class HttpResponseError extends Error {
92
+ response;
93
+ constructor(response, message) {
94
+ const statusCode = response.status ?? "unknown";
95
+ const statusMessage = response.statusText || "No status message";
96
+ super(message ?? `HTTP ${statusCode}: ${statusMessage}`);
97
+ this.name = "HttpResponseError";
98
+ this.response = response;
99
+ Error.captureStackTrace(this, HttpResponseError);
100
+ }
101
+ }
102
+ function parseRetryAfterHeader(value) {
103
+ if (!value) {
104
+ return void 0;
105
+ }
106
+ const raw = Array.isArray(value) ? value[0] : value;
107
+ if (!raw) {
108
+ return void 0;
109
+ }
110
+ const trimmed = raw.trim();
111
+ if (/^\d+$/.test(trimmed)) {
112
+ const seconds = Number(trimmed);
113
+ return seconds * 1e3;
114
+ }
115
+ const date = new Date(raw);
116
+ if (!Number.isNaN(date.getTime())) {
117
+ const delayMs = date.getTime() - Date.now();
118
+ if (delayMs > 0) {
119
+ return delayMs;
120
+ }
121
+ }
122
+ return void 0;
123
+ }
124
+ function sanitizeHeaders(headers) {
125
+ if (!headers) {
126
+ return {};
127
+ }
128
+ const sensitiveHeaders = /* @__PURE__ */ new Set([
129
+ "authorization",
130
+ "cookie",
131
+ "proxy-authorization",
132
+ "proxy-authenticate",
133
+ "set-cookie",
134
+ "www-authenticate"
135
+ ]);
136
+ const result = { __proto__: null };
137
+ for (const key of Object.keys(headers)) {
138
+ const value = headers[key];
139
+ if (sensitiveHeaders.has(key.toLowerCase())) {
140
+ result[key] = "[REDACTED]";
141
+ } else if (Array.isArray(value)) {
142
+ result[key] = value.join(", ");
143
+ } else if (value !== void 0 && value !== null) {
144
+ result[key] = String(value);
145
+ }
146
+ }
147
+ return result;
148
+ }
88
149
  function parseChecksums(text) {
89
150
  const checksums = { __proto__: null };
90
151
  for (const line of text.split("\n")) {
@@ -132,135 +193,61 @@ async function httpDownloadAttempt(url, destPath, options) {
132
193
  onProgress,
133
194
  timeout = 12e4
134
195
  } = { __proto__: null, ...options };
196
+ const response = await httpRequestAttempt(url, {
197
+ ca,
198
+ followRedirects,
199
+ headers,
200
+ maxRedirects,
201
+ method: "GET",
202
+ stream: true,
203
+ timeout
204
+ });
205
+ if (!response.ok) {
206
+ throw new Error(
207
+ `Download failed: HTTP ${response.status} ${response.statusText}`
208
+ );
209
+ }
210
+ const res = response.rawResponse;
211
+ if (!res) {
212
+ throw new Error("Stream response missing rawResponse");
213
+ }
214
+ const { createWriteStream } = /* @__PURE__ */ getFs();
215
+ const totalSize = Number.parseInt(
216
+ response.headers["content-length"] || "0",
217
+ 10
218
+ );
135
219
  return await new Promise((resolve, reject) => {
136
- const parsedUrl = new URL(url);
137
- const isHttps = parsedUrl.protocol === "https:";
138
- const httpModule = isHttps ? /* @__PURE__ */ getHttps() : /* @__PURE__ */ getHttp();
139
- const requestOptions = {
140
- headers: {
141
- "User-Agent": "socket-registry/1.0",
142
- ...headers
143
- },
144
- hostname: parsedUrl.hostname,
145
- method: "GET",
146
- path: parsedUrl.pathname + parsedUrl.search,
147
- port: parsedUrl.port,
148
- timeout
149
- };
150
- if (ca && isHttps) {
151
- requestOptions["ca"] = ca;
152
- }
153
- const { createWriteStream } = /* @__PURE__ */ getFs();
154
- let fileStream;
155
- let streamClosed = false;
156
- const closeStream = () => {
157
- if (!streamClosed && fileStream) {
158
- streamClosed = true;
159
- fileStream.close();
220
+ let downloadedSize = 0;
221
+ const fileStream = createWriteStream(destPath);
222
+ fileStream.on("error", (error) => {
223
+ fileStream.close();
224
+ reject(
225
+ new Error(`Failed to write file: ${error.message}`, { cause: error })
226
+ );
227
+ });
228
+ res.on("data", (chunk) => {
229
+ downloadedSize += chunk.length;
230
+ if (onProgress && totalSize > 0) {
231
+ onProgress(downloadedSize, totalSize);
160
232
  }
161
- };
162
- const request = httpModule.request(
163
- requestOptions,
164
- (res) => {
165
- if (followRedirects && res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
166
- if (maxRedirects <= 0) {
167
- reject(
168
- new Error(
169
- `Too many redirects (exceeded maximum: ${maxRedirects})`
170
- )
171
- );
172
- return;
173
- }
174
- const redirectUrl = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, url).toString();
175
- const redirectParsed = new URL(redirectUrl);
176
- if (isHttps && redirectParsed.protocol !== "https:") {
177
- reject(
178
- new Error(
179
- `Redirect from HTTPS to HTTP is not allowed: ${redirectUrl}`
180
- )
181
- );
182
- return;
183
- }
184
- resolve(
185
- httpDownloadAttempt(redirectUrl, destPath, {
186
- ca,
187
- followRedirects,
188
- headers,
189
- maxRedirects: maxRedirects - 1,
190
- onProgress,
191
- timeout
192
- })
193
- );
194
- return;
195
- }
196
- if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
197
- closeStream();
198
- reject(
199
- new Error(
200
- `Download failed: HTTP ${res.statusCode} ${res.statusMessage}`
201
- )
202
- );
203
- return;
204
- }
205
- const totalSize = Number.parseInt(
206
- res.headers["content-length"] || "0",
207
- 10
208
- );
209
- let downloadedSize = 0;
210
- fileStream = createWriteStream(destPath);
211
- fileStream.on("error", (error) => {
212
- closeStream();
213
- const err = new Error(`Failed to write file: ${error.message}`, {
214
- cause: error
215
- });
216
- reject(err);
217
- });
218
- res.on("data", (chunk) => {
219
- downloadedSize += chunk.length;
220
- if (onProgress && totalSize > 0) {
221
- onProgress(downloadedSize, totalSize);
222
- }
223
- });
224
- res.on("end", () => {
225
- fileStream?.close(() => {
226
- streamClosed = true;
227
- resolve({
228
- path: destPath,
229
- size: downloadedSize
230
- });
231
- });
232
- });
233
- res.on("error", (error) => {
234
- closeStream();
235
- reject(error);
233
+ });
234
+ res.on("end", () => {
235
+ fileStream.close(() => {
236
+ resolve({
237
+ headers: response.headers,
238
+ ok: true,
239
+ path: destPath,
240
+ size: downloadedSize,
241
+ status: response.status,
242
+ statusText: response.statusText
236
243
  });
237
- res.pipe(fileStream);
238
- }
239
- );
240
- request.on("error", (error) => {
241
- closeStream();
242
- const code = error.code;
243
- let message = `HTTP download failed for ${url}: ${error.message}
244
- `;
245
- if (code === "ENOTFOUND") {
246
- message += "DNS lookup failed. Check the hostname and your network connection.";
247
- } else if (code === "ECONNREFUSED") {
248
- message += "Connection refused. Verify the server is running and accessible.";
249
- } else if (code === "ETIMEDOUT") {
250
- message += "Request timed out. Check your network or increase the timeout value.";
251
- } else if (code === "ECONNRESET") {
252
- message += "Connection reset. The server may have closed the connection unexpectedly.";
253
- } else {
254
- message += "Check your network connection and verify the URL is correct.";
255
- }
256
- reject(new Error(message, { cause: error }));
244
+ });
257
245
  });
258
- request.on("timeout", () => {
259
- request.destroy();
260
- closeStream();
261
- reject(new Error(`Download timed out after ${timeout}ms`));
246
+ res.on("error", (error) => {
247
+ fileStream.close();
248
+ reject(error);
262
249
  });
263
- request.end();
250
+ res.pipe(fileStream);
264
251
  });
265
252
  }
266
253
  function enrichErrorMessage(url, method, error) {
@@ -294,15 +281,38 @@ async function httpRequestAttempt(url, options) {
294
281
  maxRedirects = 5,
295
282
  maxResponseSize,
296
283
  method = "GET",
284
+ stream = false,
297
285
  timeout = 3e4
298
286
  } = { __proto__: null, ...options };
299
287
  const startTime = Date.now();
288
+ const streamHeaders = body && typeof body === "object" && "getHeaders" in body && typeof body.getHeaders === "function" ? body.getHeaders() : void 0;
300
289
  const mergedHeaders = {
301
290
  "User-Agent": "socket-registry/1.0",
291
+ ...streamHeaders,
302
292
  ...headers
303
293
  };
304
294
  hooks?.onRequest?.({ method, url, headers: mergedHeaders, timeout });
305
295
  return await new Promise((resolve, reject) => {
296
+ let settled = false;
297
+ const resolveOnce = (response) => {
298
+ if (settled) {
299
+ return;
300
+ }
301
+ settled = true;
302
+ resolve(response);
303
+ };
304
+ const rejectOnce = (err) => {
305
+ if (settled) {
306
+ return;
307
+ }
308
+ settled = true;
309
+ if (body && typeof body === "object" && typeof body.destroy === "function") {
310
+ ;
311
+ body.destroy();
312
+ }
313
+ emitResponse({ error: err });
314
+ reject(err);
315
+ };
306
316
  const parsedUrl = new URL(url);
307
317
  const isHttps = parsedUrl.protocol === "https:";
308
318
  const httpModule = isHttps ? /* @__PURE__ */ getHttps() : /* @__PURE__ */ getHttp();
@@ -318,23 +328,28 @@ async function httpRequestAttempt(url, options) {
318
328
  requestOptions["ca"] = ca;
319
329
  }
320
330
  const emitResponse = (info) => {
321
- hooks?.onResponse?.({
322
- duration: Date.now() - startTime,
323
- method,
324
- url,
325
- ...info
326
- });
331
+ try {
332
+ hooks?.onResponse?.({
333
+ duration: Date.now() - startTime,
334
+ method,
335
+ url,
336
+ ...info
337
+ });
338
+ } catch {
339
+ }
327
340
  };
328
341
  const request = httpModule.request(
329
342
  requestOptions,
330
343
  (res) => {
331
344
  if (followRedirects && res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
345
+ res.resume();
332
346
  emitResponse({
333
347
  headers: res.headers,
334
348
  status: res.statusCode,
335
349
  statusText: res.statusMessage
336
350
  });
337
351
  if (maxRedirects <= 0) {
352
+ settled = true;
338
353
  reject(
339
354
  new Error(
340
355
  `Too many redirects (exceeded maximum: ${maxRedirects})`
@@ -345,6 +360,7 @@ async function httpRequestAttempt(url, options) {
345
360
  const redirectUrl = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, url).toString();
346
361
  const redirectParsed = new URL(redirectUrl);
347
362
  if (isHttps && redirectParsed.protocol !== "https:") {
363
+ settled = true;
348
364
  reject(
349
365
  new Error(
350
366
  `Redirect from HTTPS to HTTP is not allowed: ${redirectUrl}`
@@ -352,6 +368,7 @@ async function httpRequestAttempt(url, options) {
352
368
  );
353
369
  return;
354
370
  }
371
+ settled = true;
355
372
  resolve(
356
373
  httpRequestAttempt(redirectUrl, {
357
374
  body,
@@ -362,29 +379,59 @@ async function httpRequestAttempt(url, options) {
362
379
  maxRedirects: maxRedirects - 1,
363
380
  maxResponseSize,
364
381
  method,
382
+ stream,
365
383
  timeout
366
384
  })
367
385
  );
368
386
  return;
369
387
  }
388
+ if (stream) {
389
+ const status = res.statusCode || 0;
390
+ const statusText = res.statusMessage || "";
391
+ const ok = status >= 200 && status < 300;
392
+ emitResponse({
393
+ headers: res.headers,
394
+ status,
395
+ statusText
396
+ });
397
+ const emptyBody = Buffer.alloc(0);
398
+ resolveOnce({
399
+ arrayBuffer: () => emptyBody.buffer,
400
+ body: emptyBody,
401
+ headers: res.headers,
402
+ json: () => {
403
+ throw new Error("Cannot parse JSON from a streaming response");
404
+ },
405
+ ok,
406
+ rawResponse: res,
407
+ status,
408
+ statusText,
409
+ text: () => ""
410
+ });
411
+ return;
412
+ }
370
413
  const chunks = [];
371
414
  let totalBytes = 0;
372
415
  res.on("data", (chunk) => {
373
416
  totalBytes += chunk.length;
374
417
  if (maxResponseSize && totalBytes > maxResponseSize) {
375
418
  res.destroy();
419
+ request.destroy();
376
420
  const sizeMB = (totalBytes / (1024 * 1024)).toFixed(2);
377
421
  const maxMB = (maxResponseSize / (1024 * 1024)).toFixed(2);
378
- const err = new Error(
379
- `Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`
422
+ rejectOnce(
423
+ new Error(
424
+ `Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`
425
+ )
380
426
  );
381
- emitResponse({ error: err });
382
- reject(err);
383
427
  return;
384
428
  }
385
429
  chunks.push(chunk);
386
430
  });
387
431
  res.on("end", () => {
432
+ if (settled) {
433
+ return;
434
+ }
388
435
  const responseBody = Buffer.concat(chunks);
389
436
  const ok = res.statusCode !== void 0 && res.statusCode >= 200 && res.statusCode < 300;
390
437
  const response = {
@@ -412,11 +459,10 @@ async function httpRequestAttempt(url, options) {
412
459
  status: res.statusCode,
413
460
  statusText: res.statusMessage
414
461
  });
415
- resolve(response);
462
+ resolveOnce(response);
416
463
  });
417
464
  res.on("error", (error) => {
418
- emitResponse({ error });
419
- reject(error);
465
+ rejectOnce(error);
420
466
  });
421
467
  }
422
468
  );
@@ -426,24 +472,33 @@ async function httpRequestAttempt(url, options) {
426
472
  method,
427
473
  error
428
474
  );
429
- const enhanced = new Error(message, { cause: error });
430
- emitResponse({ error: enhanced });
431
- reject(enhanced);
475
+ rejectOnce(new Error(message, { cause: error }));
432
476
  });
433
477
  request.on("timeout", () => {
434
478
  request.destroy();
435
- const err = new Error(
436
- `${method} request timed out after ${timeout}ms: ${url}
479
+ rejectOnce(
480
+ new Error(
481
+ `${method} request timed out after ${timeout}ms: ${url}
437
482
  \u2192 Server did not respond in time.
438
483
  \u2192 Try: Increase timeout or check network connectivity.`
484
+ )
439
485
  );
440
- emitResponse({ error: err });
441
- reject(err);
442
486
  });
443
487
  if (body) {
488
+ if (typeof body === "object" && typeof body.pipe === "function") {
489
+ const stream2 = body;
490
+ stream2.on("error", (err) => {
491
+ request.destroy();
492
+ rejectOnce(err);
493
+ });
494
+ stream2.pipe(request);
495
+ return;
496
+ }
444
497
  request.write(body);
498
+ request.end();
499
+ } else {
500
+ request.end();
445
501
  }
446
- request.end();
447
502
  });
448
503
  }
449
504
  async function httpDownload(url, destPath, options) {
@@ -511,8 +566,8 @@ Computed: ${computedHash}`
511
566
  }
512
567
  await fs.promises.rename(tempPath, destPath);
513
568
  return {
514
- path: destPath,
515
- size: result.size
569
+ ...result,
570
+ path: destPath
516
571
  };
517
572
  } catch (e) {
518
573
  lastError = e;
@@ -571,31 +626,57 @@ async function httpRequest(url, options) {
571
626
  maxRedirects = 5,
572
627
  maxResponseSize,
573
628
  method = "GET",
629
+ onRetry,
574
630
  retries = 0,
575
631
  retryDelay = 1e3,
632
+ stream = false,
633
+ throwOnError = false,
576
634
  timeout = 3e4
577
635
  } = { __proto__: null, ...options };
636
+ const isStreamBody = body !== void 0 && typeof body === "object" && typeof body.pipe === "function";
637
+ if (isStreamBody && retries > 0) {
638
+ throw new Error(
639
+ "Streaming body (Readable/FormData) cannot be used with retries. Streams are consumed on first attempt and cannot be replayed. Set retries: 0 or buffer the body as a string/Buffer."
640
+ );
641
+ }
642
+ const attemptOpts = {
643
+ body,
644
+ ca,
645
+ // Disable redirect following for stream bodies — the stream is consumed
646
+ // on the first request and cannot be re-piped to the redirect target.
647
+ followRedirects: isStreamBody ? false : followRedirects,
648
+ headers,
649
+ hooks,
650
+ maxRedirects,
651
+ maxResponseSize,
652
+ method,
653
+ stream,
654
+ timeout
655
+ };
578
656
  let lastError;
579
657
  for (let attempt = 0; attempt <= retries; attempt++) {
580
658
  try {
581
- return await httpRequestAttempt(url, {
582
- body,
583
- ca,
584
- followRedirects,
585
- headers,
586
- hooks,
587
- maxRedirects,
588
- maxResponseSize,
589
- method,
590
- timeout
591
- });
659
+ const response = await httpRequestAttempt(url, attemptOpts);
660
+ if (throwOnError && !response.ok) {
661
+ throw new HttpResponseError(response);
662
+ }
663
+ return response;
592
664
  } catch (e) {
593
665
  lastError = e;
594
666
  if (attempt === retries) {
595
667
  break;
596
668
  }
597
669
  const delayMs = retryDelay * 2 ** attempt;
598
- await new Promise((resolve) => setTimeout(resolve, delayMs));
670
+ if (onRetry) {
671
+ const retryResult = onRetry(attempt + 1, e, delayMs);
672
+ if (retryResult === false) {
673
+ break;
674
+ }
675
+ const actualDelay = typeof retryResult === "number" && !Number.isNaN(retryResult) ? Math.max(0, retryResult) : delayMs;
676
+ await new Promise((resolve) => setTimeout(resolve, actualDelay));
677
+ } else {
678
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
679
+ }
599
680
  }
600
681
  }
601
682
  throw lastError || new Error("Request failed after retries");
@@ -631,6 +712,7 @@ async function httpText(url, options) {
631
712
  }
632
713
  // Annotate the CommonJS export names for ESM import in node:
633
714
  0 && (module.exports = {
715
+ HttpResponseError,
634
716
  enrichErrorMessage,
635
717
  fetchChecksums,
636
718
  httpDownload,
@@ -638,5 +720,7 @@ async function httpText(url, options) {
638
720
  httpRequest,
639
721
  httpText,
640
722
  parseChecksums,
641
- readIncomingResponse
723
+ parseRetryAfterHeader,
724
+ readIncomingResponse,
725
+ sanitizeHeaders
642
726
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socketsecurity/lib",
3
- "version": "5.13.0",
3
+ "version": "5.15.0",
4
4
  "packageManager": "pnpm@10.33.0",
5
5
  "license": "MIT",
6
6
  "description": "Core utilities and infrastructure for Socket.dev security tools",
@@ -717,7 +717,7 @@
717
717
  "update": "node scripts/update.mjs"
718
718
  },
719
719
  "devDependencies": {
720
- "@anthropic-ai/claude-code": "2.1.89",
720
+ "@anthropic-ai/claude-code": "2.1.92",
721
721
  "@babel/core": "7.28.4",
722
722
  "@babel/parser": "7.28.4",
723
723
  "@babel/traverse": "7.28.4",
@@ -735,7 +735,7 @@
735
735
  "@socketregistry/is-unicode-supported": "1.0.5",
736
736
  "@socketregistry/packageurl-js": "1.4.1",
737
737
  "@socketregistry/yocto-spinner": "1.0.25",
738
- "@socketsecurity/lib-stable": "npm:@socketsecurity/lib@5.12.0",
738
+ "@socketsecurity/lib-stable": "npm:@socketsecurity/lib@5.14.0",
739
739
  "@types/node": "24.9.2",
740
740
  "@typescript/native-preview": "7.0.0-dev.20250920.1",
741
741
  "@vitest/coverage-v8": "4.0.3",