@socketsecurity/lib 5.13.0 → 5.14.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,24 @@ 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.14.0](https://github.com/SocketDev/socket-lib/releases/tag/v5.14.0) - 2026-04-06
9
+
10
+ ### Added — http-request
11
+
12
+ - `HttpResponseError` class — thrown on non-2xx when `throwOnError` is enabled, carries the full `HttpResponse`
13
+ - `throwOnError` option on `HttpRequestOptions` — non-2xx responses throw instead of resolving with `ok: false`, enabling retry of HTTP errors
14
+ - `onRetry` callback on `HttpRequestOptions` — customize retry behavior per-attempt (return `false` to stop, a `number` to override delay, `undefined` for default backoff)
15
+ - Streaming body support — `body` accepts `Readable` streams (incl. `form-data` npm package), auto-merges `getHeaders()` when present
16
+ - `parseRetryAfterHeader()` — standalone RFC 7231 §7.1.3 `Retry-After` header parser (strict integer seconds + HTTP-date formats)
17
+ - `sanitizeHeaders()` — redact sensitive headers (`authorization`, `cookie`, `set-cookie`, `proxy-authorization`, `proxy-authenticate`, `www-authenticate`) for safe logging
18
+
19
+ ### Changed — http-request
20
+
21
+ - `HttpRequestOptions.body` type widened from `Buffer | string` to `Buffer | Readable | string`
22
+ - Redirect responses now drained via `res.resume()` to free sockets
23
+ - `maxResponseSize` exceeded now cleans up both response and request
24
+ - `onResponse` hooks wrapped in try/catch — user hook errors can no longer leave promises pending
25
+
8
26
  ## [5.13.0](https://github.com/SocketDev/socket-lib/releases/tag/v5.13.0) - 2026-04-05
9
27
 
10
28
  ### 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,23 @@ 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
+ throwOnError?: boolean | undefined;
197
281
  /**
198
282
  * Request timeout in milliseconds.
199
283
  * If the request takes longer than this, it will be aborted.
@@ -333,6 +417,56 @@ export interface HttpResponse {
333
417
  * into the standard HttpResponse interface.
334
418
  */
335
419
  export declare function readIncomingResponse(msg: IncomingResponse): Promise<HttpResponse>;
420
+ /**
421
+ * Error thrown when an HTTP response has a non-2xx status code
422
+ * and `throwOnError` is enabled. Carries the full `HttpResponse`
423
+ * so callers can inspect status, headers, and body.
424
+ */
425
+ export declare class HttpResponseError extends Error {
426
+ response: HttpResponse;
427
+ constructor(response: HttpResponse, message?: string | undefined);
428
+ }
429
+ /**
430
+ * Parse a `Retry-After` HTTP header value into milliseconds.
431
+ *
432
+ * Supports both formats defined in RFC 7231 §7.1.3:
433
+ * - **delay-seconds**: integer number of seconds (e.g., `"120"`)
434
+ * - **HTTP-date**: an absolute date/time (e.g., `"Fri, 31 Dec 2027 23:59:59 GMT"`)
435
+ *
436
+ * When the header is an array (multiple values), the first element is used.
437
+ *
438
+ * @param value - The raw Retry-After header value(s)
439
+ * @returns Delay in milliseconds, or `undefined` if the value cannot be parsed
440
+ *
441
+ * @example
442
+ * ```ts
443
+ * const delay = parseRetryAfterHeader(response.headers['retry-after'])
444
+ * if (delay !== undefined) {
445
+ * await new Promise(resolve => setTimeout(resolve, delay))
446
+ * }
447
+ * ```
448
+ */
449
+ export declare function parseRetryAfterHeader(value: string | string[] | undefined): number | undefined;
450
+ /**
451
+ * Redact sensitive HTTP headers for safe logging and telemetry.
452
+ *
453
+ * Replaces values of sensitive headers (Authorization, Cookie, etc.)
454
+ * with `[REDACTED]`. Non-sensitive headers are passed through unchanged.
455
+ * Array values are joined with `', '`.
456
+ *
457
+ * @param headers - HTTP headers to sanitize
458
+ * @returns A new object with sensitive values redacted
459
+ *
460
+ * @example
461
+ * ```ts
462
+ * const safe = sanitizeHeaders({
463
+ * 'authorization': 'Bearer secret',
464
+ * 'content-type': 'application/json'
465
+ * })
466
+ * // { authorization: '[REDACTED]', 'content-type': 'application/json' }
467
+ * ```
468
+ */
469
+ export declare function sanitizeHeaders(headers: Record<string, unknown> | undefined): Record<string, string>;
336
470
  /**
337
471
  * Configuration options for file downloads.
338
472
  */
@@ -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")) {
@@ -297,12 +358,34 @@ async function httpRequestAttempt(url, options) {
297
358
  timeout = 3e4
298
359
  } = { __proto__: null, ...options };
299
360
  const startTime = Date.now();
361
+ const streamHeaders = body && typeof body === "object" && "getHeaders" in body && typeof body.getHeaders === "function" ? body.getHeaders() : void 0;
300
362
  const mergedHeaders = {
301
363
  "User-Agent": "socket-registry/1.0",
364
+ ...streamHeaders,
302
365
  ...headers
303
366
  };
304
367
  hooks?.onRequest?.({ method, url, headers: mergedHeaders, timeout });
305
368
  return await new Promise((resolve, reject) => {
369
+ let settled = false;
370
+ const resolveOnce = (response) => {
371
+ if (settled) {
372
+ return;
373
+ }
374
+ settled = true;
375
+ resolve(response);
376
+ };
377
+ const rejectOnce = (err) => {
378
+ if (settled) {
379
+ return;
380
+ }
381
+ settled = true;
382
+ if (body && typeof body === "object" && typeof body.destroy === "function") {
383
+ ;
384
+ body.destroy();
385
+ }
386
+ emitResponse({ error: err });
387
+ reject(err);
388
+ };
306
389
  const parsedUrl = new URL(url);
307
390
  const isHttps = parsedUrl.protocol === "https:";
308
391
  const httpModule = isHttps ? /* @__PURE__ */ getHttps() : /* @__PURE__ */ getHttp();
@@ -318,23 +401,28 @@ async function httpRequestAttempt(url, options) {
318
401
  requestOptions["ca"] = ca;
319
402
  }
320
403
  const emitResponse = (info) => {
321
- hooks?.onResponse?.({
322
- duration: Date.now() - startTime,
323
- method,
324
- url,
325
- ...info
326
- });
404
+ try {
405
+ hooks?.onResponse?.({
406
+ duration: Date.now() - startTime,
407
+ method,
408
+ url,
409
+ ...info
410
+ });
411
+ } catch {
412
+ }
327
413
  };
328
414
  const request = httpModule.request(
329
415
  requestOptions,
330
416
  (res) => {
331
417
  if (followRedirects && res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
418
+ res.resume();
332
419
  emitResponse({
333
420
  headers: res.headers,
334
421
  status: res.statusCode,
335
422
  statusText: res.statusMessage
336
423
  });
337
424
  if (maxRedirects <= 0) {
425
+ settled = true;
338
426
  reject(
339
427
  new Error(
340
428
  `Too many redirects (exceeded maximum: ${maxRedirects})`
@@ -345,6 +433,7 @@ async function httpRequestAttempt(url, options) {
345
433
  const redirectUrl = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, url).toString();
346
434
  const redirectParsed = new URL(redirectUrl);
347
435
  if (isHttps && redirectParsed.protocol !== "https:") {
436
+ settled = true;
348
437
  reject(
349
438
  new Error(
350
439
  `Redirect from HTTPS to HTTP is not allowed: ${redirectUrl}`
@@ -352,6 +441,7 @@ async function httpRequestAttempt(url, options) {
352
441
  );
353
442
  return;
354
443
  }
444
+ settled = true;
355
445
  resolve(
356
446
  httpRequestAttempt(redirectUrl, {
357
447
  body,
@@ -373,18 +463,22 @@ async function httpRequestAttempt(url, options) {
373
463
  totalBytes += chunk.length;
374
464
  if (maxResponseSize && totalBytes > maxResponseSize) {
375
465
  res.destroy();
466
+ request.destroy();
376
467
  const sizeMB = (totalBytes / (1024 * 1024)).toFixed(2);
377
468
  const maxMB = (maxResponseSize / (1024 * 1024)).toFixed(2);
378
- const err = new Error(
379
- `Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`
469
+ rejectOnce(
470
+ new Error(
471
+ `Response exceeds maximum size limit (${sizeMB}MB > ${maxMB}MB)`
472
+ )
380
473
  );
381
- emitResponse({ error: err });
382
- reject(err);
383
474
  return;
384
475
  }
385
476
  chunks.push(chunk);
386
477
  });
387
478
  res.on("end", () => {
479
+ if (settled) {
480
+ return;
481
+ }
388
482
  const responseBody = Buffer.concat(chunks);
389
483
  const ok = res.statusCode !== void 0 && res.statusCode >= 200 && res.statusCode < 300;
390
484
  const response = {
@@ -412,11 +506,10 @@ async function httpRequestAttempt(url, options) {
412
506
  status: res.statusCode,
413
507
  statusText: res.statusMessage
414
508
  });
415
- resolve(response);
509
+ resolveOnce(response);
416
510
  });
417
511
  res.on("error", (error) => {
418
- emitResponse({ error });
419
- reject(error);
512
+ rejectOnce(error);
420
513
  });
421
514
  }
422
515
  );
@@ -426,24 +519,33 @@ async function httpRequestAttempt(url, options) {
426
519
  method,
427
520
  error
428
521
  );
429
- const enhanced = new Error(message, { cause: error });
430
- emitResponse({ error: enhanced });
431
- reject(enhanced);
522
+ rejectOnce(new Error(message, { cause: error }));
432
523
  });
433
524
  request.on("timeout", () => {
434
525
  request.destroy();
435
- const err = new Error(
436
- `${method} request timed out after ${timeout}ms: ${url}
526
+ rejectOnce(
527
+ new Error(
528
+ `${method} request timed out after ${timeout}ms: ${url}
437
529
  \u2192 Server did not respond in time.
438
530
  \u2192 Try: Increase timeout or check network connectivity.`
531
+ )
439
532
  );
440
- emitResponse({ error: err });
441
- reject(err);
442
533
  });
443
534
  if (body) {
535
+ if (typeof body === "object" && typeof body.pipe === "function") {
536
+ const stream = body;
537
+ stream.on("error", (err) => {
538
+ request.destroy();
539
+ rejectOnce(err);
540
+ });
541
+ stream.pipe(request);
542
+ return;
543
+ }
444
544
  request.write(body);
545
+ request.end();
546
+ } else {
547
+ request.end();
445
548
  }
446
- request.end();
447
549
  });
448
550
  }
449
551
  async function httpDownload(url, destPath, options) {
@@ -571,31 +673,55 @@ async function httpRequest(url, options) {
571
673
  maxRedirects = 5,
572
674
  maxResponseSize,
573
675
  method = "GET",
676
+ onRetry,
574
677
  retries = 0,
575
678
  retryDelay = 1e3,
679
+ throwOnError = false,
576
680
  timeout = 3e4
577
681
  } = { __proto__: null, ...options };
682
+ const isStreamBody = body !== void 0 && typeof body === "object" && typeof body.pipe === "function";
683
+ if (isStreamBody && retries > 0) {
684
+ throw new Error(
685
+ "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."
686
+ );
687
+ }
688
+ const attemptOpts = {
689
+ body,
690
+ ca,
691
+ // Disable redirect following for stream bodies — the stream is consumed
692
+ // on the first request and cannot be re-piped to the redirect target.
693
+ followRedirects: isStreamBody ? false : followRedirects,
694
+ headers,
695
+ hooks,
696
+ maxRedirects,
697
+ maxResponseSize,
698
+ method,
699
+ timeout
700
+ };
578
701
  let lastError;
579
702
  for (let attempt = 0; attempt <= retries; attempt++) {
580
703
  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
- });
704
+ const response = await httpRequestAttempt(url, attemptOpts);
705
+ if (throwOnError && !response.ok) {
706
+ throw new HttpResponseError(response);
707
+ }
708
+ return response;
592
709
  } catch (e) {
593
710
  lastError = e;
594
711
  if (attempt === retries) {
595
712
  break;
596
713
  }
597
714
  const delayMs = retryDelay * 2 ** attempt;
598
- await new Promise((resolve) => setTimeout(resolve, delayMs));
715
+ if (onRetry) {
716
+ const retryResult = onRetry(attempt + 1, e, delayMs);
717
+ if (retryResult === false) {
718
+ break;
719
+ }
720
+ const actualDelay = typeof retryResult === "number" && !Number.isNaN(retryResult) ? Math.max(0, retryResult) : delayMs;
721
+ await new Promise((resolve) => setTimeout(resolve, actualDelay));
722
+ } else {
723
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
724
+ }
599
725
  }
600
726
  }
601
727
  throw lastError || new Error("Request failed after retries");
@@ -631,6 +757,7 @@ async function httpText(url, options) {
631
757
  }
632
758
  // Annotate the CommonJS export names for ESM import in node:
633
759
  0 && (module.exports = {
760
+ HttpResponseError,
634
761
  enrichErrorMessage,
635
762
  fetchChecksums,
636
763
  httpDownload,
@@ -638,5 +765,7 @@ async function httpText(url, options) {
638
765
  httpRequest,
639
766
  httpText,
640
767
  parseChecksums,
641
- readIncomingResponse
768
+ parseRetryAfterHeader,
769
+ readIncomingResponse,
770
+ sanitizeHeaders
642
771
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@socketsecurity/lib",
3
- "version": "5.13.0",
3
+ "version": "5.14.0",
4
4
  "packageManager": "pnpm@10.33.0",
5
5
  "license": "MIT",
6
6
  "description": "Core utilities and infrastructure for Socket.dev security tools",
@@ -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.13.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",